Files
buzz-sheet/components/admin/guide-section-editor.tsx
T

5072 lines
214 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 { searchItems } from "@/lib/search";
import dynamic from "next/dynamic";
import Image from "next/image";
import { useRouter } from "next/navigation";
import {
ArrowLeftIcon,
BookOpenIcon,
ClipboardCopyIcon,
ClipboardPasteIcon,
CopyIcon,
CircleHelpIcon,
GripVerticalIcon,
HeartPulseIcon,
PlusIcon,
RefreshCcwIcon,
SearchIcon,
SparklesIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState, type ComponentProps, 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,
listTeamImportGuides,
loadTeamImportGuide,
saveTeamSection,
saveWeaponSection,
} from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
import { DeleteConditionDialog } from "@/components/admin/delete-condition-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 { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Combobox,
FuzzyCombobox,
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 { Spinner } from "@/components/ui/spinner";
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,
cloneTeamImports,
findInvalidTeamCatalogReference,
findUnsupportedTeamWeapon,
limitPresets,
parseTeamPresets,
parseTeamTransfer,
serializeTeamTransfer,
teamTransferFileName,
teamPresetCookieKey,
teamPresetStorageKey,
type TeamApplySelection,
type TeamMemberApplyField,
type TeamPreset,
type TeamToolValue,
} from "@/lib/guides/team-tools";
import {
searchTeams,
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[];
recommendedConditions: Array<{
name?: string | null;
note: string | null;
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">
<FuzzyCombobox
items={items}
value={selected}
onValueChange={(next) => onChange(next?.key ?? "")}
itemToStringLabel={(item) => item.name}
itemToStringValue={(item) => item.key}
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
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>
</FuzzyCombobox>
</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 (
<FuzzyCombobox
items={items}
value={selected}
onValueChange={(next) => onChange(next?.key ?? "")}
itemToStringLabel={(item) => item.name}
itemToStringValue={(item) => item.key}
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
autoHighlight
>
<ComboboxTrigger
aria-label={selected ? `${label}: ${selected.name}` : label}
aria-invalid={invalid}
className={cn(
"relative flex aspect-square w-full items-center justify-center overflow-hidden rounded-md border bg-muted p-0 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>
</FuzzyCombobox>
);
}
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 (
<FuzzyCombobox
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}
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
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>
</FuzzyCombobox>
);
}
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 className="w-max min-w-(--anchor-width) max-w-(--available-width)">
<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>
);
}
type ConditionNavItem = {
clientId: string;
name: string;
};
function SortableConditionTab({
item,
index,
active,
onSelect,
onRename,
}: {
item: ConditionNavItem;
index: number;
active: boolean;
onSelect: () => void;
onRename: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.clientId });
const label = item.name.trim() || `Condition ${index + 1}`;
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn("flex shrink-0 items-center rounded-md border", isDragging && "z-10 opacity-60")}
>
<ToggleGroupItem
value={item.clientId}
className="border-0 aria-pressed:bg-primary aria-pressed:text-primary-foreground"
onClick={onSelect}
onDoubleClick={onRename}
>
{label}
{index === 0 ? <Badge variant={active ? "secondary" : "outline"}>Default</Badge> : null}
</ToggleGroupItem>
<SimpleTooltip text={`ลากเพื่อจัดลำดับ ${label}`}>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none rounded-l-none"
{...attributes}
{...listeners}
>
<GripVerticalIcon />
<span className="sr-only">ลากเพื่อจัดลำดับ {label}</span>
</Button>
</SimpleTooltip>
</div>
);
}
function SortableConditionSwitcher({
ariaLabel,
items,
activeIndex,
onActiveIndexChange,
onReorder,
onRename,
}: {
ariaLabel: string;
items: ConditionNavItem[];
activeIndex: number;
onActiveIndexChange: (index: number) => void;
onReorder: (from: number, to: number) => void;
onRename: (index: number) => void;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const activeId = items[activeIndex]?.clientId;
return (
<ScrollableNav ariaLabel={ariaLabel} className="min-w-0 flex-1">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (!over || active.id === over.id) return;
const from = items.findIndex((item) => item.clientId === active.id);
const to = items.findIndex((item) => item.clientId === over.id);
if (from !== -1 && to !== -1) onReorder(from, to);
}}
>
<SortableContext items={items.map((item) => item.clientId)} strategy={rectSortingStrategy}>
<ToggleGroup
aria-label={ariaLabel}
variant="outline"
value={activeId ? [activeId] : []}
onValueChange={(values) => {
const index = items.findIndex((item) => item.clientId === values[0]);
if (index !== -1) onActiveIndexChange(index);
}}
>
{items.map((item, index) => (
<SortableConditionTab
key={item.clientId}
item={item}
index={index}
active={item.clientId === activeId}
onSelect={() => onActiveIndexChange(index)}
onRename={() => onRename(index)}
/>
))}
</ToggleGroup>
</SortableContext>
</DndContext>
</ScrollableNav>
);
}
function ConditionControls({
onAdd,
onDuplicate,
onDelete,
...switcherProps
}: ComponentProps<typeof SortableConditionSwitcher> & {
onAdd: () => void;
onDuplicate?: () => void;
onDelete: () => void;
}) {
const { items, activeIndex } = switcherProps;
const atLimit = items.length >= 20;
return (
<div className="flex flex-wrap gap-2">
<SortableConditionSwitcher {...switcherProps} />
<SimpleTooltip text="เพิ่ม Condition">
<Button type="button" size="icon" variant="outline" aria-label="เพิ่ม Condition" disabled={atLimit} onClick={onAdd}>
<PlusIcon />
</Button>
</SimpleTooltip>
{onDuplicate ? (
<SimpleTooltip text="Duplicate Condition">
<Button type="button" size="icon" variant="outline" aria-label="Duplicate Condition" disabled={atLimit || !items[activeIndex]} onClick={onDuplicate}>
<CopyIcon />
</Button>
</SimpleTooltip>
) : null}
{items.length > 1 ? (
<DeleteConditionDialog
trigger={<Button type="button" size="icon" variant="outline" aria-label="ลบ Condition นี้" />}
onConfirm={onDelete}
iconOnly
/>
) : null}
</div>
);
}
function reorderConditions<T extends ConditionNavItem>(
items: T[],
activeIndex: number,
from: number,
to: number,
) {
const activeId = items[activeIndex]?.clientId;
const reordered = arrayMove(items, from, to);
return {
items: reordered,
activeIndex: Math.max(0, reordered.findIndex((item) => item.clientId === activeId)),
};
}
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>
<ConditionControls
ariaLabel="เลือกเงื่อนไขอาวุธ"
items={groups}
activeIndex={activeGroupIndex}
onActiveIndexChange={setActiveGroupIndex}
onRename={setRenamingWeaponGroupIndex}
onReorder={(from, to) => {
const reordered = reorderConditions(groups, activeGroupIndex, from, to);
setGroups(reordered.items);
setActiveGroupIndex(reordered.activeIndex);
}}
onAdd={() => {
setGroups([...groups, { clientId: createClientId("weapon-condition"), name: "", note: "", rows: [] }]);
setActiveGroupIndex(groups.length);
}}
onDuplicate={() => {
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);
}}
onDelete={() => {
const next = groups.filter((_, index) => index !== activeGroupIndex);
setGroups(next);
setActiveGroupIndex(Math.min(activeGroupIndex, next.length - 1));
}}
/>
<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>
<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 [recommendedGroups, setRecommendedGroups] = useState(() => {
const source = profile?.recommendedConditions.length
? profile.recommendedConditions
: [{ name: null, note: null, sands: profile?.sands ?? [], goblet: profile?.goblet ?? [], circlet: profile?.circlet ?? [], substats: profile?.substats ?? [] }];
return source.map((group, index) => ({ ...group, clientId: `recommended-condition-${index}`, name: group.name ?? "", note: group.note ?? "" }));
});
const [activeRecommendedGroupIndex, setActiveRecommendedGroupIndex] = useState(0);
const [renamingRecommendedGroupIndex, setRenamingRecommendedGroupIndex] = useState<number | null>(null);
const recommended = recommendedGroups[activeRecommendedGroupIndex];
const { sands, goblet, circlet, substats } = recommended;
const setRecommendedStat = (slot: "sands" | "goblet" | "circlet" | "substats", values: string[]) =>
setRecommendedGroups((current) => current.map((group, index) => index === activeRecommendedGroupIndex ? { ...group, [slot]: values } : group));
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: recommendedGroups[0].sands,
goblet: recommendedGroups[0].goblet,
circlet: recommendedGroups[0].circlet,
substats: recommendedGroups[0].substats,
recommendedGroups: recommendedGroups.map(({ name, note, sands, goblet, circlet, substats }) => ({ name: name.trim() || null, note: 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,
recommendedGroups,
activeRecommendedGroupIndex,
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);
setRecommendedGroups(draft.recommendedGroups?.length ? draft.recommendedGroups : [{ clientId: createClientId("recommended-condition"), name: "", note: "", sands: draft.sands, goblet: draft.goblet, circlet: draft.circlet, substats: draft.substats }]);
setActiveRecommendedGroupIndex(Math.min(draft.activeRecommendedGroupIndex ?? 0, Math.max(0, (draft.recommendedGroups?.length ?? 1) - 1)));
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_4rem_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 overflow-hidden pt-0">
<CardHeader className="bg-primary py-4 text-center text-primary-foreground">
<CardTitle className="text-2xl">Recommended Stats</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<ConditionControls
ariaLabel="เลือกเงื่อนไขค่าสถานะแนะนำ"
items={recommendedGroups}
activeIndex={activeRecommendedGroupIndex}
onActiveIndexChange={setActiveRecommendedGroupIndex}
onRename={setRenamingRecommendedGroupIndex}
onReorder={(from, to) => {
const reordered = reorderConditions(recommendedGroups, activeRecommendedGroupIndex, from, to);
setRecommendedGroups(reordered.items);
setActiveRecommendedGroupIndex(reordered.activeIndex);
}}
onAdd={() => {
setRecommendedGroups([...recommendedGroups, { clientId: createClientId("recommended-condition"), name: "", note: "", sands: [], goblet: [], circlet: [], substats: [] }]);
setActiveRecommendedGroupIndex(recommendedGroups.length);
}}
onDuplicate={() => {
const source = recommendedGroups[activeRecommendedGroupIndex];
if (!source) return;
setRecommendedGroups([...recommendedGroups, { clientId: createClientId("recommended-condition"), name: source.name, note: source.note, sands: [...source.sands], goblet: [...source.goblet], circlet: [...source.circlet], substats: [...source.substats] }]);
setActiveRecommendedGroupIndex(recommendedGroups.length);
}}
onDelete={() => {
setRecommendedGroups(recommendedGroups.filter((_, index) => index !== activeRecommendedGroupIndex));
setActiveRecommendedGroupIndex(Math.max(0, activeRecommendedGroupIndex - 1));
}}
/>
<ConditionRenameDialog
open={renamingRecommendedGroupIndex !== null}
fallback={`Condition ${(renamingRecommendedGroupIndex ?? 0) + 1}`}
value={renamingRecommendedGroupIndex === null ? "" : recommendedGroups[renamingRecommendedGroupIndex]?.name ?? ""}
onOpenChange={(open) => { if (!open) setRenamingRecommendedGroupIndex(null); }}
onChange={(name) => setRecommendedGroups(recommendedGroups.map((group, index) => index === renamingRecommendedGroupIndex ? { ...group, name } : group))}
/>
<Field>
<FieldLabel htmlFor={`recommended-condition-note-${recommendedGroups[activeRecommendedGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
<Textarea id={`recommended-condition-note-${recommendedGroups[activeRecommendedGroupIndex]?.clientId}`} value={recommendedGroups[activeRecommendedGroupIndex]?.note ?? ""} onChange={(event) => setRecommendedGroups(recommendedGroups.map((group, index) => index === activeRecommendedGroupIndex ? { ...group, note: event.target.value } : group))} />
</Field>
<div className="grid overflow-hidden rounded-xl border sm:grid-cols-3">
<StatSelect
label="Sands / นาฬิกา"
options={mainStatOptions.sands}
value={sands}
max={3}
onChange={(values) => setRecommendedStat("sands", values)}
elementIconUrls={elementIconUrls}
priorityColors
/>
<StatSelect
label="Goblet / ถ้วย"
options={mainStatOptions.goblet}
value={goblet}
max={3}
onChange={(values) => setRecommendedStat("goblet", values)}
elementIconUrls={elementIconUrls}
priorityColors
/>
<StatSelect
label="Circlet / หมวก"
options={mainStatOptions.circlet}
value={circlet}
max={3}
onChange={(values) => setRecommendedStat("circlet", values)}
elementIconUrls={elementIconUrls}
priorityColors
/>
</div>
<StatSelect
label="Substats"
options={substatOptions}
value={substats}
max={substatOptions.length}
onChange={(values) => setRecommendedStat("substats", values)}
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>
<ConditionControls
ariaLabel="เลือกเงื่อนไขค่าสถานะเป้าหมาย"
items={targetGroups}
activeIndex={activeTargetGroupIndex}
onActiveIndexChange={setActiveTargetGroupIndex}
onRename={setRenamingTargetGroupIndex}
onReorder={(from, to) => {
const reordered = reorderConditions(targetGroups, activeTargetGroupIndex, from, to);
setTargetGroups(reordered.items);
setActiveTargetGroupIndex(reordered.activeIndex);
}}
onAdd={() => {
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: "", note: "", rows: targetStatDefaults.map((target) => ({ ...target })) }]);
setActiveTargetGroupIndex(targetGroups.length);
}}
onDuplicate={() => {
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);
}}
onDelete={() => {
setTargetGroups(targetGroups.filter((_, index) => index !== activeTargetGroupIndex));
setActiveTargetGroupIndex(Math.max(0, activeTargetGroupIndex - 1));
}}
/>
<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))}
/>
<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>
</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>
<ConditionControls
ariaLabel="เลือกเงื่อนไขกลุ่มดาว"
items={groups}
activeIndex={activeConstellationGroupIndex}
onActiveIndexChange={setActiveConstellationGroupIndex}
onRename={setRenamingConstellationGroupIndex}
onReorder={(from, to) => {
const reordered = reorderConditions(groups, activeConstellationGroupIndex, from, to);
setGroups(reordered.items);
setActiveConstellationGroupIndex(reordered.activeIndex);
}}
onAdd={() => {
setGroups([
...groups,
{
clientId: createClientId("constellation-condition"),
name: "",
note: "",
rows: makeRows(),
},
]);
setActiveConstellationGroupIndex(groups.length);
}}
onDelete={() => {
setGroups(groups.filter((_, index) => index !== activeConstellationGroupIndex));
setActiveConstellationGroupIndex(Math.max(0, activeConstellationGroupIndex - 1));
}}
/>
<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>
</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="justify-end">
<AutosaveStatus
state={autosave.state}
issues={
validation.success
? []
: formatGuideValidationIssues(
"constellations",
validation.error.issues,
)
}
onSave={autosave.saveNow}
/>
</CardFooter>
</Card>
);
}
type TeamImportGuide = {
id: string;
name: string;
groups: Array<{
id: string;
name: string | null;
teams: Array<{
id: string;
title: string;
rotationSeconds: string;
combo: string;
members: Array<TeamToolValue["members"][number] & { characterName: string }>;
}>;
}>;
};
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 [importFiles, setImportFiles] = useState<File[]>([]);
const [guideImportOpen, setGuideImportOpen] = useState(false);
const [importGuides, setImportGuides] = useState<Array<{ id: string; name: string; characterKey: string; isPublic: boolean; coverUrl: string }> | null>(null);
const [activeImportGuide, setActiveImportGuide] = useState<TeamImportGuide | null>(null);
const [selectedImports, setSelectedImports] = useState<Record<string, Omit<TeamToolValue, "clientId">>>({});
const [guideImportLoading, setGuideImportLoading] = useState(false);
const [guideImportError, setGuideImportError] = useState<string | null>(null);
const [guideImportCondition, setGuideImportCondition] = useState("0");
const [guideTeamQuery, setGuideTeamQuery] = useState("");
const [guideTeamSearchTarget, setGuideTeamSearchTarget] = useState<TeamSearchTarget>("all");
const [guideListQuery, setGuideListQuery] = useState("");
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 teamSearch = 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 searchTeams(teams.map((team, teamIndex) => ({ team, teamIndex })), ({ team }) => ({
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 activeImportGroup = activeImportGuide?.groups[Number(guideImportCondition)]
?? activeImportGuide?.groups[0];
const importTeamSearch = 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 searchTeams(activeImportGroup?.teams ?? [], (team) => ({
team: [team.title],
character: team.members.flatMap((member) => characterNames.get(member.characterKey) ?? [member.characterName]),
weapon: team.members.flatMap((member) => member.weaponKey ? weaponNames.get(member.weaponKey) ?? [] : []),
artifact: team.members.flatMap((member) => [member.artifactKey, member.secondArtifactKey].flatMap((key) => key ? artifactNames.get(key) ?? [] : [])),
}), guideTeamQuery, guideTeamSearchTarget);
}, [activeImportGroup, artifacts, characters, guideTeamQuery, guideTeamSearchTarget, weapons]);
const importGuideSearch = useMemo(() => {
return searchItems(importGuides ?? [], guideListQuery,
(sourceGuide) => `${sourceGuide.name} ${sourceGuide.characterKey}`,
(sourceGuide) => [sourceGuide.name]);
}, [guideListQuery, importGuides]);
const filteredTeams = teamSearch.items;
const filteredImportTeams = importTeamSearch.items;
const filteredImportGuides = importGuideSearch.items;
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 appendImportedTeams = (importedTeams: Array<Omit<TeamToolValue, "clientId">>) => {
if (!importedTeams.length) return false;
if (teams.length + importedTeams.length > 30) {
toast.error("Import ไม่ได้ เพราะ Guide จะมี Team เกิน 30 Team");
return false;
}
for (const imported of importedTeams) {
const catalogIssue = findInvalidTeamCatalogReference(
imported,
characters,
weapons,
artifacts,
);
if (catalogIssue) {
toast.error(catalogIssue);
return false;
}
}
setTeams((current) => [
...current,
...cloneTeamImports(importedTeams, () => createClientId("team")),
]);
return true;
};
const importTeamFiles = async () => {
try {
const serializedTeams = await Promise.all(importFiles.map((file) => file.text()));
if (!serializedTeams.length) return;
const importedTeams = serializedTeams.map(parseTeamTransfer);
if (!appendImportedTeams(importedTeams)) return;
setImportFiles([]);
setImportOpen(false);
toast.success(`Import ${importedTeams.length} Team แล้ว`);
} catch {
toast.error("มีไฟล์ Team ที่ไม่ถูกต้องหรือเป็นเวอร์ชันที่ไม่รองรับ");
}
};
const openGuideImport = async () => {
setImportOpen(false);
setGuideImportOpen(true);
setActiveImportGuide(null);
setSelectedImports({});
setGuideListQuery("");
setGuideImportError(null);
setGuideImportLoading(true);
const result = await listTeamImportGuides(guide.id);
setGuideImportLoading(false);
if (result.status === "success") setImportGuides(result.guides);
else setGuideImportError(result.message);
};
const enterImportGuide = async (guideId: string) => {
setGuideImportError(null);
setGuideImportLoading(true);
const result = await loadTeamImportGuide(guideId);
setGuideImportLoading(false);
if (result.status === "success") {
setGuideImportCondition("0");
setGuideTeamQuery("");
setGuideTeamSearchTarget("all");
setActiveImportGuide(result.guide);
}
else setGuideImportError(result.message);
};
const confirmGuideImport = () => {
const importedTeams = Object.values(selectedImports);
if (!appendImportedTeams(importedTeams)) return;
setGuideImportOpen(false);
setSelectedImports({});
toast.success(`Import ${importedTeams.length} Team แล้ว`);
};
return (
<Card>
<CardHeader>
<CardTitle>Team Calc</CardTitle>
<CardDescription>
ลากเพื่อเรียง Team แล้วใส่ความเสียหายของตัวละคร 4 ตัว
ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ConditionControls
ariaLabel="เลือกเงื่อนไข Team"
items={teamGroups}
activeIndex={activeTeamGroupIndex}
onActiveIndexChange={(nextIndex) => {
const group = teamGroups[nextIndex];
if (!group) return;
setActiveTeamGroupIndex(nextIndex);
setSelectedTeamIds(new Set());
setSourceTeamId(group.teams[0]?.clientId ?? "");
}}
onRename={setRenamingTeamGroupIndex}
onReorder={(from, to) => {
const reordered = reorderConditions(teamGroups, activeTeamGroupIndex, from, to);
setTeamGroups(reordered.items);
setActiveTeamGroupIndex(reordered.activeIndex);
}}
onAdd={() => {
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), name: "", note: "", teams: [] }]);
setActiveTeamGroupIndex(teamGroups.length);
setSelectedTeamIds(new Set());
setSourceTeamId("");
}}
onDuplicate={() => {
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 ?? "");
}}
onDelete={() => {
setTeamGroups(teamGroups.filter((_, index) => index !== activeTeamGroupIndex));
setActiveTeamGroupIndex(Math.max(0, activeTeamGroupIndex - 1));
setSelectedTeamIds(new Set());
}}
/>
<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))}
/>
<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>
<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 text-foreground"
type="number"
min="0.01"
step="0.5"
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 border-0 [&>img]:object-cover [&>img]:p-0",
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 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
type="number"
min="0"
step="1"
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 sm:max-w-lg">
<DialogHeader>
<DialogTitle>Import Team</DialogTitle>
<DialogDescription>
เลือก Team จาก Guide อื่น หรืออัปโหลดไฟล์ JSON ที่เคย Export ไว้
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
<Card size="sm">
<CardHeader>
<CardTitle>เลือกจาก Guide</CardTitle>
<CardDescription>เลือก Team ที่บันทึกอยู่ใน Guide อื่นได้โดยไม่ต้องดาวน์โหลดไฟล์</CardDescription>
</CardHeader>
<CardFooter>
<Button type="button" variant="outline" onClick={() => void openGuideImport()}>
<BookOpenIcon data-icon="inline-start" />
เปิดรายการ Guide
</Button>
</CardFooter>
</Card>
<Card size="sm">
<CardHeader>
<CardTitle>อัปโหลดไฟล์ JSON</CardTitle>
<CardDescription>เลือกหลายไฟล์เพื่อ Import หลาย Team พร้อมกันได้</CardDescription>
</CardHeader>
<CardContent>
<Field>
<FieldLabel htmlFor="team-import-files">ไฟล์ Team JSON</FieldLabel>
<Input
id="team-import-files"
type="file"
accept=".json,application/json"
multiple
onChange={(event) => setImportFiles(Array.from(event.target.files ?? []))}
/>
</Field>
</CardContent>
<CardFooter>
<Button type="button" disabled={!importFiles.length} onClick={() => void importTeamFiles()}>
Import {importFiles.length || ""} Team
</Button>
</CardFooter>
</Card>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setImportOpen(false)}>
ปิด
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={guideImportOpen} onOpenChange={setGuideImportOpen}>
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{activeImportGuide ? activeImportGuide.name : "เลือก Guide"}</DialogTitle>
<DialogDescription>
{activeImportGuide
? "เลือก Team ที่ต้องการ สามารถย้อนกลับไปเลือกจาก Guide อื่นเพิ่มได้"
: "ค้นหาและเปิด Guide เพื่อเลือก Team ที่ต้องการ Import"}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 overflow-hidden">
{guideImportError ? (
<Alert variant="destructive">
<AlertTitle>โหลดข้อมูลไม่สำเร็จ</AlertTitle>
<AlertDescription>{guideImportError}</AlertDescription>
</Alert>
) : null}
{guideImportLoading ? (
<div className="flex h-48 items-center justify-center">
<Spinner />
</div>
) : activeImportGuide ? (
<div className="flex h-full min-h-0 flex-col gap-3">
<Button type="button" variant="ghost" className="self-start" onClick={() => setActiveImportGuide(null)}>
<ArrowLeftIcon data-icon="inline-start" />
กลับไปเลือก Guide
</Button>
{activeImportGuide.groups.length > 1 ? (
<ScrollableNav ariaLabel="เลือกเงื่อนไขทีม" className="w-full min-w-0">
<ToggleGroup
aria-label="เลือกเงื่อนไขทีม"
variant="outline"
value={[guideImportCondition]}
onValueChange={(values) => { if (values[0]) setGuideImportCondition(values[0]); }}
>
{activeImportGuide.groups.map((group, index) => (
<ToggleGroupItem key={group.id} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">
{group.name || `Condition ${index + 1}`}
</ToggleGroupItem>
))}
</ToggleGroup>
</ScrollableNav>
) : null}
<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
aria-label="ค้นหาทีม ตัวละคร อาวุธ หรืออาร์ติแฟกต์"
className="pl-9"
placeholder="ค้นหาทีม ตัวละคร อาวุธ หรืออาร์ติแฟกต์"
value={guideTeamQuery}
onValueChange={setGuideTeamQuery}
/>
</div>
<ToggleGroup
aria-label="เลือกประเภทข้อมูลที่ต้องการค้นหา"
className="max-w-full flex-wrap justify-start"
variant="outline"
size="sm"
value={[guideTeamSearchTarget]}
onValueChange={(values) => { if (values[0]) setGuideTeamSearchTarget(values[0] as TeamSearchTarget); }}
>
<ToggleGroupItem value="all">ทั้งหมด</ToggleGroupItem>
<ToggleGroupItem value="team">ชื่อทีม</ToggleGroupItem>
<ToggleGroupItem value="character">ตัวละคร</ToggleGroupItem>
<ToggleGroupItem value="weapon">อาวุธ</ToggleGroupItem>
<ToggleGroupItem value="artifact">อาร์ติแฟกต์</ToggleGroupItem>
</ToggleGroup>
<div className="min-h-0 overflow-y-auto pr-1">
{filteredImportTeams.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{filteredImportTeams.map((team) => {
const checked = Boolean(selectedImports[team.id]);
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
const seconds = Number(team.rotationSeconds);
return (
<Card key={team.id} className={cn(checked && "ring-2 ring-primary")}>
<CardHeader>
<CardTitle>{team.title}</CardTitle>
<CardDescription>โรเทชั่น {seconds} วิ</CardDescription>
<CardAction>
<Field orientation="horizontal">
<Checkbox
id={`guide-team-${team.id}`}
checked={checked}
onCheckedChange={(nextChecked) => setSelectedImports((current) => {
if (nextChecked === true) {
return { ...current, [team.id]: {
title: team.title,
rotationSeconds: team.rotationSeconds,
combo: team.combo,
members: team.members.map((member) => ({
characterKey: member.characterKey,
weaponKey: member.weaponKey,
artifactKey: member.artifactKey,
secondArtifactKey: member.secondArtifactKey,
constellation: member.constellation,
refinement: member.refinement,
damage: member.damage,
})),
} };
}
const next = { ...current };
delete next[team.id];
return next;
})}
/>
<FieldLabel htmlFor={`guide-team-${team.id}`}>เลือก</FieldLabel>
</Field>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-4 gap-2">
{team.members.map((member) => {
const character = characters.find((item) => item.key === member.characterKey);
const weapon = weapons.find((item) => item.key === member.weaponKey);
const artifact = artifacts.find((item) => item.key === member.artifactKey);
const secondArtifact = artifacts.find((item) => item.key === member.secondArtifactKey);
return (
<div key={member.characterKey} className="min-w-0 text-center">
<div className="flex items-center justify-center gap-2">
<div className={cn("relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted", rarityGradientClass(isCustomTravelerKey(member.characterKey) ? 5 : character?.rarity))}>
{character ? <Image src={character.imageUrl} alt={character.name} fill sizes="64px" className="object-contain" /> : null}
</div>
<div className="flex flex-col gap-1">
{[weapon, artifact, secondArtifact].filter((item): item is CatalogItem => Boolean(item)).map((item) => (
<div key={item.key} className={cn("relative size-7 overflow-hidden rounded-md", rarityGradientClass(item.rarity))}>
<Image src={item.imageUrl} alt={item.name} fill sizes="28px" className="object-contain p-0.5" />
</div>
))}
</div>
</div>
<div className="mt-2 flex items-center justify-center gap-1">
<Badge variant="secondary">C{member.constellation}</Badge>
<Badge variant="secondary">R{member.refinement}</Badge>
</div>
<p className="mt-1 truncate text-sm font-medium">{character?.name ?? member.characterName}</p>
<p className="mt-1 text-sm font-semibold">{Number(member.damage).toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{total ? ((Number(member.damage) / total) * 100).toFixed(1) : "0.0"}%</p>
</div>
);
})}
</div>
<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">{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}</p></div>
</div>
<p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p>
</CardContent>
</Card>
);
})}
</div>
) : (
<Empty>
<EmptyHeader>
<EmptyTitle>{activeImportGroup?.teams.length ? "ไม่พบ Team ที่ตรงกับคำค้นหา" : "Condition นี้ยังไม่มี Team"}</EmptyTitle>
<EmptyDescription>{activeImportGroup?.teams.length ? "ลองเปลี่ยนคำค้นหาหรือประเภทข้อมูล" : "เลือก Condition หรือ Guide อื่นเพื่อ Import"}</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</div>
) : importGuides?.length ? (
<div className="flex h-full min-h-0 flex-col gap-4">
<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
aria-label="ค้นหา Guide"
className="pl-9"
placeholder="ค้นหาชื่อ Guide หรือตัวละคร…"
value={guideListQuery}
onValueChange={setGuideListQuery}
/>
</div>
<div className="min-h-0 overflow-y-auto pr-1">
{filteredImportGuides.length ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredImportGuides.map((sourceGuide) => (
<Card key={sourceGuide.id} className="relative overflow-hidden py-0 transition-colors hover:bg-muted/50">
<button
type="button"
aria-label={`เปิด Guide ${sourceGuide.name}`}
className="absolute inset-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => void enterImportGuide(sourceGuide.id)}
/>
<div className="relative aspect-4/3 bg-muted">
<Image
src={sourceGuide.coverUrl}
alt={`Guide ${sourceGuide.name}`}
fill
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 90vw"
className="object-cover"
/>
</div>
<CardHeader className="pt-4">
<CardTitle>{sourceGuide.name}</CardTitle>
<CardDescription>{sourceGuide.characterKey}</CardDescription>
<CardAction>
<Badge variant={sourceGuide.isPublic ? "default" : "secondary"}>
{sourceGuide.isPublic ? "Published" : "Draft"}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="relative pb-4">
<Button type="button" size="xs" variant="outline" onClick={() => void enterImportGuide(sourceGuide.id)}>
เปิด Guide
</Button>
</CardFooter>
</Card>
))}
</div>
) : (
<Empty>
<EmptyHeader>
<EmptyTitle>ไม่พบ Guide ที่ตรงกับคำค้นหา</EmptyTitle>
<EmptyDescription>ลองค้นหาด้วยชื่อ Guide หรือตัวละครอื่น</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</div>
) : (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><BookOpenIcon /></EmptyMedia>
<EmptyTitle>ไม่มี Guide อื่นให้เลือก</EmptyTitle>
<EmptyDescription>สร้างหรือบันทึก Team ใน Guide อื่นก่อน</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setGuideImportOpen(false)}>ยกเลิก</Button>
<Button type="button" disabled={!Object.keys(selectedImports).length} onClick={confirmGuideImport}>
Import {Object.keys(selectedImports).length || ""} 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>
);
}