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) {
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</div>
|
|
||||||
<div className="flex items-center text-blue-700 gap-2">
|
<div className="mt-3 border rounded-lg p-3 space-y-2">
|
||||||
<Mail size={20} />
|
<p className="text-sm font-semibold">Description:</p>
|
||||||
<p className="text-sm ">Suggestion Box (0)</p>
|
|
||||||
</div>
|
{detailData?.isPublish ? (
|
||||||
<div className="border p-3 border-black rounded-lg space-y-2 ">
|
<span className="inline-block bg-green-100 text-green-700 text-xs px-3 py-1 rounded-full">
|
||||||
<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
|
Approved
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-block bg-yellow-100 text-yellow-700 text-xs font-semibold px-3 py-1 rounded-full">
|
<span className="inline-block bg-yellow-100 text-yellow-700 text-xs px-3 py-1 rounded-full">
|
||||||
Pending
|
Pending
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-black font-semibold">Comment</p>
|
|
||||||
<h2 className="text-blue-600 text-xs">View Approver History</h2>
|
<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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,34 +230,45 @@ 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) => {
|
||||||
|
const status = getStatus(article);
|
||||||
|
|
||||||
|
return (
|
||||||
<TableRow key={article.id}>
|
<TableRow key={article.id}>
|
||||||
<TableCell className="font-medium max-w-xs pl-3">
|
<TableCell className="font-medium pl-3">
|
||||||
{article.title}
|
{article.title}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="text-sm text-slate-600 line-clamp-2">
|
{article.categoryName ? (
|
||||||
{article.tags || "—"}
|
<Badge className="bg-blue-100 text-blue-700 rounded-full">
|
||||||
</span>
|
{article.categoryName}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{article.createdByName || "—"}</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{(() => {
|
|
||||||
const status = getStatus(article);
|
|
||||||
return (
|
|
||||||
<span
|
<span
|
||||||
className={`px-3 py-1 text-xs rounded-full font-medium ${statusClass(
|
className={`px-3 py-1 text-xs rounded-full font-medium ${statusClass(
|
||||||
status,
|
status,
|
||||||
|
|
@ -188,26 +276,27 @@ export default function NewsArticleList({ kind, typeId }: Props) {
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>{formatDate(article.createdAt)}</TableCell>
|
<TableCell>{formatDate(article.createdAt)}</TableCell>
|
||||||
|
|
||||||
<TableCell className="text-right space-x-1">
|
<TableCell className="text-right space-x-1">
|
||||||
<Link href={`/admin/news-article/detail/${article.id}`}>
|
<Link href={`/admin/news-article/detail/${article.id}`}>
|
||||||
<Button size="icon" variant="ghost" type="button">
|
<Button size="icon" variant="ghost">
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/admin/news-article/detail/${article.id}`}>
|
|
||||||
<Button size="icon" variant="ghost" type="button">
|
<Link href={`/admin/news-article/update/${article.id}`}>
|
||||||
|
<Button size="icon" variant="ghost">
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isContributorRole(levelId) && (
|
{isContributorRole(levelId) && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
type="button"
|
|
||||||
onClick={() => handleDelete(article.id)}
|
onClick={() => handleDelete(article.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
|
@ -215,17 +304,19 @@ export default function NewsArticleList({ kind, typeId }: Props) {
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue