"use client"; import { AlertCircle, Check, CheckCircle2, ClipboardList, ExternalLink, Loader2, SearchX, } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { adminFetch } from "@/lib/admin-fetch"; interface AuditIssue { type: string; severity: "error" | "warning" | "info"; classname?: string; itemId?: number; catalogId?: number; message: string; sourceName?: string; } interface MissingFromSource { sourceName: string; sourceId: string; classname: string; itemName: string; } interface AuditSummary { totalItemsBase: number; totalCatalogItems: number; missingCatalog: number; orphanedCatalog: number; missingNitro: number; missingIcon: number; duplicates: number; missingFromSourcesTotal: number; iconsRepaired: number; iconsStillMissing: number; nitrosRepaired: number; nitrosStillMissing: number; catalogSqlCount: number; catalogSqlApplied: number; furniDataAdded: number; furniDataDuplicatesRemoved: number; orphanedRemoved: number; orphanedCleaned: number; duplicatesMerged: number; duplicateRowsRemoved: number; remappedReferences: number; } interface RepairStats { succeeded: number; failed: number; skipped: number; stillMissing: number; } interface AuditEvent { type: string; message?: string; current?: number; total?: number; section?: string; issues?: AuditIssue[]; missingFromSources?: MissingFromSource[]; summary?: AuditSummary; repair?: RepairStats; repairNitro?: RepairStats; sql?: string; sqlCount?: number; applied?: number; sqlFailed?: number; furniData?: { added: number; removedDuplicates: number }; structure?: { orphanedRemoved: number; orphanedCleaned: number; duplicatesMerged: number; duplicateRowsRemoved: number; remappedReferences: number; }; } const TYPE_LABELS: Record = { missing_catalog: "No catalog entry", orphaned_catalog: "Orphaned catalog reference", missing_nitro: "Missing .nitro file", missing_icon: "Missing icon file", duplicate_classname: "Duplicate classname", source_fetch_failed: "Source fetch failed", }; function AuditCheckbox({ checked, disabled, onChange, children, }: { checked: boolean; disabled?: boolean; onChange: (value: boolean) => void; children: React.ReactNode; }) { return ( // biome-ignore lint/a11y/useSemanticElements: custom checkbox so the checked state shows a visible checkmark on every admin theme ); } export function AuditClient() { const [loading, setLoading] = useState(false); const [progress, setProgress] = useState(""); const [activeTab, setActiveTab] = useState<"issues" | "sources">("issues"); const [repairEnabled, setRepairEnabled] = useState(true); const [repairNitros, setRepairNitros] = useState(true); const [generateSql, setGenerateSql] = useState(false); const [applySql, setApplySql] = useState(false); const [repairFurniData, setRepairFurniData] = useState(false); const [repairStructure, setRepairStructure] = useState(false); const [issues, setIssues] = useState([]); const [missingFromSources, setMissingFromSources] = useState< MissingFromSource[] >([]); const [summary, setSummary] = useState(null); const [repairStats, setRepairStats] = useState(null); const [nitroRepairStats, setNitroRepairStats] = useState( null, ); const [sqlText, setSqlText] = useState(null); const [sqlApplied, setSqlApplied] = useState<{ applied: number; failed: number; } | null>(null); const [furniDataResult, setFurniDataResult] = useState<{ added: number; removedDuplicates: number; } | null>(null); const [structureResult, setStructureResult] = useState<{ orphanedRemoved: number; orphanedCleaned: number; duplicatesMerged: number; duplicateRowsRemoved: number; remappedReferences: number; } | null>(null); const [checkProgress, setCheckProgress] = useState<{ section: string; current: number; total: number; } | null>(null); const abortRef = useRef(null); const runAudit = useCallback(async () => { setLoading(true); setProgress("Starting audit…"); setIssues([]); setMissingFromSources([]); setSummary(null); setRepairStats(null); setNitroRepairStats(null); setSqlText(null); setSqlApplied(null); setFurniDataResult(null); setStructureResult(null); setCheckProgress(null); setActiveTab("issues"); const abort = new AbortController(); abortRef.current = abort; try { const res = await adminFetch("/api/admin/import/audit", { method: "POST", signal: abort.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repair: repairEnabled, repairNitros, sql: generateSql || applySql, applySql, repairFurniData, repairStructure, }), }); if (!res.body) { toast.error("No response stream"); setLoading(false); return; } if (!res.headers.get("content-type")?.includes("text/event-stream")) { let errMsg = "Audit failed"; try { const json = await res.json(); errMsg = json.error || errMsg; } catch { // ignore } toast.error(errMsg); setLoading(false); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const parts = buf.split("\n\n"); buf = parts.pop() ?? ""; for (const part of parts) { if (!part.startsWith("data: ")) continue; try { const evt = JSON.parse(part.slice(6)) as AuditEvent; flushSync(() => { if (evt.type === "progress") { setProgress(evt.message ?? ""); } if (evt.type === "items_loaded") { setProgress(`Loaded ${evt.total} items_base entries`); } if (evt.type === "catalog_loaded") { setProgress(`Loaded ${evt.total} catalog_items entries`); } if (evt.type === "checking") { setProgress(`Checking ${evt.section}…`); if (evt.total) { setCheckProgress({ section: evt.section ?? "", current: evt.current ?? 0, total: evt.total, }); } } if (evt.type === "repair_complete") { if (evt.repair) setRepairStats(evt.repair); if (evt.repairNitro) setNitroRepairStats(evt.repairNitro); } if (evt.type === "sql_ready") { setSqlText(evt.sql ?? null); } if (evt.type === "sql_applied") { setSqlApplied({ applied: evt.applied ?? 0, failed: evt.sqlFailed ?? 0, }); } if (evt.type === "furnidata_repair_complete") { if (evt.furniData) setFurniDataResult(evt.furniData); } if (evt.type === "structure_repair_complete") { if (evt.structure) setStructureResult(evt.structure); } if (evt.type === "batch_complete") { setIssues(evt.issues ?? []); setMissingFromSources(evt.missingFromSources ?? []); if (evt.summary) setSummary(evt.summary); setCheckProgress(null); setProgress("Done"); if ( evt.issues && evt.issues.filter((i) => i.severity === "error").length > 0 ) { toast.error( `${evt.issues.filter((i) => i.severity === "error").length} error(s) found`, ); } else if ( evt.issues && evt.issues.filter((i) => i.severity === "warning").length > 0 ) { toast.warning("Warnings found"); } else { toast.success("No issues found"); } } if (evt.type === "error") { setProgress(`ERROR: ${evt.message ?? ""}`); toast.error(evt.message ?? "Audit failed"); } }); } catch { // skip parse errors } } } } catch (err) { if ((err as Error).name !== "AbortError") { toast.error("Audit request failed"); } else { toast.info("Audit cancelled"); } } finally { setLoading(false); abortRef.current = null; } }, [ repairEnabled, repairNitros, generateSql, applySql, repairFurniData, repairStructure, ]); const errors = issues.filter((i) => i.severity === "error"); const warnings = issues.filter((i) => i.severity === "warning"); const downloadSql = () => { if (!sqlText) return; const blob = new Blob([sqlText], { type: "text/sql" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `catalog-repair-${new Date().toISOString().slice(0, 10)}.sql`; a.click(); URL.revokeObjectURL(url); }; return (
{!summary && (
Repair missing icons Repair missing .nitro Generate catalog SQL Apply SQL to database Repair FurnitureData.json (add missing + dedupe) Repair structural issues (orphaned refs + duplicates) {loading && ( )}
)} {loading && checkProgress && (
{checkProgress.section}: {checkProgress.current} /{" "} {checkProgress.total}
)} {summary && ( <> {/* Summary cards */}
{summary.totalItemsBase}
items_base entries
{summary.totalCatalogItems}
catalog_items entries
0 ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" : "bg-card"}`} >
0 ? "text-[var(--admin-error)]" : ""}`} > {errors.length}
Errors
0 ? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]" : "bg-card"}`} >
0 ? "text-[var(--admin-warning)]" : ""}`} > {warnings.length}
Warnings
{/* Quick stats */}
{summary.missingCatalog > 0 ? ( ) : ( )} {summary.missingCatalog} items without catalog entry
{summary.orphanedCatalog > 0 ? ( ) : ( )} {summary.orphanedCatalog} orphaned catalog references
{summary.missingNitro > 0 ? ( ) : ( )} {summary.missingNitro} items without .nitro
{summary.missingIcon > 0 ? ( ) : ( )} {summary.missingIcon} items without icon
{summary.duplicates > 0 ? ( ) : ( )} {summary.duplicates} duplicate classnames
{summary.missingFromSourcesTotal > 0 ? ( ) : ( )} {summary.missingFromSourcesTotal} items missing from clone sources
{repairStats && (
Icon repair:{" "} {repairStats.succeeded} fixed {repairStats.failed > 0 && ( <> {" · "} {repairStats.failed} failed )} {repairStats.skipped > 0 && ( <> {" · "} {repairStats.skipped} skipped )} {repairStats.stillMissing > 0 && ( <> {" · "} {repairStats.stillMissing} still missing )}
)} {nitroRepairStats && (
Nitro repair:{" "} {nitroRepairStats.succeeded} fixed {nitroRepairStats.failed > 0 && ( <> {" · "} {nitroRepairStats.failed} failed )} {nitroRepairStats.skipped > 0 && ( <> {" · "} {nitroRepairStats.skipped} skipped )} {nitroRepairStats.stillMissing > 0 && ( <> {" · "} {nitroRepairStats.stillMissing} still missing )}
)} {sqlText && (
Catalog SQL:{" "} {summary.catalogSqlCount} statement(s) generated {sqlApplied && ( <> {" · "} {sqlApplied.applied} applied {sqlApplied.failed > 0 && ( <> {" · "} {sqlApplied.failed} failed )} )}
)} {furniDataResult && (
FurnitureData repair:{" "} {furniDataResult.added} added {furniDataResult.removedDuplicates > 0 && ( <> {" · "} {furniDataResult.removedDuplicates} duplicates removed )}
)} {structureResult && (
Structural repair:{" "} {structureResult.orphanedRemoved > 0 && ( <> {structureResult.orphanedRemoved} orphaned rows removed {" · "} )} {structureResult.orphanedCleaned > 0 && ( <> {structureResult.orphanedCleaned} cleaned {" · "} )} {structureResult.duplicatesMerged > 0 && ( <> {structureResult.duplicatesMerged} classname(s) merged {" · "} )} {structureResult.duplicateRowsRemoved > 0 && ( <> {structureResult.duplicateRowsRemoved} duplicate rows removed {" · "} )} {structureResult.remappedReferences} reference(s) remapped
)}
{/* Tabs */} {issues.length > 0 || missingFromSources.length > 0 ? (
) : null} {/* Issues list */} {activeTab === "issues" && issues.length > 0 && (
{issues.map((issue) => (
{TYPE_LABELS[issue.type] || issue.type} {issue.classname && ( {issue.classname} )} {issue.sourceName && ( source: {issue.sourceName} )}

{issue.message}

))}
)} {/* Missing from sources list */} {activeTab === "sources" && missingFromSources.length > 0 && (
{missingFromSources.map((item) => (
{item.classname} — {item.itemName} in {item.sourceName}
))}
)} {activeTab === "issues" && issues.length === 0 && missingFromSources.length === 0 && (

No issues found

Your catalog is clean!

)} )} {!loading && !summary && (

Run a full audit to check for missing catalog entries, broken references, missing files, and items available from clone sources.

)}
); }