"use client"; import { useVirtualizer } from "@tanstack/react-virtual"; import { AlertTriangle, CheckCircle2, Clock, Download, FileX2, Loader2, ScanSearch, ScrollText, Sparkles, Trash2, Wrench, X, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, 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"; import { readSseStream } from "@/lib/sse-client"; interface AssetEntry { fileName: string; base: string; size: number; dirs: string[]; lastModified: number; error?: string; } type GroupKey = "fake" | "broken" | "orphanedSwf" | "orphanedIcon"; interface ScanData { fake: AssetEntry[]; broken: AssetEntry[]; orphanedSwf: AssetEntry[]; orphanedIcon: AssetEntry[]; total: number; cached: boolean; } interface ScanSession { id: string; state: "running" | "done" | "cancelled" | "error"; startedAt: string; updatedAt: string; force?: boolean; progress?: { phase: string; scanned: number }; cached?: boolean; total?: number; error?: string; } /** * The gamedata cleanup reports its own shape instead of importing the scan * module, which pulls in `node:fs` and cannot be bundled for the browser. */ interface GamedataPlan { duplicates: Array<{ classname: string; section: string; removeIndexes: number[]; keepIndex: number; hasNitro: boolean; hasIcon: boolean; differsFromSurvivor: boolean; }>; crossSection: Array<{ classname: string; sections: string[] }>; orphans: Array<{ classname: string; section: string }>; figureMapIssues: Array<{ libraryId: string; reason: string; partId: number | null; partType: string; }>; figureDataIssues: Array<{ reason: string; id: number; setType?: string; }>; warnings: string[]; } type GamedataPass = | "dedupeFurnitureData" | "cleanFurnitureData" | "cleanFigureMap" | "cleanFigureData"; /** Display order also documents that the passes are not interchangeable. */ const GAMEDATA_PASSES: GamedataPass[] = [ "dedupeFurnitureData", "cleanFurnitureData", "cleanFigureMap", "cleanFigureData", ]; 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[]; } interface HistoryEntry { triggeredAt: string; triggeredBy: "manual" | "scheduled"; maxAgeDays: number; 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; const SESSION_POLL_MS = 1500; const DATA_IDLE_TIMEOUT_MS = 90_000; const SESSION_URL = "/api/admin/studio/nitro-cleanup/session"; const DATA_URL = "/api/admin/studio/nitro-cleanup"; function ageLabel(t: (key: string) => string, lastModified: number): string { if (!lastModified) return t("ageUnknown"); const days = Math.floor((Date.now() - lastModified) / DAY_MS); if (days < 1) return t("ageBelow"); 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); } /** * Virtualized list for one cleanup group. Directories can hold tens of * thousands of leftover files; only the visible rows are mounted as DOM nodes, * so the tab never freezes no matter how long the list is. */ function VirtualAssetList({ entries, selected, disabled = false, onToggle, copiesLabel, formatAge, }: { entries: AssetEntry[]; selected: Set; disabled?: boolean; onToggle: (fileName: string) => void; copiesLabel: string; formatAge: (lastModified: number) => string; }) { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: entries.length, getScrollElement: () => parentRef.current, estimateSize: () => 30, overscan: 10, }); return (
{virtualizer.getVirtualItems().map((virtualItem) => { const entry = entries[virtualItem.index]; return (
onToggle(entry.fileName)} aria-label={`Select ${entry.fileName}`} /> {entry.fileName} {formatBytes(entry.size)} {formatAge(entry.lastModified)} {entry.error && ( {entry.error} )} {entry.dirs.length > 1 && ( {copiesLabel.replace("{count}", String(entry.dirs.length))} )}
); })}
); } function CleanupGroup({ label, description, tone, entries, selected, disabled = false, onToggle, onToggleAll, emptyLabel, selectedLabel, copiesLabel, formatAge, }: { label: string; description: string; tone: GroupTone; entries: AssetEntry[]; selected: Set; disabled?: boolean; onToggle: (fileName: string) => void; onToggleAll: (names: string[]) => void; emptyLabel: string; selectedLabel: string; copiesLabel: string; formatAge: (lastModified: number) => string; }) { 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 && ( {selectedLabel.replace("{count}", String(checked.length))} )}
{entries.length === 0 ? (

{emptyLabel}

) : ( )}
); } export function NitroCleanupPanel() { const t = useTranslations("admin.studio.nitroCleanup"); const formatAge = (lastModified: number): string => ageLabel(t, lastModified); const [scanning, setScanning] = useState(false); const [deleting, setDeleting] = useState(false); const [repairing, setRepairing] = useState(false); const [autoCleaning, setAutoCleaning] = useState(false); const [exporting, setExporting] = useState(false); const [loading, setLoading] = useState(false); const [autoAge, setAutoAge] = useState(30); const [minAgeDays, setMinAgeDays] = useState(0); const [onlyOld, setOnlyOld] = useState(false); const [session, setSession] = useState(null); const [attachedToExisting, setAttachedToExisting] = useState(false); const [scan, setScan] = useState(null); const [history, setHistory] = useState([]); const [showHistory, setShowHistory] = useState(false); const [selected, setSelected] = useState>(new Set()); const [selectedSwf, setSelectedSwf] = useState>(new Set()); const [selectedIcon, setSelectedIcon] = useState>(new Set()); const [repairProgress, setRepairProgress] = useState<{ completed: number; total: number; ok: number; failed: number; } | null>(null); const abortRef = useRef(null); const loadAbortRef = useRef(null); const [gamedataPlan, setGamedataPlan] = useState(null); const [gamedataLoading, setGamedataLoading] = useState(false); const [gamedataRunning, setGamedataRunning] = useState(false); const [gamedataPasses, setGamedataPasses] = useState>( new Set(GAMEDATA_PASSES), ); const [gamedataMaxRemovals, setGamedataMaxRemovals] = useState(0); const [gamedataLimitError, setGamedataLimitError] = useState<{ pass: string; wouldRemove: number; maxRemovals: number; partial: Record>; } | null>(null); const { confirm, dialog } = useConfirmDialog(); // Latest filter values, read from effects without putting them in deps. const filterRef = useRef({ onlyOld, minAgeDays }); useEffect(() => { filterRef.current = { onlyOld, minAgeDays }; }); const busy = scanning || deleting || repairing || autoCleaning || exporting || gamedataRunning; const running = session?.state === "running" || (scanning && !session); const fetchSession = useCallback(async (): Promise => { const res = await adminFetch(SESSION_URL, { headers: { Accept: "application/json" }, }); if (!res.ok) return null; const data = (await res.json()) as { session?: ScanSession | null }; return data.session ?? null; }, []); /** Assemble the persisted result from the chunked /data stream. */ const loadData = useCallback(async (minAge: number): Promise => { loadAbortRef.current?.abort(); const abort = new AbortController(); loadAbortRef.current = abort; setLoading(true); const params = new URLSearchParams(); if (minAge > 0) params.set("minAgeDays", String(minAge)); const holder: { summary: { total: number; cached: boolean; groups: Record; } | null; arrays: Partial>; error: string | null; } = { summary: null, arrays: {}, error: null }; try { const res = await adminFetch( `${DATA_URL}${params.size > 0 ? `?${params.toString()}` : ""}`, { headers: { Accept: "text/event-stream" }, signal: abort.signal, credentials: "same-origin", }, ); if (!res.ok) { const body = (await res.json().catch(() => null)) as { error?: string; } | null; throw new Error( typeof body?.error === "string" ? body.error : `Failed to load scan result (HTTP ${res.status})`, ); } if (!res.body) throw new Error("No response stream"); await readSseStream( res.body, (evt) => { if (evt.type === "summary") { holder.summary = { total: Number(evt.total ?? 0), cached: evt.cached === true, groups: (evt.groups as Record) ?? { fake: 0, broken: 0, orphanedSwf: 0, orphanedIcon: 0, }, }; for (const group of Object.keys( holder.summary.groups, ) as GroupKey[]) { holder.arrays[group] = new Array( holder.summary.groups[group] ?? 0, ).fill(null); } } else if (evt.type === "chunk") { const group = evt.group as GroupKey; const offset = Number(evt.offset ?? 0); const entries = (evt.entries ?? []) as AssetEntry[]; const target = holder.arrays[group]; if (target) { for (let i = 0; i < entries.length; i++) { target[offset + i] = entries[i]; } } } else if (evt.type === "error") { holder.error = String(evt.message ?? "Failed to load scan result"); } }, abort.signal, DATA_IDLE_TIMEOUT_MS, ); if (holder.error) throw new Error(holder.error); if (abort.signal.aborted) return; if (!holder.summary) throw new Error("No scan result yet"); const arr = (g: GroupKey): AssetEntry[] => (holder.arrays[g] ?? []) as AssetEntry[]; setScan({ fake: arr("fake"), broken: arr("broken"), orphanedSwf: arr("orphanedSwf"), orphanedIcon: arr("orphanedIcon"), total: holder.summary.total, cached: holder.summary.cached, }); } catch (err) { if ((err as Error).name !== "AbortError" && !abort.signal.aborted) { toast.error( err instanceof Error ? err.message : "Failed to load scan result", ); } } finally { setLoading(false); if (loadAbortRef.current === abort) loadAbortRef.current = null; } }, []); /** Attach to whatever scan is currently persisted, restarting the poll * when a new one begins in another tab. */ useEffect(() => { let active = true; const attach = async () => { const s = await fetchSession(); if (active) setSession(s); }; void attach(); return () => { active = false; }; }, [fetchSession]); // Poll while a scan is running; the detached server process keeps updating // the session file even if this exact tab reloaded between polls. useEffect(() => { if (session?.state !== "running") return; const timer = setInterval(async () => { const s = await fetchSession(); if (s) setSession(s); }, SESSION_POLL_MS); return () => clearInterval(timer); }, [session?.state, fetchSession]); // When the persisted session turns done, load the result (also covers the // very first mount where a previous scan already completed). Filter changes // re-fetch through the toggle handlers; the effect is idempotent while the // session object stays the same, and running sessions no-op here. useEffect(() => { if (session?.state === "error" && session.error) { toast.error(session.error); } if (session?.state === "done") { const filter = filterRef.current; void loadData(filter.onlyOld ? filter.minAgeDays : 0); } }, [session, loadData]); useEffect(() => { const loadHistory = async () => { const res = await adminFetch("/api/admin/studio/nitro-cleanup/history", { headers: { Accept: "application/json" }, }); if (!res.ok) return; const data = (await res.json()) as { history?: HistoryEntry[] }; setHistory(data.history ?? []); }; void loadHistory(); }, []); const startScan = useCallback( async (force: boolean) => { if (busy || running) return; setScanning(true); setAttachedToExisting(false); try { const res = await adminFetch("/api/admin/studio/nitro-cleanup/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ force }), }); const data = (await res.json()) as { session?: ScanSession; conflict?: boolean; error?: string; }; if (!res.ok || !data.session) { throw new Error( data.error || `Failed to start scan (HTTP ${res.status})`, ); } setSession(data.session); setScan(null); setSelected(new Set()); setSelectedSwf(new Set()); setSelectedIcon(new Set()); if (data.conflict) { setAttachedToExisting(true); toast.info(t("alreadyRunning")); } } catch (err) { toast.error( err instanceof Error ? err.message : "Failed to start scan", ); } finally { setScanning(false); } }, [busy, running, t], ); const cancelScan = useCallback(async () => { await adminFetch("/api/admin/studio/nitro-cleanup/cancel", { method: "POST", headers: { Accept: "application/json" }, }); 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; /** Drop deleted/attempted files from the loaded scan without re-scanning. */ const dropFromScan = (kind: AssetKind, names: string[], removed = 0) => { const remove = new Set(names); if (remove.size === 0) return; setScan((prev) => { if (!prev) return prev; const drop = (list: T[]): T[] => list.filter((entry) => !remove.has(entry.fileName)); return { ...prev, fake: kind === "nitro" ? drop(prev.fake) : prev.fake, broken: kind === "nitro" ? drop(prev.broken) : prev.broken, orphanedSwf: kind === "swf" ? drop(prev.orphanedSwf) : prev.orphanedSwf, orphanedIcon: kind === "icon" ? drop(prev.orphanedIcon) : prev.orphanedIcon, total: Math.max(0, prev.total - removed), }; }); }; 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: t("confirmDeleteTitle", { count: totalSelected }), description: t("confirmDeleteDescription"), confirmLabel: t("confirmDeleteLabel"), 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(DATA_URL, { 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; dropFromScan(group.kind, group.names, data.deleted ?? 0); } if (hadErrors) { toast.warning(t("toastDeletePartial", { copies: copiesRemoved })); } else { toast.success(t("toastDeleteDone", { copies: copiesRemoved, deleted })); } setSelected(new Set()); setSelectedSwf(new Set()); setSelectedIcon(new Set()); } 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: t("confirmRepairTitle", { count: fileNames.length }), description: t("confirmRepairDescription"), confirmLabel: t("confirmRepairLabel"), variant: "default", }); if (!confirmed) return; abortRef.current?.abort(); setRepairing(true); setRepairProgress(null); const abort = new AbortController(); abortRef.current = abort; const holder: { result: RepairResponse | null } = { result: null }; let lastError: string | null = null; try { const res = await adminFetch("/api/admin/studio/nitro-cleanup/rebuild", { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", }, body: JSON.stringify({ fileNames }), signal: abort.signal, }); if (!res.ok) { const body = (await res.json().catch(() => null)) as { error?: string; } | null; throw new Error( typeof body?.error === "string" ? body.error : `Repair failed (HTTP ${res.status})`, ); } if (!res.body) throw new Error("Repair returned no response body"); await readSseStream( res.body, (evt) => { if (evt.type === "progress") { setRepairProgress({ completed: Number(evt.completed ?? 0), total: Number(evt.total ?? fileNames.length), ok: Number(evt.ok ?? 0), failed: Number(evt.failed ?? 0), }); } else if (evt.type === "result") { holder.result = { repaired: Number(evt.repaired ?? 0), failed: Number(evt.failed ?? 0), errors: Array.isArray(evt.errors) ? evt.errors.map(String) : undefined, }; } else if (evt.type === "error") { lastError = String(evt.message ?? "Repair failed"); } }, abort.signal, 120_000, ); if (lastError) throw new Error(lastError); const data = holder.result; if (data && data.repaired > 0) { toast.success(t("toastRepairDone", { count: data.repaired })); } else if (data) { toast.warning(t("toastRepairFailed", { failed: data.failed })); } // Only drop the attempted bundles when every one succeeded; partial // failures stay visible so they can be retried. if (data && data.repaired >= fileNames.length) { dropFromScan("nitro", fileNames); } setSelected(new Set()); } catch (err) { if ((err as Error).name === "AbortError") { toast.info(t("toastRepairCancelled")); } else { toast.error(err instanceof Error ? err.message : "Repair failed"); } } finally { setRepairing(false); setRepairProgress(null); if (abortRef.current === abort) abortRef.current = null; } }; const autoClean = async () => { if (scan === null) return; const confirmed = await confirm({ title: t("confirmAutoCleanTitle", { days: autoAge }), description: t("confirmAutoCleanDescription"), confirmLabel: t("confirmAutoCleanLabel"), 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( t("toastAutoCleanDone", { deleted: data.deleted, copies: data.copiesRemoved, recent: data.skippedRecent, broken: data.skippedBroken, }), ); const removable = (scan.fake ?? []) .filter( (entry) => Math.floor((Date.now() - entry.lastModified) / DAY_MS) >= autoAge, ) .map((entry) => entry.fileName); dropFromScan("nitro", removable, data.deleted); } else { toast.info( t("toastAutoCleanNothing", { recent: data.skippedRecent, broken: data.skippedBroken, }), ); } const hres = await adminFetch("/api/admin/studio/nitro-cleanup/history", { headers: { Accept: "application/json" }, }); const hdata = (await hres.json()) as { history?: HistoryEntry[] }; setHistory(hdata.history ?? []); } catch (err) { toast.error(err instanceof Error ? err.message : "Auto-clean failed"); } finally { setAutoCleaning(false); } }; const loadGamedataPlan = useCallback(async (): Promise => { setGamedataLoading(true); try { const res = await adminFetch( "/api/admin/studio/nitro-cleanup/gamedata", { headers: { Accept: "application/json" } }, ); const data = (await res.json()) as { plan?: GamedataPlan; error?: string; }; if (!res.ok) throw new Error(data.error || "Preview failed"); setGamedataPlan(data.plan ?? null); return data.plan ?? null; } catch (err) { toast.error( err instanceof Error ? err.message : "Gamedata preview failed", ); return null; } finally { setGamedataLoading(false); } }, []); const toggleGamedataPass = (pass: GamedataPass) => { setGamedataPasses((prev) => { const next = new Set(prev); if (next.has(pass)) next.delete(pass); else next.add(pass); return next; }); }; const runGamedataCleanup = async (maxRemovals?: number) => { const plan = gamedataPlan ?? (await loadGamedataPlan()); if (!plan) return; if (gamedataPasses.size === 0) { toast.error(t("gamedataNothingSelected")); return; } const confirmed = await confirm({ title: t("gamedataConfirmTitle"), description: t("gamedataConfirmDescription", { duplicates: plan.duplicates.length, orphans: plan.orphans.length, figureMap: plan.figureMapIssues.length, figureData: plan.figureDataIssues.length, passes: gamedataPasses.size, }), confirmLabel: t("gamedataConfirmLabel"), variant: "danger", }); if (!confirmed) return; setGamedataRunning(true); setGamedataLimitError(null); try { const res = await adminFetch( "/api/admin/studio/nitro-cleanup/gamedata/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirm: true, passes: [...gamedataPasses], ...(maxRemovals === undefined ? {} : { maxRemovals }), }), }, ); const data = (await res.json()) as { result?: Record>; error?: string; pass?: string; wouldRemove?: number; maxRemovals?: number; partial?: Record>; }; if (res.status === 409) { // A pass refused to write. Nothing was written by that pass, so // the files are as they were; the operator decides whether the // limit was wrong or the paths are. setGamedataLimitError({ pass: data.pass ?? "", wouldRemove: data.wouldRemove ?? 0, maxRemovals: data.maxRemovals ?? 0, partial: data.partial ?? {}, }); toast.error(data.error || "Gamedata cleanup refused"); await loadGamedataPlan(); return; } if (!res.ok) throw new Error(data.error || "Gamedata cleanup failed"); const removed = Object.values(data.result ?? {}).reduce( (total, counts) => total + Object.values(counts).reduce((sum, n) => sum + n, 0), 0, ); if (removed > 0) toast.success(t("gamedataToastDone", { removed })); else toast.info(t("gamedataToastNothing")); // Re-read: the plan is derived from files this just rewrote. await loadGamedataPlan(); } catch (err) { toast.error( err instanceof Error ? err.message : "Gamedata cleanup failed", ); } finally { setGamedataRunning(false); } }; const exportManifest = async () => { if (scan === null) return; setExporting(true); try { const res = await adminFetch(`${DATA_URL}?full=1`, { headers: { Accept: "application/json" }, }); const data = (await res.json()) as ScanData & { error?: string; }; if (!res.ok || data.error) { throw new Error(data.error || `Export failed (HTTP ${res.status})`); } const entries = { scannedAt: new Date().toISOString(), total: data.total ?? 0, fake: data.fake ?? [], broken: data.broken ?? [], orphanedSwf: data.orphanedSwf ?? [], orphanedIcon: data.orphanedIcon ?? [], selected: { nitro: [...selected].sort(), swf: [...selectedSwf].sort(), icon: [...selectedIcon].sort(), }, }; downloadJson(`nitro-cleanup-scan-${Date.now()}.json`, entries); toast.success(t("toastManifestDownload")); } catch (err) { toast.error(err instanceof Error ? err.message : "Export failed"); } finally { setExporting(false); } }; const toggleOnlyOld = (value: boolean) => { setOnlyOld(value); const threshold = value ? Math.max(0, minAgeDays) : 0; if (session?.state === "done") void loadData(threshold); }; const changeMinAgeDays = (value: number) => { const days = Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0; setMinAgeDays(days); if (onlyOld && session?.state === "done") void loadData(days); }; const phaseLabel = (phase: string): string => phase === "stems" ? t("phaseStems") : phase === "readdir" ? t("phaseReaddir") : phase === "validate" ? t("phaseValidate") : t("phaseScanning"); const groupProps = ( key: GroupKey, ): { label: string; description: string; tone: GroupTone; } => { switch (key) { case "fake": return { label: t("groupFakeLabel"), description: t("groupFakeDescription"), tone: "error", }; case "broken": return { label: t("groupBrokenLabel"), description: t("groupBrokenDescription"), tone: "warning", }; case "orphanedSwf": return { label: t("groupSwfLabel"), description: t("groupSwfDescription"), tone: "warning", }; default: return { label: t("groupIconLabel"), description: t("groupIconDescription"), tone: "neutral", }; } }; return (
{dialog}
{running && ( )} {scan && scan.fake.length > 0 && ( <>
setAutoAge(Number(e.target.value))} aria-label={t("autoAgeLabel")} /> {t("daysLabel")}
)} {scan && ( )}
{running && session?.progress && (
{phaseLabel(session.progress.phase)} {session.progress.scanned > 0 && ( {t("scanProgressFiles", { count: session.progress.scanned, })} )} {attachedToExisting && ( {t("attachedNotice")} )}
)} {repairing && repairProgress && (
{t("redownloading")} {repairProgress.completed}/{repairProgress.total} —{" "} {t("repairProgress", { ok: repairProgress.ok, failed: repairProgress.failed, })}
)} {session?.state === "cancelled" && (

{t("sessionCancelled")}

)}
{t("gamedataTitle")}

