833 lines
24 KiB
TypeScript
833 lines
24 KiB
TypeScript
"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 “Scan Assets” 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
|
|
</>
|
|
)}
|
|
· {(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>
|
|
);
|
|
}
|