"use client"; import { AlertTriangle, CheckCircle2, Download, FileX2, Loader2, ScanSearch, ScrollText, Sparkles, Trash2, Wrench, } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { adminFetch } from "@/lib/admin-fetch"; import { formatBytes } from "@/lib/services/disk-usage"; interface AssetEntry { fileName: string; base: string; size: number; dirs: string[]; lastModified: number; error?: string; } interface NitroCleanupScan { fake: AssetEntry[]; broken: AssetEntry[]; orphanedSwf: AssetEntry[]; orphanedIcon: AssetEntry[]; total: number; } interface DeleteResponse { deleted: number; copiesRemoved: number; errors?: string[]; } interface RepairResponse { repaired: number; failed: number; errors?: string[]; } interface AutoCleanResponse { deleted: number; copiesRemoved: number; skippedRecent: number; skippedBroken: number; errors?: string[]; } type GroupTone = "error" | "warning" | "neutral"; type AssetKind = "nitro" | "swf" | "icon"; const DAY_MS = 24 * 60 * 60 * 1000; function ageLabel(lastModified: number): string { if (!lastModified) return "age unknown"; const days = Math.floor((Date.now() - lastModified) / DAY_MS); if (days < 1) return "< 1d"; if (days < 30) return `${days}d`; if (days < 365) return `${Math.floor(days / 30)}mo`; return `${Math.floor(days / 365)}y`; } function downloadJson(filename: string, data: unknown): void { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click(); URL.revokeObjectURL(url); } function CleanupGroup({ label, description, tone, entries, selected, disabled = false, onToggle, onToggleAll, }: { label: string; description: string; tone: GroupTone; entries: AssetEntry[]; selected: Set; disabled?: boolean; onToggle: (fileName: string) => void; onToggleAll: (names: string[]) => void; }) { const checked = entries.filter((e) => selected.has(e.fileName)); const allChecked = entries.length > 0 && checked.length === entries.length; const toneClass = tone === "error" ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" : tone === "warning" ? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]" : "border-[var(--admin-border)] bg-[var(--admin-surface)]"; return (
0 ? toneClass : "border-[var(--admin-border)] bg-[var(--admin-surface)]"}`} > onToggleAll(entries.map((e) => e.fileName))} aria-label={`Select all ${label}`} />

0 ? "text-[var(--admin-text)]" : "text-[var(--admin-text-muted)]"}`} > {label} ({entries.length})

{description}

