Files
buzz-sheet/components/admin/structured-dashboard.tsx
gunshiz 316a1eecfa
CI / Build immutable images and deploy (push) Has been cancelled
CI / Verify (push) Has been cancelled
fix(ui) : keep technical labels English and compact badges
2026-09-14 17:43:29 +07:00

367 lines
14 KiB
TypeScript

"use client";
import { useTranslation } from "@/components/i18n/locale-provider";
import { searchItems } from "@/lib/search";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
PlusIcon,
RefreshCwIcon,
RotateCcwIcon,
SearchIcon,
XCircleIcon,
Trash2Icon,
} from "lucide-react";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import {
deleteStructuredGuide,
cancelCatalogSync,
getCatalogSyncProgress,
moveGuideToTrash,
restoreStructuredGuide,
startCatalogSync,
} from "@/app/admin/actions";
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
import { GuideVisibilityControl } from "@/components/admin/guide-visibility-control";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
import { useAdminPresence } from "@/hooks/use-presence";
import type { CatalogSyncJob } from "@/lib/catalog/sync";
import { cn } from "@/lib/utils";
type GuideCard = {
id: string;
characterKey: string;
name: string;
overview: string;
coverUrl: string;
version: number;
updatedAt: Date;
trashedAt: Date | null;
isPublic: boolean;
};
export function StructuredDashboard({
guides,
trash,
catalogVersion,
}: {
guides: GuideCard[];
trash: GuideCard[];
catalogVersion: string | null;
}) {
const { t, locale } = useTranslation();
const router = useRouter();
const [pending, startTransition] = useTransition();
const [query, setQuery] = useState("");
const [syncJob, setSyncJob] = useState<CatalogSyncJob | null>(null);
const presence = useAdminPresence(null);
const catalogReady = Boolean(catalogVersion);
const syncJobId = syncJob?.id;
const syncJobStatus = syncJob?.status;
const search = searchItems(guides, query,
(guide) => `${guide.name} ${guide.characterKey} ${guide.overview}`,
(guide) => [guide.name]);
const filteredGuides = search.items;
useEffect(() => {
if (!syncJobId || !syncJobStatus || ["completed", "unchanged", "cancelled", "error"].includes(syncJobStatus)) return;
let stopped = false;
const poll = async () => {
const latest = await getCatalogSyncProgress(syncJobId);
if (!stopped && latest) setSyncJob(latest);
};
void poll();
const timer = window.setInterval(() => void poll(), 1000);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [syncJobId, syncJobStatus]);
function startSync() {
startTransition(async () => {
const result = await startCatalogSync();
if (result.status === "error") toast.error(t(result.message ?? "กรุณาตรวจสอบข้อมูลให้ถูกต้อง"));
else setSyncJob(result.job);
});
}
function cancelSync() {
if (!syncJob) return;
startTransition(async () => {
const result = await cancelCatalogSync(syncJob.id);
if (result) setSyncJob(result);
});
}
function run(
action: () => Promise<{ status: string; message?: string }>,
success: string,
) {
startTransition(async () => {
const result = await action();
if (result.status === "error") toast.error(t(result.message ?? "กรุณาตรวจสอบข้อมูลให้ถูกต้อง"));
else {
toast.success(success);
router.refresh();
}
});
}
return (
<div className="flex flex-col gap-10">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{t("Lunaris ที่ Sync:")}{" "}{" "}
{catalogVersion ? `v${catalogVersion}` : t("ยังไม่ Sync")}
</Badge>
</div>
<div className="flex gap-2">
<Button
variant="outline"
disabled={pending || Boolean(syncJob && !["completed", "unchanged", "cancelled", "error"].includes(syncJob.status))}
onClick={startSync}
>
{pending ? (
<Spinner data-icon="inline-start" />
) : (
<RefreshCwIcon data-icon="inline-start" />
)}
{t("Sync Catalog")}</Button>
{syncJob && !["completed", "unchanged", "cancelled", "error"].includes(syncJob.status) ? (
<Button variant="destructive" disabled={pending} onClick={cancelSync}>
<XCircleIcon data-icon="inline-start" />
{t("Cancel Sync")}</Button>
) : null}
<Button
render={<Link href="/admin/create" />}
nativeButton={false}
disabled={!catalogReady}
>
<PlusIcon data-icon="inline-start" />
{t("สร้าง Guide")}</Button>
</div>
</div>
{syncJob ? (
<Card>
<CardContent className="space-y-2 pt-6">
<div className="flex items-center justify-between gap-3 text-sm">
<span>{t(syncJob.message)}</span>
<span className="text-muted-foreground">{syncJob.total ? `${syncJob.completed}/${syncJob.total}` : t("กำลังเริ่ม…")}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full bg-primary transition-all", syncJob.total ? "" : "w-1/3 animate-pulse")} style={syncJob.total ? { width: `${Math.min(100, (syncJob.completed / syncJob.total) * 100)}%` } : undefined} />
</div>
{syncJob.status === "error" ? <p className="text-sm text-destructive">{t(syncJob.error ?? "Sync ไม่สำเร็จ")}</p> : null}
</CardContent>
</Card>
) : null}
{guides.length ? (
<InputGroup className="h-11 max-w-2xl">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("ค้นหาตัวละคร…")}
aria-label={t("ค้นหาตัวละคร")}
/>
</InputGroup>
) : null}
{filteredGuides.length ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{filteredGuides.map((guide) => (
<Card
key={guide.id}
className="relative overflow-hidden py-0 transition-colors hover:bg-muted/50"
>
<Link
href={`/admin/${guide.characterKey}`}
prefetch={false}
className="absolute inset-0 z-10 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="sr-only">{t("แก้ไข Guide")}{" "}{guide.name}</span>
</Link>
<div className="relative aspect-square bg-muted">
<Image
src={guide.coverUrl}
alt={`Guide ${guide.name}`}
fill
sizes="(min-width: 1280px) 33vw, (min-width: 768px) 50vw, 100vw"
className="object-cover"
/>
</div>
<CardHeader className="pt-5">
<CardTitle>{guide.name}</CardTitle>
<CardDescription className="line-clamp-2">
{guide.overview}
</CardDescription>
<CardAction>
<div className="flex flex-col items-end gap-1">
<Badge variant="secondary">{`v${guide.version}`}</Badge>
{(presence?.editorsByGuide[guide.id] ?? []).map((editor) => (
<Badge key={editor.userId} variant="outline">
{editor.name}
{editor.sections.length ? ` · ${editor.sections.join(", ")}` : ""}
</Badge>
))}
</div>
</CardAction>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{t("แก้ไขล่าสุด")}{" "}{" "}
{new Date(guide.updatedAt).toLocaleString(locale === "th" ? "th-TH" : "en-GB")}
</p>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-2 pb-5">
<Button
className="relative z-20"
render={<Link href={`/admin/${guide.characterKey}`} />}
nativeButton={false}
>
{t("แก้ไข Guide")}</Button>
<GuideVisibilityControl
className="relative z-20"
guideId={guide.id}
guideName={guide.name}
isPublic={guide.isPublic}
/>
<DeleteConfirmationDialog
showSuccessToast={false}
trigger={
<Button
className="relative z-20"
variant="ghost"
size="icon"
/>
}
title={t("ย้าย {name} ไปถังขยะ?", { name: guide.name })}
description={t("Guide จะหายจากหน้าเว็บไซต์ทันที และสามารถกู้คืนได้ภายหลัง")}
confirmLabel={t("ย้ายไปถังขยะ")}
onConfirm={() =>
run(() => moveGuideToTrash(guide.id), t("ลบสำเร็จ!"))
}
>
<Trash2Icon />
<span className="sr-only">{t("ย้าย")}{" "}{guide.name} {t("ไปถังขยะ")}</span>
</DeleteConfirmationDialog>
</CardFooter>
</Card>
))}
</div>
) : guides.length ? (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>{t("ไม่พบตัวละคร")}</EmptyTitle>
<EmptyDescription>{t("ลองค้นหาด้วยชื่อตัวละครอื่น")}</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>{t("ยังไม่มี Guide")}</EmptyTitle>
<EmptyDescription>
{t("Sync Catalog แล้วเลือกตัวละครเพื่อสร้าง Guide แรก")}</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
render={<Link href="/admin/create" />}
nativeButton={false}
disabled={!catalogReady}
>
{t("สร้าง Guide")}</Button>
</EmptyContent>
</Empty>
)}
{trash.length ? (
<section className="flex flex-col gap-4">
<div>
<h2 className="font-heading text-xl font-semibold">{t("ถังขยะ")}</h2>
<p className="text-sm text-muted-foreground">
{t("Guide ที่ซ่อนไว้จากเว็บไซต์")}</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
{trash.map((guide) => (
<Card key={guide.id}>
<CardHeader>
<CardTitle>{guide.name}</CardTitle>
<CardDescription>{guide.overview}</CardDescription>
<CardAction>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={pending}
onClick={() =>
run(
() => restoreStructuredGuide(guide.id),
t("กู้คืน Guide แล้ว"),
)
}
>
<RotateCcwIcon data-icon="inline-start" />
{t("กู้คืน")}</Button>
<DeleteConfirmationDialog
showSuccessToast={false}
trigger={
<Button
variant="destructive"
size="sm"
disabled={pending}
/>
}
title={t("ลบ {name} ถาวร?", { name: guide.name })}
description={t("ข้อมูล Weapons, Artifacts, Constellations, Team และ Extra ทั้งหมดจะกู้คืนไม่ได้")}
confirmLabel={t("ยืนยันลบถาวร")}
onConfirm={() =>
run(
() => deleteStructuredGuide(guide.id),
t("ลบสำเร็จ!"),
)
}
>
<Trash2Icon data-icon="inline-start" />
{t("ลบถาวร")}</DeleteConfirmationDialog>
</div>
</CardAction>
</CardHeader>
</Card>
))}
</div>
</section>
) : null}
</div>
);
}