916 lines
40 KiB
TypeScript
916 lines
40 KiB
TypeScript
"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>
|
||
);
|
||
}
|