{checked.length > 0 && ( {checked.length} selected )}
{entries.length === 0 ? (

No {label.toLowerCase()} found

) : ( )}
); } export function NitroCleanupPanel() { const [scanning, setScanning] = useState(false); const [deleting, setDeleting] = useState(false); const [repairing, setRepairing] = useState(false); const [autoCleaning, setAutoCleaning] = useState(false); const [autoAge, setAutoAge] = useState(30); const [minAgeDays, setMinAgeDays] = useState(0); const [onlyOld, setOnlyOld] = useState(false); const [scan, setScan] = useState(null); const [selected, setSelected] = useState>(new Set()); const [selectedSwf, setSelectedSwf] = useState>(new Set()); const [selectedIcon, setSelectedIcon] = useState>(new Set()); const { confirm, dialog } = useConfirmDialog(); const scanNow = useCallback(async () => { setScanning(true); setSelected(new Set()); setSelectedSwf(new Set()); setSelectedIcon(new Set()); try { const res = await adminFetch("/api/admin/studio/nitro-cleanup"); const data = (await res.json()) as NitroCleanupScan & { error?: string; }; if (!res.ok) throw new Error(data.error || "Scan failed"); setScan({ fake: data.fake ?? [], broken: data.broken ?? [], orphanedSwf: data.orphanedSwf ?? [], orphanedIcon: data.orphanedIcon ?? [], total: data.total ?? 0, }); const found = data.fake.length + data.broken.length + data.orphanedSwf.length + data.orphanedIcon.length; if (found === 0) { toast.success("No orphaned or broken furniture assets found"); } else { toast.info(`Found ${found} removable asset file(s)`); } } catch (err) { toast.error(err instanceof Error ? err.message : "Scan failed"); } finally { setScanning(false); } }, []); const toggle = (kind: AssetKind) => (fileName: string) => { const setter = kind === "swf" ? setSelectedSwf : kind === "icon" ? setSelectedIcon : setSelected; setter((prev) => { const next = new Set(prev); if (next.has(fileName)) next.delete(fileName); else next.add(fileName); return next; }); }; const toggleAll = (kind: AssetKind) => (names: string[]) => { const setter = kind === "swf" ? setSelectedSwf : kind === "icon" ? setSelectedIcon : setSelected; setter((prev) => { const next = new Set(prev); const allChecked = names.every((n) => next.has(n)); for (const name of names) { if (allChecked) next.delete(name); else next.add(name); } return next; }); }; const totalSelected = selected.size + selectedSwf.size + selectedIcon.size; const removeSelected = async () => { const groups: Array<{ kind: AssetKind; names: string[] }> = []; for (const [kind, set] of [ ["nitro", selected], ["swf", selectedSwf], ["icon", selectedIcon], ] as const) { const names = [...set]; if (names.length > 0) groups.push({ kind, names }); } if (groups.length === 0) return; const confirmed = await confirm({ title: `Delete ${totalSelected} asset file(s)?`, description: "The files will be removed from every directory (public + mirrored gamedata). Database rows are never touched.", confirmLabel: "Delete", variant: "danger", }); if (!confirmed) return; setDeleting(true); try { let copiesRemoved = 0; let deleted = 0; let hadErrors = false; for (const group of groups) { const res = await adminFetch("/api/admin/studio/nitro-cleanup", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileNames: group.names.sort(), kind: group.kind, }), }); const data = (await res.json()) as DeleteResponse & { error?: string; }; if (!res.ok) throw new Error(data.error || "Delete failed"); deleted += data.deleted ?? 0; copiesRemoved += data.copiesRemoved ?? 0; if (data.errors?.length) hadErrors = true; } if (hadErrors) { toast.warning( `Deleted ${copiesRemoved} copy/copies, but some files could not be removed`, ); } else { toast.success( `Deleted ${copiesRemoved} file copy/copies (${deleted} file(s))`, ); } setSelected(new Set()); setSelectedSwf(new Set()); setSelectedIcon(new Set()); await scanNow(); } catch (err) { toast.error(err instanceof Error ? err.message : "Delete failed"); } finally { setDeleting(false); } }; const repairSelected = async () => { const fileNames = [...selected] .filter((name) => scan?.broken.some((e) => e.fileName === name)) .sort(); if (fileNames.length === 0) return; const confirmed = await confirm({ title: `Re-download ${fileNames.length} broken .nitro bundle(s)?`, description: "The bundles will be fetched again from the configured nitro sources and written over every directory (public + mirrored gamedata).", confirmLabel: "Re-download", variant: "default", }); if (!confirmed) return; setRepairing(true); try { const res = await adminFetch("/api/admin/studio/nitro-cleanup/rebuild", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileNames }), }); const data = (await res.json()) as RepairResponse & { error?: string; }; if (!res.ok) throw new Error(data.error || "Repair failed"); if (data.repaired > 0) { toast.success(`Repaired ${data.repaired} .nitro bundle(s)`); } else { toast.warning(`Could not re-obtain any bundle (${data.failed} failed)`); } setSelected(new Set()); await scanNow(); } catch (err) { toast.error(err instanceof Error ? err.message : "Repair failed"); } finally { setRepairing(false); } }; const autoClean = async () => { if (scan === null) return; const confirmed = await confirm({ title: `Auto-clean fake nitros older than ${autoAge} day(s)?`, description: "Only fake .nitro files (no matching item) older than the threshold are removed. Broken bundles and recent files are never touched.", confirmLabel: "Auto-clean", variant: "danger", }); if (!confirmed) return; setAutoCleaning(true); try { const res = await adminFetch( "/api/admin/studio/nitro-cleanup/auto-clean", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ maxAgeDays: autoAge }), }, ); const data = (await res.json()) as AutoCleanResponse & { error?: string; }; if (!res.ok) throw new Error(data.error || "Auto-clean failed"); if (data.deleted > 0) { toast.success( `Auto-cleaned ${data.deleted} old fake .nitro file(s) (${data.copiesRemoved} copies). Skipped ${data.skippedRecent} recent + ${data.skippedBroken} broken.`, ); } else { toast.info( `Nothing old enough to remove (skipped ${data.skippedRecent} recent, ${data.skippedBroken} broken)`, ); } await scanNow(); } catch (err) { toast.error(err instanceof Error ? err.message : "Auto-clean failed"); } finally { setAutoCleaning(false); } }; const exportManifest = () => { if (scan === null) return; const entries = { scannedAt: new Date().toISOString(), total: scan.total, fake: scan.fake, broken: scan.broken, orphanedSwf: scan.orphanedSwf, orphanedIcon: scan.orphanedIcon, selected: { nitro: [...selected].sort(), swf: [...selectedSwf].sort(), icon: [...selectedIcon].sort(), }, }; downloadJson(`nitro-cleanup-scan-${Date.now()}.json`, entries); toast.success("Scan manifest downloaded"); }; const visibleFake = useMemo(() => { if (scan === null) return []; if (!onlyOld || minAgeDays <= 0) return scan.fake; const threshold = minAgeDays * DAY_MS; return scan.fake.filter((e) => e.lastModified <= Date.now() - threshold); }, [scan, onlyOld, minAgeDays]); const visibleSwf = useMemo(() => { if (scan === null) return []; if (!onlyOld || minAgeDays <= 0) return scan.orphanedSwf; const threshold = minAgeDays * DAY_MS; return scan.orphanedSwf.filter( (e) => e.lastModified <= Date.now() - threshold, ); }, [scan, onlyOld, minAgeDays]); const visibleIcon = useMemo(() => { if (scan === null) return []; if (!onlyOld || minAgeDays <= 0) return scan.orphanedIcon; const threshold = minAgeDays * DAY_MS; return scan.orphanedIcon.filter( (e) => e.lastModified <= Date.now() - threshold, ); }, [scan, onlyOld, minAgeDays]); const busy = scanning || deleting || repairing || autoCleaning; return (
{dialog}
{scan && ( )} {scan && scan.broken.length > 0 && ( )} {scan && scan.fake.length > 0 && ( <>
setAutoAge(Number(e.target.value))} aria-label="Minimum age in days for auto-clean" /> days
)} {scan && ( )}
{scan && (
Only show files older than
setMinAgeDays(Number(e.target.value))} aria-label="Minimum age in days to display" /> days
{scan.total}
asset files scanned
{scan.fake.length}
Fake nitros (no matching item)
{scan.broken.length}
Broken nitros (unparseable)
{(scan.fake.length > 0 || scan.broken.length > 0) && (

Deleting a broken .nitro leaves its item in the database without a bundle — prefer re-downloading it, or let the catalog audit re-obtain it later. Database rows are never removed by this tool.

)}
Only asset files are touched — FurnitureData.json and database rows stay intact.
)} {!scan && !scanning && (

Scan every furni asset directory to find fake and broken nitro bundles plus orphaned SWFs and icons. Delete them, re-download the broken ones, or auto-clean old leftovers.

)}
); }