Files
gunshiz 9580407a90
CI / Verify (push) Successful in 1m56s
CI / Build immutable images and deploy (push) Successful in 3m12s
perf : cache
2026-10-08 23:21:34 +07:00

1011 lines
45 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Image from "@/components/ui/resilient-image";
import { GripVerticalIcon, HeartPulseIcon, PlusIcon, SparklesIcon, Trash2Icon } from "lucide-react";
import { useState, type ReactNode } from "react";
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { saveArtifactSection } from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
import { ArtifactSelect } from "@/components/admin/ui/catalog-select";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
import { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useAutosave } from "@/hooks/use-autosave";
import { adminDraftKey } from "@/lib/editor/local-draft";
import { artifactSectionSchema, mainStatOptions, substatOptions, talentLevelOptions } from "@/lib/guides/schemas";
import { normalizeTargetStat, recommendedStatElement, recommendedStatIconName, recommendedStatOptionConflicts, targetStatDefaults, targetStatIconName, targetStatOptions, statPriorityColorClass } from "@/lib/guides/stats";
import { elementIconFilter } from "@/lib/catalog/element";
import { normalizeTalentPriorities, reorderTalentPriorities, type TalentPriority, type TalentRelation } from "@/lib/guides/talents";
import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input";
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
import { cn } from "@/lib/utils";
import { type CatalogTalent, type EditorBase, hasIssuePrefix, parseEditorDraft, createClientId, ConditionControls, reorderConditions, ConditionRenameDialog, SELECTED_TOGGLE_CLASS } from "./shared";
export function StatSelect({
label,
options,
value,
onChange,
elementIconUrls,
priorityColors = false,
}: {
label: string;
options: readonly string[];
value: string[];
onChange: (value: string[]) => void;
elementIconUrls: Record<string, string>;
priorityColors?: boolean;
}) {
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<Select
multiple
value={value}
onValueChange={onChange}
>
<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={recommendedStatOptionConflicts(option, value)}
className={priorityColors && value.includes(option)
? statPriorityColorClass(value.indexOf(option))
: undefined}
>
<StatIcons values={[option]} elementIconUrls={elementIconUrls} />
{option}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
);
}
export 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>
);
}
export 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)}
/>
);
}
export 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>
);
}
export 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>
);
}
export 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>
);
}
export 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(() => {
const source = initial.artifactGroups.length ? initial.artifactGroups : [{ id: "artifact-condition-default", name: null, note: null, iconMediaId: null, iconUrl: null, tiers: [] }];
return source.map((condition, conditionIndex) => ({
clientId: condition.id,
name: condition.name ?? "",
note: condition.note ?? "",
iconMediaId: condition.iconMediaId ?? null,
iconUrl: condition.iconUrl ?? null,
tiers: (["Late Game", "Early Game"] as const).map((name, tierIndex) => ({
name,
rows: condition.tiers[tierIndex]?.rows.map((row, rowIndex) => ({ ...row, clientId: `${conditionIndex}-${name}-${rowIndex}` })) ?? [],
})),
}));
});
const [activeArtifactGroupIndex, setActiveArtifactGroupIndex] = useState(0);
const [renamingArtifactGroupIndex, setRenamingArtifactGroupIndex] = useState<number | null>(null);
const artifactTiers = groups[activeArtifactGroupIndex]?.tiers ?? [];
const setArtifactTierRows = (
tierIndex: number,
update: (rows: (typeof artifactTiers)[number]["rows"]) => (typeof artifactTiers)[number]["rows"],
) => setGroups((current) => current.map((condition, conditionIndex) => conditionIndex === activeArtifactGroupIndex
? { ...condition, tiers: condition.tiers.map((tier, index) => index === tierIndex ? { ...tier, rows: update(tier.rows) } : tier) }
: condition));
const [recommendedGroups, setRecommendedGroups] = useState(() => {
const source = profile?.recommendedConditions.length
? profile.recommendedConditions
: [{ name: null, note: null, iconMediaId: null, iconUrl: 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 ?? "", iconMediaId: group.iconMediaId ?? null, iconUrl: group.iconUrl ?? null }));
});
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, iconMediaId: null, iconUrl: 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 ?? "",
iconMediaId: group.iconMediaId ?? null,
iconUrl: group.iconUrl ?? null,
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.trim() || null,
note: group.note.trim() || null,
iconMediaId: group.iconMediaId,
tiers: group.tiers.map((tier) => ({
name: tier.name,
rows: tier.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, iconMediaId, sands, goblet, circlet, substats }) => ({ name: name.trim() || null, note: note.trim() || null, iconMediaId, sands, goblet, circlet, substats })),
targetGroups: targetGroups.map((group) => ({
name: group.name.trim() || null,
note: group.note.trim() || null,
iconMediaId: group.iconMediaId,
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,
activeArtifactGroupIndex,
sands,
goblet,
circlet,
substats,
recommendedGroups,
activeRecommendedGroupIndex,
targetGroups,
activeTargetGroupIndex,
talents,
note,
};
const autosave = useAutosave({
guideId: guide.id,
scope: "artifact",
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion, intent) =>
saveArtifactSection(guide.id, { ...next, expectedVersion }, intent),
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);
setActiveArtifactGroupIndex(draft.activeArtifactGroupIndex ?? 0);
setRecommendedGroups(draft.recommendedGroups?.length ? draft.recommendedGroups : [{ clientId: createClientId("recommended-condition"), name: "", note: "", iconMediaId: null, iconUrl: null, 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 activeTierIndex = artifactTiers.findIndex((group) =>
group.rows.some((row) => row.clientId === active.id),
);
const overTierIndex = artifactTiers.findIndex((group) =>
group.rows.some((row) => row.clientId === over.id),
);
if (activeTierIndex === -1 || activeTierIndex !== overTierIndex) return;
setGroups((current) =>
current.map((condition, conditionIndex) => {
if (conditionIndex !== activeArtifactGroupIndex) return condition;
return { ...condition, tiers: condition.tiers.map((group, groupIndex) => {
if (groupIndex !== activeTierIndex) 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">
<ConditionControls
ariaLabel="เลือกเงื่อนไขอาร์ติแฟกต์"
items={groups}
activeIndex={activeArtifactGroupIndex}
onActiveIndexChange={setActiveArtifactGroupIndex}
onRename={setRenamingArtifactGroupIndex}
onReorder={(from, to) => {
const reordered = reorderConditions(groups, activeArtifactGroupIndex, from, to);
setGroups(reordered.items);
setActiveArtifactGroupIndex(reordered.activeIndex);
}}
onAdd={(details) => {
setGroups([...groups, { clientId: createClientId("artifact-condition"), ...details, note: "", tiers: [{ name: "Late Game", rows: [] }, { name: "Early Game", rows: [] }] }]);
setActiveArtifactGroupIndex(groups.length);
}}
onDuplicate={() => {
const source = groups[activeArtifactGroupIndex];
if (!source) return;
setGroups([...groups, { ...source, clientId: createClientId("artifact-condition"), tiers: source.tiers.map((tier) => ({ ...tier, rows: tier.rows.map((row) => ({ ...row, clientId: createClientId("artifact") })) })) }]);
setActiveArtifactGroupIndex(groups.length);
}}
onDelete={() => {
const next = groups.filter((_, index) => index !== activeArtifactGroupIndex);
setGroups(next);
setActiveArtifactGroupIndex(Math.min(activeArtifactGroupIndex, next.length - 1));
}}
/>
<ConditionRenameDialog
open={renamingArtifactGroupIndex !== null}
fallback={`Condition ${(renamingArtifactGroupIndex ?? 0) + 1}`}
value={renamingArtifactGroupIndex === null ? "" : groups[renamingArtifactGroupIndex]?.name ?? ""}
iconUrl={renamingArtifactGroupIndex === null ? null : groups[renamingArtifactGroupIndex]?.iconUrl ?? null}
onOpenChange={(open) => { if (!open) setRenamingArtifactGroupIndex(null); }}
onChange={(name) => setGroups(groups.map((group, index) => index === renamingArtifactGroupIndex ? { ...group, name } : group))}
onIconChange={(icon) => setGroups(groups.map((group, index) => index === renamingArtifactGroupIndex ? { ...group, ...icon } : group))}
/>
<Field>
<FieldLabel htmlFor={`artifact-condition-note-${groups[activeArtifactGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
<Textarea id={`artifact-condition-note-${groups[activeArtifactGroupIndex]?.clientId}`} value={groups[activeArtifactGroupIndex]?.note ?? ""} onChange={(event) => setGroups(groups.map((group, index) => index === activeArtifactGroupIndex ? { ...group, note: event.target.value } : group))} />
</Field>
{artifactTiers.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", activeArtifactGroupIndex, "tiers", 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]">
<ArtifactSelect
items={artifacts}
value={row.firstArtifactKey}
invalid={artifactIssue("groups", activeArtifactGroupIndex, "tiers", groupIndex, "rows", rowIndex, "firstArtifactKey")}
placeholder="ค้นหาชุด Artifact แรก…"
rarityBackground
showSelectedImage
onValueChange={(firstArtifactKey) =>
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate))
}
/>
<ArtifactSelect
items={artifacts}
value={row.secondArtifactKey ?? ""}
placeholder="ค้นหาชุดที่สอง (ถ้ามี)…"
rarityBackground
showSelectedImage
onValueChange={(secondArtifactKey) =>
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate))
}
/>
<Input
type="text"
inputMode="decimal"
aria-label="Artifact percent"
value={row.percent}
aria-invalid={artifactIssue("groups", activeArtifactGroupIndex, "tiers", groupIndex, "rows", rowIndex)}
onChange={(event) =>
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: normalizePercentInput(event.target.value, candidate.percent) } : candidate))
}
onBlur={() =>
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: finalizePercentInput(candidate.percent) } : candidate))
}
/>
<DeleteConfirmationDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon"
/>
}
title={`ลบ Artifact อันดับ ${rowIndex + 1}?`}
description={`รายการ Artifact ในกลุ่ม ${group.name} และเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide`}
onConfirm={() =>
setArtifactTierRows(groupIndex, (rows) => rows.filter((_, inner) => inner !== rowIndex))
}
>
<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) =>
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, note: event.target.value } : candidate))
}
/>
</Field>
</div>
</div>
)}
</SortableArtifactRow>
))}
</div>
</SortableContext>
</DndContext>
<Button
type="button"
variant="outline"
onClick={() =>
setArtifactTierRows(groupIndex, (rows) => [...rows, { clientId: createClientId("artifact"), firstArtifactKey: "", secondArtifactKey: null, percent: "100", note: "" }])
}
>
<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={(details) => {
setRecommendedGroups([...recommendedGroups, { clientId: createClientId("recommended-condition"), ...details, 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, iconMediaId: source.iconMediaId, iconUrl: source.iconUrl, 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 ?? ""}
iconUrl={renamingRecommendedGroupIndex === null ? null : recommendedGroups[renamingRecommendedGroupIndex]?.iconUrl ?? null}
onOpenChange={(open) => { if (!open) setRenamingRecommendedGroupIndex(null); }}
onChange={(name) => setRecommendedGroups(recommendedGroups.map((group, index) => index === renamingRecommendedGroupIndex ? { ...group, name } : group))}
onIconChange={(icon) => setRecommendedGroups(recommendedGroups.map((group, index) => index === renamingRecommendedGroupIndex ? { ...group, ...icon } : 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 gap-4 sm:grid-cols-3">
<StatSelect
label="Sands / นาฬิกา"
options={mainStatOptions.sands}
value={sands}
onChange={(values) => setRecommendedStat("sands", values)}
elementIconUrls={elementIconUrls}
priorityColors
/>
<StatSelect
label="Goblet / ถ้วย"
options={mainStatOptions.goblet}
value={goblet}
onChange={(values) => setRecommendedStat("goblet", values)}
elementIconUrls={elementIconUrls}
priorityColors
/>
<StatSelect
label="Circlet / หมวก"
options={mainStatOptions.circlet}
value={circlet}
onChange={(values) => setRecommendedStat("circlet", values)}
elementIconUrls={elementIconUrls}
priorityColors
/>
</div>
<StatSelect
label="Substats"
options={substatOptions}
value={substats}
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={(details) => {
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), ...details, 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, iconMediaId: source.iconMediaId, iconUrl: source.iconUrl, 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 ?? ""}
iconUrl={renamingTargetGroupIndex === null ? null : targetGroups[renamingTargetGroupIndex]?.iconUrl ?? null}
onOpenChange={(open) => { if (!open) setRenamingTargetGroupIndex(null); }}
onChange={(name) => setTargetGroups(targetGroups.map((group, index) => index === renamingTargetGroupIndex ? { ...group, name } : group))}
onIconChange={(icon) => setTargetGroups(targetGroups.map((group, index) => index === renamingTargetGroupIndex ? { ...group, ...icon } : 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}
conflictVersion={autosave.conflictVersion}
canOverwrite={autosave.canOverwrite}
onReloadLatest={autosave.reloadLatest}
onOverwrite={autosave.overwrite}
issues={
validation.success
? []
: formatGuideValidationIssues(
"artifact",
validation.error.issues,
)
}
onSave={autosave.saveNow}
/>
</CardFooter>
</Card>
</div>
);
}