feat(guides): autosave structured guide data
CI / Verify (push) Successful in 57s
CI / Build immutable images and deploy (push) Successful in 3m10s

This commit is contained in:
2026-08-29 18:13:02 +00:00 Unverified
parent c4fcf3f996
commit ac9a733370
14 changed files with 3794 additions and 68 deletions
+13
View File
@@ -0,0 +1,13 @@
import { AlertCircleIcon, CheckIcon, ClockIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import type { AutosaveState } from "@/hooks/use-autosave";
export function AutosaveStatus({ state }: { state: AutosaveState }) {
if (state === "saving") return <Badge variant="secondary"><Spinner data-icon="inline-start" />Saving…</Badge>;
if (state === "unsaved") return <Badge variant="outline"><ClockIcon data-icon="inline-start" />Waiting to save…</Badge>;
if (state === "incomplete") return <Badge variant="outline"><AlertCircleIcon data-icon="inline-start" />Complete required fields</Badge>;
if (state === "error") return <Badge variant="destructive"><AlertCircleIcon data-icon="inline-start" />Autosave failed</Badge>;
return <Badge variant="secondary"><CheckIcon data-icon="inline-start" />Saved</Badge>;
}
+11 -9
View File
@@ -1,23 +1,25 @@
"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 { useState } from "react";
import { saveGuideOverview } from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
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";
import { useAutosave } from "@/hooks/use-autosave";
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>;
const value = { coverMediaId: cover.id, overview };
const autosave = useAutosave({
value,
valid: Boolean(cover.id && overview.trim()),
initialVersion: guide.version,
persist: (next, expectedVersion) => saveGuideOverview(guide.id, { ...next, expectedVersion }),
});
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"><AutosaveStatus state={autosave} /></CardFooter></Card>;
}
File diff suppressed because one or more lines are too long