"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 [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(`Auto-detected: ${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 || "Failed to load .nitro metadata");
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("Network error loading .nitro metadata");
closeAfterLoadError();
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, [open, classname]);
const handleTabChange = (newTab: string) => {
if (activeTab === "json" && newTab !== "json") {
try {
const parsed = JSON.parse(jsonText);
setMetadata(parsed);
setJsonError(null);
} catch {
toast.error("Invalid JSON — fix it before switching tabs");
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("Reset to original values");
};
const handleSave = async () => {
let metadataToSave = metadata;
if (activeTab === "json") {
try {
metadataToSave = JSON.parse(jsonText);
} catch {
toast.error("Invalid JSON");
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 || "Save failed");
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 (
}>
{
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 */}
{/* 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 */}
{/* 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]) => (
))}
),
)}
)}
{/* ── JSON Tab ── */}
) : null}
{hasChanges && (
Unsaved changes
)}
);
}