feat(guides): add structured image-first editor
CI / Verify (push) Successful in 1m0s
CI / Build immutable images and deploy (push) Failing after 2m38s

This commit is contained in:
2026-08-29 13:30:52 +00:00 Unverified
parent b8e3b99033
commit dc5c667b4e
36 changed files with 5611 additions and 381 deletions
+93
View File
@@ -0,0 +1,93 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { ArrowLeftIcon, CheckIcon, SearchIcon } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { createStructuredGuide } from "@/app/admin/actions";
import { MediaUploader, type UploadedMedia } from "@/components/admin/media-uploader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
type Character = { key: string; name: string; element: string | null; rarity: number | null; imageUrl: string };
export function CreateGuideFlow({ characters, existingKeys }: { characters: Character[]; existingKeys: string[] }) {
const router = useRouter();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Character | null>(null);
const [cover, setCover] = useState<UploadedMedia | null>(null);
const [overview, setOverview] = useState("");
const [pending, startTransition] = useTransition();
const existing = useMemo(() => new Set(existingKeys), [existingKeys]);
const filtered = characters.filter((character) => character.name.toLowerCase().includes(query.trim().toLowerCase()));
if (selected) {
return (
<Card className="mx-auto max-w-2xl">
<CardHeader>
<CardTitle>ตั้งค่า Guide ของ {selected.name}</CardTitle>
<CardDescription>เพิ่มภาพหน้าปกและ Overview ก่อนสร้าง Guide สาธารณะ</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>Guide Image</FieldLabel>
{cover ? (
<div className="relative aspect-video overflow-hidden rounded-lg border">
<Image src={cover.url} alt={`Guide cover ${selected.name}`} fill className="object-cover" />
</div>
) : null}
<MediaUploader onReady={setCover} />
</Field>
<Field>
<FieldLabel htmlFor="overview">Overview</FieldLabel>
<Textarea id="overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} placeholder="สรุปแนวทางเล่น จุดเด่น และสิ่งที่ผู้ชมจะพบใน Guide นี้" />
<FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription>
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="justify-between">
<Button type="button" variant="outline" onClick={() => setSelected(null)}><ArrowLeftIcon data-icon="inline-start" />เลือกใหม่</Button>
<Button disabled={pending || !cover || !overview.trim()} onClick={() => startTransition(async () => {
const result = await createStructuredGuide({ characterKey: selected.key, coverMediaId: cover?.id, overview });
if (result.status === "error") { toast.error(result.message); return; }
toast.success("สร้าง Guide แล้ว");
router.push(result.location!);
})}>{pending ? <Spinner data-icon="inline-start" /> : <CheckIcon data-icon="inline-start" />}สร้าง Guide</Button>
</CardFooter>
</Card>
);
}
return (
<div className="flex flex-col gap-6">
<InputGroup className="max-w-xl">
<InputGroupAddon><SearchIcon /></InputGroupAddon>
<InputGroupInput value={query} onChange={(event) => setQuery(event.target.value)} placeholder="ค้นหาตัวละคร…" aria-label="ค้นหาตัวละคร" />
</InputGroup>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{filtered.map((character) => {
const disabled = existing.has(character.key);
return (
<Card key={character.key} className="overflow-hidden py-0">
<div className="relative aspect-square bg-muted"><Image src={character.imageUrl} alt={character.name} fill className="object-cover" /></div>
<CardHeader className="pt-4">
<CardTitle>{character.name}</CardTitle>
<CardDescription>{character.element ?? "Unknown element"}</CardDescription>
</CardHeader>
<CardContent className="flex gap-2">{character.rarity ? <Badge variant="secondary">{character.rarity}★</Badge> : null}{disabled ? <Badge variant="outline">มี Guide แล้ว</Badge> : null}</CardContent>
<CardFooter className="pb-4"><Button className="w-full" disabled={disabled} onClick={() => setSelected(character)}>เลือกตัวละคร</Button></CardFooter>
</Card>
);
})}
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { SaveIcon } from "lucide-react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { saveGuideOverview } from "@/app/admin/actions";
import { MediaUploader } from "@/components/admin/media-uploader";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
export function GuideOverviewForm({ guide }: { guide: { id: string; version: number; overview: string; coverMediaId: string; coverUrl: string; name: string } }) {
const router = useRouter();
const [overview, setOverview] = useState(guide.overview);
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl });
const [pending, startTransition] = useTransition();
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative aspect-video max-w-2xl overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field></FieldGroup></CardContent><CardFooter className="justify-end"><Button disabled={pending || !overview.trim()} onClick={() => startTransition(async () => { const result = await saveGuideOverview(guide.id, { expectedVersion: guide.version, coverMediaId: cover.id, overview }); if (result.status === "error") { toast.error(result.message); return; } toast.success("บันทึก Overview แล้ว"); router.refresh(); })}>{pending ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}Save</Button></CardFooter></Card>;
}
File diff suppressed because one or more lines are too long
+12 -37
View File
@@ -11,10 +11,8 @@ import { IMAGE_MIME_TYPES, MAX_MEDIA_BYTES } from "@/lib/media/validation";
export interface UploadedMedia {
id: string;
fileName: string;
mimeType: string;
byteSize: number;
url: string;
fileName: string;
}
export function MediaUploader({
@@ -23,7 +21,7 @@ export function MediaUploader({
onReady: (media: UploadedMedia) => void;
}) {
const inputId = useId();
const [state, setState] = useState<"idle" | "preparing" | "uploading" | "checking">("idle");
const [state, setState] = useState<"idle" | "uploading">("idle");
const busy = state !== "idle";
async function upload(file: File) {
@@ -36,41 +34,20 @@ export function MediaUploader({
return;
}
let mediaId: string | undefined;
try {
setState("preparing");
const presignResponse = await fetch("/api/admin/media/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileName: file.name, mimeType: file.type, byteSize: file.size }),
});
if (!presignResponse.ok) throw new Error("presign");
const upload = await presignResponse.json() as {
mediaId: string;
uploadUrl: string;
headers: Record<string, string>;
};
mediaId = upload.mediaId;
setState("uploading");
const putResponse = await fetch(upload.uploadUrl, {
method: "PUT",
headers: upload.headers,
body: file,
const body = new FormData();
body.set("file", file);
const response = await fetch("/api/admin/media", {
method: "POST",
body,
});
if (!putResponse.ok) throw new Error("upload");
setState("checking");
const completeResponse = await fetch(`/api/admin/media/${mediaId}/complete`, { method: "POST" });
if (!completeResponse.ok) throw new Error("complete");
const result = await completeResponse.json() as { media: UploadedMedia };
onReady(result.media);
const result = await response.json() as UploadedMedia & { error?: string };
if (!response.ok) throw new Error(result.error || "upload");
onReady(result);
toast.success("อัปโหลดรูปภาพแล้ว");
} catch {
if (mediaId) {
await fetch(`/api/admin/media/${mediaId}`, { method: "DELETE" }).catch(() => undefined);
}
toast.error("อัปโหลดไม่สำเร็จ ไฟล์ชั่วคราวถูกเก็บกวาดแล้ว");
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : "อัปโหลดไม่สำเร็จ");
} finally {
setState("idle");
}
@@ -99,9 +76,7 @@ export function MediaUploader({
render={<label htmlFor={inputId} />}
>
{busy ? <Spinner /> : <ImagePlusIcon />}
{state === "preparing" && "กำลังเตรียม…"}
{state === "uploading" && "กำลังอัปโหลด…"}
{state === "checking" && "กำลังตรวจสอบไฟล์…"}
{state === "idle" && "อัปโหลดและเพิ่มรูปภาพ"}
{!busy ? <UploadCloudIcon className="ml-auto" /> : null}
</Button>
+60
View File
@@ -0,0 +1,60 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { PlusIcon, RefreshCwIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { useEffect, useTransition } from "react";
import { toast } from "sonner";
import { deleteStructuredGuide, moveGuideToTrash, restoreStructuredGuide, startCatalogSync } from "@/app/admin/actions";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
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 { Spinner } from "@/components/ui/spinner";
type GuideCard = { id: string; characterKey: string; name: string; overview: string; coverUrl: string; version: number; updatedAt: Date; trashedAt: Date | null };
type SyncJob = { id: string; status: string; version: string | null; progress: number; total: number; error: string | null } | null;
export function StructuredDashboard({ guides, trash, syncJob, catalogVersion }: { guides: GuideCard[]; trash: GuideCard[]; syncJob: SyncJob; catalogVersion: string | null }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const syncing = syncJob?.status === "pending" || syncJob?.status === "processing";
useEffect(() => {
if (!syncing) return;
const timer = window.setInterval(() => router.refresh(), 3_000);
return () => window.clearInterval(timer);
}, [router, syncing]);
function run(action: () => Promise<{ status: string; message?: string }>, success: string) {
startTransition(async () => {
const result = await action();
if (result.status === "error") toast.error(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 items-center gap-2"><Badge variant="outline">Lunaris {catalogVersion ?? "ยังไม่ Sync"}</Badge>{syncJob ? <Badge variant={syncJob.status === "failed" ? "destructive" : "secondary"}>{syncJob.status}{syncing && syncJob.total ? ` ${syncJob.progress}/${syncJob.total}` : ""}</Badge> : null}</div>
<div className="flex gap-2"><Button variant="outline" disabled={pending || syncing} onClick={() => run(startCatalogSync, "เริ่ม Sync แล้ว")}>{pending || syncing ? <Spinner data-icon="inline-start" /> : <RefreshCwIcon data-icon="inline-start" />}Sync Catalog</Button><Button render={<Link href="/admin/create" />} nativeButton={false} disabled={!catalogVersion}><PlusIcon data-icon="inline-start" />สร้าง Guide</Button></div>
</div>
{syncJob?.status === "failed" ? <Alert variant="destructive"><AlertTitle>Sync ไม่สำเร็จ</AlertTitle><AlertDescription>{syncJob.error}</AlertDescription></Alert> : null}
{guides.length ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">{guides.map((guide) => (
<Card key={guide.id} className="overflow-hidden py-0">
<div className="relative aspect-video bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill className="object-cover" /></div>
<CardHeader className="pt-5"><CardTitle>{guide.name}</CardTitle><CardDescription className="line-clamp-2">{guide.overview}</CardDescription><CardAction><Badge variant="secondary">v{guide.version}</Badge></CardAction></CardHeader>
<CardContent><p className="text-xs text-muted-foreground">แก้ไขล่าสุด {new Date(guide.updatedAt).toLocaleString("th-TH")}</p></CardContent>
<CardFooter className="justify-between pb-5"><Button render={<Link href={`/admin/${guide.characterKey}`} />} nativeButton={false}>แก้ไข Guide</Button><AlertDialog><AlertDialogTrigger render={<Button variant="ghost" size="icon" />}><Trash2Icon /><span className="sr-only">ย้าย {guide.name} ไปถังขยะ</span></AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ย้าย {guide.name} ไปถังขยะ?</AlertDialogTitle><AlertDialogDescription>Guide จะหายจากหน้าเว็บไซต์ทันที และสามารถกู้คืนได้ภายหลัง</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction onClick={() => run(() => moveGuideToTrash(guide.id), "ย้ายไปถังขยะแล้ว")}>ย้ายไปถังขยะ</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></CardFooter>
</Card>
))}</div>
) : <Empty className="border"><EmptyHeader><EmptyTitle>ยังไม่มี Guide</EmptyTitle><EmptyDescription>Sync Catalog แล้วเลือกตัวละครเพื่อสร้าง Guide แรก</EmptyDescription></EmptyHeader><EmptyContent><Button render={<Link href="/admin/create" />} nativeButton={false} disabled={!catalogVersion}>สร้าง Guide</Button></EmptyContent></Empty>}
{trash.length ? <section className="flex flex-col gap-4"><div><h2 className="font-heading text-xl font-semibold">ถังขยะ</h2><p className="text-sm text-muted-foreground">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), "กู้คืน Guide แล้ว")}><RotateCcwIcon data-icon="inline-start" />กู้คืน</Button><AlertDialog><AlertDialogTrigger render={<Button variant="destructive" size="sm" />}><Trash2Icon data-icon="inline-start" />ลบถาวร</AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ลบ {guide.name} ถาวร?</AlertDialogTitle><AlertDialogDescription>ข้อมูล Weapons, Artifacts, Constellations, Team และ Extra ทั้งหมดจะกู้คืนไม่ได้</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction onClick={() => run(() => deleteStructuredGuide(guide.id), "ลบ Guide ถาวรแล้ว")}>ยืนยันลบถาวร</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></div></CardAction></CardHeader></Card>)}</div></section> : null}
</div>
);
}