Files
buzz-sheet/components/admin/guide-section-editor.tsx
T
gunshiz c0d0e0c18f
CI / Verify (push) Successful in 1m8s
CI / Build immutable images and deploy (push) Successful in 1m54s
fix : remove error showing when put data there
2026-09-04 21:03:23 +07:00

3673 lines
152 KiB
TypeScript
Raw Blame History

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