{t("gamedataHint")}

{gamedataPlan === null ? (

{t("gamedataNoResult")}

) : ( <>
{( [ ["duplicates", gamedataPlan.duplicates.length], ["orphans", gamedataPlan.orphans.length], ["figureMap", gamedataPlan.figureMapIssues.length], ["figureData", gamedataPlan.figureDataIssues.length], ] as const ).map(([key, count]) => (
{t(`gamedataCount_${key}`)}
{count}
))}
{GAMEDATA_PASSES.map((pass) => (
toggleGamedataPass(pass)} className="mt-0.5" />
))}
{gamedataPlan.duplicates.some((d) => d.differsFromSurvivor) && (

{t("gamedataDifferingWarning")}

)} {gamedataPlan.crossSection.length > 0 && (

{t("gamedataCrossSectionNote", { count: gamedataPlan.crossSection.length, })}

)} {gamedataPlan.warnings.map((warning) => (

{warning}

))}
setGamedataMaxRemovals(Number(e.target.value))} aria-label={t("gamedataMaxRemovalsLabel")} /> {t("gamedataMaxRemovalsHint")}
{gamedataLimitError && (

{t("gamedataLimitTitle", { pass: gamedataLimitError.pass, wouldRemove: gamedataLimitError.wouldRemove, maxRemovals: gamedataLimitError.maxRemovals, })}

{t("gamedataLimitDescription")}

)} )}
{scan && (
{t("onlyOldLabel")}
changeMinAgeDays(Number(e.target.value))} aria-label={t("onlyOldAgeLabel")} /> {t("daysLabel")}
{totalSelected > 0 && (
{t("selectedCount", { count: totalSelected })} {scan.broken.length > 0 && [...selected].some((n) => scan.broken.some((e) => e.fileName === n), ) && ( )}
)} {loading && (
{t("loadingResult")}
)}
{scan.total}
{t("scannedStat")} {scan.cached && ( {" "} {t("cachedNotice")} )}
{scan.fake.length}
{t("fakeStat")}
{scan.broken.length}
{t("brokenStat")}
{( [ ["fake", selected], ["broken", selected], ["orphanedSwf", selectedSwf], ["orphanedIcon", selectedIcon], ] as Array<[GroupKey, Set]> ).map(([key, set]) => { const props = groupProps(key); const entries = key === "fake" ? scan.fake : key === "broken" ? scan.broken : key === "orphanedSwf" ? scan.orphanedSwf : scan.orphanedIcon; return ( ); })} {(scan.fake.length > 0 || scan.broken.length > 0) && (

{t("warningText")}

)}
{t("onlyAssetsTouched")}
)} {!scan && !session && !running && !loading && (

{t("emptyHint")}

)} {!scan && session?.state === "done" && !loading && (

{t("noResult")}

)} {!scan && session?.state === "error" && !loading && (

{t("scanFailed")}

{session.error && (

{session.error}

)}
)} {history.length > 0 && (
{showHistory && (
    {history.map((entry) => (
  • {entry.triggeredBy === "scheduled" ? t("historyScheduled") : t("historyManual")} {new Date(entry.triggeredAt).toLocaleString()} {t("historyEntry", { deleted: entry.deleted, copies: entry.copiesRemoved, recent: entry.skippedRecent, broken: entry.skippedBroken, })} {entry.errors.length > 0 && ( {t("historyErrors", { count: entry.errors.length })} )}
  • ))}
)}
)}
); }