Files
buzz-sheet/components/admin/editors/weapon-editor.tsx
T

437 lines
20 KiB
TypeScript

"use client";
import { GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useState, type ComponentProps, 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 { saveWeaponSection } from "@/app/admin/actions";
import { SaveStatus } from "@/components/admin/save-status";
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
import { WeaponSelect } from "@/components/catalog-select";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { WeaponComparisonCard, weaponComparisonPercent } from "@/components/weapon-comparison-card";
import { rarityColor } from "@/lib/catalog/rarity";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useManualSave } from "@/hooks/use-manual-save";
import { adminDraftKey } from "@/lib/editor/local-draft";
import { weaponSectionSchema } from "@/lib/guides/schemas";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input";
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
import { cn } from "@/lib/utils";
import { type EditorBase, hasIssuePrefix, parseEditorDraft, ConditionControls, reorderConditions, createClientId, ConditionRenameDialog } from "./shared";
function WeaponDamageInput({ value, onFocus, onBlur, ...props }: ComponentProps<typeof Input> & { value: string }) {
const [focused, setFocused] = useState(false);
const numeric = Number(value);
const displayValue = !value.trim() || !Number.isFinite(numeric)
? value
: weaponComparisonPercent(value) ?? "";
return <Input
{...props}
value={focused ? value : displayValue}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
/>;
}
export 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="plain"
size="icon-sm"
className="touch-none cursor-grab active:cursor-grabbing"
{...attributes}
{...listeners}
>
<GripVerticalIcon />
<span className="sr-only">ลากเพื่อจัดลำดับอาวุธ</span>
</Button>,
)}
</div>
);
}
export 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 [note, setNote] = useState(initial.sectionNote ?? "");
const [groups, setGroups] = useState(() => {
const source = initial.weaponGroups.length
? initial.weaponGroups
: [{ id: "weapon-condition-default", name: null, note: null, iconMediaId: null, iconUrl: null, rows: [] }];
return source.map((group) => ({
clientId: group.id,
name: group.name ?? "",
note: group.note ?? "",
iconMediaId: group.iconMediaId ?? null,
iconUrl: group.iconUrl ?? null,
rows: group.rows.map((row) => ({
clientId: row.id,
weaponKey: row.weaponKey,
refinement: row.refinement,
overallPercent: row.overallPercent ?? "",
personalPercent: row.personalPercent ?? "",
note: row.note ?? "",
})),
}));
});
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const [renamingWeaponGroupIndex, setRenamingWeaponGroupIndex] = useState<number | null>(null);
const rows = groups[activeGroupIndex]?.rows ?? [];
const setRows = (update: typeof rows | ((current: typeof rows) => typeof rows)) =>
setGroups((current) => current.map((group, index) => index === activeGroupIndex
? { ...group, rows: typeof update === "function" ? update(group.rows) : update }
: group));
const value = {
note: note.trim() || null,
groups: groups.map((group) => ({
name: group.name.trim() || null,
note: group.note.trim() || null,
iconMediaId: group.iconMediaId,
rows: group.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 validation = weaponSectionSchema.safeParse({
...value,
expectedVersion: guide.version,
});
const weaponIssue = (...path: (string | number)[]) =>
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
const localDraft = { note, groups, activeGroupIndex };
const manualSave = useManualSave({
guideId: guide.id,
scope: "weapon",
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion, intent) =>
saveWeaponSection(guide.id, { ...next, expectedVersion }, intent),
storageKey: adminDraftKey(guide.id, "weapon"),
localDraft,
parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(candidate, ["groups"], ["note"]),
restoreLocalDraft: (draft) => {
setNote(draft.note);
setGroups(draft.groups.map((group) => ({ ...group, name: group.name ?? "" })));
setActiveGroupIndex(draft.activeGroupIndex ?? 0);
},
});
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>จัดอันดับอาวุธ</CardTitle>
<CardDescription>
ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา กรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่องต่ออาวุธ
โดย 100% คือค่าฐานในการเปรียบเทียบ แล้วกดบันทึกเพื่อบันทึกการเปลี่ยนแปลง
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="weapon-section-note">
หมายเหตุส่วนอาวุธ (ไม่บังคับ)
</FieldLabel>
<Textarea
id="weapon-section-note"
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</Field>
<ConditionControls
ariaLabel="เลือกเงื่อนไขอาวุธ"
items={groups}
activeIndex={activeGroupIndex}
onActiveIndexChange={setActiveGroupIndex}
onRename={setRenamingWeaponGroupIndex}
onReorder={(from, to) => {
const reordered = reorderConditions(groups, activeGroupIndex, from, to);
setGroups(reordered.items);
setActiveGroupIndex(reordered.activeIndex);
}}
onAdd={(details) => {
setGroups([...groups, { clientId: createClientId("weapon-condition"), ...details, note: "", rows: [] }]);
setActiveGroupIndex(groups.length);
}}
onDuplicate={() => {
const source = groups[activeGroupIndex];
if (!source) return;
setGroups([
...groups,
{
clientId: createClientId("weapon-condition"),
name: source.name,
note: source.note,
iconMediaId: source.iconMediaId,
iconUrl: source.iconUrl,
rows: source.rows.map((row) => ({
...row,
clientId: createClientId("weapon"),
})),
},
]);
setActiveGroupIndex(groups.length);
}}
onDelete={() => {
const next = groups.filter((_, index) => index !== activeGroupIndex);
setGroups(next);
setActiveGroupIndex(Math.min(activeGroupIndex, next.length - 1));
}}
/>
<ConditionRenameDialog
open={renamingWeaponGroupIndex !== null}
fallback={`Condition ${(renamingWeaponGroupIndex ?? 0) + 1}`}
value={renamingWeaponGroupIndex === null ? "" : groups[renamingWeaponGroupIndex]?.name ?? ""}
iconUrl={renamingWeaponGroupIndex === null ? null : groups[renamingWeaponGroupIndex]?.iconUrl ?? null}
onOpenChange={(open) => { if (!open) setRenamingWeaponGroupIndex(null); }}
onChange={(name) => setGroups(groups.map((group, index) => index === renamingWeaponGroupIndex ? { ...group, name } : group))}
onIconChange={(icon) => setGroups(groups.map((group, index) => index === renamingWeaponGroupIndex ? { ...group, ...icon } : group))}
/>
<Field>
<FieldLabel htmlFor={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}>
หมายเหตุเงื่อนไข (ไม่บังคับ)
</FieldLabel>
<Textarea
id={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}
value={groups[activeGroupIndex]?.note ?? ""}
onChange={(event) => setGroups(groups.map((group, index) => index === activeGroupIndex ? { ...group, note: event.target.value } : group))}
/>
</Field>
<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) => (
<WeaponComparisonCard
weapon={selectedWeapon ?? { key: row.weaponKey, name: "เลือกอาวุธ", imageUrl: null, rarity: 1 }}
rank={rowIndex + 1}
refinement={row.refinement}
note={row.note}
overallPercent={row.overallPercent}
personalPercent={row.personalPercent}
imageControl={
<Field className="w-20 shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}>
<WeaponSelect
presentation="slot"
className="size-20 border-0 bg-transparent"
label={`เลือกอาวุธอันดับ ${rowIndex + 1}`}
items={compatibleWeapons}
value={row.weaponKey}
invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}
placeholder="ค้นหาชื่ออาวุธ…"
rarityBackground={false}
onValueChange={(weaponKey) => setRows((current) => current.map((item) =>
item.clientId === row.clientId ? { ...item, weaponKey } : item,
))}
/>
</Field>
}
refinementControl={
<span className="inline-flex shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "refinement")}>
<Select
value={row.refinement}
onValueChange={(refinement) => {
if (refinement) setRows((current) => current.map((item) =>
item.clientId === row.clientId ? { ...item, refinement } : item,
));
}}
>
<SelectTrigger
className="border-white/30 text-white hover:border-white/60 [&_svg]:text-white"
style={{ backgroundColor: rarityColor(selectedWeapon?.rarity ?? 1) }}
size="sm" aria-label={`ระดับขัดเกลาอาวุธอันดับ ${rowIndex + 1}`} aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "refinement")}>
<SelectValue>{`R${row.refinement}`}</SelectValue>
</SelectTrigger>
<SelectContent
className="text-white [--color-accent:rgba(255,255,255,0.2)] [--color-accent-foreground:white]"
style={{ backgroundColor: rarityColor(selectedWeapon?.rarity ?? 1) }}
>
<SelectGroup>
{["1", "2", "3", "4", "5", "1-5"].map((refinement) => (
<SelectItem key={refinement} value={refinement}>R{refinement}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</span>
}
noteControl={
<Field>
<FieldLabel className="sr-only" htmlFor={`weapon-note-${row.clientId}`}>หมายเหตุอาวุธอันดับ {rowIndex + 1}</FieldLabel>
<Textarea
id={`weapon-note-${row.clientId}`}
rows={1}
className="min-h-8 bg-white/10! text-white placeholder:text-white/70"
placeholder="หมายเหตุอาวุธ (ไม่บังคับ)"
value={row.note}
onChange={(event) => setRows((current) => current.map((item) =>
item.clientId === row.clientId ? { ...item, note: event.target.value } : item,
))}
/>
</Field>
}
actions={
<div className="absolute top-2 right-2 flex gap-1">
{dragHandle}
<DeleteConfirmationDialog
trigger={
<Button
type="button"
variant="plain"
size="icon-sm"
/>
}
title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`}
description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide"
onConfirm={() =>
setRows(
rows.filter(
(item) => item.clientId !== row.clientId,
),
)
}
>
<Trash2Icon />
<span className="sr-only">ลบอาวุธ</span>
</DeleteConfirmationDialog>
</div>
}
renderMetric={(metric) => <WeaponDamageInput
className="h-[1em] rounded-none border-0 bg-transparent! p-0 text-center text-[length:inherit]! leading-none font-bold focus-visible:ring-2"
id={`weapon-${metric === "overallPercent" ? "overall" : "personal"}-${row.clientId}`}
aria-label={metric === "overallPercent" ? "ดาเมจทั้งทีม (%)" : "ดาเมจส่วนตัว (%)"}
type="text"
inputMode="decimal"
value={row[metric]}
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
onChange={(event) => setRows((current) => current.map((item) =>
item.clientId === row.clientId
? { ...item, [metric]: normalizePercentInput(event.target.value, item[metric]) }
: item,
))}
onBlur={() => setRows((current) => current.map((item) =>
item.clientId === row.clientId
? { ...item, [metric]: finalizePercentInput(item[metric]) }
: item,
))}
/>}
/>
)}
</SortableWeaponCard>
);
})}
</div>
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
setRows([
...rows,
{
clientId: createClientId("weapon"),
weaponKey: "",
refinement: "1",
overallPercent: "",
personalPercent: "",
note: "",
},
])
}
>
<PlusIcon data-icon="inline-start" />
เพิ่มอาวุธ
</Button>
<SaveStatus
state={manualSave.state}
conflictVersion={manualSave.conflictVersion}
canOverwrite={manualSave.canOverwrite}
onReloadLatest={manualSave.reloadLatest}
onOverwrite={manualSave.overwrite}
issues={
validation.success
? []
: formatGuideValidationIssues("weapon", validation.error.issues)
}
onSave={manualSave.saveNow}
canSave={manualSave.canSave}
/>
</CardFooter>
</Card>
);
}