Files
buzz-sheet/components/admin/guide-section-editor.tsx
T
gunshiz 6003f408bd
CI / Verify (push) Successful in 1m0s
CI / Build immutable images and deploy (push) Successful in 1m43s
fix : fix
2026-08-30 11:51:01 +00:00

388 lines
36 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 { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon, PlusIcon, SparklesIcon, 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,
saveConstellationSection,
saveExtraSectionList,
saveTeamSection,
saveWeaponSection,
} from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { MediaUploader } from "@/components/admin/media-uploader";
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 { 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: 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 }> };
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 DEFAULT_TARGET_STATS = ["ATK", "EM", "ER", "CRIT Rate", "CRIT DMG"] as const;
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 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 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;
}
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 [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 autosave = useAutosave({ value, valid: weaponSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveWeaponSection(guide.id, { ...next, expectedVersion }) });
return (
<Card>
<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>; })}
</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>
</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 = profile?.targets ?? [];
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<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) })) })), 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) => {
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="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>)}
</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>
{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>
);
}
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="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 [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 autosave = useAutosave({ value, valid: teamSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveTeamSection(guide.id, { ...next, expectedVersion }) });
return (
<Card>
<CardHeader><CardTitle>Team Calc</CardTitle><CardDescription>ใส่ความเสียหายของตัวละคร 4 ตัว ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-5">
{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 <Card key={teamIndex} size="sm"><CardHeader><CardTitle>Team {teamIndex + 1}</CardTitle><CardDescription>Damage {total.toLocaleString()} • DPS {dps.toFixed(0)}</CardDescription><CardAction><RowActions index={teamIndex} length={teams.length} move={(from, to) => setTeams(moveItem(teams, from, to))} remove={() => setTeams(teams.filter((_, index) => index !== teamIndex))} /></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>ชื่อ 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>ตัวละคร</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>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>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>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>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>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>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>Combo</FieldLabel><Textarea value={team.combo} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, combo: event.target.value } : item))} /></Field></FieldGroup></CardContent></Card>; })}
</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>
</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>
);
}