367 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|