qudoco-fe/components/main/my-content.tsx

468 lines
15 KiB
TypeScript
Raw Normal View History

2026-02-17 10:02:35 +00:00
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
2026-04-14 06:39:30 +00:00
import {
Search,
Filter,
ChevronLeft,
ChevronRight,
FileText,
FilePenLine,
Clock3,
CheckCircle2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import Cookies from "js-cookie";
import { formatDate } from "@/utils/global";
import { listCmsContentSubmissions } from "@/service/cms-content-submissions";
import { getArticlesForMyContent } from "@/service/article";
import { apiPayload } from "@/service/cms-landing";
import { Loader2 } from "lucide-react";
import {
isApproverOrAdmin,
isContributorRole,
} from "@/constants/user-roles";
const PLACEHOLDER_IMG =
"https://placehold.co/400x240/f1f5f9/64748b?text=Content";
type CmsRow = {
id: string;
domain: string;
title: string;
status: string;
submitter_name: string;
submitted_by_id: number;
created_at: string;
};
2026-02-17 10:02:35 +00:00
type ArticleRow = {
id: number;
title: string;
thumbnailUrl?: string;
isDraft?: boolean;
isPublish?: boolean;
publishStatus?: string;
statusId?: number | null;
createdAt?: string;
created_at?: string;
createdByName?: string;
};
2026-02-17 10:02:35 +00:00
type UnifiedStatus = "draft" | "pending" | "approved" | "rejected";
2026-02-17 10:02:35 +00:00
type UnifiedItem = {
key: string;
source: "website" | "news";
title: string;
thumb: string;
status: UnifiedStatus;
statusLabel: string;
date: string;
href: string;
};
2026-02-17 10:02:35 +00:00
function cmsToUnified(r: CmsRow): UnifiedItem {
const st = (r.status || "").toLowerCase();
let status: UnifiedStatus = "pending";
let statusLabel = "Pending Approval";
if (st === "approved") {
status = "approved";
statusLabel = "Approved";
} else if (st === "rejected") {
status = "rejected";
statusLabel = "Rejected";
} else if (st === "pending") {
status = "pending";
statusLabel = "Pending Approval";
}
return {
key: `cms-${r.id}`,
source: "website",
title: r.title,
thumb: PLACEHOLDER_IMG,
status,
statusLabel,
date: r.created_at,
href: "/admin/content-website",
};
}
function articleHistoryStatus(a: ArticleRow): UnifiedStatus {
if (a.isDraft) return "draft";
if (a.isPublish) return "approved";
const ps = (a.publishStatus || "").toLowerCase();
if (ps.includes("reject")) return "rejected";
if (a.statusId === 3) return "rejected";
return "pending";
}
function articleStatusLabel(s: UnifiedStatus): string {
switch (s) {
case "draft":
return "Draft";
case "pending":
return "Pending Approval";
case "approved":
return "Approved";
case "rejected":
return "Rejected";
2026-02-17 10:02:35 +00:00
default:
return "Pending Approval";
2026-02-17 10:02:35 +00:00
}
}
function articleToUnified(r: ArticleRow): UnifiedItem {
const status = articleHistoryStatus(r);
const rawDate = r.createdAt ?? r.created_at ?? "";
return {
key: `art-${r.id}`,
source: "news",
title: r.title || "Untitled",
thumb: r.thumbnailUrl?.trim() || PLACEHOLDER_IMG,
status,
statusLabel: articleStatusLabel(status),
date: rawDate,
href: `/admin/news-article/detail/${r.id}`,
};
}
function statusBadgeClass(status: UnifiedStatus): string {
switch (status) {
case "pending":
return "bg-yellow-100 text-yellow-800 border-yellow-200";
case "approved":
return "bg-green-100 text-green-800 border-green-200";
case "rejected":
return "bg-red-100 text-red-800 border-red-200";
case "draft":
default:
return "bg-slate-100 text-slate-700 border-slate-200";
}
}
const PAGE_SIZE = 8;
2026-02-17 10:02:35 +00:00
export default function MyContent() {
const [levelId, setLevelId] = useState<string | undefined>();
2026-02-17 10:02:35 +00:00
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [sourceFilter, setSourceFilter] = useState<"all" | "news" | "website">(
"all",
);
const [statusFilter, setStatusFilter] = useState<
"all" | UnifiedStatus
>("all");
const [loading, setLoading] = useState(true);
const [cmsRows, setCmsRows] = useState<CmsRow[]>([]);
const [articleRows, setArticleRows] = useState<ArticleRow[]>([]);
const [page, setPage] = useState(1);
const isContributor = isContributorRole(levelId);
const isApprover = isApproverOrAdmin(levelId);
useEffect(() => {
setLevelId(Cookies.get("urie"));
}, []);
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 350);
return () => clearTimeout(t);
}, [search]);
const load = useCallback(async () => {
if (levelId === undefined) return;
setLoading(true);
try {
const cmsMine = isContributor;
const cmsRes = await listCmsContentSubmissions({
status: "all",
mine: cmsMine,
page: 1,
limit: 500,
});
const cmsData = apiPayload<CmsRow[]>(cmsRes);
setCmsRows(Array.isArray(cmsData) ? cmsData : []);
const artMode = isApprover ? "approver" : "own";
const artRes = await getArticlesForMyContent({
mode: artMode,
page: 1,
limit: 500,
});
const artData = apiPayload<ArticleRow[]>(artRes);
setArticleRows(Array.isArray(artData) ? artData : []);
} finally {
setLoading(false);
}
}, [levelId, isContributor, isApprover]);
useEffect(() => {
load();
}, [load]);
const mergedAll = useMemo(() => {
const cms = cmsRows.map(cmsToUnified);
const arts = articleRows.map(articleToUnified);
let list = [...cms, ...arts];
const q = search.trim().toLowerCase();
if (q) {
list = list.filter(
(x) =>
x.title.toLowerCase().includes(q) ||
x.statusLabel.toLowerCase().includes(q),
);
}
if (sourceFilter === "news") list = list.filter((x) => x.source === "news");
if (sourceFilter === "website")
list = list.filter((x) => x.source === "website");
if (statusFilter !== "all")
list = list.filter((x) => x.status === statusFilter);
list.sort((a, b) => (a.date < b.date ? 1 : -1));
return list;
}, [cmsRows, articleRows, debouncedSearch, sourceFilter, statusFilter]);
const stats = useMemo(() => {
const draft = mergedAll.filter((x) => x.status === "draft").length;
const pending = mergedAll.filter((x) => x.status === "pending").length;
const approved = mergedAll.filter((x) => x.status === "approved").length;
const rejected = mergedAll.filter((x) => x.status === "rejected").length;
return {
total: mergedAll.length,
draft,
pending,
approved,
rejected,
};
}, [mergedAll]);
const totalPages = Math.max(1, Math.ceil(mergedAll.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageItems = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return mergedAll.slice(start, start + PAGE_SIZE);
}, [mergedAll, currentPage]);
useEffect(() => {
setPage(1);
}, [sourceFilter, statusFilter, debouncedSearch, levelId]);
if (levelId === undefined) {
return (
<div className="flex min-h-[200px] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
</div>
);
}
2026-02-17 10:02:35 +00:00
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-semibold text-slate-900">My Content</h1>
<p className="text-sm text-muted-foreground mt-1">
Track all your content submissions and drafts.
</p>
<p className="text-xs text-slate-500 mt-2 max-w-3xl">
{isContributor
? "Riwayat konten Anda: perubahan Content Website (setelah diajukan) dan artikel. Buka item untuk mengedit atau melanjutkan persetujuan di halaman masing-masing."
: isApprover
? "Riwayat dari kontributor: Content Website dan News & Articles (tanpa draft). Persetujuan dilakukan di halaman Content Website atau detail artikel."
: "Ringkasan konten terkait akun Anda."}
2026-02-17 10:02:35 +00:00
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{[
2026-04-14 06:39:30 +00:00
{
title: "Total Content",
value: stats.total,
color: "bg-blue-500",
icon: FileText,
},
{
title: "Drafts",
value: stats.draft,
color: "bg-slate-600",
icon: FilePenLine,
},
{
title: "Pending",
value: stats.pending,
color: "bg-yellow-500",
icon: Clock3,
},
{
title: "Approved",
value: stats.approved,
color: "bg-green-600",
icon: CheckCircle2,
},
{
title: "Revision/Rejected",
value: stats.rejected,
color: "bg-red-600",
2026-04-14 06:39:30 +00:00
icon: XCircle,
},
2026-04-14 06:39:30 +00:00
].map((item) => {
const Icon = item.icon;
return (
<Card key={item.title} className="rounded-2xl shadow-sm">
2026-02-17 10:02:35 +00:00
<CardContent className="p-5 flex items-center gap-4">
<div
className={`w-12 h-12 rounded-xl flex items-center justify-center text-white shrink-0 ${item.color}`}
2026-04-14 06:39:30 +00:00
>
<Icon className="w-6 h-6" />
</div>
2026-02-17 10:02:35 +00:00
<div>
<p className="text-2xl font-bold text-slate-900">{item.value}</p>
2026-02-17 10:02:35 +00:00
<p className="text-sm text-muted-foreground">{item.title}</p>
</div>
</CardContent>
</Card>
2026-04-14 06:39:30 +00:00
);
})}
2026-02-17 10:02:35 +00:00
</div>
<div className="flex flex-col md:flex-row gap-3 md:items-center md:justify-between">
<div className="relative w-full md:max-w-md">
<Search className="absolute left-3 top-3 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search by title…"
2026-02-17 10:02:35 +00:00
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex flex-wrap gap-2 items-center">
<div className="flex items-center gap-2 rounded-lg border bg-white px-3 py-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<select
className="text-sm bg-transparent border-none outline-none"
value={sourceFilter}
onChange={(e) =>
setSourceFilter(e.target.value as typeof sourceFilter)
}
>
<option value="all">All sources</option>
<option value="news">News & Articles</option>
<option value="website">Content Website</option>
</select>
</div>
<div className="flex items-center gap-2 rounded-lg border bg-white px-3 py-2">
<select
className="text-sm bg-transparent border-none outline-none"
value={statusFilter}
onChange={(e) =>
setStatusFilter(e.target.value as typeof statusFilter)
}
>
<option value="all">All statuses</option>
<option value="draft">Draft</option>
<option value="pending">Pending Approval</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => load()}
disabled={loading}
>
Refresh
</Button>
</div>
2026-02-17 10:02:35 +00:00
</div>
{loading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-10 w-10 animate-spin text-[#966314]" />
</div>
) : pageItems.length === 0 ? (
<p className="text-center text-slate-500 py-16">No content found.</p>
) : (
<>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{pageItems.map((item) => (
<Link key={item.key} href={item.href} className="block group">
<Card className="rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all duration-200 bg-white h-full overflow-hidden">
<div className="flex items-center gap-1 flex-wrap px-3 pt-3">
<Badge
className={`${statusBadgeClass(item.status)} border font-medium`}
>
{item.statusLabel}
</Badge>
<Badge
variant="outline"
className="text-blue-600 border-blue-200 bg-blue-50"
>
{item.source === "news"
? "News & Articles"
: "Content Website"}
</Badge>
</div>
<div className="relative w-full aspect-[4/3] mt-2 bg-slate-100">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.thumb}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
<CardContent className="px-4 pb-4 pt-3">
<h3 className="text-sm font-semibold leading-snug line-clamp-2 text-slate-900 group-hover:text-blue-700">
{item.title}
</h3>
<p className="text-xs text-slate-500 mt-2">
{item.date ? formatDate(item.date) : "—"}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
2026-02-17 10:02:35 +00:00
<div className="flex flex-col sm:flex-row justify-center items-center gap-3 pt-6">
<p className="text-sm text-slate-500">
Showing {(currentPage - 1) * PAGE_SIZE + 1} to{" "}
{Math.min(currentPage * PAGE_SIZE, mergedAll.length)} of{" "}
{mergedAll.length} items
</p>
<div className="flex items-center gap-2">
<Button
type="button"
2026-02-17 10:02:35 +00:00
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
2026-02-17 10:02:35 +00:00
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<span className="text-sm text-slate-600 px-2">
Page {currentPage} / {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
2026-02-17 10:02:35 +00:00
</div>
</div>
</>
)}
2026-02-17 10:02:35 +00:00
</div>
);
}