"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 ? (
}
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,
),
)
}
/>
)}
))}
))}
);
}