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
@@ -1,23 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { AuditClient } from "./audit-client";
|
||||
|
||||
export default async function AuditPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Catalog Audit</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Scan your entire furniture catalog for missing entries, broken
|
||||
references, missing files, and items available from clone sources.
|
||||
</p>
|
||||
</div>
|
||||
<AuditClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,14 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportBadgesClient } from "./import-badges-client";
|
||||
|
||||
export default async function ImportBadgesPage(_props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
return <ImportBadgesClient />;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportCloneClient } from "./import-clone-client";
|
||||
|
||||
export default async function ImportClonePage(_props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
return <ImportCloneClient />;
|
||||
}
|
||||
@@ -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,13 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportClothingClient } from "./import-clothing-client";
|
||||
|
||||
export default async function ImportClothingPage(_props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
return <ImportClothingClient />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// src/app/[locale]/admin/import/effects/page.tsx
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { ImportEffectsClient } from "./import-effects-client";
|
||||
|
||||
export default async function ImportEffectsPage(_props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
return <ImportEffectsClient />;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,45 +0,0 @@
|
||||
import type { SyntheticEvent } from "react";
|
||||
|
||||
export function getFurniImageUrl(classname: string, revision: number) {
|
||||
// Use official Habbo CDN for furniture icons
|
||||
const base = classname.includes("*")
|
||||
? classname.substring(0, classname.indexOf("*"))
|
||||
: classname;
|
||||
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`;
|
||||
}
|
||||
|
||||
export function getFurniIconUrl(classname: string, revision: number) {
|
||||
const iconName = classname.replace(/\*/g, "_");
|
||||
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`;
|
||||
}
|
||||
|
||||
export function handleImgError(
|
||||
e: SyntheticEvent<HTMLImageElement>,
|
||||
classname: string,
|
||||
revision: number,
|
||||
) {
|
||||
const img = e.target as HTMLImageElement;
|
||||
const iconName = classname.replace(/\*/g, "_");
|
||||
if (!img.dataset.fallback) {
|
||||
img.dataset.fallback = "1";
|
||||
img.src = getFurniIconUrl(classname, revision);
|
||||
} else if (img.dataset.fallback === "1") {
|
||||
img.dataset.fallback = "2";
|
||||
img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`;
|
||||
} else {
|
||||
// Show placeholder instead of invisible image
|
||||
img.style.opacity = "0.3";
|
||||
img.src =
|
||||
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="%23666" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
pending: "Pending",
|
||||
started: "Starting",
|
||||
downloading: "Downloading",
|
||||
converting: "Converting",
|
||||
writing_db: "Writing DB",
|
||||
done: "Done",
|
||||
failed: "Failed",
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
export interface FurniItem {
|
||||
id: number;
|
||||
classname: string;
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
revision: number;
|
||||
category: string;
|
||||
alreadyImported: boolean;
|
||||
nitroExists: boolean;
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
total: number;
|
||||
perPage: number;
|
||||
}
|
||||
|
||||
export interface BatchItemStatus {
|
||||
classname: string;
|
||||
status:
|
||||
| "pending"
|
||||
| "started"
|
||||
| "downloading"
|
||||
| "converting"
|
||||
| "writing_db"
|
||||
| "done"
|
||||
| "failed";
|
||||
message?: string;
|
||||
warnings?: string[];
|
||||
itemId?: number;
|
||||
}
|
||||
|
||||
export interface BatchSummary {
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
warnings: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface FurniStats {
|
||||
totalInDb: number;
|
||||
inCatalog: number;
|
||||
missingNitro: number;
|
||||
notInCatalog: number;
|
||||
}
|
||||
@@ -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,17 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
import { ImportFurniClient } from "./import-furni-client";
|
||||
|
||||
export default async function ImportFurniPage(_props: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const hotel = await getHabboGamedataHotel();
|
||||
return <ImportFurniClient source={buildFurniImportSource(hotel)} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { UploadFurniClient } from "./upload-furni-client";
|
||||
|
||||
export default async function UploadFurniPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Upload .nitro Furniture
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Upload a .nitro bundle and optional icon to import furniture into the
|
||||
hotel. The database entry, catalog item, and FurnitureData.json will
|
||||
be updated automatically.
|
||||
</p>
|
||||
</div>
|
||||
<UploadFurniClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { RepairIconsClient } from "./repair-icons-client";
|
||||
|
||||
export default async function RepairIconsPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Repair Missing Icons
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Scan all existing furniture and download any missing icon files. Icons
|
||||
are extracted from local .nitro bundles or fetched from configured
|
||||
clone sources.
|
||||
</p>
|
||||
</div>
|
||||
<RepairIconsClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { SyncAllClient } from "./sync-all-client";
|
||||
|
||||
export default async function SyncAllPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Scan all configured clone sources and automatically import any
|
||||
furniture that is not yet present in your hotel. Missing .nitro files,
|
||||
icons, database entries, catalog items, and FurnitureData.json will be
|
||||
added automatically.
|
||||
</p>
|
||||
</div>
|
||||
<SyncAllClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user