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(),
|
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: [
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 />;
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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 { 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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+6
-5
@@ -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.
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user