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

fix

parent cff4d2fb
......@@ -116,6 +116,9 @@ importers:
html-react-parser:
specifier: ^5.2.7
version: 5.2.7(@types/react@19.2.2)(react@19.2.0)
jodit-react:
specifier: ^5.3.21
version: 5.3.21(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
lodash-es:
specifier: ^4.17.21
version: 4.17.21
......@@ -595,78 +598,92 @@ packages:
resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.3':
resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.3':
resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.3':
resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.3':
resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.4':
resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.4':
resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.4':
resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.4':
resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.4':
resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.4':
resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.4':
resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.4':
resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==}
......@@ -754,24 +771,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.0.10':
resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.0.10':
resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.0.10':
resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.0.10':
resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==}
......@@ -1585,24 +1606,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.16':
resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.16':
resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.16':
resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.16':
resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==}
......@@ -1795,41 +1820,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
......@@ -2781,6 +2814,15 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jodit-react@5.3.21:
resolution: {integrity: sha512-dSFVKkrDVbhVwKDjuFMJ3HhPdqeEz/Yz5MhJf9v9B3Gg29CnelKCZ00h6MxXzlhglF3qvtvUTc2HSKrSB15khw==}
peerDependencies:
react: ~0.14 || ^15 || ^16 || ^17 || ^18 || ^19
react-dom: ~0.14 || ^15 || ^16 || ^17 || ^18 || ^19
jodit@4.12.2:
resolution: {integrity: sha512-SoZAH2YvL8JxPmL4muQJPbbF27rFKVzFQOiCRabjtSQcLVghm+XpIm5t9dXq+fCA4d1Z2O+8x/sORPVdLI4zbg==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
......@@ -2893,24 +2935,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
......@@ -6772,6 +6818,14 @@ snapshots:
jiti@2.6.1: {}
jodit-react@5.3.21(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
jodit: 4.12.2
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
jodit@4.12.2: {}
js-tokens@4.0.0: {}
js-yaml@4.1.0:
......
'use client';
import React from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { AdminNewsForm } from '@/components/admin/news-form';
import {
buildHeaderCategoryTree,
getHeaderCategorySeed,
HEADER_CONFIG_STORAGE_KEY,
HeaderCategoryItem,
type HeaderCategoryItem,
normalizeHeaderCategories,
toSlug,
} from '@/mockdata/header-config';
import {
EMPTY_HEADER_CATEGORY_POST_FORM,
getHeaderCategoryPostSeed,
HEADER_CATEGORY_POSTS_STORAGE_KEY,
HeaderCategoryPostFormValues,
HeaderCategoryPostItem,
normalizeHeaderCategoryPosts,
} from '@/mockdata/header-category-posts';
import { ArrowLeft, Save } from 'lucide-react';
const fieldClassName =
'border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30';
function getInitialHeaderConfig() {
function readHeaderConfig() {
if (typeof window === 'undefined') {
return getHeaderCategorySeed();
}
......@@ -50,281 +30,28 @@ function getInitialHeaderConfig() {
}
}
function useHeaderConfigModule() {
const [items, setItems] = React.useState<HeaderCategoryItem[]>([]);
const [isReady, setIsReady] = React.useState(false);
React.useEffect(() => {
setItems(getInitialHeaderConfig());
setIsReady(true);
}, []);
const tree = React.useMemo(() => buildHeaderCategoryTree(items), [items]);
return {
tree,
isReady,
};
}
function getInitialHeaderCategoryPosts() {
if (typeof window === 'undefined') {
return getHeaderCategoryPostSeed();
}
const raw = window.localStorage.getItem(HEADER_CATEGORY_POSTS_STORAGE_KEY);
if (!raw) return getHeaderCategoryPostSeed();
try {
const parsed = JSON.parse(raw) as HeaderCategoryPostItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getHeaderCategoryPostSeed();
}
return normalizeHeaderCategoryPosts(parsed);
} catch {
return getHeaderCategoryPostSeed();
}
}
function persistHeaderCategoryPosts(items: HeaderCategoryPostItem[]) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(
HEADER_CATEGORY_POSTS_STORAGE_KEY,
JSON.stringify(normalizeHeaderCategoryPosts(items)),
);
}
function useHeaderCategoryPostsModule() {
const [items, setItems] = React.useState<HeaderCategoryPostItem[]>([]);
const [isReady, setIsReady] = React.useState(false);
React.useEffect(() => {
setItems(getInitialHeaderCategoryPosts());
setIsReady(true);
}, []);
React.useEffect(() => {
if (!isReady) return;
persistHeaderCategoryPosts(items);
}, [isReady, items]);
const getPostsByCategory = React.useCallback(
(categoryId: string) => items.filter((item) => item.category_id === categoryId),
[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: `header-post-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
category_id: categoryId,
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 || now.slice(0, 10),
is_active: values.is_active,
created_at: now,
updated_at: now,
};
setItems((current) => normalizeHeaderCategoryPosts([...current, nextPost]));
return nextPost;
},
[],
);
const updatePost = React.useCallback((postId: string, values: HeaderCategoryPostFormValues) => {
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(
(post?: HeaderCategoryPostItem | null): HeaderCategoryPostFormValues => {
if (!post) return EMPTY_HEADER_CATEGORY_POST_FORM;
return {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
content: post.content,
thumbnail: post.thumbnail,
published_at: post.published_at,
is_active: post.is_active,
};
},
[],
);
return {
isReady,
getPostsByCategory,
getPostById,
createPost,
updatePost,
toFormValues,
};
}
export default function HeaderCategoryPostFormPage() {
const params = useParams();
const router = useRouter();
const categoryId = String(params.categoryId ?? '');
const postId = String(params.postId ?? '');
const isCreate = postId === 'new';
const { tree, isReady: categoriesReady } = useHeaderConfigModule();
const {
isReady: postsReady,
getPostsByCategory,
getPostById,
createPost,
updatePost,
toFormValues,
} = useHeaderCategoryPostsModule();
const [form, setForm] = React.useState<HeaderCategoryPostFormValues>(
EMPTY_HEADER_CATEGORY_POST_FORM,
);
const flatCategories = React.useMemo(() => {
const rows: typeof tree = [];
const walk = (items: typeof tree) => {
items.forEach((item) => {
rows.push(item);
if (item.children.length > 0) {
walk(item.children);
}
});
};
walk(tree);
return rows;
}, [tree]);
const category = React.useMemo(
() => flatCategories.find((item) => item.id === categoryId) ?? null,
[categoryId, flatCategories],
);
const post = React.useMemo(() => {
if (isCreate) return null;
return getPostById(postId);
}, [getPostById, isCreate, postId]);
const categoryPosts = React.useMemo(
() => getPostsByCategory(categoryId),
[categoryId, getPostsByCategory],
);
const isSinglePostCategory = category?.type === 'page';
const canManagePosts = Boolean(
category && (category.type === 'page' || category.type === 'news' || category.type === 'image'),
);
const [category, setCategory] = React.useState<HeaderCategoryItem | null>(null);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
if (!postsReady) return;
if (isCreate) {
setForm(EMPTY_HEADER_CATEGORY_POST_FORM);
return;
}
setForm(toFormValues(post));
}, [isCreate, post, postsReady, toFormValues]);
const items = readHeaderConfig();
setCategory(items.find((item) => item.id === categoryId) ?? null);
setReady(true);
}, [categoryId]);
React.useEffect(() => {
if (!categoriesReady || !postsReady) return;
if (!category || !canManagePosts) {
if (!ready) return;
if (!category || (category.type !== 'page' && category.type !== 'news')) {
router.replace('/admin/header-config');
return;
}
if (isCreate && isSinglePostCategory && categoryPosts.length >= 1) {
toast.error('Danh mục bài viết trang chỉ được tạo 1 bài viết');
router.replace(`/admin/header-config/${categoryId}/posts`);
return;
}
}, [category, ready, router]);
if (!isCreate && !post) {
router.replace(`/admin/header-config/${categoryId}/posts`);
}
}, [
canManagePosts,
categoriesReady,
category,
categoryId,
categoryPosts.length,
isCreate,
isSinglePostCategory,
post,
postsReady,
router,
]);
const setField = <K extends keyof HeaderCategoryPostFormValues>(
key: K,
value: HeaderCategoryPostFormValues[K],
) => {
setForm((previous) => ({ ...previous, [key]: value }));
};
const handleTitleChange = (value: string) => {
setForm((previous) => ({
...previous,
title: value,
slug: toSlug(value) || previous.slug,
}));
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form.title.trim()) {
toast.error('Tiêu đề bài viết là bắt buộc');
return;
}
if (isCreate) {
createPost(categoryId, form);
toast.success('Đã tạo bài viết');
} else {
updatePost(postId, form);
toast.success('Đã cập nhật bài viết');
}
router.push(`/admin/header-config/${categoryId}/posts`);
};
if (!categoriesReady || !postsReady || !category || !canManagePosts || (!isCreate && !post)) {
if (!ready || !category || (category.type !== 'page' && category.type !== 'news')) {
return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-8 text-center text-sm text-gray-700 shadow-sm">
Đang tải form bài viết...
......@@ -333,132 +60,11 @@ export default function HeaderCategoryPostFormPage() {
}
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex flex-col gap-4 rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm">
<Button
variant="ghost"
asChild
className="h-9 w-fit px-3 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Link href={`/admin/header-config/${categoryId}/posts`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Quay lại danh sách bài viết
</Link>
</Button>
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-[#063e8e]">
{isCreate ? 'Thêm bài viết mới' : 'Chỉnh sửa bài viết'}
</h2>
<p className="text-sm text-gray-700">
Danh mục hiện tại: <span className="font-medium text-black">{category.name}</span>
</p>
</div>
</div>
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm"
>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">Tiêu đề bài viết *</Label>
<Input
required
value={form.title}
onChange={(event) => handleTitleChange(event.target.value)}
placeholder="Nhập tiêu đề bài viết"
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Slug</Label>
<Input
value={form.slug}
onChange={(event) => setField('slug', event.target.value)}
placeholder="tieu-de-bai-viet"
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Ngày đăng</Label>
<Input
type="date"
value={form.published_at}
onChange={(event) => setField('published_at', event.target.value)}
className={fieldClassName}
/>
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">Ảnh đại diện</Label>
<Input
value={form.thumbnail}
onChange={(event) => setField('thumbnail', event.target.value)}
placeholder="https://..."
className={fieldClassName}
/>
{form.thumbnail ? (
<div className="mt-3 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/5 p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={form.thumbnail}
alt={form.title || 'thumbnail'}
className="h-48 w-full rounded-lg object-cover"
/>
</div>
) : null}
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">Tóm tắt</Label>
<Textarea
rows={4}
value={form.excerpt}
onChange={(event) => setField('excerpt', event.target.value)}
placeholder="Nhập mô tả ngắn cho bài viết"
className={fieldClassName}
/>
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">Nội dung</Label>
<Textarea
rows={12}
value={form.content}
onChange={(event) => setField('content', event.target.value)}
placeholder="<p>Nội dung bài viết...</p>"
className={`${fieldClassName} font-mono text-sm`}
/>
</div>
</div>
<div className="flex items-center gap-3 rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/5 px-4 py-3">
<Switch
checked={form.is_active}
onCheckedChange={(checked) => setField('is_active', checked)}
className="data-[state=checked]:bg-[#063e8e] data-[state=unchecked]:bg-gray-300"
/>
<Label className="cursor-pointer text-sm text-gray-700">Hiển thị bài viết</Label>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
asChild
className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Link href={`/admin/header-config/${categoryId}/posts`}>Hủy</Link>
</Button>
<Button type="submit" className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90">
<Save className="mr-2 h-4 w-4" />
{isCreate ? 'Lưu bài viết' : 'Cập nhật bài viết'}
</Button>
</div>
</form>
</div>
<AdminNewsForm
newsId={postId}
presetHeaderCategoryId={category.id}
lockedType={category.type === 'page' ? 'baiviettrang' : 'tintuc'}
returnPath={`/admin/header-config/${category.id}/posts`}
/>
);
}
'use client';
import React from 'react';
import dayjs from 'dayjs';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { toast } from 'sonner';
import dayjs from 'dayjs';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
ArrowLeft,
Edit,
EyeOff,
FileText,
MoreHorizontal,
Plus,
Star,
Trash2,
} from 'lucide-react';
import { AdminDeleteDialog } from '@/components/admin/admin-delete-dialog';
import { AdminStatsGrid } from '@/components/admin/admin-stats-grid';
import { AdminTableLayout } from '@/components/admin/admin-table-layout';
import { SafeNextImage } from '@/components/admin/safe-next-image';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
......@@ -26,32 +36,21 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
ADMIN_NEWS_TYPE_LABELS,
type AdminNewsItem,
persistAdminNewsItems,
readAdminNewsItems,
} from '@/mockdata/admin-news';
import {
buildHeaderCategoryTree,
getHeaderCategorySeed,
HEADER_CONFIG_STORAGE_KEY,
HeaderCategoryItem,
HeaderCategoryType,
type HeaderCategoryItem,
normalizeHeaderCategories,
} from '@/mockdata/header-config';
import {
getHeaderCategoryPostSeed,
HEADER_CATEGORY_POSTS_STORAGE_KEY,
HeaderCategoryPostItem,
normalizeHeaderCategoryPosts,
} from '@/mockdata/header-category-posts';
import {
ArrowLeft,
Eye,
EyeOff,
Image as ImageIcon,
Pencil,
Plus,
Search,
Trash2,
} from 'lucide-react';
function getInitialHeaderConfig() {
function readHeaderConfig() {
if (typeof window === 'undefined') {
return getHeaderCategorySeed();
}
......@@ -71,157 +70,139 @@ function getInitialHeaderConfig() {
}
}
function useHeaderConfigModule() {
const [items, setItems] = React.useState<HeaderCategoryItem[]>([]);
const [isReady, setIsReady] = React.useState(false);
React.useEffect(() => {
setItems(getInitialHeaderConfig());
setIsReady(true);
}, []);
const tree = React.useMemo(() => buildHeaderCategoryTree(items), [items]);
return {
tree,
isReady,
};
function formatDateTime(value: string) {
return value ? dayjs(value).format('DD/MM/YYYY HH:mm') : '—';
}
function getInitialHeaderCategoryPosts() {
if (typeof window === 'undefined') {
return getHeaderCategoryPostSeed();
}
const raw = window.localStorage.getItem(HEADER_CATEGORY_POSTS_STORAGE_KEY);
if (!raw) return getHeaderCategoryPostSeed();
try {
const parsed = JSON.parse(raw) as HeaderCategoryPostItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getHeaderCategoryPostSeed();
}
return normalizeHeaderCategoryPosts(parsed);
} catch {
return getHeaderCategoryPostSeed();
}
}
function persistHeaderCategoryPosts(items: HeaderCategoryPostItem[]) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(
HEADER_CATEGORY_POSTS_STORAGE_KEY,
JSON.stringify(normalizeHeaderCategoryPosts(items)),
);
}
function useHeaderCategoryPostsModule() {
const [items, setItems] = React.useState<HeaderCategoryPostItem[]>([]);
const [isReady, setIsReady] = React.useState(false);
React.useEffect(() => {
setItems(getInitialHeaderCategoryPosts());
setIsReady(true);
}, []);
React.useEffect(() => {
if (!isReady) return;
persistHeaderCategoryPosts(items);
}, [isReady, items]);
const getPostsByCategory = React.useCallback(
(categoryId: string) => items.filter((item) => item.category_id === categoryId),
[items],
);
const removePost = React.useCallback((postId: string) => {
setItems((current) => current.filter((item) => item.id !== postId));
}, []);
function flattenTree(items: ReturnType<typeof buildHeaderCategoryTree>) {
const rows: ReturnType<typeof buildHeaderCategoryTree> = [];
return {
isReady,
getPostsByCategory,
removePost,
const walk = (nodes: ReturnType<typeof buildHeaderCategoryTree>) => {
nodes.forEach((item) => {
rows.push(item);
if (item.children.length > 0) {
walk(item.children);
}
});
};
walk(items);
return rows;
}
function getTypeLabel(type: HeaderCategoryType) {
switch (type) {
case 'page':
return 'Bài viết trang';
case 'news':
return 'Tin tức';
case 'image':
return 'Ảnh';
case 'category':
return 'Danh mục';
default:
return type;
}
function HeaderCategoryPostsLoading() {
return Array.from({ length: 3 }).map((_, index) => (
<TableRow
key={`loading-${index}`}
className={index % 2 === 0 ? 'bg-white' : 'bg-[#063e8e]/[0.03]'}
>
<TableCell colSpan={7} className="px-4 py-4">
<div className="h-20 animate-pulse rounded-2xl bg-[#063e8e]/10" />
</TableCell>
</TableRow>
));
}
export default function HeaderCategoryPostsPage() {
const params = useParams();
const router = useRouter();
const categoryId = String(params.categoryId ?? '');
const { tree, isReady: categoryReady } = useHeaderConfigModule();
const { isReady: postsReady, getPostsByCategory, removePost } = useHeaderCategoryPostsModule();
const [items, setItems] = React.useState<AdminNewsItem[]>([]);
const [headerItems, setHeaderItems] = React.useState<HeaderCategoryItem[]>([]);
const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = React.useState<string | null>(null);
const flatCategories = React.useMemo(() => {
const rows: typeof tree = [];
const [ready, setReady] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<AdminNewsItem | null>(null);
const walk = (items: typeof tree) => {
items.forEach((item) => {
rows.push(item);
if (item.children.length > 0) {
walk(item.children);
}
});
};
React.useEffect(() => {
setItems(readAdminNewsItems());
setHeaderItems(readHeaderConfig());
setReady(true);
}, []);
walk(tree);
return rows;
}, [tree]);
const flatCategories = React.useMemo(() => {
return flattenTree(buildHeaderCategoryTree(headerItems));
}, [headerItems]);
const category = React.useMemo(
() => flatCategories.find((item) => item.id === categoryId) ?? null,
[categoryId, flatCategories],
);
const posts = React.useMemo(() => getPostsByCategory(categoryId), [categoryId, getPostsByCategory]);
const canManagePosts = Boolean(
category && (category.type === 'page' || category.type === 'news'),
);
const categoryPosts = React.useMemo(() => {
return items
.filter((item) => item.header_category_id === categoryId)
.sort((left, right) => {
const leftFeatured = left.type === 'tintuc' && left.is_featured ? 1 : 0;
const rightFeatured = right.type === 'tintuc' && right.is_featured ? 1 : 0;
if (leftFeatured !== rightFeatured) {
return rightFeatured - leftFeatured;
}
const leftTime = new Date(left.published_at || left.created_at).getTime();
const rightTime = new Date(right.published_at || right.created_at).getTime();
return rightTime - leftTime;
});
}, [categoryId, items]);
const filteredPosts = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return posts;
if (!keyword) return categoryPosts;
return posts.filter(
(post) =>
post.title.toLowerCase().includes(keyword) ||
post.slug.toLowerCase().includes(keyword) ||
post.excerpt.toLowerCase().includes(keyword),
);
}, [posts, search]);
return categoryPosts.filter((item) => {
return (
item.title.toLowerCase().includes(keyword) ||
item.slug.toLowerCase().includes(keyword)
);
});
}, [categoryPosts, search]);
const isSinglePostCategory = category?.type === 'page';
const canCreatePost = Boolean(
category && (category.type === 'page' || category.type === 'news' || category.type === 'image'),
);
const createLabel = category?.type === 'image' ? 'Thêm ảnh' : 'Thêm bài viết';
const createHref = `/admin/header-config/${categoryId}/posts/new`;
React.useEffect(() => {
if (!categoryReady || !postsReady) return;
if (!category || !canCreatePost) {
if (!ready) return;
if (!category || !canManagePosts) {
router.replace('/admin/header-config');
}
}, [canCreatePost, category, categoryReady, postsReady, router]);
}, [canManagePosts, category, ready, router]);
const stats = React.useMemo(() => {
return [
{
label: 'Tổng bài viết',
value: categoryPosts.length,
icon: <FileText className="h-4 w-4 text-[#063e8e]" />,
},
{
label: 'Đang hiển thị',
value: categoryPosts.filter((item) => !item.is_hidden).length,
icon: <FileText className="h-4 w-4 text-[#063e8e]" />,
},
{
label: 'Tin nổi bật',
value: categoryPosts.filter((item) => item.type === 'tintuc' && item.is_featured).length,
icon: <Star className="h-4 w-4 text-[#063e8e]" />,
},
];
}, [categoryPosts]);
const handleDelete = () => {
if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id);
setItems(nextItems);
persistAdminNewsItems(nextItems);
toast.success('Đã xóa bài viết');
setDeleteTarget(null);
};
if (!categoryReady || !postsReady || !category || !canCreatePost) {
if (!ready || !category || !canManagePosts) {
return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-8 text-center text-sm text-gray-700 shadow-sm">
Đang tải dữ liệu danh mục...
......@@ -230,201 +211,205 @@ export default function HeaderCategoryPostsPage() {
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<Button
variant="ghost"
asChild
className="h-9 w-fit px-3 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Link href="/admin/header-config">
<ArrowLeft className="mr-2 h-4 w-4" />
Quay lại cấu hình danh mục
</Link>
</Button>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-2xl font-semibold text-[#063e8e]">{category.name}</h2>
<Badge variant="outline" className="border-[#063e8e]/20 text-[#063e8e]">
{getTypeLabel(category.type)}
</Badge>
</div>
<p className="max-w-3xl text-sm text-gray-700">
{isSinglePostCategory
? 'Danh mục dạng bài viết trang chỉ cho phép gắn đúng 1 bài viết. Nếu cần thay nội dung, hãy chỉnh sửa bài hiện có hoặc xóa rồi tạo lại.'
: category.type === 'image'
? 'Danh mục dạng ảnh cho phép thêm nhiều nội dung và quản lý tập trung theo đúng cấu trúc header.'
: 'Danh mục dạng tin tức cho phép thêm nhiều bài viết và quản lý tập trung theo đúng cấu trúc header.'}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/5 px-4 py-3 text-sm text-gray-700">
<span className="font-semibold text-[#063e8e]">{posts.length}</span> bài viết
</div>
{canCreatePost && (!isSinglePostCategory || posts.length < 1) ? (
<Button asChild className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90">
<Link href={`/admin/header-config/${category.id}/posts/new`}>
<Plus className="mr-2 h-4 w-4" />
{createLabel}
</Link>
</Button>
) : (
<Button
type="button"
disabled
className="bg-[#063e8e]/30 text-white hover:bg-[#063e8e]/30"
>
<Plus className="mr-2 h-4 w-4" />
{createLabel}
</Button>
)}
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-700" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Tìm kiếm bài viết..."
className="border-[#063e8e]/15 bg-white pl-9 text-gray-700 placeholder:text-gray-700"
/>
</div>
{isSinglePostCategory ? (
<p className="text-sm text-gray-700">Loại danh mục này chỉ giữ 1 bài viết hiển thị.</p>
) : (
<div className="space-y-8">
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="icon"
asChild
className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Link href="/admin/header-config">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-xl font-semibold text-[#063e8e]">
Quản lý bài viết: {category.name}
</h1>
<p className="text-sm text-gray-700">
{category.type === 'image'
? 'Bạn có thể thêm nhiều mục nội dung ảnh cho danh mục này.'
: 'Bạn có thể thêm nhiều bài viết cho danh mục này.'}
{isSinglePostCategory
? 'Danh mục bài viết trang chỉ quản lý một bài viết duy nhất thuộc danh mục hiển thị này.'
: 'Quản lý toàn bộ bài viết thuộc danh mục hiển thị tương ứng trong quản lý bài viết.'}
</p>
)}
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-[#063e8e]/15 bg-white shadow-sm">
<Table>
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="py-4 text-center text-white">Tiêu đề</TableHead>
<TableHead className="w-[180px] py-4 text-center text-white">Slug</TableHead>
<TableHead className="w-[160px] py-4 text-center text-white">Ngày đăng</TableHead>
<TableHead className="w-[130px] py-4 text-center text-white">Hiển thị</TableHead>
<TableHead className="w-[160px] py-4 text-center text-white">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPosts.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center text-sm text-gray-700">
{posts.length === 0
? 'Danh mục này chưa có bài viết nào.'
: 'Không tìm thấy bài viết phù hợp.'}
</TableCell>
{category.type === 'news' ? <AdminStatsGrid items={stats} /> : null}
<AdminTableLayout
searchValue={search}
searchPlaceholder="Tìm kiếm bài viết thuộc danh mục..."
actionLabel={isSinglePostCategory ? 'Thêm bài viết trang' : 'Thêm bài viết'}
actionIcon={<Plus className="mr-2 h-4 w-4" />}
actionDisabled={isSinglePostCategory && categoryPosts.length >= 1}
onSearchChange={setSearch}
onActionClick={() => router.push(createHref)}
filters={
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03] px-4 py-2 text-sm text-gray-700">
<span className="font-semibold text-[#063e8e]">{categoryPosts.length}</span>{' '}
bài viết thuộc danh mục này
</div>
}
>
<div className="overflow-x-auto">
<Table className="min-w-[980px] table-fixed">
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-[300px] py-4 text-center text-white">
Tiêu đề
</TableHead>
<TableHead className="w-[150px] py-4 text-center text-white">
Hình ảnh đại diện
</TableHead>
<TableHead className="w-[160px] py-4 text-center text-white">
Loại bài viết
</TableHead>
<TableHead className="w-[170px] py-4 text-center text-white">
Ngày xuất bản
</TableHead>
<TableHead className="w-[170px] py-4 text-center text-white">
Ngày hết hạn
</TableHead>
<TableHead className="w-[120px] py-4 text-center text-white">
Hiển thị
</TableHead>
<TableHead className="w-[100px] py-4 text-center text-white">
Thao tác
</TableHead>
</TableRow>
) : (
filteredPosts.map((post, index) => (
<TableRow
key={post.id}
className={index % 2 === 0 ? 'bg-white' : 'bg-[#063e8e]/[0.03]'}
>
<TableCell className="py-4">
<div className="flex items-start gap-4">
<div className="flex h-14 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-[#063e8e]/10 bg-[#063e8e]/5">
{post.thumbnail ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={post.thumbnail}
alt={post.title}
className="h-full w-full object-cover"
</TableHeader>
<TableBody>
{!ready ? (
<HeaderCategoryPostsLoading />
) : filteredPosts.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center text-sm text-gray-700">
{categoryPosts.length === 0
? 'Danh mục này chưa có bài viết nào.'
: 'Không có bài viết nào phù hợp.'}
</TableCell>
</TableRow>
) : (
filteredPosts.map((item, index) => (
<TableRow
key={item.id}
className={index % 2 === 0 ? 'bg-white' : 'bg-[#063e8e]/[0.03]'}
>
<TableCell className="py-4">
<div className="space-y-2">
<p className="line-clamp-2 text-sm font-semibold text-black">
{item.title}
</p>
{item.type === 'tintuc' && item.is_featured ? (
<span className="inline-flex items-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-xs font-medium text-[#063e8e]">
<Star className="mr-1.5 h-3.5 w-3.5 fill-current" />
Tin nổi bật
</span>
) : null}
</div>
</TableCell>
<TableCell className="text-center">
<div className="relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03]">
{item.thumbnail ? (
<SafeNextImage
src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name}
fill
className="object-cover"
/>
) : (
<ImageIcon className="h-5 w-5 text-[#063e8e]" />
<div className="flex h-full items-center justify-center text-xs text-gray-700">
Không có ảnh
</div>
)}
</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>
</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') : '-'}
</TableCell>
<TableCell className="text-center">
{post.is_active ? (
<span className="inline-flex items-center gap-2 text-sm font-medium text-[#063e8e]">
<Eye className="h-4 w-4" />
Hiển thị
</span>
) : (
<span className="inline-flex items-center gap-2 text-sm font-medium text-gray-700">
<EyeOff className="h-4 w-4" />
Ẩn
</span>
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
asChild
className="text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Link href={`/admin/header-config/${category.id}/posts/${post.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-700 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteId(post.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent className="border-[#063e8e]/15 bg-white">
<AlertDialogHeader>
<AlertDialogTitle className="text-[#063e8e]">Xóa bài viết</AlertDialogTitle>
<AlertDialogDescription className="text-gray-700">
Bài viết này sẽ bị xóa khỏi danh mục hiện tại.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]">
Hủy
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 text-white hover:bg-red-700"
onClick={() => {
if (!deleteId) return;
removePost(deleteId);
toast.success('Đã xóa bài viết');
setDeleteId(null);
}}
>
Xóa
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{ADMIN_NEWS_TYPE_LABELS[item.type]}
</Badge>
</TableCell>
<TableCell className="text-center text-sm text-gray-700">
{formatDateTime(item.published_at)}
</TableCell>
<TableCell className="text-center text-sm text-gray-700">
{formatDateTime(item.expired_at)}
</TableCell>
<TableCell className="text-center">
{item.is_hidden ? (
<span className="inline-flex items-center rounded-full border border-gray-300 px-2.5 py-1 text-sm text-gray-700">
<EyeOff className="mr-1.5 h-3.5 w-3.5" />
Ẩn
</span>
) : (
<span className="inline-flex items-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-sm text-[#063e8e]">
Hiển thị
</span>
)}
</TableCell>
<TableCell className="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
asChild
className="text-gray-700 focus:text-[#063e8e]"
>
<Link href={`/admin/header-config/${categoryId}/posts/${item.id}`}>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</AdminTableLayout>
<AdminDeleteDialog
open={!!deleteTarget}
title="Xóa bài viết"
description={
deleteTarget ? (
<>
Bài viết <strong>{deleteTarget.title}</strong> sẽ bị xóa khỏi dữ liệu quản trị.
</>
) : null
}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={handleDelete}
/>
</div>
);
}
"use client";
import * as React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import {
Dialog,
......@@ -22,9 +21,8 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
headerArticleCategoryOptions,
HeaderCategoryTreeItem,
HeaderCategoryType,
type HeaderCategoryTreeItem,
type HeaderCategoryType,
toSlug,
} from "@/mockdata/header-config";
......@@ -38,7 +36,7 @@ export interface HeaderCategoryFormValues {
parent_id: string;
type: HeaderCategoryType;
description: string;
category_ids: string[];
tagsearch: string;
}
interface HeaderCategoryFormDialogProps {
......@@ -56,7 +54,6 @@ const TYPE_OPTIONS: Array<{ value: HeaderCategoryType; label: string }> = [
{ value: "category", label: "Danh mục" },
{ value: "page", label: "Bài viết trang" },
{ value: "news", label: "Tin tức" },
{ value: "image", label: "Ảnh" },
];
const fieldClassName =
......@@ -67,7 +64,8 @@ const selectTriggerClassName =
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName = "text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
const selectItemClassName =
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
export function HeaderCategoryFormDialog({
mode,
......@@ -99,6 +97,11 @@ export function HeaderCategoryFormDialog({
}));
};
const searchTags = values.tagsearch
.split(",")
.map((item) => item.trim())
.filter(Boolean);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto border-[#063e8e]/15 bg-white text-gray-700 shadow-xl">
......@@ -111,7 +114,7 @@ export function HeaderCategoryFormDialog({
<div className="grid grid-cols-1 gap-4 py-2 md:grid-cols-2">
<div>
<Label className="mb-1.5 block text-gray-700">Tên danh mục *</Label>
<Label className="mb-1.5 block text-gray-700">Tên danh mục <span className="text-red-600">*</span></Label>
<Input
value={values.name}
onChange={(event) => handleNameChange(event.target.value)}
......@@ -121,7 +124,7 @@ export function HeaderCategoryFormDialog({
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Thể loại *</Label>
<Label className="mb-1.5 block text-gray-700">Thể loại <span className="text-red-600">*</span></Label>
<Select
value={values.type}
onValueChange={(value) =>
......@@ -182,7 +185,7 @@ export function HeaderCategoryFormDialog({
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Thứ tự</Label>
<Label className="mb-1.5 block text-gray-700">Thứ tự <span className="text-red-600">*</span></Label>
<Input
type="number"
min="0"
......@@ -194,7 +197,7 @@ export function HeaderCategoryFormDialog({
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Slug</Label>
<Label className="mb-1.5 block text-gray-700">Slug <span className="text-red-600">*</span></Label>
<Input
value={values.slug}
onChange={(event) => setField("slug", event.target.value)}
......@@ -214,33 +217,28 @@ export function HeaderCategoryFormDialog({
/>
</div>
{values.type === "news" ? (
{mode === "edit" && values.type === "news" ? (
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">Thể loại bài viết</Label>
<div className="grid grid-cols-1 gap-2 rounded-lg border border-[#063e8e]/15 p-4 md:grid-cols-2">
{headerArticleCategoryOptions.map((category) => (
<label
key={category.id}
className="flex items-center gap-3 rounded-md border border-[#063e8e]/10 px-3 py-2"
>
<Checkbox
checked={values.category_ids.includes(category.id)}
onCheckedChange={(checked) => {
if (checked) {
setField("category_ids", [...values.category_ids, category.id]);
return;
}
setField(
"category_ids",
values.category_ids.filter((id) => id !== category.id),
);
}}
/>
<span className="text-sm text-gray-700">{category.name}</span>
</label>
))}
</div>
<Label className="mb-1.5 block text-gray-700">Tag tìm kiếm</Label>
<Textarea
rows={3}
value={values.tagsearch}
onChange={(event) => setField("tagsearch", event.target.value)}
placeholder="Nhập tag tìm kiếm, ngăn cách bằng dấu phẩy"
className={fieldClassName}
/>
{searchTags.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{searchTags.map((item) => (
<span
key={item}
className="inline-flex items-center rounded-full border border-[#063e8e]/15 bg-[#063e8e]/[0.04] px-3 py-1 text-sm text-gray-700"
>
{item}
</span>
))}
</div>
) : null}
</div>
) : null}
</div>
......@@ -253,7 +251,10 @@ export function HeaderCategoryFormDialog({
>
Hủy
</Button>
<Button className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90" onClick={onSubmit}>
<Button
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={onSubmit}
>
{mode === "create" ? "Lưu danh mục" : "Cập nhật danh mục"}
</Button>
</DialogFooter>
......
......@@ -7,7 +7,6 @@ import {
ChevronRight,
Edit,
ExternalLink,
FileImage,
FileText,
FolderTree,
MoreHorizontal,
......@@ -33,7 +32,10 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { HeaderCategoryTreeItem, getHeaderCategoryTypeLabel } from "@/mockdata/header-config";
import {
type HeaderCategoryTreeItem,
getHeaderCategoryTypeLabel,
} from "@/mockdata/header-config";
export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & {
depth: number;
......@@ -69,9 +71,8 @@ function getDisplaySortOrder(item: HeaderCategoryFlatRow, rows: HeaderCategoryFl
function getTypeIcon(type: HeaderCategoryTreeItem["type"]) {
switch (type) {
case "news":
case "page":
return <FileText className="h-4 w-4 text-[#063e8e]" />;
case "image":
return <FileImage className="h-4 w-4 text-[#063e8e]" />;
default:
return <FolderTree className="h-4 w-4 text-[#063e8e]" />;
}
......@@ -160,9 +161,7 @@ export function HeaderCategoryTable({
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";
const canManagePosts = item.type === "page" || item.type === "news";
return (
<TableRow
......@@ -191,11 +190,13 @@ export function HeaderCategoryTable({
<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={
......@@ -207,6 +208,7 @@ export function HeaderCategoryTable({
{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">
......@@ -217,6 +219,7 @@ export function HeaderCategoryTable({
) : null}
</div>
</TableCell>
<TableCell className="w-[120px] text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
......@@ -241,9 +244,9 @@ export function HeaderCategoryTable({
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 href={`/admin/header-config/${item.id}/posts`}>
<FileText className="mr-2 h-4 w-4" />
Quản lý bài viết
</Link>
</DropdownMenuItem>
) : null}
......
......@@ -29,7 +29,7 @@ const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = {
parent_id: '',
type: 'page',
description: '',
category_ids: [],
tagsearch: '',
};
function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValues {
......@@ -43,10 +43,26 @@ function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValue
parent_id: item.parent_id ?? '',
type: item.type,
description: item.description ?? '',
category_ids: item.category_ids ?? [],
tagsearch: (item.tagsearch_values ?? []).join(', '),
};
}
function parseTagsearch(value: string) {
const seen = new Set<string>();
return value
.split(',')
.map((item) => item.trim())
.filter((item) => {
if (!item) return false;
const key = item.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function getInitialHeaderConfig() {
if (typeof window === 'undefined') {
return getHeaderCategorySeed();
......@@ -126,7 +142,8 @@ function useHeaderConfigModule() {
is_article: values.type === 'news',
parent_id: values.parent_id || null,
level: 1,
category_ids: values.type === 'news' ? values.category_ids : [],
category_ids: [],
tagsearch_values: values.type === 'news' ? parseTagsearch(values.tagsearch) : [],
description: values.description.trim(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
......@@ -150,7 +167,8 @@ function useHeaderConfigModule() {
type: values.type,
is_article: values.type === 'news',
parent_id: values.parent_id || null,
category_ids: values.type === 'news' ? values.category_ids : [],
category_ids: [],
tagsearch_values: values.type === 'news' ? parseTagsearch(values.tagsearch) : [],
description: values.description.trim(),
updated_at: new Date().toISOString(),
});
......
'use client';
import { AdminNewsForm } from "@/components/admin/news-form";
import React, { useEffect, useState, useTransition } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import {
useGetNewsId,
usePostNews,
usePutNewsId,
getGetNewsAdminQueryKey,
} from '@/api/endpoints/news';
import { useGetCategory } from '@/api/endpoints/category';
import { useGetNewsPageConfigGetHierarchical } from '@/api/endpoints/news-page-config';
import { GetCategoryAdminResponseType } from '@/api/types/category';
import {
GetNewsPageConfigResponseType,
NewsPageConfigItem,
} from '@/api/types/news-page-config';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, Save } from 'lucide-react';
import { Spinner } from '@/components/ui';
// Flatten news page config tree for select options
function flattenTree(
node: NewsPageConfigItem,
depth = 0,
): { id: string; label: string }[] {
const prefix = ' '.repeat(depth);
const self = { id: node.id, label: `${prefix}${node.name}` };
const children = (node.children ?? []).flatMap((c) => flattenTree(c, depth + 1));
return [self, ...children];
interface AdminNewsDetailPageProps {
params: Promise<{
id: string;
}>;
}
export default function NewsFormPage() {
const params = useParams();
const router = useRouter();
const qc = useQueryClient();
const id = params?.id as string;
const isNew = id === 'new';
const [, startTransition] = useTransition();
const [form, setForm] = useState({
title: '',
thumbnail: '',
external_link: '',
description: '',
release_at: '',
is_active: true,
category: '',
page_config_id: '',
});
// Fetch existing news when editing
const { data: newsData, isLoading: newsLoading } = useGetNewsId(isNew ? '' : id, {
query: { enabled: !isNew && !!id },
});
useEffect(() => {
const d = (newsData as Record<string, unknown>)?.responseData as Record<string, unknown> | undefined
?? (newsData as Record<string, unknown>)?.data as Record<string, unknown> | undefined;
if (!d) return;
// Intentional: populate form when data loads
startTransition(() => setForm({
title: (d.title as string) ?? '',
thumbnail: (d.thumbnail as string) ?? '',
external_link: (d.external_link as string) ?? '',
description: (d.description as string) ?? '',
release_at: d.release_at ? (d.release_at as string).slice(0, 10) : '',
is_active: (d.is_active as boolean) ?? true,
category: (d.category as string) ?? '',
page_config_id: ((d.page_config as Record<string, string>)?.id) ?? (d.page_config_id as string) ?? '',
}));
}, [newsData]);
// Categories
const { data: catData } = useGetCategory<GetCategoryAdminResponseType>({ pageSize: '100' });
const categories = catData?.responseData?.rows ?? [];
// Page config tree
const { data: configData } = useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>();
const configRoot = configData?.responseData;
const configOptions = configRoot ? flattenTree(configRoot) : [];
// Mutations
const { mutate: create, isPending: creating } = usePostNews({
mutation: {
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetNewsAdminQueryKey() });
router.push('/admin/news');
},
},
});
const { mutate: update, isPending: updating } = usePutNewsId({
mutation: {
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetNewsAdminQueryKey() });
router.push('/admin/news');
},
},
});
const isPending = creating || updating;
const setField = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const payload = {
title: form.title,
thumbnail: form.thumbnail || undefined,
external_link: form.external_link || undefined,
description: form.description,
release_at: form.release_at || undefined,
is_active: form.is_active,
category: form.category || undefined,
page_config_id: form.page_config_id || undefined,
};
if (isNew) {
create({ data: [payload] });
} else {
update({ id, data: payload });
}
};
if (!isNew && newsLoading) {
return (
<div className="flex justify-center py-20">
<Spinner />
</div>
);
}
return (
<div className="max-w-2xl">
<div className="flex items-center gap-3 mb-6">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin/news">
<ArrowLeft size={18} />
</Link>
</Button>
<div>
<h2 className="text-xl font-bold text-gray-800">
{isNew ? 'Thêm bài viết mới' : 'Chỉnh sửa bài viết'}
</h2>
<p className="text-sm text-gray-500 mt-0.5">
{isNew ? 'Điền thông tin để tạo bài viết mới.' : `Đang sửa bài viết #${id}`}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-lg border shadow-sm p-6 space-y-5">
{/* Title */}
<div className="space-y-1.5">
<Label htmlFor="title">Tiêu đề *</Label>
<Input
id="title"
required
value={form.title}
onChange={(e) => setField('title', e.target.value)}
placeholder="Nhập tiêu đề bài viết..."
/>
</div>
{/* Thumbnail */}
<div className="space-y-1.5">
<Label htmlFor="thumbnail">URL ảnh thumbnail</Label>
<Input
id="thumbnail"
value={form.thumbnail}
onChange={(e) => setField('thumbnail', e.target.value)}
placeholder="https://..."
/>
{form.thumbnail && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.thumbnail}
alt="preview"
className="mt-2 h-32 w-auto rounded-md border object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
{/* External link */}
<div className="space-y-1.5">
<Label htmlFor="ext-link">Đường dẫn ngoài (nếu có)</Label>
<Input
id="ext-link"
value={form.external_link}
onChange={(e) => setField('external_link', e.target.value)}
placeholder="https://..."
/>
</div>
{/* Category */}
<div className="space-y-1.5">
<Label>Thể loại</Label>
<Select value={form.category} onValueChange={(v) => setField('category', v)}>
<SelectTrigger>
<SelectValue placeholder="Chọn thể loại..." />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Page config */}
<div className="space-y-1.5">
<Label>Danh mục menu (page config)</Label>
<Select value={form.page_config_id} onValueChange={(v) => setField('page_config_id', v)}>
<SelectTrigger>
<SelectValue placeholder="Chọn danh mục..." />
</SelectTrigger>
<SelectContent className="max-h-60">
{configOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Release date */}
<div className="space-y-1.5">
<Label htmlFor="release-at">Ngày đăng</Label>
<Input
id="release-at"
type="date"
value={form.release_at}
onChange={(e) => setField('release_at', e.target.value)}
/>
</div>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="desc">Nội dung / Mô tả (HTML)</Label>
<Textarea
id="desc"
value={form.description}
onChange={(e) => setField('description', e.target.value)}
placeholder="<p>Nội dung bài viết...</p>"
rows={8}
className="font-mono text-sm"
/>
</div>
{/* Is active */}
<div className="flex items-center gap-3">
<Switch
id="is-active"
checked={form.is_active}
onCheckedChange={(v) => setField('is_active', v)}
/>
<Label htmlFor="is-active" className="cursor-pointer">
Hiển thị bài viết
</Label>
</div>
export default async function AdminNewsDetailPage({
params,
}: AdminNewsDetailPageProps) {
const { id } = await params;
{/* Submit */}
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" type="button" asChild>
<Link href="/admin/news">Huỷ</Link>
</Button>
<Button type="submit" disabled={isPending}>
<Save size={15} className="mr-1" />
{isPending ? 'Đang lưu...' : isNew ? 'Tạo bài viết' : 'Cập nhật'}
</Button>
</div>
</form>
</div>
);
return <AdminNewsForm newsId={id} />;
}
'use client';
"use client";
import React, { useState } from 'react';
import Link from 'next/link';
import * as React from "react";
import dayjs from "dayjs";
import {
useGetNewsAdmin,
useDeleteNewsId,
getGetNewsAdminQueryKey,
} from '@/api/endpoints/news';
import { GetNewsResponseType } from '@/api/types/news';
import { useQueryClient } from '@tanstack/react-query';
Edit,
EyeOff,
MoreHorizontal,
Plus,
Star,
Tag,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
......@@ -16,177 +41,399 @@ import {
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Eye, EyeOff, Pencil, Plus, Search, Trash2 } from 'lucide-react';
import dayjs from 'dayjs';
import { Spinner } from '@/components/ui';
function DeleteConfirm({ item, onClose }: { item: { id: string; title: string }; onClose: () => void }) {
const qc = useQueryClient();
const { mutate: del, isPending } = useDeleteNewsId({
mutation: {
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetNewsAdminQueryKey() });
onClose();
},
},
});
ADMIN_NEWS_TYPE_LABELS,
ADMIN_NEWS_TYPE_OPTIONS,
type AdminNewsItem,
persistAdminNewsItems,
readAdminNewsItems,
} from "@/mockdata/admin-news";
import {
type HeaderCategoryItem,
getHeaderCategorySeed,
HEADER_CONFIG_STORAGE_KEY,
normalizeHeaderCategories,
} from "@/mockdata/header-config";
return (
<AlertDialog open onOpenChange={() => onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Xoá bài viết?</AlertDialogTitle>
<AlertDialogDescription>
Bài viết <strong>"{item.title}"</strong> sẽ bị xoá vĩnh viễn.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Huỷ</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={() => del({ id: String(item.id) })}
disabled={isPending}
>
{isPending ? 'Đang xoá...' : 'Xoá'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
const selectTriggerClassName =
"w-full border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[180px]";
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName =
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
function readHeaderConfig() {
if (typeof window === "undefined") {
return getHeaderCategorySeed();
}
const raw = window.localStorage.getItem(HEADER_CONFIG_STORAGE_KEY);
if (!raw) return getHeaderCategorySeed();
try {
const parsed = JSON.parse(raw) as HeaderCategoryItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getHeaderCategorySeed();
}
return normalizeHeaderCategories(parsed);
} catch {
return getHeaderCategorySeed();
}
}
export default function NewsPage() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [deleteItem, setDeleteItem] = useState<{ id: string; title: string } | null>(null);
const pageSize = 20;
function formatDateTime(value: string) {
return value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "—";
}
function stripHtml(html: string) {
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
function AdminNewsTableLoading() {
return Array.from({ length: 3 }).map((_, index) => (
<TableRow
key={`loading-${index}`}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/[0.03]"}
>
<TableCell colSpan={8} className="px-4 py-4">
<div className="h-20 animate-pulse rounded-2xl bg-[#063e8e]/10" />
</TableCell>
</TableRow>
));
}
export default function AdminNewsPage() {
const router = useRouter();
const [items, setItems] = React.useState<AdminNewsItem[]>([]);
const [headerItems, setHeaderItems] = React.useState<HeaderCategoryItem[]>([]);
const [search, setSearch] = React.useState("");
const [typeFilter, setTypeFilter] = React.useState("all");
const [categoryFilter, setCategoryFilter] = React.useState("all");
const [statusFilter, setStatusFilter] = React.useState("all");
const [deleteTarget, setDeleteTarget] = React.useState<AdminNewsItem | null>(null);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
setItems(readAdminNewsItems());
setHeaderItems(readHeaderConfig());
setReady(true);
}, []);
const categoryOptions = React.useMemo(() => {
return headerItems.filter((item) => item.type === "news" || item.type === "page");
}, [headerItems]);
const filteredItems = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
return items
.filter((item) => {
const categoryName =
headerItems.find((category) => category.id === item.header_category_id)?.name ?? "";
const matchesKeyword =
!keyword ||
item.title.toLowerCase().includes(keyword) ||
item.slug.toLowerCase().includes(keyword) ||
stripHtml(item.summary).toLowerCase().includes(keyword) ||
categoryName.toLowerCase().includes(keyword);
const matchesType = typeFilter === "all" || item.type === typeFilter;
const matchesCategory =
categoryFilter === "all" || item.header_category_id === categoryFilter;
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "visible" && !item.is_hidden) ||
(statusFilter === "hidden" && item.is_hidden);
const { data, isLoading } = useGetNewsAdmin<GetNewsResponseType>({
currentPage: String(page),
pageSize: String(pageSize),
filters: search ? `title@=${search}` : undefined,
});
return matchesKeyword && matchesType && matchesCategory && matchesStatus;
})
.sort((left, right) => {
const leftFeatured = left.type === "tintuc" && left.is_featured ? 1 : 0;
const rightFeatured = right.type === "tintuc" && right.is_featured ? 1 : 0;
const rows = data?.responseData?.rows ?? [];
const totalPages = data?.responseData?.totalPages ?? 1;
if (leftFeatured !== rightFeatured) {
return rightFeatured - leftFeatured;
}
const leftTime = new Date(left.published_at || left.created_at).getTime();
const rightTime = new Date(right.published_at || right.created_at).getTime();
return rightTime - leftTime;
});
}, [categoryFilter, headerItems, items, search, statusFilter, typeFilter]);
const stats = React.useMemo(() => {
return [
{
label: "Tổng bài viết",
value: items.length,
icon: <Tag className="h-4 w-4 text-[#063e8e]" />,
},
{
label: "Đang hiển thị",
value: items.filter((item) => !item.is_hidden).length,
icon: <Tag className="h-4 w-4 text-[#063e8e]" />,
},
{
label: "Tin nổi bật",
value: items.filter((item) => item.type === "tintuc" && item.is_featured).length,
icon: <Tag className="h-4 w-4 text-[#063e8e]" />,
},
];
}, [items]);
const handleDelete = () => {
if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id);
setItems(nextItems);
persistAdminNewsItems(nextItems);
toast.success("Đã xóa bài viết");
setDeleteTarget(null);
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-gray-800">Danh sách bài viết</h2>
<p className="text-sm text-gray-500 mt-1">Tất cả bài viết trong hệ thống.</p>
</div>
<Button asChild>
<Link href="/admin/news/new">
<Plus size={16} className="mr-1" /> Thêm bài viết
</Link>
</Button>
</div>
<div className="relative w-72 mb-4">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<Input
className="pl-9"
placeholder="Tìm kiếm tiêu đề..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8">#</TableHead>
<TableHead>Tiêu đề</TableHead>
<TableHead>Thể loại</TableHead>
<TableHead>Danh mục</TableHead>
<TableHead className="w-16 text-center">Hiển thị</TableHead>
<TableHead>Ngày đăng</TableHead>
<TableHead className="w-24 text-right">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10"><Spinner /></TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-gray-400 py-10 text-sm">Chưa có bài viết nào.</TableCell>
<div className="space-y-8">
<AdminStatsGrid items={stats} />
<AdminTableLayout
searchValue={search}
searchPlaceholder="Tìm kiếm bài viết..."
actionLabel="Thêm bài viết"
actionIcon={<Plus className="mr-2 h-4 w-4" />}
onSearchChange={setSearch}
onActionClick={() => router.push("/admin/news/new")}
filters={
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Loại bài viết" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả loại bài viết
</SelectItem>
{ADMIN_NEWS_TYPE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={selectItemClassName}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Danh mục hiển thị" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả danh mục
</SelectItem>
{categoryOptions.map((category) => (
<SelectItem
key={category.id}
value={category.id}
className={selectItemClassName}
>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Trạng thái" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả trạng thái
</SelectItem>
<SelectItem value="visible" className={selectItemClassName}>
Đang hiển thị
</SelectItem>
<SelectItem value="hidden" className={selectItemClassName}>
Đang ẩn
</SelectItem>
</SelectContent>
</Select>
</div>
}
>
<div className="overflow-x-auto">
<Table className="min-w-[1250px] table-fixed">
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-[260px] py-4 text-center text-white">
Tiêu đề
</TableHead>
<TableHead className="w-[140px] py-4 text-center text-white">
Hình ảnh đại diện
</TableHead>
<TableHead className="w-40 py-4 text-center text-white">
Loại bài viết
</TableHead>
<TableHead className="w-[190px] py-4 text-center text-white">
Danh mục hiển thị
</TableHead>
<TableHead className="w-[170px] py-4 text-center text-white">
Ngày xuất bản
</TableHead>
<TableHead className="w-[170px] py-4 text-center text-white">
Ngày hết hạn
</TableHead>
<TableHead className="w-[120px] py-4 text-center text-white">
Hiển thị
</TableHead>
<TableHead className="w-[100px] py-4 text-center text-white">
Thao tác
</TableHead>
</TableRow>
) : rows.map((news: Record<string, any>, idx: number) => (
<TableRow key={news.id}>
<TableCell className="text-gray-400 text-sm">{(page - 1) * pageSize + idx + 1}</TableCell>
<TableCell className="max-w-xs">
<p className="font-medium text-sm truncate">{news.title}</p>
{news.external_link && (
<p className="text-xs text-blue-400 truncate">{news.external_link}</p>
)}
</TableCell>
<TableCell>
{news.category?.name ? (
<Badge variant="secondary" className="text-xs">{news.category.name}</Badge>
) : <span className="text-gray-300 text-xs"></span>}
</TableCell>
<TableCell className="text-sm text-gray-500 max-w-[120px] truncate">
{news.pageConfig?.name || '—'}
</TableCell>
<TableCell className="text-center">
{news.is_active ? (
<Eye size={16} className="inline text-green-500" />
) : (
<EyeOff size={16} className="inline text-gray-300" />
)}
</TableCell>
<TableCell className="text-gray-400 text-sm">
{news.release_at ? dayjs(news.release_at).format('DD/MM/YYYY') : '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="icon" variant="ghost" asChild>
<Link href={`/admin/news/${news.id}`}><Pencil size={15} /></Link>
</Button>
<Button
size="icon"
variant="ghost"
className="text-red-500 hover:text-red-600"
onClick={() => setDeleteItem({ id: String(news.id), title: String(news.title) })}
</TableHeader>
<TableBody>
{!ready ? (
<AdminNewsTableLoading />
) : filteredItems.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-12 text-center text-sm text-gray-700">
Không có bài viết nào phù hợp.
</TableCell>
</TableRow>
) : (
filteredItems.map((item, index) => {
const category = headerItems.find((entry) => entry.id === item.header_category_id);
return (
<TableRow
key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"}
>
<Trash2 size={15} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Trước</Button>
<span className="text-sm text-gray-500 self-center">{page} / {totalPages}</span>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Sau</Button>
<TableCell className="py-4">
<div className="space-y-2">
<p className="line-clamp-2 text-sm font-semibold text-black">
{item.title}
</p>
{item.type === "tintuc" && item.is_featured ? (
<span className="inline-flex items-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-xs font-medium text-[#063e8e]">
<Star className="mr-1.5 h-3.5 w-3.5 fill-current" />
Tin nổi bật
</span>
) : null}
</div>
</TableCell>
<TableCell className="text-center">
<div className="relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/3">
{item.thumbnail ? (
<SafeNextImage
src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-gray-700">
Không có ảnh
</div>
)}
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{ADMIN_NEWS_TYPE_LABELS[item.type]}
</Badge>
</TableCell>
<TableCell className="text-center text-sm text-gray-700">
{category?.name || "—"}
</TableCell>
<TableCell className="text-center text-sm text-gray-700">
{formatDateTime(item.published_at)}
</TableCell>
<TableCell className="text-center text-sm text-gray-700">
{formatDateTime(item.expired_at)}
</TableCell>
<TableCell className="text-center">
{item.is_hidden ? (
<span className="inline-flex items-center rounded-full border border-gray-300 px-2.5 py-1 text-sm text-gray-700">
<EyeOff className="mr-1.5 h-3.5 w-3.5" />
Ẩn
</span>
) : (
<span className="inline-flex items-center rounded-full border border-[#063e8e]/20 bg-[#063e8e]/10 px-2.5 py-1 text-sm text-[#063e8e]">
Hiển thị
</span>
)}
</TableCell>
<TableCell className="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
asChild
className="text-gray-700 focus:text-[#063e8e]"
>
<Link href={`/admin/news/${item.id}`}>
<Edit className="mr-2 h-4 w-4" />
Chỉnh sửa
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-gray-700 focus:text-[#063e8e]"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
)}
</AdminTableLayout>
{deleteItem && <DeleteConfirm item={deleteItem} onClose={() => setDeleteItem(null)} />}
<AdminDeleteDialog
open={!!deleteTarget}
title="Xóa bài viết"
description={
deleteTarget ? (
<>
Bài viết <strong>{deleteTarget.title}</strong> sẽ bị xóa khỏi dữ liệu quản trị.
</>
) : null
}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={handleDelete}
/>
</div>
);
}
......@@ -12,6 +12,7 @@ interface AdminTableLayoutProps {
actionIcon?: React.ReactNode;
actionDisabled?: boolean;
children: React.ReactNode;
filters?: React.ReactNode;
onSearchChange: (value: string) => void;
onActionClick?: () => void;
}
......@@ -23,18 +24,22 @@ export function AdminTableLayout({
actionIcon,
actionDisabled = false,
children,
filters,
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"
/>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center">
<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"
/>
{filters}
</div>
{actionLabel ? (
<Button
......
"use client";
import * as React from "react";
import { ImagePlus, Search, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import {
AdminMediaItem,
createAdminMediaId,
persistAdminMediaItems,
readAdminMediaItems,
} from "@/mockdata/admin-news";
import { cn } from "@/lib/utils";
interface AdminImagePickerProps {
open: boolean;
selectedId?: string | null;
onOpenChange: (open: boolean) => void;
onSelect: (item: AdminMediaItem) => void;
}
function formatFileSize(size: number) {
if (!size) return "Ảnh hệ thống";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
export function AdminImagePicker({
open,
selectedId,
onOpenChange,
onSelect,
}: AdminImagePickerProps) {
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [search, setSearch] = React.useState("");
const [items, setItems] = React.useState<AdminMediaItem[]>([]);
React.useEffect(() => {
if (!open) return;
setItems(readAdminMediaItems());
}, [open]);
const visibleItems = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) => {
return (
item.name.toLowerCase().includes(keyword) ||
item.alt.toLowerCase().includes(keyword) ||
item.url.toLowerCase().includes(keyword)
);
});
}, [items, search]);
const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const nextItem: AdminMediaItem = {
id: createAdminMediaId(),
name: file.name,
alt: file.name.replace(/\.[^.]+$/, ""),
url: typeof reader.result === "string" ? reader.result : "/img-error.png",
mime: file.type || "image/*",
size: file.size,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
source: "upload",
};
const nextItems = [nextItem, ...items];
setItems(nextItems);
persistAdminMediaItems(nextItems);
onSelect(nextItem);
onOpenChange(false);
};
reader.readAsDataURL(file);
event.target.value = "";
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] max-w-5xl overflow-hidden border-[#063e8e]/15 bg-white p-0">
<DialogHeader className="border-b border-[#063e8e]/10 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle className="text-xl font-semibold text-black">
Thư viện hình ảnh
</DialogTitle>
<DialogDescription className="mt-1 text-sm text-gray-700">
Chọn ảnh có sẵn hoặc tải thêm ảnh mới cho bài viết.
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="flex flex-col gap-4 border-b border-[#063e8e]/10 px-6 py-4 lg:flex-row lg:items-center lg:justify-between">
<div className="relative w-full lg:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-700" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Tìm kiếm hình ảnh..."
className="border-[#063e8e]/15 bg-white pl-9 text-gray-700 placeholder:text-gray-700"
/>
</div>
<div className="flex items-center gap-3">
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleUpload}
/>
<Button
type="button"
onClick={() => inputRef.current?.click()}
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Upload className="mr-2 h-4 w-4" />
Tải hình ảnh
</Button>
</div>
</div>
<div className="max-h-[60vh] overflow-y-auto px-6 py-6">
{visibleItems.length === 0 ? (
<div className="flex min-h-[240px] flex-col items-center justify-center rounded-2xl border border-dashed border-[#063e8e]/15 bg-[#063e8e]/[0.03] px-6 text-center">
<ImagePlus className="mb-3 h-10 w-10 text-[#063e8e]" />
<p className="text-base font-medium text-black">Chưa có hình ảnh phù hợp</p>
<p className="mt-1 text-sm text-gray-700">
Hãy thử từ khóa khác hoặc tải thêm hình ảnh vào thư viện.
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
{visibleItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onSelect(item);
onOpenChange(false);
}}
className={cn(
"group overflow-hidden rounded-2xl border bg-white text-left transition-all",
item.id === selectedId
? "border-[#063e8e] shadow-[0_0_0_2px_rgba(6,62,142,0.12)]"
: "border-[#063e8e]/10 hover:border-[#063e8e]/40 hover:shadow-sm",
)}
>
<div className="relative aspect-[4/3] overflow-hidden bg-[#063e8e]/[0.04]">
<SafeNextImage
src={item.url}
alt={item.alt || item.name}
fill
className="object-cover transition duration-300 group-hover:scale-[1.02]"
/>
{item.id === selectedId ? (
<div className="absolute right-3 top-3 rounded-full bg-[#063e8e] px-2 py-1 text-xs font-medium text-white">
Đã chọn
</div>
) : null}
</div>
<div className="space-y-1 px-4 py-3">
<p className="line-clamp-1 text-sm font-medium text-black">{item.name}</p>
<div className="flex items-center justify-between gap-2 text-xs text-gray-700">
<span>{formatFileSize(item.size)}</span>
<span>{item.source === "upload" ? "Tải lên" : "Hệ thống"}</span>
</div>
</div>
</button>
))}
</div>
)}
</div>
<div className="flex justify-end border-t border-[#063e8e]/10 px-6 py-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<X className="mr-2 h-4 w-4" />
Đóng
</Button>
</div>
</DialogContent>
</Dialog>
);
}
"use client";
import * as React from "react";
import dayjs from "dayjs";
import { ArrowLeft, Save, Upload, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminPostContentEditor } from "@/components/admin/post-content-editor";
import { AdminRichTextEditor } from "@/components/admin/rich-text-editor";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
type AdminMediaItem,
type AdminNewsFormValues,
type AdminNewsImageRef,
type AdminNewsItem,
type AdminNewsType,
ADMIN_NEWS_TYPE_OPTIONS,
cloneAdminNewsFormValues,
createAdminNewsId,
persistAdminNewsItems,
readAdminNewsItems,
resolveAdminNewsType,
slugifyAdminNews,
} from "@/mockdata/admin-news";
import {
type HeaderCategoryItem,
type HeaderCategoryTreeItem,
HEADER_CONFIG_STORAGE_KEY,
buildHeaderCategoryTree,
getHeaderCategorySeed,
normalizeHeaderCategories,
} from "@/mockdata/header-config";
interface AdminNewsFormProps {
newsId?: string;
presetHeaderCategoryId?: string;
lockedType?: AdminNewsType;
returnPath?: string;
}
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
const readOnlyFieldClassName =
"border-[#063e8e]/10 bg-[#063e8e]/[0.03] text-gray-700 placeholder:text-gray-700";
const selectTriggerClassName =
"border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30";
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName =
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
function readHeaderConfig() {
if (typeof window === "undefined") {
return getHeaderCategorySeed();
}
const raw = window.localStorage.getItem(HEADER_CONFIG_STORAGE_KEY);
if (!raw) return getHeaderCategorySeed();
try {
const parsed = JSON.parse(raw) as HeaderCategoryItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getHeaderCategorySeed();
}
return normalizeHeaderCategories(parsed);
} catch {
return getHeaderCategorySeed();
}
}
function flattenHeaderTree(
items: HeaderCategoryTreeItem[],
depth = 0,
): Array<{
id: string;
name: string;
type: HeaderCategoryItem["type"];
depth: number;
}> {
return items.flatMap((item) => [
{ id: item.id, name: item.name, type: item.type, depth },
...flattenHeaderTree(item.children, depth + 1),
]);
}
function isCategoryCompatible(
headerType: HeaderCategoryItem["type"],
postType: AdminNewsType | "",
) {
if (!postType) return true;
if (headerType === "news") return postType !== "baiviettrang";
if (headerType === "page") return postType === "baiviettrang";
return true;
}
function toImageRef(item: AdminMediaItem): AdminNewsImageRef {
return {
id: item.id,
name: item.name,
alt: item.alt,
url: item.url,
};
}
function FormSection({
title,
description,
children,
}: {
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-5 shadow-sm">
<div className="mb-4">
<h2 className="text-base font-semibold text-[#063e8e]">{title}</h2>
{description ? (
<p className="mt-1 text-sm text-gray-700">{description}</p>
) : null}
</div>
{children}
</div>
);
}
export function AdminNewsForm({
newsId,
presetHeaderCategoryId,
lockedType,
returnPath,
}: AdminNewsFormProps) {
const router = useRouter();
const isCreate = !newsId || newsId === "new";
const backPath = returnPath || "/admin/news";
const isHeaderCategoryLocked = Boolean(presetHeaderCategoryId);
const isTypeLocked = Boolean(lockedType);
const [items, setItems] = React.useState<AdminNewsItem[]>([]);
const [headerItems, setHeaderItems] = React.useState<HeaderCategoryItem[]>([]);
const [form, setForm] = React.useState<AdminNewsFormValues | null>(null);
const [pickerOpen, setPickerOpen] = React.useState(false);
React.useEffect(() => {
const nextNewsItems = readAdminNewsItems();
const nextHeaderItems = readHeaderConfig();
const currentItem = nextNewsItems.find((item) => item.id === newsId) ?? null;
setItems(nextNewsItems);
setHeaderItems(nextHeaderItems);
if (isCreate) {
const now = new Date().toISOString();
setForm({
...cloneAdminNewsFormValues(),
type: lockedType ?? "tintuc",
header_category_id: presetHeaderCategoryId ?? "",
created_at: now,
updated_at: now,
});
return;
}
if (!currentItem) {
setForm(null);
return;
}
if (
presetHeaderCategoryId &&
currentItem.header_category_id !== presetHeaderCategoryId
) {
setForm(null);
return;
}
if (lockedType && currentItem.type !== lockedType) {
setForm(null);
return;
}
setForm(cloneAdminNewsFormValues(currentItem));
}, [isCreate, lockedType, newsId, presetHeaderCategoryId]);
const headerOptions = React.useMemo(() => {
return flattenHeaderTree(buildHeaderCategoryTree(headerItems)).filter(
(item) => item.type === "news" || item.type === "page",
);
}, [headerItems]);
const selectedHeaderCategory = React.useMemo(() => {
return headerItems.find((item) => item.id === form?.header_category_id) ?? null;
}, [form?.header_category_id, headerItems]);
const availableSearchTags = React.useMemo(() => {
if (!selectedHeaderCategory || selectedHeaderCategory.type !== "news") return [];
return selectedHeaderCategory.tagsearch_values ?? [];
}, [selectedHeaderCategory]);
const articlePageAlreadyUsed = React.useMemo(() => {
if (!form?.header_category_id || form.type !== "baiviettrang") return false;
return items.some(
(item) =>
item.header_category_id === form.header_category_id &&
item.type === "baiviettrang" &&
item.id !== newsId,
);
}, [form?.header_category_id, form?.type, items, newsId]);
const handleField = <K extends keyof AdminNewsFormValues>(
key: K,
value: AdminNewsFormValues[K],
) => {
setForm((current) => {
if (!current) return current;
return { ...current, [key]: value };
});
};
const handleTitleChange = (value: string) => {
setForm((current) => {
if (!current) return current;
return {
...current,
title: value,
slug: slugifyAdminNews(value),
};
});
};
const handleTypeChange = (value: string) => {
const nextType = resolveAdminNewsType(value) ?? "tintuc";
setForm((current) => {
if (!current) return current;
const compatibleHeader = headerOptions.find(
(option) =>
option.id === current.header_category_id &&
isCategoryCompatible(option.type, nextType),
);
return {
...current,
type: nextType,
header_category_id: compatibleHeader ? current.header_category_id : "",
category_ids: nextType === "baiviettrang" ? [] : current.category_ids,
tagsearch_values: nextType === "baiviettrang" ? [] : current.tagsearch_values,
is_featured: nextType === "tintuc" ? current.is_featured : false,
};
});
};
const handleHeaderCategoryChange = (value: string) => {
const nextCategory = headerItems.find((item) => item.id === value) ?? null;
const nextSearchTags =
nextCategory?.type === "news" ? nextCategory.tagsearch_values ?? [] : [];
setForm((current) => {
if (!current) return current;
return {
...current,
header_category_id: value,
tagsearch_values: current.tagsearch_values.filter((item) =>
nextSearchTags.includes(item),
),
};
});
};
const handleToggleSearchTag = (value: string, checked: boolean) => {
setForm((current) => {
if (!current) return current;
return {
...current,
tagsearch_values: checked
? [...current.tagsearch_values, value]
: current.tagsearch_values.filter((item) => item !== value),
};
});
};
const handleThumbnailSelect = (item: AdminMediaItem) => {
handleField("thumbnail", toImageRef(item));
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form) return;
if (!form.title.trim()) {
toast.error("Tiêu đề bài viết là bắt buộc");
return;
}
if (!form.slug.trim()) {
toast.error("Slug bài viết là bắt buộc");
return;
}
if (!form.type) {
toast.error("Vui lòng chọn loại bài viết");
return;
}
if (!form.header_category_id) {
toast.error("Vui lòng chọn danh mục hiển thị");
return;
}
if (articlePageAlreadyUsed) {
toast.error("Danh mục bài viết trang chỉ được tạo 1 bài viết");
return;
}
const now = new Date().toISOString();
const currentId =
items.find((item) => item.id === newsId)?.id ?? createAdminNewsId();
const payload: AdminNewsItem = {
id: isCreate ? currentId : (newsId as string),
title: form.title.trim(),
slug: slugifyAdminNews(form.slug.trim()),
summary: form.summary,
type: form.type,
header_category_id: form.header_category_id,
category_ids: form.type === "baiviettrang" ? [] : form.category_ids,
tagsearch_values:
form.type === "baiviettrang"
? []
: form.tagsearch_values.filter((item) => availableSearchTags.includes(item)),
is_featured: form.type === "tintuc" ? form.is_featured : false,
thumbnail: form.thumbnail,
is_hidden: form.is_hidden,
created_at: form.created_at || now,
updated_at: now,
published_at: form.published_at,
expired_at: form.expired_at,
started_at: form.started_at,
ended_at: form.ended_at,
registration_deadline: form.registration_deadline,
location: form.location.trim(),
participation_fee: form.participation_fee.trim(),
post_content: form.post_content.map((section, index) => ({
...section,
position: index + 1,
})),
};
const nextItems = isCreate
? [payload, ...items]
: items.map((item) => (item.id === payload.id ? payload : item));
persistAdminNewsItems(nextItems);
setItems(nextItems);
toast.success(isCreate ? "Đã tạo bài viết" : "Đã cập nhật bài viết");
router.push(backPath);
};
if (form === null && !isCreate) {
return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white px-6 py-12 text-center">
<p className="text-lg font-semibold text-black">Không tìm thấy bài viết</p>
<p className="mt-2 text-sm text-gray-700">
{presetHeaderCategoryId
? "Bài viết bạn muốn chỉnh sửa không tồn tại hoặc không thuộc danh mục hiện tại."
: "Bài viết bạn muốn chỉnh sửa không tồn tại trong dữ liệu hiện tại."}
</p>
<Button
asChild
className="mt-5 bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Link href={backPath}>Quay lại danh sách</Link>
</Button>
</div>
);
}
if (!form) {
return (
<div className="rounded-2xl border border-[#063e8e]/15 bg-white px-6 py-12 text-center text-sm text-gray-700">
Đang tải dữ liệu...
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="icon"
asChild
className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Link href={backPath}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<FormSection title={isCreate ? "Tạo bài viết" : "Chỉnh sửa bài viết"}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-1.5 block text-gray-700">Ngày tạo</Label>
<Input
value={
form.created_at
? dayjs(form.created_at).format("DD/MM/YYYY HH:mm")
: ""
}
readOnly
className={readOnlyFieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Ngày cập nhật</Label>
<Input
value={
form.updated_at
? dayjs(form.updated_at).format("DD/MM/YYYY HH:mm")
: ""
}
readOnly
className={readOnlyFieldClassName}
/>
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">
Tiêu đề <span className="text-red-600">*</span>
</Label>
<Input
value={form.title}
onChange={(event) => handleTitleChange(event.target.value)}
placeholder="Nhập tiêu đề bài viết"
className={fieldClassName}
/>
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-gray-700">
Slug <span className="text-red-600">*</span>
</Label>
<Input
value={form.slug}
onChange={(event) => handleField("slug", event.target.value)}
placeholder="slug-bai-viet"
className={fieldClassName}
/>
</div>
</div>
</FormSection>
<FormSection title="Thể loại, hình ảnh và hiển thị">
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[300px_minmax(0,1fr)]">
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4">
<div className="space-y-3">
<div>
<Label className="block text-gray-700">Hình ảnh đại diện</Label>
</div>
<div className="relative overflow-hidden rounded-2xl border border-[#063e8e]/15 bg-white">
<div className="relative aspect-[16/11]">
{form.thumbnail ? (
<SafeNextImage
src={form.thumbnail.url}
alt={form.thumbnail.alt || form.thumbnail.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-gray-700">
Chưa chọn hình đại diện
</div>
)}
</div>
{form.thumbnail ? (
<button
type="button"
onClick={() => handleField("thumbnail", null)}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/95 text-gray-700 shadow-sm transition hover:text-red-600"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<Button
type="button"
variant="outline"
onClick={() => setPickerOpen(true)}
className="w-full border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Upload className="mr-2 h-4 w-4" />
{form.thumbnail ? "Đổi hình đại diện" : "Chọn hình đại diện"}
</Button>
</div>
</div>
<div className="rounded-xl border border-[#063e8e]/15 bg-white p-4">
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-1.5 block text-gray-700">
Loại bài viết <span className="text-red-600">*</span>
</Label>
<Select
value={form.type}
onValueChange={handleTypeChange}
disabled={isTypeLocked}
>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Chọn loại bài viết" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
{ADMIN_NEWS_TYPE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={selectItemClassName}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">
Danh mục hiển thị
</Label>
<Select
value={form.header_category_id}
onValueChange={handleHeaderCategoryChange}
disabled={isHeaderCategoryLocked}
>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Chọn danh mục hiển thị" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
{headerOptions
.filter((option) => isCategoryCompatible(option.type, form.type))
.map((option) => (
<SelectItem
key={option.id}
value={option.id}
className={selectItemClassName}
>
{`${"-- ".repeat(option.depth)}${option.name}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-1.5 block text-gray-700">Ngày xuất bản</Label>
<Input
type="datetime-local"
value={form.published_at}
onChange={(event) =>
handleField("published_at", event.target.value)
}
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Ngày hết hạn</Label>
<Input
type="datetime-local"
value={form.expired_at}
onChange={(event) =>
handleField("expired_at", event.target.value)
}
className={fieldClassName}
/>
</div>
</div>
<div className="rounded-xl bg-[#063e8e]/[0.04] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-700">
Trạng thái hiển thị (bài viết sẽ được lưu nhưng không hiển thị trên website)
</p>
<p className="mt-1 text-sm font-medium text-[#063e8e]">
{form.is_hidden ? "Đang ẩn" : "Đang hiển thị"}
</p>
</div>
<Switch
checked={!form.is_hidden}
onCheckedChange={(checked) => handleField("is_hidden", !checked)}
/>
</div>
</div>
{form.type === "tintuc" ? (
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-700">Tin nổi bật</p>
<p className="mt-1 text-sm text-gray-700">
Đánh dấu để ưu tiên hiển thị như một tin nổi bật.
</p>
</div>
<Switch
checked={form.is_featured}
onCheckedChange={(checked) => handleField("is_featured", checked)}
/>
</div>
</div>
) : null}
{availableSearchTags.length > 0 ? (
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4">
<Label className="mb-3 block text-gray-700">Tag tìm kiếm</Label>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{availableSearchTags.map((item) => (
<label
key={item}
className="flex items-center gap-3 rounded-lg border border-[#063e8e]/10 bg-white px-3 py-2"
>
<Checkbox
checked={form.tagsearch_values.includes(item)}
onCheckedChange={(checked) =>
handleToggleSearchTag(item, checked === true)
}
className="border-[#063e8e]/30 data-[state=checked]:border-[#063e8e] data-[state=checked]:bg-[#063e8e]"
/>
<span className="text-sm text-gray-700">{item}</span>
</label>
))}
</div>
</div>
) : null}
</div>
</div>
</div>
</FormSection>
<FormSection
title="Thông tin sự kiện (tùy chọn)"
description="Nhóm các trường dành cho bài viết có tính chất sự kiện hoặc chương trình."
>
<div className="rounded-xl border border-[#063e8e]/15 p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<div>
<Label className="mb-1.5 block text-gray-700">Ngày bắt đầu</Label>
<Input
type="datetime-local"
value={form.started_at}
onChange={(event) => handleField("started_at", event.target.value)}
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Ngày kết thúc</Label>
<Input
type="datetime-local"
value={form.ended_at}
onChange={(event) => handleField("ended_at", event.target.value)}
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">
Ngày hạn đăng ký
</Label>
<Input
type="datetime-local"
value={form.registration_deadline}
onChange={(event) =>
handleField("registration_deadline", event.target.value)
}
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Địa điểm</Label>
<Input
value={form.location}
onChange={(event) => handleField("location", event.target.value)}
placeholder="Nhập địa điểm"
className={fieldClassName}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Phí tham dự</Label>
<Input
value={form.participation_fee}
onChange={(event) =>
handleField("participation_fee", event.target.value)
}
placeholder="Ví dụ: Miễn phí hoặc 500.000 VNĐ"
className={fieldClassName}
/>
</div>
</div>
</div>
</FormSection>
<FormSection title="Tóm tắt">
<AdminRichTextEditor
value={form.summary}
onChange={(value) => handleField("summary", value)}
placeholder="Nhập tóm tắt bài viết"
minHeight={180}
/>
</FormSection>
<FormSection
title="Nội dung bài viết"
description="Thêm section văn bản và hình ảnh theo đúng cấu trúc nội dung mong muốn."
>
<AdminPostContentEditor
sections={form.post_content}
onChange={(sections) => handleField("post_content", sections)}
/>
</FormSection>
<div className="flex flex-wrap items-center justify-end gap-3">
<Button
type="button"
variant="outline"
asChild
className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Link href={backPath}>Hủy</Link>
</Button>
<Button
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
type="submit"
>
<Save className="mr-2 h-4 w-4" />
{isCreate ? "Lưu bài viết" : "Cập nhật bài viết"}
</Button>
</div>
</form>
<AdminImagePicker
open={pickerOpen}
selectedId={form.thumbnail?.id}
onOpenChange={setPickerOpen}
onSelect={handleThumbnailSelect}
/>
</div>
);
}
"use client";
import * as React from "react";
import { Image as ImageIcon, Plus, Type, Upload, X } from "lucide-react";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminRichTextEditor } from "@/components/admin/rich-text-editor";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
type AdminMediaItem,
type AdminNewsContentSection,
createAdminNewsSectionId,
} from "@/mockdata/admin-news";
interface AdminPostContentEditorProps {
sections: AdminNewsContentSection[];
onChange: (sections: AdminNewsContentSection[]) => void;
}
export function AdminPostContentEditor({
sections,
onChange,
}: AdminPostContentEditorProps) {
const [pickerState, setPickerState] = React.useState<{
open: boolean;
sectionId: string | null;
position: number;
selectedId: string | null;
}>({
open: false,
sectionId: null,
position: 0,
selectedId: null,
});
const updateSection = React.useCallback(
(
sectionId: string,
updater: (section: AdminNewsContentSection) => AdminNewsContentSection,
) => {
onChange(sections.map((section) => (section.id === sectionId ? updater(section) : section)));
},
[onChange, sections],
);
const appendSection = (type: "text" | "image") => {
const nextSection: AdminNewsContentSection = {
id: createAdminNewsSectionId(),
type,
position: sections.length + 1,
content: "",
image_columns: 2,
image_rows: 1,
images: [],
};
onChange([...sections, nextSection]);
};
const removeSection = (sectionId: string) => {
const nextSections = sections
.filter((section) => section.id !== sectionId)
.map((section, index) => ({
...section,
position: index + 1,
}));
onChange(nextSections);
};
const updateGrid = (sectionId: string, columns: number, rows: number) => {
updateSection(sectionId, (section) => {
const maxImages = columns * rows;
return {
...section,
image_columns: columns,
image_rows: rows,
images: section.images.slice(0, maxImages).map((image, index) => ({
...image,
position: index + 1,
})),
};
});
};
const handleSelectImage = (item: AdminMediaItem) => {
if (!pickerState.sectionId || pickerState.position <= 0) return;
updateSection(pickerState.sectionId, (section) => {
const nextImages = section.images.filter((image) => image.position !== pickerState.position);
nextImages.push({
position: pickerState.position,
image: {
id: item.id,
name: item.name,
alt: item.alt,
url: item.url,
},
});
return {
...section,
images: nextImages.sort((left, right) => left.position - right.position),
};
});
setPickerState({
open: false,
sectionId: null,
position: 0,
selectedId: null,
});
};
const handleRemoveImage = (sectionId: string, position: number) => {
updateSection(sectionId, (section) => ({
...section,
images: section.images
.filter((image) => image.position !== position)
.map((image, index) => ({
...image,
position: index + 1,
})),
}));
};
return (
<div className="space-y-5">
{sections.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[#063e8e]/20 bg-white px-6 py-10 text-center">
<p className="text-base font-medium text-black">Chưa có nội dung bài viết</p>
<p className="mt-1 text-sm text-gray-700">
Bắt đầu bằng section văn bản hoặc section hình ảnh.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => appendSection("text")}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<Type className="mr-2 h-4 w-4" />
Thêm section văn bản
</Button>
<Button
type="button"
variant="outline"
onClick={() => appendSection("image")}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<ImageIcon className="mr-2 h-4 w-4" />
Thêm section hình ảnh
</Button>
</div>
</div>
) : null}
{sections.map((section) => {
const maxSlots = section.image_columns * section.image_rows;
return (
<div
key={section.id}
className="rounded-3xl border border-[#063e8e]/15 bg-white shadow-sm"
>
<div className="flex items-center justify-between gap-4 border-b border-[#063e8e]/10 px-5 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#063e8e]/10 text-[#063e8e]">
{section.type === "text" ? (
<Type className="h-4 w-4" />
) : (
<ImageIcon className="h-4 w-4" />
)}
</div>
<div>
<p className="text-sm font-medium text-gray-700">Section {section.position}</p>
<p className="text-base font-semibold text-black">
{section.type === "text" ? "Văn bản" : "Hình ảnh"}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeSection(section.id)}
className="text-gray-700 hover:bg-red-50 hover:text-red-600"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-5 px-5 py-5">
{section.type === "text" ? (
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">Nội dung</Label>
<AdminRichTextEditor
value={section.content}
onChange={(value) =>
updateSection(section.id, (current) => ({
...current,
content: value,
}))
}
placeholder="Nhập nội dung section văn bản..."
minHeight={240}
/>
</div>
) : (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">Số cột ảnh</Label>
<Select
value={String(section.image_columns)}
onValueChange={(value) =>
updateGrid(section.id, Number(value), section.image_rows)
}
>
<SelectTrigger className="border-[#063e8e]/15 text-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 cột</SelectItem>
<SelectItem value="2">2 cột</SelectItem>
<SelectItem value="3">3 cột</SelectItem>
<SelectItem value="4">4 cột</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">Số hàng ảnh</Label>
<Select
value={String(section.image_rows)}
onValueChange={(value) =>
updateGrid(section.id, section.image_columns, Number(value))
}
>
<SelectTrigger className="border-[#063e8e]/15 text-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 hàng</SelectItem>
<SelectItem value="2">2 hàng</SelectItem>
<SelectItem value="3">3 hàng</SelectItem>
<SelectItem value="4">4 hàng</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label className="text-sm font-medium text-gray-700">
Hình ảnh ({section.images.length}/{maxSlots})
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setPickerState({
open: true,
sectionId: section.id,
position: Math.min(section.images.length + 1, maxSlots),
selectedId: null,
})
}
disabled={section.images.length >= maxSlots}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<Plus className="mr-2 h-4 w-4" />
Thêm ảnh
</Button>
</div>
<div
className="grid gap-3 rounded-2xl border border-[#063e8e]/10 bg-[#063e8e]/[0.03] p-4"
style={{
gridTemplateColumns: `repeat(${section.image_columns}, minmax(0, 1fr))`,
}}
>
{Array.from({ length: maxSlots }).map((_, index) => {
const position = index + 1;
const currentImage = section.images.find(
(image) => image.position === position,
);
return (
<div
key={`${section.id}-${position}`}
role="button"
tabIndex={0}
onClick={() =>
setPickerState({
open: true,
sectionId: section.id,
position,
selectedId: currentImage?.image.id ?? null,
})
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setPickerState({
open: true,
sectionId: section.id,
position,
selectedId: currentImage?.image.id ?? null,
});
}
}}
className="group relative flex aspect-square cursor-pointer items-center justify-center overflow-hidden rounded-2xl border border-dashed border-[#063e8e]/20 bg-white text-center transition hover:border-[#063e8e]/40"
>
{currentImage ? (
<>
<SafeNextImage
src={currentImage.image.url}
alt={currentImage.image.alt || currentImage.image.name}
fill
className="object-cover"
/>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleRemoveImage(section.id, position);
}}
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-white/95 text-gray-700 shadow-sm transition hover:text-red-600"
>
<X className="h-3.5 w-3.5" />
</button>
</>
) : (
<div className="px-3 text-center">
<Upload className="mx-auto mb-2 h-5 w-5 text-[#063e8e]" />
<p className="text-xs font-medium text-gray-700">
Chọn ảnh {position}
</p>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
)}
</div>
</div>
);
})}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => appendSection("text")}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<Type className="mr-2 h-4 w-4" />
Thêm section văn bản
</Button>
<Button
type="button"
variant="outline"
onClick={() => appendSection("image")}
className="border-[#063e8e]/15 text-gray-700 hover:bg-[#063e8e]/[0.04]"
>
<ImageIcon className="mr-2 h-4 w-4" />
Thêm section hình ảnh
</Button>
</div>
<AdminImagePicker
open={pickerState.open}
selectedId={pickerState.selectedId}
onOpenChange={(open) =>
setPickerState((current) => ({
...current,
open,
...(open ? {} : { sectionId: null, position: 0, selectedId: null }),
}))
}
onSelect={handleSelectImage}
/>
</div>
);
}
"use client";
import dynamic from "next/dynamic";
import { useMemo, useRef } from "react";
import type { JoditEditorProps } from "jodit-react";
const JoditEditor = dynamic(() => import("jodit-react"), {
ssr: false,
loading: () => (
<div className="flex min-h-[260px] items-center justify-center rounded-2xl border border-[#063e8e]/15 bg-white text-sm text-gray-500">
Đang tải trình soạn thảo...
</div>
),
});
interface AdminRichTextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: number;
readOnly?: boolean;
}
export function AdminRichTextEditor({
value,
onChange,
placeholder = "Nhập nội dung...",
className = "",
minHeight = 260,
readOnly = false,
}: AdminRichTextEditorProps) {
const editor = useRef(null);
const config: JoditEditorProps["config"] = useMemo(
() => ({
readonly: readOnly,
placeholder,
minHeight,
language: "vi",
toolbarButtonSize: "middle",
uploader: {
insertImageAsBase64URI: true,
},
buttons: [
"bold",
"italic",
"underline",
"strikethrough",
"|",
"ul",
"ol",
"|",
"outdent",
"indent",
"|",
"font",
"fontsize",
"brush",
"paragraph",
"|",
"image",
"table",
"link",
"|",
"align",
"undo",
"redo",
"|",
"hr",
"eraser",
"copyformat",
"|",
"symbol",
"fullsize",
],
buttonsXS: [
"bold",
"italic",
"|",
"ul",
"ol",
"|",
"image",
"link",
"table",
"|",
"align",
"|",
"undo",
"redo",
"|",
"dots",
],
askBeforePasteHTML: false,
askBeforePasteFromWord: false,
defaultActionOnPaste: "insert_as_html",
enter: "p",
showPlaceholder: false,
}),
[minHeight, placeholder, readOnly],
);
return (
<div className={className}>
<style jsx global>{`
.admin-rich-text-editor .jodit-container {
border-radius: 1rem;
border: 1px solid rgba(6, 62, 142, 0.15);
overflow: hidden;
background: #ffffff;
}
.admin-rich-text-editor .jodit-toolbar__box {
border-bottom: 1px solid rgba(6, 62, 142, 0.12);
background: rgba(6, 62, 142, 0.04);
padding: 10px;
}
.admin-rich-text-editor .jodit-workplace {
min-height: ${minHeight}px;
}
.admin-rich-text-editor .jodit-wysiwyg {
min-height: ${minHeight}px;
padding: 16px 18px;
color: #111827;
font-size: 14px;
line-height: 1.8;
}
.admin-rich-text-editor .jodit-wysiwyg p {
margin-bottom: 1em;
}
.admin-rich-text-editor .jodit-wysiwyg img {
max-width: 100%;
height: auto;
border-radius: 0.75rem;
}
.admin-rich-text-editor .jodit-placeholder {
color: #374151 !important;
}
`}</style>
<div className="admin-rich-text-editor">
<JoditEditor
ref={editor}
value={value}
config={config}
onBlur={(nextContent) => onChange(nextContent)}
onChange={() => undefined}
/>
</div>
</div>
);
}
"use client";
import Image, { type ImageProps } from "next/image";
import * as React from "react";
interface SafeNextImageProps extends Omit<ImageProps, "src"> {
src?: string | null;
fallbackSrc?: string;
}
export function SafeNextImage({
src,
alt,
fallbackSrc = "/img-error.png",
...props
}: SafeNextImageProps) {
const [currentSrc, setCurrentSrc] = React.useState(src || fallbackSrc);
const [hasFailed, setHasFailed] = React.useState(false);
React.useEffect(() => {
setCurrentSrc(src || fallbackSrc);
setHasFailed(false);
}, [fallbackSrc, src]);
return (
<Image
{...props}
src={currentSrc}
alt={alt}
onError={() => {
if (hasFailed || currentSrc === fallbackSrc) return;
setHasFailed(true);
setCurrentSrc(fallbackSrc);
}}
unoptimized
/>
);
}
......@@ -28,17 +28,17 @@ type NavItem = {
};
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/admin/dashboard', icon: BarChart3 },
// { name: 'Dashboard', href: '/admin/dashboard', icon: BarChart3 },
{ name: 'Cấu hình danh mục', href: '/admin/header-config', icon: Layers },
{ name: 'Quản lý bài viết', href: '/admin/news', icon: Newspaper },
{ name: 'Quản lý hội viên', href: '/admin/members', icon: Users },
{ name: 'Quản lý đối tác', href: '/admin/partners', icon: Building2 },
{ name: 'Email thông tin', href: '/admin/emails', icon: Mail },
{
name: 'Thiết lập',
icon: Settings,
children: [{ name: 'Thông tin website', href: '/admin/website-config' }],
},
// { name: 'Quản lý hội viên', href: '/admin/members', icon: Users },
// { name: 'Quản lý đối tác', href: '/admin/partners', icon: Building2 },
// { name: 'Email thông tin', href: '/admin/emails', icon: Mail },
// {
// name: 'Thiết lập',
// icon: Settings,
// children: [{ name: 'Thông tin website', href: '/admin/website-config' }],
// },
];
export function AdminSidebar() {
......
"use client";
export const ADMIN_NEWS_STORAGE_KEY = "vcci-news.admin-news.data.v3";
export const ADMIN_MEDIA_STORAGE_KEY = "vcci-news.admin-media-library.data.v1";
export const ADMIN_NEWS_TYPE_OPTIONS = [
{ value: "tintuc", label: "Tin tức" },
{ value: "baiviettrang", label: "Bài viết trang" },
] as const;
export type AdminNewsType = (typeof ADMIN_NEWS_TYPE_OPTIONS)[number]["value"];
export const ADMIN_NEWS_TYPE_LABELS: Record<AdminNewsType, string> =
ADMIN_NEWS_TYPE_OPTIONS.reduce(
(result, option) => {
result[option.value] = option.label;
return result;
},
{} as Record<AdminNewsType, string>,
);
export interface AdminMediaItem {
id: string;
name: string;
alt: string;
url: string;
mime: string;
size: number;
created_at: string;
updated_at: string;
source: "seed" | "upload";
}
export interface AdminNewsImageRef {
id: string;
name: string;
alt: string;
url: string;
}
export interface AdminNewsContentImage {
position: number;
image: AdminNewsImageRef;
}
export interface AdminNewsContentSection {
id: string;
type: "text" | "image";
position: number;
content: string;
image_columns: number;
image_rows: number;
images: AdminNewsContentImage[];
}
export interface AdminNewsItem {
id: string;
title: string;
slug: string;
summary: string;
type: AdminNewsType;
header_category_id: string;
category_ids: string[];
tagsearch_values: string[];
is_featured: boolean;
thumbnail: AdminNewsImageRef | null;
is_hidden: boolean;
created_at: string;
updated_at: string;
published_at: string;
expired_at: string;
started_at: string;
ended_at: string;
registration_deadline: string;
location: string;
participation_fee: string;
post_content: AdminNewsContentSection[];
}
export interface AdminNewsFormValues {
title: string;
slug: string;
summary: string;
type: AdminNewsType | "";
header_category_id: string;
category_ids: string[];
tagsearch_values: string[];
is_featured: boolean;
thumbnail: AdminNewsImageRef | null;
is_hidden: boolean;
created_at: string;
updated_at: string;
published_at: string;
expired_at: string;
started_at: string;
ended_at: string;
registration_deadline: string;
location: string;
participation_fee: string;
post_content: AdminNewsContentSection[];
}
export const EMPTY_ADMIN_NEWS_FORM: AdminNewsFormValues = {
title: "",
slug: "",
summary: "",
type: "tintuc",
header_category_id: "",
category_ids: [],
tagsearch_values: [],
is_featured: false,
thumbnail: null,
is_hidden: false,
created_at: "",
updated_at: "",
published_at: "",
expired_at: "",
started_at: "",
ended_at: "",
registration_deadline: "",
location: "",
participation_fee: "",
post_content: [],
};
const mediaSeed: AdminMediaItem[] = [
{
id: "media-banner",
name: "Banner VCCI News",
alt: "Banner VCCI News",
url: "/banner.webp",
mime: "image/webp",
size: 0,
created_at: "2026-05-01T08:00:00.000Z",
updated_at: "2026-05-01T08:00:00.000Z",
source: "seed",
},
{
id: "media-thumbnail",
name: "Thumbnail mặc định",
alt: "Thumbnail mặc định",
url: "/thumbnail.png",
mime: "image/png",
size: 0,
created_at: "2026-05-01T08:10:00.000Z",
updated_at: "2026-05-01T08:10:00.000Z",
source: "seed",
},
{
id: "media-home-01",
name: "Hoạt động hội viên",
alt: "Hoạt động hội viên",
url: "/home/20-2048x1365.webp",
mime: "image/webp",
size: 0,
created_at: "2026-05-02T07:30:00.000Z",
updated_at: "2026-05-02T07:30:00.000Z",
source: "seed",
},
{
id: "media-home-02",
name: "Banner sự kiện",
alt: "Banner sự kiện",
url: "/home/eCarAid_web_banner_600x400.webp",
mime: "image/webp",
size: 0,
created_at: "2026-05-03T09:15:00.000Z",
updated_at: "2026-05-03T09:15:00.000Z",
source: "seed",
},
];
function toImageRef(item: AdminMediaItem): AdminNewsImageRef {
return {
id: item.id,
name: item.name,
alt: item.alt,
url: item.url,
};
}
const newsSeed: AdminNewsItem[] = [
{
id: "admin-news-01",
title: "VCCI thúc đẩy kết nối doanh nghiệp hội viên khu vực phía Nam",
slug: "vcci-thuc-day-ket-noi-doanh-nghiep-hoi-vien-khu-vuc-phia-nam",
summary:
"<p>Bản tin tổng hợp các hoạt động kết nối doanh nghiệp, mở rộng thị trường và nâng cao năng lực quản trị cho hội viên VCCI.</p>",
type: "tintuc",
header_category_id: "activity-news",
category_ids: ["cat-news", "cat-activity"],
tagsearch_values: ["Doanh nghiệp hội viên", "Chuyển đổi số"],
is_featured: true,
thumbnail: toImageRef(mediaSeed[2]),
is_hidden: false,
created_at: "2026-05-08T09:00:00.000Z",
updated_at: "2026-05-10T09:30:00.000Z",
published_at: "2026-05-08T09:00",
expired_at: "",
started_at: "",
ended_at: "",
registration_deadline: "",
location: "TP. Hồ Chí Minh",
participation_fee: "Miễn phí",
post_content: [
{
id: "section-admin-news-01-a",
type: "text",
position: 1,
content:
"<p>Chương trình tập trung vào các giải pháp mở rộng mạng lưới doanh nghiệp hội viên, đồng thời hỗ trợ các đơn vị tiếp cận cơ hội hợp tác mới trong năm 2026.</p>",
image_columns: 2,
image_rows: 2,
images: [],
},
{
id: "section-admin-news-01-b",
type: "image",
position: 2,
content: "",
image_columns: 2,
image_rows: 1,
images: [
{ position: 1, image: toImageRef(mediaSeed[2]) },
{ position: 2, image: toImageRef(mediaSeed[3]) },
],
},
],
},
{
id: "admin-news-02",
title: "Lịch hội thảo chuyển đổi số dành cho hội viên tháng 5",
slug: "lich-hoi-thao-chuyen-doi-so-danh-cho-hoi-vien-thang-5",
summary:
"<p>Lịch hội thảo cập nhật những chương trình đào tạo, chia sẻ chuyên đề và kết nối nguồn lực hỗ trợ doanh nghiệp.</p>",
type: "tintuc",
header_category_id: "activity-events",
category_ids: ["cat-event"],
tagsearch_values: ["Hội thảo", "Đăng ký"],
is_featured: false,
thumbnail: toImageRef(mediaSeed[3]),
is_hidden: false,
created_at: "2026-05-09T08:30:00.000Z",
updated_at: "2026-05-11T11:00:00.000Z",
published_at: "2026-05-09T08:30",
expired_at: "2026-05-31T18:00",
started_at: "2026-05-20T08:00",
ended_at: "2026-05-20T17:00",
registration_deadline: "2026-05-18T17:00",
location: "Trung tâm Hội nghị VCCI",
participation_fee: "500.000 VNĐ",
post_content: [
{
id: "section-admin-news-02-a",
type: "text",
position: 1,
content:
"<p>Nội dung chuỗi hội thảo bao gồm chuyển đổi số, quản trị dữ liệu, truyền thông nội bộ và ứng dụng AI trong hoạt động doanh nghiệp.</p>",
image_columns: 2,
image_rows: 2,
images: [],
},
],
},
{
id: "admin-news-03",
title: "Giới thiệu vai trò của VCCI News trong hệ sinh thái nội dung số",
slug: "gioi-thieu-vai-tro-cua-vcci-news-trong-he-sinh-thai-noi-dung-so",
summary:
"<p>Bài viết trang giới thiệu định hướng phát triển nội dung, cấu trúc quản trị và trải nghiệm người dùng trên website.</p>",
type: "baiviettrang",
header_category_id: "intro-about",
category_ids: [],
tagsearch_values: [],
is_featured: false,
thumbnail: toImageRef(mediaSeed[0]),
is_hidden: false,
created_at: "2026-05-06T10:00:00.000Z",
updated_at: "2026-05-10T16:45:00.000Z",
published_at: "2026-05-06T10:00",
expired_at: "",
started_at: "",
ended_at: "",
registration_deadline: "",
location: "",
participation_fee: "",
post_content: [
{
id: "section-admin-news-03-a",
type: "text",
position: 1,
content:
"<p>VCCI News được định hướng là trung tâm cập nhật thông tin, chuyên đề và hoạt động hội viên trên cùng một nền tảng nội dung thống nhất.</p>",
image_columns: 2,
image_rows: 2,
images: [],
},
{
id: "section-admin-news-03-b",
type: "image",
position: 2,
content: "",
image_columns: 1,
image_rows: 1,
images: [{ position: 1, image: toImageRef(mediaSeed[0]) }],
},
],
},
];
export function slugifyAdminNews(value: string) {
return 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, "-");
}
export function resolveAdminNewsType(value?: string | null): AdminNewsType | undefined {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
const compact = normalized.replace(/[\s_-]+/g, "");
const direct = ADMIN_NEWS_TYPE_OPTIONS.find(
(option) => option.value === normalized || option.value === compact,
);
if (direct) return direct.value;
const aliases: Record<string, AdminNewsType> = {
news: "tintuc",
"tin tuc": "tintuc",
"tin tức": "tintuc",
pagepost: "baiviettrang",
"bai viet trang": "baiviettrang",
"bài viết trang": "baiviettrang",
};
return aliases[normalized] || aliases[compact];
}
export function createAdminNewsId() {
return `admin-news-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function createAdminMediaId() {
return `admin-media-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function createAdminNewsSectionId() {
return `section-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function cloneAdminNewsFormValues(item?: AdminNewsItem | null): AdminNewsFormValues {
if (!item) {
return {
...EMPTY_ADMIN_NEWS_FORM,
category_ids: [],
tagsearch_values: [],
post_content: [],
};
}
return {
title: item.title,
slug: item.slug,
summary: item.summary,
type: item.type,
header_category_id: item.header_category_id,
category_ids: [...item.category_ids],
tagsearch_values: [...(item.tagsearch_values ?? [])],
is_featured: item.is_featured ?? false,
thumbnail: item.thumbnail ? { ...item.thumbnail } : null,
is_hidden: item.is_hidden,
created_at: item.created_at,
updated_at: item.updated_at,
published_at: item.published_at,
expired_at: item.expired_at,
started_at: item.started_at,
ended_at: item.ended_at,
registration_deadline: item.registration_deadline,
location: item.location,
participation_fee: item.participation_fee,
post_content: item.post_content.map((section) => ({
...section,
images: section.images.map((image) => ({
...image,
image: { ...image.image },
})),
})),
};
}
export function normalizeAdminMediaItems(items: AdminMediaItem[]) {
return [...items].sort((left, right) => {
return (
new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime() ||
right.name.localeCompare(left.name, "vi")
);
});
}
export function normalizeAdminNewsItems(items: AdminNewsItem[]) {
return [...items]
.map((item) => ({
...item,
category_ids: Array.isArray(item.category_ids) ? item.category_ids : [],
tagsearch_values: Array.isArray(item.tagsearch_values)
? item.tagsearch_values.filter(Boolean)
: [],
is_featured: item.type === "tintuc" ? Boolean(item.is_featured) : false,
}))
.sort((left, right) => {
const leftTime = new Date(left.published_at || left.created_at).getTime();
const rightTime = new Date(right.published_at || right.created_at).getTime();
return rightTime - leftTime || right.updated_at.localeCompare(left.updated_at);
});
}
export function getAdminMediaSeed() {
return normalizeAdminMediaItems(mediaSeed);
}
export function getAdminNewsSeed() {
return normalizeAdminNewsItems(newsSeed);
}
export function readAdminMediaItems() {
if (typeof window === "undefined") return getAdminMediaSeed();
const raw = window.localStorage.getItem(ADMIN_MEDIA_STORAGE_KEY);
if (!raw) return getAdminMediaSeed();
try {
const parsed = JSON.parse(raw) as AdminMediaItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getAdminMediaSeed();
}
return normalizeAdminMediaItems(parsed);
} catch {
return getAdminMediaSeed();
}
}
export function readAdminNewsItems() {
if (typeof window === "undefined") return getAdminNewsSeed();
const raw = window.localStorage.getItem(ADMIN_NEWS_STORAGE_KEY);
if (!raw) return getAdminNewsSeed();
try {
const parsed = JSON.parse(raw) as AdminNewsItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
return getAdminNewsSeed();
}
return normalizeAdminNewsItems(parsed);
} catch {
return getAdminNewsSeed();
}
}
export function persistAdminMediaItems(items: AdminMediaItem[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(
ADMIN_MEDIA_STORAGE_KEY,
JSON.stringify(normalizeAdminMediaItems(items)),
);
}
export function persistAdminNewsItems(items: AdminNewsItem[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(
ADMIN_NEWS_STORAGE_KEY,
JSON.stringify(normalizeAdminNewsItems(items)),
);
}
"use client";
export type HeaderCategoryType = "category" | "page" | "news" | "image";
export type HeaderCategoryType = "category" | "page" | "news";
export interface HeaderCategoryItem {
id: string;
......@@ -13,6 +13,7 @@ export interface HeaderCategoryItem {
parent_id: string | null;
level: number;
category_ids: string[];
tagsearch_values: string[];
description?: string;
created_at?: string;
updated_at?: string;
......@@ -29,6 +30,29 @@ export interface HeaderArticleCategoryOption {
export const HEADER_CONFIG_STORAGE_KEY = "vcci-news.header-config.data.v1";
const DEFAULT_HEADER_CATEGORY_SEARCH_TAGS: Record<string, string[]> = {
"activity-news": [
"Doanh nghiệp hội viên",
"Xúc tiến thương mại",
"Chuyển đổi số",
"Kết nối giao thương",
"Bản tin nổi bật",
],
"activity-events": [
"Hội thảo",
"Đăng ký",
"Sự kiện nổi bật",
"Lịch sự kiện",
"Mời tham dự",
],
"library-highlight": [
"Album ảnh",
"Thư viện số",
"Khoảnh khắc nổi bật",
"Hình ảnh sự kiện",
],
};
export const headerCategorySeed: HeaderCategoryItem[] = [
{
id: "root-home",
......@@ -41,6 +65,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: null,
level: 1,
category_ids: [],
tagsearch_values: [],
description: "Trang gốc của website",
},
{
......@@ -54,6 +79,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: null,
level: 1,
category_ids: [],
tagsearch_values: [],
description: "Nhóm nội dung giới thiệu",
},
{
......@@ -67,6 +93,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: "intro",
level: 2,
category_ids: [],
tagsearch_values: [],
description: "Trang nội dung giới thiệu hệ thống",
},
{
......@@ -80,6 +107,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: "intro",
level: 2,
category_ids: [],
tagsearch_values: [],
description: "Trang thông tin cơ cấu tổ chức",
},
{
......@@ -93,6 +121,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: null,
level: 1,
category_ids: [],
tagsearch_values: [],
description: "Nhóm nội dung tin tức và hoạt động",
},
{
......@@ -106,6 +135,11 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: "activity",
level: 2,
category_ids: ["cat-news", "cat-activity"],
tagsearch_values: [
"Doanh nghiệp hội viên",
"Xúc tiến thương mại",
"Chuyển đổi số",
],
description: "Danh mục tin tức tổng hợp",
},
{
......@@ -119,6 +153,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: "activity",
level: 2,
category_ids: ["cat-event"],
tagsearch_values: ["Hội thảo", "Đăng ký", "Sự kiện nổi bật"],
description: "Danh mục sự kiện",
},
{
......@@ -132,6 +167,7 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
parent_id: null,
level: 1,
category_ids: [],
tagsearch_values: [],
description: "Khu vực ảnh và album",
},
{
......@@ -140,11 +176,12 @@ export const headerCategorySeed: HeaderCategoryItem[] = [
slug: "album-noi-bat",
static_link: "/thu-vien-anh/album-noi-bat",
sort_order: 1,
type: "image",
is_article: false,
type: "news",
is_article: true,
parent_id: "library",
level: 2,
category_ids: [],
tagsearch_values: ["Album ảnh", "Thư viện số"],
description: "Album ảnh nổi bật",
},
];
......@@ -154,125 +191,10 @@ export const headerArticleCategoryOptions: HeaderArticleCategoryOption[] = [
{ id: "cat-activity", name: "Hoạt động VCCI" },
{ id: "cat-event", name: "Sự kiện" },
{ id: "cat-policy", name: "Chính sách" },
{ id: "cat-gallery", name: "Ảnh nổi bật" },
];
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)
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
......@@ -284,6 +206,27 @@ export function toSlug(value: string) {
.replace(/-+/g, "-");
}
function normalizeTagsearchValues(values?: string[]) {
if (!Array.isArray(values)) return [];
const seen = new Set<string>();
return values
.map((value) => value.trim())
.filter((value) => {
if (!value) return false;
const key = value.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function getDefaultTagsearchValues(itemId: string) {
return DEFAULT_HEADER_CATEGORY_SEARCH_TAGS[itemId] ?? [];
}
function buildStaticLink(
item: Pick<HeaderCategoryItem, "slug" | "parent_id">,
items: HeaderCategoryItem[],
......@@ -296,9 +239,11 @@ function buildStaticLink(
while (currentParentId) {
const parent = items.find((entry) => entry.id === currentParentId);
if (!parent) break;
if (parent.slug.trim()) {
segments.unshift(parent.slug.trim());
}
currentParentId = parent.parent_id;
}
......@@ -320,7 +265,19 @@ function assignLevel(item: HeaderCategoryItem, items: HeaderCategoryItem[]) {
}
export function normalizeHeaderCategories(items: HeaderCategoryItem[]) {
const sanitizedItems = items.map((item) => normalizeHeaderCategoryText(item));
const sanitizedItems = items.map((item) => {
const normalizedType =
(item.type as unknown as string) === "image" ? "news" : item.type;
return {
...item,
type: normalizedType as HeaderCategoryType,
is_article: normalizedType === "news",
category_ids: Array.isArray(item.category_ids) ? item.category_ids : [],
tagsearch_values: normalizeTagsearchValues(item.tagsearch_values),
};
});
const parentIds = new Set(
sanitizedItems
.filter((item) => item.parent_id)
......@@ -333,14 +290,19 @@ export function normalizeHeaderCategories(items: HeaderCategoryItem[]) {
if (parentIds.has(next.id)) {
next.type = "category";
next.category_ids = [];
next.tagsearch_values = [];
}
next.level = assignLevel(next, sanitizedItems);
next.static_link = next.slug === "" && !next.parent_id ? "/" : buildStaticLink(next, sanitizedItems);
next.static_link =
next.slug === "" && !next.parent_id ? "/" : buildStaticLink(next, sanitizedItems);
next.is_article = next.type === "news";
if (next.type !== "news") {
next.category_ids = [];
next.tagsearch_values = [];
} else if (next.tagsearch_values.length === 0) {
next.tagsearch_values = getDefaultTagsearchValues(next.id);
}
return next;
......@@ -389,8 +351,6 @@ export function getHeaderCategoryTypeLabel(type: HeaderCategoryType) {
return "Bài viết trang";
case "news":
return "Tin tức";
case "image":
return "Ảnh";
default:
return type;
}
......
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