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 { UploadFurniClient } from "./upload-furni-client";
export default async function UploadFurniPage() {
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">
Upload .nitro Furniture
</h1>
<p className="text-muted-foreground mt-1">
Upload a .nitro bundle and optional icon to import furniture into the
hotel. The database entry, catalog item, and FurnitureData.json will
be updated automatically.
</p>
</div>
<UploadFurniClient />
</div>
);
}
@@ -0,0 +1,331 @@
"use client";
import { AlertCircle, Check, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { adminFetch } from "@/lib/admin-fetch";
interface UploadResult {
itemId: number;
catalogItemId: number | null;
classname: string;
warnings: string[];
}
export function UploadFurniClient() {
const nitroRef = useRef<HTMLInputElement>(null);
const iconRef = useRef<HTMLInputElement>(null);
const [classname, setClassname] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [itemType, setItemType] = useState<"s" | "i">("s");
const [xdim, setXdim] = useState(1);
const [ydim, setYdim] = useState(1);
const [canstandon, setCanstandon] = useState(false);
const [cansiton, setCansiton] = useState(false);
const [canlayon, setCanlayon] = useState(false);
const [interactionType, setInteractionType] = useState("default");
const [customparams, setCustomparams] = useState("");
const [nitroFile, setNitroFile] = useState<File | null>(null);
const [iconFile, setIconFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<UploadResult | null>(null);
const [error, setError] = useState("");
function resetForm() {
setClassname("");
setName("");
setDescription("");
setItemType("s");
setXdim(1);
setYdim(1);
setCanstandon(false);
setCansiton(false);
setCanlayon(false);
setInteractionType("default");
setCustomparams("");
setNitroFile(null);
setIconFile(null);
setResult(null);
setError("");
if (nitroRef.current) nitroRef.current.value = "";
if (iconRef.current) iconRef.current.value = "";
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setUploading(true);
setError("");
setResult(null);
try {
const fd = new FormData();
fd.set("classname", classname.trim());
fd.set("name", name.trim());
fd.set("description", description.trim());
fd.set("itemType", itemType);
fd.set("xdim", String(xdim));
fd.set("ydim", String(ydim));
fd.set("canstandon", String(canstandon));
fd.set("cansiton", String(cansiton));
fd.set("canlayon", String(canlayon));
fd.set("interactionType", interactionType.trim());
fd.set("customparams", customparams.trim());
if (nitroFile) fd.set("nitro", nitroFile);
if (iconFile) fd.set("icon", iconFile);
const res = await adminFetch("/api/admin/import/furni/upload", {
method: "POST",
body: fd,
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Upload failed");
toast.error(data.error || "Upload failed");
return;
}
setResult(data);
toast.success(`${data.classname} imported successfully`);
} catch {
const msg = "Network error";
setError(msg);
toast.error(msg);
} finally {
setUploading(false);
}
}
return (
<div className="space-y-6">
{!result ? (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-xl border bg-card p-5 space-y-5">
<div className="text-sm font-medium">Furniture Details</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="classname">Classname *</Label>
<Input
id="classname"
placeholder="e.g. rare_dragon"
value={classname}
onChange={(e) => setClassname(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
placeholder="e.g. Dragon Egg"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Optional description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor="itemType">Type *</Label>
<select
id="itemType"
value={itemType}
onChange={(e) => setItemType(e.target.value as "s" | "i")}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="xdim">Width (x)</Label>
<Input
id="xdim"
type="number"
min={1}
max={64}
value={xdim}
onChange={(e) => setXdim(Number(e.target.value))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ydim">Length (y)</Label>
<Input
id="ydim"
type="number"
min={1}
max={64}
value={ydim}
onChange={(e) => setYdim(Number(e.target.value))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="interactionType">Interaction</Label>
<Input
id="interactionType"
placeholder="default"
value={interactionType}
onChange={(e) => setInteractionType(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={canstandon}
onChange={(e) => setCanstandon(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<span className="text-sm">Can stand on</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={cansiton}
onChange={(e) => setCansiton(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<span className="text-sm">Can sit on</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={canlayon}
onChange={(e) => setCanlayon(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<span className="text-sm">Can lay on</span>
</label>
</div>
<div className="space-y-1.5">
<Label htmlFor="customparams">Custom params</Label>
<Input
id="customparams"
placeholder="Optional custom parameters"
value={customparams}
onChange={(e) => setCustomparams(e.target.value)}
/>
</div>
</div>
<div className="rounded-xl border bg-card p-5 space-y-4">
<div className="text-sm font-medium">Files</div>
<div className="space-y-1.5">
<Label htmlFor="nitro">.nitro file *</Label>
<Input
ref={nitroRef}
id="nitro"
type="file"
accept=".nitro"
onChange={(e) => setNitroFile(e.target.files?.[0] ?? null)}
required
/>
<p className="text-xs text-muted-foreground">
The .nitro bundle file (max 50MB)
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="icon">Icon (PNG)</Label>
<Input
ref={iconRef}
id="icon"
type="file"
accept=".png,image/png"
onChange={(e) => setIconFile(e.target.files?.[0] ?? null)}
/>
<p className="text-xs text-muted-foreground">
Optional 64x64 PNG icon (max 2MB). Leave empty to skip.
</p>
</div>
</div>
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
<div className="flex gap-3">
<Button type="submit" disabled={uploading}>
{uploading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{uploading ? "Importing…" : "Import Furniture"}
</Button>
</div>
</form>
) : (
<div className="rounded-xl border bg-card p-6 space-y-4">
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Import Successful</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Classname:</span>
<code className="font-mono">{result.classname}</code>
</div>
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Item ID:</span>
<code className="font-mono">{result.itemId}</code>
</div>
{result.catalogItemId && (
<div className="flex gap-2">
<span className="text-muted-foreground w-32">Catalog ID:</span>
<code className="font-mono">{result.catalogItemId}</code>
</div>
)}
{result.warnings.length > 0 && (
<div className="space-y-1 pt-2">
<span className="text-muted-foreground font-medium">
Warnings:
</span>
{result.warnings.map((w) => (
<div
key={w}
className="flex items-center gap-1.5 text-yellow-600 dark:text-yellow-400"
>
<AlertCircle className="w-3 h-3 shrink-0" />
<span>{w}</span>
</div>
))}
</div>
)}
</div>
<Button variant="outline" onClick={resetForm}>
Import Another
</Button>
</div>
)}
</div>
);
}
+21
View File
@@ -3,8 +3,11 @@ import {
Cat,
Copy,
Dumbbell,
ImageIcon,
Puzzle,
RefreshCw,
Shirt,
Upload,
} from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
@@ -31,6 +34,12 @@ export default async function ImportPage() {
icon: Puzzle,
description: t("furniDescription"),
},
{
href: "/admin/import/furni/upload",
label: t("upload"),
icon: Upload,
description: t("uploadDescription"),
},
{
href: "/admin/import/clothing",
label: t("clothing"),
@@ -55,6 +64,18 @@ export default async function ImportPage() {
icon: Copy,
description: t("cloneDescription"),
},
{
href: "/admin/import/sync",
label: t("sync"),
icon: RefreshCw,
description: t("syncDescription"),
},
{
href: "/admin/import/repair-icons",
label: t("repairIcons"),
icon: ImageIcon,
description: t("repairIconsDescription"),
},
] as const;
return (
@@ -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>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { SyncAllClient } from "./sync-all-client";
export default async function SyncAllPage() {
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">
Sync All Sources
</h1>
<p className="text-muted-foreground mt-1">
Scan all configured clone sources and automatically import
any furniture that is not yet present in your hotel. Missing
.nitro files, icons, database entries, catalog items, and
FurnitureData.json will be added automatically.
</p>
</div>
<SyncAllClient />
</div>
);
}
@@ -0,0 +1,176 @@
"use client";
import { Check, CloudDownload, Loader2 } 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 SyncEvent {
type: string;
classname?: string;
status?: string;
succeeded?: number;
failed?: number;
}
export function SyncAllClient() {
const [syncing, setSyncing] = 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;
sources: number;
} | null>(null);
const startSync = useCallback(async () => {
setSyncing(true);
setProgress(null);
setLog([]);
setResult(null);
try {
const res = await adminFetch("/api/admin/import/clone/sync-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!res.body) {
toast.error("No response stream");
setSyncing(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 SyncEvent;
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
}
}
}
// Check for skipped state (all up to date)
try {
const parsed = JSON.parse(
buf.includes("data:") ? (buf.split("data:").pop() ?? "{}") : "{}",
);
if (parsed.skipped) {
toast.info(parsed.message || "All sources are up to date");
setResult({ succeeded: 0, failed: 0, sources: 0 });
setSyncing(false);
return;
}
} catch {
// not a skipped response
}
setResult({ succeeded, failed, sources: 1 });
if (succeeded > 0) toast.success(`${succeeded} items synced`);
if (failed > 0) toast.error(`${failed} items failed`);
} catch {
toast.error("Sync request failed");
} finally {
setSyncing(false);
}
}, []);
return (
<div className="space-y-4">
{!result && (
<Button onClick={startSync} disabled={syncing} size="lg">
{syncing ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<CloudDownload className="w-4 h-4 mr-2" />
)}
{syncing
? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}`
: "Start Sync All Sources"}
</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 && result.sources > 0 && (
<div className="rounded-xl border bg-card p-5 space-y-3">
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Sync Complete</span>
</div>
<div className="text-sm space-y-1">
<p>
<span className="text-muted-foreground">Succeeded:</span>{" "}
{result.succeeded}
</p>
<p>
<span className="text-muted-foreground">Failed:</span>{" "}
{result.failed}
</p>
</div>
</div>
)}
{!syncing && !result && (
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground text-sm">
<CloudDownload className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>
Click the button above to scan all clone sources for missing
furniture and import it automatically.
</p>
</div>
)}
</div>
);
}