"use client"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState, useTransition, type ReactNode } from "react"; import { GripVerticalIcon, PlusIcon, RefreshCwIcon, Trash2Icon, } from "lucide-react"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { toast } from "sonner"; import { saveStygianRecommendationsAction, syncStygianArchiveAction, } from "@/app/admin/stygian/actions"; import { AutosaveStatus } from "@/components/admin/autosave-status"; import { ArtifactSelect, CatalogDropdown, CharacterSelect, WeaponSelect, } from "@/components/admin/ui/catalog-select"; import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog"; import { StygianBossCard, StygianBossTab, type StygianBossDisplay, } from "@/components/stygian/stygian-boss-display"; import { AdminDropdown, ConstellationSelect, ElementSelect, RefinementSelect, } from "@/components/admin/ui/option-select"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Field, FieldGroup, FieldLabel, FieldSet, FieldLegend, } from "@/components/ui/field"; import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger, } from "@/components/ui/popover"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Spinner } from "@/components/ui/spinner"; import { elementIconFilter } from "@/lib/catalog/element"; import { AutosaveCoordinator, type AutosaveStatus as CoordinatorStatus } from "@/lib/editor/autosave"; import { supportsWeaponType } from "@/lib/catalog/lunaris"; import { stygianElements, type StygianRecommendationItemInput, } from "@/lib/stygian/schema"; import { cn } from "@/lib/utils"; type CatalogItem = { key: string; name: string; aliases: string[]; imageUrl: string; rarity: number | null; weaponType?: string | null; }; type Character = CatalogItem & { element: string | null; elementIconUrl: string | null; }; type ScheduleSummary = { scheduleId: number; challengeName: string }; type Boss = StygianBossDisplay & { level: number; position: number }; type StoredItem = { kind: string; catalogKey: string | null; weaponKey: string | null; artifactKey: string | null; refinement: number | null; constellationLevel: number | null; element: string | null; }; type Recommendation = { id: string; level: number; bossPosition: number; kind: string; items: StoredItem[]; }; function toInput(item: StoredItem): StygianRecommendationItemInput | null { if ( (item.kind === "character" || item.kind === "weapon" || item.kind === "artifact") && item.catalogKey ) { return item.kind === "character" ? { kind: "character", catalogKey: item.catalogKey, weaponKey: item.weaponKey, artifactKey: item.artifactKey, constellationLevel: item.constellationLevel, refinement: item.refinement, } : { kind: item.kind, catalogKey: item.catalogKey }; } if ( item.kind === "element" && item.element && stygianElements.includes(item.element as (typeof stygianElements)[number]) ) { return { kind: "element", element: item.element as (typeof stygianElements)[number], }; } return null; } function ItemPicker({ characters, weapons, artifacts, onAdd, }: { characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; onAdd: (item: StygianRecommendationItemInput) => void; }) { const [kind, setKind] = useState("character"); const catalog = kind === "character" ? characters : kind === "weapon" ? weapons : artifacts; return ( Type setKind(next as typeof kind)} triggerProps={{ className: "w-full" }} options={[ { value: "character", label: "Character" }, { value: "element", label: "Element" }, { value: "weapon", label: "Weapon" }, { value: "artifact", label: "Artifact" }, ]} /> {kind === "element" ? ( Element character.element && character.elementIconUrl ? [[character.element, character.elementIconUrl]] : [], ), )} placeholder="Choose element…" onValueChange={(next) => onAdd({ kind: "element", element: next as (typeof stygianElements)[number], }) } triggerProps={{ className: "w-full" }} /> ) : null} {kind !== "element" ? ( Recommendation { if (!catalogKey) return; if (kind === "character") onAdd({ kind, catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null, refinement: null, }); else if (kind === "weapon") onAdd({ kind, catalogKey }); else onAdd({ kind: "artifact", catalogKey }); }} placeholder={`Search ${kind}…`} emptyLabel="No matches" rarityBackground characterBackground={kind === "character"} /> ) : null} ); } function AddRecommendationSlot({ characters, weapons, artifacts, onAdd, }: { characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; onAdd: (item: StygianRecommendationItemInput) => void; }) { const [open, setOpen] = useState(false); return ( } > Add recommendation Add recommendation Selecting an item adds it immediately. { onAdd(item); setOpen(false); }} /> ); } function AddCharacterSlot({ characters, onAdd, }: { characters: Character[]; onAdd: (item: StygianRecommendationItemInput) => void; }) { return ( { if (catalogKey) onAdd({ kind: "character", catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null, refinement: null, }); }} /> ); } function VisualRecommendation({ item, onChange, onRemove, characters, weapons, artifacts, teamLayout = false, dragHandle, }: { item: StygianRecommendationItemInput; onChange: (item: StygianRecommendationItemInput) => void; onRemove: () => void; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; teamLayout?: boolean; dragHandle?: ReactNode; }) { const character = item.kind === "character" ? characters.find((entry) => entry.key === item.catalogKey) : null; const weapon = item.kind === "character" ? weapons.find((entry) => entry.key === item.weaponKey) : null; const artifact = item.kind === "character" ? artifacts.find((entry) => entry.key === item.artifactKey) : null; const compatibleWeapons = weapons.filter((entry) => supportsWeaponType(character?.weaponType, entry.weaponType), ); const elementSource = item.kind === "element" ? characters.find( (entry) => entry.element === item.element && entry.elementIconUrl, ) : null; const elementIconUrls = Object.fromEntries( characters.flatMap((entry) => entry.element && entry.elementIconUrl ? [[entry.element, entry.elementIconUrl]] : [], ), ); const updateCharacter = ( patch: Partial< Extract >, ) => { if (item.kind === "character") onChange({ ...item, ...patch }); }; return (
{dragHandle ? (
{dragHandle}
) : null} {character ? ( <>
img]:p-0", teamLayout && "col-span-2 row-span-2", )} onValueChange={(catalogKey) => { if (catalogKey) updateCharacter({ catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null, refinement: null, }); }} /> {teamLayout ? (
updateCharacter({ weaponKey: weaponKey || null, refinement: weaponKey ? item.kind === "character" ? (item.refinement ?? 1) : 1 : null, }) } /> updateCharacter({ artifactKey: artifactKey || null }) } />
) : (
updateCharacter({ artifactKey: artifactKey || null }) } /> updateCharacter({ weaponKey: weaponKey || null }) } />
)}
{teamLayout && item.kind === "character" ? (
updateCharacter({ constellationLevel: next === "none" ? null : Number(next), }) } triggerProps={{ size: "sm", className: "h-7 min-w-0 justify-center px-1 text-xs", }} /> updateCharacter({ refinement: next === "none" ? null : Number(next), }) } triggerProps={{ size: "sm", className: "h-7 min-w-0 justify-center px-1 text-xs", }} />
) : (
{character.name} {item.kind === "character" && item.constellationLevel !== null ? ( C{item.constellationLevel} ) : null}
)} ) : (
{item.kind === "weapon" ? ( <> { if (catalogKey) onChange({ ...item, catalogKey }); }} /> {weapons.find((entry) => entry.key === item.catalogKey)?.name ?? item.catalogKey} ) : null} {item.kind === "artifact" ? ( <> { if (catalogKey) onChange({ ...item, catalogKey }); }} /> {artifacts.find((entry) => entry.key === item.catalogKey) ?.name ?? item.catalogKey} ) : null} {item.kind === "element" ? ( <> {elementSource?.elementIconUrl ? (
{item.element}
) : null} onChange({ ...item, element: element as (typeof stygianElements)[number], }) } triggerProps={{ size: "sm", className: "h-7 w-full justify-center px-1 text-xs", }} /> ) : null}
)}
); } function SortableRecommendation({ id, children, }: { id: string; children: (dragHandle: ReactNode) => ReactNode; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id }); return (
{children( , )}
); } function VisualRecommendationGrid({ items, limit, onChange, characters, weapons, artifacts, teamLayout = false, characterOnly = false, }: { items: StygianRecommendationItemInput[]; limit?: number; onChange: (items: StygianRecommendationItemInput[]) => void; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; teamLayout?: boolean; characterOnly?: boolean; }) { const emptyCount = limit ? Math.max(0, limit - items.length) : 1; const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const sortableIds = items.map((item, index) => item.kind === "character" ? `character:${item.catalogKey}:${index}` : `${item.kind}:${index}`, ); const cards = (
{items.map((item, index) => { const card = (dragHandle?: ReactNode) => ( onChange( items.map((entry, candidate) => candidate === index ? next : entry, ), ) } onRemove={() => onChange(items.filter((_, candidate) => candidate !== index)) } /> ); return ( {card} ); })} {Array.from({ length: emptyCount }, (_, index) => teamLayout || characterOnly ? ( onChange([...items, item])} /> ) : ( onChange([...items, item])} /> ), )}
); const handleDragEnd = ({ active, over }: DragEndEvent) => { if (!over || active.id === over.id) return; const from = sortableIds.indexOf(String(active.id)); const to = sortableIds.indexOf(String(over.id)); if (from >= 0 && to >= 0) onChange(arrayMove(items, from, to)); }; return ( {cards} ); } function SortableTeam({ id, position, children, }: { id: string; position: number; children: ReactNode; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id, animateLayoutChanges: () => false }); return (
Team {position + 1}
{children}
); } function BossRecommendationEditor({ scheduleId, version, level, boss, recommendations, characters, weapons, artifacts, onVersionChange, }: { scheduleId: number; version: number; level: 105 | 110; boss: Boss; recommendations: Recommendation[]; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; onVersionChange: (version: number) => void; }) { const groups = recommendations.filter( (group) => group.level === level && group.bossPosition === boss.position, ); const [teams, setTeams] = useState<{ id: string; items: StygianRecommendationItemInput[] }[]>(() => groups .filter((group) => group.kind === "team") .map((group) => ({ id: group.id, items: group.items.flatMap((item) => toInput(item) ?? []) })), ); const [units, setUnits] = useState(() => groups .filter((group) => group.kind === "unit") .flatMap((group) => group.items.flatMap((item) => toInput(item) ?? [])), ); const teamSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const teamIds = useMemo(() => teams.map((team) => team.id), [teams]); const fingerprint = JSON.stringify({ teams: teams.map((team) => team.items), units }); const valid = teams.every( (team) => team.items.length >= 1 && team.items.length <= 4 && team.items.every((item) => item.kind === "character"), ); const [savedFingerprint, setSavedFingerprint] = useState(fingerprint); const savedFingerprintRef = useRef(fingerprint); const latestDraftRef = useRef({ fingerprint, valid }); const [saveStatus, setSaveStatus] = useState({ state: "idle" }); const initialVersionRef = useRef(version); const coordinatorRef = useRef | null>(null); const saveState = !valid ? "incomplete" : saveStatus.state === "saving" ? "saving" : fingerprint === savedFingerprint ? "saved" : saveStatus.state === "error" ? "error" : "unsaved"; useEffect(() => { const coordinator = new AutosaveCoordinator({ initialVersion: initialVersionRef.current, debounceMs: 2_500, maxRetries: 0, createPayload: (draft: string, expectedVersion) => ({ fingerprint: draft, expectedVersion, ...JSON.parse(draft) as { teams: StygianRecommendationItemInput[][]; units: StygianRecommendationItemInput[] }, }), save: async ({ fingerprint: draft, ...payload }) => { const result = await saveStygianRecommendationsAction(scheduleId, { ...payload, level, bossPosition: boss.position, }); return result.status === "saved" ? { status: "saved" as const, version: result.version, snapshot: draft } : { status: "transient-error" as const, message: result.message }; }, onStatus: (status) => { setSaveStatus(status); if (status.state === "error") toast.error(status.message); }, onSaved: (draft, savedVersion) => { if (draft) { savedFingerprintRef.current = draft; setSavedFingerprint(draft); const latest = latestDraftRef.current; if (latest.valid && latest.fingerprint !== draft) coordinator.schedule(latest.fingerprint); } onVersionChange(savedVersion); }, }); coordinatorRef.current = coordinator; return () => { coordinator.dispose(); coordinatorRef.current = null; }; }, [boss.position, level, onVersionChange, scheduleId]); useEffect(() => { const coordinator = coordinatorRef.current; if (!coordinator) return; coordinator.updateVersion(version); }, [version]); useEffect(() => { const coordinator = coordinatorRef.current; if (!coordinator) return; latestDraftRef.current = { fingerprint, valid }; if (!valid || fingerprint === savedFingerprintRef.current) coordinator.cancelPending(); else coordinator.schedule(fingerprint); }, [fingerprint, valid]); return (
Recommended team { if (!over || active.id === over.id) return; setTeams((current) => { const from = current.findIndex((team) => team.id === active.id); const to = current.findIndex((team) => team.id === over.id); return from === -1 || to === -1 ? current : arrayMove(current, from, to); }); }} >
{teams.map((team, teamIndex) => ( setTeams((current) => current .map((entry) => entry.id === team.id ? { ...entry, items } : entry) .filter((entry) => entry.items.length), ) } /> } type="Team" description="This team and all of its character recommendations will be removed." confirmLabel="Remove team" onConfirm={() => setTeams((current) => current.filter((entry) => entry.id !== team.id)) } > Remove team ))}
Recommended unit
void coordinatorRef.current?.flush()} />
); } export function StygianEditor({ schedules, schedule, bosses, recommendations, characters, weapons, artifacts, }: { schedules: ScheduleSummary[]; schedule: ScheduleSummary & { version: number }; bosses: Boss[]; recommendations: Recommendation[]; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; }) { const router = useRouter(); const [version, setVersion] = useState(schedule.version); const [level, setLevel] = useState<105 | 110>(110); const [bossPosition, setBossPosition] = useState(0); const [pending, startTransition] = useTransition(); return (
setLevel(Number(next) as 105 | 110)} > Level 105 Level 110 {([105, 110] as const).map((entry) => ( setBossPosition(Number(next))} > {[0, 1, 2].map((position) => { const boss = bosses.find( (candidate) => candidate.level === entry && candidate.position === position, ); const label = boss?.name ?? `Boss ${position + 1}`; const positionLabel = ["บน", "กลาง", "ล่าง"][position]; return ( ); })} {bosses .filter((boss) => boss.level === entry) .map((boss) => ( ))} ))}
); }