perf : cache
CI / Verify (push) Successful in 1m56s
CI / Build immutable images and deploy (push) Successful in 3m12s

This commit is contained in:
2026-10-08 23:21:34 +07:00 Unverified
parent 36063fc214
commit 9580407a90
58 changed files with 5797 additions and 5336 deletions
+915
View File
@@ -0,0 +1,915 @@
"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 { AutosaveStatus } from "@/components/admin/autosave-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 { useAutosave } from "@/hooks/use-autosave";
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: () => <Skeleton className="h-36 w-full" />,
},
);
export const ExtraSheetEditor = dynamic(
() => import("@/components/admin/extra-sheet-editor").then((module) => module.ExtraSheetEditor),
{
ssr: false,
loading: () => <Skeleton className="h-48 w-full" />,
},
);
export function SortableExtraItem({
id,
label,
children,
}: {
id: string;
label: string;
children: (dragHandle: ReactNode) => ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(isDragging && "opacity-60")}
>
{children(
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
{...attributes}
{...listeners}
>
<GripVerticalIcon />
<span className="sr-only">{label}</span>
</Button>,
)}
</div>
);
}
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<ExtraCatalogItem["kind"]>("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 (
<Field>
<FieldLabel>ตัวละคร อาวุธ และอาร์ติแฟกต์</FieldLabel>
<div className="flex flex-col gap-3">
{value.length ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={value.map(itemId)}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{value.map((reference, index) => {
const item = catalogs[reference.kind].find(
(candidate) => candidate.key === reference.key,
);
const label = item?.name ?? reference.key;
return (
<SortableExtraItem
key={itemId(reference)}
id={itemId(reference)}
label={`ลากเพื่อจัดลำดับ ${label}`}
>
{(dragHandle) => (
<Card size="sm">
<CardContent className="flex items-center gap-3">
{item ? (
<div className={cn("relative size-12 shrink-0 overflow-hidden rounded-md", rarityGradientClass(item.rarity))}>
<Image src={item.imageUrl} alt="" fill sizes="48px" className="object-contain" />
</div>
) : null}
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{label}</p>
<p className="text-sm text-muted-foreground">{labels[reference.kind]}</p>
</div>
{dragHandle}
<Button type="button" variant="ghost" size="icon-sm" onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} aria-label={`ลบ ${label}`}>
<XIcon />
</Button>
</CardContent>
</Card>
)}
</SortableExtraItem>
);
})}
</div>
</SortableContext>
</DndContext>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Select value={kind} onValueChange={(next) => { setKind(next as ExtraCatalogItem["kind"]); setKey(""); }}>
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>
<SelectItem value="character">ตัวละคร</SelectItem>
<SelectItem value="weapon">อาวุธ</SelectItem>
<SelectItem value="artifact">อาร์ติแฟกต์</SelectItem>
</SelectGroup></SelectContent>
</Select>
<div className="min-w-64 flex-1">
<CatalogDropdown
items={items}
value={key}
onValueChange={(nextKey) => {
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"}
/>
</div>
</div>
</div>
</Field>
);
}
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 autosave = useAutosave({
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<typeof localDraft>(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 (
<Card>
<CardHeader>
<CardTitle>{sections[0]?.title ?? "Extra section"}</CardTitle>
<CardDescription>
เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความที่จัดรูปแบบได้ และเปอร์เซ็นต์
ระบบจะบันทึกอัตโนมัติ
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
{sections.map((section, sectionIndex) => (
<Card
key={section.clientId}
size="sm"
className={cn(
extraIssue("sections", sectionIndex, "title") && "border-destructive",
)}
>
<CardHeader>
<CardTitle>
<Input
value={section.title}
aria-label="ชื่อ Extra section"
aria-invalid={extraIssue("sections", sectionIndex, "title")}
onChange={(event) =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? { ...item, title: event.target.value }
: item,
),
)
}
/>
</CardTitle>
<CardAction className="flex items-center gap-1">
<DeleteConfirmationDialog
trigger={
<Button type="button" variant="ghost" size="icon-sm" />
}
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();
}}
>
<Trash2Icon />
<span className="sr-only">ลบส่วน</span>
</DeleteConfirmationDialog>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<DndContext
sensors={extraSensors}
collisionDetection={closestCenter}
onDragEnd={(event) => handleRowDragEnd(section.clientId, event)}
>
<SortableContext
items={section.rows.map((row) => row.clientId)}
strategy={verticalListSortingStrategy}
>
<Accordion
multiple
value={openRowIds}
onValueChange={setOpenRowIds}
className="gap-4"
>
{section.rows.map((row, rowIndex) => (
<SortableExtraItem
key={row.clientId}
id={row.clientId}
label={`ลากเพื่อจัดลำดับ ${row.heading || `แถว ${rowIndex + 1}`}`}
>
{(rowDragHandle) => (
<AccordionItem value={row.clientId} className="border-0">
<Card
size="sm"
className={cn(
extraIssue("sections", sectionIndex, "rows", rowIndex) &&
"border-destructive",
)}
>
<CardHeader>
<CardTitle>
{row.heading || `แถว ${rowIndex + 1}`}
</CardTitle>
<CardAction className="flex items-center gap-1">
{rowDragHandle}
<DeleteConfirmationDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
/>
}
title={`ลบ ${row.heading || `แถว ${rowIndex + 1}`}?`}
description="ข้อความ รูปภาพ และเปอร์เซ็นต์ในแถวนี้จะถูกลบออกจาก Guide"
onConfirm={() =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.filter(
(_, inner) => inner !== rowIndex,
),
}
: item,
),
)
}
>
<Trash2Icon />
<span className="sr-only">ลบแถว</span>
</DeleteConfirmationDialog>
<AccordionTrigger
className="size-8 flex-none items-center justify-center p-0 hover:no-underline"
headerClassName="flex-none"
aria-label={`${openRowIds.includes(row.clientId) ? "ซ่อน" : "แสดง"} ${row.heading || `แถว ${rowIndex + 1}`}`}
title={openRowIds.includes(row.clientId) ? "ซ่อนส่วนนี้" : "แสดงส่วนนี้"}
/>
</CardAction>
</CardHeader>
<AccordionContent className="pb-0">
<CardContent>
<FieldGroup>
{row.imageUrl ? (
<div
className="relative overflow-hidden rounded-lg border"
style={{
aspectRatio:
row.imageWidth && row.imageHeight
? `${row.imageWidth} / ${row.imageHeight}`
: "16 / 9",
}}
>
<Image
src={row.imageUrl}
alt={row.heading}
fill
className="object-cover"
/>
<DeleteConfirmationDialog
trigger={
<Button
type="button"
variant="destructive"
size="icon-sm"
className="absolute top-2 right-2"
aria-label="ลบภาพ"
/>
}
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,
),
)
}
>
<XIcon />
</DeleteConfirmationDialog>
</div>
) : null}
<MediaUploader
onReady={(asset) =>
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,
),
)
}
/>
<ExtraCatalogItemsEditor
value={row.catalogItems}
characters={characters}
weapons={weapons}
artifacts={artifacts}
onChange={(catalogItems) =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? { ...candidate, catalogItems }
: candidate,
),
}
: item,
),
)
}
/>
<Field>
<FieldLabel>หัวข้อ</FieldLabel>
<Input
value={row.heading}
aria-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"heading",
)}
onChange={(event) =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
heading: event.target.value,
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Field>
<FieldLabel>ข้อความ</FieldLabel>
<RichTextEditor
value={row.body}
showBubbleMenu={false}
invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"body",
)}
onChange={(body) =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
body,
highlights: [],
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Field>
<FieldLabel>ตารางข้อมูล (optional)</FieldLabel>
{row.sheet ? (
<ExtraSheetEditor
value={row.sheet}
createId={createClientId}
onChange={(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,
),
)
}
/>
) : (
<Button
type="button"
variant="outline"
onClick={() =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? { ...candidate, sheet: createExtraSheet(createClientId) }
: candidate,
),
}
: item,
),
)
}
>
<PlusIcon data-icon="inline-start" />
เพิ่มตาราง 4 × 6
</Button>
)}
</Field>
<Field>
<FieldLabel>Percent (optional)</FieldLabel>
<Input
type="text"
inputMode="decimal"
value={row.percent ?? ""}
onChange={(event) =>
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,
),
)
}
/>
</Field>
<FieldSet>
<FieldLegend>สีพื้นหลังการ์ด (optional)</FieldLegend>
<FieldDescription>
สีจะใช้เฉพาะการ์ดนี้บนหน้า Guide
</FieldDescription>
<Field
data-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"backgroundColor",
) || undefined}
>
<FieldLabel>สีพื้นหลัง</FieldLabel>
<Popover>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
aria-label="เลือกสีพื้นหลังการ์ด"
aria-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"backgroundColor",
)}
>
<span
className="size-5 rounded-sm border"
style={{
backgroundColor:
row.backgroundColor ?? DEFAULT_EXTRA_BACKGROUND_COLOR,
}}
/>
</Button>
}
/>
<PopoverContent align="start" className="w-64">
<ColorPicker
value={row.backgroundColor ?? DEFAULT_EXTRA_BACKGROUND_COLOR}
className="h-auto"
onChange={(color) => {
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,
),
);
}}
>
<ColorPickerSelection className="h-32" />
<ColorPickerHue />
<ColorPickerEyeDropper aria-label="ดูดสีจากหน้าจอ" />
</ColorPicker>
</PopoverContent>
</Popover>
</Field>
<Button
type="button"
variant="outline"
size="sm"
disabled={row.backgroundColor === null}
onClick={() =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? { ...candidate, backgroundColor: null }
: candidate,
),
}
: item,
),
)
}
>
<RefreshCcwIcon data-icon="inline-start" />
ใช้สีตามธีม
</Button>
</FieldSet>
</FieldGroup>
</CardContent>
</AccordionContent>
</Card>
</AccordionItem>
)}
</SortableExtraItem>
))}
</Accordion>
</SortableContext>
</DndContext>
<Button
type="button"
variant="outline"
onClick={() => {
const clientId = createClientId("extra-row");
setOpenRowIds((current) => [...current, clientId]);
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: [
...item.rows,
{
id: createClientId("extra-row-record"),
mediaId: null,
imageUrl: null,
imageWidth: null,
imageHeight: null,
heading: "",
body: "",
percent: null,
backgroundColor: null,
textColor: null,
sheet: null,
catalogItems: [],
highlights: [],
clientId,
},
],
}
: item,
),
);
}}
>
<PlusIcon data-icon="inline-start" />
เพิ่มแถว
</Button>
</CardContent>
</Card>
))}
</CardContent>
<CardFooter className="flex-wrap justify-end gap-3">
<AutosaveStatus
state={autosave.state}
conflictVersion={autosave.conflictVersion}
canOverwrite={autosave.canOverwrite}
onReloadLatest={autosave.reloadLatest}
onOverwrite={autosave.overwrite}
issues={
validation.success
? []
: formatGuideValidationIssues("extra", validation.error.issues)
}
onSave={autosave.saveNow}
/>
</CardFooter>
</Card>
);
}