feat: relocate import tools into studio and harden catalog/furnidata integrity
CI / check (push) Failing after 26s
CI / release (push) Skipped
CI / deploy (push) Skipped

- Move /admin/import/* tools under /admin/studio/* and add studio nav, layout and tabs
- Add shared catalog maintenance panel plus a /admin/studio/maintenance tab
- Make furnidata reconciliation overwrite conflicting entries with the
  DB-authoritative items_base classname/id (surfaced via fixedConflicts)
- Add furnidata_translate_enabled setting to skip the heavy localized
  furnidata build during import (manual build-languages still forces it)
- Update staff smoke contract test for the studio hub
This commit is contained in:
openhands committed 2026-08-24 17:13:03 +02:00
1 parent c2e61dc324
commit c7ba04bda1
36 files changed
+616 -2835

No files matched your search

-23
View File
@@ -1,23 +0,0 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { AuditClient } from "./audit-client";
export default async function AuditPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Catalog Audit</h1>
<p className="text-muted-foreground mt-1">
Scan your entire furniture catalog for missing entries, broken
references, missing files, and items available from clone sources.
</p>
</div>
<AuditClient />
</div>
);
}
-21
View File
@@ -1,21 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ImportBadgesLoading() {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-72 mt-2" />
</div>
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }, (_, i) => i).map((n) => (
<div key={`skeleton-field-${n}`} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
);
}
-14
View File
@@ -1,14 +0,0 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportBadgesClient } from "./import-badges-client";
export default async function ImportBadgesPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportBadgesClient />;
}
-13
View File
@@ -1,13 +0,0 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportCloneClient } from "./import-clone-client";
export default async function ImportClonePage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportCloneClient />;
}
-16
View File
@@ -1,16 +0,0 @@
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }, (_, i) => i).map((n) => (
<div
key={`figure-skeleton-${n}`}
className="rounded-lg border bg-card p-3 animate-pulse"
>
<div className="h-12 w-12 bg-muted rounded mx-auto mb-2" />
<div className="h-4 bg-muted rounded w-3/4 mx-auto mb-1" />
<div className="h-7 bg-muted rounded" />
</div>
))}
</div>
);
}
-13
View File
@@ -1,13 +0,0 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportClothingClient } from "./import-clothing-client";
export default async function ImportClothingPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportClothingClient />;
}
-17
View File
@@ -1,17 +0,0 @@
// src/app/[locale]/admin/import/effects/loading.tsx
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }, (_, i) => i).map((n) => (
<div
key={`effect-skeleton-${n}`}
className="rounded-lg border bg-card p-3 animate-pulse"
>
<div className="h-12 w-12 bg-muted rounded mx-auto mb-2" />
<div className="h-4 bg-muted rounded w-3/4 mx-auto mb-1" />
<div className="h-7 bg-muted rounded" />
</div>
))}
</div>
);
}
-14
View File
@@ -1,14 +0,0 @@
// src/app/[locale]/admin/import/effects/page.tsx
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportEffectsClient } from "./import-effects-client";
export default async function ImportEffectsPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportEffectsClient />;
}
File diff suppressed because it is too large. Load diff
@@ -1,45 +0,0 @@
import type { SyntheticEvent } from "react";
export function getFurniImageUrl(classname: string, revision: number) {
// Use official Habbo CDN for furniture icons
const base = classname.includes("*")
? classname.substring(0, classname.indexOf("*"))
: classname;
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`;
}
export function getFurniIconUrl(classname: string, revision: number) {
const iconName = classname.replace(/\*/g, "_");
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`;
}
export function handleImgError(
e: SyntheticEvent<HTMLImageElement>,
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 {
// Show placeholder instead of invisible image
img.style.opacity = "0.3";
img.src =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="%23666" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>';
}
}
export const STATUS_LABELS: Record<string, string> = {
pending: "Pending",
started: "Starting",
downloading: "Downloading",
converting: "Converting",
writing_db: "Writing DB",
done: "Done",
failed: "Failed",
};
@@ -1,47 +0,0 @@
export interface FurniItem {
id: number;
classname: string;
type: string;
name: string;
description: string;
revision: number;
category: string;
alreadyImported: boolean;
nitroExists: boolean;
}
export interface Meta {
currentPage: number;
lastPage: number;
total: number;
perPage: number;
}
export interface BatchItemStatus {
classname: string;
status:
| "pending"
| "started"
| "downloading"
| "converting"
| "writing_db"
| "done"
| "failed";
message?: string;
warnings?: string[];
itemId?: number;
}
export interface BatchSummary {
succeeded: number;
failed: number;
warnings: number;
duration: number;
}
export interface FurniStats {
totalInDb: number;
inCatalog: number;
missingNitro: number;
notInCatalog: number;
}
-21
View File
@@ -1,21 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ImportFurniLoading() {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-72 mt-2" />
</div>
<div className="rounded-lg border bg-card p-6 space-y-4">
{Array.from({ length: 6 }, (_, i) => i).map((n) => (
<div key={`skeleton-field-${n}`} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<Skeleton className="h-10 w-24 mt-4" />
</div>
</div>
);
}
-17
View File
@@ -1,17 +0,0 @@
import { redirect } from "next/navigation";
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { ImportFurniClient } from "./import-furni-client";
export default async function ImportFurniPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const hotel = await getHabboGamedataHotel();
return <ImportFurniClient source={buildFurniImportSource(hotel)} />;
}
-89
View File
@@ -1,89 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import type { Meta } from "./import-furni-types";
export function Pagination({
meta,
loading,
onPageChange,
}: {
meta: Meta | null;
loading: boolean;
onPageChange: (page: number) => void;
}) {
if (!meta || meta.lastPage <= 1) return null;
const nodes: ReactNode[] = [];
if (meta.currentPage > 1) {
nodes.push(
<Button
key="prev"
variant="ghost"
size="sm"
onClick={() => onPageChange(meta.currentPage - 1)}
disabled={loading}
className="text-xs"
>
&larr; Prev
</Button>,
);
}
for (let p = 1; p <= meta.lastPage; p++) {
if (p === meta.currentPage) {
nodes.push(
<Button
key={p}
variant="default"
size="sm"
className="text-xs min-w-[32px]"
>
{p}
</Button>,
);
} else if (
Math.abs(p - meta.currentPage) <= 2 ||
p === 1 ||
p === meta.lastPage
) {
nodes.push(
<Button
key={p}
variant="ghost"
size="sm"
onClick={() => onPageChange(p)}
disabled={loading}
className="text-xs min-w-[32px]"
>
{p}
</Button>,
);
} else if (Math.abs(p - meta.currentPage) === 3) {
nodes.push(
<span key={`dots-${p}`} className="text-muted-foreground text-xs px-1">
&hellip;
</span>,
);
}
}
if (meta.currentPage < meta.lastPage) {
nodes.push(
<Button
key="next"
variant="ghost"
size="sm"
onClick={() => onPageChange(meta.currentPage + 1)}
disabled={loading}
className="text-xs"
>
Next &rarr;
</Button>,
);
}
return (
<div className="flex items-center justify-center gap-1 mt-4 flex-wrap">
{nodes}
</div>
);
}
@@ -1,26 +0,0 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { UploadFurniClient } from "./upload-furni-client";
export default async function UploadFurniPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Upload .nitro Furniture
</h1>
<p className="text-muted-foreground mt-1">
Upload a .nitro bundle and optional icon to import furniture into the
hotel. The database entry, catalog item, and FurnitureData.json will
be updated automatically.
</p>
</div>
<UploadFurniClient />
</div>
);
}
-113
View File
@@ -1,113 +0,0 @@
import {
BadgeCheck,
Cat,
ClipboardCheck,
Copy,
Dumbbell,
ImageIcon,
Puzzle,
RefreshCw,
Shirt,
Upload,
} from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function ImportPage() {
const t = await getTranslations("pages.admin.import");
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const SECTIONS = [
{
href: "/admin/import/badges",
label: t("badges"),
icon: BadgeCheck,
description: t("badgesDescription"),
},
{
href: "/admin/import/furni",
label: t("furni"),
icon: Puzzle,
description: t("furniDescription"),
},
{
href: "/admin/import/furni/upload",
label: t("upload"),
icon: Upload,
description: t("uploadDescription"),
},
{
href: "/admin/import/clothing",
label: t("clothing"),
icon: Shirt,
description: t("clothingDescription"),
},
{
href: "/admin/import/effects",
label: t("effects"),
icon: Dumbbell,
description: t("effectsDescription"),
},
{
href: "/admin/import/pets",
label: t("pets"),
icon: Cat,
description: t("petsDescription"),
},
{
href: "/admin/import/clone",
label: t("clone"),
icon: Copy,
description: t("cloneDescription"),
},
{
href: "/admin/import/sync",
label: t("sync"),
icon: RefreshCw,
description: t("syncDescription"),
},
{
href: "/admin/import/repair-icons",
label: t("repairIcons"),
icon: ImageIcon,
description: t("repairIconsDescription"),
},
{
href: "/admin/import/audit",
label: t("audit"),
icon: ClipboardCheck,
description: t("auditDescription"),
},
] as const;
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{SECTIONS.map(({ href, label, icon: Icon, description }) => (
<Link
key={href}
href={href}
className="group rounded-lg border bg-card p-4 transition-colors hover:border-primary hover:bg-accent"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<Icon className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold group-hover:text-primary">
{label}
</h2>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
</Link>
))}
</div>
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
export default function Loading() {
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 18 }, (_, i) => i).map((n) => (
<div
key={`pet-skeleton-${n}`}
className="rounded-lg border bg-card p-3 animate-pulse"
>
<div className="h-12 w-12 bg-muted rounded mx-auto mb-2" />
<div className="h-4 bg-muted rounded w-3/4 mx-auto mb-1" />
<div className="h-7 bg-muted rounded" />
</div>
))}
</div>
);
}
-13
View File
@@ -1,13 +0,0 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportPetsClient } from "./import-pets-client";
export default async function ImportPetsPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportPetsClient />;
}
@@ -1,26 +0,0 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { RepairIconsClient } from "./repair-icons-client";
export default async function RepairIconsPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Repair Missing Icons
</h1>
<p className="text-muted-foreground mt-1">
Scan all existing furniture and download any missing icon files. Icons
are extracted from local .nitro bundles or fetched from configured
clone sources.
</p>
</div>
<RepairIconsClient />
</div>
);
}
-25
View File
@@ -1,25 +0,0 @@
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { SyncAllClient } from "./sync-all-client";
export default async function SyncAllPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1>
<p className="text-muted-foreground mt-1">
Scan all configured clone sources and automatically import any
furniture that is not yet present in your hotel. Missing .nitro files,
icons, database entries, catalog items, and FurnitureData.json will be
added automatically.
</p>
</div>
<SyncAllClient />
</div>
);
}