update UI
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Anang Yusman 2026-04-15 15:09:53 +08:00
parent aeccdcffe9
commit eb039a3ca4
6 changed files with 570 additions and 145 deletions

View File

@ -0,0 +1,11 @@
import EditArticleForm from "@/components/form/article/edit-article-form";
export default function DetailArticlePage() {
return (
<div className="">
<div className="h-[96vh] p-3 lg:p-8 bg-slate-100 dark:!bg-black overflow-y-auto">
<EditArticleForm isDetail={false} />
</div>
</div>
);
}

View File

@ -0,0 +1,11 @@
import EditArticleForm from "@/components/form/article/edit-article-form";
export default function UpdateArticlePage() {
return (
<div className="">
<div className="h-[96vh] p-3 lg:p-8 bg-slate-100 dark:!bg-black overflow-y-auto">
<EditArticleForm isDetail={false} />
</div>
</div>
);
}

View File

@ -54,7 +54,7 @@ function ViewEditor(props) {
} }
.ckeditor-view-wrapper :global(.ck.ck-editor__main) { .ckeditor-view-wrapper :global(.ck.ck-editor__main) {
min-height: ${props.height || 400}px; // min-height: ${props.height || 400}px;
max-height: ${maxHeight}px; max-height: ${maxHeight}px;
} }

View File

