Files
buzz-sheet/components/admin/editors/artifact-editor.tsx
T
gunshiz a3803118ef
CI / Verify (push) Successful in 2m10s
CI / Build immutable images and deploy (push) Successful in 2m33s
feat : add analytics page
2026-10-10 20:19:49 +07:00

1012 lines
45 KiB
TypeScript

"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 { SaveStatus } from "@/components/admin/save-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 { useManualSave } from "@/hooks/use-manual-save";
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 manualSave = useManualSave({
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">
<SaveStatus
state={manualSave.state}
conflictVersion={manualSave.conflictVersion}
canOverwrite={manualSave.canOverwrite}
onReloadLatest={manualSave.reloadLatest}
onOverwrite={manualSave.overwrite}
issues={
validation.success
? []
: formatGuideValidationIssues(
"artifact",
validation.error.issues,
)
}
onSave={manualSave.saveNow}
canSave={manualSave.canSave}
/>
</CardFooter>
</Card>
</div>
);
}