1011 lines
45 KiB
TypeScript
1011 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 { 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="มากกว่า"
|
||
>
|
||
>
|
||
</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>
|
||
);
|
||
}
|