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

+62
View File
@@ -38,6 +38,68 @@ const nextConfig: NextConfig = {
deploymentId: resolveDeploymentId(),
serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"],
// The /admin/import hub was consolidated into the catalogue studio at
// /admin/studio. Redirect the old routes so bookmarks/links don't 404.
async redirects() {
return [
{
source: "/admin/import",
destination: "/admin/studio/furni",
permanent: true,
},
{
source: "/admin/import/badges",
destination: "/admin/studio/badges",
permanent: true,
},
{
source: "/admin/import/furni",
destination: "/admin/studio/furni",
permanent: true,
},
{
source: "/admin/import/furni/upload",
destination: "/admin/studio/upload",
permanent: true,
},
{
source: "/admin/import/clothing",
destination: "/admin/studio/clothing",
permanent: true,
},
{
source: "/admin/import/effects",
destination: "/admin/studio/effects",
permanent: true,
},
{
source: "/admin/import/pets",
destination: "/admin/studio/pets",
permanent: true,
},
{
source: "/admin/import/clone",
destination: "/admin/studio/clone",
permanent: true,
},
{
source: "/admin/import/sync",
destination: "/admin/studio/sync",
permanent: true,
},
{
source: "/admin/import/repair-icons",
destination: "/admin/studio/repair-icons",
permanent: true,
},
{
source: "/admin/import/audit",
destination: "/admin/studio/audit",
permanent: true,
},
];
},
// Silence Turbopack warnings for the broad file patterns in src/lib.
turbopack: {
ignoreIssue: [
File diff suppressed because it is too large. Load diff
-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>
);
}
-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>
);
}
-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>
);
}
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>
);
}
-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>
);
}
File diff suppressed because it is too large. Load diff
-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 />;
}
@@ -164,6 +164,14 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
icon: "music",
description: "Public URLs and overrides for SWF / Nitro asset packages.",
fields: [
{
key: "furnidata_translate_enabled",
label: "Translate furniture names",
description:
"Rebuild FurnitureData_<lang>.json with translated names after each import. Disable to skip the LibreTranslate / deep-clone work and save CPU & RAM when you don't need localized furniture names. You can still rebuild on demand via the import 'build languages' action.",
type: "boolean",
defaultValue: "1",
},
{
key: "habbo_gamedata_hotel",
label: "Habbo hotel for furni names",
@@ -1,18 +1,19 @@
import { redirect } from "next/navigation";
import { AuditClient } from "@/app/admin/import/audit/audit-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { AuditClient } from "./audit-client";
export default async function AuditPage() {
export default async function StudioAuditPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Catalog Audit</h1>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
Scan your entire furniture catalog for missing entries, broken
references, missing files, and items available from clone sources.
</p>
@@ -1,14 +1,17 @@
import { redirect } from "next/navigation";
import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportBadgesClient } from "./import-badges-client";
export default async function ImportBadgesPage(_props: {
export default async function StudioBadgesPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportBadgesClient />;
return (
<div className="p-4">
<ImportBadgesClient />
</div>
);
}
@@ -1,13 +1,17 @@
import { redirect } from "next/navigation";
import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportClothingClient } from "./import-clothing-client";
export default async function ImportClothingPage(_props: {
export default async function StudioClonePage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportClothingClient />;
return (
<div className="p-4">
<ImportCloneClient />
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import { ImportClothingClient } from "@/app/admin/import/clothing/import-clothing-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function StudioClothingPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="p-4">
<ImportClothingClient />
</div>
);
}
@@ -1,13 +1,17 @@
import { redirect } from "next/navigation";
import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportCloneClient } from "./import-clone-client";
export default async function ImportClonePage(_props: {
export default async function StudioEffectsPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportCloneClient />;
return (
<div className="p-4">
<ImportEffectsClient />
</div>
);
}
@@ -1,10 +1,11 @@
import { redirect } from "next/navigation";
import { StudioClient } from "@/components/admin/studio/studio-client";
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getTreeFlat } from "@/lib/services/catalog-tree";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { ImportFurniClient } from "./import-furni-client";
export default async function ImportFurniPage(_props: {
export default async function StudioFurniPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
@@ -12,6 +13,15 @@ export default async function ImportFurniPage(_props: {
redirect("/admin");
}
const hotel = await getHabboGamedataHotel();
return <ImportFurniClient source={buildFurniImportSource(hotel)} />;
const [hotel, catalogTree] = await Promise.all([
getHabboGamedataHotel(),
getTreeFlat(),
]);
return (
<StudioClient
source={buildFurniImportSource(hotel)}
initialTree={catalogTree}
/>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import {
BadgeCheck,
Cat,
ClipboardCheck,
Copy,
Dumbbell,
Image as ImageIcon,
type LucideIcon,
Puzzle,
RefreshCw,
Shirt,
Upload,
Wrench,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const TABS: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/admin/studio/furni", label: "Furni", icon: Puzzle },
{ href: "/admin/studio/upload", label: "Upload", icon: Upload },
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt },
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell },
{ href: "/admin/studio/pets", label: "Pets", icon: Cat },
{ href: "/admin/studio/clone", label: "Clone", icon: Copy },
{ href: "/admin/studio/sync", label: "Sync", icon: RefreshCw },
{
href: "/admin/studio/repair-icons",
label: "Repair Icons",
icon: ImageIcon,
},
{ href: "/admin/studio/audit", label: "Audit", icon: ClipboardCheck },
{
href: "/admin/studio/maintenance",
label: "Catalogus onderhoud",
icon: Wrench,
},
];
export default function StudioLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname() ?? "";
return (
<div
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-2 py-1.5">
{TABS.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={href}
href={href}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
active
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]",
)}
>
<Icon size={13} />
{label}
</Link>
);
})}
</div>
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
</div>
);
}
@@ -0,0 +1,9 @@
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
export default function StudioCatalogMaintenancePage() {
return (
<div className="min-h-0">
<CatalogMaintenancePanel />
</div>
);
}
+2 -24
View File
@@ -1,27 +1,5 @@
import { redirect } from "next/navigation";
import { StudioClient } from "@/components/admin/studio/studio-client";
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getTreeFlat } from "@/lib/services/catalog-tree";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
export default async function StudioPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
const [hotel, catalogTree] = await Promise.all([
getHabboGamedataHotel(),
getTreeFlat(),
]);
return (
<StudioClient
source={buildFurniImportSource(hotel)}
initialTree={catalogTree}
/>
);
export default function StudioIndexPage() {
redirect("/admin/studio/furni");
}
@@ -1,14 +1,17 @@
// src/app/[locale]/admin/import/effects/page.tsx
import { redirect } from "next/navigation";
import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportEffectsClient } from "./import-effects-client";
export default async function ImportEffectsPage(_props: {
export default async function StudioPetsPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return <ImportEffectsClient />;
return (
<div className="p-4">
<ImportPetsClient />
</div>
);
}
@@ -1,20 +1,21 @@
import { redirect } from "next/navigation";
import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { RepairIconsClient } from "./repair-icons-client";
export default async function RepairIconsPage() {
export default async function StudioRepairIconsPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Repair Missing Icons
</h1>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
Scan all existing furniture and download any missing icon files. Icons
are extracted from local .nitro bundles or fetched from configured
clone sources.
@@ -1,18 +1,19 @@
import { redirect } from "next/navigation";
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { SyncAllClient } from "./sync-all-client";
export default async function SyncAllPage() {
export default async function StudioSyncPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
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
@@ -1,20 +1,21 @@
import { redirect } from "next/navigation";
import { UploadFurniClient } from "@/app/admin/import/furni/upload/upload-furni-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { UploadFurniClient } from "./upload-furni-client";
export default async function UploadFurniPage() {
export default async function StudioUploadPage(_props: {
params: Promise<{ locale: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin");
}
return (
<div className="space-y-6">
<div className="space-y-6 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Upload .nitro Furniture
</h1>
<p className="text-muted-foreground mt-1">
<p className="mt-1 text-muted-foreground">
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.
+2 -1
View File
@@ -220,7 +220,7 @@ export const GET = withAdmin(
}
if (action === "build-languages") {
const languages = await buildLocalizedFurniDataFiles();
const languages = await buildLocalizedFurniDataFiles(true);
return apiOk({ languages });
}
@@ -680,6 +680,7 @@ export const POST = withAdmin(
furniDataFixedIds: furniReconcile.fixedIds,
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
furniDataMissing: furniReconcile.missing,
furniDataFixedConflicts: furniReconcile.fixedConflicts,
furniDataConflicts: furniReconcile.conflicts,
offerRebuildChecked: offerRebuild.checked,
offerRebuildFixed: offerRebuild.fixed,
File diff suppressed because it is too large. Load diff
@@ -602,10 +602,7 @@ export function StudioClient({
);
return (
<div
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<div className="flex h-full min-h-[520px] flex-col overflow-hidden">
{/* ── Header ─────────────────────────────────────────── */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex items-center gap-2.5">
-41
View File
@@ -13,7 +13,6 @@ import {
Gavel,
HelpCircle,
Image,
Import,
KeyRound,
Languages,
LayoutDashboard,
@@ -35,7 +34,6 @@ import {
Terminal,
Ticket,
Trophy,
Upload,
Users,
UsersRound,
Volume2,
@@ -219,38 +217,6 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
{ href: "/admin/devops/errors", labelKey: "errors" },
],
},
{
id: "import",
titleKey: "import",
subtitleKey: "importSubtitle",
icon: Upload,
prefixes: ["/admin/import"],
tabs: [
{
href: "/admin/import",
labelKey: "importOverview",
match: ["/admin/import"],
},
{ href: "/admin/import/badges", labelKey: "importBadges" },
{
href: "/admin/import/furni/upload",
labelKey: "importUpload",
match: ["/admin/import/furni/upload"],
},
{
href: "/admin/import/furni",
labelKey: "importFurni",
match: ["/admin/import/furni"],
},
{ href: "/admin/import/clothing", labelKey: "importClothing" },
{ href: "/admin/import/effects", labelKey: "importEffects" },
{ href: "/admin/import/pets", labelKey: "importPets" },
{ href: "/admin/import/clone", labelKey: "importClone" },
{ href: "/admin/import/sync", labelKey: "importSync" },
{ href: "/admin/import/repair-icons", labelKey: "importRepairIcons" },
{ href: "/admin/import/audit", labelKey: "importAudit" },
],
},
];
/** Longest-prefix match so nested routes (e.g. /admin/logs/audit) win. */
@@ -637,13 +603,6 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
permission: PERMS.ASSETS_IMPORT,
matchPrefixes: ["/admin/studio"],
},
{
href: "/admin/import",
labelKey: "import",
icon: Import,
permission: PERMS.ASSETS_IMPORT,
matchPrefixes: ["/admin/import"],
},
{
href: "/admin/translations",
labelKey: "translations",
-1
View File
@@ -6,7 +6,6 @@ const ROOTS = ["src/app/admin", "src/components/admin"];
const GRAPHICAL_ALLOWLIST = [
"src/app/admin/favicon/favicon-generator.tsx",
"src/app/admin/import/clone/import-clone-client.tsx",
"src/app/admin/import/furni/import-furni-client.tsx",
"src/components/admin/catalog/items-shop-preview.tsx",
"src/components/admin/media-grid.tsx",
];
+32 -3
View File
@@ -41,6 +41,22 @@ export const FURNIDATA_LANGUAGES = [
export type FurnidataLang = (typeof FURNIDATA_LANGUAGES)[number]["lang"];
/**
* Master switch for FurnitureData localization. Rebuilding the per-language
* files deep-clones the 100k+ entry master 13× and hits LibreTranslate, which
* can spike RAM/CPU on every import. Admins can disable it in settings to skip
* the work entirely (the master FurnitureData.json is still written by the
* import itself). The manual "build languages" action passes `force` to run
* regardless.
*/
async function translationEnabled(): Promise<boolean> {
try {
return await siteSettings.getBool("furnidata_translate_enabled", true);
} catch {
return true;
}
}
export interface LanguageFileResult {
lang: string;
hotel: string;
@@ -415,9 +431,15 @@ function cloneFurniData(
* translation exists for that classname. Runs under the furnidata lock so it
* never interleaves with concurrent imports.
*/
export async function buildLocalizedFurniDataFiles(): Promise<
LanguageFileResult[]
> {
export async function buildLocalizedFurniDataFiles(
force = false,
): Promise<LanguageFileResult[]> {
if (!force && !(await translationEnabled())) {
logger.debug(
"[furni-i18n] FurnitureData translation disabled by setting; skipping localized build.",
);
return [];
}
return withFurniDataLock(async () => {
const master = (await readFurniData()) as Record<
string,
@@ -554,8 +576,15 @@ export async function buildLocalizedFurniDataFiles(): Promise<
*/
export async function patchLocalizedFurniDataEntries(
entries: Array<{ classname: string; name?: string; description?: string }>,
force = false,
): Promise<LanguageFileResult[]> {
if (entries.length === 0) return [];
if (!force && !(await translationEnabled())) {
logger.debug(
"[furni-i18n] FurnitureData translation disabled by setting; skipping localized patch.",
);
return [];
}
return withFurniDataLock(async () => {
const maps = await Promise.all(
+32 -7
View File
@@ -485,7 +485,12 @@ export interface FurniDataReconcileResult {
normalizedIds: number;
/** items_base rows with no matching furnitype entry (need re-import). */
missing: number;
/** spriteIds used by a different classname in the file; left untouched. */
/** spriteIds used by a different classname in the file, now resolved by
* overwriting the entry's classname with the items_base (authoritative)
* value so the client renders the correct furniture. */
fixedConflicts: number;
/** spriteIds still used by a different classname after the pass (none, once
* fixedConflicts resolves every conflict). */
conflicts: number;
}
@@ -498,8 +503,10 @@ export interface FurniDataReconcileResult {
* the emulator's catalog offer lookup stays in sync with the local ids;
* - rows missing entirely from the file are counted (they need a re-import
* or batch-regen, since building an entry requires the SWF/nitro metadata);
* - genuine spriteId conflicts (id used by a different classname) are counted
* but never auto-fixed.
* - genuine spriteId conflicts (id used by a different classname) are resolved
* in favour of items_base: the furnidata entry occupying that id has its
* classname overwritten with the items_base classname, so the client renders
* the real (imported) furniture instead of a legacy/conflicting one.
*/
export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconcileResult> {
return withFurniDataLock(async () => {
@@ -539,7 +546,8 @@ export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconc
let fixedOfferIds = 0;
let normalizedIds = 0;
let missing = 0;
let conflicts = 0;
let fixedConflicts = 0;
const conflicts = 0;
const entryOfferId = (entry: Record<string, unknown>): number => {
const v = Number(entry.offerid);
@@ -551,7 +559,17 @@ export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconc
const existing = byId.get(spriteId);
if (existing) {
if (existing.entry.classname !== row.itemName) {
conflicts++;
// items_base is authoritative: this furnidata entry sits at the
// wrong classname for this id (legacy/conflicting data). Overwrite
// it so the client renders the imported furniture, not a stray one.
const displaced = String(existing.entry.classname ?? "");
existing.entry.classname = row.itemName;
existing.entry.id = spriteId;
existing.entry.offerid = spriteId;
// Keep the in-memory maps coherent for the corrected classname.
byClass.set(row.itemName, existing);
if (displaced) byClass.delete(displaced);
fixedConflicts++;
} else if (entryOfferId(existing.entry) !== spriteId) {
existing.entry.offerid = spriteId;
fixedOfferIds++;
@@ -589,10 +607,17 @@ export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconc
}
}
if (fixedIds > 0 || fixedOfferIds > 0) {
if (fixedIds > 0 || fixedOfferIds > 0 || fixedConflicts > 0) {
await writeFurniData(furniData as Record<string, unknown>);
}
return { fixedIds, fixedOfferIds, normalizedIds, missing, conflicts };
return {
fixedIds,
fixedOfferIds,
normalizedIds,
missing,
fixedConflicts,
conflicts,
};
});
}
+6 -4
View File
@@ -180,8 +180,8 @@ describeGuard("live furni import / catalog ID consistency (sandbox DB)", () => {
const before = buildMaps(await readFurniData());
// Genuine id conflicts (our spriteId is owned by a different classname
// in the file) are left untouched by design — they are a separate
// pre-existing data problem, not part of this fix.
// in the file) are resolved in favour of items_base: the entry keeps its
// id but its classname is overwritten with the items_base classname.
let conflicts = 0;
let missing = 0;
for (const row of rows) {
@@ -205,7 +205,8 @@ describeGuard("live furni import / catalog ID consistency (sandbox DB)", () => {
const result = await reconcileFurniDataWithItemsBase();
expect(result.fixedIds).toBe(idMisBefore);
expect(result.conflicts).toBe(conflicts);
expect(result.fixedConflicts).toBe(conflicts);
expect(result.conflicts).toBe(0);
expect(result.missing).toBe(missing);
// Mirror the function's contract exactly: rows whose id IS keyed in the
@@ -218,7 +219,8 @@ describeGuard("live furni import / catalog ID consistency (sandbox DB)", () => {
// furnidata file can only hold one id per classname, so a duplicate
// classname can never satisfy this invariant for all its rows. Those
// are repaired by the dedicated duplicate-classname tool, not by this
// function. Genuine id conflicts are likewise out of scope.
// function. Genuine id conflicts are now resolved by overwriting the
// entry's classname with the items_base value (see fixedConflicts).
const dupClassnames = new Map<string, number>();
for (const row of rows)
dupClassnames.set(
+4 -4
View File
@@ -243,11 +243,11 @@ describe("staff smoke contract", () => {
);
});
it("ships import hub with synced badge import path", () => {
it("ships studio hub with synced badge import path", () => {
const nav = readFileSync("src/lib/admin-nav.ts", "utf8");
expect(nav).toContain('id: "import"');
expect(nav).toContain("importSubtitle");
expect(nav).toContain("/admin/import");
expect(nav).toContain('href: "/admin/studio"');
expect(nav).toContain('labelKey: "studio"');
expect(nav).toContain('"/admin/studio"');
expect(existsSync("src/lib/services/import-badge.ts")).toBe(true);
const badgeService = readFileSync(
"src/lib/services/import-badge.ts",