feat: add .nitro upload + sync-all + repair-icons features
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 32s

- Upload .nitro bundles directly with full DB, catalog, and furnidata integration
- Generate SQL migration files on upload (optional)
- Auto-sync missing furniture from all configured clone sources (SSE batch)
- Repair missing icons by extracting from local .nitro or downloading from sources
- All behind ASSETS_IMPORT permission
This commit is contained in:
openhands committed 2026-07-21 14:53:26 +02:00
1 parent c9144dc293
commit 804daeffca
13 files changed
+1484 -1

No files matched your search

@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { RepairIconsClient } from "./repair-icons-client";
export default async function RepairIconsPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Repair Missing Icons
</h1>
<p className="text-muted-foreground mt-1">
Scan all existing furniture and download any missing icon files. Icons
are extracted from local .nitro bundles or fetched from configured
clone sources.
</p>
</div>
<RepairIconsClient />
</div>
);
}
@@ -0,0 +1,159 @@
"use client";
import { ImageIcon, Loader2, Wrench } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
interface RepairEvent {
type: string;
classname?: string;
status?: string;
succeeded?: number;
failed?: number;
}
export function RepairIconsClient() {
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [log, setLog] = useState<string[]>([]);
const [result, setResult] = useState<{
succeeded: number;
failed: number;
} | null>(null);
const startRepair = useCallback(async () => {
setRunning(true);
setProgress(null);
setLog([]);
setResult(null);
try {
const res = await adminFetch("/api/admin/import/furni/repair-icons", {
method: "POST",
});
if (!res.body) {
toast.error("No response stream");
setRunning(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
let totalItems = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) 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 RepairEvent;
if (
evt.type === "item_progress" &&
(evt.status === "done" || evt.status === "failed")
) {
totalItems++;
if (evt.status === "done") succeeded++;
else failed++;
setProgress({ done: succeeded + failed, total: totalItems });
setLog((prev) => [
...prev,
`${evt.status === "done" ? "✓" : "✗"} ${evt.classname ?? ""}`,
]);
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded ?? succeeded;
failed = evt.failed ?? failed;
}
} catch {
// skip parse errors
}
}
}
setResult({ succeeded, failed });
if (succeeded > 0) toast.success(`${succeeded} icons repaired`);
if (failed > 0) toast.error(`${failed} icons could not be obtained`);
} catch {
toast.error("Repair request failed");
} finally {
setRunning(false);
}
}, []);
return (
<div className="space-y-4">
{!result && (
<Button onClick={startRepair} disabled={running} size="lg">
{running ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Wrench className="w-4 h-4 mr-2" />
)}
{running
? `Repairing… ${progress ? `${progress.done}/${progress.total}` : ""}`
: "Start Icon Repair"}
</Button>
)}
{progress && (
<div className="text-sm text-muted-foreground">
Processed {progress.done} items
{progress.total > 0 ? ` out of ${progress.total}` : ""}
</div>
)}
{log.length > 0 && (
<div className="rounded-xl border bg-card p-4 max-h-80 overflow-y-auto space-y-0.5">
{log.map((line) => (
<div key={line} className="text-xs font-mono text-muted-foreground">
{line}
</div>
))}
</div>
)}
{result && (
<div className="rounded-xl border bg-card p-5 space-y-3">
<div className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Repair Complete</span>
</div>
<div className="text-sm space-y-1">
<p>
<span className="text-muted-foreground">Icons downloaded:</span>{" "}
{result.succeeded}
</p>
<p>
<span className="text-muted-foreground">Failed:</span>{" "}
{result.failed}
</p>
</div>
</div>
)}
{!running && !result && (
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground text-sm">
<ImageIcon className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>
Scans all furniture in the database and downloads any missing icon
files. Icons are extracted from local .nitro bundles or downloaded
from configured clone sources.
</p>
</div>
)}
</div>
);
}