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 * 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(),
});
......
This diff is collapsed.
This diff is collapsed.
......@@ -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>
);
}
This diff is collapsed.
This diff is collapsed.
"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() {
......
This diff is collapsed.
This diff is collapsed.
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