Commit cff4d2fb authored by Lê Bảo Hồng Đức's avatar Lê Bảo Hồng Đức

fix

parent 4489f507
......@@ -15,15 +15,14 @@ import {
HEADER_CONFIG_STORAGE_KEY,
HeaderCategoryItem,
normalizeHeaderCategories,
toSlug,
} from '@/mockdata/header-config';
import {
createHeaderCategoryPostId,
EMPTY_HEADER_CATEGORY_POST_FORM,
getHeaderCategoryPostSeed,
HEADER_CATEGORY_POSTS_STORAGE_KEY,
HeaderCategoryPostFormValues,
HeaderCategoryPostItem,
makeHeaderCategoryPostSlug,
normalizeHeaderCategoryPosts,
} from '@/mockdata/header-category-posts';
import { ArrowLeft, Save } from 'lucide-react';
......@@ -97,14 +96,6 @@ function persistHeaderCategoryPosts(items: HeaderCategoryPostItem[]) {
);
}
function upsertPost(items: HeaderCategoryPostItem[], post: HeaderCategoryPostItem) {
const exists = items.some((item) => item.id === post.id);
return normalizeHeaderCategoryPosts(
exists ? items.map((item) => (item.id === post.id ? post : item)) : [...items, post],
);
}
function useHeaderCategoryPostsModule() {
const [items, setItems] = React.useState<HeaderCategoryPostItem[]>([]);
const [isReady, setIsReady] = React.useState(false);
......@@ -134,10 +125,10 @@ function useHeaderCategoryPostsModule() {
const now = new Date().toISOString();
const nextPost: HeaderCategoryPostItem = {
id: createHeaderCategoryPostId(),
id: `header-post-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
category_id: categoryId,
title: values.title.trim(),
slug: values.slug.trim() || makeHeaderCategoryPostSlug(values.title),
slug: values.slug.trim() || toSlug(values.title),
excerpt: values.excerpt.trim(),
content: values.content.trim(),
thumbnail: values.thumbnail.trim(),
......@@ -147,35 +138,32 @@ function useHeaderCategoryPostsModule() {
updated_at: now,
};
setItems((current) => upsertPost(current, nextPost));
setItems((current) => normalizeHeaderCategoryPosts([...current, nextPost]));
return nextPost;
},
[],
);
const updatePost = React.useCallback((postId: string, values: HeaderCategoryPostFormValues) => {
let updatedPost: HeaderCategoryPostItem | null = null;
setItems((current) => {
const existing = current.find((item) => item.id === postId);
if (!existing) return current;
updatedPost = {
...existing,
title: values.title.trim(),
slug: values.slug.trim() || makeHeaderCategoryPostSlug(values.title),
excerpt: values.excerpt.trim(),
content: values.content.trim(),
thumbnail: values.thumbnail.trim(),
published_at: values.published_at || existing.published_at,
is_active: values.is_active,
updated_at: new Date().toISOString(),
};
return upsertPost(current, updatedPost);
});
return updatedPost;
setItems((current) =>
normalizeHeaderCategoryPosts(
current.map((item) =>
item.id === postId
? {
...item,
title: values.title.trim(),
slug: values.slug.trim() || toSlug(values.title),
excerpt: values.excerpt.trim(),
content: values.content.trim(),
thumbnail: values.thumbnail.trim(),
published_at: values.published_at || item.published_at,
is_active: values.is_active,
updated_at: new Date().toISOString(),
}
: item,
),
),
);
}, []);
const toFormValues = React.useCallback(
......@@ -313,17 +301,7 @@ export default function HeaderCategoryPostFormPage() {
setForm((previous) => ({
...previous,
title: value,
slug:
value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/đ/g, 'd')
.replace(/Đ/g, 'D')
.toLowerCase()
.trim()
.replace(/[^a-z0-9\\s-]/g, '')
.replace(/\\s+/g, '-')
.replace(/-+/g, '-') || previous.slug,
slug: toSlug(value) || previous.slug,
}));
};
......
......@@ -35,12 +35,9 @@ import {
normalizeHeaderCategories,
} from '@/mockdata/header-config';
import {
createHeaderCategoryPostId,
getHeaderCategoryPostSeed,
HEADER_CATEGORY_POSTS_STORAGE_KEY,
HeaderCategoryPostFormValues,
HeaderCategoryPostItem,
makeHeaderCategoryPostSlug,
normalizeHeaderCategoryPosts,
} from '@/mockdata/header-category-posts';
import {
......@@ -120,14 +117,6 @@ function persistHeaderCategoryPosts(items: HeaderCategoryPostItem[]) {
);
}
function upsertPost(items: HeaderCategoryPostItem[], post: HeaderCategoryPostItem) {
const exists = items.some((item) => item.id === post.id);
return normalizeHeaderCategoryPosts(
exists ? items.map((item) => (item.id === post.id ? post : item)) : [...items, post],
);
}
function useHeaderCategoryPostsModule() {
const [items, setItems] = React.useState<HeaderCategoryPostItem[]>([]);
const [isReady, setIsReady] = React.useState(false);
......@@ -147,60 +136,6 @@ function useHeaderCategoryPostsModule() {
[items],
);
const getPostById = React.useCallback(
(postId: string) => items.find((item) => item.id === postId) ?? null,
[items],
);
const createPost = React.useCallback(
(categoryId: string, values: HeaderCategoryPostFormValues) => {
const now = new Date().toISOString();
const nextPost: HeaderCategoryPostItem = {
id: createHeaderCategoryPostId(),
category_id: categoryId,
title: values.title.trim(),
slug: values.slug.trim() || makeHeaderCategoryPostSlug(values.title),
excerpt: values.excerpt.trim(),
content: values.content.trim(),
thumbnail: values.thumbnail.trim(),
published_at: values.published_at || now.slice(0, 10),
is_active: values.is_active,
created_at: now,
updated_at: now,
};
setItems((current) => upsertPost(current, nextPost));
return nextPost;
},
[],
);
const updatePost = React.useCallback((postId: string, values: HeaderCategoryPostFormValues) => {
let updatedPost: HeaderCategoryPostItem | null = null;
setItems((current) => {
const existing = current.find((item) => item.id === postId);
if (!existing) return current;
updatedPost = {
...existing,
title: values.title.trim(),
slug: values.slug.trim() || makeHeaderCategoryPostSlug(values.title),
excerpt: values.excerpt.trim(),
content: values.content.trim(),
thumbnail: values.thumbnail.trim(),
published_at: values.published_at || existing.published_at,
is_active: values.is_active,
updated_at: new Date().toISOString(),
};
return upsertPost(current, updatedPost);
});
return updatedPost;
}, []);
const removePost = React.useCallback((postId: string) => {
setItems((current) => current.filter((item) => item.id !== postId));
}, []);
......@@ -208,9 +143,6 @@ function useHeaderCategoryPostsModule() {
return {
isReady,
getPostsByCategory,
getPostById,
createPost,
updatePost,
removePost,
};
}
......@@ -417,13 +349,13 @@ export default function HeaderCategoryPostsPage() {
</div>
<div className="min-w-0 space-y-1">
<p className="truncate font-medium text-black">{post.title}</p>
<p className="line-clamp-2 text-sm text-gray-700">{post.excerpt || ''}</p>
<p className="line-clamp-2 text-sm text-gray-700">{post.excerpt || '-'}</p>
</div>
</div>
</TableCell>
<TableCell className="text-center text-sm text-gray-700">{post.slug}</TableCell>
<TableCell className="text-center text-sm text-gray-700">
{post.published_at ? dayjs(post.published_at).format('DD/MM/YYYY') : ''}
{post.published_at ? dayjs(post.published_at).format('DD/MM/YYYY') : '-'}
</TableCell>
<TableCell className="text-center">
{post.is_active ? (
......
......@@ -8,22 +8,31 @@ interface HeaderCategoryStatsProps {
total: number;
root: number;
nested: number;
grouped: number;
}
export function HeaderCategoryStats({
total,
root,
nested,
grouped,
}: HeaderCategoryStatsProps) {
return (
<AdminStatsGrid
items={[
{ label: "Tổng danh mục", value: total, icon: <FolderTree className="h-4 w-4 text-[#063e8e]" /> },
{ label: "Danh mục cha", value: root, icon: <FolderTree className="h-4 w-4 text-[#063e8e]" /> },
{ label: "Danh mục con", value: nested, icon: <FolderTree className="h-4 w-4 text-[#063e8e]" /> },
{ label: "Có danh mục con", value: grouped, icon: <FolderTree className="h-4 w-4 text-[#063e8e]" /> },
{
label: "Tổng danh mục",
value: total,
icon: <FolderTree className="h-4 w-4 text-[#063e8e]" />,
},
{
label: "Danh mục cha",
value: root,
icon: <FolderTree className="h-4 w-4 text-[#063e8e]" />,
},
{
label: "Danh mục con",
value: nested,
icon: <FolderTree className="h-4 w-4 text-[#063e8e]" />,
},
]}
/>
);
......
......@@ -14,6 +14,7 @@ import {
Plus,
Trash,
} from "lucide-react";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
......@@ -23,7 +24,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
......@@ -45,14 +45,27 @@ interface HeaderCategoryTableProps {
expanded: Record<string, boolean>;
isLoading: boolean;
searchValue: string;
action?: React.ReactNode;
onSearchChange: (value: string) => void;
onToggle: (id: string) => void;
onCreateRoot: () => void;
onCreateChild: (item: HeaderCategoryTreeItem) => void;
onEdit: (item: HeaderCategoryTreeItem) => void;
onDelete: (item: HeaderCategoryTreeItem) => void;
}
function getDisplaySortOrder(item: HeaderCategoryFlatRow, rows: HeaderCategoryFlatRow[]) {
if (!item.parentId) {
return String(item.sort_order);
}
const parent = rows.find((entry) => entry.id === item.parentId);
if (!parent) {
return String(item.sort_order);
}
return `${parent.sort_order}-${item.sort_order}`;
}
function getTypeIcon(type: HeaderCategoryTreeItem["type"]) {
switch (type) {
case "news":
......@@ -80,10 +93,10 @@ function HeaderCategoryTableLoading() {
<Skeleton className="mx-auto h-7 w-28 rounded-full bg-[#063e8e]/15" />
</TableCell>
<TableCell className="w-[140px] text-center">
<Skeleton className="mx-auto h-8 w-10 rounded-full bg-[#063e8e]/15" />
<Skeleton className="mx-auto h-8 w-12 rounded-full bg-[#063e8e]/15" />
</TableCell>
<TableCell className="w-[280px] py-4">
<Skeleton className="h-4 w-52 bg-[#063e8e]/15" />
<Skeleton className="mx-auto h-4 w-52 bg-[#063e8e]/15" />
</TableCell>
<TableCell className="w-[120px] text-center">
<Skeleton className="mx-auto h-8 w-8 rounded-md bg-[#063e8e]/15" />
......@@ -97,161 +110,171 @@ export function HeaderCategoryTable({
expanded,
isLoading,
searchValue,
action,
onSearchChange,
onToggle,
onCreateRoot,
onCreateChild,
onEdit,
onDelete,
}: HeaderCategoryTableProps) {
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
value={searchValue}
placeholder="Tìm kiếm danh mục..."
onChange={(event) => onSearchChange(event.target.value)}
className="max-w-sm border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700"
/>
{action}
</div>
<div className="overflow-hidden rounded-xl border border-[#063e8e]/15 bg-white shadow-sm">
<Table className="table-fixed">
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-[34%] py-4 pl-4 text-center text-white">Tên danh mục</TableHead>
<TableHead className="w-[180px] py-4 text-center text-white">Thể loại</TableHead>
<TableHead className="w-[140px] py-4 text-center text-white">Thứ tự</TableHead>
<TableHead className="w-[280px] py-4 text-center text-white">Liên kết</TableHead>
<TableHead className="w-[120px] py-4 text-center text-white">Thao tác</TableHead>
<AdminTableLayout
searchValue={searchValue}
searchPlaceholder="Tìm kiếm danh mục..."
actionLabel="Thêm danh mục"
actionIcon={<Plus className="mr-2 h-4 w-4" />}
onSearchChange={onSearchChange}
onActionClick={onCreateRoot}
>
<Table className="table-fixed">
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-[34%] py-4 pl-4 text-center text-white">
Tên danh mục
</TableHead>
<TableHead className="w-[180px] py-4 text-center text-white">
Thể loại
</TableHead>
<TableHead className="w-[140px] py-4 text-center text-white">
Thứ tự
</TableHead>
<TableHead className="w-[280px] py-4 text-center text-white">
Liên kết
</TableHead>
<TableHead className="w-[120px] py-4 text-center text-white">
Thao tác
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<HeaderCategoryTableLoading />
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center text-sm text-gray-700">
Không có danh mục nào phù hợp.
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<HeaderCategoryTableLoading />
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center text-sm text-gray-700">
Không có danh mục nào phù hợp.
</TableCell>
</TableRow>
) : (
rows.map((item, index) => {
const hasChildren = rows.some((entry) => entry.parentId === item.id);
const isExpanded = expanded[item.id] ?? true;
const canCreateChild = !item.parent_id && item.type === "category";
const canManagePosts =
item.type === "page" || item.type === "news" || item.type === "image";
const createContentLabel = item.type === "image" ? "Thêm ảnh" : "Thêm bài viết";
) : (
rows.map((item, index) => {
const hasChildren = rows.some((entry) => entry.parentId === item.id);
const isExpanded = expanded[item.id] ?? true;
const canCreateChild = !item.parent_id && item.type === "category";
const canManagePosts =
item.type === "page" || item.type === "news" || item.type === "image";
const createContentLabel = item.type === "image" ? "Thêm ảnh" : "Thêm bài viết";
return (
<TableRow
key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/[0.03]"}
>
<TableCell className="w-[34%] py-4">
<div className="flex items-center" style={{ marginLeft: item.depth * 24 }}>
{hasChildren ? (
<button
type="button"
className="mr-2 rounded p-1 hover:bg-[#063e8e]/10"
onClick={() => onToggle(item.id)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
) : (
<span className="mr-2 w-6" />
)}
return (
<TableRow
key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/[0.03]"}
>
<TableCell className="w-[34%] py-4">
<div className="flex items-center" style={{ marginLeft: item.depth * 24 }}>
{hasChildren ? (
<button
type="button"
className="mr-2 rounded p-1 hover:bg-[#063e8e]/10"
onClick={() => onToggle(item.id)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
) : (
<span className="mr-2 w-6" />
)}
<div className="mr-2">{getTypeIcon(item.type)}</div>
<div className="truncate font-medium text-black">{item.name}</div>
</div>
</TableCell>
<TableCell className="w-[180px] text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{getHeaderCategoryTypeLabel(item.type)}
</Badge>
</TableCell>
<TableCell className="w-[140px] text-center font-medium text-black">
<span
className={
item.parent_id
? "inline-flex min-w-8 items-center justify-center rounded-full border border-gray-300 px-2.5 py-1 text-sm text-gray-700"
: "inline-flex min-w-8 items-center justify-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-sm text-[#063e8e]"
}
>
{item.sort_order}
<div className="mr-2">{getTypeIcon(item.type)}</div>
<div className="truncate font-medium text-black">{item.name}</div>
</div>
</TableCell>
<TableCell className="w-[180px] text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{getHeaderCategoryTypeLabel(item.type)}
</Badge>
</TableCell>
<TableCell className="w-[140px] text-center font-medium text-black">
<span
className={
item.parent_id
? "inline-flex min-w-8 items-center justify-center rounded-full border border-gray-300 px-2.5 py-1 text-sm text-gray-700"
: "inline-flex min-w-8 items-center justify-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-sm text-[#063e8e]"
}
>
{getDisplaySortOrder(item, rows)}
</span>
</TableCell>
<TableCell className="w-[280px] text-sm text-gray-700">
<div className="mx-auto flex max-w-[220px] items-center justify-center gap-2">
<span className="block max-w-[180px] truncate">
{item.static_link || "-"}
</span>
</TableCell>
<TableCell className="w-[280px] text-sm text-gray-700">
<div className="mx-auto flex max-w-[220px] items-center justify-center gap-2">
<span className="block max-w-[180px] truncate">{item.static_link || "—"}</span>
{item.static_link ? (
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-[#063e8e]" />
) : null}
</div>
</TableCell>
<TableCell className="w-[120px] text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{item.static_link ? (
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-[#063e8e]" />
) : null}
</div>
</TableCell>
<TableCell className="w-[120px] text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => onEdit(item)}
>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</DropdownMenuItem>
{canManagePosts ? (
<DropdownMenuItem
asChild
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => onEdit(item)}
>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</DropdownMenuItem>
{canManagePosts ? (
<DropdownMenuItem asChild className="text-gray-700 focus:text-[#063e8e]">
<Link href={`/admin/header-config/${item.id}/posts/new`}>
<Plus className="mr-2 h-4 w-4" />
{createContentLabel}
</Link>
</DropdownMenuItem>
) : null}
{canCreateChild ? (
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => onCreateChild(item)}
>
<Link href={`/admin/header-config/${item.id}/posts/new`}>
<Plus className="mr-2 h-4 w-4" />
Thêm danh mục con
</DropdownMenuItem>
) : null}
{createContentLabel}
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
{canCreateChild ? (
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => onDelete(item)}
onClick={() => onCreateChild(item)}
>
<Trash className="mr-2 h-4 w-4" />
Xóa
<Plus className="mr-2 h-4 w-4" />
Thêm danh mục con
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => onDelete(item)}
>
<Trash className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</AdminTableLayout>
);
}
'use client';
import React from 'react';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import {
HeaderCategoryDeleteDialog,
......@@ -12,7 +11,6 @@ import {
HeaderCategoryStats,
HeaderCategoryTable,
} from './components';
import { Button } from '@/components/ui/button';
import {
buildHeaderCategoryTree,
createHeaderCategoryId,
......@@ -252,11 +250,6 @@ export default function HeaderConfigPage() {
[tree],
);
const groupedCount = React.useMemo(
() => flatRows.filter((item) => item.children.length > 0).length,
[flatRows],
);
const editingItem = React.useMemo(
() => flatRows.find((item) => item.id === formValues.id) ?? null,
[flatRows, formValues.id],
......@@ -347,7 +340,6 @@ export default function HeaderConfigPage() {
total={flatRows.length}
root={flatRows.filter((item) => !item.parentId).length}
nested={flatRows.filter((item) => item.parentId).length}
grouped={groupedCount}
/>
<HeaderCategoryTable
......@@ -355,19 +347,11 @@ export default function HeaderConfigPage() {
expanded={expanded}
isLoading={!isReady}
searchValue={search}
action={
<Button
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={openCreateRoot}
>
<Plus className="mr-2 h-4 w-4" />
Thêm danh mục
</Button>
}
onSearchChange={setSearch}
onToggle={(id) =>
setExpanded((previous) => ({ ...previous, [id]: !(previous[id] ?? true) }))
}
onCreateRoot={openCreateRoot}
onCreateChild={openCreateChild}
onEdit={openEdit}
onDelete={setDeleteTarget}
......
"use client";
import * as React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface AdminStatsGridItem {
label: string;
......@@ -15,20 +14,31 @@ interface AdminStatsGridProps {
}
export function AdminStatsGrid({ items, className }: AdminStatsGridProps) {
const gridClassName =
className ??
(items.length === 3
? "grid grid-cols-1 gap-4 md:grid-cols-3"
: "grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4");
return (
<div className={className ?? "grid grid-cols-2 gap-4 lg:grid-cols-4"}>
<div className={gridClassName}>
{items.map((item) => (
<Card key={item.label} className="border-slate-200 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-700">
{item.label}
</CardTitle>
{item.icon ?? null}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-black">{item.value}</div>
</CardContent>
</Card>
<div
key={item.label}
className="rounded-2xl border border-[#063e8e]/15 bg-white px-5 py-4 shadow-sm"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">{item.label}</p>
<div className="text-3xl font-semibold leading-none text-black">{item.value}</div>
</div>
{item.icon ? (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#063e8e]/10">
{item.icon}
</div>
) : null}
</div>
</div>
))}
</div>
);
......
"use client";
import * as React from "react";
import { Plus } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface AdminTableLayoutProps {
searchValue: string;
searchPlaceholder?: string;
actionLabel?: string;
actionIcon?: React.ReactNode;
actionDisabled?: boolean;
children: React.ReactNode;
onSearchChange: (value: string) => void;
onActionClick?: () => void;
}
export function AdminTableLayout({
searchValue,
searchPlaceholder = "Tìm kiếm...",
actionLabel,
actionIcon,
actionDisabled = false,
children,
onSearchChange,
onActionClick,
}: AdminTableLayoutProps) {
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
value={searchValue}
placeholder={searchPlaceholder}
onChange={(event) => onSearchChange(event.target.value)}
className="max-w-sm border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700"
/>
{actionLabel ? (
<Button
type="button"
disabled={actionDisabled}
onClick={onActionClick}
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{actionIcon ?? <Plus className="mr-2 h-4 w-4" />}
{actionLabel}
</Button>
) : null}
</div>
<div className="overflow-hidden rounded-xl border border-[#063e8e]/15 bg-white shadow-sm">
{children}
</div>
</div>
);
}
......@@ -19,6 +19,88 @@ export interface HeaderCategoryPostItem {
export const HEADER_CATEGORY_POSTS_STORAGE_KEY =
"vcci-news.header-category-posts.data.v1";
function normalizeVietnameseText(value: string) {
return value
.replace(/Ä‘/g, "đ")
.replace(/Đ/g, "Đ")
.replace(/à/g, "à")
.replace(/á/g, "á")
.replace(/ả/g, "ả")
.replace(/ã/g, "ã")
.replace(/ạ/g, "ạ")
.replace(/ă/g, "ă")
.replace(/â/g, "â")
.replace(/è/g, "è")
.replace(/é/g, "é")
.replace(/ẻ/g, "ẻ")
.replace(/ẽ/g, "ẽ")
.replace(/ẹ/g, "ẹ")
.replace(/ê/g, "ê")
.replace(/ì/g, "ì")
.replace(/í/g, "í")
.replace(/Ä©/g, "ĩ")
.replace(/ò/g, "ò")
.replace(/ó/g, "ó")
.replace(/õ/g, "õ")
.replace(/ô/g, "ô")
.replace(/Æ¡/g, "ơ")
.replace(/ù/g, "ù")
.replace(/ú/g, "ú")
.replace(/Å©/g, "ũ")
.replace(/ư/g, "ư")
.replace(/ề/g, "ề")
.replace(/ế/g, "ế")
.replace(/ể/g, "ể")
.replace(/á»…/g, "ễ")
.replace(/ệ/g, "ệ")
.replace(/ờ/g, "ờ")
.replace(/á»›/g, "ớ")
.replace(/ở/g, "ở")
.replace(/ỡ/g, "ỡ")
.replace(/ợ/g, "ợ")
.replace(/ừ/g, "ừ")
.replace(/ứ/g, "ứ")
.replace(/á»­/g, "ử")
.replace(/ữ/g, "ữ")
.replace(/á»±/g, "ự")
.replace(/ỳ/g, "ỳ")
.replace(/ý/g, "ý")
.replace(/á»·/g, "ỷ")
.replace(/ỹ/g, "ỹ")
.replace(/ỵ/g, "ỵ")
.replace(/ổ/g, "ổ")
.replace(/ố/g, "ố")
.replace(/ồ/g, "ồ")
.replace(/á»—/g, "ỗ")
.replace(/á»™/g, "ộ")
.replace(/ầ/g, "ầ")
.replace(/ấ/g, "ấ")
.replace(/ẩ/g, "ẩ")
.replace(/ẫ/g, "ẫ")
.replace(/ậ/g, "ậ")
.replace(/ằ/g, "ằ")
.replace(/ắ/g, "ắ")
.replace(/ẳ/g, "ẳ")
.replace(/ẵ/g, "ẵ")
.replace(/ặ/g, "ặ")
.replace(/ỏ/g, "ỏ")
.replace(/ọ/g, "ọ")
.replace(/á»§/g, "ủ")
.replace(/ụ/g, "ụ")
.replace(/ỉ/g, "ỉ")
.replace(/ị/g, "ị")
.replace(/ời/g, "ời");
}
function normalizePostItem(item: HeaderCategoryPostItem): HeaderCategoryPostItem {
return {
...item,
title: normalizeVietnameseText(item.title),
excerpt: normalizeVietnameseText(item.excerpt),
content: normalizeVietnameseText(item.content),
};
}
export const headerCategoryPostSeed: HeaderCategoryPostItem[] = [
{
id: "header-post-intro-about",
......@@ -124,12 +206,14 @@ export const EMPTY_HEADER_CATEGORY_POST_FORM: HeaderCategoryPostFormValues = {
};
export function normalizeHeaderCategoryPosts(items: HeaderCategoryPostItem[]) {
return [...items].sort((left, right) => {
const leftTime = new Date(left.published_at || left.updated_at).getTime();
const rightTime = new Date(right.published_at || right.updated_at).getTime();
return [...items]
.map((item) => normalizePostItem(item))
.sort((left, right) => {
const leftTime = new Date(left.published_at || left.updated_at).getTime();
const rightTime = new Date(right.published_at || right.updated_at).getTime();
return rightTime - leftTime || right.updated_at.localeCompare(left.updated_at);
});
return rightTime - leftTime || right.updated_at.localeCompare(left.updated_at);
});
}
export function createHeaderCategoryPostId() {
......
......@@ -157,8 +157,122 @@ export const headerArticleCategoryOptions: HeaderArticleCategoryOption[] = [
{ id: "cat-gallery", name: "Ảnh nổi bật" },
];
export function toSlug(value: string) {
function normalizeVietnameseText(value: string) {
return value
.replace(/Tìm/g, "Tìm")
.replace(/Tên/g, "Tên")
.replace(/Tổng/g, "Tổng")
.replace(/Thể/g, "Thể")
.replace(/Thứ/g, "Thứ")
.replace(/Liên/g, "Liên")
.replace(/Không/g, "Không")
.replace(/Danh mục/g, "Danh mục")
.replace(/danh mục/g, "danh mục")
.replace(/Bài viết/g, "Bài viết")
.replace(/Tin tức/g, "Tin tức")
.replace(/Ảnh/g, "Ảnh")
.replace(/Giá»›i thiệu/g, "Giới thiệu")
.replace(/Về/g, "Về")
.replace(/CÆ¡ cấu tổ chức/g, "Cơ cấu tổ chức")
.replace(/Hoạt động/g, "Hoạt động")
.replace(/Sá»± kiện/g, "Sự kiện")
.replace(/Thư viện ảnh/g, "Thư viện ảnh")
.replace(/nổi bật/g, "nổi bật")
.replace(/Nhóm/g, "Nhóm")
.replace(/ná»™i dung/g, "nội dung")
.replace(/thông tin/g, "thông tin")
.replace(/tổng hợp/g, "tổng hợp")
.replace(/Chính sách/g, "Chính sách")
.replace(/Ä‘/g, "đ")
.replace(/Đ/g, "Đ")
.replace(/à/g, "à")
.replace(/á/g, "á")
.replace(/ả/g, "ả")
.replace(/ã/g, "ã")
.replace(/ạ/g, "ạ")
.replace(/ă/g, "ă")
.replace(/ằ/g, "ằ")
.replace(/ắ/g, "ắ")
.replace(/ẳ/g, "ẳ")
.replace(/ẵ/g, "ẵ")
.replace(/ặ/g, "ặ")
.replace(/â/g, "â")
.replace(/ầ/g, "ầ")
.replace(/ấ/g, "ấ")
.replace(/ẩ/g, "ẩ")
.replace(/ẫ/g, "ẫ")
.replace(/ậ/g, "ậ")
.replace(/è/g, "è")
.replace(/é/g, "é")
.replace(/ẻ/g, "ẻ")
.replace(/ẽ/g, "ẽ")
.replace(/ẹ/g, "ẹ")
.replace(/ê/g, "ê")
.replace(/ề/g, "ề")
.replace(/ế/g, "ế")
.replace(/ể/g, "ể")
.replace(/á»…/g, "ễ")
.replace(/ệ/g, "ệ")
.replace(/ì/g, "ì")
.replace(/í/g, "í")
.replace(/ỉ/g, "ỉ")
.replace(/Ä©/g, "ĩ")
.replace(/ị/g, "ị")
.replace(/ò/g, "ò")
.replace(/ó/g, "ó")
.replace(/ỏ/g, "ỏ")
.replace(/õ/g, "õ")
.replace(/ọ/g, "ọ")
.replace(/ô/g, "ô")
.replace(/ồ/g, "ồ")
.replace(/ố/g, "ố")
.replace(/ổ/g, "ổ")
.replace(/á»—/g, "ỗ")
.replace(/á»™/g, "ộ")
.replace(/Æ¡/g, "ơ")
.replace(/ờ/g, "ờ")
.replace(/á»›/g, "ớ")
.replace(/ở/g, "ở")
.replace(/ỡ/g, "ỡ")
.replace(/ợ/g, "ợ")
.replace(/ù/g, "ù")
.replace(/ú/g, "ú")
.replace(/á»§/g, "ủ")
.replace(/Å©/g, "ũ")
.replace(/ụ/g, "ụ")
.replace(/ư/g, "ư")
.replace(/ừ/g, "ừ")
.replace(/ứ/g, "ứ")
.replace(/á»­/g, "ử")
.replace(/ữ/g, "ữ")
.replace(/á»±/g, "ự")
.replace(/ỳ/g, "ỳ")
.replace(/ý/g, "ý")
.replace(/á»·/g, "ỷ")
.replace(/ỹ/g, "ỹ")
.replace(/ỵ/g, "ỵ");
}
function normalizeHeaderCategoryText<T extends HeaderCategoryItem | HeaderArticleCategoryOption>(
item: T,
): T {
const normalized = {
...item,
name: normalizeVietnameseText(item.name),
} as T;
if ("description" in item && typeof item.description === "string") {
return {
...normalized,
description: normalizeVietnameseText(item.description),
};
}
return normalized;
}
export function toSlug(value: string) {
return normalizeVietnameseText(value)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
......@@ -206,11 +320,14 @@ function assignLevel(item: HeaderCategoryItem, items: HeaderCategoryItem[]) {
}
export function normalizeHeaderCategories(items: HeaderCategoryItem[]) {
const sanitizedItems = items.map((item) => normalizeHeaderCategoryText(item));
const parentIds = new Set(
items.filter((item) => item.parent_id).map((item) => item.parent_id as string),
sanitizedItems
.filter((item) => item.parent_id)
.map((item) => item.parent_id as string),
);
return items.map((item) => {
return sanitizedItems.map((item) => {
const next = { ...item };
if (parentIds.has(next.id)) {
......@@ -218,8 +335,8 @@ export function normalizeHeaderCategories(items: HeaderCategoryItem[]) {
next.category_ids = [];
}
next.level = assignLevel(next, items);
next.static_link = next.slug === "" && !next.parent_id ? "/" : buildStaticLink(next, items);
next.level = assignLevel(next, sanitizedItems);
next.static_link = next.slug === "" && !next.parent_id ? "/" : buildStaticLink(next, sanitizedItems);
next.is_article = next.type === "news";
if (next.type !== "news") {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment