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:
1 parent
5b09179404
commit
54f2bc5e0c
2 files changed
+127
-24
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user