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=<id> 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
This commit is contained in:
openhands committed 2026-08-15 15:43:51 +02:00
1 parent 5b09179404
commit 54f2bc5e0c
2 files changed
+127 -24

No files matched your search

+59
View File
@@ -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.
+68 -24
View File
@@ -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<HTMLImageElement>,
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<Array<{ id: string; name: string }>>(
[],
);
const [activeSource, setActiveSource] = useState<string>("");
const [activeSourceName, setActiveSourceName] = useState("");
// Filters
const [typeFilter, setTypeFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("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
</h1>
<p className="text-[0.65rem] uppercase tracking-wider text-[var(--admin-text-muted)]">
{source.label} furnidata
{activeSourceName || source.label} furnidata
</p>
</div>
</div>
@@ -704,6 +741,20 @@ export function StudioClient({
{/* ── Filters + actions ─────────────────────────────── */}
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2">
<Select value={activeSource} onValueChange={changeSource}>
<SelectTrigger className="h-8 w-44 text-xs">
<SelectValue placeholder="Furni source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{source.label} (official)</SelectItem>
{sources.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue placeholder="Type" />
@@ -785,7 +836,7 @@ export function StudioClient({
<p className="text-sm text-[var(--admin-text-muted)]">
{activeSearch
? `No furni found for "${activeSearch}"`
: "Search the official Habbo furni library to get started."}
: "Search a furni library to get started."}
</p>
</div>
) : (
@@ -808,16 +859,11 @@ export function StudioClient({
>
<div className="relative flex h-24 items-center justify-center overflow-hidden bg-gradient-to-br from-[var(--admin-surface)] to-[var(--admin-canvas)]">
<img
src={getFurniImageUrl(
item.classname,
item.revision,
)}
src={getFurniImageUrl(item)}
alt={item.name}
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
loading="lazy"
onError={(e) =>
handleImgError(e, item.classname, item.revision)
}
onError={(e) => handleImgError(e, item)}
/>
<button
type="button"
@@ -925,12 +971,10 @@ export function StudioClient({
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="mb-4 grid h-40 place-items-center rounded-xl border border-[var(--admin-border)] bg-gradient-to-br from-[var(--admin-surface)] to-[var(--admin-canvas)]">
<img
src={getFurniImageUrl(detail.classname, detail.revision)}
src={getFurniImageUrl(detail)}
alt={detail.name}
className="h-28 w-28 object-contain"
onError={(e) =>
handleImgError(e, detail.classname, detail.revision)
}
onError={(e) => handleImgError(e, detail)}
/>
</div>