234 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|