@ -18,10 +18,7 @@ import { useParams, useRouter } from "next/navigation";
import GetSeoScore from "./get-seo-score-form"; import GetSeoScore from "./get-seo-score-form";
import Link from "next/link"; import Link from "next/link";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { import { isApproverOrAdmin, isContributorRole } from "@/constants/user-roles";
isApproverOrAdmin,
isContributorRole,
} from "@/constants/user-roles";
import { import {
createArticleSchedule, createArticleSchedule,
deleteArticleFiles, deleteArticleFiles,
@ -107,7 +104,7 @@ const createArticleSchema = z.object({
message: "Deskripsi harus diisi", message: "Deskripsi harus diisi",
}), }),
category: z.array(categorySchema), category: z.array(categorySchema),
tags: z.array(z.string()).nonempty({ tags: z.array(z.string()).min(1, {
message: "Minimal 1 tag", message: "Minimal 1 tag",
}), }),
source: z.enum(["internal", "external"]).optional(), source: z.enum(["internal", "external"]).optional(),
@ -162,6 +159,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
const [isScheduled, setIsScheduled] = useState(false); const [isScheduled, setIsScheduled] = useState(false);
const [startDateValue, setStartDateValue] = useState<Date | undefined>(); const [startDateValue, setStartDateValue] = useState<Date | undefined>();
const [startTimeValue, setStartTimeValue] = useState<string>(""); const [startTimeValue, setStartTimeValue] = useState<string>("");
const [openHistory, setOpenHistory] = useState(false);
const [levelId, setLevelId] = useState<string | undefined>(); const [levelId, setLevelId] = useState<string | undefined>();
@ -184,7 +182,14 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
const formOptions = { const formOptions = {
resolver: zodResolver(createArticleSchema), resolver: zodResolver(createArticleSchema),
defaultValues: { title: "", description: "", category: [], tags: [], slug: "", customCreatorName: "" }, defaultValues: {
title: "",
description: "",
category: [],
tags: [],
slug: "",
customCreatorName: "",
},
}; };
type UserSettingSchema = z.infer<typeof createArticleSchema>; type UserSettingSchema = z.infer<typeof createArticleSchema>;
const { const {
@ -256,7 +261,10 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
temp.push(datas[0]); temp.push(datas[0]);
} }
} }
setValue("category", temp.length ? (temp as [CategoryType, ...CategoryType[]]) : []); setValue(
"category",
temp.length ? (temp as [CategoryType, ...CategoryType[]]) : [],
);
}; };
useEffect(() => { useEffect(() => {
@ -328,9 +336,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
title: detailData?.title, title: detailData?.title,
typeId: detailData?.typeId ?? 1, typeId: detailData?.typeId ?? 1,
slug: detailData?.slug, slug: detailData?.slug,
categoryIds: (getValues("category") ?? []) categoryIds: (getValues("category") ?? []).map((val) => val.id).join(","),
.map((val) => val.id)
.join(","),
tags: getValues("tags").join(","), tags: getValues("tags").join(","),
description: htmlToString(getValues("description")), description: htmlToString(getValues("description")),
htmlDescription: getValues("description"), htmlDescription: getValues("description"),
@ -372,9 +378,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
title: detailData?.title, title: detailData?.title,
typeId: detailData?.typeId ?? 1, typeId: detailData?.typeId ?? 1,
slug: detailData?.slug, slug: detailData?.slug,
categoryIds: (getValues("category") ?? []) categoryIds: (getValues("category") ?? []).map((val) => val.id).join(","),
.map((val) => val.id)
.join(","),
tags: getValues("tags").join(","), tags: getValues("tags").join(","),
description: htmlToString(getValues("description")), description: htmlToString(getValues("description")),
htmlDescription: getValues("description"), htmlDescription: getValues("description"),
@ -685,81 +689,99 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
}; };
if (isDetail) { if (isDetail) {
const tags =
detailData?.tags
?.split(",")
.map((t: string) => t.replace("#", "").trim()) ?? [];
return ( return (
<div className="min-h-screen bg-gray-100 p-3"> <div className="min-h-screen bg-gray-100 p-4">
{/* 🔹 BREADCRUMB
<div className="max-w-7xl mx-auto mb-4 text-sm text-gray-500 flex items-center gap-2">
<span className="text-blue-600 font-medium">News & Articles</span>
<span></span>
<span className="text-gray-700 font-medium">Detail Article</span>
</div> */}
<div className="max-w-7xl mx-auto flex flex-col lg:flex-row gap-6"> <div className="max-w-7xl mx-auto flex flex-col lg:flex-row gap-6">
{/* ================= LEFT SIDE ================= */} {/* ================= LEFT ================= */}
<div className="w-full lg:w-full bg-white rounded-2xl shadow-sm border p-4 space-y-3"> <div className="flex-1 bg-white rounded-2xl border shadow-sm p-6 space-y-6">
{/* Title */} {/* Title */}
<div className=""> <div>
<p className="text-sm text-black mb-2">Title</p> <p className="text-sm text-gray-500 mb-2">Title</p>
<div className="bg-gray-100 rounded-lg px-4 py-3 text-gray-800 text-lg font-medium"> <div className="bg-gray-100 rounded-lg px-4 py-3 text-gray-800 font-medium">
{detailData?.title} {detailData?.title}
</div> </div>
</div> </div>
{/* Content type (articles.type_id) */} {/* Category */}
<div className=""> <div>
<p className="text-sm text-black mb-2">Content type</p> <p className="text-sm text-gray-500 mb-2">Category</p>
<div className="bg-gray-100 rounded-lg px-4 py-3"> <div className="bg-gray-100 rounded-lg px-4 py-3">
{detailData?.typeId === 1 && "Image"} {detailData?.categories?.length > 0
{detailData?.typeId === 2 && "Text"} ? detailData.categories
{detailData?.typeId === 3 && "Video"} .map((cat: any) => cat.title)
{detailData?.typeId === 4 && "Audio"} .join(", ")
{![1, 2, 3, 4].includes(detailData?.typeId) && (detailData?.typeId ?? "—")} : "—"}
</div> </div>
</div> </div>
{/* Description */} {/* Description */}
<div className=""> <div className="">
<p className="text-sm text-black mb-4">Description</p> <p className="text-sm text-gray-500 mb-4">Description</p>
<div className="prose max-w-none">
<ViewEditor initialData={detailData?.htmlDescription} /> <ViewEditor initialData={detailData?.htmlDescription} />
</div>
</div> </div>
{/* Media File */} {/* Media */}
<div className=""> <div>
<p className="text-sm text-gray-500 mb-4">Media File</p> <p className="text-sm text-gray-500 mb-3">Media File</p>
{detailfiles?.length > 0 ? ( {detailData?.files?.length > 0 ? (
<> <>
<Image <Image
src={detailfiles[0]?.fileUrl} src={detailData.files[0]?.fileUrl || ""}
width={900} width={900}
height={500} height={500}
alt="media" alt="media"
className="rounded-xl w-full object-cover" className="rounded-xl w-full object-cover"
/> />
<div className="flex gap-3 mt-4 overflow-x-auto"> <div className="flex gap-3 mt-3 overflow-x-auto">
{detailfiles.map((file: any, index: number) => ( {detailData.files.map((file: any, i: number) => (
<Image <Image
key={index} key={i}
src={file.fileUrl} src={file.fileUrl}
width={200} width={120}
height={120} height={80}
alt="preview" alt="preview"
className="rounded-lg h-24 w-40 object-cover border" className="rounded-lg object-cover border"
/> />
))} ))}
</div> </div>
</> </>
) : ( ) : (
<p className="text-gray-400">Belum ada file</p> <p className="text-gray-400">No media</p>
)} )}
</div> </div>
</div> </div>
{/* ================= RIGHT SIDE ================= */} {/* ================= RIGHT ================= */}
<div className="w-full lg:w-[30%] space-y-6"> <div className="w-full lg:w-[320px] space-y-6">
{/* Meta & Thumbnail Card */} <div className="bg-white rounded-2xl border shadow-sm p-5 space-y-5">
<div className="bg-white rounded-2xl shadow-sm border p-6 space-y-6"> {/* Creator */}
<div>
<p className="text-sm text-gray-500 mb-2">Creator</p>
<div className="bg-gray-100 rounded-lg px-4 py-3 font-medium">
{detailData?.createdByName || "-"}
</div>
</div>
{/* Thumbnail */} {/* Thumbnail */}
<div> <div>
<p className="text-sm text-gray-500 mb-2">Thumbnail Image</p> <p className="text-sm text-gray-500 mb-2">Thumbnail Image</p>
<Image <Image
src={detailData?.thumbnailUrl || "/default-avatar.png"} src={detailData?.thumbnailUrl || "/placeholder.png"}
width={400} width={400}
height={250} height={250}
alt="thumbnail" alt="thumbnail"
@ -767,59 +789,67 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
/> />
</div> </div>
{/* Tag */} {/* Tags */}
<div> <div>
<p className="text-sm text-gray-500 mb-2">Tag</p> <p className="text-sm text-gray-500 mb-2">Tag</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{detailData?.tags {tags.length > 0 ? (
?.split(",") tags.map((tag: string, i: number) => (
.map((tag: string, i: number) => ( <Badge
<span
key={i} key={i}
className="bg-gray-100 text-sm px-3 py-1 rounded-full border" className="bg-gray-100 text-gray-700 rounded-full"
> >
{tag} {tag}
</span> </Badge>
))} ))
) : (
<span className="text-gray-400"></span>
)}
</div> </div>
</div> </div>
{/* Notes */} {/* Suggestion Box */}
<div> <div>
<p className="text-sm text-gray-500 mb-2">Notes</p> <div className="flex items-center gap-2 text-blue-600 text-sm font-medium">
<div className="bg-gray-100 rounded-lg px-4 py-3 text-gray-400"> <Mail size={16} />
- Suggestion Box (0)
</div>
<div className="mt-3 border rounded-lg p-3 space-y-2">
<p className="text-sm font-semibold">Description:</p>
{detailData?.isPublish ? (
<span className="inline-block bg-green-100 text-green-700 text-xs px-3 py-1 rounded-full">
Approved
</span>
) : (
<span className="inline-block bg-yellow-100 text-yellow-700 text-xs px-3 py-1 rounded-full">
Pending
</span>
)}
<p className="text-sm font-semibold">Comment:</p>
<p className="text-xs text-gray-500">
{detailData?.customCreatorName || "-"}
</p>
<p
className="text-xs text-blue-600 cursor-pointer"
onClick={() => setOpenHistory(true)}
>
View Approver History
</p>
</div> </div>
</div>
<div className="flex items-center text-blue-700 gap-2">
<Mail size={20} />
<p className="text-sm ">Suggestion Box (0)</p>
</div>
<div className="border p-3 border-black rounded-lg space-y-2 ">
<h2 className="text-sm text-black font-semibold">
Description :
</h2>
{detailData?.isPublish === true ? (
<span className="inline-block bg-green-100 text-green-700 text-xs font-semibold px-3 py-1 rounded-full">
Approved
</span>
) : (
<span className="inline-block bg-yellow-100 text-yellow-700 text-xs font-semibold px-3 py-1 rounded-full">
Pending
</span>
)}
<p className="text-sm text-black font-semibold">Comment</p>
<h2 className="text-blue-600 text-xs">View Approver History</h2>
</div> </div>
</div> </div>
{/* ================= ACTION BUTTON ================= */} {/* ACTION */}
<div className="space-y-3"> <div className="space-y-3">
{isApproverOrAdmin(levelId) && !detailData?.isPublish && ( {isApproverOrAdmin(levelId) && !detailData?.isPublish && (
<> <>
<Button <Button
onClick={doPublish} onClick={doPublish}
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-xl" className="w-full bg-blue-600 hover:bg-blue-700 text-white"
> >
Approve Approve
</Button> </Button>
@ -829,7 +859,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
setApprovalStatus(4); setApprovalStatus(4);
doApproval(); doApproval();
}} }}
className="w-full bg-orange-500 hover:bg-orange-600 text-white py-3 rounded-xl" className="w-full bg-orange-500 hover:bg-orange-600 text-white"
> >
Revision Revision
</Button> </Button>
@ -839,17 +869,16 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
setApprovalStatus(5); setApprovalStatus(5);
doApproval(); doApproval();
}} }}
className="w-full bg-red-600 hover:bg-red-700 text-white py-3 rounded-xl" className="w-full bg-red-600 hover:bg-red-700 text-white"
> >
Reject Reject
</Button> </Button>
</> </>
)} )}
{/* 🔥 Jika levelId 3 → hanya tampilkan Cancel */}
{isContributorRole(levelId) && ( {isContributorRole(levelId) && (
<Link href="/admin/news-article/image"> <Link href="/admin/news-article/image">
<Button variant="outline" className="w-full py-3 rounded-xl"> <Button variant="outline" className="w-full">
Cancel Cancel
</Button> </Button>
</Link> </Link>
@ -857,7 +886,230 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
</div> </div>
</div> </div>
</div> </div>
<Dialog open={openHistory} onOpenChange={setOpenHistory}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Approver History</DialogTitle>
</DialogHeader>
{/* CONTENT */}
<div className="py-4">
<div className="flex flex-col items-center gap-4">
{/* STEP 1 */}
<div className="bg-blue-100 text-blue-700 px-4 py-2 rounded-full text-sm">
Upload
</div>
{/* LINE */}
<div className="w-[2px] h-10 bg-gray-300" />
{/* STEP 2 */}
<div className="bg-teal-500 text-white px-5 py-3 rounded-lg shadow">
<p className="text-sm font-semibold">Level 1</p>
<p className="text-xs">Review oleh: Mabas Poin - Approver</p>
</div>
{/* LINE */}
<div className="w-[2px] h-10 bg-gray-300" />
{/* STEP 3 */}
<div className="bg-green-500 text-white px-4 py-2 rounded-full text-sm">
Publish
</div>
{/* NOTE */}
<div className="bg-cyan-100 text-cyan-700 px-4 py-2 rounded-md text-sm mt-2 w-full text-center">
Catatan: -
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Tutup</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
if (!isDetail) {
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="min-h-screen bg-gray-100 p-4">
<div className="max-w-7xl mx-auto flex flex-col lg:flex-row gap-6">
{/* ================= LEFT ================= */}
<div className="flex-1 bg-white rounded-2xl border shadow-sm p-6 space-y-6">
{/* TITLE */}
<div>
<p className="text-sm text-gray-500 mb-2">Title</p>
<Input {...register("title")} />
{errors.title && (
<p className="text-red-500 text-xs mt-1">
{errors.title.message}
</p>
)}
</div>
{/* SLUG */}
<div>
<p className="text-sm text-gray-500 mb-2">Slug</p>
<Input {...register("slug")} />
</div>
{/* CREATOR */}
<div>
<p className="text-sm text-gray-500 mb-2">Creator</p>
<Input {...register("customCreatorName")} />
</div>
{/* CATEGORY */}
<div>
<p className="text-sm text-gray-500 mb-2">Category</p>
<Controller
name="category"
control={control}
render={({ field }) => (
<ReactSelect
{...field}
isMulti
options={listCategory}
components={animatedComponents}
/>
)}
/>
</div>
{/* DESCRIPTION */}
<div>
<p className="text-sm text-gray-500 mb-4">Description</p>
<CustomEditor
initialData={getValues("description")}
onChange={(val: string) => setValue("description", val)}
/>
</div>
{/* MEDIA (UPLOAD) */}
<div>
<p className="text-sm text-gray-500 mb-3">Media File</p>
<div
{...getRootProps()}
className="border-2 border-dashed rounded-xl p-6 text-center cursor-pointer hover:bg-gray-50"
>
<input {...getInputProps()} />
<CloudUploadIcon className="mx-auto mb-2" />
<p className="text-sm text-gray-500">
Drag & drop atau klik untuk upload gambar
</p>
</div>
{/* PREVIEW FILE */}
<div className="mt-4 space-y-2">
{files.map((file) => (
<div
key={file.name}
className="flex justify-between items-center border p-2 rounded-md"
>
<span className="text-sm">{file.name}</span>
<Button
size="icon"
variant="ghost"
onClick={() => handleRemoveFile(file)}
>
<TimesIcon />
</Button>
</div>
))}
</div>
</div>
</div>
{/* ================= RIGHT ================= */}
<div className="w-full lg:w-[320px] space-y-6">
<div className="bg-white rounded-2xl border shadow-sm p-5 space-y-5">
{/* THUMBNAIL */}
<div>
<p className="text-sm text-gray-500 mb-2">Thumbnail</p>
{thumbnail ? (
<Image
src={thumbnail}
width={400}
height={250}
alt="thumbnail"
className="rounded-xl w-full object-cover mb-2"
/>
) : (
<div className="w-full h-[200px] bg-gray-100 rounded-xl flex items-center justify-center text-gray-400">
No Image
</div>
)}
<Input type="file" onChange={handleFileChange} />
</div>
{/* TAG */}
<div>
<p className="text-sm text-gray-500 mb-2">Tags</p>
<Input
placeholder="Tekan enter untuk tambah tag"
value={tag}
onChange={(e) => setTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (!tag) return;
setValue("tags", [...getValues("tags"), tag]);
setTag("");
}
}}
/>
<div className="flex flex-wrap gap-2 mt-3">
{getValues("tags")?.map((t, i) => (
<Badge key={i} className="flex items-center gap-1">
{t}
<X
size={14}
className="cursor-pointer"
onClick={() => {
const filtered = getValues("tags").filter(
(_, idx) => idx !== i,
);
setValue("tags", filtered);
}}
/>
</Badge>
))}
</div>
</div>
</div>
{/* ACTION */}
<div className="space-y-3">
<Button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
Save
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => router.back()}
>
Cancel
</Button>
</div>
</div>
</div>
</div>
</form>
);
}
} }

