|
|
|
@@ -93,6 +93,8 @@ import {
|
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null; weaponType?: string | null };
|
|
|
|
@@ -357,12 +359,12 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
|
|
|
|
<DndContext sensors={weaponSensors} collisionDetection={closestCenter} onDragEnd={handleWeaponDragEnd}>
|
|
|
|
|
<SortableContext items={rows.map((row) => row.clientId)} strategy={verticalListSortingStrategy}>
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
{rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return <SortableWeaponCard key={row.clientId} id={row.clientId}>{(dragHandle) => <Card size="sm"><CardHeader><CardTitle className="flex items-center gap-3">{selectedWeapon ? <div className={cn("relative size-10 shrink-0 overflow-hidden rounded-lg", rarityGradientClass(selectedWeapon.rarity))}><Image src={selectedWeapon.imageUrl} alt="" fill className="object-contain" /></div> : null}<span>อันดับ {rowIndex + 1}</span></CardTitle><CardAction><div className="flex gap-1">{dragHandle}<DeleteConfirmationDialog trigger={<Button type="button" variant="ghost" size="icon-sm" />} title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`} description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide" onConfirm={() => setRows(rows.filter((item) => item.clientId !== row.clientId))}><Trash2Icon /><span className="sr-only">ลบอาวุธ</span></DeleteConfirmationDialog></div></CardAction></CardHeader><CardContent><FieldGroup><div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4"><Field className="min-w-0"><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={compatibleWeapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" rarityBackground onChange={(weaponKey) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, weaponKey } : item))} /></Field><Field className="shrink-0"><FieldLabel>Refinement</FieldLabel><ToggleGroup variant="outline" value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field></div><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" step="0.01" value={row.overallPercent} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: event.target.value } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="number" step="0.01" value={row.personalPercent} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: event.target.value } : item))} /></Field></div><Field><FieldLabel htmlFor={`weapon-note-${row.clientId}`}>Note (optional)</FieldLabel><Textarea id={`weapon-note-${row.clientId}`} value={row.note} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, note: event.target.value } : item))} /></Field></FieldGroup></CardContent></Card>}</SortableWeaponCard>; })}
|
|
|
|
|
{rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return <SortableWeaponCard key={row.clientId} id={row.clientId}>{(dragHandle) => <Card size="sm"><CardHeader><CardTitle className="flex items-center gap-3">{selectedWeapon ? <div className={cn("relative size-10 shrink-0 overflow-hidden rounded-lg", rarityGradientClass(selectedWeapon.rarity))}><Image src={selectedWeapon.imageUrl} alt="" fill className="object-contain" /></div> : null}<span>อันดับ {rowIndex + 1}</span></CardTitle><CardAction><div className="flex gap-1">{dragHandle}<DeleteConfirmationDialog trigger={<Button type="button" variant="ghost" size="icon-sm" />} title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`} description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide" onConfirm={() => setRows(rows.filter((item) => item.clientId !== row.clientId))}><Trash2Icon /><span className="sr-only">ลบอาวุธ</span></DeleteConfirmationDialog></div></CardAction></CardHeader><CardContent><FieldGroup><div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4"><Field className="min-w-0"><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={compatibleWeapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" rarityBackground onChange={(weaponKey) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, weaponKey } : item))} /></Field><Field className="shrink-0"><FieldLabel>Refinement</FieldLabel><ToggleGroup variant="outline" value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field></div><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="text" inputMode="decimal" value={row.overallPercent} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: normalizePercentInput(event.target.value, item.overallPercent) } : item))} onBlur={() => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: finalizePercentInput(item.overallPercent) } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="text" inputMode="decimal" value={row.personalPercent} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: normalizePercentInput(event.target.value, item.personalPercent) } : item))} onBlur={() => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: finalizePercentInput(item.personalPercent) } : item))} /></Field></div><Field><FieldLabel htmlFor={`weapon-note-${row.clientId}`}>Note (optional)</FieldLabel><Textarea id={`weapon-note-${row.clientId}`} value={row.note} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, note: event.target.value } : item))} /></Field></FieldGroup></CardContent></Card>}</SortableWeaponCard>; })}
|
|
|
|
|
</div>
|
|
|
|
|
</SortableContext>
|
|
|
|
|
</DndContext>
|
|
|
|
|
</CardContent>
|
|
|
|
|
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { clientId: createClientId("weapon"), weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "", note: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
|
|
|
|
|
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { clientId: createClientId("weapon"), weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "", note: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : formatGuideValidationIssues("weapon", validation.error.issues)} onSave={autosave.saveNow} /></CardFooter>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
@@ -461,7 +463,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
|
|
|
|
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_8rem_auto]">
|
|
|
|
|
<CatalogPicker items={artifacts} value={row.firstArtifactKey} placeholder="ค้นหาชุด Artifact แรก…" rarityBackground showSelectedImage onChange={(firstArtifactKey) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate) } : item))} />
|
|
|
|
|
<CatalogPicker items={artifacts} value={row.secondArtifactKey ?? ""} placeholder="ค้นหาชุดที่สอง (ถ้ามี)…" rarityBackground showSelectedImage onChange={(secondArtifactKey) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate) } : item))} />
|
|
|
|
|
<Input type="number" step="0.01" aria-label="Artifact percent" value={row.percent} onChange={(event) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value } : candidate) } : item))} />
|
|
|
|
|
<Input type="text" inputMode="decimal" aria-label="Artifact percent" value={row.percent} onChange={(event) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: normalizePercentInput(event.target.value, candidate.percent) } : candidate) } : item))} onBlur={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: finalizePercentInput(candidate.percent) } : candidate) } : item))} />
|
|
|
|
|
<DeleteConfirmationDialog trigger={<Button type="button" variant="ghost" size="icon" />} title={`ลบ Artifact อันดับ ${rowIndex + 1}?`} description={`รายการ Artifact ในกลุ่ม ${group.name} และเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide`} onConfirm={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}><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) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, note: event.target.value } : candidate) } : item))} /></Field>
|
|
|
|
@@ -552,7 +554,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
|
|
|
|
</Field>
|
|
|
|
|
</FieldGroup>
|
|
|
|
|
</CardContent>
|
|
|
|
|
<CardFooter className="justify-end"><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
|
|
|
|
|
<CardFooter className="justify-end"><AutosaveStatus state={autosave.state} issues={validation.success ? [] : formatGuideValidationIssues("artifact", validation.error.issues)} onSave={autosave.saveNow} /></CardFooter>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
@@ -594,12 +596,12 @@ function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBa
|
|
|
|
|
<CardContent className="flex flex-col gap-4">
|
|
|
|
|
<Field><FieldLabel htmlFor={`constellation-condition-note-${group.clientId}`}>Condition note (optional)</FieldLabel><Textarea id={`constellation-condition-note-${group.clientId}`} value={group.note} onChange={(event) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, note: event.target.value } : item))} /></Field>
|
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
|
|
|
{group.rows.map((row, rowIndex) => <Card key={row.level} size="sm"><CardHeader>{row.iconUrl ? <Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>C{row.level} · {row.name}</CardTitle><CardDescription>{row.description}</CardDescription></CardHeader><CardContent className="flex items-end gap-3"><Field className="min-w-0 flex-1"><FieldLabel>Cumulative %</FieldLabel><Input type="number" step="0.01" value={row.cumulativePercent} onChange={(event) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: event.target.value } : candidate) } : item))} /></Field><Toggle variant="outline" pressed={row.isGreat} onPressedChange={(isGreat) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, isGreat } : candidate) } : item))} aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}><SparklesIcon data-icon="inline-start" />Great %</Toggle></CardContent></Card>)}
|
|
|
|
|
{group.rows.map((row, rowIndex) => <Card key={row.level} size="sm"><CardHeader>{row.iconUrl ? <Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>C{row.level} · {row.name}</CardTitle><CardDescription>{row.description}</CardDescription></CardHeader><CardContent className="flex items-end gap-3"><Field className="min-w-0 flex-1"><FieldLabel>Cumulative %</FieldLabel><Input type="text" inputMode="decimal" value={row.cumulativePercent} onChange={(event) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: normalizePercentInput(event.target.value, candidate.cumulativePercent) } : candidate) } : item))} onBlur={() => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: finalizePercentInput(candidate.cumulativePercent) } : candidate) } : item))} /></Field><Toggle variant="outline" pressed={row.isGreat} onPressedChange={(isGreat) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, isGreat } : candidate) } : item))} aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}><SparklesIcon data-icon="inline-start" />Great %</Toggle></CardContent></Card>)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" disabled={groups.length >= 20} onClick={() => setGroups([...groups, { clientId: createClientId("constellation-condition"), note: "", rows: makeRows() }])}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
|
|
|
|
|
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" disabled={groups.length >= 20} onClick={() => setGroups([...groups, { clientId: createClientId("constellation-condition"), note: "", rows: makeRows() }])}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : formatGuideValidationIssues("constellations", validation.error.issues)} onSave={autosave.saveNow} /></CardFooter>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
@@ -728,7 +730,7 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
|
|
|
|
|
</SortableContext>
|
|
|
|
|
</DndContext>
|
|
|
|
|
</CardContent>
|
|
|
|
|
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" disabled={characters.length < 4} onClick={() => setTeams([...teams, { clientId: createClientId("team"), title: "", rotationSeconds: "20", combo: "", members: fallbackMembers }])}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
|
|
|
|
|
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" disabled={characters.length < 4} onClick={() => setTeams([...teams, { clientId: createClientId("team"), title: "", rotationSeconds: "20", combo: "", members: fallbackMembers }])}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : formatGuideValidationIssues("team", validation.error.issues)} onSave={autosave.saveNow} /></CardFooter>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
@@ -809,7 +811,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
|
|
|
|
</Field>
|
|
|
|
|
<Field><FieldLabel>หัวข้อ</FieldLabel><Input value={row.heading} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, heading: event.target.value } : candidate) } : item))} /></Field>
|
|
|
|
|
<Field><FieldLabel>ข้อความ</FieldLabel><Textarea value={row.body} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, body: event.target.value } : candidate) } : item))} /></Field>
|
|
|
|
|
<Field><FieldLabel>Percent (optional)</FieldLabel><Input type="number" step="0.01" value={row.percent ?? ""} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value || null } : candidate) } : item))} /></Field>
|
|
|
|
|
<Field><FieldLabel>Percent (optional)</FieldLabel><Input type="text" inputMode="decimal" value={row.percent ?? ""} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: normalizePercentInput(event.target.value, candidate.percent ?? "") || null } : candidate) } : item))} onBlur={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: finalizePercentInput(candidate.percent ?? "") || null } : candidate) } : item))} /></Field>
|
|
|
|
|
</FieldGroup>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
@@ -819,7 +821,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
|
|
|
|
</Card>
|
|
|
|
|
))}
|
|
|
|
|
</CardContent>
|
|
|
|
|
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" onClick={() => setSections([...sections, { title: "Extra", rows: [] }])}><PlusIcon data-icon="inline-start" />เพิ่ม Extra section</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
|
|
|
|
|
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" onClick={() => setSections([...sections, { title: "Extra", rows: [] }])}><PlusIcon data-icon="inline-start" />เพิ่ม Extra section</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : formatGuideValidationIssues("extra", validation.error.issues)} onSave={autosave.saveNow} /></CardFooter>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|