update UI
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
aeccdcffe9
commit
eb039a3ca4
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ function ViewEditor(props) {
|
|||
}
|
||||
|
||||
.ckeditor-view-wrapper :global(.ck.ck-editor__main) {
|
||||
min-height: ${props.height || 400}px;
|
||||
// min-height: ${props.height || 400}px;
|
||||
max-height: ${maxHeight}px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,10 +18,7 @@ import { useParams, useRouter } from "next/navigation";
|
|||
import GetSeoScore from "./get-seo-score-form";
|
||||
import Link from "next/link";
|
||||
import Cookies from "js-cookie";
|
||||
import {
|
||||
isApproverOrAdmin,
|
||||
isContributorRole,
|
||||
} from "@/constants/user-roles";
|
||||
import { isApproverOrAdmin, isContributorRole } from "@/constants/user-roles";
|
||||
import {
|
||||
createArticleSchedule,
|
||||
deleteArticleFiles,
|
||||
|
|
@ -107,7 +104,7 @@ const createArticleSchema = z.object({
|
|||
message: "Deskripsi harus diisi",
|
||||
}),
|
||||
category: z.array(categorySchema),
|
||||
tags: z.array(z.string()).nonempty({
|
||||
tags: z.array(z.string()).min(1, {
|
||||
message: "Minimal 1 tag",
|
||||
}),
|
||||
source: z.enum(["internal", "external"]).optional(),
|
||||
|
|
@ -162,6 +159,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
|||
const [isScheduled, setIsScheduled] = useState(false);
|
||||
const [startDateValue, setStartDateValue] = useState<Date | undefined>();
|
||||
const [startTimeValue, setStartTimeValue] = useState<string>("");
|
||||
const [openHistory, setOpenHistory] = useState(false);
|
||||
|
||||
const [levelId, setLevelId] = useState<string | undefined>();
|
||||
|
||||
|
|
@ -184,7 +182,14 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
|||
|
||||
const formOptions = {
|
||||
resolver: zodResolver(createArticleSchema),
|
||||
defaultValues: { title: "", description: "", category: [], tags: [], slug: "", customCreatorName: "" },
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
category: [],
|
||||
tags: [],
|
||||
slug: "",
|
||||
customCreatorName: "",
|
||||
},
|
||||
};
|
||||
type UserSettingSchema = z.infer<typeof createArticleSchema>;
|
||||
const {
|
||||
|
|
@ -256,7 +261,10 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
|||
temp.push(datas[0]);
|
||||
}
|
||||
}
|
||||
setValue("category", temp.length ? (temp as [CategoryType, ...CategoryType[]]) : []);
|
||||
setValue(
|
||||
"category",
|
||||
temp.length ? (temp as [CategoryType, ...CategoryType[]]) : [],
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -328,9 +336,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
|||
title: detailData?.title,
|
||||
typeId: detailData?.typeId ?? 1,
|
||||
slug: detailData?.slug,
|
||||
categoryIds: (getValues("category") ?? [])
|
||||
.map((val) => val.id)
|
||||
.join(","),
|
||||
categoryIds: (getValues("category") ?? []).map((val) => val.id).join(","),
|
||||
tags: getValues("tags").join(","),
|
||||
description: htmlToString(getValues("description")),
|
||||
htmlDescription: getValues("description"),
|
||||
|
|
@ -372,9 +378,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
|||
title: detailData?.title,
|
||||
typeId: detailData?.typeId ?? 1,
|
||||
slug: detailData?.slug,
|
||||
categoryIds: (getValues("category") ?? [])
|
||||
.map((val) => val.id)
|
||||
.join(","),
|
||||
categoryIds: (getValues("category") ?? []).map((val) => val.id).join(","),
|
||||
tags: getValues("tags").join(","),
|
||||
description: htmlToString(getValues("description")),
|
||||
htmlDescription: getValues("description"),
|
||||
|
|
@ -685,81 +689,99 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
|||
};
|
||||
|
||||
if (isDetail) {
|
||||
const tags =
|
||||
detailData?.tags
|
||||
?.split(",")
|
||||
.map((t: string) => t.replace("#", "").trim()) ?? [];
|
||||
|
||||
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">
|
||||
{/* ================= LEFT SIDE ================= */}
|
||||
<div className="w-full lg:w-full bg-white rounded-2xl shadow-sm border p-4 space-y-3">
|
||||
{/* ================= LEFT ================= */}
|
||||
<div className="flex-1 bg-white rounded-2xl border shadow-sm p-6 space-y-6">
|
||||
{/* Title */}
|
||||
<div className="">
|
||||
<p className="text-sm text-black mb-2">Title</p>
|
||||
<div className="bg-gray-100 rounded-lg px-4 py-3 text-gray-800 text-lg font-medium">
|
||||
<div>
|
||||
<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 font-medium">
|
||||
{detailData?.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content type (articles.type_id) */}
|
||||
<div className="">
|
||||
<p className="text-sm text-black mb-2">Content type</p>
|
||||
{/* Category */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2">Category</p>
|
||||
<div className="bg-gray-100 rounded-lg px-4 py-3">
|
||||
{detailData?.typeId === 1 && "Image"}
|
||||
{detailData?.typeId === 2 && "Text"}
|
||||
{detailData?.typeId === 3 && "Video"}
|
||||
{detailData?.typeId === 4 && "Audio"}
|
||||
{![1, 2, 3, 4].includes(detailData?.typeId) && (detailData?.typeId ?? "—")}
|
||||
{detailData?.categories?.length > 0
|
||||
? detailData.categories
|
||||
.map((cat: any) => cat.title)
|
||||
.join(", ")
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="">
|
||||
<p className="text-sm text-black mb-4">Description</p>
|
||||
<div className="prose max-w-none">
|
||||
<ViewEditor initialData={detailData?.htmlDescription} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">Description</p>
|
||||
|
||||
<ViewEditor initialData={detailData?.htmlDescription} />
|
||||
</div>
|
||||
|
||||
{/* Media File */}
|
||||
<div className="">
|
||||
<p className="text-sm text-gray-500 mb-4">Media File</p>
|
||||
{/* Media */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-3">Media File</p>
|
||||
|
||||
{detailfiles?.length > 0 ? (
|
||||
{detailData?.files?.length > 0 ? (
|
||||
<>
|
||||
<Image
|
||||
src={detailfiles[0]?.fileUrl}
|
||||
src={detailData.files[0]?.fileUrl || ""}
|
||||
width={900}
|
||||
height={500}
|
||||
alt="media"
|
||||
className="rounded-xl w-full object-cover"
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 mt-4 overflow-x-auto">
|
||||
{detailfiles.map((file: any, index: number) => (
|
||||
<div className="flex gap-3 mt-3 overflow-x-auto">
|
||||
{detailData.files.map((file: any, i: number) => (
|
||||
<Image
|
||||
key={index}
|
||||
key={i}
|
||||
src={file.fileUrl}
|
||||
width={200}
|
||||
height={120}
|
||||
width={120}
|
||||
height={80}
|
||||
alt="preview"
|
||||
className="rounded-lg h-24 w-40 object-cover border"
|
||||
className="rounded-lg object-cover border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-400">Belum ada file</p>
|
||||
<p className="text-gray-400">No media</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================= RIGHT SIDE ================= */}
|
||||
<div className="w-full lg:w-[30%] space-y-6">
|
||||
{/* Meta & Thumbnail Card */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border p-6 space-y-6">
|
||||
{/* ================= 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">
|
||||
{/* 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 */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2">Thumbnail Image</p>
|
||||
<Image
|
||||
src={detailData?.thumbnailUrl || "/default-avatar.png"}
|
||||
src={detailData?.thumbnailUrl || "/placeholder.png"}
|
||||
width={400}
|
||||
height={250}
|
||||
alt="thumbnail"
|
||||
|
|
@ -767,59 +789,67 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Tag */}
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2">Tag</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{detailData?.tags
|
||||
?.split(",")
|
||||
.map((tag: string, i: number) => (
|
||||
<span
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag: string, i: number) => (
|
||||
<Badge
|
||||
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}
|
||||
</span>
|
||||
))}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{/* Suggestion Box */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2">Notes</p>
|
||||
<div className="bg-gray-100 rounded-lg px-4 py-3 text-gray-400">
|
||||
-
|
||||
<div className="flex items-center gap-2 text-blue-600 text-sm font-medium">
|
||||
<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 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>
|
||||
|
||||
{/* ================= ACTION BUTTON ================= */}
|
||||
{/* ACTION */}
|
||||
<div className="space-y-3">
|
||||
{isApproverOrAdmin(levelId) && !detailData?.isPublish && (
|
||||
<>
|
||||
<Button
|
||||
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
|
||||
</Button>
|
||||
|
|
@ -829,7 +859,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
|||
setApprovalStatus(4);
|
||||
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
|
||||
</Button>
|
||||
|
|
@ -839,17 +869,16 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
|||
setApprovalStatus(5);
|
||||
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
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 🔥 Jika levelId 3 → hanya tampilkan Cancel */}
|
||||
{isContributorRole(levelId) && (
|
||||
<Link href="/admin/news-article/image">
|
||||
<Button variant="outline" className="w-full py-3 rounded-xl">
|
||||
<Button variant="outline" className="w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
|
|
@ -857,7 +886,230 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
{
|
||||
title: "Dashboard",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ import Cookies from "js-cookie";
|
|||
import { isContributorRole } from "@/constants/user-roles";
|
||||
import Swal from "sweetalert2";
|
||||
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 = {
|
||||
kind: ArticleContentKind;
|
||||
|
|
@ -36,6 +39,7 @@ export default function NewsArticleList({ kind, typeId }: Props) {
|
|||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [levelId, setLevelId] = useState<string | undefined>();
|
||||
const [selectedTag, setSelectedTag] = useState<string>("all");
|
||||
|
||||
useEffect(() => {
|
||||
setLevelId(Cookies.get("urie"));
|
||||
|
|
@ -96,6 +100,7 @@ export default function NewsArticleList({ kind, typeId }: Props) {
|
|||
showCancelButton: true,
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
|
||||
loading();
|
||||
try {
|
||||
const res = await deleteArticle(String(id));
|
||||
|
|
@ -108,12 +113,56 @@ export default function NewsArticleList({ kind, typeId }: Props) {
|
|||
return;
|
||||
}
|
||||
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 {
|
||||
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 basePath = articleListPath(kind);
|
||||
|
||||
|
|
@ -122,12 +171,13 @@ export default function NewsArticleList({ kind, typeId }: Props) {
|
|||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-slate-800">
|
||||
News & Articles — {label}
|
||||
News & Articles
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{isContributorRole(levelId) && (
|
||||
<Link href={`${basePath}/create`}>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 rounded-lg">
|
||||
|
|
@ -138,6 +188,33 @@ export default function NewsArticleList({ kind, typeId }: Props) {
|
|||
)}
|
||||
</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="relative flex-1">
|
||||
<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>
|
||||
|
||||
{/* TABLE */}
|
||||
<Card className="rounded-2xl border shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-3">Article</TableHead>
|
||||
<TableHead>Tags</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Author</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{articles.length > 0 ? (
|
||||
articles.map((article) => (
|
||||
<TableRow key={article.id}>
|
||||
<TableCell className="font-medium max-w-xs pl-3">
|
||||
{article.title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-600 line-clamp-2">
|
||||
{article.tags || "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const status = getStatus(article);
|
||||
return (
|
||||
<span
|
||||
className={`px-3 py-1 text-xs rounded-full font-medium ${statusClass(
|
||||
status,
|
||||
)}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<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" 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)}
|
||||
{filteredArticles.length > 0 ? (
|
||||
filteredArticles.map((article) => {
|
||||
const status = getStatus(article);
|
||||
|
||||
return (
|
||||
<TableRow key={article.id}>
|
||||
<TableCell className="font-medium pl-3">
|
||||
{article.title}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{article.categoryName ? (
|
||||
<Badge className="bg-blue-100 text-blue-700 rounded-full">
|
||||
{article.categoryName}
|
||||
</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{article.createdByName || "—"}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<span
|
||||
className={`px-3 py-1 text-xs rounded-full font-medium ${statusClass(
|
||||
status,
|
||||
)}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
{status}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<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>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-slate-500">
|
||||
No articles yet. Create one to get started.
|
||||
<TableCell colSpan={6} className="text-center py-8">
|
||||
No articles found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* PAGINATION */}
|
||||
<div className="flex items-center justify-between p-4 border-t text-sm text-slate-500">
|
||||
<p>
|
||||
Page {page} of {totalPage}
|
||||
|
|
@ -235,13 +326,15 @@ export default function NewsArticleList({ kind, typeId }: Props) {
|
|||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button size="sm" className="bg-blue-600">
|
||||
{page}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
Loading…
Reference in New Issue