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(), deploymentId: resolveDeploymentId(),
serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"], 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. // Silence Turbopack warnings for the broad file patterns in src/lib.
turbopack: { turbopack: {
ignoreIssue: [ ignoreIssue: [
+2 -288
View File
@@ -1,291 +1,5 @@
"use client"; import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
import { AlertTriangle, CheckCircle2, RefreshCw, Wrench } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
applyAlignIdsAction,
fixCatalogOffersAction,
fixEverythingAction,
fixSpriteIdsAction,
getFurniHealthAction,
previewAlignIdsAction,
reconcileIdsAction,
removeDuplicateItemsBaseAction,
} from "@/actions/furni-maintenance";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface Health {
itemsBaseTotal: number;
spriteIdMismatch: number;
catalogOfferMismatch: number;
duplicateClassnames: number;
idMismatchVsFurnidata: number;
catalogDuplicates: number;
catalogBcDuplicates: number;
}
export default function CatalogMaintenancePage() { export default function CatalogMaintenancePage() {
const [health, setHealth] = useState<Health | null>(null); return <CatalogMaintenancePanel />;
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [alignPreview, setAlignPreview] = useState<{
moves: number;
inserted: number;
skipped: string[];
} | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
const res = await getFurniHealthAction();
if (res.ok && res.data?.health) setHealth(res.data.health as Health);
else if (!res.ok) toast.error(res.error);
setAlignPreview(null);
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function run(
key: string,
fn: () => Promise<{ ok: boolean; error?: string; data?: unknown }>,
success: string,
) {
setBusy(key);
try {
const res = await fn();
if (res.ok) {
toast.success(success);
await refresh();
} else {
toast.error(res.error ?? "Failed");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(null);
}
}
function Stat({
label,
value,
hint,
}: {
label: string;
value: number;
hint?: string;
}) {
const ok = value === 0;
return (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
<Badge variant={ok ? "default" : "destructive"}>
{ok ? <CheckCircle2 className="mr-1 h-3 w-3" /> : value}
</Badge>
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold">
<Wrench className="h-5 w-5" /> Catalog maintenance
</h1>
<p className="text-sm text-muted-foreground">
Force the furniture tables to 100% consistency with
FurnitureData.json. Every action runs inside a database transaction
and rolls back on error.
</p>
</div>
<Button
variant="outline"
onClick={() => void refresh()}
disabled={loading}
>
<RefreshCw
className={loading ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"}
/>
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Current health</CardTitle>
<CardDescription>
{health
? `${health.itemsBaseTotal} items in items_base`
: "Loading…"}
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{health && (
<>
<Stat
label="sprite_id ≠ id"
value={health.spriteIdMismatch}
hint="items_base sprite drift"
/>
<Stat
label="offer_id ≠ id"
value={health.catalogOfferMismatch}
hint="catalog offer drift"
/>
<Stat
label="Duplicate classnames"
value={health.duplicateClassnames}
hint="items_base item_name repeats"
/>
<Stat
label="Duplicate catalog rows"
value={health.catalogDuplicates}
hint="same product listed twice on a page"
/>
<Stat
label="ID ≠ FurnitureData"
value={health.idMismatchVsFurnidata}
hint="items_base id differs from furnidata"
/>
</>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
disabled={busy !== null}
onClick={() =>
void run("sprite", fixSpriteIdsAction, "sprite_id fixed")
}
>
Fix sprite_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run("offer", fixCatalogOffersAction, "catalog offer_id fixed")
}
>
Fix catalog offer_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"dup",
removeDuplicateItemsBaseAction,
"Duplicates removed (items + catalog)",
)
}
>
Remove duplicates (items + catalog)
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"reconcile",
reconcileIdsAction,
"FurnitureData reconciled",
)
}
>
Reconcile FurnitureData ↔ DB
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Align items_base IDs to FurnitureData</CardTitle>
<CardDescription>
Moves every items_base.id to the FurnitureData sprite id (source of
truth). Rewrites all {22} referencing tables. Preview first.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
variant="outline"
disabled={busy !== null}
onClick={async () => {
setBusy("preview");
const res = await previewAlignIdsAction();
setBusy(null);
if (res.ok && res.data) {
setAlignPreview(res.data as typeof alignPreview);
toast.success("Preview ready");
} else if (!res.ok) toast.error(res.error ?? "Preview failed");
}}
>
Preview changes
</Button>
{alignPreview && (
<div className="rounded-md border p-3 text-sm">
<div>
Planned ID moves: <b>{alignPreview.moves}</b>
</div>
<div>
New items to insert: <b>{alignPreview.inserted}</b>
</div>
{alignPreview.skipped.length > 0 && (
<div className="mt-1 text-muted-foreground">
Skipped (id occupied): {alignPreview.skipped.join(", ")}
</div>
)}
</div>
)}
<Button
disabled={busy !== null || !alignPreview}
onClick={() =>
void run(
"align",
applyAlignIdsAction,
"items_base IDs aligned to FurnitureData",
)
}
>
Apply alignment
</Button>
</CardContent>
</Card>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" /> Fix everything
</CardTitle>
<CardDescription>
Runs sprite fix, offer fix, reconcile, dedup and ID alignment in
order. Heavy operation — make a backup first.
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
disabled={busy !== null}
onClick={() =>
void run("all", fixEverythingAction, "All repairs applied")
}
>
Fix everything now
</Button>
</CardContent>
</Card>
</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>
);
}
-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>
);
}
-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 />;
}
@@ -164,6 +164,14 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
icon: "music", icon: "music",
description: "Public URLs and overrides for SWF / Nitro asset packages.", description: "Public URLs and overrides for SWF / Nitro asset packages.",
fields: [ 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", key: "habbo_gamedata_hotel",
label: "Habbo hotel for furni names", label: "Habbo hotel for furni names",
@@ -1,18 +1,19 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuditClient } from "@/app/admin/import/audit/audit-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; 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(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6 p-4">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Catalog Audit</h1> <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 Scan your entire furniture catalog for missing entries, broken
references, missing files, and items available from clone sources. references, missing files, and items available from clone sources.
</p> </p>
@@ -1,14 +1,17 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; 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 }>; params: Promise<{ locale: string }>;
}) { }) {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
return (
return <ImportBadgesClient />; <div className="p-4">
<ImportBadgesClient />
</div>
);
} }
@@ -1,13 +1,17 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; 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 }>; params: Promise<{ locale: string }>;
}) { }) {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); 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 { redirect } from "next/navigation";
import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; 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 }>; params: Promise<{ locale: string }>;
}) { }) {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
return <ImportCloneClient />; return (
<div className="p-4">
<ImportEffectsClient />
</div>
);
} }
@@ -1,10 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { StudioClient } from "@/components/admin/studio/studio-client";
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel"; import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getTreeFlat } from "@/lib/services/catalog-tree";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; 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 }>; params: Promise<{ locale: string }>;
}) { }) {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
@@ -12,6 +13,15 @@ export default async function ImportFurniPage(_props: {
redirect("/admin"); redirect("/admin");
} }
const hotel = await getHabboGamedataHotel(); const [hotel, catalogTree] = await Promise.all([
return <ImportFurniClient source={buildFurniImportSource(hotel)} />; 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 { 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: { export default function StudioIndexPage() {
params: Promise<{ locale: string }>; redirect("/admin/studio/furni");
}) {
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}
/>
);
} }
@@ -1,14 +1,17 @@
// src/app/[locale]/admin/import/effects/page.tsx
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; 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 }>; params: Promise<{ locale: string }>;
}) { }) {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
return <ImportEffectsClient />; return (
<div className="p-4">
<ImportPetsClient />
</div>
);
} }
@@ -1,20 +1,21 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; 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(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6 p-4">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
Repair Missing Icons Repair Missing Icons
</h1> </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 Scan all existing furniture and download any missing icon files. Icons
are extracted from local .nitro bundles or fetched from configured are extracted from local .nitro bundles or fetched from configured
clone sources. clone sources.
@@ -1,18 +1,19 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; 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(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6 p-4">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1> <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 Scan all configured clone sources and automatically import any
furniture that is not yet present in your hotel. Missing .nitro files, furniture that is not yet present in your hotel. Missing .nitro files,
icons, database entries, catalog items, and FurnitureData.json will be icons, database entries, catalog items, and FurnitureData.json will be
@@ -1,20 +1,21 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { UploadFurniClient } from "@/app/admin/import/furni/upload/upload-furni-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; 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(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
redirect("/admin"); redirect("/admin");
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6 p-4">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
Upload .nitro Furniture Upload .nitro Furniture
</h1> </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 Upload a .nitro bundle and optional icon to import furniture into the
hotel. The database entry, catalog item, and FurnitureData.json will hotel. The database entry, catalog item, and FurnitureData.json will
be updated automatically. be updated automatically.
+2 -1
View File
@@ -220,7 +220,7 @@ export const GET = withAdmin(
} }
if (action === "build-languages") { if (action === "build-languages") {
const languages = await buildLocalizedFurniDataFiles(); const languages = await buildLocalizedFurniDataFiles(true);
return apiOk({ languages }); return apiOk({ languages });
} }
@@ -680,6 +680,7 @@ export const POST = withAdmin(
furniDataFixedIds: furniReconcile.fixedIds, furniDataFixedIds: furniReconcile.fixedIds,
furniDataFixedOfferIds: furniReconcile.fixedOfferIds, furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
furniDataMissing: furniReconcile.missing, furniDataMissing: furniReconcile.missing,
furniDataFixedConflicts: furniReconcile.fixedConflicts,
furniDataConflicts: furniReconcile.conflicts, furniDataConflicts: furniReconcile.conflicts,
offerRebuildChecked: offerRebuild.checked, offerRebuildChecked: offerRebuild.checked,
offerRebuildFixed: offerRebuild.fixed, offerRebuildFixed: offerRebuild.fixed,
@@ -0,0 +1,296 @@
"use client";
import { AlertTriangle, CheckCircle2, RefreshCw, Wrench } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
applyAlignIdsAction,
fixCatalogOffersAction,
fixEverythingAction,
fixSpriteIdsAction,
getFurniHealthAction,
previewAlignIdsAction,
reconcileIdsAction,
removeDuplicateItemsBaseAction,
} from "@/actions/furni-maintenance";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface Health {
itemsBaseTotal: number;
spriteIdMismatch: number;
catalogOfferMismatch: number;
duplicateClassnames: number;
idMismatchVsFurnidata: number;
catalogDuplicates: number;
catalogBcDuplicates: number;
}
export function CatalogMaintenancePanel() {
const [health, setHealth] = useState<Health | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [alignPreview, setAlignPreview] = useState<{
moves: number;
inserted: number;
skipped: string[];
} | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
const res = await getFurniHealthAction();
if (res.ok && res.data?.health) setHealth(res.data.health as Health);
else if (!res.ok) toast.error(res.error);
setAlignPreview(null);
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function run(
key: string,
fn: () => Promise<{ ok: boolean; error?: string; data?: unknown }>,
success: string,
) {
setBusy(key);
try {
const res = await fn();
if (res.ok) {
toast.success(success);
await refresh();
} else {
toast.error(res.error ?? "Failed");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(null);
}
}
function Stat({
label,
value,
hint,
}: {
label: string;
value: number;
hint?: string;
}) {
const ok = value === 0;
return (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
<Badge variant={ok ? "default" : "destructive"}>
{ok ? <CheckCircle2 className="mr-1 h-3 w-3" /> : value}
</Badge>
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold">
<Wrench className="h-5 w-5" /> Catalog maintenance
</h1>
<p className="text-sm text-muted-foreground">
Force the furniture tables to 100% consistency with
FurnitureData.json. Every action runs inside a database transaction
and rolls back on error.
</p>
</div>
<Button
variant="outline"
onClick={() => void refresh()}
disabled={loading}
>
<RefreshCw
className={loading ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"}
/>
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Current health</CardTitle>
<CardDescription>
{health
? `${health.itemsBaseTotal} items in items_base`
: "Loading…"}
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{health && (
<>
<Stat
label="sprite_id ≠ id"
value={health.spriteIdMismatch}
hint="items_base sprite drift"
/>
<Stat
label="offer_id ≠ id"
value={health.catalogOfferMismatch}
hint="catalog offer drift"
/>
<Stat
label="Duplicate classnames"
value={health.duplicateClassnames}
hint="items_base item_name repeats"
/>
<Stat
label="Duplicate catalog rows"
value={health.catalogDuplicates}
hint="same product listed twice on a page"
/>
<Stat
label="ID ≠ FurnitureData"
value={health.idMismatchVsFurnidata}
hint="items_base id differs from furnidata"
/>
<Stat
label="Duplicate BC catalog rows"
value={health.catalogBcDuplicates}
hint="same product listed twice in builder club"
/>
</>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
disabled={busy !== null}
onClick={() =>
void run("sprite", fixSpriteIdsAction, "sprite_id fixed")
}
>
Fix sprite_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run("offer", fixCatalogOffersAction, "catalog offer_id fixed")
}
>
Fix catalog offer_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"dup",
removeDuplicateItemsBaseAction,
"Duplicates removed (items + catalog)",
)
}
>
Remove duplicates (items + catalog)
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"reconcile",
reconcileIdsAction,
"FurnitureData reconciled",
)
}
>
Reconcile FurnitureData ↔ DB
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Align items_base IDs to FurnitureData</CardTitle>
<CardDescription>
Moves every items_base.id to the FurnitureData sprite id (source of
truth). Rewrites all referencing tables. Preview first.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
variant="outline"
disabled={busy !== null}
onClick={async () => {
setBusy("preview");
const res = await previewAlignIdsAction();
setBusy(null);
if (res.ok && res.data) {
setAlignPreview(res.data as typeof alignPreview);
toast.success("Preview ready");
} else if (!res.ok) toast.error(res.error ?? "Preview failed");
}}
>
Preview changes
</Button>
{alignPreview && (
<div className="rounded-md border p-3 text-sm">
<div>
Planned ID moves: <b>{alignPreview.moves}</b>
</div>
<div>
New items to insert: <b>{alignPreview.inserted}</b>
</div>
{alignPreview.skipped.length > 0 && (
<div className="mt-1 text-muted-foreground">
Skipped (id occupied): {alignPreview.skipped.join(", ")}
</div>
)}
</div>
)}
<Button
disabled={busy !== null || !alignPreview}
onClick={() =>
void run(
"align",
applyAlignIdsAction,
"items_base IDs aligned to FurnitureData",
)
}
>
Apply alignment
</Button>
</CardContent>
</Card>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" /> Fix everything
</CardTitle>
<CardDescription>
Runs sprite fix, offer fix, reconcile, dedup and ID alignment in
order. Heavy operation — make a backup first.
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
disabled={busy !== null}
onClick={() =>
void run("all", fixEverythingAction, "All repairs applied")
}
>
Fix everything now
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -602,10 +602,7 @@ export function StudioClient({
); );
return ( return (
<div <div className="flex h-full min-h-[520px] flex-col overflow-hidden">
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)" }}
>
{/* ── Header ─────────────────────────────────────────── */} {/* ── 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"> <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"> <div className="flex items-center gap-2.5">
-41
View File
@@ -13,7 +13,6 @@ import {
Gavel, Gavel,
HelpCircle, HelpCircle,
Image, Image,
Import,
KeyRound, KeyRound,
Languages, Languages,
LayoutDashboard, LayoutDashboard,
@@ -35,7 +34,6 @@ import {
Terminal, Terminal,
Ticket, Ticket,
Trophy, Trophy,
Upload,
Users, Users,
UsersRound, UsersRound,
Volume2, Volume2,
@@ -219,38 +217,6 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
{ href: "/admin/devops/errors", labelKey: "errors" }, { 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. */ /** 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, permission: PERMS.ASSETS_IMPORT,
matchPrefixes: ["/admin/studio"], matchPrefixes: ["/admin/studio"],
}, },
{
href: "/admin/import",
labelKey: "import",
icon: Import,
permission: PERMS.ASSETS_IMPORT,
matchPrefixes: ["/admin/import"],
},
{ {
href: "/admin/translations", href: "/admin/translations",
labelKey: "translations", labelKey: "translations",
-1
View File
@@ -6,7 +6,6 @@ const ROOTS = ["src/app/admin", "src/components/admin"];
const GRAPHICAL_ALLOWLIST = [ const GRAPHICAL_ALLOWLIST = [
"src/app/admin/favicon/favicon-generator.tsx", "src/app/admin/favicon/favicon-generator.tsx",
"src/app/admin/import/clone/import-clone-client.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/catalog/items-shop-preview.tsx",
"src/components/admin/media-grid.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"]; 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 { export interface LanguageFileResult {
lang: string; lang: string;
hotel: string; hotel: string;
@@ -415,9 +431,15 @@ function cloneFurniData(
* translation exists for that classname. Runs under the furnidata lock so it * translation exists for that classname. Runs under the furnidata lock so it
* never interleaves with concurrent imports. * never interleaves with concurrent imports.
*/ */
export async function buildLocalizedFurniDataFiles(): Promise< export async function buildLocalizedFurniDataFiles(
LanguageFileResult[] force = false,
> { ): Promise<LanguageFileResult[]> {
if (!force && !(await translationEnabled())) {
logger.debug(
"[furni-i18n] FurnitureData translation disabled by setting; skipping localized build.",
);
return [];
}
return withFurniDataLock(async () => { return withFurniDataLock(async () => {
const master = (await readFurniData()) as Record< const master = (await readFurniData()) as Record<
string, string,
@@ -554,8 +576,15 @@ export async function buildLocalizedFurniDataFiles(): Promise<
*/ */
export async function patchLocalizedFurniDataEntries( export async function patchLocalizedFurniDataEntries(
entries: Array<{ classname: string; name?: string; description?: string }>, entries: Array<{ classname: string; name?: string; description?: string }>,
force = false,
): Promise<LanguageFileResult[]> { ): Promise<LanguageFileResult[]> {
if (entries.length === 0) return []; 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 () => { return withFurniDataLock(async () => {
const maps = await Promise.all( const maps = await Promise.all(
+32 -7
View File
@@ -485,7 +485,12 @@ export interface FurniDataReconcileResult {
normalizedIds: number; normalizedIds: number;
/** items_base rows with no matching furnitype entry (need re-import). */ /** items_base rows with no matching furnitype entry (need re-import). */
missing: number; 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; conflicts: number;
} }
@@ -498,8 +503,10 @@ export interface FurniDataReconcileResult {
* the emulator's catalog offer lookup stays in sync with the local ids; * 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 * - 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); * or batch-regen, since building an entry requires the SWF/nitro metadata);
* - genuine spriteId conflicts (id used by a different classname) are counted * - genuine spriteId conflicts (id used by a different classname) are resolved
* but never auto-fixed. * 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> { export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconcileResult> {
return withFurniDataLock(async () => { return withFurniDataLock(async () => {
@@ -539,7 +546,8 @@ export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconc
let fixedOfferIds = 0; let fixedOfferIds = 0;
let normalizedIds = 0; let normalizedIds = 0;
let missing = 0; let missing = 0;
let conflicts = 0; let fixedConflicts = 0;
const conflicts = 0;
const entryOfferId = (entry: Record<string, unknown>): number => { const entryOfferId = (entry: Record<string, unknown>): number => {
const v = Number(entry.offerid); const v = Number(entry.offerid);
@@ -551,7 +559,17 @@ export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconc
const existing = byId.get(spriteId); const existing = byId.get(spriteId);
if (existing) { if (existing) {
if (existing.entry.classname !== row.itemName) { 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) { } else if (entryOfferId(existing.entry) !== spriteId) {
existing.entry.offerid = spriteId; existing.entry.offerid = spriteId;
fixedOfferIds++; 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>); 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()); const before = buildMaps(await readFurniData());
// Genuine id conflicts (our spriteId is owned by a different classname // Genuine id conflicts (our spriteId is owned by a different classname
// in the file) are left untouched by design — they are a separate // in the file) are resolved in favour of items_base: the entry keeps its
// pre-existing data problem, not part of this fix. // id but its classname is overwritten with the items_base classname.
let conflicts = 0; let conflicts = 0;
let missing = 0; let missing = 0;
for (const row of rows) { for (const row of rows) {
@@ -205,7 +205,8 @@ describeGuard("live furni import / catalog ID consistency (sandbox DB)", () => {
const result = await reconcileFurniDataWithItemsBase(); const result = await reconcileFurniDataWithItemsBase();
expect(result.fixedIds).toBe(idMisBefore); 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); expect(result.missing).toBe(missing);
// Mirror the function's contract exactly: rows whose id IS keyed in the // 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 // furnidata file can only hold one id per classname, so a duplicate
// classname can never satisfy this invariant for all its rows. Those // classname can never satisfy this invariant for all its rows. Those
// are repaired by the dedicated duplicate-classname tool, not by this // 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>(); const dupClassnames = new Map<string, number>();
for (const row of rows) for (const row of rows)
dupClassnames.set( 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"); const nav = readFileSync("src/lib/admin-nav.ts", "utf8");
expect(nav).toContain('id: "import"'); expect(nav).toContain('href: "/admin/studio"');
expect(nav).toContain("importSubtitle"); expect(nav).toContain('labelKey: "studio"');
expect(nav).toContain("/admin/import"); expect(nav).toContain('"/admin/studio"');
expect(existsSync("src/lib/services/import-badge.ts")).toBe(true); expect(existsSync("src/lib/services/import-badge.ts")).toBe(true);
const badgeService = readFileSync( const badgeService = readFileSync(
"src/lib/services/import-badge.ts", "src/lib/services/import-badge.ts",