Files
buzz-sheet/components/admin/guide-extra-controls.tsx
T
gunshiz 6d0feb11ca
CI / Verify (push) Successful in 1m45s
CI / Build immutable images and deploy (push) Successful in 2m26s
feat : update
2026-10-07 19:01:45 +07:00

234 lines
8.1 KiB
TypeScript

"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ArrowUpRightIcon,
GripVerticalIcon,
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 { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
type ExtraLink = { id: string; slug: string; title: string };
function SortableExtraCard({ extra, index, characterKey, disabled }: { extra: ExtraLink; index: number; characterKey: string; disabled: boolean }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: extra.id, disabled });
return (
<Card ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn(isDragging && "relative z-10 opacity-70 shadow-lg")}>
<CardHeader>
<CardTitle>{extra.title}</CardTitle>
<CardDescription>Extra section {index + 1}</CardDescription>
<CardAction>
<Button type="button" variant="ghost" size="icon-sm" disabled={disabled} className="touch-none cursor-grab active:cursor-grabbing" aria-label={`จัดลำดับ ${extra.title}`} {...attributes} {...listeners}>
<GripVerticalIcon />
</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>
);
}
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 [pending, setPending] = useState(false);
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
const reorder = async (event: DragEndEvent) => {
if (pending || !event.over || event.active.id === event.over.id) return;
const oldIndex = extras.findIndex((extra) => extra.id === event.active.id);
const newIndex = extras.findIndex((extra) => extra.id === event.over?.id);
if (oldIndex < 0 || newIndex < 0) return;
const previous = extras;
const next = arrayMove(extras, oldIndex, newIndex);
setExtras(next);
setPending(true);
const result = await reorderGuideExtras(guideId, {
expectedVersion: version,
sectionIds: next.map((extra) => extra.id),
});
setPending(false);
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 ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(event) => void reorder(event)}>
<SortableContext items={extras.map((extra) => extra.id)} strategy={rectSortingStrategy}>
<div className="grid gap-4 md:grid-cols-2">
{extras.map((extra, index) => <SortableExtraCard key={extra.id} extra={extra} index={index} characterKey={characterKey} disabled={pending} />)}
</div>
</SortableContext>
</DndContext>
) : (
<Card>
<CardHeader>
<CardTitle>ยังไม่มี Extra</CardTitle>
<CardDescription>กดเพิ่ม Extra เพื่อสร้างส่วนเสริมแรกของ Guide นี้</CardDescription>
</CardHeader>
</Card>
)}
</section>
);
}