"use client"; import { AlertCircle, CheckCircle2, Loader2, Play, RefreshCw, Search, Trash2, Wrench, XCircle, } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { deleteImportedFurni } from "@/actions/import-furni"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; type MissingKind = "nitro" | "icon" | "both"; interface MissingItem { id: number; classname: string; baseClassname: string; revision: number; type: "s" | "i"; publicName: string; hasSwf: boolean; hasIcon: boolean; hasNitro: boolean; missing: MissingKind; unrepairable: boolean; } interface AuditSummary { total: number; missingBoth: number; missingNitro: number; missingIcon: number; unrepairable: number; items: MissingItem[]; } type ItemStatus = "idle" | "working" | "done" | "partial" | "failed"; interface ItemProgress { status: ItemStatus; message?: string; nitroRepaired?: boolean; iconRepaired?: boolean; unrepairable?: boolean; } interface RepairSummary { succeeded: number; partial: number; failed: number; unrepairable: number; duration: number; } const TAB_FILTERS: Record boolean> = { all: () => true, both: (m) => m.missing === "both", nitro: (m) => m.missing === "nitro", icon: (m) => m.missing === "icon", unrepairable: (m) => m.unrepairable, }; export function RepairClient() { const [summary, setSummary] = useState(null); const [auditing, setAuditing] = useState(false); const [repairing, setRepairing] = useState(false); const [search, setSearch] = useState(""); const [tab, setTab] = useState("all"); const [selected, setSelected] = useState>(new Set()); const [progress, setProgress] = useState>( new Map(), ); const [lastSummary, setLastSummary] = useState(null); const [confirmDeleteAll, setConfirmDeleteAll] = useState(false); // ── Audit ────────────────────────────────────────────────────────── const runAudit = useCallback(async () => { setAuditing(true); setLastSummary(null); try { const res = await fetch("/api/admin/import/repair/audit", { method: "POST", }); if (!res.ok) { toast.error("Audit failed"); return; } const data = await res.json(); setSummary(data); setSelected(new Set()); setProgress(new Map()); toast.success( `Audit: ${data.items.length} item${data.items.length === 1 ? "" : "s"} need repair (${data.total} scanned)`, ); } catch { toast.error("Audit network error"); } finally { setAuditing(false); } }, []); // ── Filters ──────────────────────────────────────────────────────── const filtered = useMemo(() => { if (!summary) return []; const s = search.trim().toLowerCase(); return summary.items.filter((it) => { if (!TAB_FILTERS[tab](it)) return false; if (!s) return true; return ( it.classname.toLowerCase().includes(s) || it.publicName.toLowerCase().includes(s) || String(it.id).includes(s) ); }); }, [summary, search, tab]); // ── Selection ────────────────────────────────────────────────────── const allSelected = filtered.length > 0 && filtered.every((i) => selected.has(i.classname)); const toggleAll = () => { setSelected((prev) => { if (allSelected) { const next = new Set(prev); for (const i of filtered) { next.delete(i.classname); } return next; } const next = new Set(prev); for (const i of filtered) { next.add(i.classname); } return next; }); }; const toggleOne = (cn: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(cn)) next.delete(cn); else next.add(cn); return next; }); }; // ── Repair ───────────────────────────────────────────────────────── const runRepair = useCallback(async (classnames: string[]) => { if (classnames.length === 0) return; setRepairing(true); setLastSummary(null); // Init progress for these items setProgress((prev) => { const next = new Map(prev); for (const cn of classnames) { next.set(cn, { status: "working" }); } return next; }); try { const res = await fetch("/api/admin/import/repair", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ classnames, concurrency: 6 }), }); if (!res.ok || !res.body) { toast.error("Failed to start repair"); setRepairing(false); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; try { const event = JSON.parse(line.slice(6)); if (event.type === "repair_progress") { setProgress((prev) => { const next = new Map(prev); next.set(event.classname, { status: event.status as ItemStatus, message: event.error || (event.warnings?.[0] ?? undefined), nitroRepaired: event.nitroRepaired, iconRepaired: event.iconRepaired, unrepairable: event.unrepairable, }); return next; }); // Promote newly-discovered unrepairable flags onto the audit list if (event.unrepairable) { setSummary((prev) => { if (!prev) return prev; const item = prev.items.find( (i) => i.classname === event.classname, ); if (!item || item.unrepairable) return prev; return { ...prev, unrepairable: prev.unrepairable + 1, items: prev.items.map((i) => i.classname === event.classname ? { ...i, unrepairable: true } : i, ), }; }); } // Remove repaired items from the audit list on "done" if (event.status === "done") { setSummary((prev) => { if (!prev) return prev; const items = prev.items.filter( (i) => i.classname !== event.classname, ); const diff = prev.items.length - items.length; if (diff === 0) return prev; const removed = prev.items.find( (i) => i.classname === event.classname, ); return { ...prev, items, missingBoth: prev.missingBoth - (removed?.missing === "both" ? 1 : 0), missingNitro: prev.missingNitro - (removed?.missing === "nitro" ? 1 : 0), missingIcon: prev.missingIcon - (removed?.missing === "icon" ? 1 : 0), }; }); setSelected((prev) => { const next = new Set(prev); next.delete(event.classname); return next; }); } } else if (event.type === "repair_complete") { setLastSummary({ succeeded: event.succeeded, partial: event.partial, failed: event.failed, unrepairable: event.unrepairable ?? 0, duration: event.duration, }); } } catch { /* skip malformed */ } } } } catch { toast.error("Connection lost during repair"); } finally { setRepairing(false); } }, []); // ── Delete unrepairable items ────────────────────────────────────── const deleteSelected = useCallback(async () => { const toDelete = Array.from(selected); if (toDelete.length === 0) return; setConfirmDeleteAll(false); let ok = 0; let fail = 0; for (const cn of toDelete) { setProgress((prev) => { const next = new Map(prev); next.set(cn, { status: "working", message: "Deleting…" }); return next; }); try { const result = await deleteImportedFurni({ classname: cn }); if (result.ok) { ok++; setSummary((prev) => { if (!prev) return prev; const removed = prev.items.find((i) => i.classname === cn); if (!removed) return prev; return { ...prev, total: prev.total - 1, items: prev.items.filter((i) => i.classname !== cn), missingBoth: prev.missingBoth - (removed.missing === "both" ? 1 : 0), missingNitro: prev.missingNitro - (removed.missing === "nitro" ? 1 : 0), missingIcon: prev.missingIcon - (removed.missing === "icon" ? 1 : 0), }; }); setSelected((prev) => { const next = new Set(prev); next.delete(cn); return next; }); } else { fail++; setProgress((prev) => { const next = new Map(prev); next.set(cn, { status: "failed", message: "Delete failed" }); return next; }); } } catch { fail++; setProgress((prev) => { const next = new Map(prev); next.set(cn, { status: "failed", message: "Delete error" }); return next; }); } } toast.success(`Deleted ${ok}${fail > 0 ? `, ${fail} failed` : ""}`); }, [selected]); // ── Render ───────────────────────────────────────────────────────── const selectedArray = Array.from(selected); const selectedCount = selectedArray.length; return (
{/* Header */}

Repair Imported Assets

Re-download missing{" "} .nitro bundles and icons for items already imported. Items with no public asset on habbo.com can be deleted from here.

{/* Initial empty state */} {!summary && !auditing && (

Click “Scan Assets” to audit imported furniture.

This checks every row in items_base against files on disk. Scanning ~33k items takes a few seconds.

)} {/* Stats */} {summary && (
)} {/* Batch repair summary */} {lastSummary && (
Last run: {lastSummary.succeeded} fixed {lastSummary.partial > 0 && ( <> , {lastSummary.partial} partial )} {lastSummary.failed > 0 && ( <> , {lastSummary.failed} failed )} {lastSummary.unrepairable > 0 && ( <> , {lastSummary.unrepairable}{" "} unrepairable )}  · {(lastSummary.duration / 1000).toFixed(1)}s
)} {/* Table area */} {summary && summary.items.length === 0 && (

All imported items have their assets on disk.

{summary.total} items scanned, nothing to repair.

)} {summary && summary.items.length > 0 && ( <> {/* Toolbar */}
setSearch(e.target.value)} placeholder="Filter by classname, name, or id…" className="pl-8" />
{summary && summary.unrepairable > 0 && ( )}
{/* Tabs */} setTab(v as keyof typeof TAB_FILTERS)} > All ({summary.items.length}) Both ({summary.missingBoth}) .nitro ({summary.missingNitro}) Icon ({summary.missingIcon}) Unrepairable ({summary.unrepairable})
ID Classname Rev .nitro Icon SWF Status Actions {filtered.length === 0 && ( No items match the current filter. )} {filtered.map((it) => ( toggleOne(it.classname)} progress={progress.get(it.classname)} onRepair={() => runRepair([it.classname])} disabled={repairing} /> ))}
)} Delete {selectedCount} imported item {selectedCount === 1 ? "" : "s"}? This removes the rows from items_base /{" "} catalog_items, the entry in{" "} FurnitureData.json, and any remaining asset files on disk. Use this for items that have no public asset on habbo.com and therefore cannot be repaired. This cannot be undone.
); } // ── Small cell helpers ──────────────────────────────────────────────── function StatCard({ label, value, tone, sub, }: { label: string; value: number; tone: "default" | "danger" | "warn" | "info"; sub?: string; }) { const tones = { default: "border-border", danger: "border-destructive/40 bg-destructive/5", warn: "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]", info: "border-[var(--admin-info)]/40 bg-[var(--admin-info)]/5", }; return (
{label}
{value.toLocaleString()}
{sub &&
{sub}
}
); } function FileBadge({ present }: { present: boolean }) { return present ? ( On disk ) : ( Missing ); } function StatusCell({ progress }: { progress?: ItemProgress }) { if (!progress || progress.status === "idle") return —; if (progress.status === "working") { return ( {progress.message || "Working…"} ); } if (progress.status === "done") { const parts = [ progress.nitroRepaired ? ".nitro ✓" : null, progress.iconRepaired ? "icon ✓" : null, ].filter(Boolean); return ( {parts.length > 0 ? parts.join(" · ") : "Repaired"} ); } if (progress.status === "partial") { return ( Partial · {progress.message || "check logs"} ); } return ( {progress.message || "Failed"} ); } function ItemRow({ item, selected, onToggle, progress, onRepair, disabled, }: { item: MissingItem; selected: boolean; onToggle: () => void; progress?: ItemProgress; onRepair: () => void; disabled: boolean; }) { const isUnrepairable = item.unrepairable || progress?.unrepairable; return ( {item.id}
{item.classname} {isUnrepairable && ( Unrepairable )}
{item.publicName}
{item.revision || "—"}
); }