Files
buzz-sheet/components/admin/guide-section-editor.tsx
T
gunshiz f353aff0fa
CI / Build immutable images and deploy (push) Has been cancelled
CI / Verify (push) Has been cancelled
feat(weapon-conditions) : duplicate weapon conditions
2026-09-04 00:55:33 +07:00

3252 lines
136 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Image from "next/image";
import {
ChartNoAxesCombinedIcon,
CopyIcon,
GemIcon,
GripVerticalIcon,
HeartPulseIcon,
ListOrderedIcon,
PlusIcon,
RefreshCcwIcon,
SparklesIcon,
SwordIcon,
TimerIcon,
Trash2Icon,
UserRoundIcon,
XIcon,
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
saveArtifactSection,
saveConstellationSection,
saveExtraSectionList,
saveTeamSection,
saveWeaponSection,
} from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
import { MediaUploader } from "@/components/admin/media-uploader";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Toggle } from "@/components/ui/toggle";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useAutosave } from "@/hooks/use-autosave";
import { adminDraftKey } from "@/lib/editor/local-draft";
import {
artifactSectionSchema,
constellationSectionSchema,
extraSectionSchema,
mainStatOptions,
substatOptions,
talentLevelOptions,
teamSectionSchema,
weaponSectionSchema,
} from "@/lib/guides/schemas";
import {
normalizeTargetStat,
recommendedStatIconName,
targetStatDefaults,
targetStatIconName,
targetStatOptions,
} from "@/lib/guides/stats";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { rarityGradientClass } from "@/lib/catalog/rarity";
import {
applyPresetFields,
applyTeamFields,
limitPresets,
parseTeamPresets,
teamApplyFields,
teamPresetCookieKey,
teamPresetStorageKey,
type TeamApplyField,
type TeamPreset,
type TeamToolValue,
} from "@/lib/guides/team-tools";
import {
normalizeTalentPriorities,
reorderTalentPriorities,
type TalentPriority,
type TalentRelation,
} from "@/lib/guides/talents";
import {
finalizePercentInput,
normalizePercentInput,
} from "@/lib/guides/percent-input";
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
import { cn } from "@/lib/utils";
type CatalogItem = {
key: string;
name: string;
imageUrl: string;
rarity?: number | null;
weaponType?: string | null;
};
type Guide = {
id: string;
characterKey: string;
version: number;
name: string;
};
type WeaponValue = {
id: string;
weaponKey: string;
refinement: number;
overallPercent: string | null;
personalPercent: string | null;
note: string | null;
weapon: CatalogItem;
};
type WeaponGroupValue = {
id: string;
note: string | null;
rows: WeaponValue[];
};
type ArtifactValue = {
id: string;
name: string;
rows: Array<{
firstArtifactKey: string;
secondArtifactKey: string | null;
percent: string;
note: string | null;
}>;
};
type ArtifactProfile = {
sands: string[];
goblet: string[];
circlet: string[];
substats: string[];
targets: Array<{ stat: string; value: string }>;
talents: TalentPriority[];
note: string | null;
} | null;
type ConstellationValue = {
level: number;
detail: string;
cumulativePercent: string;
isGreat: boolean;
};
type ConstellationGroupValue = {
id: string;
note: string | null;
rows: ConstellationValue[];
};
type CatalogConstellation = {
level: number;
name: string;
description: string;
iconUrl: string | null;
};
type CatalogTalent = {
type: "NA" | "E" | "Q";
name: string;
iconUrl: string | null;
};
type TeamValue = {
id: string;
title: string;
rotationSeconds: string;
combo: string;
members: Array<{
characterKey: string;
weaponKey: string | null;
artifactKey: string | null;
secondArtifactKey: string | null;
constellation: number;
refinement: number;
damage: string;
}>;
};
let clientIdCounter = 0;
function createClientId(prefix: string): string {
const browserCrypto = globalThis.crypto;
if (typeof browserCrypto?.randomUUID === "function")
return `${prefix}-${browserCrypto.randomUUID()}`;
clientIdCounter += 1;
return `${prefix}-${Date.now()}-${clientIdCounter}`;
}
function parseEditorDraft<Draft>(
value: unknown,
arrayKeys: string[],
stringKeys: string[] = [],
): Draft | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (arrayKeys.some((key) => !Array.isArray(record[key]))) return null;
if (stringKeys.some((key) => typeof record[key] !== "string")) return null;
return value as Draft;
}
function hasIssuePrefix(
issues: readonly { path: readonly PropertyKey[] }[],
...path: (string | number)[]
): boolean {
return issues.some((issue) => path.every((part, index) => issue.path[index] === part));
}
export interface GuideSectionEditorProps {
section: "weapon" | "artifact" | "constellations" | "team" | "extra";
guide: Guide;
weapons: CatalogItem[];
artifacts: CatalogItem[];
characters: CatalogItem[];
catalogConstellations: CatalogConstellation[];
catalogTalents: CatalogTalent[];
initial: {
sectionNote: string | null;
weaponGroups: WeaponGroupValue[];
artifactGroups: ArtifactValue[];
artifactProfile: ArtifactProfile;
constellationGroups: ConstellationGroupValue[];
teams: TeamValue[];
extras: Array<{
title: string;
rows: Array<{
mediaId: string | null;
ratio: "1:1" | "16:9";
heading: string;
body: string;
percent: string | null;
imageUrl: string | null;
}>;
}>;
};
}
const SELECTED_TOGGLE_CLASS =
"aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-primary/30";
function CatalogPicker({
items,
value,
onChange,
placeholder,
rarityBackground = false,
showSelectedImage = false,
invalid = false,
}: {
items: CatalogItem[];
value: string;
onChange: (value: string) => void;
placeholder: string;
rarityBackground?: boolean;
showSelectedImage?: boolean;
invalid?: boolean;
}) {
const selected = items.find((item) => item.key === value) ?? null;
return (
<div className="flex min-w-0 items-center gap-2">
{showSelectedImage && selected ? (
<div
className={cn(
"relative size-10 shrink-0 overflow-hidden rounded-md",
rarityBackground && rarityGradientClass(selected.rarity),
)}
>
<Image
src={selected.imageUrl}
alt=""
fill
className="object-contain"
/>
</div>
) : null}
<div className="min-w-0 flex-1">
<Combobox
items={items}
value={selected}
onValueChange={(next) => onChange(next?.key ?? "")}
itemToStringLabel={(item) => item.name}
itemToStringValue={(item) => item.key}
autoHighlight
>
<ComboboxInput
className="w-full"
placeholder={placeholder}
showClear
aria-invalid={invalid}
/>
<ComboboxContent className="min-w-[24rem]">
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item.key} value={item}>
<div
className={cn(
"relative size-8 shrink-0 overflow-hidden rounded-md",
rarityBackground && rarityGradientClass(item.rarity),
)}
>
<Image
src={item.imageUrl}
alt=""
fill
className="object-contain"
/>
</div>
<span className="whitespace-nowrap">{item.name}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</div>
);
}
function StatSelect({
label,
options,
value,
max,
onChange,
}: {
label: string;
options: readonly string[];
value: string[];
max: number;
onChange: (value: string[]) => void;
}) {
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<Select
multiple
value={value}
onValueChange={(next) => {
if (next.length <= max) onChange(next);
}}
>
<SelectTrigger className="w-full">
<StatIcons values={value} />
<SelectValue placeholder="Select recommended stats…" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((option) => (
<SelectItem
key={option}
value={option}
disabled={value.length >= max && !value.includes(option)}
>
<StatIcons values={[option]} />
{option}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>เลือกได้สูงสุด {max} ค่า</FieldDescription>
</Field>
);
}
function StatIcons({ values }: { values: readonly string[] }) {
const iconNames = values.flatMap((value) => {
const targetIcon = targetStatIconName(value);
if (targetIcon) return [targetIcon];
const recommendedIcon = recommendedStatIconName(value);
return recommendedIcon ? [recommendedIcon] : [];
});
if (!iconNames.length) return null;
return (
<span className="flex shrink-0 items-center gap-0.5" aria-hidden="true">
{iconNames.map((iconName) => (
<StatIcon key={iconName} iconName={iconName} />
))}
</span>
);
}
function StatIcon({ iconName }: { iconName: string }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
const PlaceholderIcon =
iconName === "healing-bonus" ? HeartPulseIcon : SparklesIcon;
return (
<PlaceholderIcon
className="size-5 text-muted-foreground"
aria-hidden="true"
/>
);
}
return (
<Image
src={`/icon/stats/${iconName}.webp`}
alt=""
width={20}
height={20}
className="size-5 object-contain brightness-0 invert"
onError={() => setHasError(true)}
/>
);
}
function TalentLevelPicker({
value,
onChange,
}: {
value?: string;
onChange: (value?: string) => void;
}) {
const selected = talentLevelOptions.find((level) => level === value) ?? null;
return (
<Combobox
items={[...talentLevelOptions]}
value={selected}
onValueChange={(next) => onChange(next ?? undefined)}
>
<ComboboxInput
className="w-full"
placeholder="เลือกระดับ 1–10"
showClear
/>
<ComboboxContent>
<ComboboxEmpty>ไม่พบระดับ</ComboboxEmpty>
<ComboboxList>
{(level) => (
<ComboboxItem key={level} value={level}>
ระดับ {level}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
function SortableTalentCard({
talent,
catalogTalent,
onLevelChange,
}: {
talent: TalentPriority;
catalogTalent?: CatalogTalent;
onLevelChange: (level?: string) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: talent.type });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn("w-56 shrink-0", isDragging && "opacity-60")}
>
<Card size="sm" className="h-full">
<CardHeader className="flex items-center gap-2">
{catalogTalent?.iconUrl ? (
<Image
src={catalogTalent.iconUrl}
alt=""
width={40}
height={40}
className="size-10 shrink-0 rounded-lg bg-muted"
/>
) : null}
<CardTitle className="min-w-0 flex-1 truncate">
{talent.type}. {catalogTalent?.name ?? talent.type}
</CardTitle>
<CardAction>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
{...attributes}
{...listeners}
>
<GripVerticalIcon />
<span className="sr-only">
ลากเพื่อจัดลำดับ {catalogTalent?.name ?? talent.type}
</span>
</Button>
</CardAction>
</CardHeader>
<CardContent>
<Field>
<FieldLabel>ระดับเป้าหมาย</FieldLabel>
<TalentLevelPicker value={talent.level} onChange={onLevelChange} />
</Field>
</CardContent>
</Card>
</div>
);
}
function SortableWeaponCard({
id,
children,
}: {
id: 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">ลากเพื่อจัดลำดับอาวุธ</span>
</Button>,
)}
</div>
);
}
function SortableArtifactRow({
id,
children,
}: {
id: 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">ลากเพื่อจัดลำดับ Artifact</span>
</Button>,
)}
</div>
);
}
function SortableTeamItem({
id,
children,
}: {
id: string;
children: (dragHandle: ReactNode) => ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<AccordionItem
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
value={id}
className={cn("rounded-xl border px-4", isDragging && "opacity-60")}
>
{children(
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
{...attributes}
{...listeners}
>
<GripVerticalIcon />
<span className="sr-only">ลากเพื่อจัดลำดับ Team</span>
</Button>,
)}
</AccordionItem>
);
}
export function GuideSectionEditor(props: GuideSectionEditorProps) {
return (
<div className="flex flex-col gap-6">
{props.section === "weapon" ? <WeaponEditor {...props} /> : null}
{props.section === "artifact" ? <ArtifactEditor {...props} /> : null}
{props.section === "constellations" ? (
<ConstellationEditor {...props} />
) : null}
{props.section === "team" ? <TeamEditor {...props} /> : null}
{props.section === "extra" ? <ExtraEditor {...props} /> : null}
</div>
);
}
type EditorBase = GuideSectionEditorProps;
function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
const characterWeaponType = characters.find(
(character) => character.key === guide.characterKey,
)?.weaponType;
const compatibleWeapons = weapons.filter((weapon) =>
supportsWeaponType(characterWeaponType, weapon.weaponType),
);
const weaponSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [note, setNote] = useState(initial.sectionNote ?? "");
const [groups, setGroups] = useState(() => {
const source = initial.weaponGroups.length
? initial.weaponGroups
: [{ id: "weapon-condition-default", note: null, rows: [] }];
return source.map((group) => ({
clientId: group.id,
note: group.note ?? "",
rows: group.rows.map((row) => ({
clientId: row.id,
weaponKey: row.weaponKey,
refinement: row.refinement,
overallPercent: row.overallPercent ?? "",
personalPercent: row.personalPercent ?? "",
note: row.note ?? "",
})),
}));
});
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const rows = groups[activeGroupIndex]?.rows ?? [];
const setRows = (update: typeof rows | ((current: typeof rows) => typeof rows)) =>
setGroups((current) => current.map((group, index) => index === activeGroupIndex
? { ...group, rows: typeof update === "function" ? update(group.rows) : update }
: group));
const value = {
note: note.trim() || null,
groups: groups.map((group) => ({
note: group.note.trim() || null,
rows: group.rows.map((row) => ({
weaponKey: row.weaponKey,
refinement: row.refinement,
overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent),
personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent),
note: row.note.trim() || null,
})),
})),
};
const validation = weaponSectionSchema.safeParse({
...value,
expectedVersion: guide.version,
});
const weaponIssue = (...path: (string | number)[]) =>
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
const localDraft = { note, groups, activeGroupIndex };
const autosave = useAutosave({
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion) =>
saveWeaponSection(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "weapon"),
localDraft,
parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(candidate, ["groups"], ["note"]),
restoreLocalDraft: (draft) => {
setNote(draft.note);
setGroups(draft.groups);
setActiveGroupIndex(draft.activeGroupIndex ?? 0);
},
});
const handleWeaponDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setRows((current) => {
const from = current.findIndex((row) => row.clientId === active.id);
const to = current.findIndex((row) => row.clientId === over.id);
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
});
};
return (
<Card>
<CardHeader>
<CardTitle>Weapon tier list</CardTitle>
<CardDescription>
ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง
ระบบจะบันทึกอัตโนมัติ
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="weapon-section-note">
Header note (optional)
</FieldLabel>
<Textarea
id="weapon-section-note"
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</Field>
<div className="flex flex-wrap gap-2">
{groups.map((group, index) => (
<Button key={group.clientId} type="button" variant={index === activeGroupIndex ? "default" : "outline"} onClick={() => setActiveGroupIndex(index)}>
Condition {index + 1}
</Button>
))}
<Button type="button" variant="outline" disabled={groups.length >= 20} onClick={() => {
setGroups([...groups, { clientId: createClientId("weapon-condition"), note: "", rows: [] }]);
setActiveGroupIndex(groups.length);
}}>
<PlusIcon data-icon="inline-start" />เพิ่ม Condition
</Button>
<Button
type="button"
variant="outline"
disabled={groups.length >= 20 || !groups[activeGroupIndex]}
onClick={() => {
const source = groups[activeGroupIndex];
if (!source) return;
setGroups([
...groups,
{
clientId: createClientId("weapon-condition"),
note: source.note,
rows: source.rows.map((row) => ({
...row,
clientId: createClientId("weapon"),
})),
},
]);
setActiveGroupIndex(groups.length);
}}
>
<CopyIcon data-icon="inline-start" />Duplicate Condition
</Button>
</div>
<Field>
<FieldLabel htmlFor={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}>
Condition note (optional)
</FieldLabel>
<Textarea
id={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}
value={groups[activeGroupIndex]?.note ?? ""}
onChange={(event) => setGroups(groups.map((group, index) => index === activeGroupIndex ? { ...group, note: event.target.value } : group))}
/>
</Field>
<DndContext
sensors={weaponSensors}
collisionDetection={closestCenter}
onDragEnd={handleWeaponDragEnd}
>
<SortableContext
items={rows.map((row) => row.clientId)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-4">
{rows.map((row, rowIndex) => {
const selectedWeapon = compatibleWeapons.find(
(weapon) => weapon.key === row.weaponKey,
);
return (
<SortableWeaponCard key={row.clientId} id={row.clientId}>
{(dragHandle) => (
<Card size="sm">
<CardHeader>
<CardTitle className="flex items-center gap-3">
{selectedWeapon ? (
<div
className={cn(
"relative size-10 shrink-0 overflow-hidden rounded-lg",
rarityGradientClass(selectedWeapon.rarity),
)}
>
<Image
src={selectedWeapon.imageUrl}
alt=""
fill
className="object-contain"
/>
</div>
) : null}
<span>อันดับ {rowIndex + 1}</span>
</CardTitle>
<CardAction>
<div className="flex gap-1">
{dragHandle}
<DeleteConfirmationDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
/>
}
title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`}
description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide"
onConfirm={() =>
setRows(
rows.filter(
(item) => item.clientId !== row.clientId,
),
)
}
>
<Trash2Icon />
<span className="sr-only">ลบอาวุธ</span>
</DeleteConfirmationDialog>
</div>
</CardAction>
</CardHeader>
<CardContent>
<FieldGroup>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4">
<Field className="min-w-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}>
<FieldLabel>อาวุธ</FieldLabel>
<CatalogPicker
items={compatibleWeapons}
value={row.weaponKey}
invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}
placeholder="ค้นหาชื่ออาวุธ…"
rarityBackground
onChange={(weaponKey) =>
setRows(
rows.map((item) =>
item.clientId === row.clientId
? { ...item, weaponKey }
: item,
),
)
}
/>
</Field>
<Field className="shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
<FieldLabel>Refinement</FieldLabel>
<ToggleGroup
variant="outline"
value={[`R${row.refinement}`]}
onValueChange={(selected) => {
const next = selected[0];
if (next)
setRows(
rows.map((item) =>
item.clientId === row.clientId
? {
...item,
refinement: Number(
next.slice(1),
),
}
: item,
),
);
}}
>
{[1, 2, 3, 4, 5].map((refinement) => (
<ToggleGroupItem
className={SELECTED_TOGGLE_CLASS}
key={refinement}
value={`R${refinement}`}
>
R{refinement}
</ToggleGroupItem>
))}
</ToggleGroup>
</Field>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
<FieldLabel>Overall %</FieldLabel>
<Input
type="text"
inputMode="decimal"
value={row.overallPercent}
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
onChange={(event) =>
setRows(
rows.map((item) =>
item.clientId === row.clientId
? {
...item,
overallPercent:
normalizePercentInput(
event.target.value,
item.overallPercent,
),
}
: item,
),
)
}
onBlur={() =>
setRows(
rows.map((item) =>
item.clientId === row.clientId
? {
...item,
overallPercent:
finalizePercentInput(
item.overallPercent,
),
}
: item,
),
)
}
/>
</Field>
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
<FieldLabel>Personal %</FieldLabel>
<Input
type="text"
inputMode="decimal"
value={row.personalPercent}
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
onChange={(event) =>
setRows(
rows.map((item) =>
item.clientId === row.clientId
? {
...item,
personalPercent:
normalizePercentInput(
event.target.value,
item.personalPercent,
),
}
: item,
),
)
}
onBlur={() =>
setRows(
rows.map((item) =>
item.clientId === row.clientId
? {
...item,
personalPercent:
finalizePercentInput(
item.personalPercent,
),
}
: item,
),
)
}
/>
</Field>
</div>
<Field>
<FieldLabel
htmlFor={`weapon-note-${row.clientId}`}
>
Note (optional)
</FieldLabel>
<Textarea
id={`weapon-note-${row.clientId}`}
value={row.note}
onChange={(event) =>
setRows(
rows.map((item) =>
item.clientId === row.clientId
? { ...item, note: event.target.value }
: item,
),
)
}
/>
</Field>
</FieldGroup>
</CardContent>
</Card>
)}
</SortableWeaponCard>
);
})}
</div>
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
setRows([
...rows,
{
clientId: createClientId("weapon"),
weaponKey: "",
refinement: 1,
overallPercent: "",
personalPercent: "",
note: "",
},
])
}
>
<PlusIcon data-icon="inline-start" />
เพิ่มอาวุธ
</Button>
{groups.length > 1 ? (
<Button type="button" variant="ghost" onClick={() => {
const next = groups.filter((_, index) => index !== activeGroupIndex);
setGroups(next);
setActiveGroupIndex(Math.min(activeGroupIndex, next.length - 1));
}}>
<Trash2Icon data-icon="inline-start" />ลบ Condition นี้
</Button>
) : null}
<AutosaveStatus
state={autosave.state}
issues={
validation.success
? []
: formatGuideValidationIssues("weapon", validation.error.issues)
}
onSave={autosave.saveNow}
/>
</CardFooter>
</Card>
);
}
function ArtifactEditor({
guide,
artifacts,
initial,
catalogTalents,
}: EditorBase) {
const profile = initial.artifactProfile;
const artifactSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const talentSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [groups, setGroups] = useState(() =>
(["Late Game", "Early Game"] as const).map((name, index) => ({
name,
rows:
initial.artifactGroups[index]?.rows.map((row, rowIndex) => ({
...row,
clientId: `${name}-${rowIndex}`,
})) ?? [],
})),
);
const [sands, setSands] = useState(profile?.sands ?? []);
const [goblet, setGoblet] = useState(profile?.goblet ?? []);
const [circlet, setCirclet] = useState(profile?.circlet ?? []);
const [substats, setSubstats] = useState(profile?.substats ?? []);
const [targets, setTargets] = useState(() => {
const existing = new Map(
(profile?.targets ?? []).flatMap((target) => {
const stat = normalizeTargetStat(target.stat);
return stat ? [[stat, target.value] as const] : [];
}),
);
return targetStatDefaults.map((target) => ({
stat: target.stat,
value: existing.get(target.stat) ?? target.value,
}));
});
const [talents, setTalents] = useState<TalentPriority[]>(() => {
const source: TalentPriority[] = profile?.talents.length
? profile.talents
: (["NA", "E", "Q"] as const).map((type, priority) => ({
type,
priority: priority + 1,
}));
return normalizeTalentPriorities(
source.map((talent) => ({
...talent,
level:
talent.level &&
talentLevelOptions.includes(
talent.level as (typeof talentLevelOptions)[number],
)
? talent.level
: undefined,
})),
);
});
const [note, setNote] = useState(profile?.note ?? "");
const value = {
groups: groups.map((group) => ({
name: group.name,
rows: group.rows.map((row) => ({
firstArtifactKey: row.firstArtifactKey,
secondArtifactKey: row.secondArtifactKey,
percent: Number(row.percent),
note: row.note?.trim() || null,
})),
})),
sands,
goblet,
circlet,
substats,
targets: targets
.filter((target) => target.value.trim())
.map((target) => ({ stat: target.stat, value: target.value.trim() })),
talents,
note: note || null,
};
const validation = artifactSectionSchema.safeParse({
...value,
expectedVersion: guide.version,
});
const artifactIssue = (...path: (string | number)[]) =>
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
const localDraft = {
groups,
sands,
goblet,
circlet,
substats,
targets,
talents,
note,
};
const autosave = useAutosave({
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion) =>
saveArtifactSection(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "artifact"),
localDraft,
parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(
candidate,
[
"groups",
"sands",
"goblet",
"circlet",
"substats",
"targets",
"talents",
],
["note"],
),
restoreLocalDraft: (draft) => {
setGroups(draft.groups);
setSands(draft.sands);
setGoblet(draft.goblet);
setCirclet(draft.circlet);
setSubstats(draft.substats);
setTargets(draft.targets);
setTalents(draft.talents);
setNote(draft.note);
},
});
const orderedTalents = normalizeTalentPriorities(talents);
const handleArtifactDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
const activeGroupIndex = groups.findIndex((group) =>
group.rows.some((row) => row.clientId === active.id),
);
const overGroupIndex = groups.findIndex((group) =>
group.rows.some((row) => row.clientId === over.id),
);
if (activeGroupIndex === -1 || activeGroupIndex !== overGroupIndex) return;
setGroups((current) =>
current.map((group, groupIndex) => {
if (groupIndex !== activeGroupIndex) return group;
const from = group.rows.findIndex((row) => row.clientId === active.id);
const to = group.rows.findIndex((row) => row.clientId === over.id);
return from === -1 || to === -1
? group
: { ...group, rows: arrayMove(group.rows, from, to) };
}),
);
};
const handleTalentDragEnd = ({ active, over }: DragEndEvent) => {
if (!over) return;
setTalents(
reorderTalentPriorities(
orderedTalents,
active.id as TalentPriority["type"],
over.id as TalentPriority["type"],
),
);
};
const setTalentRelation = (
type: TalentPriority["type"],
relationToNext: TalentRelation,
) => {
setTalents(
orderedTalents.map((talent) =>
talent.type === type ? { ...talent, relationToNext } : talent,
),
);
};
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Artifact tier list</CardTitle>
<CardDescription>
จัดชุด Artifact ในกลุ่ม Late Game และ Early Game
ระบบจะบันทึกอัตโนมัติ
</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
{groups.map((group, groupIndex) => (
<Card key={group.name} size="sm">
<CardHeader>
<CardTitle>{group.name}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<DndContext
sensors={artifactSensors}
collisionDetection={closestCenter}
onDragEnd={handleArtifactDragEnd}
>
<SortableContext
items={group.rows.map((row) => row.clientId)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-3">
{group.rows.map((row, rowIndex) => (
<SortableArtifactRow
key={row.clientId}
id={row.clientId}
>
{(dragHandle) => (
<div
className={cn(
"flex items-start gap-2 rounded-lg border p-3",
rowIndex % 2 === 0
? "bg-muted/60"
: "bg-muted/20",
artifactIssue("groups", groupIndex, "rows", rowIndex) && "border-destructive",
)}
>
{dragHandle}
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_8rem_auto]">
<CatalogPicker
items={artifacts}
value={row.firstArtifactKey}
invalid={artifactIssue("groups", groupIndex, "rows", rowIndex, "firstArtifactKey")}
placeholder="ค้นหาชุด Artifact แรก…"
rarityBackground
showSelectedImage
onChange={(firstArtifactKey) =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? {
...candidate,
firstArtifactKey,
}
: candidate,
),
}
: item,
),
)
}
/>
<CatalogPicker
items={artifacts}
value={row.secondArtifactKey ?? ""}
placeholder="ค้นหาชุดที่สอง (ถ้ามี)…"
rarityBackground
showSelectedImage
onChange={(secondArtifactKey) =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? {
...candidate,
secondArtifactKey:
secondArtifactKey ||
null,
}
: candidate,
),
}
: item,
),
)
}
/>
<Input
type="text"
inputMode="decimal"
aria-label="Artifact percent"
value={row.percent}
aria-invalid={artifactIssue("groups", groupIndex, "rows", rowIndex)}
onChange={(event) =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? {
...candidate,
percent:
normalizePercentInput(
event.target
.value,
candidate.percent,
),
}
: candidate,
),
}
: item,
),
)
}
onBlur={() =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? {
...candidate,
percent:
finalizePercentInput(
candidate.percent,
),
}
: candidate,
),
}
: item,
),
)
}
/>
<DeleteConfirmationDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon"
/>
}
title={`ลบ Artifact อันดับ ${rowIndex + 1}?`}
description={`รายการ Artifact ในกลุ่ม ${group.name} และเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide`}
onConfirm={() =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.filter(
(_, inner) =>
inner !== rowIndex,
),
}
: item,
),
)
}
>
<Trash2Icon />
<span className="sr-only">ลบ Artifact</span>
</DeleteConfirmationDialog>
</div>
<Field>
<FieldLabel
htmlFor={`artifact-note-${groupIndex}-${rowIndex}`}
>
Note (optional)
</FieldLabel>
<Textarea
id={`artifact-note-${groupIndex}-${rowIndex}`}
value={row.note ?? ""}
onChange={(event) =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? {
...candidate,
note: event.target
.value,
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
</div>
</div>
)}
</SortableArtifactRow>
))}
</div>
</SortableContext>
</DndContext>
<Button
type="button"
variant="outline"
onClick={() =>
setGroups(
groups.map((item, index) =>
index === groupIndex
? {
...item,
rows: [
...item.rows,
{
clientId: createClientId("artifact"),
firstArtifactKey: "",
secondArtifactKey: null,
percent: "100",
note: "",
},
],
}
: item,
),
)
}
>
<PlusIcon data-icon="inline-start" />
เพิ่มชุด Artifact
</Button>
</CardContent>
</Card>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recommended Stats</CardTitle>
<CardDescription>
เลือกค่าจากรายการแทนปุ่ม และเลือก Main Stat ได้สูงสุด 3 ค่าต่อชิ้น
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-3">
<StatSelect
label="Sands / นาฬิกา"
options={mainStatOptions.sands}
value={sands}
max={3}
onChange={setSands}
/>
<StatSelect
label="Goblet / ถ้วย"
options={mainStatOptions.goblet}
value={goblet}
max={3}
onChange={setGoblet}
/>
<StatSelect
label="Circlet / หมวก"
options={mainStatOptions.circlet}
value={circlet}
max={3}
onChange={setCirclet}
/>
</div>
<StatSelect
label="Substats"
options={substatOptions}
value={substats}
max={substatOptions.length}
onChange={setSubstats}
/>
<FieldSet>
<FieldLegend>Target Stats</FieldLegend>
<div className="grid gap-5 sm:grid-cols-2">
{[
targetStatOptions.slice(0, 4),
targetStatOptions.slice(4),
].map((column) => (
<div key={column[0]} className="flex flex-col gap-3">
{column.map((stat) => {
const target = targets.find((item) => item.stat === stat);
return (
<Field
key={stat}
orientation="horizontal"
className="items-center"
>
<FieldLabel className="flex w-32 shrink-0 items-center gap-2">
<StatIcons values={[stat]} />
{stat}
</FieldLabel>
<Input
value={target?.value ?? ""}
placeholder="Value or text"
aria-label={`${stat} value`}
onChange={(event) =>
setTargets(
targets.map((item) =>
item.stat === stat
? { ...item, value: event.target.value }
: item,
),
)
}
/>
</Field>
);
})}
</div>
))}
</div>
<FieldDescription>
กรอกเฉพาะค่าเป็นตัวเลข เปอร์เซ็นต์ หรือข้อความ เช่น -%
หากไม่กรอกค่า ระบบจะไม่แสดงส่วนค่าสถานะเป้าหมาย
</FieldDescription>
</FieldSet>
<FieldSet>
<FieldLegend>Talent Priority</FieldLegend>
<DndContext
sensors={talentSensors}
collisionDetection={closestCenter}
onDragEnd={handleTalentDragEnd}
>
<SortableContext
items={orderedTalents.map((talent) => talent.type)}
strategy={horizontalListSortingStrategy}
>
<div className="flex items-start gap-3 overflow-x-auto pb-1">
{orderedTalents.map((talent, index) => {
const catalogTalent = catalogTalents.find(
(item) => item.type === talent.type,
);
return (
<div
key={talent.type}
className="flex shrink-0 items-center gap-3"
>
<SortableTalentCard
talent={talent}
catalogTalent={catalogTalent}
onLevelChange={(level) =>
setTalents(
orderedTalents.map((item) =>
item.type === talent.type
? { ...item, level }
: item,
),
)
}
/>
{index < orderedTalents.length - 1 ? (
<ToggleGroup
variant="outline"
spacing={0}
value={[talent.relationToNext ?? "gt"]}
onValueChange={(selected) => {
const relation = selected[0] as
| TalentRelation
| undefined;
if (relation)
setTalentRelation(talent.type, relation);
}}
aria-label={`ตัวเปรียบเทียบหลัง ${catalogTalent?.name ?? talent.type}`}
className="shrink-0"
>
<ToggleGroupItem
className={SELECTED_TOGGLE_CLASS}
value="gt"
aria-label="มากกว่า"
>
&gt;
</ToggleGroupItem>
<ToggleGroupItem
className={SELECTED_TOGGLE_CLASS}
value="gte"
aria-label="มากกว่าหรือเท่ากับ"
>
≥
</ToggleGroupItem>
<ToggleGroupItem
className={SELECTED_TOGGLE_CLASS}
value="eq"
aria-label="เท่ากับ"
>
=
</ToggleGroupItem>
</ToggleGroup>
) : null}
</div>
);
})}
</div>
</SortableContext>
</DndContext>
</FieldSet>
<Field>
<FieldLabel htmlFor="artifact-note">Note (optional)</FieldLabel>
<Textarea
id="artifact-note"
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end">
<AutosaveStatus
state={autosave.state}
issues={
validation.success
? []
: formatGuideValidationIssues(
"artifact",
validation.error.issues,
)
}
onSave={autosave.saveNow}
/>
</CardFooter>
</Card>
</div>
);
}
function ConstellationEditor({
guide,
initial,
catalogConstellations,
}: EditorBase) {
const [note, setNote] = useState(initial.sectionNote ?? "");
const makeRows = (savedRows: ConstellationValue[] = []) => {
const source = catalogConstellations.length
? catalogConstellations
: Array.from({ length: 6 }, (_, index) => ({
level: index + 1,
name: `C${index + 1}`,
description:
savedRows.find((row) => row.level === index + 1)?.detail ?? "",
iconUrl: null,
}));
return source.map((constellation) => {
const saved = savedRows.find((row) => row.level === constellation.level);
return {
...constellation,
cumulativePercent: saved?.cumulativePercent ?? "",
isGreat: saved?.isGreat ?? false,
};
});
};
const [groups, setGroups] = useState(() => {
const source = initial.constellationGroups.length
? initial.constellationGroups
: [{ id: "constellation-condition-default", note: null, rows: [] }];
return source.map((group) => ({
clientId: group.id,
note: group.note ?? "",
rows: makeRows(group.rows),
}));
});
const value = {
note: note.trim() || null,
groups: groups.map((group) => ({
note: group.note.trim() || null,
rows: group.rows
.filter((row) => row.description.trim() && row.cumulativePercent !== "")
.map((row) => ({
level: row.level,
detail: row.description,
cumulativePercent: Number(row.cumulativePercent),
isGreat: row.isGreat,
})),
})),
};
const validation = constellationSectionSchema.safeParse({
...value,
expectedVersion: guide.version,
});
const constellationIssue = (...path: (string | number)[]) =>
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
const localDraft = { note, groups };
const autosave = useAutosave({
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion) =>
saveConstellationSection(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "constellations"),
localDraft,
parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(candidate, ["groups"], ["note"]),
restoreLocalDraft: (draft) => {
setNote(draft.note);
setGroups(draft.groups);
},
});
return (
<Card>
<CardHeader>
<CardTitle>Constellations</CardTitle>
<CardDescription>
ชื่อ รายละเอียด และไอคอนมาจาก Lunaris อัตโนมัติ
กรอกเฉพาะเปอร์เซ็นต์เทียบกับ C0 = 100%
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="constellation-section-note">
Header note (optional)
</FieldLabel>
<Textarea
id="constellation-section-note"
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</Field>
{groups.map((group, groupIndex) => (
<Card key={group.clientId} size="sm">
<CardHeader>
<CardTitle>Condition {groupIndex + 1}</CardTitle>
<CardDescription>
ใช้ note เพื่ออธิบายอาวุธ ทีม หรือเงื่อนไขที่ใช้เปรียบเทียบ
</CardDescription>
{groups.length > 1 ? (
<CardAction>
<DeleteConfirmationDialog
trigger={
<Button type="button" variant="ghost" size="icon-sm" />
}
title={`ลบ Condition ${groupIndex + 1}?`}
description="เปอร์เซ็นต์กลุ่มดาวทั้งหมดในเงื่อนไขนี้จะถูกลบออก"
onConfirm={() =>
setGroups(
groups.filter(
(item) => item.clientId !== group.clientId,
),
)
}
>
<Trash2Icon />
<span className="sr-only">
ลบ Condition {groupIndex + 1}
</span>
</DeleteConfirmationDialog>
</CardAction>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field>
<FieldLabel
htmlFor={`constellation-condition-note-${group.clientId}`}
>
Condition note (optional)
</FieldLabel>
<Textarea
id={`constellation-condition-note-${group.clientId}`}
value={group.note}
onChange={(event) =>
setGroups(
groups.map((item) =>
item.clientId === group.clientId
? { ...item, note: event.target.value }
: item,
),
)
}
/>
</Field>
<div className="grid gap-4 lg:grid-cols-2">
{group.rows.map((row, rowIndex) => (
<Card
key={row.level}
size="sm"
className={cn(
constellationIssue("groups", groupIndex, "rows", rowIndex) && "border-destructive",
)}
>
<CardHeader>
{row.iconUrl ? (
<Image
src={row.iconUrl}
alt=""
width={48}
height={48}
className="rounded-lg bg-muted"
/>
) : null}
<CardTitle>
C{row.level} · {row.name}
</CardTitle>
<CardDescription>{row.description}</CardDescription>
</CardHeader>
<CardContent className="flex items-end gap-3">
<Field className="min-w-0 flex-1">
<FieldLabel>Cumulative %</FieldLabel>
<Input
type="text"
inputMode="decimal"
value={row.cumulativePercent}
aria-invalid={constellationIssue("groups", groupIndex, "rows", rowIndex, "cumulativePercent")}
onChange={(event) =>
setGroups(
groups.map((item) =>
item.clientId === group.clientId
? {
...item,
rows: item.rows.map((candidate, index) =>
index === rowIndex
? {
...candidate,
cumulativePercent:
normalizePercentInput(
event.target.value,
candidate.cumulativePercent,
),
}
: candidate,
),
}
: item,
),
)
}
onBlur={() =>
setGroups(
groups.map((item) =>
item.clientId === group.clientId
? {
...item,
rows: item.rows.map((candidate, index) =>
index === rowIndex
? {
...candidate,
cumulativePercent:
finalizePercentInput(
candidate.cumulativePercent,
),
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Toggle
variant="outline"
pressed={row.isGreat}
onPressedChange={(isGreat) =>
setGroups(
groups.map((item) =>
item.clientId === group.clientId
? {
...item,
rows: item.rows.map((candidate, index) =>
index === rowIndex
? { ...candidate, isGreat }
: candidate,
),
}
: item,
),
)
}
aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`}
className={cn(
SELECTED_TOGGLE_CLASS,
"data-[state=on]:text-warning",
)}
>
<SparklesIcon data-icon="inline-start" />
Great %
</Toggle>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
))}
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3">
<Button
type="button"
variant="outline"
disabled={groups.length >= 20}
onClick={() =>
setGroups([
...groups,
{
clientId: createClientId("constellation-condition"),
note: "",
rows: makeRows(),
},
])
}
>
<PlusIcon data-icon="inline-start" />
เพิ่ม Condition
</Button>
<AutosaveStatus
state={autosave.state}
issues={
validation.success
? []
: formatGuideValidationIssues(
"constellations",
validation.error.issues,
)
}
onSave={autosave.saveNow}
/>
</CardFooter>
</Card>
);
}
function TeamEditor({
guide,
characters,
weapons,
artifacts,
initial,
}: EditorBase) {
const fallbackMembers = Array.from({ length: 4 }, () => ({
characterKey: "",
weaponKey: null,
artifactKey: null,
secondArtifactKey: null,
constellation: 0,
refinement: 1,
damage: "0",
}));
const teamSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [teams, setTeams] = useState<TeamToolValue[]>(
initial.teams.map(({ id, ...team }) => ({ clientId: id, ...team })),
);
const [selectedTeamIds, setSelectedTeamIds] = useState<Set<string>>(
() => new Set(),
);
const [sourceTeamId, setSourceTeamId] = useState(teams[0]?.clientId ?? "");
const [applyOpen, setApplyOpen] = useState(false);
const [presetOpen, setPresetOpen] = useState(false);
const [presetName, setPresetName] = useState("");
const [presets, setPresets] = useState<TeamPreset[]>([]);
const [presetsHydrated, setPresetsHydrated] = useState(false);
const [presetsGuideId, setPresetsGuideId] = useState<string | null>(null);
const [applyFields, setApplyFields] = useState<TeamApplyField[]>(["loadout"]);
const value = {
teams: teams.map((team) => ({
title: team.title,
rotationSeconds: Number(team.rotationSeconds),
combo: team.combo,
members: team.members.map((member) => ({
...member,
damage: Number(member.damage),
})),
})),
};
const validation = teamSectionSchema.safeParse({
...value,
expectedVersion: guide.version,
});
const teamFieldInvalid = (teamIndex: number, field: string) =>
!validation.success && validation.error.issues.some((issue) =>
issue.path[0] === "teams" && issue.path[1] === teamIndex && issue.path[2] === field,
);
const memberFieldInvalid = (teamIndex: number, memberIndex: number, field: string) =>
!validation.success && validation.error.issues.some((issue) =>
issue.path[0] === "teams" && issue.path[1] === teamIndex && issue.path[2] === "members" &&
(issue.path[3] === memberIndex && issue.path[4] === field || issue.path.length === 3),
);
const localDraft = { teams };
useEffect(() => {
const timer = window.setTimeout(() => {
try {
setPresets(parseTeamPresets(window.localStorage.getItem(teamPresetStorageKey(guide.id))));
} catch {
setPresets([]);
}
setPresetsHydrated(true);
setPresetsGuideId(guide.id);
}, 0);
return () => window.clearTimeout(timer);
}, [guide.id]);
useEffect(() => {
if (!presetsHydrated || presetsGuideId !== guide.id) return;
try {
window.localStorage.setItem(teamPresetStorageKey(guide.id), JSON.stringify(presets));
document.cookie = `${teamPresetCookieKey(guide.id)}=${encodeURIComponent(JSON.stringify(presets.map(({ id, name, createdAt }) => ({ id, name, createdAt })) ))}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
} catch {
// Browser storage is optional; the editor remains usable without it.
}
}, [guide.id, presets, presetsGuideId, presetsHydrated]);
const autosave = useAutosave({
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion) =>
saveTeamSection(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "team"),
localDraft,
parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(candidate, ["teams"]),
restoreLocalDraft: (draft) => setTeams(draft.teams),
});
const handleTeamDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setTeams((current) => {
const from = current.findIndex((team) => team.clientId === active.id);
const to = current.findIndex((team) => team.clientId === over.id);
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
});
};
const toggleTeamSelection = (teamId: string, checked: boolean) => {
setSelectedTeamIds((current) => {
const next = new Set(current);
if (checked) next.add(teamId);
else next.delete(teamId);
return next;
});
};
const toggleApplyField = (field: TeamApplyField, checked: boolean) => {
setApplyFields((current) => {
const next = new Set(current);
if (checked) next.add(field);
else next.delete(field);
return teamApplyFields.filter((candidate) => next.has(candidate));
});
};
const applyChanges = () => {
if (!sourceTeamId || !selectedTeamIds.size || !applyFields.length) return;
setTeams((current) => applyTeamFields(current, sourceTeamId, selectedTeamIds, applyFields));
setSelectedTeamIds(new Set());
setApplyOpen(false);
};
const savePreset = () => {
const source = teams.find((team) => team.clientId === sourceTeamId);
const name = presetName.trim();
if (!source || !name) return;
const preset: TeamPreset = {
id: createClientId("preset"),
name,
createdAt: new Date().toISOString(),
team: {
title: source.title,
rotationSeconds: source.rotationSeconds,
combo: source.combo,
members: source.members.map((member) => ({ ...member })),
},
};
setPresets((current) => limitPresets([preset, ...current]));
setPresetName("");
setPresetOpen(false);
};
const applyPreset = (preset: TeamPreset) => {
if (!selectedTeamIds.size || !applyFields.length) return;
setTeams((current) => applyPresetFields(current, preset, selectedTeamIds, applyFields));
setSelectedTeamIds(new Set());
};
return (
<Card>
<CardHeader>
<CardTitle>Team Calc</CardTitle>
<CardDescription>
ลากเพื่อเรียง Team แล้วใส่ความเสียหายของตัวละคร 4 ตัว
ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-3">
<Checkbox
checked={selectedTeamIds.size === teams.length && teams.length > 0}
onCheckedChange={(checked) => setSelectedTeamIds(checked ? new Set(teams.map((team) => team.clientId)) : new Set())}
aria-label="เลือกทุก Team"
/>
<span className="text-sm font-medium">เลือก Team สำหรับ Multiple Apply</span>
<Select value={sourceTeamId} onValueChange={(value) => { if (value) setSourceTeamId(value); }}>
<SelectTrigger className="w-48"><SelectValue placeholder="Source Team" /></SelectTrigger>
<SelectContent>
<SelectGroup>{teams.map((team) => <SelectItem key={team.clientId} value={team.clientId}>{team.title || "Untitled Team"}</SelectItem>)}</SelectGroup>
</SelectContent>
</Select>
<Button type="button" size="sm" disabled={!selectedTeamIds.size} onClick={() => setApplyOpen(true)}>Multiple Apply</Button>
<Button type="button" variant="outline" size="sm" disabled={!sourceTeamId} onClick={() => setPresetOpen(true)}>Save preset</Button>
{presets.map((preset) => (
<div key={preset.id} className="flex items-center gap-1">
<Button type="button" variant="ghost" size="sm" disabled={!selectedTeamIds.size} onClick={() => applyPreset(preset)}>{preset.name}</Button>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setPresets((current) => current.filter((item) => item.id !== preset.id))} aria-label={`ลบ preset ${preset.name}`}><XIcon /></Button>
</div>
))}
</div>
<DndContext
sensors={teamSensors}
collisionDetection={closestCenter}
onDragEnd={handleTeamDragEnd}
>
<SortableContext
items={teams.map((team) => team.clientId)}
strategy={verticalListSortingStrategy}
>
<Accordion
multiple
defaultValue={teams.length ? [teams[0].clientId] : []}
className="gap-3"
>
{teams.map((team, teamIndex) => {
const total = team.members.reduce(
(sum, member) => sum + (Number(member.damage) || 0),
0,
);
const dps = total / (Number(team.rotationSeconds) || 1);
return (
<SortableTeamItem key={team.clientId} id={team.clientId}>
{(dragHandle) => (
<>
<div className="flex w-full items-center gap-2">
<Checkbox
checked={selectedTeamIds.has(team.clientId)}
onCheckedChange={(checked) => toggleTeamSelection(team.clientId, checked === true)}
onClick={(event) => event.stopPropagation()}
aria-label={`เลือก ${team.title || `Team ${teamIndex + 1}`}`}
/>
<AccordionTrigger
headerClassName="min-w-0 flex-1"
className="min-w-0 flex-1 py-4 hover:no-underline"
>
<span className="flex min-w-0 flex-col gap-1">
<span className="font-semibold">
{team.title || `Team ${teamIndex + 1}`}
</span>
<span className="text-xs font-normal text-muted-foreground">
Damage {total.toLocaleString()} • DPS{" "}
{dps.toFixed(0)}
</span>
</span>
</AccordionTrigger>
<div className="ml-auto flex shrink-0 items-center gap-1">
{dragHandle}
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={teams.length >= 30}
onClick={() =>
setTeams((current) => {
const sourceIndex = current.findIndex(
(item) => item.clientId === team.clientId,
);
if (
sourceIndex === -1 ||
current.length >= 30
)
return current;
const source = current[sourceIndex];
const title = source.title.trim()
? `${source.title.slice(0, 153)} (Copy)`
: `Team ${sourceIndex + 1} (Copy)`;
const copy = {
...source,
clientId: createClientId("team"),
title,
members: source.members.map((member) => ({
...member,
})),
};
return current.toSpliced(
sourceIndex + 1,
0,
copy,
);
})
}
>
<CopyIcon />
<span className="sr-only">ทำสำเนา Team</span>
</Button>
<DeleteConfirmationDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
/>
}
title={`ลบ ${team.title || `Team ${teamIndex + 1}`}?`}
description="ข้อมูล Team และตัวละครทั้ง 4 คนจะถูกลบออกจาก Guide"
confirmLabel="ยืนยันลบ Team"
onConfirm={() =>
setTeams(
teams.filter(
(item) => item.clientId !== team.clientId,
),
)
}
>
<Trash2Icon />
<span className="sr-only">ลบ Team</span>
</DeleteConfirmationDialog>
</div>
</div>
<AccordionContent className="pb-4" data-invalid={teamFieldInvalid(teamIndex, "members")}>
<FieldGroup>
<Field data-invalid={teamFieldInvalid(teamIndex, "title")}>
<FieldLabel className="flex items-center gap-2">
<UserRoundIcon />
ชื่อ Team
</FieldLabel>
<Input
value={team.title}
aria-invalid={teamFieldInvalid(teamIndex, "title")}
onChange={(event) =>
setTeams(
teams.map((item, index) =>
index === teamIndex
? { ...item, title: event.target.value }
: item,
),
)
}
/>
</Field>
<div className="grid gap-4 md:grid-cols-2">
{team.members.map((member, memberIndex) => {
const character = characters.find(
(item) => item.key === member.characterKey,
);
const compatibleWeapons = weapons.filter(
(weapon) =>
supportsWeaponType(
character?.weaponType,
weapon.weaponType,
),
);
return (
<Card key={memberIndex} size="sm" data-invalid={memberFieldInvalid(teamIndex, memberIndex, "members")}>
<CardHeader>
<CardTitle>
ตัวละคร {memberIndex + 1}
</CardTitle>
<CardDescription>
{total
? `${(((Number(member.damage) || 0) / total) * 100).toFixed(2)}% ของ Team`
: "0% ของ Team"}
</CardDescription>
{character ? (
<CardAction>
<Avatar>
<AvatarImage
src={character.imageUrl}
alt={character.name}
/>
<AvatarFallback>
{character.name
.slice(0, 2)
.toUpperCase()}
</AvatarFallback>
</Avatar>
</CardAction>
) : null}
</CardHeader>
<CardContent>
<FieldGroup>
<Field data-invalid={memberFieldInvalid(teamIndex, memberIndex, "characterKey")}>
<FieldLabel className="flex items-center gap-2">
<UserRoundIcon />
ตัวละคร
</FieldLabel>
<CatalogPicker
items={characters}
value={member.characterKey}
invalid={memberFieldInvalid(teamIndex, memberIndex, "characterKey")}
placeholder="ค้นหาชื่อตัวละคร…"
onChange={(characterKey) =>
setTeams(
teams.map((item, index) =>
index === teamIndex
? {
...item,
members:
item.members.map(
(
candidate,
inner,
) =>
inner ===
memberIndex
? {
...candidate,
characterKey,
weaponKey:
null,
artifactKey:
null,
secondArtifactKey:
null,
constellation: 0,
refinement: 1,
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Field data-invalid={memberFieldInvalid(teamIndex, memberIndex, "damage")}>
<FieldLabel className="flex items-center gap-2">
<ChartNoAxesCombinedIcon />
Damage
</FieldLabel>
<Input
type="number"
min="0"
step="0.01"
value={member.damage}
aria-invalid={memberFieldInvalid(teamIndex, memberIndex, "damage")}
onChange={(event) =>
setTeams(
teams.map((item, index) =>
index === teamIndex
? {
...item,
members:
item.members.map(
(
candidate,
inner,
) =>
inner ===
memberIndex
? {
...candidate,
damage:
event
.target
.value,
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Field>
<FieldLabel className="flex items-center gap-2">
<SparklesIcon />
Constellation
</FieldLabel>
<ToggleGroup
className="flex-wrap justify-start"
variant="outline"
value={[`C${member.constellation}`]}
onValueChange={(selected) => {
const constellation = selected[0];
if (constellation)
setTeams(
teams.map((item, index) =>
index === teamIndex
? {
...item,
members:
item.members.map(
(
candidate,
inner,
) =>
inner ===
memberIndex
? {
...candidate,
constellation:
Number(
constellation.slice(
1,
),
),
}
: candidate,
),
}
: item,
),
);
}}
>
{[0, 1, 2, 3, 4, 5, 6].map(
(constellation) => (
<ToggleGroupItem
className={
SELECTED_TOGGLE_CLASS
}
key={constellation}
value={`C${constellation}`}
>
C{constellation}
</ToggleGroupItem>
),
)}
</ToggleGroup>
</Field>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4">
<Field className="min-w-0">
<FieldLabel className="flex items-center gap-2">
<SwordIcon />
Weapon
</FieldLabel>
<CatalogPicker
items={compatibleWeapons}
value={member.weaponKey ?? ""}
placeholder="ค้นหาอาวุธที่รองรับ…"
rarityBackground
onChange={(weaponKey) =>
setTeams(
teams.map((item, index) =>
index === teamIndex
? {
...item,
members:
item.members.map(
(
candidate,
inner,
) =>
inner ===
memberIndex
? {
...candidate,
weaponKey:
weaponKey ||
null,
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Field className="shrink-0">
<FieldLabel className="flex items-center gap-2">
<RefreshCcwIcon />
Refinement
</FieldLabel>
<ToggleGroup
className="flex-nowrap"
variant="outline"
value={[`R${member.refinement}`]}
onValueChange={(selected) => {
const refinement = selected[0];
if (refinement)
setTeams(
teams.map((item, index) =>
index === teamIndex
? {
...item,
members:
item.members.map(
(
candidate,
inner,
) =>
inner ===
memberIndex
? {
...candidate,
refinement:
Number(
refinement.slice(
1,
),
),
}
: candidate,
),
}
: item,
),
);
}}
>
{[1, 2, 3, 4, 5].map(
(refinement) => (
<ToggleGroupItem
className={
SELECTED_TOGGLE_CLASS
}
key={refinement}
value={`R${refinement}`}
>
R{refinement}
</ToggleGroupItem>
),
)}
</ToggleGroup>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel className="flex items-center gap-2">
<GemIcon />
Artifact set 1
</FieldLabel>
<CatalogPicker
items={artifacts}
value={member.artifactKey ?? ""}
placeholder="ค้นหาชุด Artifact…"
rarityBackground
onChange={(artifactKey) =>
setTeams(
teams.map((item, index) =>
index === teamIndex
? {
...item,
members:
item.members.map(
(
candidate,
inner,
) =>
inner ===
memberIndex
? {
...candidate,
artifactKey:
artifactKey ||
null,
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
<Field>
<FieldLabel className="flex items-center gap-2">
<GemIcon />
Artifact set 2
</FieldLabel>
<CatalogPicker
items={artifacts}
value={
member.secondArtifactKey ?? ""
}
placeholder="ค้นหาชุด Artifact…"
rarityBackground
onChange={(secondArtifactKey) =>
setTeams(
teams.map((item, index) =>
index === teamIndex
? {
...item,
members:
item.members.map(
(
candidate,
inner,
) =>
inner ===
memberIndex
? {
...candidate,
secondArtifactKey:
secondArtifactKey ||
null,
}
: candidate,
),
}
: item,
),
)
}
/>
</Field>
</div>
</FieldGroup>
</CardContent>
</Card>
);
})}
</div>
<Field data-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}>
<FieldLabel className="flex items-center gap-2">
<TimerIcon />
Rotation (seconds)
</FieldLabel>
<Input
type="number"
min="0.01"
step="0.01"
value={team.rotationSeconds}
aria-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}
onChange={(event) =>
setTeams(
teams.map((item, index) =>
index === teamIndex
? {
...item,
rotationSeconds: event.target.value,
}
: item,
),
)
}
/>
</Field>
<Field data-invalid={teamFieldInvalid(teamIndex, "combo")}>
<FieldLabel className="flex items-center gap-2">
<ListOrderedIcon />
Combo
</FieldLabel>
<Textarea
value={team.combo}
aria-invalid={teamFieldInvalid(teamIndex, "combo")}
onChange={(event) =>
setTeams(
teams.map((item, index) =>
index === teamIndex
? { ...item, combo: event.target.value }
: item,
),
)
}
/>
</Field>
</FieldGroup>
</AccordionContent>
</>
)}
</SortableTeamItem>
);
})}
</Accordion>
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3">
<Button
variant="outline"
disabled={characters.length < 4}
onClick={() =>
setTeams([
...teams,
{
clientId: createClientId("team"),
title: "",
rotationSeconds: "20",
combo: "",
members: fallbackMembers,
},
])
}
>
<PlusIcon data-icon="inline-start" />
เพิ่ม Team
</Button>
<AutosaveStatus
state={autosave.state}
issues={
validation.success
? []
: formatGuideValidationIssues("team", validation.error.issues, {
teamNames: teams.map((team) => team.title),
})
}
onSave={autosave.saveNow}
/>
</CardFooter>
<Dialog open={applyOpen} onOpenChange={setApplyOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Multiple Apply</DialogTitle>
<DialogDescription>คัดลอกค่าจาก {teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"} ไปยัง {selectedTeamIds.size} Team ที่เลือก</DialogDescription>
</DialogHeader>
<FieldSet>
<FieldLegend>ข้อมูลที่ต้องการคัดลอก</FieldLegend>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("loadout")} onCheckedChange={(checked) => toggleApplyField("loadout", checked === true)} /><FieldLabel>Loadout: Character, Weapon, Artifact, C, R</FieldLabel></Field>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("damage")} onCheckedChange={(checked) => toggleApplyField("damage", checked === true)} /><FieldLabel>Damage</FieldLabel></Field>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("rotation")} onCheckedChange={(checked) => toggleApplyField("rotation", checked === true)} /><FieldLabel>Rotation</FieldLabel></Field>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("combo")} onCheckedChange={(checked) => toggleApplyField("combo", checked === true)} /><FieldLabel>Combo</FieldLabel></Field>
</FieldSet>
<DialogFooter><Button type="button" variant="outline" onClick={() => setApplyOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!applyFields.length} onClick={applyChanges}>ยืนยันการคัดลอก</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={presetOpen} onOpenChange={setPresetOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Save team preset</DialogTitle><DialogDescription>บันทึก Source Team นี้ไว้ใช้กับ Guide นี้ในเบราว์เซอร์นี้</DialogDescription></DialogHeader>
<Field><FieldLabel htmlFor="team-preset-name">ชื่อ Preset</FieldLabel><Input id="team-preset-name" value={presetName} onChange={(event) => setPresetName(event.target.value)} /></Field>
<DialogFooter><Button type="button" variant="outline" onClick={() => setPresetOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!presetName.trim()} onClick={savePreset}>บันทึก</Button></DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
function ExtraEditor({ guide, initial }: EditorBase) {
const [sections, setSections] = useState(initial.extras);
const value = {
sections: sections.map((section) => ({
title: section.title,
rows: section.rows.map((row) => ({
mediaId: row.mediaId,
ratio: row.ratio,
heading: row.heading,
body: row.body,
percent:
row.percent === null || row.percent === ""
? null
: Number(row.percent),
})),
})),
};
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({
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion) =>
saveExtraSectionList(guide.id, { ...next, expectedVersion }),
storageKey: adminDraftKey(guide.id, "extra"),
localDraft,
parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(candidate, ["sections"]),
restoreLocalDraft: (draft) => setSections(draft.sections),
});
return (
<Card>
<CardHeader>
<CardTitle>Extra sections</CardTitle>
<CardDescription>
เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
ระบบจะบันทึกอัตโนมัติ
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
{sections.map((section, sectionIndex) => (
<Card
key={sectionIndex}
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>
<DeleteConfirmationDialog
trigger={
<Button type="button" variant="ghost" size="icon-sm" />
}
title={`ลบ ${section.title || `Extra section ${sectionIndex + 1}`}?`}
description="แถว ข้อความ รูปภาพ และเปอร์เซ็นต์ทั้งหมดในส่วนนี้จะถูกลบออกจาก Guide"
onConfirm={() =>
setSections(
sections.filter((_, index) => index !== sectionIndex),
)
}
>
<Trash2Icon />
<span className="sr-only">ลบส่วน</span>
</DeleteConfirmationDialog>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{section.rows.map((row, rowIndex) => (
<Card
key={rowIndex}
size="sm"
className={cn(
extraIssue("sections", sectionIndex, "rows", rowIndex) &&
"border-destructive",
)}
>
<CardHeader>
<CardTitle>
{row.heading || `แถว ${rowIndex + 1}`}
</CardTitle>
<CardAction>
<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>
</CardAction>
</CardHeader>
<CardContent>
<FieldGroup>
{row.imageUrl ? (
<div
className={cn(
"relative overflow-hidden rounded-lg border",
row.ratio === "1:1"
? "aspect-square"
: "aspect-video",
)}
>
<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,
}
: 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,
}
: candidate,
),
}
: item,
),
)
}
/>
<Field>
<FieldLabel>อัตราส่วนภาพ</FieldLabel>
<ToggleGroup
variant="outline"
value={[row.ratio]}
onValueChange={(selected) => {
const ratio = selected[0];
if (ratio === "1:1" || ratio === "16:9")
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? { ...candidate, ratio }
: candidate,
),
}
: item,
),
);
}}
className="flex-wrap justify-start"
>
<ToggleGroupItem
className={SELECTED_TOGGLE_CLASS}
value="1:1"
>
1:1
</ToggleGroupItem>
<ToggleGroupItem
className={SELECTED_TOGGLE_CLASS}
value="16:9"
>
16:9
</ToggleGroupItem>
</ToggleGroup>
</Field>
<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>
<Textarea
value={row.body}
onChange={(event) =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
body: event.target.value,
}
: candidate,
),
}
: item,
),
)
}
/>
</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>
</FieldGroup>
</CardContent>
</Card>
))}
<Button
type="button"
variant="outline"
onClick={() =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: [
...item.rows,
{
mediaId: null,
imageUrl: null,
ratio: "16:9",
heading: "",
body: "",
percent: null,
},
],
}
: item,
),
)
}
>
<PlusIcon data-icon="inline-start" />
เพิ่มแถว
</Button>
</CardContent>
</Card>
))}
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3">
<Button
variant="outline"
onClick={() =>
setSections([...sections, { title: "Extra", rows: [] }])
}
>
<PlusIcon data-icon="inline-start" />
เพิ่ม Extra section
</Button>
<AutosaveStatus
state={autosave.state}
issues={
validation.success
? []
: formatGuideValidationIssues("extra", validation.error.issues)
}
onSave={autosave.saveNow}
/>
</CardFooter>
</Card>
);
}