View File

@ -64,7 +64,65 @@ const getSidebarByRole = (roleId: string | null) => {
]; ];
} }
if (roleId === "2" || roleId === "3") { if (roleId === "2") {
return [
{
title: "Dashboard",
items: [
{
title: "Dashboard",
icon: () => (
<Icon icon="material-symbols:dashboard" className="text-lg" />
),
link: "/admin/dashboard",
},
],
},
{
items: [
{
title: "Content Website",
icon: () => <Icon icon="ri:article-line" className="text-lg" />,
link: "/admin/content-website",
},
],
},
{
title: "News & Article",
items: [
{
title: "News & Article",
icon: () => (
<Icon icon="grommet-icons:article" className="text-lg" />
),
children: [
{
title: "Text",
icon: () => <Icon icon="mdi:file-document-outline" />,
link: "/admin/news-article/text",
},
{
title: "Image",
icon: () => <Icon icon="mdi:image-outline" />,
link: "/admin/news-article/image",
},
{
title: "Video",
icon: () => <Icon icon="mdi:video-outline" />,
link: "/admin/news-article/video",
},
{
title: "Audio",
icon: () => <Icon icon="mdi:music-note-outline" />,
link: "/admin/news-article/audio",
},
],
},
],
},
];
}
if (roleId === "3") {
return [ return [
{ {
title: "Dashboard", title: "Dashboard",

View File

@ -22,7 +22,10 @@ import Cookies from "js-cookie";
import { isContributorRole } from "@/constants/user-roles"; import { isContributorRole } from "@/constants/user-roles";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import type { ArticleContentKind } from "@/constants/article-content-types"; import type { ArticleContentKind } from "@/constants/article-content-types";
import { ARTICLE_KIND_LABEL, articleListPath } from "@/constants/article-content-types"; import {
ARTICLE_KIND_LABEL,
articleListPath,
} from "@/constants/article-content-types";
type Props = { type Props = {
kind: ArticleContentKind; kind: ArticleContentKind;
@ -36,6 +39,7 @@ export default function NewsArticleList({ kind, typeId }: Props) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const [levelId, setLevelId] = useState<string | undefined>(); const [levelId, setLevelId] = useState<string | undefined>();
const [selectedTag, setSelectedTag] = useState<string>("all");
useEffect(() => { useEffect(() => {
setLevelId(Cookies.get("urie")); setLevelId(Cookies.get("urie"));
@ -96,6 +100,7 @@ export default function NewsArticleList({ kind, typeId }: Props) {
showCancelButton: true, showCancelButton: true,
}); });
if (!ok.isConfirmed) return; if (!ok.isConfirmed) return;
loading(); loading();
try { try {
const res = await deleteArticle(String(id)); const res = await deleteArticle(String(id));
@ -108,12 +113,56 @@ export default function NewsArticleList({ kind, typeId }: Props) {
return; return;
} }
await fetchData(); await fetchData();
await Swal.fire({ icon: "success", title: "Deleted", timer: 1200, showConfirmButton: false }); await Swal.fire({
icon: "success",
title: "Deleted",
timer: 1200,
showConfirmButton: false,
});
} finally { } finally {
close(); close();
} }
} }
const parseTags = (tags?: string) => {
if (!tags) return [];
return tags
.split(",")
.map((t) => t.replace("#", "").trim())
.filter(Boolean);
};
const tagCounts: Record<string, number> = {};
articles.forEach((article) => {
parseTags(article.tags).forEach((tag) => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
});
const tagEntries = Object.entries(tagCounts);
const allOne = tagEntries.every(([, count]) => count === 1);
let topTags: string[];
if (allOne) {
topTags = tagEntries
.sort(() => Math.random() - 0.5)
.slice(0, 5)
.map(([tag]) => tag);
} else {
topTags = tagEntries
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([tag]) => tag);
}
const filteredArticles =
selectedTag === "all"
? articles
: articles.filter((a) => parseTags(a.tags).includes(selectedTag));
const label = ARTICLE_KIND_LABEL[kind]; const label = ARTICLE_KIND_LABEL[kind];
const basePath = articleListPath(kind); const basePath = articleListPath(kind);
@ -122,12 +171,13 @@ export default function NewsArticleList({ kind, typeId }: Props) {
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold text-slate-800"> <h1 className="text-2xl font-semibold text-slate-800">
News & Articles {label} News & Articles
</h1> </h1>
<p className="text-sm text-slate-500 mt-1"> <p className="text-sm text-slate-500 mt-1">
Create and manage {label.toLowerCase()} articles. Organize with tags only. Create and manage {label.toLowerCase()} articles.
</p> </p>
</div> </div>
{isContributorRole(levelId) && ( {isContributorRole(levelId) && (
<Link href={`${basePath}/create`}> <Link href={`${basePath}/create`}>
<Button className="bg-blue-600 hover:bg-blue-700 rounded-lg"> <Button className="bg-blue-600 hover:bg-blue-700 rounded-lg">
@ -138,6 +188,33 @@ export default function NewsArticleList({ kind, typeId }: Props) {
)} )}
</div> </div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSelectedTag("all")}
className={`px-4 py-1.5 rounded-full text-sm text-black font-bold border ${
selectedTag === "all"
? "bg-blue-600 text-white"
: "bg-white text-slate-600"
}`}
>
All ({articles.length})
</button>
{topTags.map((tag) => (
<button
key={tag}
onClick={() => setSelectedTag(tag)}
className={`px-4 py-1.5 rounded-full text-sm text-black font-bold border ${
selectedTag === tag
? "bg-blue-600 text-white"
: "bg-white text-slate-600"
}`}
>
{tag} ({tagCounts[tag]})
</button>
))}
</div>
{/* SEARCH */}
<div className="flex gap-3"> <div className="flex gap-3">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-3 w-4 h-4 text-slate-400" /> <Search className="absolute left-3 top-3 w-4 h-4 text-slate-400" />
@ -153,79 +230,93 @@ export default function NewsArticleList({ kind, typeId }: Props) {
</div> </div>
</div> </div>
{/* TABLE */}
<Card className="rounded-2xl border shadow-sm"> <Card className="rounded-2xl border shadow-sm">
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="pl-3">Article</TableHead> <TableHead className="pl-3">Article</TableHead>
<TableHead>Tags</TableHead> <TableHead>Category</TableHead>
<TableHead>Author</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Date</TableHead> <TableHead>Date</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{articles.length > 0 ? ( {filteredArticles.length > 0 ? (
articles.map((article) => ( filteredArticles.map((article) => {
<TableRow key={article.id}> const status = getStatus(article);
<TableCell className="font-medium max-w-xs pl-3">
{article.title} return (
</TableCell> <TableRow key={article.id}>
<TableCell> <TableCell className="font-medium pl-3">
<span className="text-sm text-slate-600 line-clamp-2"> {article.title}
{article.tags || "—"} </TableCell>
</span>
</TableCell> <TableCell>
<TableCell> {article.categoryName ? (
{(() => { <Badge className="bg-blue-100 text-blue-700 rounded-full">
const status = getStatus(article); {article.categoryName}
return ( </Badge>
<span ) : (
className={`px-3 py-1 text-xs rounded-full font-medium ${statusClass( "—"
status, )}
)}`} </TableCell>
>
{status} <TableCell>{article.createdByName || "—"}</TableCell>
</span>
); <TableCell>
})()} <span
</TableCell> className={`px-3 py-1 text-xs rounded-full font-medium ${statusClass(
<TableCell>{formatDate(article.createdAt)}</TableCell> status,
<TableCell className="text-right space-x-1"> )}`}
<Link href={`/admin/news-article/detail/${article.id}`}>
<Button size="icon" variant="ghost" type="button">
<Eye className="w-4 h-4" />
</Button>
</Link>
<Link href={`/admin/news-article/detail/${article.id}`}>
<Button size="icon" variant="ghost" type="button">
<Pencil className="w-4 h-4" />
</Button>
</Link>
{isContributorRole(levelId) && (
<Button
size="icon"
variant="ghost"
type="button"
onClick={() => handleDelete(article.id)}
> >
<Trash2 className="w-4 h-4 text-red-500" /> {status}
</Button> </span>
)} </TableCell>
</TableCell>
</TableRow> <TableCell>{formatDate(article.createdAt)}</TableCell>
))
<TableCell className="text-right space-x-1">
<Link href={`/admin/news-article/detail/${article.id}`}>
<Button size="icon" variant="ghost">
<Eye className="w-4 h-4" />
</Button>
</Link>
<Link href={`/admin/news-article/update/${article.id}`}>
<Button size="icon" variant="ghost">
<Pencil className="w-4 h-4" />
</Button>
</Link>
{isContributorRole(levelId) && (
<Button
size="icon"
variant="ghost"
onClick={() => handleDelete(article.id)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</TableCell>
</TableRow>
);
})
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center py-8 text-slate-500"> <TableCell colSpan={6} className="text-center py-8">
No articles yet. Create one to get started. No articles found.
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>
{/* PAGINATION */}
<div className="flex items-center justify-between p-4 border-t text-sm text-slate-500"> <div className="flex items-center justify-between p-4 border-t text-sm text-slate-500">
<p> <p>
Page {page} of {totalPage} Page {page} of {totalPage}
@ -235,13 +326,15 @@ export default function NewsArticleList({ kind, typeId }: Props) {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={page === 1} disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => p - 1)}
> >
Previous Previous
</Button> </Button>
<Button size="sm" className="bg-blue-600"> <Button size="sm" className="bg-blue-600">
{page} {page}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"