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