Remove import/repair feature and related API routes.
Local Build and Deploy / deploy (push) Successful in 1m41s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 18:52:38 +02:00
1 parent bb7d581084
commit 49d204e85c
9 files changed
+2 -1468

No files matched your search

-7
View File
@@ -5,7 +5,6 @@ import {
Dumbbell,
Puzzle,
Shirt,
Wrench,
} from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
@@ -56,12 +55,6 @@ export default async function ImportPage() {
icon: Copy,
description: t("cloneDescription"),
},
{
href: "/admin/import/repair",
label: t("repair"),
icon: Wrench,
description: t("repairDescription"),
},
] as const;
return (
-12
View File
@@ -1,12 +0,0 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { RepairClient } from "./repair-client";
export default async function RepairImportedPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <RepairClient />;
}
@@ -1,832 +0,0 @@
"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<string, (m: MissingItem) => 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<AuditSummary | null>(null);
const [auditing, setAuditing] = useState(false);
const [repairing, setRepairing] = useState(false);
const [search, setSearch] = useState("");
const [tab, setTab] = useState<keyof typeof TAB_FILTERS>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [progress, setProgress] = useState<Map<string, ItemProgress>>(
new Map(),
);
const [lastSummary, setLastSummary] = useState<RepairSummary | null>(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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Repair Imported Assets
</h1>
<p className="text-muted-foreground">
Re-download missing{" "}
<code className="rounded bg-muted px-1">.nitro</code> bundles and
icons for items already imported. Items with no public asset on
habbo.com can be deleted from here.
</p>
</div>
<div className="flex gap-2">
<Button onClick={runAudit} disabled={auditing || repairing}>
{auditing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Scanning…
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />{" "}
{summary ? "Re-scan" : "Scan Assets"}
</>
)}
</Button>
</div>
</div>
{/* Initial empty state */}
{!summary && !auditing && (
<div className="rounded-md border bg-muted/30 p-10 text-center">
<Wrench className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
<p className="font-medium">
Click &ldquo;Scan Assets&rdquo; to audit imported furniture.
</p>
<p className="text-sm text-muted-foreground">
This checks every row in <code>items_base</code> against files on
disk. Scanning ~33k items takes a few seconds.
</p>
</div>
)}
{/* Stats */}
{summary && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
<StatCard
label="Imported total"
value={summary.total}
tone="default"
/>
<StatCard
label="Missing both"
value={summary.missingBoth}
tone="danger"
sub="no .nitro · no icon"
/>
<StatCard
label="Missing .nitro"
value={summary.missingNitro}
tone="warn"
sub="shows black cube"
/>
<StatCard
label="Missing icon"
value={summary.missingIcon}
tone="info"
sub="generic icon in client"
/>
<StatCard
label="Unrepairable"
value={summary.unrepairable}
tone="danger"
sub="no asset on habbo.com"
/>
</div>
)}
{/* Batch repair summary */}
{lastSummary && (
<div className="rounded-md border bg-muted/30 p-3 text-sm">
Last run: <b>{lastSummary.succeeded}</b> fixed
{lastSummary.partial > 0 && (
<>
, <b>{lastSummary.partial}</b> partial
</>
)}
{lastSummary.failed > 0 && (
<>
, <b className="text-destructive">{lastSummary.failed}</b> failed
</>
)}
{lastSummary.unrepairable > 0 && (
<>
, <b className="text-destructive">{lastSummary.unrepairable}</b>{" "}
unrepairable
</>
)}
&nbsp;·&nbsp;{(lastSummary.duration / 1000).toFixed(1)}s
</div>
)}
{/* Table area */}
{summary && summary.items.length === 0 && (
<div className="rounded-md border bg-muted/30 p-10 text-center">
<CheckCircle2 className="mx-auto mb-3 h-10 w-10 text-[var(--admin-success)]" />
<p className="font-medium">
All imported items have their assets on disk.
</p>
<p className="text-sm text-muted-foreground">
{summary.total} items scanned, nothing to repair.
</p>
</div>
)}
{summary && summary.items.length > 0 && (
<>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[240px]">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter by classname, name, or id…"
className="pl-8"
/>
</div>
<Button
variant="default"
onClick={() => {
// Skip unrepairable items — no point paying the network cost for them
const pool =
selectedCount > 0
? selectedArray
: filtered.map((i) => i.classname);
const unrep =
summary?.items
.filter((i) => i.unrepairable)
.map((i) => i.classname) ?? [];
const unrepSet = new Set(unrep);
const targets = pool.filter((cn) => !unrepSet.has(cn));
if (targets.length === 0) {
toast.info(
"Nothing to repair — selected items are all flagged unrepairable.",
);
return;
}
runRepair(targets);
}}
disabled={repairing || filtered.length === 0}
>
{repairing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Repairing…
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
{selectedCount > 0
? `Repair Selected (${selectedCount})`
: `Repair All Visible (${filtered.length})`}
</>
)}
</Button>
<Button
variant="destructive"
onClick={() => setConfirmDeleteAll(true)}
disabled={repairing || selectedCount === 0}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected ({selectedCount})
</Button>
{summary && summary.unrepairable > 0 && (
<Button
variant="destructive"
onClick={() => {
// Auto-select every unrepairable item, then open the same confirm dialog
const unrep = summary.items
.filter((i) => i.unrepairable)
.map((i) => i.classname);
setSelected(new Set(unrep));
setConfirmDeleteAll(true);
}}
disabled={repairing}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete All Unrepairable ({summary.unrepairable})
</Button>
)}
</div>
{/* Tabs */}
<Tabs
value={tab}
onValueChange={(v) => setTab(v as keyof typeof TAB_FILTERS)}
>
<TabsList>
<TabsTrigger value="all">
All ({summary.items.length})
</TabsTrigger>
<TabsTrigger value="both">
Both ({summary.missingBoth})
</TabsTrigger>
<TabsTrigger value="nitro">
.nitro ({summary.missingNitro})
</TabsTrigger>
<TabsTrigger value="icon">
Icon ({summary.missingIcon})
</TabsTrigger>
<TabsTrigger value="unrepairable">
Unrepairable ({summary.unrepairable})
</TabsTrigger>
</TabsList>
<TabsContent value={tab} className="mt-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-[80px]">ID</TableHead>
<TableHead>Classname</TableHead>
<TableHead className="w-[90px]">Rev</TableHead>
<TableHead className="w-[100px]">.nitro</TableHead>
<TableHead className="w-[100px]">Icon</TableHead>
<TableHead className="w-[100px]">SWF</TableHead>
<TableHead className="w-[240px]">Status</TableHead>
<TableHead className="w-[120px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell
colSpan={9}
className="py-10 text-center text-muted-foreground"
>
No items match the current filter.
</TableCell>
</TableRow>
)}
{filtered.map((it) => (
<ItemRow
key={it.classname}
item={it}
selected={selected.has(it.classname)}
onToggle={() => toggleOne(it.classname)}
progress={progress.get(it.classname)}
onRepair={() => runRepair([it.classname])}
disabled={repairing}
/>
))}
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
</>
)}
<Dialog open={confirmDeleteAll} onOpenChange={setConfirmDeleteAll}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
Delete {selectedCount} imported item
{selectedCount === 1 ? "" : "s"}?
</DialogTitle>
<DialogDescription>
This removes the rows from <code>items_base</code> /{" "}
<code>catalog_items</code>, the entry in{" "}
<code>FurnitureData.json</code>, 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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDeleteAll(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={deleteSelected}>
<Trash2 className="mr-2 h-4 w-4" /> Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── 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 (
<div className={`rounded-md border p-4 ${tones[tone]}`}>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-1 text-2xl font-bold">{value.toLocaleString()}</div>
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
function FileBadge({ present }: { present: boolean }) {
return present ? (
<Badge
variant="secondary"
className="border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)]"
>
<CheckCircle2 className="mr-1 h-3 w-3" /> On disk
</Badge>
) : (
<Badge
variant="secondary"
className="border-destructive/40 bg-destructive/10 text-destructive"
>
<XCircle className="mr-1 h-3 w-3" /> Missing
</Badge>
);
}
function StatusCell({ progress }: { progress?: ItemProgress }) {
if (!progress || progress.status === "idle")
return <span className="text-muted-foreground text-xs">—</span>;
if (progress.status === "working") {
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{progress.message || "Working…"}
</span>
);
}
if (progress.status === "done") {
const parts = [
progress.nitroRepaired ? ".nitro ✓" : null,
progress.iconRepaired ? "icon ✓" : null,
].filter(Boolean);
return (
<span className="flex items-center gap-1 text-xs text-[var(--admin-success)] dark:text-[var(--admin-success)]">
<CheckCircle2 className="h-3 w-3" />
{parts.length > 0 ? parts.join(" · ") : "Repaired"}
</span>
);
}
if (progress.status === "partial") {
return (
<span
className="flex items-center gap-1 text-xs text-[var(--admin-warning)] dark:text-[var(--admin-warning)]"
title={progress.message}
>
<AlertCircle className="h-3 w-3" />
Partial · {progress.message || "check logs"}
</span>
);
}
return (
<span
className="flex items-center gap-1 text-xs text-destructive"
title={progress.message}
>
<XCircle className="h-3 w-3" />
{progress.message || "Failed"}
</span>
);
}
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 (
<TableRow className={isUnrepairable ? "bg-destructive/5" : undefined}>
<TableCell>
<Checkbox
checked={selected}
onCheckedChange={onToggle}
aria-label={`Select ${item.classname}`}
/>
</TableCell>
<TableCell className="font-mono text-xs">{item.id}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{item.classname}</span>
{isUnrepairable && (
<Badge
variant="secondary"
className="border-destructive/40 bg-destructive/10 text-destructive"
>
<AlertCircle className="mr-1 h-3 w-3" /> Unrepairable
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">{item.publicName}</div>
</TableCell>
<TableCell className="text-xs">{item.revision || "—"}</TableCell>
<TableCell>
<FileBadge present={item.hasNitro} />
</TableCell>
<TableCell>
<FileBadge present={item.hasIcon} />
</TableCell>
<TableCell>
<FileBadge present={item.hasSwf} />
</TableCell>
<TableCell>
<StatusCell progress={progress} />
</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={onRepair}
disabled={
disabled || progress?.status === "working" || isUnrepairable
}
title={
isUnrepairable
? "habbo.com does not have this asset"
: "Retry download + convert"
}
>
{progress?.status === "working" ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Wrench className="h-3 w-3" />
)}
</Button>
</TableCell>
</TableRow>
);
}