feat : update
CI / Verify (push) Successful in 50s
CI / Build immutable images and deploy (push) Successful in 4m15s

This commit is contained in:
2026-08-30 11:17:58 +00:00 Unverified
parent fd3472bc8d
commit 8177c91434
9 changed files with 257 additions and 18 deletions
+135 -8
View File
@@ -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="มากกว่า">&gt;</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>