"use client"; import "./nitro-editor.css"; import { Box, Check, Compass, Copy, Eye, Layers, Loader2, MousePointerClick, Palette, RotateCcw, Ruler, Save, Sparkles, } from "lucide-react"; import { useTranslations } from "next-intl"; import { lazy, Suspense, useEffect, useEffectEvent, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { adminFetch } from "@/lib/admin-fetch"; import { autoDetectInteraction, getInteractionTypeLabel, nitroAnimationStatesCount, } from "@/lib/furni/auto-interaction"; const NitroQualityPanel = lazy(() => import("@/components/admin/studio/nitro-quality-panel").then((module) => ({ default: module.NitroQualityPanel, })), ); const NitroScale32Generator = lazy(() => import("@/components/admin/studio/nitro-scale32-generator").then( (module) => ({ default: module.NitroScale32Generator }), ), ); interface NitroEditorDialogProps { classname: string; name?: string; open: boolean; onOpenChange: (open: boolean) => void; } interface NitroVisualization { size?: number; layers?: Record; colors?: Record>; animations?: Record; } // The .nitro metadata is untyped JSON from disk; typed only for the fields the // editor reads/writes. Unknown keys are traversed via the path helpers below. interface NitroMetadata { visualizationType?: string; logicType?: string; logic?: { model?: { dimensions?: { x: number; y: number; z: number }; directions?: number[]; }; }; visualizations?: NitroVisualization[]; } type JsonObject = Record; function updateNested( obj: T, path: string, value: unknown, ): T { const clone: T = JSON.parse(JSON.stringify(obj)); const keys = path.split("."); let current: JsonObject = clone as JsonObject; for (let i = 0; i < keys.length - 1; i++) { const next = current[keys[i]]; if (!next || typeof next !== "object" || Array.isArray(next)) { current[keys[i]] = {}; } current = current[keys[i]] as JsonObject; } current[keys[keys.length - 1]] = value; return clone; } function getNested(obj: unknown, path: string, fallback: T): T { const keys = path.split("."); let current: unknown = obj; for (const key of keys) { if (current == null || typeof current !== "object") return fallback; current = (current as JsonObject)[key]; } return current === undefined ? fallback : (current as T); } const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7]; const DIR_LABELS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; function hexToColor(hex: string): string { return `#${hex.padStart(6, "0")}`; } function CopyClassname({ value }: { value: string }) { const [copied, setCopied] = useState(false); return ( ); } export function NitroEditorDialog({ classname, name = "", open, onOpenChange, }: NitroEditorDialogProps) { const loadingT = useTranslations("pages.admin.housekeeping"); const t = useTranslations("pages.admin.import.furni.nitroEditorDialog"); const [metadata, setMetadata] = useState(null); const [originalMetadata, setOriginalMetadata] = useState(null); const [jsonText, setJsonText] = useState(""); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [bundleRevision, setBundleRevision] = useState(0); const [activeTab, setActiveTab] = useState("visual"); const [flags, setFlags] = useState({ canstandon: false, cansiton: false, canlayon: false, }); const [originalFlags, setOriginalFlags] = useState({ canstandon: false, cansiton: false, canlayon: false, }); const [autoDetectResult, setAutoDetectResult] = useState | null>(null); const [jsonError, setJsonError] = useState(null); const handleAutoDetect = () => { // Use real data when available: the DB flags for this item plus the // .nitro animation states already loaded in the editor. Keyword // detection only kicks in when neither is present — no false positives. const nitroJson = metadata && typeof metadata === "object" ? (metadata as Record) : null; const result = autoDetectInteraction(classname, name, { cansiton: flags.cansiton, canlayon: flags.canlayon, canstandon: flags.canstandon, logicType: nitroJson?.logicType as string | undefined, animationStates: nitroAnimationStatesCount(nitroJson), }); setAutoDetectResult(result); setFlags({ canstandon: result.canStand, cansiton: result.canSit, canlayon: result.canLay, }); toast.success(t("autoDetected", { reason: result.reason })); }; const patchMeta = (path: string, value: unknown) => { setMetadata((prev) => prev == null ? prev : updateNested(prev, path, value), ); }; const closeAfterLoadError = useEffectEvent(() => onOpenChange(false)); useEffect(() => { if (!open || !classname) return; const controller = new AbortController(); setActiveTab("visual"); setLoading(true); setJsonError(null); setAutoDetectResult(null); void (async () => { try { const res = await adminFetch( `/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`, { signal: controller.signal }, ); const data = await res.json(); if (controller.signal.aborted) return; if (!res.ok) { toast.error(data.error || t("loadFailed")); closeAfterLoadError(); return; } setMetadata(data.metadata); setOriginalMetadata(structuredClone(data.metadata)); setJsonText(JSON.stringify(data.metadata, null, 2)); setFlags(data.flags); setOriginalFlags({ ...data.flags }); } catch { if (!controller.signal.aborted) { toast.error(t("networkErrorLoad")); closeAfterLoadError(); } } finally { if (!controller.signal.aborted) setLoading(false); } })(); return () => controller.abort(); }, [open, classname, t]); const handleTabChange = (newTab: string) => { if (activeTab === "json" && newTab !== "json") { try { const parsed = JSON.parse(jsonText); setMetadata(parsed); setJsonError(null); } catch { toast.error(t("invalidJsonSwitchTabs")); return; } } else if (activeTab !== "json" && newTab === "json") { setJsonText(JSON.stringify(metadata, null, 2)); } setActiveTab(newTab); }; const handleReset = () => { if (!originalMetadata) return; setMetadata(JSON.parse(JSON.stringify(originalMetadata))); setFlags({ ...originalFlags }); setJsonText(JSON.stringify(originalMetadata, null, 2)); setJsonError(null); toast.info(t("resetToOriginal")); }; const handleSave = async () => { let metadataToSave = metadata; if (activeTab === "json") { try { metadataToSave = JSON.parse(jsonText); } catch { toast.error(t("invalidJson")); return; } } setSaving(true); try { const res = await adminFetch("/api/admin/import/furni/nitro-editor", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ classname, metadata: metadataToSave, ...flags, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); toast.error(err.error || t("saveFailed")); return; } toast.success("Nitro metadata saved"); setMetadata(metadataToSave); setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave))); setOriginalFlags({ ...flags }); setJsonText(JSON.stringify(metadataToSave, null, 2)); } catch { toast.error("Network error"); } finally { setSaving(false); } }; // ── Visual tab helpers ── const dims = metadata ? getNested(metadata, "logic.model.dimensions", { x: 1, y: 1, z: 0 }) : { x: 1, y: 1, z: 0 }; const directions = metadata ? getNested(metadata, "logic.model.directions", [0]) : [0]; const setDim = (axis: "x" | "y" | "z", value: string) => { const num = axis === "z" ? parseFloat(value) || 0 : parseInt(value, 10) || 0; patchMeta(`logic.model.dimensions.${axis}`, num); }; const toggleDirection = (dir: number) => { const current = getNested(metadata, "logic.model.directions", [0]); const next = current.includes(dir) ? current.filter((d: number) => d !== dir) : [...current, dir].sort((a: number, b: number) => a - b); if (next.length === 0) return; patchMeta("logic.model.directions", next); }; // Get visualization with size=64 for layer/color editing const viz64 = metadata?.visualizations?.find((v) => v.size === 64); const viz64Index = metadata?.visualizations?.findIndex((v) => v.size === 64) ?? -1; const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0; const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0; const animCount = viz64?.animations ? Object.keys(viz64.animations).length : 0; const handleJsonTextChange = (value: string) => { setJsonText(value); try { JSON.parse(value); setJsonError(null); } catch (e) { setJsonError((e as Error).message); } }; // Check if there are unsaved changes const hasChanges = metadata && originalMetadata && ((activeTab === "json" ? jsonText !== JSON.stringify(originalMetadata, null, 2) : JSON.stringify(metadata) !== JSON.stringify(originalMetadata)) || JSON.stringify(flags) !== JSON.stringify(originalFlags)); // Furni preview URL const starIdx = classname.indexOf("*"); const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname; return ( {/* Header with preview */}
{/* Furni preview */}
{classname} { (e.target as HTMLImageElement).style.opacity = "0"; }} />
Edit Nitro {/* Quick stats */} {metadata && (
{dims.x}×{dims.y}×{dims.z} {directions.length} dir {layerCount} layers {colorCount > 0 && ( {colorCount} colors )} {animCount > 0 && ( {animCount} anim )} {metadata.visualizationType || "?"}
)}
{loading ? (
) : metadata ? (
Visual 32 / 64 {"{ }"} JSON
{loadingT("loading")}

}> { setBundleRevision((value) => value + 1); setMetadata(updated as NitroMetadata); setOriginalMetadata( structuredClone(updated) as NitroMetadata, ); setJsonText(JSON.stringify(updated, null, 2)); }} />
{/* ── Visual Tab ── */} {/* Dimensions + Interaction in 2-column layout */}
{/* Dimensions card */}

