fix: use configured furni source and catalog assets

This commit is contained in:
Simo committed 2026-08-02 14:22:05 +02:00
1 parent aed70db81f
commit c78f8812ad
25 files changed
+368 -129

No files matched your search

@@ -7,8 +7,10 @@ import {
Check,
CheckCheck,
Download,
ExternalLink,
FileWarning,
FolderSync,
Globe2,
LayoutGrid,
List,
Loader2,
@@ -52,6 +54,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { adminFetch } from "@/lib/admin-fetch";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { NitroEditorDialog } from "./nitro-editor-dialog";
interface FurniItem {
@@ -146,7 +149,7 @@ const STATUS_LABELS: Record<string, string> = {
failed: "Failed",
};
export function ImportFurniClient() {
export function ImportFurniClient({ source }: { source: FurniImportSource }) {
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<FurniItem[]>([]);
@@ -936,6 +939,31 @@ export function ImportFurniClient() {
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3">
<div className="flex items-center gap-3">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<Globe2 className="h-4 w-4" />
</div>
<div>
<p className="text-xs text-muted-foreground">
Official furnidata source
</p>
<p className="text-sm font-medium">{source.label}</p>
<p className="text-[10px] text-muted-foreground">
Furniture assets: {source.assetCdnHost}
</p>
</div>
</div>
<a
href={source.furnidataUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{source.host}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{/* Dashboard Statistics */}
{!stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
@@ -0,0 +1,38 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import * as hotelHelpers from "@/lib/habbo-gamedata-hotel";
describe("furni import source metadata", () => {
it("describes the configured hotel without changing the global asset CDN", () => {
const helpers = hotelHelpers as unknown as {
buildFurniImportSource?: (hotel: string) => {
hotel: string;
label: string;
host: string;
furnidataUrl: string;
assetCdnHost: string;
};
officialHabboEnrichmentWarning?: (hotel: string, name: string) => string;
};
expect(helpers.buildFurniImportSource).toBeTypeOf("function");
expect(helpers.officialHabboEnrichmentWarning).toBeTypeOf("function");
if (
!helpers.buildFurniImportSource ||
!helpers.officialHabboEnrichmentWarning
) {
return;
}
expect(helpers.buildFurniImportSource("nl")).toEqual({
hotel: "nl",
label: "Netherlands (habbo.nl)",
host: "habbo.nl",
furnidataUrl: "https://www.habbo.nl/gamedata/furnidata_json/1",
assetCdnHost: "images.habbo.com",
});
expect(helpers.officialHabboEnrichmentWarning("nl", "Stoel")).toBe(
'Enriched from habbo.nl: "Stoel"',
);
});
});
+4 -1
View File
@@ -1,5 +1,7 @@
import { redirect } from "next/navigation";
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { ImportFurniClient } from "./import-furni-client";
export default async function ImportFurniPage(_props: {
@@ -10,5 +12,6 @@ export default async function ImportFurniPage(_props: {
redirect("/admin");
}
return <ImportFurniClient />;
const hotel = await getHabboGamedataHotel();
return <ImportFurniClient source={buildFurniImportSource(hotel)} />;
}
+13 -22
View File
@@ -1,16 +1,11 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
const CATALOGUE_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
"public",
"swf",
"c_images",
"catalogue",
);
import {
catalogueAssetDirectories,
readCatalogueAssetFiles,
} from "@/lib/services/catalog-assets";
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW },
@@ -23,18 +18,14 @@ export const GET = withAdmin(
const offset = Math.max(Number(sp.get("offset") || 0) || 0, 0);
const search = (sp.get("search") || "").trim().toLowerCase();
let icons: number[] = [];
try {
const files = await fs.readdir(CATALOGUE_DIR);
icons = files
.map((f) => /^icon_(\d+)\.(?:png|gif)$/i.exec(f))
.filter((m): m is RegExpExecArray => Boolean(m))
.map((m) => Number(m[1]))
.filter((n) => Number.isFinite(n))
.sort((a, b) => a - b);
} catch {
icons = [];
}
const directories = catalogueAssetDirectories(await getGamedataRoot());
const files = await readCatalogueAssetFiles(directories);
let icons = files
.map((f) => /^icon_(\d+)\.(?:png|gif)$/i.exec(f))
.filter((m): m is RegExpExecArray => Boolean(m))
.map((m) => Number(m[1]))
.filter((n) => Number.isFinite(n))
.sort((a, b) => a - b);
if (search) {
icons = icons.filter((id) => String(id).includes(search));
+15 -23
View File
@@ -1,16 +1,11 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
const CATALOGUE_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
"public",
"swf",
"c_images",
"catalogue",
);
import {
catalogueAssetDirectories,
readCatalogueAssetFiles,
} from "@/lib/services/catalog-assets";
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW },
@@ -25,20 +20,17 @@ export const GET = withAdmin(
const type = sp.get("type") || "all";
let names: string[] = [];
try {
const files = await fs.readdir(CATALOGUE_DIR);
const seen = new Set<string>();
for (const file of files) {
if (/^icon_\d+\./i.test(file)) continue;
const base = file.replace(/\.(png|gif|jpg|jpeg|webp)$/i, "");
if (!base || seen.has(base)) continue;
seen.add(base);
names.push(base);
}
names.sort((a, b) => a.localeCompare(b));
} catch {
names = [];
const directories = catalogueAssetDirectories(await getGamedataRoot());
const files = await readCatalogueAssetFiles(directories);
const seen = new Set<string>();
for (const file of files) {
if (/^icon_\d+\./i.test(file)) continue;
const base = file.replace(/\.(png|gif|jpg|jpeg|webp)$/i, "");
if (!base || seen.has(base)) continue;
seen.add(base);
names.push(base);
}
names.sort((a, b) => a.localeCompare(b));
if (type === "header") {
names = names.filter((n) => /header/i.test(n));
@@ -12,7 +12,7 @@ import {
readFurniData,
} from "@/lib/services/furni-data";
import { downloadFile, ensureDirectories } from "@/lib/services/furni-import";
import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache";
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
import { rcon } from "@/lib/services/rcon";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
@@ -62,7 +62,7 @@ export const POST = withAdmin(
}> = [];
// Load furnidata once for all items
const furnidata = await getHabboItFurnidata();
const furnidata = await getOfficialHabboFurnidata();
// Load existing FurnitureData.json classnames for dedup
const existingFD = (await readFurniData()) as {
@@ -204,7 +204,7 @@ export const POST = withAdmin(
spriteId: dbItem?.spriteId || 0,
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
metadata: conversion.metadata || null,
habboIt: fd || null,
officialHabbo: fd || null,
});
furniDataEntries.push({ entry, itemType });
fdClassnames.add(baseClassname);
@@ -10,7 +10,7 @@ import {
getFurnitureDataPath,
readFurniData,
} from "@/lib/services/furni-data";
import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache";
import { lookupOfficialHabboFurni } from "@/lib/services/habbo-furnidata-cache";
import { rcon } from "@/lib/services/rcon";
/**
@@ -86,9 +86,9 @@ export const POST = withAdmin(
for (const row of rows) {
try {
const classname = row.itemName;
let habboIt = null;
let officialHabbo = null;
try {
habboIt = await lookupHabboIt(classname);
officialHabbo = await lookupOfficialHabboFurni(classname);
} catch {
/* optional */
}
@@ -96,10 +96,10 @@ export const POST = withAdmin(
const entry = buildFurniEntry({
id: row.id,
classname,
revision: habboIt?.revision ?? 0,
category: habboIt?.category || "unknown",
name: row.publicName || habboIt?.name || classname,
description: habboIt?.description || "",
revision: officialHabbo?.revision ?? 0,
category: officialHabbo?.category || "unknown",
name: row.publicName || officialHabbo?.name || classname,
description: officialHabbo?.description || "",
spriteId: row.spriteId,
dims: {
x: row.width,
@@ -107,7 +107,7 @@ export const POST = withAdmin(
z: Math.round(Number(row.stackHeight)),
},
metadata: null,
habboIt,
officialHabbo,
});
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
+5 -5
View File
@@ -18,7 +18,7 @@ import {
IMPORTED_PAGE_CAPTION_SAVE,
importSingleFurni,
} from "@/lib/services/furni-import";
import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache";
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
import { listFurniture } from "@/lib/services/habbofurni";
import { rcon } from "@/lib/services/rcon";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
@@ -183,7 +183,7 @@ export const GET = withAdmin(
const pageItems = missing.slice(start, start + perPage);
// Enrich with revision from official furnidata
const furnidata = await getHabboItFurnidata();
const furnidata = await getOfficialHabboFurnidata();
const items = pageItems.map((item) => {
const fd =
furnidata.get(item.item_name) ||
@@ -434,7 +434,7 @@ export const PATCH = withAdmin(
// Resolve revision: try furnidata first, then spriteId as fallback
let rev = item.revision || 0;
if (rev === 0) {
const furnidata = await getHabboItFurnidata();
const furnidata = await getOfficialHabboFurnidata();
const fd =
furnidata.get(item.classname) ||
furnidata.get(baseClassname) ||
@@ -516,7 +516,7 @@ export const PATCH = withAdmin(
.from(ItemsBase)
.where(eq(ItemsBase.itemName, item.classname))
.limit(1);
const furnidata = await getHabboItFurnidata();
const furnidata = await getOfficialHabboFurnidata();
const fd =
furnidata.get(item.classname) || furnidata.get(baseClassname);
const itemType = dbItem?.type === "i" ? "i" : "s";
@@ -531,7 +531,7 @@ export const PATCH = withAdmin(
spriteId: dbItem?.spriteId || 0,
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
metadata: conversion.metadata || null,
habboIt: fd || null,
officialHabbo: fd || null,
});
await appendFurniEntry(entry, itemType);
}