diff --git a/src/app/admin/studio/page.tsx b/src/app/admin/studio/page.tsx new file mode 100644 index 00000000..bc97280b --- /dev/null +++ b/src/app/admin/studio/page.tsx @@ -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 ( + + ); +} diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx new file mode 100644 index 00000000..d7881828 --- /dev/null +++ b/src/components/admin/studio/studio-client.tsx @@ -0,0 +1,1104 @@ +"use client"; + +import { + Check, + ChevronDown, + ChevronRight, + CircleAlert, + CloudDownload, + Download, + FolderTree, + Layers, + Loader2, + Package, + Pencil, + Search, + Sparkles, + Trash2, + WandSparkles, + X, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { deleteImportedFurni } from "@/actions/import-furni"; +import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { adminFetch } from "@/lib/admin-fetch"; +import { + type AutoCatalogPreview, + previewAutoCatalog, +} from "@/lib/furni/auto-catalog"; +import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; +import { cn } from "@/lib/utils"; +import type { TreeNode } from "@/types/catalog"; + +interface FurniItem { + id: number; + classname: string; + type: string; + name: string; + description: string; + revision: number; + category: string; + alreadyImported: boolean; + nitroExists: boolean; +} + +interface Meta { + currentPage: number; + lastPage: number; + total: number; + perPage: number; +} + +interface FurniStats { + totalInDb: number; + inCatalog: number; + notInCatalog: number; + missingNitro: number; +} + +interface BatchItemStatus { + classname: string; + status: + | "pending" + | "started" + | "downloading" + | "converting" + | "writing_db" + | "done" + | "failed"; + message?: string; + warnings?: string[]; + itemId?: number; +} + +function getFurniImageUrl(classname: string, revision: number) { + const base = classname.includes("*") + ? classname.substring(0, classname.indexOf("*")) + : classname; + return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`; +} + +function getFurniIconUrl(classname: string, revision: number) { + const iconName = classname.replace(/\*/g, "_"); + return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`; +} + +function handleImgError( + e: React.SyntheticEvent, + classname: string, + revision: number, +) { + const img = e.target as HTMLImageElement; + const iconName = classname.replace(/\*/g, "_"); + if (!img.dataset.fallback) { + img.dataset.fallback = "1"; + img.src = getFurniIconUrl(classname, revision); + } else if (img.dataset.fallback === "1") { + img.dataset.fallback = "2"; + img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`; + } else { + img.style.opacity = "0.3"; + img.src = + 'data:image/svg+xml,'; + } +} + +const TYPE_LABELS: Record = { + flooritem: "Floor", + wallitem: "Wall", +}; + +// ── Catalog structure rail ───────────────────────────────────────── + +function CatalogRail({ + tree, + selectedId, + onSelect, +}: { + tree: TreeNode[]; + selectedId: number | null; + onSelect: (id: number | null) => void; +}) { + const [collapsed, setCollapsed] = useState>(new Set()); + + const visible = useMemo(() => { + const hidden = new Set(); + for (const node of tree) { + if (hidden.has(node.parentId)) { + hidden.add(node.id); + continue; + } + if (collapsed.has(node.parentId)) { + hidden.add(node.id); + } + } + return tree.filter((n) => !hidden.has(n.id)); + }, [tree, collapsed]); + + const toggle = (id: number) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( + + ); +} + +// ── Batch progress panel ─────────────────────────────────────────── + +function BatchProgress({ + progress, + done, + succeeded, + failed, +}: { + progress: Map; + done: boolean; + succeeded: number; + failed: number; +}) { + const entries = [...progress.entries()]; + return ( +
+
+ + Importing… + + {done ? ( + 0 + ? "text-[var(--admin-warning)]" + : "text-[var(--admin-success)]", + )} + > + ✓ {succeeded} ok{failed > 0 ? ` · ${failed} failed` : ""} + + ) : ( + + )} +
+
+ {entries.map(([cn, s]) => ( +
+ {s.status === "done" ? ( + + ) : s.status === "failed" ? ( + + ) : ( + + )} + {cn} + + {s.status.replace(/_/g, " ")} + +
+ ))} +
+
+ ); +} + +// ── Studio workspace ─────────────────────────────────────────────── + +export function StudioClient({ + source, + initialTree, +}: { + source: FurniImportSource; + initialTree: TreeNode[]; +}) { + // Catalog tree + const [tree] = useState(initialTree); + const [selectedPage, setSelectedPage] = useState(null); + + // Search & list + const [searchTerm, setSearchTerm] = useState(""); + const [activeSearch, setActiveSearch] = useState(""); + const [items, setItems] = useState([]); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + // Filters + const [typeFilter, setTypeFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + + // Stats + const [stats, setStats] = useState(null); + + // Selection + detail + const [selected, setSelected] = useState>(new Set()); + const [detail, setDetail] = useState(null); + + // Import state + const [importingId, setImportingId] = useState(null); + const [batchProgress, setBatchProgress] = useState | null>(null); + const [batchDone, setBatchDone] = useState(false); + const [batchSucceeded, setBatchSucceeded] = useState(0); + const [batchFailed, setBatchFailed] = useState(0); + const batchAbortRef = useRef(null); + + // Nitro editor + const [editingClassname, setEditingClassname] = useState(null); + + // Delete confirm + const [deleting, setDeleting] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + + const filteredItems = useMemo(() => { + let result = items; + if (typeFilter !== "all") { + result = result.filter((i) => i.type === typeFilter); + } + if (statusFilter !== "all") { + if (statusFilter === "new") + result = result.filter((i) => !i.alreadyImported); + else if (statusFilter === "imported") + result = result.filter((i) => i.alreadyImported); + else if (statusFilter === "inCatalog") + result = result.filter((i) => i.alreadyImported && i.nitroExists); + else if (statusFilter === "missingNitro") + result = result.filter((i) => i.alreadyImported && !i.nitroExists); + } + return result; + }, [items, typeFilter, statusFilter]); + + const allSelected = useMemo( + () => + filteredItems.length > 0 && + filteredItems.every((i) => selected.has(i.classname)), + [filteredItems, selected], + ); + + const fetchStats = useCallback(async () => { + try { + const res = await adminFetch("/api/admin/import/furni?action=stats"); + if (res.ok) { + const data = await res.json(); + setStats(data); + } + } catch { + /* ignore */ + } + }, []); + + const fetchItems = useCallback( + async (search = activeSearch, page = 1) => { + setLoading(true); + setError(""); + try { + const qs = new URLSearchParams({ page: String(page) }); + if (search) qs.set("search", search); + const res = await adminFetch( + `/api/admin/import/furni?${qs.toString()}`, + ); + if (!res.ok) { + setError(`Request failed (${res.status})`); + setLoading(false); + return; + } + const data = await res.json(); + if (page === 1) { + setItems(data.items ?? []); + } else { + setItems((prev) => [...prev, ...(data.items ?? [])]); + } + setMeta(data.meta ?? null); + } catch { + setError("Network error"); + } finally { + setLoading(false); + } + }, + [activeSearch], + ); + + useEffect(() => { + fetchItems(); + fetchStats(); + }, [fetchItems, fetchStats]); + + // Keep detail in sync with the refreshed list. + useEffect(() => { + if (!detail) return; + const updated = items.find((i) => i.classname === detail.classname); + if (updated) setDetail(updated); + }, [items, detail]); + + const submitSearch = () => { + setActiveSearch(searchTerm); + fetchItems(searchTerm, 1); + }; + + function toggleSelect(classname: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(classname)) next.delete(classname); + else next.add(classname); + return next; + }); + } + + const toggleAll = () => { + if (allSelected) { + const toRemove = new Set(filteredItems.map((i) => i.classname)); + setSelected((prev) => { + const next = new Set(prev); + for (const cn of toRemove) next.delete(cn); + return next; + }); + } else { + setSelected((prev) => { + const next = new Set(prev); + for (const i of filteredItems) next.add(i.classname); + return next; + }); + } + }; + + async function importSingle(item: FurniItem) { + setImportingId(item.classname); + try { + const res = await adminFetch("/api/admin/import/furni", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: item.id, + classname: item.classname, + name: item.name, + description: item.description, + type: item.type, + revision: item.revision, + category: item.category, + }), + }); + const data = await res.json(); + if (res.ok) { + setItems((prev) => + prev.map((i) => + i.classname === item.classname + ? { ...i, alreadyImported: true, nitroExists: true } + : i, + ), + ); + if (detail?.classname === item.classname) { + setDetail({ ...item, alreadyImported: true, nitroExists: true }); + } + fetchStats(); + toast.success(`${item.classname} imported into the catalog`); + } else { + toast.error(data.error || `Import failed for ${item.classname}`); + } + } catch { + toast.error("Network error"); + } finally { + setImportingId(null); + } + } + + async function importBatch() { + const toImport = filteredItems.filter( + (i) => selected.has(i.classname) && !i.alreadyImported, + ); + if (toImport.length === 0) { + toast.info("Select furni that isn't imported yet"); + return; + } + + setBatchProgress(new Map()); + setBatchDone(false); + setBatchSucceeded(0); + setBatchFailed(0); + + const initial = new Map(); + for (const i of toImport) { + initial.set(i.classname, { classname: i.classname, status: "pending" }); + } + setBatchProgress(new Map(initial)); + + const abort = new AbortController(); + batchAbortRef.current = abort; + + try { + const res = await adminFetch("/api/admin/import/furni/batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: toImport.map((i) => ({ + id: i.id, + classname: i.classname, + name: i.name, + description: i.description, + type: i.type, + revision: i.revision, + category: i.category, + })), + concurrency: 3, + }), + signal: abort.signal, + }); + + if (!res.ok || !res.body) { + setBatchProgress(null); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const event = JSON.parse(line.slice(6)); + if (event.type === "item_progress") { + setBatchProgress((prev) => { + if (!prev) return prev; + const next = new Map(prev); + next.set(event.classname, { + classname: event.classname, + status: event.status, + message: event.message, + warnings: event.warnings, + itemId: event.itemId, + }); + return next; + }); + if (event.status === "done") { + setItems((prev) => + prev.map((i) => + i.classname === event.classname + ? { ...i, alreadyImported: true, nitroExists: true } + : i, + ), + ); + } + } else if (event.type === "batch_complete") { + setBatchSucceeded(event.succeeded); + setBatchFailed(event.failed); + setBatchDone(true); + } + } catch { + /* skip malformed */ + } + } + } + } catch (err) { + if ((err as Error).name === "AbortError") { + toast.info("Batch import cancelled"); + } else { + toast.error("Connection lost during batch import"); + } + } finally { + batchAbortRef.current = null; + setSelected(new Set()); + fetchStats(); + if (!batchDone) setBatchProgress(null); + } + } + + async function deleteItem() { + if (!deleting) return; + try { + const result = await deleteImportedFurni({ classname: deleting }); + if (result.ok) { + const data = result.data as { + deletedFiles: string[]; + errors?: string[]; + }; + setItems((prev) => prev.filter((i) => i.classname !== deleting)); + if (detail?.classname === deleting) setDetail(null); + fetchStats(); + toast.success( + `Deleted ${deleting} (${data.deletedFiles?.length ?? 0} files)`, + ); + } else { + toast.error(result.error || "Delete failed"); + } + } catch { + toast.error("Network error"); + } finally { + setDeleting(null); + setConfirmDelete(false); + } + } + + const detailPreview = useMemo(() => { + if (!detail) return null; + return previewAutoCatalog( + detail.classname, + detail.type === "wallitem" ? "i" : "s", + ); + }, [detail]); + + const selectedNonImported = useMemo( + () => + filteredItems.filter( + (i) => selected.has(i.classname) && !i.alreadyImported, + ).length, + [filteredItems, selected], + ); + + return ( +
+ {/* ── Header ─────────────────────────────────────────── */} +
+
+
+ +
+
+

+ Studio +

+

+ {source.label} furnidata +

+
+
+ +
+
+ + setSearchTerm(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submitSearch()} + placeholder="Search furni, classnames, keywords…" + className="pl-8" + /> +
+ +
+ +
+ {stats && ( + <> + + {stats.totalInDb} in DB + + + {stats.inCatalog} in catalog + + 0 && + "text-[var(--admin-warning)] border-[var(--admin-warning)]/40", + )} + > + {stats.missingNitro} missing nitro + + + )} +
+
+ + {/* ── Filters + actions ─────────────────────────────── */} +
+ + + + + + +
+ + {selectedNonImported > 0 && ( + + )} +
+ + {/* ── Body: rail + grid + drawer ────────────────────── */} +
+ + +
+
+ {loading && items.length === 0 ? ( +
+ {Array.from({ length: 10 }, (_, i) => i).map((n) => ( +
+ ))} +
+ ) : error ? ( +
+ {error} +
+ ) : filteredItems.length === 0 ? ( +
+ +

+ {activeSearch + ? `No furni found for "${activeSearch}"` + : "Search the official Habbo furni library to get started."} +

+
+ ) : ( + <> +
+ {filteredItems.map((item) => { + const isSel = selected.has(item.classname); + return ( + +
+
+
+

+ {item.name} +

+

+ {item.classname} +

+
+
+
+ + {TYPE_LABELS[item.type] ?? item.type} + + {item.alreadyImported ? ( + + {item.nitroExists ? "Imported" : "No nitro"} + + ) : ( + + New + + )} +
+ + ); + })} +
+ + {meta && meta.currentPage < meta.lastPage && ( +
+ +
+ )} + + )} +
+ + {batchProgress ? ( + + ) : null} +
+ + {/* ── Detail drawer ─────────────────────────────── */} + {detail ? ( + + ) : null} +
+ + !open && setEditingClassname(null)} + /> + + + + + Delete imported furni + +

+ This permanently removes{" "} + + {deleting} + {" "} + from items_base and the catalog, plus its asset files. +

+ + + + +
+
+
+ ); +} + +function CheckboxDot({ checked }: { checked: boolean }) { + return ( + + {checked ? ( + + ) : null} + + ); +} diff --git a/src/lib/admin-nav.ts b/src/lib/admin-nav.ts index 981c3af5..f47d01cb 100644 --- a/src/lib/admin-nav.ts +++ b/src/lib/admin-nav.ts @@ -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", diff --git a/src/lib/furni/auto-catalog.ts b/src/lib/furni/auto-catalog.ts new file mode 100644 index 00000000..2dc08b55 --- /dev/null +++ b/src/lib/furni/auto-catalog.ts @@ -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_`) +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__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_ → 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 }; +} diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index c0166991..6e667e34 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -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_`) -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 { } /** 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__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_ → 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 diff --git a/src/messages/en.json b/src/messages/en.json index 10b6d756..ab1ce8df 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2597,6 +2597,7 @@ "commando": "Commando", "rooms": "Rooms", "import": "Import", + "studio": "Studio", "importOverview": "Overview", "importBadges": "Badges", "importFurni": "Furni", diff --git a/src/messages/nl.json b/src/messages/nl.json index daaaed84..eea67630 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -2854,6 +2854,7 @@ "commando": "Commando", "rooms": "Kamers", "import": "Import", + "studio": "Studio", "importOverview": "Overzicht", "importBadges": "Badges", "importFurni": "Meubels",