507 lines
23 KiB
TypeScript
507 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import Image from "@/components/ui/resilient-image";
|
|
import { GripVerticalIcon, PlusIcon, Trash2Icon } 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 { saveWeaponSection } from "@/app/admin/actions";
|
|
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
|
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
|
|
import { WeaponSelect } from "@/components/admin/ui/catalog-select";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
|
import { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
import { useAutosave } from "@/hooks/use-autosave";
|
|
import { adminDraftKey } from "@/lib/editor/local-draft";
|
|
import { weaponSectionSchema } from "@/lib/guides/schemas";
|
|
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
|
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
|
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, SELECTED_TOGGLE_CLASS } from "./shared";
|
|
|
|
|
|
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="ghost"
|
|
size="icon-sm"
|
|
className="touch-none"
|
|
{...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 autosave = useAutosave({
|
|
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>Weapon tier list</CardTitle>
|
|
<CardDescription>
|
|
ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง
|
|
ระบบจะบันทึกอัตโนมัติ
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
<Field>
|
|
<FieldLabel htmlFor="weapon-section-note">
|
|
Header note (optional)
|
|
</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}`}>
|
|
Condition note (optional)
|
|
</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) => (
|
|
<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
|
|
sizes="40px"
|
|
className="object-contain"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<span>อันดับ {rowIndex + 1}</span>
|
|
</CardTitle>
|
|
<CardAction>
|
|
<div className="flex gap-1">
|
|
{dragHandle}
|
|
<DeleteConfirmationDialog
|
|
trigger={
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
/>
|
|
}
|
|
title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`}
|
|
description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide"
|
|
onConfirm={() =>
|
|
setRows(
|
|
rows.filter(
|
|
(item) => item.clientId !== row.clientId,
|
|
),
|
|
)
|
|
}
|
|
>
|
|
<Trash2Icon />
|
|
<span className="sr-only">ลบอาวุธ</span>
|
|
</DeleteConfirmationDialog>
|
|
</div>
|
|
</CardAction>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FieldGroup>
|
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4">
|
|
<Field className="min-w-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}>
|
|
<FieldLabel>อาวุธ</FieldLabel>
|
|
<WeaponSelect
|
|
items={compatibleWeapons}
|
|
value={row.weaponKey}
|
|
invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}
|
|
placeholder="ค้นหาชื่ออาวุธ…"
|
|
rarityBackground
|
|
onValueChange={(weaponKey) =>
|
|
setRows(
|
|
rows.map((item) =>
|
|
item.clientId === row.clientId
|
|
? { ...item, weaponKey }
|
|
: item,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field className="shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
|
|
<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: next.slice(1),
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
}}
|
|
>
|
|
{["1", "2", "3", "4", "5", "1-5"].map((refinement) => (
|
|
<ToggleGroupItem
|
|
className={SELECTED_TOGGLE_CLASS}
|
|
key={refinement}
|
|
value={`R${refinement}`}
|
|
>
|
|
R{refinement}
|
|
</ToggleGroupItem>
|
|
))}
|
|
</ToggleGroup>
|
|
</Field>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
|
|
<FieldLabel>Overall %</FieldLabel>
|
|
<Input
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={row.overallPercent}
|
|
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
|
|
onChange={(event) =>
|
|
setRows(
|
|
rows.map((item) =>
|
|
item.clientId === row.clientId
|
|
? {
|
|
...item,
|
|
overallPercent:
|
|
normalizePercentInput(
|
|
event.target.value,
|
|
item.overallPercent,
|
|
),
|
|
}
|
|
: item,
|
|
),
|
|
)
|
|
}
|
|
onBlur={() =>
|
|
setRows(
|
|
rows.map((item) =>
|
|
item.clientId === row.clientId
|
|
? {
|
|
...item,
|
|
overallPercent:
|
|
finalizePercentInput(
|
|
item.overallPercent,
|
|
),
|
|
}
|
|
: item,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
|
|
<FieldLabel>Personal %</FieldLabel>
|
|
<Input
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={row.personalPercent}
|
|
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
|
|
onChange={(event) =>
|
|
setRows(
|
|
rows.map((item) =>
|
|
item.clientId === row.clientId
|
|
? {
|
|
...item,
|
|
personalPercent:
|
|
normalizePercentInput(
|
|
event.target.value,
|
|
item.personalPercent,
|
|
),
|
|
}
|
|
: item,
|
|
),
|
|
)
|
|
}
|
|
onBlur={() =>
|
|
setRows(
|
|
rows.map((item) =>
|
|
item.clientId === row.clientId
|
|
? {
|
|
...item,
|
|
personalPercent:
|
|
finalizePercentInput(
|
|
item.personalPercent,
|
|
),
|
|
}
|
|
: item,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field>
|
|
<FieldLabel
|
|
htmlFor={`weapon-note-${row.clientId}`}
|
|
>
|
|
Note (optional)
|
|
</FieldLabel>
|
|
<Textarea
|
|
id={`weapon-note-${row.clientId}`}
|
|
value={row.note}
|
|
onChange={(event) =>
|
|
setRows(
|
|
rows.map((item) =>
|
|
item.clientId === row.clientId
|
|
? { ...item, note: event.target.value }
|
|
: item,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
</FieldGroup>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</SortableWeaponCard>
|
|
);
|
|
})}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</CardContent>
|
|
<CardFooter className="flex-wrap justify-between gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() =>
|
|
setRows([
|
|
...rows,
|
|
{
|
|
clientId: createClientId("weapon"),
|
|
weaponKey: "",
|
|
refinement: "1",
|
|
overallPercent: "",
|
|
personalPercent: "",
|
|
note: "",
|
|
},
|
|
])
|
|
}
|
|
>
|
|
<PlusIcon data-icon="inline-start" />
|
|
เพิ่มอาวุธ
|
|
</Button>
|
|
<AutosaveStatus
|
|
state={autosave.state}
|
|
conflictVersion={autosave.conflictVersion}
|
|
canOverwrite={autosave.canOverwrite}
|
|
onReloadLatest={autosave.reloadLatest}
|
|
onOverwrite={autosave.overwrite}
|
|
issues={
|
|
validation.success
|
|
? []
|
|
: formatGuideValidationIssues("weapon", validation.error.issues)
|
|
}
|
|
onSave={autosave.saveNow}
|
|
/>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
}
|