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 { CatalogPages, db, ItemsBase } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
import { getCloneList } from "@/lib/services/clone-import";
|
||||||
import { getSource } from "@/lib/services/clone-sources";
|
import { getSource } from "@/lib/services/clone-sources";
|
||||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||||
import {
|
import {
|
||||||
@@ -213,6 +214,64 @@ export const GET = withAdmin(
|
|||||||
const search = request.nextUrl.searchParams.get("search") || "";
|
const search = request.nextUrl.searchParams.get("search") || "";
|
||||||
const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
|
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
|
// 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
|
// items to the top *before* pagination and (b) skip the per-page lookup
|
||||||
// below. Single-column indexed query — fast even at 10k+ rows.
|
// below. Single-column indexed query — fast even at 10k+ rows.
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ interface FurniItem {
|
|||||||
category: string;
|
category: string;
|
||||||
alreadyImported: boolean;
|
alreadyImported: boolean;
|
||||||
nitroExists: boolean;
|
nitroExists: boolean;
|
||||||
|
/** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */
|
||||||
|
iconUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Meta {
|
interface Meta {
|
||||||
@@ -89,11 +91,12 @@ interface BatchItemStatus {
|
|||||||
itemId?: number;
|
itemId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFurniImageUrl(classname: string, revision: number) {
|
function getFurniImageUrl(item: FurniItem) {
|
||||||
const base = classname.includes("*")
|
if (item.iconUrl) return item.iconUrl;
|
||||||
? classname.substring(0, classname.indexOf("*"))
|
const base = item.classname.includes("*")
|
||||||
: classname;
|
? item.classname.substring(0, item.classname.indexOf("*"))
|
||||||
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`;
|
: item.classname;
|
||||||
|
return `https://images.habbo.com/dcr/hof_furni/${item.revision}/${encodeURIComponent(base)}_icon.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFurniIconUrl(classname: string, revision: number) {
|
function getFurniIconUrl(classname: string, revision: number) {
|
||||||
@@ -103,14 +106,13 @@ function getFurniIconUrl(classname: string, revision: number) {
|
|||||||
|
|
||||||
function handleImgError(
|
function handleImgError(
|
||||||
e: React.SyntheticEvent<HTMLImageElement>,
|
e: React.SyntheticEvent<HTMLImageElement>,
|
||||||
classname: string,
|
item: FurniItem,
|
||||||
revision: number,
|
|
||||||
) {
|
) {
|
||||||
const img = e.target as HTMLImageElement;
|
const img = e.target as HTMLImageElement;
|
||||||
const iconName = classname.replace(/\*/g, "_");
|
const iconName = item.classname.replace(/\*/g, "_");
|
||||||
if (!img.dataset.fallback) {
|
if (!img.dataset.fallback) {
|
||||||
img.dataset.fallback = "1";
|
img.dataset.fallback = "1";
|
||||||
img.src = getFurniIconUrl(classname, revision);
|
img.src = getFurniIconUrl(item.classname, item.revision);
|
||||||
} else if (img.dataset.fallback === "1") {
|
} else if (img.dataset.fallback === "1") {
|
||||||
img.dataset.fallback = "2";
|
img.dataset.fallback = "2";
|
||||||
img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`;
|
img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`;
|
||||||
@@ -310,6 +312,13 @@ export function StudioClient({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
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
|
// Filters
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
@@ -383,6 +392,7 @@ export function StudioClient({
|
|||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams({ page: String(page) });
|
const qs = new URLSearchParams({ page: String(page) });
|
||||||
if (search) qs.set("search", search);
|
if (search) qs.set("search", search);
|
||||||
|
if (activeSource) qs.set("source", activeSource);
|
||||||
const res = await adminFetch(
|
const res = await adminFetch(
|
||||||
`/api/admin/import/furni?${qs.toString()}`,
|
`/api/admin/import/furni?${qs.toString()}`,
|
||||||
);
|
);
|
||||||
@@ -404,13 +414,38 @@ export function StudioClient({
|
|||||||
setLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchItems();
|
fetchItems();
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}, [fetchItems, fetchStats]);
|
fetchSources();
|
||||||
|
}, [fetchItems, fetchStats, fetchSources]);
|
||||||
|
|
||||||
// Keep detail in sync with the refreshed list.
|
// Keep detail in sync with the refreshed list.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -464,6 +499,7 @@ export function StudioClient({
|
|||||||
type: item.type,
|
type: item.type,
|
||||||
revision: item.revision,
|
revision: item.revision,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
|
sourceId: activeSource || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -518,6 +554,7 @@ export function StudioClient({
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
sourceId: activeSource || undefined,
|
||||||
items: toImport.map((i) => ({
|
items: toImport.map((i) => ({
|
||||||
id: i.id,
|
id: i.id,
|
||||||
classname: i.classname,
|
classname: i.classname,
|
||||||
@@ -655,7 +692,7 @@ export function StudioClient({
|
|||||||
Studio
|
Studio
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[0.65rem] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
<p className="text-[0.65rem] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||||
{source.label} furnidata
|
{activeSourceName || source.label} furnidata
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -704,6 +741,20 @@ export function StudioClient({
|
|||||||
|
|
||||||
{/* ── Filters + actions ─────────────────────────────── */}
|
{/* ── 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">
|
<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}>
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<SelectTrigger className="h-8 w-32 text-xs">
|
<SelectTrigger className="h-8 w-32 text-xs">
|
||||||
<SelectValue placeholder="Type" />
|
<SelectValue placeholder="Type" />
|
||||||
@@ -785,7 +836,7 @@ export function StudioClient({
|
|||||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||||
{activeSearch
|
{activeSearch
|
||||||
? `No furni found for "${activeSearch}"`
|
? `No furni found for "${activeSearch}"`
|
||||||
: "Search the official Habbo furni library to get started."}
|
: "Search a furni library to get started."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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)]">
|
<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
|
<img
|
||||||
src={getFurniImageUrl(
|
src={getFurniImageUrl(item)}
|
||||||
item.classname,
|
|
||||||
item.revision,
|
|
||||||
)}
|
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
|
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={(e) =>
|
onError={(e) => handleImgError(e, item)}
|
||||||
handleImgError(e, item.classname, item.revision)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -925,12 +971,10 @@ export function StudioClient({
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
<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)]">
|
<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
|
<img
|
||||||
src={getFurniImageUrl(detail.classname, detail.revision)}
|
src={getFurniImageUrl(detail)}
|
||||||
alt={detail.name}
|
alt={detail.name}
|
||||||
className="h-28 w-28 object-contain"
|
className="h-28 w-28 object-contain"
|
||||||
onError={(e) =>
|
onError={(e) => handleImgError(e, detail)}
|
||||||
handleImgError(e, detail.classname, detail.revision)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user