feat : update
This commit is contained in:
@@ -1,8 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import {
|
||||
saveArtifactSection,
|
||||
@@ -43,13 +59,19 @@ import {
|
||||
} from "@/lib/guides/schemas";
|
||||
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import {
|
||||
normalizeTalentPriorities,
|
||||
reorderTalentPriorities,
|
||||
type TalentPriority,
|
||||
type TalentRelation,
|
||||
} from "@/lib/guides/talents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null; weaponType?: string | null };
|
||||
type Guide = { id: string; characterKey: string; version: number; name: string };
|
||||
type WeaponValue = { weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; weapon: CatalogItem };
|
||||
type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string }> };
|
||||
type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>; note: string | null } | null;
|
||||
type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: TalentPriority[]; note: string | null } | null;
|
||||
type ConstellationValue = { level: number; detail: string; cumulativePercent: string };
|
||||
type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null };
|
||||
type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; iconUrl: string | null };
|
||||
@@ -119,6 +141,46 @@ function TalentLevelPicker({ value, onChange }: { value?: string; onChange: (val
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTalentCard({
|
||||
talent,
|
||||
index,
|
||||
catalogTalent,
|
||||
onLevelChange,
|
||||
}: {
|
||||
talent: TalentPriority;
|
||||
index: number;
|
||||
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(isDragging && "opacity-60")}
|
||||
>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
{catalogTalent?.iconUrl ? <Image src={catalogTalent.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}
|
||||
<CardTitle>{index + 1}. {catalogTalent?.name ?? talent.type}</CardTitle>
|
||||
<CardAction>
|
||||
<Button type="button" variant="ghost" size="icon-sm" className="touch-none" {...attributes} {...listeners}>
|
||||
<GripVerticalIcon />
|
||||
<span className="sr-only">ลากเพื่อจัดลำดับ {catalogTalent?.name ?? talent.type}</span>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Field>
|
||||
<FieldLabel>ระดับเป้าหมาย</FieldLabel>
|
||||
<TalentLevelPicker value={talent.level} onChange={onLevelChange} />
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowActions({ index, length, move, remove }: { index: number; length: number; move: (from: number, to: number) => void; remove?: () => void }) {
|
||||
return <div className="flex gap-1"><Button type="button" variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => move(index, index - 1)}><ArrowUpIcon /><span className="sr-only">เลื่อนขึ้น</span></Button><Button type="button" variant="ghost" size="icon-sm" disabled={index === length - 1} onClick={() => move(index, index + 1)}><ArrowDownIcon /><span className="sr-only">เลื่อนลง</span></Button>{remove ? <Button type="button" variant="ghost" size="icon-sm" onClick={remove}><Trash2Icon /><span className="sr-only">ลบ</span></Button> : null}</div>;
|
||||
}
|
||||
@@ -163,6 +225,10 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
|
||||
function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBase) {
|
||||
const profile = initial.artifactProfile;
|
||||
const talentSensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
const [groups, setGroups] = useState(() => (["Late Game", "Early Game"] as const).map((name, index) => ({
|
||||
name,
|
||||
rows: initial.artifactGroups[index]?.rows.map((row) => ({ ...row })) ?? [],
|
||||
@@ -176,19 +242,26 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
||||
const defaults = DEFAULT_TARGET_STATS.map((stat) => existing.find((target) => target.stat === stat) ?? { stat, value: "" });
|
||||
return [...defaults, ...existing.filter((target) => !DEFAULT_TARGET_STATS.includes(target.stat as (typeof DEFAULT_TARGET_STATS)[number]))];
|
||||
});
|
||||
const [talents, setTalents] = useState<Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>>(() => {
|
||||
const source: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }> = profile?.talents.length
|
||||
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 source.map((talent) => ({
|
||||
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) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent) })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.stat.trim() && target.value.trim()), talents, note: note || null };
|
||||
const autosave = useAutosave({ value, valid: artifactSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveArtifactSection(guide.id, { ...next, expectedVersion }) });
|
||||
const orderedTalents = [...talents].sort((a, b) => a.priority - b.priority);
|
||||
const orderedTalents = normalizeTalentPriorities(talents);
|
||||
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">
|
||||
<Card>
|
||||
@@ -199,7 +272,61 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Recommended Stats</CardTitle><CardDescription>เลือกค่าจากรายการแทนปุ่ม และเลือก Main Stat ได้สูงสุด 2 ค่าต่อชิ้น</CardDescription></CardHeader>
|
||||
<CardContent><FieldGroup><StatSelect label="Sands / นาฬิกา" options={mainStatOptions.sands} value={sands} max={2} onChange={setSands} /><StatSelect label="Goblet / ถ้วย" options={mainStatOptions.goblet} value={goblet} max={2} onChange={setGoblet} /><StatSelect label="Circlet / หมวก" options={mainStatOptions.circlet} value={circlet} max={2} onChange={setCirclet} /><StatSelect label="Substats" options={substatOptions} value={substats} max={substatOptions.length} onChange={setSubstats} /><FieldSet><FieldLegend>Target Stats</FieldLegend>{targets.map((target, index) => <div key={`${target.stat}-${index}`} className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]"><Input value={target.stat} placeholder="ATK" aria-label={`Target stat ${index + 1}`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat: event.target.value } : item))} /><Input value={target.value} placeholder={target.stat === "ATK" ? "2200+" : "Recommended value"} aria-label={`${target.stat || "Target"} value`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} /><Button variant="ghost" size="icon" onClick={() => setTargets(targets.filter((_, itemIndex) => itemIndex !== index))}><Trash2Icon /><span className="sr-only">ลบเป้าหมาย</span></Button></div>)}<Button variant="outline" onClick={() => setTargets([...targets, { stat: "", value: "" }])}><PlusIcon data-icon="inline-start" />เพิ่ม Target</Button></FieldSet><FieldSet><FieldLegend>Talent Priority</FieldLegend>{orderedTalents.map((talent, index) => { const catalogTalent = catalogTalents.find((item) => item.type === talent.type); return <Card key={talent.type} size="sm"><CardHeader>{catalogTalent?.iconUrl ? <Image src={catalogTalent.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>{index + 1}. {catalogTalent?.name ?? talent.type}</CardTitle><CardAction><RowActions index={index} length={orderedTalents.length} move={(from, to) => setTalents(moveItem(orderedTalents, from, to).map((item, priority) => ({ ...item, priority: priority + 1 })))} /></CardAction></CardHeader><CardContent><Field><FieldLabel>ระดับเป้าหมาย</FieldLabel><TalentLevelPicker value={talent.level} onChange={(level) => setTalents(talents.map((item) => item.type === talent.type ? { ...item, level } : item))} /></Field></CardContent></Card>; })}</FieldSet><Field><FieldLabel htmlFor="artifact-note">Note (optional)</FieldLabel><Textarea id="artifact-note" value={note} onChange={(event) => setNote(event.target.value)} /></Field></FieldGroup></CardContent>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<StatSelect label="Sands / นาฬิกา" options={mainStatOptions.sands} value={sands} max={2} onChange={setSands} />
|
||||
<StatSelect label="Goblet / ถ้วย" options={mainStatOptions.goblet} value={goblet} max={2} onChange={setGoblet} />
|
||||
<StatSelect label="Circlet / หมวก" options={mainStatOptions.circlet} value={circlet} max={2} onChange={setCirclet} />
|
||||
<StatSelect label="Substats" options={substatOptions} value={substats} max={substatOptions.length} onChange={setSubstats} />
|
||||
<FieldSet>
|
||||
<FieldLegend>Target Stats</FieldLegend>
|
||||
{targets.map((target, index) => <div key={`${target.stat}-${index}`} className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]"><Input value={target.stat} placeholder="ATK" aria-label={`Target stat ${index + 1}`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat: event.target.value } : item))} /><Input value={target.value} placeholder={target.stat === "ATK" ? "2200+" : "Recommended value"} aria-label={`${target.stat || "Target"} value`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} /><Button variant="ghost" size="icon" onClick={() => setTargets(targets.filter((_, itemIndex) => itemIndex !== index))}><Trash2Icon /><span className="sr-only">ลบเป้าหมาย</span></Button></div>)}
|
||||
<Button variant="outline" onClick={() => setTargets([...targets, { stat: "", value: "" }])}><PlusIcon data-icon="inline-start" />เพิ่ม Target</Button>
|
||||
</FieldSet>
|
||||
<FieldSet>
|
||||
<FieldLegend>Talent Priority</FieldLegend>
|
||||
<DndContext sensors={talentSensors} collisionDetection={closestCenter} onDragEnd={handleTalentDragEnd}>
|
||||
<SortableContext items={orderedTalents.map((talent) => talent.type)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{orderedTalents.map((talent, index) => {
|
||||
const catalogTalent = catalogTalents.find((item) => item.type === talent.type);
|
||||
return (
|
||||
<div key={talent.type} className="flex flex-col gap-2">
|
||||
<SortableTalentCard
|
||||
talent={talent}
|
||||
index={index}
|
||||
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="self-center"
|
||||
>
|
||||
<ToggleGroupItem className={SELECTED_TOGGLE_CLASS} value="gt" aria-label="มากกว่า">></ToggleGroupItem>
|
||||
<ToggleGroupItem className={SELECTED_TOGGLE_CLASS} value="gte" aria-label="มากกว่าหรือเท่ากับ">≥</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</FieldSet>
|
||||
<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} /></CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user