feat: relocate import tools into studio and harden catalog/furnidata integrity
- 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:
1 parent
c2e61dc324
commit
c7ba04bda1
36 files changed
+616
-2835
No files matched your search
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← 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">
|
||||
…
|
||||
</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 →
|
||||
</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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+6
-5
@@ -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.
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user