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>
);
}
File diff suppressed because it is too large. Load diff
+21
View File
@@ -3,8 +3,11 @@ import {
Cat, Cat,
Copy, Copy,
Dumbbell, Dumbbell,
ImageIcon,
Puzzle, Puzzle,
RefreshCw,
Shirt, Shirt,
Upload,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -31,6 +34,12 @@ export default async function ImportPage() {
icon: Puzzle, icon: Puzzle,
description: t("furniDescription"), description: t("furniDescription"),
}, },
{
href: "/admin/import/furni/upload",
label: t("upload"),
icon: Upload,
description: t("uploadDescription"),
},
{ {
href: "/admin/import/clothing", href: "/admin/import/clothing",
label: t("clothing"), label: t("clothing"),
@@ -55,6 +64,18 @@ export default async function ImportPage() {
icon: Copy, icon: Copy,
description: t("cloneDescription"), 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; ] as const;
return ( 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>
);
}
File diff suppressed because it is too large. Load diff
+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>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,33 @@
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { repairMissingIcons } from "@/lib/services/repair-icons";
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
);
} catch {
/* stream closed */
}
};
await repairMissingIcons(send);
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
});
@@ -0,0 +1,92 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { uploadSingleFurni } from "@/lib/services/upload-import";
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
const formData = await request.formData();
const classname = (formData.get("classname") as string)?.trim();
const name = (formData.get("name") as string)?.trim();
const description = (formData.get("description") as string)?.trim() || "";
const itemType = formData.get("itemType") as string;
const xdim = formData.get("xdim")
? Number(formData.get("xdim"))
: undefined;
const ydim = formData.get("ydim")
? Number(formData.get("ydim"))
: undefined;
const canstandon = formData.get("canstandon") === "true";
const cansiton = formData.get("cansiton") === "true";
const canlayon = formData.get("canlayon") === "true";
const interactionType =
(formData.get("interactionType") as string)?.trim() || "default";
const customparams = (formData.get("customparams") as string)?.trim() || "";
const generateSql = formData.get("generateSql") === "true";
const nitroFile = formData.get("nitro") as File | null;
const iconFile = formData.get("icon") as File | null;
if (!classname) return apiError("classname is required", 400);
if (!name) return apiError("name is required", 400);
if (!itemType || !["s", "i"].includes(itemType))
return apiError("itemType must be 's' or 'i'", 400);
if (!nitroFile || nitroFile.size === 0)
return apiError(".nitro file is required", 400);
if (nitroFile.size > 50 * 1024 * 1024)
return apiError(".nitro file too large (max 50MB)", 400);
const nitroBuffer = Buffer.from(await nitroFile.arrayBuffer());
let iconBuffer: Buffer | null = null;
if (iconFile && iconFile.size > 0) {
if (iconFile.size > 2 * 1024 * 1024)
return apiError("Icon file too large (max 2MB)", 400);
iconBuffer = Buffer.from(await iconFile.arrayBuffer());
}
const result = await uploadSingleFurni({
classname,
name,
description,
itemType: itemType as "s" | "i",
xdim,
ydim,
canstandon,
cansiton,
canlayon,
interactionType,
customparams,
nitroBuffer,
iconBuffer,
generateSql,
});
if (!result.ok) {
return apiError(result.error || "Upload failed", 400);
}
logAudit({
userId: ctx.session.user.id,
action: "furni_upload",
target: "FurniAsset",
targetId: result.itemId ?? 0,
after: {
classname: result.classname,
itemId: result.itemId,
catalogItemId: result.catalogItemId,
},
});
return apiOk({
itemId: result.itemId,
catalogItemId: result.catalogItemId,
classname: result.classname,
warnings: result.warnings,
sqlFile: result.sqlFile,
});
},
);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -2481,6 +2481,8 @@
"badgesDescription": "Import and manage badge assets from external sources.", "badgesDescription": "Import and manage badge assets from external sources.",
"furni": "Furni", "furni": "Furni",
"furniDescription": "Import furniture definitions, SWF files, and icons.", "furniDescription": "Import furniture definitions, SWF files, and icons.",
"upload": "Upload .nitro",
"uploadDescription": "Upload .nitro bundles and icons directly without SWF conversion.",
"clothing": "Clothing", "clothing": "Clothing",
"clothingDescription": "Import clothing and wearables for avatars.", "clothingDescription": "Import clothing and wearables for avatars.",
"effects": "Effects", "effects": "Effects",
@@ -2488,7 +2490,11 @@
"pets": "Pets", "pets": "Pets",
"petsDescription": "Import pet data and icons.", "petsDescription": "Import pet data and icons.",
"clone": "Clone", "clone": "Clone",
"cloneDescription": "Clone existing items from one source to another." "cloneDescription": "Clone existing items from one source to another.",
"sync": "Auto Sync",
"syncDescription": "Automatically import missing furniture from all configured sources.",
"repairIcons": "Repair Icons",
"repairIconsDescription": "Download any missing icon files for furniture already in the database."
}, },
"translations": { "translations": {
"title": "Translations", "title": "Translations",