feat : update
CI / Verify (push) Successful in 1m16s
CI / Build immutable images and deploy (push) Successful in 1m58s

This commit is contained in:
2026-09-05 22:36:31 +07:00 Unverified
parent 66238795bf
commit 2de07c4917
12 changed files with 637 additions and 146 deletions
+240
View File
@@ -0,0 +1,240 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowDownIcon,
ArrowUpIcon,
ArrowUpRightIcon,
PlusIcon,
} from "lucide-react";
import { useState, type ComponentProps } from "react";
import { toast } from "sonner";
import {
createGuideExtra,
reorderGuideExtras,
} from "@/app/admin/actions";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
type ExtraLink = { id: string; slug: string; title: string };
export function AddExtraButton({
guideId,
characterKey,
variant = "ghost",
size = "sm",
label = "เพิ่ม Extra",
}: {
guideId: string;
characterKey: string;
variant?: ComponentProps<typeof Button>["variant"];
size?: ComponentProps<typeof Button>["size"];
label?: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [pending, setPending] = useState(false);
const create = async () => {
const trimmedTitle = title.trim();
if (!trimmedTitle) return;
const browserWindow = window as Window & { __guideHasUnsavedChanges?: boolean };
if (
browserWindow.__guideHasUnsavedChanges &&
!window.confirm("ข้อมูลยังบันทึกไม่เสร็จ ต้องการสร้าง Extra ใหม่หรือไม่?")
) {
return;
}
setPending(true);
const result = await createGuideExtra(guideId, { title: trimmedTitle });
setPending(false);
if (result.status === "error") {
toast.error(result.message);
return;
}
setOpen(false);
setTitle("");
toast.success("สร้าง Extra แล้ว");
router.push(`/admin/${characterKey}/${result.section.slug}`);
router.refresh();
};
return (
<>
<Button
type="button"
variant={variant}
size={size}
aria-label={label || "เพิ่ม Extra"}
onClick={() => setOpen(true)}
>
<PlusIcon data-icon="inline-start" />
{label}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>เพิ่ม Extra section</DialogTitle>
<DialogDescription>
ตั้งชื่อส่วนเสริม ระบบจะสร้างและเปิดหน้าแก้ไขให้ทันที
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault();
void create();
}}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="new-extra-title">ชื่อ Extra</FieldLabel>
<Input
id="new-extra-title"
value={title}
maxLength={160}
autoFocus
onChange={(event) => setTitle(event.target.value)}
/>
</Field>
</FieldGroup>
<DialogFooter className="mt-4">
<Button type="submit" disabled={!title.trim() || pending}>
{pending ? <Spinner data-icon="inline-start" /> : <PlusIcon data-icon="inline-start" />}
สร้าง Extra
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
export function ExtraOverview({
guideId,
characterKey,
initialVersion,
initialExtras,
}: {
guideId: string;
characterKey: string;
initialVersion: number;
initialExtras: ExtraLink[];
}) {
const router = useRouter();
const [extras, setExtras] = useState(initialExtras);
const [version, setVersion] = useState(initialVersion);
const [pendingId, setPendingId] = useState<string | null>(null);
const move = async (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= extras.length) return;
const previous = extras;
const next = [...extras];
[next[index], next[target]] = [next[target], next[index]];
setExtras(next);
setPendingId(extras[index].id);
const result = await reorderGuideExtras(guideId, {
expectedVersion: version,
sectionIds: next.map((extra) => extra.id),
});
setPendingId(null);
if (result.status !== "saved") {
setExtras(previous);
toast.error(result.message);
if (result.status === "conflict") window.location.reload();
return;
}
if (result.version) setVersion(result.version);
router.refresh();
};
return (
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-heading text-xl font-semibold">Extra sections</h2>
<p className="text-sm text-muted-foreground">แต่ละ Extra มีหน้าแก้ไขและบันทึกแยกจากกัน</p>
</div>
<AddExtraButton
guideId={guideId}
characterKey={characterKey}
variant="outline"
/>
</div>
{extras.length ? (
<div className="grid gap-4 md:grid-cols-2">
{extras.map((extra, index) => (
<Card key={extra.id}>
<CardHeader>
<CardTitle>{extra.title}</CardTitle>
<CardDescription>Extra section {index + 1}</CardDescription>
<CardAction className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={index === 0 || pendingId !== null}
aria-label={`เลื่อน ${extra.title} ขึ้น`}
onClick={() => void move(index, -1)}
>
<ArrowUpIcon />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={index === extras.length - 1 || pendingId !== null}
aria-label={`เลื่อน ${extra.title} ลง`}
onClick={() => void move(index, 1)}
>
<ArrowDownIcon />
</Button>
</CardAction>
</CardHeader>
<CardContent>
<Button
variant="outline"
render={<Link href={`/admin/${characterKey}/${extra.slug}`} />}
nativeButton={false}
>
แก้ไข Extra
<ArrowUpRightIcon data-icon="inline-end" />
</Button>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardHeader>
<CardTitle>ยังไม่มี Extra</CardTitle>
<CardDescription>กดเพิ่ม Extra เพื่อสร้างส่วนเสริมแรกของ Guide นี้</CardDescription>
</CardHeader>
</Card>
)}
</section>
);
}