"use client"; import { AlertTriangle, CheckCircle2, FileX2, Loader2, ScanSearch, Trash2, } from "lucide-react"; import { useCallback, 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 { adminFetch } from "@/lib/admin-fetch"; import { formatBytes } from "@/lib/services/disk-usage"; interface NitroCleanupEntry { fileName: string; base: string; size: number; dirs: string[]; error?: string; } interface NitroCleanupScan { fake: NitroCleanupEntry[]; broken: NitroCleanupEntry[]; total: number; } interface DeleteResponse { deleted: number; copiesRemoved: number; errors?: string[]; } type GroupKey = "fake" | "broken"; function CleanupGroup({ group, label, description, entries, selected, onToggle, onToggleAll, }: { group: GroupKey; label: string; description: string; entries: NitroCleanupEntry[]; selected: Set; 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 isError = group === "fake"; return (
0 ? isError ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" : "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]" : "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 [scan, setScan] = useState(null); const [selected, setSelected] = useState>(new Set()); const { confirm, dialog } = useConfirmDialog(); const scanNow = useCallback(async () => { setScanning(true); setSelected(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"); const fake = data.fake ?? []; const broken = data.broken ?? []; setScan({ fake, broken, total: data.total ?? 0, }); const found = fake.length + broken.length; if (found === 0) { toast.success("No fake or broken .nitro files found"); } else { toast.info(`Found ${found} removable .nitro file(s)`); } } catch (err) { toast.error(err instanceof Error ? err.message : "Scan failed"); } finally { setScanning(false); } }, []); const toggle = (fileName: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(fileName)) next.delete(fileName); else next.add(fileName); return next; }); }; const toggleAll = (names: string[]) => { setSelected((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 removeSelected = async () => { const fileNames = [...selected].sort(); if (fileNames.length === 0) return; const confirmed = await confirm({ title: `Delete ${fileNames.length} .nitro file(s)?`, description: "The .nitro bundles will be removed from every directory (public + mirrored gamedata). Database rows are never touched.", confirmLabel: "Delete", variant: "danger", }); if (!confirmed) return; setDeleting(true); try { const res = await adminFetch("/api/admin/studio/nitro-cleanup", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileNames }), }); const data = (await res.json()) as DeleteResponse & { error?: string; }; if (!res.ok) throw new Error(data.error || "Delete failed"); if (data.errors?.length) { toast.warning( `Deleted ${data.copiesRemoved} copy/copies, but some files could not be removed`, ); } else { toast.success( `Deleted ${data.copiesRemoved} .nitro file copy/copies (${data.deleted} file(s))`, ); } setSelected(new Set()); await scanNow(); } catch (err) { toast.error(err instanceof Error ? err.message : "Delete failed"); } finally { setDeleting(false); } }; return (
{dialog}
{scan && ( )}
{scan && (
{scan.total}
.nitro files scanned
{scan.fake.length}
Fake (no matching item)
{scan.broken.length}
Broken (unparseable)
{(scan.fake.length > 0 || scan.broken.length > 0) && (

Deleting a broken .nitro leaves its item in the database without a bundle — the catalog audit can re-download it later. Database rows are never removed by this tool.

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

Scan every nitro directory to find fake bundles that belong to no furniture and broken bundles that can no longer be parsed. Remove them with one click.

)}
); }