Add all-in-one Studio merging furni import and catalog management
Add a full-viewport /admin/studio workspace that unifies the import and catalog systems into a single automated flow: - Furni library browser against official Habbo furnidata with search, type/status filters, live previews and pagination - Catalog structure rail showing where imported furniture lands - Detail drawer with automatic placement preview (category + price via classifyFurni/autoPriceFurni) and one-click import that downloads assets, converts SWF to nitro, writes items_base, updates FurnitureData.json and creates an auto-priced catalog entry - Batch auto-import with SSE progress and RCON cache refresh - Nitro editor integration and imported-item deletion Extract pure auto-catalog helpers (CATEGORY_PAGE, CLASSNAME_RULES, classifyFurni, autoPriceFurni) into a client-safe module shared by the server pipeline and the Studio UI so the preview always matches what the emulator import applies.
This commit is contained in:
1 parent
e31f6d985c
commit
5b09179404
7 files changed
+1297
-107
No files matched your search
@@ -0,0 +1,27 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { StudioClient } from "@/components/admin/studio/studio-client";
|
||||
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { getTreeFlat } from "@/lib/services/catalog-tree";
|
||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
|
||||
export default async function StudioPage(_props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const [hotel, catalogTree] = await Promise.all([
|
||||
getHabboGamedataHotel(),
|
||||
getTreeFlat(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<StudioClient
|
||||
source={buildFurniImportSource(hotel)}
|
||||
initialTree={catalogTree}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -23,6 +23,7 @@ import {
|
||||
Monitor,
|
||||
Newspaper,
|
||||
Package,
|
||||
Palette,
|
||||
Radio,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -622,6 +623,13 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
permission: PERMS.ROOMS_VIEW,
|
||||
matchPrefixes: ["/admin/rooms"],
|
||||
},
|
||||
{
|
||||
href: "/admin/studio",
|
||||
labelKey: "studio",
|
||||
icon: Palette,
|
||||
permission: PERMS.ASSETS_IMPORT,
|
||||
matchPrefixes: ["/admin/studio"],
|
||||
},
|
||||
{
|
||||
href: "/admin/import",
|
||||
labelKey: "import",
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Client-safe auto-catalog helpers (pure, no Node/DB imports).
|
||||
*
|
||||
* Shared by the server-side import pipeline (furni-import.ts re-exports from
|
||||
* here) and the admin Studio UI, so the on-screen "auto category + auto price"
|
||||
* preview always matches what the emulator import actually applies.
|
||||
*/
|
||||
|
||||
export interface AutoPrice {
|
||||
credits: number;
|
||||
points: number;
|
||||
pointsType: number;
|
||||
}
|
||||
|
||||
export interface AutoCatalogPreview extends AutoPrice {
|
||||
categoryKey: string;
|
||||
categoryLabel: string;
|
||||
}
|
||||
|
||||
// Catalog sub-page definitions (key = internal id used for caption_save `imp_<key>`)
|
||||
export const CATEGORY_PAGE: Record<
|
||||
string,
|
||||
{ label: string; icon: number; order: number }
|
||||
> = {
|
||||
credits: { label: "Credits & Exchange", icon: 263, order: 1 },
|
||||
rares: { label: "Rares", icon: 11, order: 2 },
|
||||
wired: { label: "Wired", icon: 1, order: 3 },
|
||||
nft: { label: "NFT", icon: 12, order: 4 },
|
||||
seasonal: { label: "Seasonal", icon: 10, order: 5 },
|
||||
music: { label: "Music & Sound", icon: 13, order: 6 },
|
||||
clothing: { label: "Clothing", icon: 3, order: 7 },
|
||||
builders: { label: "Builders Club", icon: 7, order: 8 },
|
||||
walls: { label: "Wall Items", icon: 5, order: 9 },
|
||||
promo: { label: "Promo & Custom", icon: 2, order: 10 },
|
||||
trophies: { label: "Trophies", icon: 6, order: 11 },
|
||||
pets: { label: "Pets", icon: 4, order: 12 },
|
||||
room: { label: "Room & Building", icon: 8, order: 13 },
|
||||
other: { label: "Other", icon: 1, order: 99 },
|
||||
};
|
||||
|
||||
// Classname prefix → category key (order matters: first match wins)
|
||||
export const CLASSNAME_RULES: Array<{
|
||||
test: (cn: string, type: string) => boolean;
|
||||
cat: string;
|
||||
}> = [
|
||||
{
|
||||
test: (cn) => /^CF_/i.test(cn) || /^exe_/i.test(cn) || cn === "credit",
|
||||
cat: "credits",
|
||||
},
|
||||
{ test: (cn) => /^rare_/i.test(cn), cat: "rares" },
|
||||
{ test: (cn) => /^wf_/i.test(cn), cat: "wired" },
|
||||
{ test: (cn) => /^nft_/i.test(cn), cat: "nft" },
|
||||
{
|
||||
test: (cn) =>
|
||||
/^(xmas|hween|easter|val_|valentine|hw_|newyear|summer|carnival)/i.test(
|
||||
cn,
|
||||
),
|
||||
cat: "seasonal",
|
||||
},
|
||||
{ test: (cn) => /^(trax|jukebox|sound|music)/i.test(cn), cat: "music" },
|
||||
{ test: (cn) => /^(clothing|clothes)/i.test(cn), cat: "clothing" },
|
||||
{ test: (cn) => /^bc_/i.test(cn), cat: "builders" },
|
||||
{ test: (cn) => /^(poster|wall_|window_|wallpaper)/i.test(cn), cat: "walls" },
|
||||
{ test: (_cn, type) => type === "wallitem" || type === "i", cat: "walls" },
|
||||
{ test: (cn) => /^(ads_|cstm_)/i.test(cn), cat: "promo" },
|
||||
{
|
||||
test: (cn) => /^(trophy|gold_|silver_|bronze_)/i.test(cn),
|
||||
cat: "trophies",
|
||||
},
|
||||
{
|
||||
test: (cn) =>
|
||||
/^(pet|dog|cat_|bird|horse|pig|monkey|frog|dragon|bear_|spider|bunny)/i.test(
|
||||
cn,
|
||||
),
|
||||
cat: "pets",
|
||||
},
|
||||
{
|
||||
test: (cn) =>
|
||||
/^(room_|floor_|tile_|carpet|rug|door_|gate_|stair)/i.test(cn),
|
||||
cat: "room",
|
||||
},
|
||||
];
|
||||
|
||||
/** Determine the catalog category for a furniture item based on its classname and type. */
|
||||
export function classifyFurni(classname: string, itemType: string): string {
|
||||
for (const rule of CLASSNAME_RULES) {
|
||||
if (rule.test(classname, itemType)) return rule.cat;
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect catalog price from classname.
|
||||
* - CF_750_xxx → cost_credits = 750 (credit furni = the credit value it represents)
|
||||
* - exe_50, exe_100, exe_200 etc. → cost_credits based on exchange value
|
||||
* - rare_xxx → cost_credits = 25 (rares are expensive)
|
||||
* - wf_xxx → cost_credits = 5 (wired components)
|
||||
* - everything else → cost_credits = 3 (default)
|
||||
*/
|
||||
export function autoPriceFurni(classname: string): AutoPrice {
|
||||
// CF_<number>_xxx → credit furniture, price = the credit value
|
||||
const cfMatch = classname.match(/^CF_(\d+)/i);
|
||||
if (cfMatch) {
|
||||
return { credits: parseInt(cfMatch[1], 10), points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
// exe_<number> → exchange items (diamonds/duckets)
|
||||
const exeMatch = classname.match(/^exe_(\d+)/i);
|
||||
if (exeMatch) {
|
||||
return { credits: parseInt(exeMatch[1], 10), points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
// Rares
|
||||
if (/^rare_/i.test(classname)) {
|
||||
return { credits: 25, points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
// Wired
|
||||
if (/^wf_/i.test(classname)) {
|
||||
return { credits: 5, points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
// NFT items
|
||||
if (/^nft_/i.test(classname)) {
|
||||
return { credits: 10, points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
// Trophies
|
||||
if (/^(trophy|gold_|silver_|bronze_)/i.test(classname)) {
|
||||
return { credits: 10, points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
// Default
|
||||
return { credits: 3, points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
/** Combine auto-category + auto-price into a single preview for the Studio UI. */
|
||||
export function previewAutoCatalog(
|
||||
classname: string,
|
||||
itemType: string,
|
||||
): AutoCatalogPreview {
|
||||
const categoryKey = classifyFurni(classname, itemType);
|
||||
const categoryLabel =
|
||||
CATEGORY_PAGE[categoryKey]?.label ?? CATEGORY_PAGE.other.label;
|
||||
const price = autoPriceFurni(classname);
|
||||
return { ...price, categoryKey, categoryLabel };
|
||||
}
|
||||
@@ -50,69 +50,11 @@ export const FURNI_NITRO_DIR = DEFAULT_FURNI_NITRO_DIR;
|
||||
export const IMPORTED_PAGE_CAPTION_SAVE = "imported_furni";
|
||||
export const IMPORTED_PAGE_CAPTION = "Imported Furniture";
|
||||
|
||||
// Catalog sub-page definitions (key = internal id used for caption_save `imp_<key>`)
|
||||
export const CATEGORY_PAGE: Record<
|
||||
string,
|
||||
{ label: string; icon: number; order: number }
|
||||
> = {
|
||||
credits: { label: "Credits & Exchange", icon: 263, order: 1 },
|
||||
rares: { label: "Rares", icon: 11, order: 2 },
|
||||
wired: { label: "Wired", icon: 1, order: 3 },
|
||||
nft: { label: "NFT", icon: 12, order: 4 },
|
||||
seasonal: { label: "Seasonal", icon: 10, order: 5 },
|
||||
music: { label: "Music & Sound", icon: 13, order: 6 },
|
||||
clothing: { label: "Clothing", icon: 3, order: 7 },
|
||||
builders: { label: "Builders Club", icon: 7, order: 8 },
|
||||
walls: { label: "Wall Items", icon: 5, order: 9 },
|
||||
promo: { label: "Promo & Custom", icon: 2, order: 10 },
|
||||
trophies: { label: "Trophies", icon: 6, order: 11 },
|
||||
pets: { label: "Pets", icon: 4, order: 12 },
|
||||
room: { label: "Room & Building", icon: 8, order: 13 },
|
||||
other: { label: "Other", icon: 1, order: 99 },
|
||||
};
|
||||
// Shared with the admin Studio via the client-safe auto-catalog module so the
|
||||
// on-screen preview always matches what the emulator import applies.
|
||||
export { CATEGORY_PAGE, CLASSNAME_RULES } from "@/lib/furni/auto-catalog";
|
||||
|
||||
// Classname prefix → category key (order matters: first match wins)
|
||||
export const CLASSNAME_RULES: Array<{
|
||||
test: (cn: string, type: string) => boolean;
|
||||
cat: string;
|
||||
}> = [
|
||||
{
|
||||
test: (cn) => /^CF_/i.test(cn) || /^exe_/i.test(cn) || cn === "credit",
|
||||
cat: "credits",
|
||||
},
|
||||
{ test: (cn) => /^rare_/i.test(cn), cat: "rares" },
|
||||
{ test: (cn) => /^wf_/i.test(cn), cat: "wired" },
|
||||
{ test: (cn) => /^nft_/i.test(cn), cat: "nft" },
|
||||
{
|
||||
test: (cn) =>
|
||||
/^(xmas|hween|easter|val_|valentine|hw_|newyear|summer|carnival)/i.test(
|
||||
cn,
|
||||
),
|
||||
cat: "seasonal",
|
||||
},
|
||||
{ test: (cn) => /^(trax|jukebox|sound|music)/i.test(cn), cat: "music" },
|
||||
{ test: (cn) => /^(clothing|clothes)/i.test(cn), cat: "clothing" },
|
||||
{ test: (cn) => /^bc_/i.test(cn), cat: "builders" },
|
||||
{ test: (cn) => /^(poster|wall_|window_|wallpaper)/i.test(cn), cat: "walls" },
|
||||
{ test: (_cn, type) => type === "wallitem" || type === "i", cat: "walls" },
|
||||
{ test: (cn) => /^(ads_|cstm_)/i.test(cn), cat: "promo" },
|
||||
{
|
||||
test: (cn) => /^(trophy|gold_|silver_|bronze_)/i.test(cn),
|
||||
cat: "trophies",
|
||||
},
|
||||
{
|
||||
test: (cn) =>
|
||||
/^(pet|dog|cat_|bird|horse|pig|monkey|frog|dragon|bear_|spider|bunny)/i.test(
|
||||
cn,
|
||||
),
|
||||
cat: "pets",
|
||||
},
|
||||
{
|
||||
test: (cn) =>
|
||||
/^(room_|floor_|tile_|carpet|rug|door_|gate_|stair)/i.test(cn),
|
||||
cat: "room",
|
||||
},
|
||||
];
|
||||
import { CATEGORY_PAGE } from "@/lib/furni/auto-catalog";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -221,12 +163,9 @@ export async function getOrCreateImportedParentPage(): Promise<number> {
|
||||
}
|
||||
|
||||
/** Determine the catalog category for a furniture item based on its classname and type. */
|
||||
export function classifyFurni(classname: string, itemType: string): string {
|
||||
for (const rule of CLASSNAME_RULES) {
|
||||
if (rule.test(classname, itemType)) return rule.cat;
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
export { classifyFurni } from "@/lib/furni/auto-catalog";
|
||||
|
||||
import { classifyFurni } from "@/lib/furni/auto-catalog";
|
||||
|
||||
/**
|
||||
* Auto-detect catalog price from classname.
|
||||
@@ -238,46 +177,9 @@ export function classifyFurni(classname: string, itemType: string): string {
|
||||
*
|
||||
* Returns { credits, points, pointsType }
|
||||
*/
|
||||
export function autoPriceFurni(classname: string): {
|
||||
credits: number;
|
||||
points: number;
|
||||
pointsType: number;
|
||||
} {
|
||||
// CF_<number>_xxx → credit furniture, price = the credit value
|
||||
const cfMatch = classname.match(/^CF_(\d+)/i);
|
||||
if (cfMatch) {
|
||||
return { credits: parseInt(cfMatch[1], 10), points: 0, pointsType: 0 };
|
||||
}
|
||||
export { autoPriceFurni } from "@/lib/furni/auto-catalog";
|
||||
|
||||
// exe_<number> → exchange items (diamonds/duckets)
|
||||
const exeMatch = classname.match(/^exe_(\d+)/i);
|
||||
if (exeMatch) {
|
||||
return { credits: parseInt(exeMatch[1], 10), points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
// Rares
|
||||
if (/^rare_/i.test(classname)) {
|
||||
return { credits: 25, points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
// Wired
|
||||
if (/^wf_/i.test(classname)) {
|
||||
return { credits: 5, points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
// NFT items
|
||||
if (/^nft_/i.test(classname)) {
|
||||
return { credits: 10, points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
// Trophies
|
||||
if (/^(trophy|gold_|silver_|bronze_)/i.test(classname)) {
|
||||
return { credits: 10, points: 0, pointsType: 0 };
|
||||
}
|
||||
|
||||
// Default
|
||||
return { credits: 3, points: 0, pointsType: 0 };
|
||||
}
|
||||
import { autoPriceFurni } from "@/lib/furni/auto-catalog";
|
||||
|
||||
/** Get or create a category sub-page under the imported parent page.
|
||||
* Caches category page IDs in-memory for CATEGORY_CACHE_TTL_MS to avoid
|
||||
|
||||
@@ -2597,6 +2597,7 @@
|
||||
"commando": "Commando",
|
||||
"rooms": "Rooms",
|
||||
"import": "Import",
|
||||
"studio": "Studio",
|
||||
"importOverview": "Overview",
|
||||
"importBadges": "Badges",
|
||||
"importFurni": "Furni",
|
||||
|
||||
@@ -2854,6 +2854,7 @@
|
||||
"commando": "Commando",
|
||||
"rooms": "Kamers",
|
||||
"import": "Import",
|
||||
"studio": "Studio",
|
||||
"importOverview": "Overzicht",
|
||||
"importBadges": "Badges",
|
||||
"importFurni": "Meubels",
|
||||
|
||||
Reference in new issue
Block a user