From 54f2bc5e0c35af5b90e39877a909fa1d22d70eea Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 15 Aug 2026 15:43:51 +0200 Subject: [PATCH] Add clone source selection to Studio furni browser Allow the admin Studio to browse and import furniture from custom retro hotel sources, not just official Habbo furnidata. - GET /api/admin/import/furni?source= lists a clone source's furnidata (via getCloneList) and returns a per-item iconUrl from the source's iconBaseUrl so previews render correctly - Studio client gets a source selector dropdown (official Habbo plus all configured clone_sources) that resets the selection and reloads the list when switched - Single and batch imports now send sourceId so SWF/nitro/icon assets are fetched from the selected hotel's CDN - Preview images prefer the source iconUrl with the existing fallback chain; header shows the active source name --- src/app/api/admin/import/furni/route.ts | 59 ++++++++++++ src/components/admin/studio/studio-client.tsx | 92 ++++++++++++++----- 2 files changed, 127 insertions(+), 24 deletions(-) diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 1f501a011d..746c08e56e 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -6,6 +6,7 @@ import { apiError, apiOk } from "@/lib/api-response"; import { CatalogPages, db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; +import { getCloneList } from "@/lib/services/clone-import"; import { getSource } from "@/lib/services/clone-sources"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { @@ -213,6 +214,64 @@ export const GET = withAdmin( const search = request.nextUrl.searchParams.get("search") || ""; const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10); + // ── Browse a custom clone source (retro hotel furnidata) ──────── + const sourceId = request.nextUrl.searchParams.get("source"); + if (sourceId) { + const source = await getSource(sourceId); + if (!source) return apiError("Source not found", 404); + + const { items, meta } = await getCloneList({ + source, + search, + page, + perPage: 50, + filter: "all", + }); + + const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, ""); + const enriched = items.map((item) => { + const imported = item.present; + let nitroExists = false; + if (imported) { + const starIdx = item.classname.indexOf("*"); + const base = + starIdx !== -1 + ? item.classname.substring(0, starIdx) + : item.classname; + const p = path.join( + /*turbopackIgnore: true*/ nitroDir, + `${base}.nitro`, + ); + nitroExists = existsSync(/*turbopackIgnore: true*/ p); + } + return { + id: item.id, + classname: item.classname, + type: item.itemType === "i" ? "wallitem" : "flooritem", + name: item.name, + description: item.description, + revision: 0, + category: String(item.category ?? ""), + alreadyImported: imported, + nitroExists, + iconUrl: sourceIconBase + ? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png` + : undefined, + }; + }); + + return apiOk({ + items: enriched, + source: { id: source.id, name: source.name }, + meta: { + currentPage: meta.page, + lastPage: Math.max(Math.ceil(meta.total / meta.perPage), 1), + total: meta.total, + perPage: meta.perPage, + }, + }); + } + // Pull every imported classname once so we can both (a) sort non-imported // items to the top *before* pagination and (b) skip the per-page lookup // below. Single-column indexed query — fast even at 10k+ rows. diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index d78818283b..38b37f7124 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -58,6 +58,8 @@ interface FurniItem { category: string; alreadyImported: boolean; nitroExists: boolean; + /** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */ + iconUrl?: string; } interface Meta { @@ -89,11 +91,12 @@ interface BatchItemStatus { 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 getFurniImageUrl(item: FurniItem) { + if (item.iconUrl) return item.iconUrl; + const base = item.classname.includes("*") + ? item.classname.substring(0, item.classname.indexOf("*")) + : item.classname; + return `https://images.habbo.com/dcr/hof_furni/${item.revision}/${encodeURIComponent(base)}_icon.png`; } function getFurniIconUrl(classname: string, revision: number) { @@ -103,14 +106,13 @@ function getFurniIconUrl(classname: string, revision: number) { function handleImgError( e: React.SyntheticEvent, - classname: string, - revision: number, + item: FurniItem, ) { const img = e.target as HTMLImageElement; - const iconName = classname.replace(/\*/g, "_"); + const iconName = item.classname.replace(/\*/g, "_"); if (!img.dataset.fallback) { img.dataset.fallback = "1"; - img.src = getFurniIconUrl(classname, revision); + img.src = getFurniIconUrl(item.classname, item.revision); } else if (img.dataset.fallback === "1") { img.dataset.fallback = "2"; img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`; @@ -310,6 +312,13 @@ export function StudioClient({ const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + // Custom clone sources (retro hotels); empty string = official Habbo furnidata + const [sources, setSources] = useState>( + [], + ); + const [activeSource, setActiveSource] = useState(""); + const [activeSourceName, setActiveSourceName] = useState(""); + // Filters const [typeFilter, setTypeFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); @@ -383,6 +392,7 @@ export function StudioClient({ try { const qs = new URLSearchParams({ page: String(page) }); if (search) qs.set("search", search); + if (activeSource) qs.set("source", activeSource); const res = await adminFetch( `/api/admin/import/furni?${qs.toString()}`, ); @@ -404,13 +414,38 @@ export function StudioClient({ setLoading(false); } }, - [activeSearch], + [activeSearch, activeSource], ); + const changeSource = (sourceId: string) => { + setActiveSource(sourceId); + setActiveSourceName( + sourceId ? (sources.find((s) => s.id === sourceId)?.name ?? "") : "", + ); + setSelected(new Set()); + setItems([]); + setActiveSearch(""); + setSearchTerm(""); + fetchItems("", 1); + }; + + const fetchSources = useCallback(async () => { + try { + const res = await adminFetch("/api/admin/import/clone?action=sources"); + if (res.ok) { + const data = await res.json(); + setSources(data.sources ?? []); + } + } catch { + /* ignore */ + } + }, []); + useEffect(() => { fetchItems(); fetchStats(); - }, [fetchItems, fetchStats]); + fetchSources(); + }, [fetchItems, fetchStats, fetchSources]); // Keep detail in sync with the refreshed list. useEffect(() => { @@ -464,6 +499,7 @@ export function StudioClient({ type: item.type, revision: item.revision, category: item.category, + sourceId: activeSource || undefined, }), }); const data = await res.json(); @@ -518,6 +554,7 @@ export function StudioClient({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ + sourceId: activeSource || undefined, items: toImport.map((i) => ({ id: i.id, classname: i.classname, @@ -655,7 +692,7 @@ export function StudioClient({ Studio

- {source.label} furnidata + {activeSourceName || source.label} furnidata

@@ -704,6 +741,20 @@ export function StudioClient({ {/* ── Filters + actions ─────────────────────────────── */}
+ +