Files
gunshiz 9580407a90
CI / Verify (push) Successful in 1m56s
CI / Build immutable images and deploy (push) Successful in 3m12s
perf : cache
2026-10-08 23:21:34 +07:00

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>
);
}