feat : fix bug
CI / Verify (push) Successful in 1m0s
CI / Build immutable images and deploy (push) Successful in 1m40s

This commit is contained in:
2026-08-30 12:30:12 +00:00 Unverified
parent 811678fd52
commit 4335b54d49
13 changed files with 7161 additions and 49 deletions
+92 -30
View File
@@ -1,8 +1,8 @@
"use client";
import Image from "next/image";
import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon, PlusIcon, SparklesIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { GripVerticalIcon, PlusIcon, SparklesIcon, Trash2Icon } from "lucide-react";
import { useState, type ReactNode } from "react";
import {
closestCenter,
DndContext,
@@ -13,6 +13,7 @@ import {
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
@@ -71,13 +72,13 @@ 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 WeaponValue = { id: string; weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; note: string | null; weapon: CatalogItem };
type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string; note: string | 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; isGreat: boolean };
type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null };
type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; iconUrl: string | null };
type TeamValue = { title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; constellation: number; refinement: number; damage: string }> };
type TeamValue = { id: string; title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; constellation: number; refinement: number; damage: string }> };
export interface GuideSectionEditorProps {
section: "weapon" | "artifact" | "constellations" | "team" | "extra";
@@ -183,15 +184,41 @@ function SortableTalentCard({
);
}
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>;
function SortableWeaponCard({ 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">ลากเพื่อจัดลำดับอาวุธ</span>
</Button>,
)}
</div>
);
}
function moveItem<T>(items: T[], from: number, to: number): T[] {
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
function SortableTeamItem({ id, children }: { id: string; children: (dragHandle: ReactNode) => ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
return (
<AccordionItem
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
value={id}
className={cn("rounded-xl border px-4", isDragging && "opacity-60")}
>
{children(
<Button type="button" variant="ghost" size="icon-sm" className="touch-none" {...attributes} {...listeners}>
<GripVerticalIcon />
<span className="sr-only">ลากเพื่อจัดลำดับ Team</span>
</Button>,
)}
</AccordionItem>
);
}
export function GuideSectionEditor(props: GuideSectionEditorProps) {
@@ -211,16 +238,34 @@ type EditorBase = GuideSectionEditorProps;
function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
const characterWeaponType = characters.find((character) => character.key === guide.characterKey)?.weaponType;
const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(characterWeaponType, weapon.weaponType));
const [rows, setRows] = useState(initial.weapons.map((row) => ({ weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent ?? "", personalPercent: row.personalPercent ?? "" })));
const value = { rows: rows.map((row) => ({ ...row, overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent), personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent) })) };
const weaponSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const [rows, setRows] = useState(initial.weapons.map((row) => ({ clientId: row.id, weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent ?? "", personalPercent: row.personalPercent ?? "", note: row.note ?? "" })));
const value = { rows: rows.map((row) => ({ weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent), personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent), note: row.note.trim() || null })) };
const autosave = useAutosave({ value, valid: weaponSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveWeaponSection(guide.id, { ...next, expectedVersion }) });
const handleWeaponDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setRows((current) => {
const from = current.findIndex((row) => row.clientId === active.id);
const to = current.findIndex((row) => row.clientId === over.id);
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
});
};
return (
<Card>
<CardHeader><CardTitle>Weapon tier list</CardTitle><CardDescription>เรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardHeader><CardTitle>Weapon tier list</CardTitle><CardDescription>ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">
{rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return <Card key={rowIndex} 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><RowActions index={rowIndex} length={rows.length} move={(from, to) => setRows(moveItem(rows, from, to))} remove={() => setRows(rows.filter((_, index) => index !== rowIndex))} /></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={compatibleWeapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" rarityBackground onChange={(weaponKey) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, weaponKey } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup variant="outline" value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item, index) => index === rowIndex ? { ...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 className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" min="0" step="0.01" value={row.overallPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, overallPercent: event.target.value } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="number" min="0" step="0.01" value={row.personalPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, personalPercent: event.target.value } : item))} /></Field></div></FieldGroup></CardContent></Card>; })}
<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}<Button type="button" variant="ghost" size="icon-sm" onClick={() => setRows(rows.filter((item) => item.clientId !== row.clientId))}><Trash2Icon /><span className="sr-only">ลบอาวุธ</span></Button></div></CardAction></CardHeader><CardContent><FieldGroup><Field><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><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 className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" min="0" 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" min="0" 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>; })}
</div>
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { clientId: crypto.randomUUID(), weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "", note: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave} /></CardFooter>
</Card>
);
}
@@ -254,7 +299,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
})));
});
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 value = { groups: groups.map((group) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent), note: row.note?.trim() || null })) })), 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 = normalizeTalentPriorities(talents);
const handleTalentDragEnd = ({ active, over }: DragEndEvent) => {
@@ -269,7 +314,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
<Card>
<CardHeader><CardTitle>Artifact tier list</CardTitle><CardDescription>จัดชุด Artifact ในกลุ่ม Late Game และ Early Game ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-5">
{groups.map((group, groupIndex) => <Card key={group.name} size="sm"><CardHeader><CardTitle>{group.name}</CardTitle></CardHeader><CardContent className="flex flex-col gap-3">{group.rows.map((row, rowIndex) => <div key={rowIndex} className="grid gap-3 rounded-lg border p-3 lg:grid-cols-[1fr_1fr_8rem_auto]"><CatalogPicker items={artifacts} value={row.firstArtifactKey} placeholder="ค้นหาชุด Artifact แรก…" rarityBackground 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 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" min="0" 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))} /><Button variant="ghost" size="icon" onClick={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}><Trash2Icon /><span className="sr-only">ลบ Artifact</span></Button></div>)}<Button variant="outline" onClick={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: [...item.rows, { firstArtifactKey: "", secondArtifactKey: null, percent: "100" }] } : item))}><PlusIcon data-icon="inline-start" />เพิ่มชุด Artifact</Button></CardContent></Card>)}
{groups.map((group, groupIndex) => <Card key={group.name} size="sm"><CardHeader><CardTitle>{group.name}</CardTitle></CardHeader><CardContent className="flex flex-col gap-3">{group.rows.map((row, rowIndex) => <div key={rowIndex} className="flex flex-col gap-3 rounded-lg border p-3"><div className="grid gap-3 lg:grid-cols-[1fr_1fr_8rem_auto]"><CatalogPicker items={artifacts} value={row.firstArtifactKey} placeholder="ค้นหาชุด Artifact แรก…" rarityBackground 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 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" min="0" 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))} /><Button variant="ghost" size="icon" onClick={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}><Trash2Icon /><span className="sr-only">ลบ Artifact</span></Button></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></div>)}<Button variant="outline" onClick={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: [...item.rows, { firstArtifactKey: "", secondArtifactKey: null, percent: "100", note: "" }] } : item))}><PlusIcon data-icon="inline-start" />เพิ่มชุด Artifact</Button></CardContent></Card>)}
</CardContent>
</Card>
<Card>
@@ -358,19 +403,33 @@ function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBa
function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBase) {
const fallbackMembers = characters.slice(0, 4).map((character) => ({ characterKey: character.key, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1, damage: "0" }));
const [teams, setTeams] = useState(initial.teams);
const value = { teams: teams.map((team) => ({ ...team, rotationSeconds: Number(team.rotationSeconds), members: team.members.map((member) => ({ ...member, damage: Number(member.damage) })) })) };
const teamSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const [teams, setTeams] = useState(initial.teams.map(({ id, ...team }) => ({ clientId: id, ...team })));
const value = { teams: teams.map((team) => ({ title: team.title, rotationSeconds: Number(team.rotationSeconds), combo: team.combo, members: team.members.map((member) => ({ ...member, damage: Number(member.damage) })) })) };
const autosave = useAutosave({ value, valid: teamSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveTeamSection(guide.id, { ...next, expectedVersion }) });
const handleTeamDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setTeams((current) => {
const from = current.findIndex((team) => team.clientId === active.id);
const to = current.findIndex((team) => team.clientId === over.id);
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
});
};
return (
<Card>
<CardHeader><CardTitle>Team Calc</CardTitle><CardDescription>ใส่ความเสียหายของตัวละคร 4 ตัว ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardHeader><CardTitle>Team Calc</CardTitle><CardDescription>ลากเพื่อเรียง Team แล้วใส่ความเสียหายของตัวละคร 4 ตัว ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent>
<Accordion multiple defaultValue={teams.length ? ["team-0"] : []} className="gap-3">
{teams.map((team, teamIndex) => {
<DndContext sensors={teamSensors} collisionDetection={closestCenter} onDragEnd={handleTeamDragEnd}>
<SortableContext items={teams.map((team) => team.clientId)} strategy={verticalListSortingStrategy}>
<Accordion multiple defaultValue={teams.length ? [teams[0].clientId] : []} className="gap-3">
{teams.map((team, teamIndex) => {
const total = team.members.reduce((sum, member) => sum + (Number(member.damage) || 0), 0);
const dps = total / (Number(team.rotationSeconds) || 1);
return (
<AccordionItem key={teamIndex} value={`team-${teamIndex}`} className="rounded-xl border px-4">
<SortableTeamItem key={team.clientId} id={team.clientId}>{(dragHandle) => <>
<div className="flex items-center gap-2">
<AccordionTrigger className="min-w-0 py-4 hover:no-underline">
<span className="flex min-w-0 flex-col gap-1">
@@ -378,7 +437,8 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
<span className="text-xs font-normal text-muted-foreground">Damage {total.toLocaleString()} • DPS {dps.toFixed(0)}</span>
</span>
</AccordionTrigger>
<RowActions index={teamIndex} length={teams.length} move={(from, to) => setTeams(moveItem(teams, from, to))} remove={() => setTeams(teams.filter((_, index) => index !== teamIndex))} />
{dragHandle}
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setTeams(teams.filter((item) => item.clientId !== team.clientId))}><Trash2Icon /><span className="sr-only">ลบ Team</span></Button>
</div>
<AccordionContent className="pb-4">
<FieldGroup>
@@ -388,12 +448,14 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
<Field><FieldLabel>Combo</FieldLabel><Textarea value={team.combo} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, combo: event.target.value } : item))} /></Field>
</FieldGroup>
</AccordionContent>
</AccordionItem>
</>}</SortableTeamItem>
);
})}
</Accordion>
})}
</Accordion>
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" disabled={characters.length < 4} onClick={() => setTeams([...teams, { title: "", rotationSeconds: "20", combo: "", members: fallbackMembers }])}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button><AutosaveStatus state={autosave} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" disabled={characters.length < 4} onClick={() => setTeams([...teams, { clientId: crypto.randomUUID(), title: "", rotationSeconds: "20", combo: "", members: fallbackMembers }])}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button><AutosaveStatus state={autosave} /></CardFooter>
</Card>
);
}