qudoco-fe/components/main/news-article-list.tsx

353 lines
10 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Search, Plus, Eye, Pencil, Trash2 } from "lucide-react";
import Link from "next/link";
import { getArticlePagination, deleteArticle } from "@/service/article";
import { formatDate } from "@/utils/global";
import { close, loading } from "@/config/swal";
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";
type Props = {
kind: ArticleContentKind;
typeId: number;
};
export default function NewsArticleList({ kind, typeId }: Props) {
const [articles, setArticles] = useState<any[]>([]);
const [page, setPage] = useState(1);
const [totalPage, setTotalPage] = useState(1);
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"));
}, []);
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 350);
return () => clearTimeout(t);
}, [search]);
const fetchData = useCallback(async () => {
loading();
try {
const req = {
limit: "10",
page,
title: debouncedSearch,
source: "",
categoryId: null,
search: "",
sort: "desc",
sortBy: "created_at",
typeId,
};
const res = await getArticlePagination(req as any);
const payload = (res as any)?.data;
const data = payload?.data ?? [];
setArticles(Array.isArray(data) ? data : []);
setTotalPage(payload?.meta?.totalPage ?? 1);
} finally {
close();
}
}, [page, debouncedSearch, typeId]);
useEffect(() => {
fetchData();
}, [fetchData]);
const getStatus = (article: any) => {
if (article.isDraft) return "Draft";
if (article.publishStatus?.toLowerCase() === "cancel") return "Pending";
if (article.isPublish) return "Published";
return "Pending";
};
const statusClass = (status: string) => {
const v = status.toLowerCase();
if (v === "published") return "bg-green-100 text-green-700";
if (v === "pending") return "bg-yellow-100 text-yellow-700";
if (v === "draft") return "bg-gray-200 text-gray-600";
return "bg-gray-200 text-gray-600";
};
async function handleDelete(id: number) {
const ok = await Swal.fire({
icon: "warning",
title: "Delete this article?",
showCancelButton: true,
});
if (!ok.isConfirmed) return;
loading();
try {
const res = await deleteArticle(String(id));
if ((res as any)?.error) {
await Swal.fire({
icon: "error",
title: "Delete failed",
text: String((res as any)?.message ?? ""),
});
return;
}
await fetchData();
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);
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-slate-800">
News & Articles
</h1>
<p className="text-sm text-slate-500 mt-1">
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">
<Plus className="w-4 h-4 mr-2" />
New {label} article
</Button>
</Link>
)}
</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" />
<Input
placeholder="Search by title..."
className="pl-9"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
</div>
</div>
{/* TABLE */}
<Card className="rounded-2xl border shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-3">Article</TableHead>
<TableHead>Category</TableHead>
<TableHead>Author</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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,
)}`}
>
{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={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}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<Button size="sm" className="bg-blue-600">
{page}
</Button>
<Button
variant="outline"
size="sm"
disabled={page === totalPage}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}