perf : cache
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user