Files
buzz-sheet/components/admin/guide-section-editor.tsx
T
gunshiz 14a98c55f1
CI / Verify (push) Successful in 1m5s
CI / Build immutable images and deploy (push) Successful in 1m41s
feat : buzz feedback
2026-08-30 15:11:35 +00:00

559 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Image from "next/image";
import {
ChartNoAxesCombinedIcon,
GemIcon,
GripVerticalIcon,
ListOrderedIcon,
PlusIcon,
RefreshCcwIcon,
SparklesIcon,
SwordIcon,
TimerIcon,
Trash2Icon,
UserRoundIcon,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
saveArtifactSection,
saveConstellationSection,
saveExtraSectionList,
saveTeamSection,
saveWeaponSection,
} from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { MediaUploader } from "@/components/admin/media-uploader";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
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 { Input } from "@/components/ui/input";
import {
Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Toggle } from "@/components/ui/toggle";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useAutosave } from "@/hooks/use-autosave";
import {
artifactSectionSchema,
constellationSectionSchema,
extraSectionSchema,
mainStatOptions,
substatOptions,
talentLevelOptions,
teamSectionSchema,
weaponSectionSchema,
} from "@/lib/guides/schemas";
import {
normalizeTargetStat,
targetStatDefaults,
targetStatOptions,
} from "@/lib/guides/stats";
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 = { 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 = { 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";
guide: Guide;
weapons: CatalogItem[];
artifacts: CatalogItem[];
characters: CatalogItem[];
catalogConstellations: CatalogConstellation[];
catalogTalents: CatalogTalent[];
initial: {
weapons: WeaponValue[];
artifactGroups: ArtifactValue[];
artifactProfile: ArtifactProfile;
constellations: ConstellationValue[];
teams: TeamValue[];
extras: Array<{ title: string; rows: Array<{ mediaId: string | null; heading: string; body: string; percent: string | null; imageUrl: string | null }> }>;
};
}
const SELECTED_TOGGLE_CLASS = "aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-primary/30";
function CatalogPicker({ items, value, onChange, placeholder, rarityBackground = false }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string; rarityBackground?: boolean }) {
const selected = items.find((item) => item.key === value) ?? null;
return (
<Combobox items={items} value={selected} onValueChange={(next) => onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} autoHighlight>
<ComboboxInput className="w-full" placeholder={placeholder} showClear />
<ComboboxContent>
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
<ComboboxList>
{(item) => <ComboboxItem key={item.key} value={item}><div className={cn("relative size-8 shrink-0 overflow-hidden rounded-md", rarityBackground && rarityGradientClass(item.rarity))}><Image src={item.imageUrl} alt="" fill className="object-contain" /></div>{item.name}</ComboboxItem>}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
function StatSelect({ label, options, value, max, onChange }: { label: string; options: readonly string[]; value: string[]; max: number; onChange: (value: string[]) => void }) {
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<Select multiple value={value} onValueChange={(next) => { if (next.length <= max) onChange(next); }}>
<SelectTrigger className="w-full"><SelectValue placeholder="Select recommended stats…" /></SelectTrigger>
<SelectContent><SelectGroup>{options.map((option) => <SelectItem key={option} value={option} disabled={value.length >= max && !value.includes(option)}>{option}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
<FieldDescription>เลือกได้สูงสุด {max} ค่า</FieldDescription>
</Field>
);
}
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>
);
}
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 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 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) {
return (
<div className="flex flex-col gap-6">
{props.section === "weapon" ? <WeaponEditor {...props} /> : null}
{props.section === "artifact" ? <ArtifactEditor {...props} /> : null}
{props.section === "constellations" ? <ConstellationEditor {...props} /> : null}
{props.section === "team" ? <TeamEditor {...props} /> : null}
{props.section === "extra" ? <ExtraEditor {...props} /> : null}
</div>
);
}
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 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>
<CardContent className="flex flex-col gap-4">
<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, { clientId: crypto.randomUUID(), weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "", note: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave} /></CardFooter>
</Card>
);
}
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 })) ?? [],
})));
const [sands, setSands] = useState(profile?.sands ?? []);
const [goblet, setGoblet] = useState(profile?.goblet ?? []);
const [circlet, setCirclet] = useState(profile?.circlet ?? []);
const [substats, setSubstats] = useState(profile?.substats ?? []);
const [targets, setTargets] = useState(() => {
const existing = new Map(
(profile?.targets ?? []).flatMap((target) => {
const stat = normalizeTargetStat(target.stat);
return stat ? [[stat, target.value] as const] : [];
}),
);
return targetStatDefaults.map((target) => ({
stat: target.stat,
value: existing.get(target.stat) ?? target.value,
}));
});
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) => ({ ...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.value.trim()).map((target) => ({ stat: target.stat, value: 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) => {
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>
<CardHeader><CardTitle>Artifact tier list</CardTitle><CardDescription>จัดชุด Artifact ในกลุ่ม Late Game และ Early Game ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
{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>
<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>
<div className="flex flex-col gap-3">
{targets.map((target, index) => (
<div key={`${target.stat}-${index}`} className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<Select value={target.stat} onValueChange={(stat) => { if (stat) setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat } : item)); }}>
<SelectTrigger className="w-full" aria-label={`Target stat ${index + 1}`}><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>{targetStatOptions.map((option) => <SelectItem key={option} value={option} disabled={targets.some((item, itemIndex) => itemIndex !== index && item.stat === option)}>{option}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
<Input value={target.value} placeholder="Value or text" aria-label={`${target.stat} value`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} />
</div>
))}
</div>
<FieldDescription>เลือกสเตตัสจากรายการ และกรอกค่าเป็นตัวเลข เปอร์เซ็นต์ หรือข้อความ เช่น -%</FieldDescription>
</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>
);
}
function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBase) {
const [rows, setRows] = useState(() => {
const source = catalogConstellations.length ? catalogConstellations : Array.from({ length: 6 }, (_, index) => ({ level: index + 1, name: `C${index + 1}`, description: initial.constellations.find((row) => row.level === index + 1)?.detail ?? "", iconUrl: null }));
return source.map((constellation) => {
const saved = initial.constellations.find((row) => row.level === constellation.level);
return { ...constellation, cumulativePercent: saved?.cumulativePercent ?? "", isGreat: saved?.isGreat ?? false };
});
});
const value = { rows: rows.filter((row) => row.description.trim() && row.cumulativePercent !== "").map((row) => ({ level: row.level, detail: row.description, cumulativePercent: Number(row.cumulativePercent), isGreat: row.isGreat })) };
const autosave = useAutosave({ value, valid: constellationSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveConstellationSection(guide.id, { ...next, expectedVersion }) });
return (
<Card>
<CardHeader><CardTitle>Constellations</CardTitle><CardDescription>ชื่อ รายละเอียด และไอคอนมาจาก Lunaris อัตโนมัติ กรอกเฉพาะเปอร์เซ็นต์เทียบกับ C0 = 100%</CardDescription></CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-2">
{rows.map((row, index) => <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" min="0" step="0.01" value={row.cumulativePercent} onChange={(event) => setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, cumulativePercent: event.target.value } : item))} /></Field><Toggle variant="outline" pressed={row.isGreat} onPressedChange={(isGreat) => setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, isGreat } : item))} aria-label={`Mark 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>)}
</CardContent>
<CardFooter className="justify-end"><AutosaveStatus state={autosave} /></CardFooter>
</Card>
);
}
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 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>ลากเพื่อเรียง Team แล้วใส่ความเสียหายของตัวละคร 4 ตัว ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent>
<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 (
<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">
<span className="font-semibold">{team.title || `Team ${teamIndex + 1}`}</span>
<span className="text-xs font-normal text-muted-foreground">Damage {total.toLocaleString()} • DPS {dps.toFixed(0)}</span>
</span>
</AccordionTrigger>
{dragHandle}
<AlertDialog>
<AlertDialogTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}>
<Trash2Icon /><span className="sr-only">ลบ Team</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ลบ {team.title || `Team ${teamIndex + 1}`}?</AlertDialogTitle>
<AlertDialogDescription>ข้อมูล Team และตัวละครทั้ง 4 คนจะถูกลบออกจาก Guide</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => setTeams(teams.filter((item) => item.clientId !== team.clientId))}>ยืนยันลบ Team</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<AccordionContent className="pb-4">
<FieldGroup>
<Field><FieldLabel className="flex items-center gap-2"><UserRoundIcon />ชื่อ Team</FieldLabel><Input value={team.title} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, title: event.target.value } : item))} /></Field>
<div className="grid gap-4 md:grid-cols-2">
{team.members.map((member, memberIndex) => {
const character = characters.find((item) => item.key === member.characterKey);
const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(character?.weaponType, weapon.weaponType));
return (
<Card key={memberIndex} size="sm">
<CardHeader>
<CardTitle>ตัวละคร {memberIndex + 1}</CardTitle>
<CardDescription>{total ? `${((Number(member.damage) || 0) / total * 100).toFixed(2)}% ของ Team` : "0% ของ Team"}</CardDescription>
{character ? <CardAction><Avatar size="lg"><AvatarImage src={character.imageUrl} alt={character.name} /><AvatarFallback>{character.name.slice(0, 2).toUpperCase()}</AvatarFallback></Avatar></CardAction> : null}
</CardHeader>
<CardContent>
<FieldGroup>
<Field><FieldLabel className="flex items-center gap-2"><UserRoundIcon />ตัวละคร</FieldLabel><CatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาชื่อตัวละคร…" onChange={(characterKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1 } : candidate) } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><ChartNoAxesCombinedIcon />Damage</FieldLabel><Input type="number" min="0" step="0.01" value={member.damage} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, damage: event.target.value } : candidate) } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><SwordIcon />Weapon</FieldLabel><CatalogPicker items={compatibleWeapons} value={member.weaponKey ?? ""} placeholder="ค้นหาอาวุธที่รองรับ…" rarityBackground onChange={(weaponKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, weaponKey: weaponKey || null } : candidate) } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><RefreshCcwIcon />Refinement</FieldLabel><ToggleGroup className="flex-wrap justify-start" variant="outline" value={[`R${member.refinement}`]} onValueChange={(selected) => { const refinement = selected[0]; if (refinement) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, refinement: Number(refinement.slice(1)) } : candidate) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field>
<Field><FieldLabel className="flex items-center gap-2"><GemIcon />Artifact set</FieldLabel><CatalogPicker items={artifacts} value={member.artifactKey ?? ""} placeholder="ค้นหาชุด Artifact…" rarityBackground onChange={(artifactKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, artifactKey: artifactKey || null } : candidate) } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><SparklesIcon />Constellation</FieldLabel><ToggleGroup className="flex-wrap justify-start" variant="outline" value={[`C${member.constellation}`]} onValueChange={(selected) => { const constellation = selected[0]; if (constellation) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, constellation: Number(constellation.slice(1)) } : candidate) } : item)); }}>{[0, 1, 2, 3, 4, 5, 6].map((constellation) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={constellation} value={`C${constellation}`}>C{constellation}</ToggleGroupItem>)}</ToggleGroup></Field>
</FieldGroup>
</CardContent>
</Card>
);
})}
</div>
<Field><FieldLabel className="flex items-center gap-2"><TimerIcon />Rotation (seconds)</FieldLabel><Input type="number" min="0.01" step="0.01" value={team.rotationSeconds} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, rotationSeconds: event.target.value } : item))} /></Field>
<Field><FieldLabel className="flex items-center gap-2"><ListOrderedIcon />Combo</FieldLabel><Textarea value={team.combo} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, combo: event.target.value } : item))} /></Field>
</FieldGroup>
</AccordionContent>
</>}</SortableTeamItem>
);
})}
</Accordion>
</SortableContext>
</DndContext>
</CardContent>
<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>
);
}
function ExtraEditor({ guide, initial }: EditorBase) {
const [sections, setSections] = useState(initial.extras);
const value = { sections: sections.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent === null ? null : Number(row.percent) })) })) };
const autosave = useAutosave({ value, valid: extraSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveExtraSectionList(guide.id, { ...next, expectedVersion }) });
return (
<Card>
<CardHeader><CardTitle>Extra sections</CardTitle><CardDescription>เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์ ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-5">
{sections.map((section, sectionIndex) => <Card key={sectionIndex} size="sm"><CardHeader><CardTitle><Input value={section.title} aria-label="ชื่อ Extra section" onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, title: event.target.value } : item))} /></CardTitle><CardAction><Button variant="ghost" size="icon-sm" onClick={() => setSections(sections.filter((_, index) => index !== sectionIndex))}><Trash2Icon /><span className="sr-only">ลบส่วน</span></Button></CardAction></CardHeader><CardContent className="flex flex-col gap-4">{section.rows.map((row, rowIndex) => <Card key={rowIndex} size="sm"><CardHeader><CardTitle>{row.heading || `แถว ${rowIndex + 1}`}</CardTitle><CardAction><Button variant="ghost" size="icon-sm" onClick={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}><Trash2Icon /><span className="sr-only">ลบแถว</span></Button></CardAction></CardHeader><CardContent><FieldGroup>{row.imageUrl ? <div className="relative aspect-video overflow-hidden rounded-lg border"><Image src={row.imageUrl} alt={row.heading} fill className="object-cover" /></div> : null}<MediaUploader onReady={(asset) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, mediaId: asset.id, imageUrl: asset.url } : candidate) } : item))} /><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" min="0" 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></FieldGroup></CardContent></Card>)}<Button variant="outline" onClick={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: [...item.rows, { mediaId: null, imageUrl: null, heading: "", body: "", percent: null }] } : item))}><PlusIcon data-icon="inline-start" />เพิ่มแถว</Button></CardContent></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} /></CardFooter>
</Card>
);
}