"use client"; import dynamic from "next/dynamic"; import Image from "@/components/ui/resilient-image"; import { useRouter } from "next/navigation"; import { GripVerticalIcon, PlusIcon, RefreshCcwIcon, Trash2Icon, XIcon } from "lucide-react"; import { useState, type ReactNode } from "react"; import { toast } from "sonner"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { deleteGuideExtra, saveGuideExtra } from "@/app/admin/actions"; import { SaveStatus } from "@/components/admin/save-status"; import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog"; import { CatalogDropdown } from "@/components/admin/ui/catalog-select"; import { MediaUploader } from "@/components/admin/media-uploader"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; import { ColorPicker, ColorPickerEyeDropper, ColorPickerHue, ColorPickerSelection } from "@/components/ui/color-picker"; import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"; import { ClearableInput as Input } from "@/components/admin/ui/clearable-input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { useManualSave } from "@/hooks/use-manual-save"; import { adminDraftKey } from "@/lib/editor/local-draft"; import { extraSectionSchema } from "@/lib/guides/schemas"; import { rarityGradientClass } from "@/lib/catalog/rarity"; import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input"; import { formatGuideValidationIssues } from "@/lib/guides/validation-messages"; import { normalizeExtraRichText } from "@/lib/rich-text"; import { createExtraSheet } from "@/lib/guides/extra-sheet"; import { cn } from "@/lib/utils"; import { type ExtraCatalogItem, type CatalogItem, type EditorBase, hasIssuePrefix, parseEditorDraft, createClientId } from "./shared"; export const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C"; export const RichTextEditor = dynamic( () => import("@/components/rich-text-editor").then((module) => module.RichTextEditor), { ssr: false, loading: () => , }, ); export const ExtraSheetEditor = dynamic( () => import("@/components/admin/extra-sheet-editor").then((module) => module.ExtraSheetEditor), { ssr: false, loading: () => , }, ); export function SortableExtraItem({ id, label, children, }: { id: string; label: string; children: (dragHandle: ReactNode) => ReactNode; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id }); return (
{children( , )}
); } export function ExtraCatalogItemsEditor({ value, characters, weapons, artifacts, onChange, }: { value: ExtraCatalogItem[]; characters: CatalogItem[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; onChange: (value: ExtraCatalogItem[]) => void; }) { const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const [kind, setKind] = useState("character"); const [key, setKey] = useState(""); const catalogs = { character: characters, weapon: weapons, artifact: artifacts }; const labels = { character: "ตัวละคร", weapon: "อาวุธ", artifact: "อาร์ติแฟกต์" }; const items = catalogs[kind]; const itemId = (item: ExtraCatalogItem) => `${item.kind}:${item.key}`; const handleDragEnd = ({ active, over }: DragEndEvent) => { if (!over || active.id === over.id) return; const from = value.findIndex((item) => itemId(item) === active.id); const to = value.findIndex((item) => itemId(item) === over.id); if (from !== -1 && to !== -1) onChange(arrayMove(value, from, to)); }; return ( ตัวละคร อาวุธ และอาร์ติแฟกต์
{value.length ? (
{value.map((reference, index) => { const item = catalogs[reference.kind].find( (candidate) => candidate.key === reference.key, ); const label = item?.name ?? reference.key; return ( {(dragHandle) => ( {item ? (
) : null}

{label}

{labels[reference.kind]}

{dragHandle}
)}
); })}
) : null}
{ if ( nextKey && !value.some( (item) => item.kind === kind && item.key === nextKey, ) ) { onChange([...value, { kind, key: nextKey }]); } setKey(""); }} placeholder={`เลือก${labels[kind]}`} showSelectedImage rarityBackground characterBackground={kind === "character"} />
); } export function ExtraEditor({ guide, extraSectionId, initial, characters, weapons, artifacts }: EditorBase) { const router = useRouter(); const sectionId = extraSectionId ?? initial.extras[0]?.id ?? ""; const extraSensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const [sections, setSections] = useState(() => initial.extras.map((section) => ({ ...section, clientId: section.id, rows: section.rows.map((row) => ({ ...row, body: normalizeExtraRichText(row.body, row.highlights), highlights: [], clientId: row.id, })), })), ); const [openRowIds, setOpenRowIds] = useState(() => initial.extras.flatMap((section) => section.rows.map((row) => row.id)), ); const value = { sections: sections.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent === null || row.percent === "" ? null : Number(row.percent), backgroundColor: row.backgroundColor, textColor: row.textColor, sheet: row.sheet, catalogItems: row.catalogItems, highlights: [], })), })), }; const validation = extraSectionSchema.safeParse({ ...value, expectedVersion: guide.version, }); const extraIssue = (...path: (string | number)[]) => !validation.success && hasIssuePrefix(validation.error.issues, ...path); const localDraft = { sections }; const manualSave = useManualSave({ guideId: guide.id, scope: `extra:${sectionId}`, value, valid: validation.success, initialVersion: guide.version, persist: (next, expectedVersion, intent) => saveGuideExtra(guide.id, sectionId, { section: next.sections[0], expectedVersion, }, intent), storageKey: adminDraftKey(guide.id, `extra:${sectionId}`), localDraft, parseLocalDraft: (candidate) => parseEditorDraft(candidate, ["sections"]), restoreLocalDraft: (draft) => { const restoredSections = draft.sections.map((section) => ({ ...section, id: section.id || createClientId("extra-section-record"), clientId: section.clientId || createClientId("extra-section"), rows: section.rows.map((row) => ({ ...row, id: row.id || createClientId("extra-row-record"), body: normalizeExtraRichText(row.body, row.highlights ?? []), backgroundColor: row.backgroundColor ?? null, textColor: row.textColor ?? null, sheet: row.sheet ?? null, catalogItems: row.catalogItems ?? [], highlights: [], clientId: row.clientId || createClientId("extra-row"), })), })); setSections(restoredSections); setOpenRowIds( restoredSections.flatMap((section) => section.rows.map((row) => row.clientId), ), ); }, }); const handleRowDragEnd = (sectionId: string, { active, over }: DragEndEvent) => { if (!over || active.id === over.id) return; setSections((current) => current.map((section) => { if (section.clientId !== sectionId) return section; const from = section.rows.findIndex((row) => row.clientId === active.id); const to = section.rows.findIndex((row) => row.clientId === over.id); return from === -1 || to === -1 ? section : { ...section, rows: arrayMove(section.rows, from, to) }; })); }; return ( {sections[0]?.title ?? "Extra section"} เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความที่จัดรูปแบบได้ และเปอร์เซ็นต์ กดปุ่มบันทึกเพื่อบันทึกการเปลี่ยนแปลง {sections.map((section, sectionIndex) => ( setSections( sections.map((item, index) => index === sectionIndex ? { ...item, title: event.target.value } : item, ), ) } /> } title={`ลบ ${section.title || `Extra section ${sectionIndex + 1}`}?`} description="แถว ข้อความ รูปภาพ และเปอร์เซ็นต์ทั้งหมดในส่วนนี้จะถูกลบออกจาก Guide" showSuccessToast={false} onConfirm={async () => { const result = await deleteGuideExtra(guide.id, section.id); if (result.status !== "saved") { toast.error(result.message); return; } window.localStorage.removeItem(adminDraftKey(guide.id, `extra:${section.id}`)); toast.success("ลบ Extra แล้ว"); router.push(`/admin/${guide.characterKey}`); router.refresh(); }} > ลบส่วน handleRowDragEnd(section.clientId, event)} > row.clientId)} strategy={verticalListSortingStrategy} > {section.rows.map((row, rowIndex) => ( {(rowDragHandle) => ( {row.heading || `แถว ${rowIndex + 1}`} {rowDragHandle} } title={`ลบ ${row.heading || `แถว ${rowIndex + 1}`}?`} description="ข้อความ รูปภาพ และเปอร์เซ็นต์ในแถวนี้จะถูกลบออกจาก Guide" onConfirm={() => setSections( sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.filter( (_, inner) => inner !== rowIndex, ), } : item, ), ) } > ลบแถว {row.imageUrl ? (
{row.heading} } title="ลบภาพนี้?" description="ภาพจะถูกนำออกจากแถวนี้" confirmLabel="ยืนยันลบภาพ" onConfirm={() => setSections( sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map( (candidate, inner) => inner === rowIndex ? { ...candidate, mediaId: null, imageUrl: null, imageWidth: null, imageHeight: null, } : candidate, ), } : item, ), ) } >
) : null} setSections( sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, mediaId: asset.id, imageUrl: asset.url, imageWidth: asset.width, imageHeight: asset.height, } : candidate, ), } : item, ), ) } /> setSections( sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, catalogItems } : candidate, ), } : item, ), ) } /> หัวข้อ setSections( sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, heading: event.target.value, } : candidate, ), } : item, ), ) } /> ข้อความ setSections( sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, body, highlights: [], } : candidate, ), } : item, ), ) } /> ตารางข้อมูล (optional) {row.sheet ? ( setSections( sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, sheet } : candidate, ), } : item, ), ) } onRemove={() => setSections( sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, sheet: null } : candidate, ), } : item, ), ) } /> ) : ( )} Percent (optional) setSections( sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: normalizePercentInput( event.target.value, candidate.percent ?? "", ) || null, } : candidate, ), } : item, ), ) } onBlur={() => setSections( sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: finalizePercentInput( candidate.percent ?? "", ) || null, } : candidate, ), } : item, ), ) } />
สีพื้นหลังการ์ด (optional) สีจะใช้เฉพาะการ์ดนี้บนหน้า Guide สีพื้นหลัง } /> { if (!Array.isArray(color)) return; const backgroundColor = `#${color .slice(0, 3) .map((channel) => Math.round(channel).toString(16).padStart(2, "0"), ) .join("")}`.toUpperCase(); setSections( sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, backgroundColor } : candidate, ), } : item, ), ); }} >
)}
))}
))}
); }