Files
buzz-sheet/components/admin/guide-section-editor.tsx
T
gunshiz 80d19c8ebb
CI / Verify (push) Successful in 1m16s
CI / Build immutable images and deploy (push) Successful in 1m45s
fix(ui) : constrain condition selectors on mobile
2026-09-08 00:21:08 +07:00

4583 lines
189 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 dynamic from "next/dynamic";
import Image from "next/image";
import { useRouter } from "next/navigation";
import {
ClipboardCopyIcon,
ClipboardPasteIcon,
CopyIcon,
CircleHelpIcon,
GripVerticalIcon,
HeartPulseIcon,
PlusIcon,
RefreshCcwIcon,
SearchIcon,
SparklesIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode, type SetStateAction } 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,
saveArtifactSection,
saveConstellationSection,
saveGuideExtra,
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 { ScrollableNav } from "@/components/public/scrollable-nav";
import { TeamArtifactIcon } from "@/components/public/team-search";
import { SimpleTooltip } from "@/components/tooltip";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
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,
ComboboxTrigger,
} from "@/components/ui/combobox";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
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 { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
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,
recommendedStatElement,
recommendedStatIconName,
targetStatDefaults,
targetStatIconName,
targetStatOptions,
statPriorityColorClass,
} from "@/lib/guides/stats";
import { elementBackgroundColor, elementIconFilter } from "@/lib/catalog/element";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { rarityGradientClass } from "@/lib/catalog/rarity";
import { isCustomTravelerKey } from "@/lib/catalog/travelers";
import {
applyPresetFields,
applyTeamFields,
findInvalidTeamCatalogReference,
findUnsupportedTeamWeapon,
limitPresets,
parseTeamPresets,
parseTeamTransfer,
serializeTeamTransfer,
teamTransferFileName,
teamPresetCookieKey,
teamPresetStorageKey,
type TeamApplySelection,
type TeamMemberApplyField,
type TeamPreset,
type TeamToolValue,
} from "@/lib/guides/team-tools";
import {
matchesTeamSearch,
type TeamSearchTarget,
} from "@/lib/guides/team-search";
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 { normalizeExtraRichText } from "@/lib/rich-text";
import { createExtraSheet, type ExtraSheet } from "@/lib/guides/extra-sheet";
import { cn } from "@/lib/utils";
type CatalogItem = {
key: string;
name: string;
aliases?: string[];
imageUrl: string;
rarity?: number | null;
weaponType?: string | null;
element?: string | null;
elementIconUrl?: string | null;
};
const catalogItemsEqual = (item: CatalogItem, value: CatalogItem) =>
item.key === value.key;
type ExtraCatalogItem = {
kind: "character" | "weapon" | "artifact";
key: string;
};
const teamMemberApplyOptions = [
["characterKey", "Character"],
["weaponKey", "Weapon"],
["artifactKey", "Artifact 1"],
["secondArtifactKey", "Artifact 2"],
["constellation", "C"],
["refinement", "R"],
["damage", "Damage"],
] as const satisfies readonly (readonly [TeamMemberApplyField, string])[];
const multipleApplyStorageKey = (guideId: string) =>
`buzz-sheet:team-multiple-apply:${guideId}`;
type Guide = {
id: string;
characterKey: string;
version: number;
name: string;
};
type WeaponValue = {
id: string;
weaponKey: string;
refinement: string;
overallPercent: string | null;
personalPercent: string | null;
note: string | null;
weapon: CatalogItem;
};
type WeaponGroupValue = {
id: string;
name: string | null;
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 }>;
targetConditions: Array<{
name?: string | null;
note: string | null;
rows: 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;
name: string | null;
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;
}>;
};
type TeamGroupValue = {
id: string;
name: string | null;
note: string | null;
teams: TeamValue[];
};
let clientIdCounter = 0;
const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C";
const RichTextEditor = dynamic(
() => import("@/components/rich-text-editor").then((module) => module.RichTextEditor),
{
ssr: false,
loading: () => <Skeleton className="h-36 w-full" />,
},
);
const ExtraSheetEditor = dynamic(
() => import("@/components/admin/extra-sheet-editor").then((module) => module.ExtraSheetEditor),
{
ssr: false,
loading: () => <Skeleton className="h-48 w-full" />,
},
);
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 ConditionRenameDialog({
open,
fallback,
value,
onOpenChange,
onChange,
}: {
open: boolean;
fallback: string;
value: string;
onOpenChange: (open: boolean) => void;
onChange: (value: string) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename condition</DialogTitle>
<DialogDescription>
Leave blank to use {fallback}.
</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor="condition-name">Condition name</FieldLabel>
<Input
id="condition-name"
autoFocus
maxLength={120}
placeholder={fallback}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</Field>
<DialogFooter>
<Button type="button" onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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";
extraSectionId?: string;
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[];
teamGroups: TeamGroupValue[];
extras: Array<{
id: string;
title: string;
rows: Array<{
id: string;
mediaId: string | null;
heading: string;
body: string;
percent: string | null;
backgroundColor: string | null;
textColor: string | null;
sheet: ExtraSheet | null;
catalogItems: ExtraCatalogItem[];
highlights: Array<{ text: string; color: string }>;
imageUrl: string | null;
imageWidth: number | null;
imageHeight: number | 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
sizes="40px"
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}
filter={(item, query) =>
[item.name, ...(item.aliases ?? [])]
.join(" ")
.toLocaleLowerCase("th")
.includes(query.trim().toLocaleLowerCase("th"))
}
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
sizes="32px"
className="object-contain"
/>
</div>
<span className="whitespace-nowrap">{item.name}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</div>
);
}
function CatalogSlotPicker({
items,
value,
onChange,
label,
placeholder,
rarityBackground = false,
invalid = false,
className,
}: {
items: CatalogItem[];
value: string;
onChange: (value: string) => void;
label: string;
placeholder: string;
rarityBackground?: boolean;
invalid?: boolean;
className?: string;
}) {
const selected = items.find((item) => item.key === value) ?? null;
return (
<Combobox
items={items}
value={selected}
onValueChange={(next) => onChange(next?.key ?? "")}
itemToStringLabel={(item) => item.name}
itemToStringValue={(item) => item.key}
filter={(item, query) =>
[item.name, ...(item.aliases ?? [])]
.join(" ")
.toLocaleLowerCase("th")
.includes(query.trim().toLocaleLowerCase("th"))
}
autoHighlight
>
<ComboboxTrigger
aria-label={selected ? `${label}: ${selected.name}` : label}
aria-invalid={invalid}
className={cn(
"relative aspect-square w-full overflow-hidden rounded-md border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring [&>svg]:hidden",
rarityBackground && selected && rarityGradientClass(selected.rarity),
className,
)}
>
{selected ? (
<Image
src={selected.imageUrl}
alt=""
fill
sizes="96px"
className="object-contain p-0.5"
/>
) : (
<CircleHelpIcon className="size-8 text-muted-foreground" aria-hidden="true" />
)}
</ComboboxTrigger>
<ComboboxContent className="min-w-72">
<ComboboxInput placeholder={placeholder} showClear showTrigger={false} />
<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
sizes="32px"
className="object-contain"
/>
</div>
<span className="whitespace-nowrap">{item.name}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
function ArtifactSlotPicker({
items,
values,
onChange,
label,
}: {
items: CatalogItem[];
values: string[];
onChange: (values: string[]) => void;
label: string;
}) {
const firstKey = values[0];
const secondKey = values[1];
const selected = useMemo(
() => [firstKey, secondKey].flatMap((value) => {
const item = items.find((candidate) => candidate.key === value);
return item ? [item] : [];
}),
[firstKey, items, secondKey],
);
const artifactIconValue = (item: CatalogItem | undefined) => item
? {
key: item.key,
name: item.name,
imageUrl: item.imageUrl,
rarity: item.rarity ?? null,
}
: null;
return (
<Combobox
multiple
items={items}
value={selected}
onValueChange={(next) =>
onChange(next.slice(0, 2).map((item) => item.key))
}
itemToStringLabel={(item) => item.name}
itemToStringValue={(item) => item.key}
isItemEqualToValue={catalogItemsEqual}
filter={(item, query) =>
[item.name, ...(item.aliases ?? [])]
.join(" ")
.toLocaleLowerCase("th")
.includes(query.trim().toLocaleLowerCase("th"))
}
autoHighlight
>
<ComboboxTrigger
aria-label={selected.length
? `${label}: ${selected.map((item) => item.name).join(", ")}`
: label}
className="relative aspect-square w-full overflow-hidden rounded-md border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring [&>svg]:hidden"
>
{selected.length ? (
<TeamArtifactIcon
first={artifactIconValue(selected[0])}
second={artifactIconValue(selected[1])}
className="border-0"
sizes="96px"
/>
) : (
<CircleHelpIcon className="size-8 text-muted-foreground" aria-hidden="true" />
)}
</ComboboxTrigger>
<ComboboxContent className="min-w-72">
<ComboboxInput
placeholder="ค้นหาชุด Artifact…"
showClear
showTrigger={false}
/>
<ComboboxEmpty>ไม่พบชุด Artifact</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem
key={item.key}
value={item}
disabled={
selected.length >= 2 &&
!selected.some((candidate) => candidate.key === item.key)
}
>
<div
className={cn(
"relative size-8 shrink-0 overflow-hidden rounded-md",
rarityGradientClass(item.rarity),
)}
>
<Image
src={item.imageUrl}
alt=""
fill
sizes="32px"
className="object-contain"
/>
</div>
<span className="whitespace-nowrap">{item.name}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
function StatSelect({
label,
options,
value,
max,
onChange,
elementIconUrls,
priorityColors = false,
}: {
label: string;
options: readonly string[];
value: string[];
max: number;
onChange: (value: string[]) => void;
elementIconUrls: Record<string, string>;
priorityColors?: boolean;
}) {
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<Select
multiple
value={value}
onValueChange={(next) => {
if (next.length <= max) onChange(next);
}}
>
<SelectTrigger className="w-full">
<StatIcons values={value} elementIconUrls={elementIconUrls} />
<SelectValue placeholder="Select recommended stats…">
{priorityColors && value.length
? () => value.map((selected, index) => (
<span key={selected} className={statPriorityColorClass(index)}>
{selected}{index < value.length - 1 ? "," : ""}
</span>
))
: undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((option) => (
<SelectItem
key={option}
value={option}
disabled={value.length >= max && !value.includes(option)}
className={priorityColors && value.includes(option)
? statPriorityColorClass(value.indexOf(option))
: undefined}
>
<StatIcons values={[option]} elementIconUrls={elementIconUrls} />
{option}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>เลือกได้สูงสุด {max} ค่า</FieldDescription>
</Field>
);
}
function StatIcons({
values,
elementIconUrls = {},
}: {
values: readonly string[];
elementIconUrls?: Record<string, string>;
}) {
const icons = values.flatMap((value) => {
const element = recommendedStatElement(value);
if (element && elementIconUrls[element]) {
return [{ name: element, src: elementIconUrls[element], element }];
}
const targetIcon = targetStatIconName(value);
if (targetIcon) {
return [{ name: targetIcon, src: `/icon/stats/${targetIcon}.webp` }];
}
const recommendedIcon = recommendedStatIconName(value);
return recommendedIcon
? [{
name: recommendedIcon,
src: `/icon/stats/${recommendedIcon}.${recommendedIcon === "elemental-bonus" ? "svg" : "webp"}`,
}]
: [];
});
if (!icons.length) return null;
return (
<span className="flex shrink-0 items-center gap-0.5" aria-hidden="true">
{icons.map((icon) => (
<StatIcon key={icon.name} {...icon} />
))}
</span>
);
}
function StatIcon({
name,
src,
element,
}: {
name: string;
src: string;
element?: string;
}) {
const [hasError, setHasError] = useState(false);
if (hasError) {
const PlaceholderIcon =
name === "healing-bonus" ? HeartPulseIcon : SparklesIcon;
return (
<PlaceholderIcon
className="size-5 text-muted-foreground"
aria-hidden="true"
/>
);
}
return (
<Image
src={src}
alt=""
width={20}
height={20}
className={cn("size-5 object-contain", !element && "brightness-0 invert")}
style={element ? { filter: elementIconFilter(element) } : undefined}
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-full shrink-0 sm:w-56", 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 (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn("min-w-0", isDragging && "opacity-60")}
>
{children(
<SimpleTooltip text="ลากเพื่อจัดลำดับ Team">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
{...attributes}
{...listeners}
>
<GripVerticalIcon />
<span className="sr-only">ลากเพื่อจัดลำดับ Team</span>
</Button>
</SimpleTooltip>,
)}
</div>
);
}
function SortableTeamMember({
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(
<SimpleTooltip text="ลากเพื่อย้ายตำแหน่งตัวละคร">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
{...attributes}
{...listeners}
>
<GripVerticalIcon />
<span className="sr-only">ลากเพื่อย้ายตำแหน่งตัวละคร</span>
</Button>
</SimpleTooltip>,
)}
</div>
);
}
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 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", name: null, note: null, rows: [] }];
return source.map((group) => ({
clientId: group.id,
name: group.name ?? "",
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 [renamingWeaponGroupIndex, setRenamingWeaponGroupIndex] = useState<number | null>(null);
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) => ({
name: group.name.trim() || null,
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.map((group) => ({ ...group, name: group.name ?? "" })));
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">
<ScrollableNav ariaLabel="เลือกเงื่อนไขอาวุธ" className="min-w-0 flex-1">
<ToggleGroup
aria-label="เลือกเงื่อนไขอาวุธ"
variant="outline"
value={[String(activeGroupIndex)]}
onValueChange={(values) => {
if (values[0]) setActiveGroupIndex(Number(values[0]));
}}
>
{groups.map((group, index) => (
<ToggleGroupItem
key={group.clientId}
value={String(index)}
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
onDoubleClick={() => setRenamingWeaponGroupIndex(index)}
>
{group.name.trim() || `Condition ${index + 1}`}
</ToggleGroupItem>
))}
</ToggleGroup>
</ScrollableNav>
<Button type="button" variant="outline" disabled={groups.length >= 20} onClick={() => {
setGroups([...groups, { clientId: createClientId("weapon-condition"), name: "", 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"),
name: source.name,
note: source.note,
rows: source.rows.map((row) => ({
...row,
clientId: createClientId("weapon"),
})),
},
]);
setActiveGroupIndex(groups.length);
}}
>
<CopyIcon data-icon="inline-start" />Duplicate Condition
</Button>
</div>
<ConditionRenameDialog
open={renamingWeaponGroupIndex !== null}
fallback={`Condition ${(renamingWeaponGroupIndex ?? 0) + 1}`}
value={renamingWeaponGroupIndex === null ? "" : groups[renamingWeaponGroupIndex]?.name ?? ""}
onOpenChange={(open) => { if (!open) setRenamingWeaponGroupIndex(null); }}
onChange={(name) => setGroups(groups.map((group, index) => index === renamingWeaponGroupIndex ? { ...group, name } : group))}
/>
<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
sizes="40px"
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: next.slice(1),
}
: item,
),
);
}}
>
{["1", "2", "3", "4", "5", "1-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,
characters,
}: EditorBase) {
const profile = initial.artifactProfile;
const elementIconUrls = Object.fromEntries(
characters.flatMap((character) =>
character.element && character.elementIconUrl
? [[character.element, character.elementIconUrl] as const]
: [],
),
);
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 [targetGroups, setTargetGroups] = useState(() => {
const source = profile?.targetConditions.length
? profile.targetConditions
: [{ name: null, note: null, rows: profile?.targets ?? [] }];
return source.map((group, index) => {
const existing = new Map(group.rows.flatMap((target) => {
const stat = normalizeTargetStat(target.stat);
return stat ? [[stat, target.value] as const] : [];
}));
return {
clientId: `target-condition-${index}`,
name: group.name ?? "",
note: group.note ?? "",
rows: targetStatDefaults.map((target) => ({ stat: target.stat, value: existing.get(target.stat) ?? target.value })),
};
});
});
const [activeTargetGroupIndex, setActiveTargetGroupIndex] = useState(0);
const [renamingTargetGroupIndex, setRenamingTargetGroupIndex] = useState<number | null>(null);
const targets = targetGroups[activeTargetGroupIndex]?.rows ?? [];
const setTargets = (rows: typeof targets) => setTargetGroups((current) => current.map((group, index) => index === activeTargetGroupIndex ? { ...group, rows } : group));
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,
targetGroups: targetGroups.map((group) => ({
name: group.name.trim() || null,
note: group.note.trim() || null,
rows: group.rows.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,
targetGroups,
activeTargetGroupIndex,
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",
"targetGroups",
"talents",
],
["note"],
),
restoreLocalDraft: (draft) => {
setGroups(draft.groups);
setSands(draft.sands);
setGoblet(draft.goblet);
setCirclet(draft.circlet);
setSubstats(draft.substats);
setTargetGroups(draft.targetGroups.map((group) => ({ ...group, name: group.name ?? "" })));
setActiveTargetGroupIndex(draft.activeTargetGroupIndex ?? 0);
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">
<div className="grid items-start gap-6 xl:grid-cols-2">
<Card className="order-1">
<CardHeader>
<CardTitle>Artifact tier list</CardTitle>
<CardDescription>
จัดชุด Artifact ในกลุ่ม Late Game และ Early Game
ระบบจะบันทึกอัตโนมัติ
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
{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 className="order-3">
<CardHeader className="bg-primary text-center text-primary-foreground">
<CardTitle>Recommended Stats</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<div className="grid overflow-hidden rounded-xl border sm:grid-cols-3">
<StatSelect
label="Sands / นาฬิกา"
options={mainStatOptions.sands}
value={sands}
max={3}
onChange={setSands}
elementIconUrls={elementIconUrls}
/>
<StatSelect
label="Goblet / ถ้วย"
options={mainStatOptions.goblet}
value={goblet}
max={3}
onChange={setGoblet}
elementIconUrls={elementIconUrls}
/>
<StatSelect
label="Circlet / หมวก"
options={mainStatOptions.circlet}
value={circlet}
max={3}
onChange={setCirclet}
elementIconUrls={elementIconUrls}
/>
</div>
<StatSelect
label="Substats"
options={substatOptions}
value={substats}
max={substatOptions.length}
onChange={setSubstats}
elementIconUrls={elementIconUrls}
priorityColors
/>
</FieldGroup>
</CardContent>
</Card>
<Card className="order-2 xl:row-span-2 xl:h-full">
<CardHeader>
<CardTitle>Target Stats</CardTitle>
<CardDescription>
ตั้งค่าเป้าหมายค่าสถานะสำหรับแต่ละ Condition
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet>
<FieldLegend>Target Stats</FieldLegend>
<div className="flex flex-wrap gap-2">
<ScrollableNav ariaLabel="เลือกเงื่อนไขค่าสถานะเป้าหมาย" className="min-w-0 flex-1">
<ToggleGroup aria-label="เลือกเงื่อนไขค่าสถานะเป้าหมาย" variant="outline" value={[String(activeTargetGroupIndex)]} onValueChange={(values) => {
if (values[0]) setActiveTargetGroupIndex(Number(values[0]));
}}>
{targetGroups.map((group, index) => (
<ToggleGroupItem key={group.clientId} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground" onDoubleClick={() => setRenamingTargetGroupIndex(index)}>
{group.name.trim() || `Condition ${index + 1}`}
</ToggleGroupItem>
))}
</ToggleGroup>
</ScrollableNav>
<ConditionRenameDialog
open={renamingTargetGroupIndex !== null}
fallback={`Condition ${(renamingTargetGroupIndex ?? 0) + 1}`}
value={renamingTargetGroupIndex === null ? "" : targetGroups[renamingTargetGroupIndex]?.name ?? ""}
onOpenChange={(open) => { if (!open) setRenamingTargetGroupIndex(null); }}
onChange={(name) => setTargetGroups(targetGroups.map((group, index) => index === renamingTargetGroupIndex ? { ...group, name } : group))}
/>
<Button type="button" variant="outline" disabled={targetGroups.length >= 20} onClick={() => {
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: "", note: "", rows: targetStatDefaults.map((target) => ({ ...target })) }]);
setActiveTargetGroupIndex(targetGroups.length);
}}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button>
<Button type="button" variant="outline" disabled={targetGroups.length >= 20} onClick={() => {
const source = targetGroups[activeTargetGroupIndex];
if (!source) return;
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: source.name, note: source.note, rows: source.rows.map((row) => ({ ...row })) }]);
setActiveTargetGroupIndex(targetGroups.length);
}}><CopyIcon data-icon="inline-start" />Duplicate Condition</Button>
</div>
<Field>
<FieldLabel htmlFor={`target-condition-note-${targetGroups[activeTargetGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
<Textarea id={`target-condition-note-${targetGroups[activeTargetGroupIndex]?.clientId}`} value={targetGroups[activeTargetGroupIndex]?.note ?? ""} onChange={(event) => setTargetGroups(targetGroups.map((group, index) => index === activeTargetGroupIndex ? { ...group, note: event.target.value } : group))} />
</Field>
<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 rounded-md bg-muted/40 px-3 py-2"
>
<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>
{targetGroups.length > 1 ? (
<Button type="button" variant="ghost" onClick={() => {
setTargetGroups(targetGroups.filter((_, index) => index !== activeTargetGroupIndex));
setActiveTargetGroupIndex(Math.max(0, activeTargetGroupIndex - 1));
}}><Trash2Icon data-icon="inline-start" />ลบ Condition นี้</Button>
) : null}
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Talent Upgrade</CardTitle>
<CardDescription>
จัดลำดับ Talent เลือกระดับเป้าหมาย และกำหนดความสำคัญ
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet>
<FieldLegend>Talent Priority</FieldLegend>
<DndContext
sensors={talentSensors}
collisionDetection={closestCenter}
onDragEnd={handleTalentDragEnd}
>
<SortableContext
items={orderedTalents.map((talent) => talent.type)}
strategy={rectSortingStrategy}
>
<div className="flex min-w-0 flex-col items-stretch gap-3 sm:flex-row sm:items-start sm:overflow-x-auto sm:pb-1">
{orderedTalents.map((talent, index) => {
const catalogTalent = catalogTalents.find(
(item) => item.type === talent.type,
);
return (
<div
key={talent.type}
className="flex w-full shrink-0 flex-col items-stretch gap-3 sm:w-auto sm:flex-row sm:items-center"
>
<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>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardContent>
<FieldGroup>
<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", name: null, note: null, rows: [] }];
return source.map((group) => ({
clientId: group.id,
name: group.name ?? "",
note: group.note ?? "",
rows: makeRows(group.rows),
}));
});
const [activeConstellationGroupIndex, setActiveConstellationGroupIndex] = useState(0);
const [renamingConstellationGroupIndex, setRenamingConstellationGroupIndex] = useState<number | null>(null);
const value = {
note: note.trim() || null,
groups: groups.map((group) => ({
name: group.name.trim() || null,
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.map((group) => ({ ...group, name: group.name ?? "" })));
},
});
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>
<ScrollableNav ariaLabel="เลือกเงื่อนไขกลุ่มดาว" className="w-full min-w-0">
<ToggleGroup
aria-label="เลือกเงื่อนไขกลุ่มดาว"
variant="outline"
value={[String(activeConstellationGroupIndex)]}
onValueChange={(values) => {
if (values[0]) setActiveConstellationGroupIndex(Number(values[0]));
}}
>
{groups.map((group, index) => (
<ToggleGroupItem
key={group.clientId}
value={String(index)}
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
onDoubleClick={() => setRenamingConstellationGroupIndex(index)}
>
{group.name.trim() || `Condition ${index + 1}`}
</ToggleGroupItem>
))}
</ToggleGroup>
</ScrollableNav>
<ConditionRenameDialog
open={renamingConstellationGroupIndex !== null}
fallback={`Condition ${(renamingConstellationGroupIndex ?? 0) + 1}`}
value={renamingConstellationGroupIndex === null ? "" : groups[renamingConstellationGroupIndex]?.name ?? ""}
onOpenChange={(open) => { if (!open) setRenamingConstellationGroupIndex(null); }}
onChange={(name) => setGroups(groups.map((group, index) => index === renamingConstellationGroupIndex ? { ...group, name } : group))}
/>
{groups[activeConstellationGroupIndex] ? [groups[activeConstellationGroupIndex]].map((group) => {
const groupIndex = activeConstellationGroupIndex;
return (
<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));
setActiveConstellationGroupIndex(Math.max(0, groupIndex - 1));
}}
>
<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>
);}) : null}
</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"),
name: "",
note: "",
rows: makeRows(),
},
])
setActiveConstellationGroupIndex(groups.length);
}}
>
<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 [teamGroups, setTeamGroups] = useState(() => initial.teamGroups.length
? initial.teamGroups.map((group) => ({ clientId: group.id, name: group.name ?? "", note: group.note ?? "", teams: group.teams.map(({ id, ...team }) => ({ clientId: id, ...team })) }))
: [{ clientId: "team-condition-default", name: "", note: "", teams: [] as TeamToolValue[] }]);
const [activeTeamGroupIndex, setActiveTeamGroupIndex] = useState(0);
const [renamingTeamGroupIndex, setRenamingTeamGroupIndex] = useState<number | null>(null);
const teams = useMemo(() => teamGroups[activeTeamGroupIndex]?.teams ?? [], [activeTeamGroupIndex, teamGroups]);
const setTeams = (update: SetStateAction<TeamToolValue[]>) => setTeamGroups((current) => current.map((group, index) => index === activeTeamGroupIndex ? { ...group, teams: typeof update === "function" ? update(group.teams) : update } : group));
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 [importOpen, setImportOpen] = useState(false);
const [importValue, setImportValue] = useState("");
const [importFiles, setImportFiles] = useState<File[]>([]);
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<TeamApplySelection[]>([]);
const [multipleApplyGuideId, setMultipleApplyGuideId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
const filteredTeams = useMemo(() => {
const characterNames = new Map(characters.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
const weaponNames = new Map(weapons.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
const artifactNames = new Map(artifacts.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
return teams
.map((team, teamIndex) => ({ team, teamIndex }))
.filter(({ team }) => matchesTeamSearch({
team: [team.title],
character: team.members.flatMap((member) => {
const name = characterNames.get(member.characterKey);
return name ?? [];
}),
weapon: team.members.flatMap((member) => {
const name = member.weaponKey
? weaponNames.get(member.weaponKey)
: undefined;
return name ?? [];
}),
artifact: team.members.flatMap((member) =>
[member.artifactKey, member.secondArtifactKey].flatMap((key) => {
const name = key ? artifactNames.get(key) : undefined;
return name ?? [];
}),
),
}, searchQuery, searchTarget));
}, [artifacts, characters, searchQuery, searchTarget, teams, weapons]);
const value = {
groups: teamGroups.map((group) => ({ name: group.name.trim() || null, note: group.note.trim() || null, teams: group.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] === "groups" && issue.path[1] === activeTeamGroupIndex && issue.path[2] === "teams" && issue.path[3] === teamIndex && issue.path[4] === field,
);
const memberFieldInvalid = (teamIndex: number, memberIndex: number, field: string) =>
!validation.success && validation.error.issues.some((issue) =>
issue.path[0] === "groups" && issue.path[1] === activeTeamGroupIndex && issue.path[2] === "teams" && issue.path[3] === teamIndex && issue.path[4] === "members" &&
issue.path[5] === memberIndex && issue.path[6] === field,
);
const localDraft = { teamGroups, activeTeamGroupIndex };
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(() => {
const timer = window.setTimeout(() => {
try {
const stored = JSON.parse(
window.localStorage.getItem(multipleApplyStorageKey(guide.id)) ?? "null",
) as { selectedTeamIds?: unknown; applyFields?: unknown } | null;
const teamIds = new Set(initial.teamGroups.flatMap((group) => group.teams.map((team) => team.id)));
setSelectedTeamIds(new Set(
Array.isArray(stored?.selectedTeamIds)
? stored.selectedTeamIds.filter(
(id): id is string => typeof id === "string" && teamIds.has(id),
)
: [],
));
setApplyFields(
Array.isArray(stored?.applyFields)
? stored.applyFields.filter(
(field): field is TeamApplySelection => typeof field === "string",
)
: [],
);
} catch {
setSelectedTeamIds(new Set());
setApplyFields([]);
}
setMultipleApplyGuideId(guide.id);
}, 0);
return () => window.clearTimeout(timer);
}, [guide.id, initial.teamGroups]);
useEffect(() => {
if (multipleApplyGuideId !== guide.id) return;
try {
window.localStorage.setItem(
multipleApplyStorageKey(guide.id),
JSON.stringify({
selectedTeamIds: [...selectedTeamIds],
applyFields,
}),
);
} catch {
// Browser storage is optional; multiple apply remains usable without it.
}
}, [applyFields, guide.id, multipleApplyGuideId, selectedTeamIds]);
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, ["teamGroups"]),
restoreLocalDraft: (draft) => {
setTeamGroups(draft.teamGroups.map((group) => ({ ...group, name: group.name ?? "" })));
setActiveTeamGroupIndex(draft.activeTeamGroupIndex ?? 0);
},
});
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 handleTeamMemberDragEnd = (
teamId: string,
{ active, over }: DragEndEvent,
) => {
if (!over || active.id === over.id) return;
setTeams((current) =>
current.map((team) => {
if (team.clientId !== teamId) return team;
const memberIds = team.members.map(
(_, index) => `${team.clientId}-member-${index}`,
);
const from = memberIds.indexOf(String(active.id));
const to = memberIds.indexOf(String(over.id));
return from === -1 || to === -1
? team
: { ...team, members: arrayMove(team.members, from, to) };
}),
);
};
const updateTeam = (
teamIndex: number,
update: Partial<Omit<TeamToolValue, "clientId" | "members">>,
) => {
setTeams((current) =>
current.map((team, index) =>
index === teamIndex ? { ...team, ...update } : team,
),
);
};
const updateTeamMember = (
teamIndex: number,
memberIndex: number,
update: Partial<TeamToolValue["members"][number]>,
) => {
setTeams((current) =>
current.map((team, index) =>
index === teamIndex
? {
...team,
members: team.members.map((member, innerIndex) =>
innerIndex === memberIndex ? { ...member, ...update } : member,
),
}
: team,
),
);
};
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: TeamApplySelection, checked: boolean) => {
setApplyFields((current) => {
const next = new Set(current);
if (checked) next.add(field);
else next.delete(field);
return [...next];
});
};
const applyChanges = () => {
if (!sourceTeamId || !selectedTeamIds.size || !applyFields.length) return;
const nextTeams = applyTeamFields(
teams,
sourceTeamId,
selectedTeamIds,
applyFields,
);
const unsupported = findUnsupportedTeamWeapon(
nextTeams,
selectedTeamIds,
characters,
weapons,
);
if (unsupported) {
toast.error(
`${unsupported.characterName} ไม่รองรับอาวุธ ${unsupported.weaponName}`,
);
return;
}
setTeams(nextTeams);
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;
const nextTeams = applyPresetFields(
teams,
preset,
selectedTeamIds,
applyFields,
);
const unsupported = findUnsupportedTeamWeapon(
nextTeams,
selectedTeamIds,
characters,
weapons,
);
if (unsupported) {
toast.error(
`${unsupported.characterName} ไม่รองรับอาวุธ ${unsupported.weaponName}`,
);
return;
}
setTeams(nextTeams);
setSelectedTeamIds(new Set());
};
const exportTeam = async (team: TeamToolValue) => {
let serialized: string;
try {
serialized = serializeTeamTransfer(team);
} catch {
toast.error("Export Team ไม่สำเร็จ กรุณาตรวจสอบข้อมูล Team");
return;
}
const url = URL.createObjectURL(new Blob([serialized], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = teamTransferFileName(team.title);
link.click();
URL.revokeObjectURL(url);
try {
await navigator.clipboard.writeText(serialized);
toast.success(`คัดลอกและดาวน์โหลด ${team.title} แล้ว`);
} catch {
toast.success(`ดาวน์โหลด ${teamTransferFileName(team.title)} แล้ว`);
}
};
const importTeam = async () => {
try {
const serializedTeams = [
...(importValue.trim() ? [importValue] : []),
...(await Promise.all(importFiles.map((file) => file.text()))),
];
if (!serializedTeams.length) return;
if (teams.length + serializedTeams.length > 30) {
toast.error("Import ไม่ได้ เพราะ Guide จะมี Team เกิน 30 Team");
return;
}
const importedTeams = serializedTeams.map(parseTeamTransfer);
for (const imported of importedTeams) {
const catalogIssue = findInvalidTeamCatalogReference(
imported,
characters,
weapons,
artifacts,
);
if (catalogIssue) {
toast.error(catalogIssue);
return;
}
}
setTeams((current) => [
...current,
...importedTeams.map((imported) => ({
clientId: createClientId("team"),
...imported,
members: imported.members.map((member) => ({ ...member })),
})),
]);
setImportValue("");
setImportFiles([]);
setImportOpen(false);
toast.success(`Import ${importedTeams.length} Team แล้ว`);
} catch {
toast.error("มีไฟล์หรือ JSON ของ Team ที่ไม่ถูกต้องหรือเป็นเวอร์ชันที่ไม่รองรับ");
}
};
return (
<Card>
<CardHeader>
<CardTitle>Team Calc</CardTitle>
<CardDescription>
ลากเพื่อเรียง Team แล้วใส่ความเสียหายของตัวละคร 4 ตัว
ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<ScrollableNav ariaLabel="เลือกเงื่อนไข Team" className="min-w-0 flex-1">
<ToggleGroup
aria-label="เลือกเงื่อนไข Team"
variant="outline"
value={[String(activeTeamGroupIndex)]}
onValueChange={(values) => {
const nextIndex = Number(values[0]);
const group = teamGroups[nextIndex];
if (!group) return;
setActiveTeamGroupIndex(nextIndex);
setSelectedTeamIds(new Set());
setSourceTeamId(group.teams[0]?.clientId ?? "");
}}
>
{teamGroups.map((group, index) => (
<ToggleGroupItem key={group.clientId} value={String(index)} onDoubleClick={() => setRenamingTeamGroupIndex(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">
{group.name.trim() || `Condition ${index + 1}`}
</ToggleGroupItem>
))}
</ToggleGroup>
</ScrollableNav>
<ConditionRenameDialog
open={renamingTeamGroupIndex !== null}
fallback={`Condition ${(renamingTeamGroupIndex ?? 0) + 1}`}
value={renamingTeamGroupIndex === null ? "" : teamGroups[renamingTeamGroupIndex]?.name ?? ""}
onOpenChange={(open) => { if (!open) setRenamingTeamGroupIndex(null); }}
onChange={(name) => setTeamGroups(teamGroups.map((group, index) => index === renamingTeamGroupIndex ? { ...group, name } : group))}
/>
<Button type="button" variant="outline" disabled={teamGroups.length >= 20} onClick={() => {
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), name: "", note: "", teams: [] }]);
setActiveTeamGroupIndex(teamGroups.length);
setSelectedTeamIds(new Set());
setSourceTeamId("");
}}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button>
<Button type="button" variant="outline" disabled={teamGroups.length >= 20} onClick={() => {
const source = teamGroups[activeTeamGroupIndex];
if (!source) return;
const copiedTeams = source.teams.map((team) => ({ ...team, clientId: createClientId("team"), members: team.members.map((member) => ({ ...member })) }));
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), name: source.name, note: source.note, teams: copiedTeams }]);
setActiveTeamGroupIndex(teamGroups.length);
setSelectedTeamIds(new Set());
setSourceTeamId(copiedTeams[0]?.clientId ?? "");
}}><CopyIcon data-icon="inline-start" />Duplicate Condition</Button>
</div>
<Field>
<FieldLabel htmlFor={`team-condition-note-${teamGroups[activeTeamGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
<Textarea id={`team-condition-note-${teamGroups[activeTeamGroupIndex]?.clientId}`} value={teamGroups[activeTeamGroupIndex]?.note ?? ""} onChange={(event) => setTeamGroups(teamGroups.map((group, index) => index === activeTeamGroupIndex ? { ...group, note: event.target.value } : group))} />
</Field>
{teamGroups.length > 1 ? <Button className="self-start" type="button" variant="ghost" onClick={() => {
setTeamGroups(teamGroups.filter((_, index) => index !== activeTeamGroupIndex));
setActiveTeamGroupIndex(Math.max(0, activeTeamGroupIndex - 1));
setSelectedTeamIds(new Set());
}}><Trash2Icon data-icon="inline-start" />ลบ Condition นี้</Button> : null}
<div className="flex min-w-0 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-full basis-full"><span>{teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"}</span></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 min-w-0 max-w-full items-center gap-1">
<Button className="min-w-0 max-w-full truncate" type="button" variant="ghost" size="sm" disabled={!selectedTeamIds.size} onClick={() => applyPreset(preset)}>{preset.name}</Button>
<SimpleTooltip text={`ลบ preset ${preset.name}`}>
<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>
</SimpleTooltip>
</div>
))}
</div>
<FieldGroup className="mt-4">
<Field>
<FieldLabel htmlFor="team-search">ค้นหา Team</FieldLabel>
<div className="relative">
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="team-search"
className="pl-9"
placeholder="ค้นหาชื่อทีม ตัวละคร อาวุธ หรืออาร์ติแฟกต์"
value={searchQuery}
onValueChange={setSearchQuery}
/>
</div>
</Field>
<Field>
<FieldLabel>ค้นหาใน</FieldLabel>
<ToggleGroup
aria-label="เลือกประเภทข้อมูลที่ต้องการค้นหา"
className="max-w-full flex-wrap justify-start"
variant="outline"
size="sm"
value={[searchTarget]}
onValueChange={(value) => {
const target = value[0] as TeamSearchTarget | undefined;
if (target) setSearchTarget(target);
}}
>
<ToggleGroupItem value="all">ทั้งหมด</ToggleGroupItem>
<ToggleGroupItem value="team">ชื่อทีม</ToggleGroupItem>
<ToggleGroupItem value="character">ตัวละคร</ToggleGroupItem>
<ToggleGroupItem value="weapon">อาวุธ</ToggleGroupItem>
<ToggleGroupItem value="artifact">อาร์ติแฟกต์</ToggleGroupItem>
</ToggleGroup>
</Field>
</FieldGroup>
<DndContext
sensors={teamSensors}
collisionDetection={closestCenter}
onDragEnd={handleTeamDragEnd}
>
<SortableContext
items={filteredTeams.map(({ team }) => team.clientId)}
strategy={rectSortingStrategy}
>
{filteredTeams.length ? (
<div className="grid gap-5 xl:grid-cols-2">
{filteredTeams.map(({ team, teamIndex }) => {
const total = team.members.reduce(
(sum, member) => sum + (Number(member.damage) || 0),
0,
);
const seconds = Number(team.rotationSeconds);
const dps = seconds > 0 ? total / seconds : 0;
return (
<SortableTeamItem key={team.clientId} id={team.clientId}>
{(dragHandle) => (
<Card
className="h-full"
size="sm"
data-invalid={teamFieldInvalid(teamIndex, "members")}
>
<CardHeader>
<CardTitle>
<Field data-invalid={teamFieldInvalid(teamIndex, "title")}>
<FieldLabel className="sr-only" htmlFor={`team-title-${team.clientId}`}>
ชื่อ Team
</FieldLabel>
<Input
id={`team-title-${team.clientId}`}
value={team.title}
placeholder={`Team ${teamIndex + 1}`}
aria-invalid={teamFieldInvalid(teamIndex, "title")}
onChange={(event) =>
updateTeam(teamIndex, { title: event.target.value })
}
/>
</Field>
</CardTitle>
<CardDescription>
<Field
className="w-fit justify-start"
orientation="horizontal"
data-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}
>
<FieldLabel
className="flex-none"
htmlFor={`team-rotation-${team.clientId}`}
>
Rotation
</FieldLabel>
<Input
id={`team-rotation-${team.clientId}`}
className="w-24"
type="number"
min="0.01"
step="0.01"
value={team.rotationSeconds}
aria-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}
onChange={(event) =>
updateTeam(teamIndex, {
rotationSeconds: event.target.value,
})
}
/>
<span>วินาที</span>
</Field>
</CardDescription>
<CardAction className="flex max-w-44 flex-wrap justify-end gap-1">
<Checkbox
checked={selectedTeamIds.has(team.clientId)}
onCheckedChange={(checked) =>
toggleTeamSelection(team.clientId, checked === true)
}
aria-label={`เลือก ${team.title || `Team ${teamIndex + 1}`}`}
/>
{dragHandle}
<SimpleTooltip text="Export Team">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => void exportTeam(team)}
>
<ClipboardCopyIcon />
<span className="sr-only">
Export {team.title || `Team ${teamIndex + 1}`}
</span>
</Button>
</SimpleTooltip>
<SimpleTooltip text="ทำสำเนา Team">
<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)`;
return current.toSpliced(sourceIndex + 1, 0, {
...source,
clientId: createClientId("team"),
title,
members: source.members.map((member) => ({
...member,
})),
});
})
}
>
<CopyIcon />
<span className="sr-only">ทำสำเนา Team</span>
</Button>
</SimpleTooltip>
<DeleteConfirmationDialog
triggerTooltip="ลบ Team"
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>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<DndContext
sensors={teamSensors}
collisionDetection={closestCenter}
onDragEnd={(event) =>
handleTeamMemberDragEnd(team.clientId, event)
}
>
<SortableContext
items={team.members.map(
(_, index) =>
`${team.clientId}-member-${index}`,
)}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-4 gap-1 sm:gap-3">
{team.members.map((member, memberIndex) => {
const character = characters.find(
(item) => item.key === member.characterKey,
);
const compatibleWeapons = weapons.filter(
(weapon) =>
supportsWeaponType(
character?.weaponType,
weapon.weaponType,
),
);
const selectedWeapon = compatibleWeapons.find(
(weapon) => weapon.key === member.weaponKey,
);
const share = total
? ((Number(member.damage) || 0) / total) * 100
: 0;
return (
<SortableTeamMember
key={`${team.clientId}-member-${memberIndex}`}
id={`${team.clientId}-member-${memberIndex}`}
>
{(memberDragHandle) => (
<div
className={cn(
"flex min-w-0 flex-col rounded-lg p-0.5 text-center sm:p-1",
!character && "bg-muted/40",
)}
style={
character?.element
? {
backgroundColor:
elementBackgroundColor(
character.element,
),
}
: undefined
}
>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
#{memberIndex + 1}
</span>
{memberDragHandle}
</div>
<div className="flex flex-col gap-1 sm:grid sm:grid-cols-3">
<div className="sm:col-span-2 sm:row-span-2">
<CatalogSlotPicker
items={characters}
value={member.characterKey}
label={`เลือกตัวละครตำแหน่ง ${memberIndex + 1}`}
placeholder="ค้นหาชื่อตัวละคร…"
invalid={memberFieldInvalid(
teamIndex,
memberIndex,
"characterKey",
)}
className={cn(
"rounded-lg",
character &&
rarityGradientClass(
isCustomTravelerKey(
character.key,
)
? 5
: character.rarity,
),
)}
onChange={(characterKey) =>
updateTeamMember(
teamIndex,
memberIndex,
{
characterKey,
weaponKey: null,
artifactKey: null,
secondArtifactKey: null,
constellation: 0,
refinement: 1,
},
)
}
/>
</div>
<div className="grid min-w-0 grid-cols-2 gap-1 sm:row-span-2 sm:grid-cols-1">
<CatalogSlotPicker
items={compatibleWeapons}
value={member.weaponKey ?? ""}
label={`เลือกอาวุธของตัวละครตำแหน่ง ${memberIndex + 1}`}
placeholder="ค้นหาอาวุธที่รองรับ…"
rarityBackground
onChange={(weaponKey) =>
updateTeamMember(
teamIndex,
memberIndex,
{
weaponKey:
weaponKey || null,
refinement: 1,
},
)
}
/>
<ArtifactSlotPicker
items={artifacts}
values={[
member.artifactKey,
member.secondArtifactKey,
].filter(
(key): key is string => Boolean(key),
)}
label={`เลือก Artifact สูงสุด 2 ชุดของตัวละครตำแหน่ง ${memberIndex + 1}`}
onChange={(artifactKeys) =>
updateTeamMember(
teamIndex,
memberIndex,
{
artifactKey:
artifactKeys[0] ?? null,
secondArtifactKey:
artifactKeys[1] ?? null,
},
)
}
/>
</div>
</div>
<div className="mt-1 flex min-w-0 gap-1">
<Select
value={String(member.constellation)}
disabled={!character}
onValueChange={(value) => {
if (value !== null)
updateTeamMember(
teamIndex,
memberIndex,
{
constellation: Number(value),
},
);
}}
>
<SelectTrigger
size="sm"
className="h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden"
aria-label={`Constellation ของตัวละครตำแหน่ง ${memberIndex + 1}`}
>
<SelectValue>
C{member.constellation}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[0, 1, 2, 3, 4, 5, 6].map(
(constellation) => (
<SelectItem
key={constellation}
value={String(constellation)}
>
C{constellation}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
<Select
value={String(member.refinement)}
disabled={!selectedWeapon}
onValueChange={(value) => {
if (value !== null)
updateTeamMember(
teamIndex,
memberIndex,
{
refinement: Number(value),
},
);
}}
>
<SelectTrigger
size="sm"
className="h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden"
aria-label={`Refinement ของอาวุธตำแหน่ง ${memberIndex + 1}`}
>
<SelectValue>
R{member.refinement}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[1, 2, 3, 4, 5].map(
(refinement) => (
<SelectItem
key={refinement}
value={String(refinement)}
>
R{refinement}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Field
data-invalid={memberFieldInvalid(
teamIndex,
memberIndex,
"damage",
)}
>
<FieldLabel
className="sr-only"
htmlFor={`team-${team.clientId}-damage-${memberIndex}`}
>
Damage ของตัวละครตำแหน่ง {memberIndex + 1}
</FieldLabel>
<Input
id={`team-${team.clientId}-damage-${memberIndex}`}
className="h-7 text-center"
type="number"
min="0"
step="0.01"
value={member.damage}
aria-invalid={memberFieldInvalid(
teamIndex,
memberIndex,
"damage",
)}
onChange={(event) =>
updateTeamMember(
teamIndex,
memberIndex,
{
damage: event.target.value,
},
)
}
/>
</Field>
<p className="mt-0.5 text-xs text-muted-foreground">
{share.toFixed(2)}%
</p>
</div>
)}
</SortableTeamMember>
);
})}
</div>
</SortableContext>
</DndContext>
<Separator />
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">
ดาเมจรวม
</p>
<p className="font-semibold">
{total.toLocaleString()}
</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">DPS</p>
<p className="text-3xl font-semibold text-primary">
{dps.toLocaleString(undefined, {
maximumFractionDigits: 0,
})}
</p>
</div>
</div>
<Field data-invalid={teamFieldInvalid(teamIndex, "combo")}>
<FieldLabel
className="sr-only"
htmlFor={`team-combo-${team.clientId}`}
>
Combo
</FieldLabel>
<Textarea
id={`team-combo-${team.clientId}`}
value={team.combo}
placeholder="ใส่ลำดับ Combo…"
aria-invalid={teamFieldInvalid(teamIndex, "combo")}
onChange={(event) =>
updateTeam(teamIndex, {
combo: event.target.value,
})
}
/>
</Field>
</CardContent>
</Card>
)}
</SortableTeamItem>
);
})}
</div>
) : (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchIcon />
</EmptyMedia>
<EmptyTitle>
{teams.length
? "ไม่พบ Team ที่ตรงกับคำค้นหา"
: "ยังไม่มี Team ใน Condition นี้"}
</EmptyTitle>
<EmptyDescription>
{teams.length
? "ลองเปลี่ยนคำค้นหาหรือประเภทข้อมูล"
: "เพิ่ม Team เพื่อเริ่มจัดตัวละครและคำนวณ DPS"}
</EmptyDescription>
</EmptyHeader>
{!teams.length ? (
<EmptyContent>
<Button
type="button"
variant="outline"
disabled={characters.length < 4}
onClick={() =>
setTeams([
...teams,
{
clientId: createClientId("team"),
title: "",
rotationSeconds: "20",
combo: "",
members: fallbackMembers.map((member) => ({
...member,
})),
},
])
}
>
<PlusIcon data-icon="inline-start" />
เพิ่ม Team
</Button>
</EmptyContent>
) : null}
</Empty>
)}
</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.map((member) => ({ ...member })),
},
])
}
>
<PlusIcon data-icon="inline-start" />
เพิ่ม Team
</Button>
<Button
type="button"
variant="outline"
disabled={teams.length >= 30}
onClick={() => setImportOpen(true)}
>
<ClipboardPasteIcon data-icon="inline-start" />
Import 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 className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Multiple Apply</DialogTitle>
<DialogDescription className="break-words">คัดลอกค่าจาก {teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"} ไปยัง {selectedTeamIds.size} Team ที่เลือก: {[...selectedTeamIds].map((id) => teams.find((team) => team.clientId === id)?.title || "Untitled Team").join(", ")}</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto pr-1">
{teams.find((team) => team.clientId === sourceTeamId)?.members.map((member, memberIndex) => {
const characterName = characters.find((character) => character.key === member.characterKey)?.name || "No character";
return (
<FieldSet key={memberIndex}>
<FieldLegend>{characterName} (char{memberIndex + 1})</FieldLegend>
<div className="grid gap-3 sm:grid-cols-4">
{teamMemberApplyOptions.map(([field, label]) => {
const selection = `member:${memberIndex}:${field}` as TeamApplySelection;
const id = `apply-${memberIndex}-${field}`;
return (
<Field key={field} orientation="horizontal">
<Checkbox id={id} checked={applyFields.includes(selection)} onCheckedChange={(checked) => toggleApplyField(selection, checked === true)} />
<FieldLabel htmlFor={id}>{label}{field === "refinement" ? ` (R${member.refinement})` : ""}</FieldLabel>
</Field>
);
})}
</div>
</FieldSet>
);
})}
<FieldSet>
<FieldLegend>Team fields</FieldLegend>
<div className="grid gap-3 sm:grid-cols-2">
{(["rotation", "combo"] as const).map((field) => {
const id = `apply-${field}`;
return (
<Field key={field} orientation="horizontal">
<Checkbox id={id} checked={applyFields.includes(field)} onCheckedChange={(checked) => toggleApplyField(field, checked === true)} />
<FieldLabel htmlFor={id}>{field === "rotation" ? "Rotation" : "Combo"}</FieldLabel>
</Field>
);
})}
</div>
</FieldSet>
</div>
<DialogFooter><Button type="button" variant="outline" onClick={() => setApplyOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!applyFields.length} onClick={applyChanges}>ยืนยันการคัดลอก</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={importOpen} onOpenChange={setImportOpen}>
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden">
<DialogHeader>
<DialogTitle>Import Team</DialogTitle>
<DialogDescription>
วาง JSON ที่ Export จาก Team ใน Guide อื่น ระบบจะเพิ่ม Team ใหม่ต่อท้ายรายการ
</DialogDescription>
</DialogHeader>
<FieldGroup className="min-h-0 overflow-y-auto pr-1">
<Field>
<FieldLabel htmlFor="team-import-json">Team JSON</FieldLabel>
<Textarea
id="team-import-json"
value={importValue}
rows={12}
placeholder={'{\n "type": "buzz-sheet.team",\n ...\n}'}
onChange={(event) => setImportValue(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="team-import-files">เลือกไฟล์ JSON</FieldLabel>
<Input
id="team-import-files"
type="file"
accept=".json,application/json"
multiple
onChange={(event) => setImportFiles(Array.from(event.target.files ?? []))}
/>
<FieldDescription>
เลือกหลายไฟล์เพื่อ Import หลาย Team พร้อมกันได้
</FieldDescription>
</Field>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setImportOpen(false)}>
ยกเลิก
</Button>
<Button type="button" disabled={!importValue.trim() && !importFiles.length} onClick={() => void importTeam()}>
Import Team
</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 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={verticalListSortingStrategy}
>
<div className="flex flex-col gap-2">
{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">
<CatalogPicker
items={items}
value={key}
onChange={(nextKey) => {
if (
nextKey &&
!value.some(
(item) => item.kind === kind && item.key === nextKey,
)
) {
onChange([...value, { kind, key: nextKey }]);
}
setKey("");
}}
placeholder={`เลือก${labels[kind]}`}
showSelectedImage
rarityBackground
/>
</div>
</div>
</div>
</Field>
);
}
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 [openSectionIds, setOpenSectionIds] = useState(() =>
initial.extras.map((section) => section.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({
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion) =>
saveGuideExtra(guide.id, sectionId, {
section: next.sections[0],
expectedVersion,
}),
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);
setOpenSectionIds(restoredSections.map((section) => section.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">
<Accordion
multiple
value={openSectionIds}
onValueChange={setOpenSectionIds}
className="gap-5"
>
{sections.map((section, sectionIndex) => (
<AccordionItem key={section.clientId} value={section.clientId} className="border-0">
<Card
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>
<AccordionTrigger
className="size-8 flex-none items-center justify-center p-0 hover:no-underline"
headerClassName="flex-none"
aria-label={`${openSectionIds.includes(section.clientId) ? "ซ่อน" : "แสดง"} ${section.title || `Extra section ${sectionIndex + 1}`}`}
title={openSectionIds.includes(section.clientId) ? "ซ่อนส่วนนี้" : "แสดงส่วนนี้"}
/>
</CardAction>
</CardHeader>
<AccordionContent className="pb-0">
<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}
>
{section.rows.map((row, rowIndex) => (
<SortableExtraItem
key={row.clientId}
id={row.clientId}
label={`ลากเพื่อจัดลำดับ ${row.heading || `แถว ${rowIndex + 1}`}`}
>
{(rowDragHandle) => (
<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>
</CardAction>
</CardHeader>
<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" />
เพิ่มตาราง 2 × 2
</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>
<Input
type="color"
value={row.backgroundColor ?? DEFAULT_EXTRA_BACKGROUND_COLOR}
aria-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"backgroundColor",
)}
onClick={() => {
if (row.backgroundColor !== null) return;
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? {
...candidate,
backgroundColor: DEFAULT_EXTRA_BACKGROUND_COLOR,
}
: candidate,
),
}
: item,
),
);
}}
onChange={(event) =>
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map((candidate, inner) =>
inner === rowIndex
? { ...candidate, backgroundColor: event.target.value }
: candidate,
),
}
: item,
),
)
}
/>
</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>
</Card>
)}
</SortableExtraItem>
))}
</SortableContext>
</DndContext>
<Button
type="button"
variant="outline"
onClick={() =>
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: createClientId("extra-row"),
},
],
}
: item,
),
)
}
>
<PlusIcon data-icon="inline-start" />
เพิ่มแถว
</Button>
</CardContent>
</AccordionContent>
</Card>
</AccordionItem>
))}
</Accordion>
</CardContent>
<CardFooter className="flex-wrap justify-end gap-3">
<AutosaveStatus
state={autosave.state}
issues={
validation.success
? []
: formatGuideValidationIssues("extra", validation.error.issues)
}
onSave={autosave.saveNow}
/>
</CardFooter>
</Card>
);
}