Dimensions

setDim("x", e.target.value)} className="mt-1" />
setDim("y", e.target.value)} className="mt-1" />
setDim("z", e.target.value)} className="mt-1" />
{/* Available rotations */}

Directions

{directions.length} / 8 active
{ALL_DIRECTIONS.map((dir) => ( ))}

Choose the available rotations. Keep at least one direction active.

{/* Interaction card */}

Interaction

Can Stand On setFlags((f) => ({ ...f, canstandon: !!v })) } />
Can Sit On setFlags((f) => ({ ...f, cansiton: !!v })) } />
Can Lay On setFlags((f) => ({ ...f, canlayon: !!v })) } />
{autoDetectResult && (
{autoDetectResult.canLay ? ( ) : autoDetectResult.canSit ? ( ) : ( )} {getInteractionTypeLabel( autoDetectResult.interactionType, )} {autoDetectResult.clickLimit > 0 && ( Click limit: {autoDetectResult.clickLimit} )} {autoDetectResult.interactionModesCount > 0 && ( Modes: {autoDetectResult.interactionModesCount} )}

{autoDetectResult.reason}

)}
{/* Types */}

Types

patchMeta("visualizationType", e.target.value) } className="mt-1 font-mono text-sm" />
patchMeta("logicType", e.target.value)} className="mt-1 font-mono text-sm" />
{/* Layers */} {viz64?.layers && layerCount > 0 && (

Layer

{layerCount}
{/* Header */}
Layer Z-Index Alpha Ink
{Object.entries(viz64.layers).map( ([layerId, layerData], idx) => { const layer = layerData as Record; return (
#{layerId} { patchMeta( `visualizations.${viz64Index}.layers.${layerId}.z`, parseInt(e.target.value, 10) || 0, ); }} /> { patchMeta( `visualizations.${viz64Index}.layers.${layerId}.alpha`, parseInt(e.target.value, 10) || 0, ); }} /> { patchMeta( `visualizations.${viz64Index}.layers.${layerId}.ink`, e.target.value || undefined, ); }} />
); }, )}
)} {/* Colors */} {viz64?.colors && colorCount > 0 && (

Colori

{colorCount} colors
{Object.entries(viz64.colors).map( ([colorId, colorLayers]) => (
Color Variant #{colorId} {/* Color preview dots */}
{Object.values( colorLayers as Record< string, { color: number } >, ).map((lc, i) => (
))}
{Object.entries( colorLayers as Record, ).map(([layerId, layerColor]) => (
{ const hex = e.target.value .replace(/[^0-9a-fA-F]/g, "") .slice(0, 6); const num = parseInt(hex, 16) || 0; patchMeta( `visualizations.${viz64Index}.colors.${colorId}.${layerId}.color`, num, ); }} /> {/* Color swatch inside input */}
))}
), )}
)}
{/* ── JSON Tab ── */}