diff --git a/next.config.ts b/next.config.ts index b164596b..f7e5868d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -38,6 +38,68 @@ const nextConfig: NextConfig = { deploymentId: resolveDeploymentId(), serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"], + // The /admin/import hub was consolidated into the catalogue studio at + // /admin/studio. Redirect the old routes so bookmarks/links don't 404. + async redirects() { + return [ + { + source: "/admin/import", + destination: "/admin/studio/furni", + permanent: true, + }, + { + source: "/admin/import/badges", + destination: "/admin/studio/badges", + permanent: true, + }, + { + source: "/admin/import/furni", + destination: "/admin/studio/furni", + permanent: true, + }, + { + source: "/admin/import/furni/upload", + destination: "/admin/studio/upload", + permanent: true, + }, + { + source: "/admin/import/clothing", + destination: "/admin/studio/clothing", + permanent: true, + }, + { + source: "/admin/import/effects", + destination: "/admin/studio/effects", + permanent: true, + }, + { + source: "/admin/import/pets", + destination: "/admin/studio/pets", + permanent: true, + }, + { + source: "/admin/import/clone", + destination: "/admin/studio/clone", + permanent: true, + }, + { + source: "/admin/import/sync", + destination: "/admin/studio/sync", + permanent: true, + }, + { + source: "/admin/import/repair-icons", + destination: "/admin/studio/repair-icons", + permanent: true, + }, + { + source: "/admin/import/audit", + destination: "/admin/studio/audit", + permanent: true, + }, + ]; + }, + // Silence Turbopack warnings for the broad file patterns in src/lib. turbopack: { ignoreIssue: [ diff --git a/src/app/admin/catalog/maintenance/page.tsx b/src/app/admin/catalog/maintenance/page.tsx index d2ed63c0..c5de8160 100644 --- a/src/app/admin/catalog/maintenance/page.tsx +++ b/src/app/admin/catalog/maintenance/page.tsx @@ -1,291 +1,5 @@ -"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; -} +import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel"; export default function CatalogMaintenancePage() { - const [health, setHealth] = useState(null); - const [loading, setLoading] = useState(true); - const [busy, setBusy] = useState(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 ( -
-
-
{label}
-
{hint}
-
- - {ok ? : value} - -
- ); - } - - return ( -
-
-
-

- Catalog maintenance -

-

- Force the furniture tables to 100% consistency with - FurnitureData.json. Every action runs inside a database transaction - and rolls back on error. -

-
- -
- - - - Current health - - {health - ? `${health.itemsBaseTotal} items in items_base` - : "Loading…"} - - - - {health && ( - <> - - - - - - - )} - - - -
- - - - -
- - - - Align items_base IDs to FurnitureData - - Moves every items_base.id to the FurnitureData sprite id (source of - truth). Rewrites all {22} referencing tables. Preview first. - - - - - {alignPreview && ( -
-
- Planned ID moves: {alignPreview.moves} -
-
- New items to insert: {alignPreview.inserted} -
- {alignPreview.skipped.length > 0 && ( -
- Skipped (id occupied): {alignPreview.skipped.join(", ")} -
- )} -
- )} - -
-
- - - - - Fix everything - - - Runs sprite fix, offer fix, reconcile, dedup and ID alignment in - order. Heavy operation — make a backup first. - - - - - - -
- ); + return ; } diff --git a/src/app/admin/import/badges/loading.tsx b/src/app/admin/import/badges/loading.tsx deleted file mode 100644 index 057a0aac..00000000 --- a/src/app/admin/import/badges/loading.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function ImportBadgesLoading() { - return ( -
-
- - -
-
- {Array.from({ length: 6 }, (_, i) => i).map((n) => ( -
- - -
- ))} - -
-
- ); -} diff --git a/src/app/admin/import/clothing/loading.tsx b/src/app/admin/import/clothing/loading.tsx deleted file mode 100644 index 1883e279..00000000 --- a/src/app/admin/import/clothing/loading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export default function Loading() { - return ( -
- {Array.from({ length: 18 }, (_, i) => i).map((n) => ( -
-
-
-
-
- ))} -
- ); -} diff --git a/src/app/admin/import/effects/loading.tsx b/src/app/admin/import/effects/loading.tsx deleted file mode 100644 index ddcd07be..00000000 --- a/src/app/admin/import/effects/loading.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// src/app/[locale]/admin/import/effects/loading.tsx -export default function Loading() { - return ( -
- {Array.from({ length: 18 }, (_, i) => i).map((n) => ( -
-
-
-
-
- ))} -
- ); -} diff --git a/src/app/admin/import/furni/import-furni-client.tsx b/src/app/admin/import/furni/import-furni-client.tsx deleted file mode 100644 index 8ddf48f8..00000000 --- a/src/app/admin/import/furni/import-furni-client.tsx +++ /dev/null @@ -1,2022 +0,0 @@ -"use client"; - -import { - AlertCircle, - ArrowUpDown, - BarChart3, - Check, - CheckCheck, - DatabaseZap, - Download, - ExternalLink, - FileWarning, - Globe2, - ImageDown, - LayoutGrid, - List, - Loader2, - MoreHorizontal, - Package, - Pencil, - RefreshCw, - Search, - ShoppingBag, - Trash2, - X, - XCircle, -} from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { cleanSwfFiles, deleteImportedFurni } from "@/actions/import-furni"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - type RecentFurniResyncResult, - requestRecentFurniResync, -} from "@/lib/admin/recent-furni-resync"; -import { - requestVerifyInteractions, - type VerifyInteractionsResult, -} from "@/lib/admin/verify-interactions"; -import { adminFetch } from "@/lib/admin-fetch"; -import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; -import { - getFurniIconUrl, - getFurniImageUrl, - handleImgError, - STATUS_LABELS, -} from "./import-furni-icons"; -import type { - BatchItemStatus, - BatchSummary, - FurniItem, - FurniStats, - Meta, -} from "./import-furni-types"; -import { NitroEditorDialog } from "./nitro-editor-dialog"; -import { Pagination } from "./pagination"; - -export function ImportFurniClient({ source }: { source: FurniImportSource }) { - const [searchTerm, setSearchTerm] = useState(""); - const [activeSearch, setActiveSearch] = useState(""); - const [items, setItems] = useState([]); - const [meta, setMeta] = useState(null); - const [_page, setPage] = useState(1); - const [loading, setLoading] = useState(false); - const [importingId, setImportingId] = useState(null); - const [batchImporting, setBatchImporting] = useState(false); - const [error, setError] = useState(""); - const [cleaningSwf, setCleaningSwf] = useState(false); - const [selected, setSelected] = useState>(new Set()); - - // Filters & sort - const [typeFilter, setTypeFilter] = useState("all"); - const [categoryFilter, setCategoryFilter] = useState("all"); - const [statusFilter, setStatusFilter] = useState("all"); - const [sortBy, setSortBy] = useState("default"); - const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); - - // Batch regen SSE - const [regenProgress, setRegenProgress] = useState | null>(null); - const [regenSummary, setRegenSummary] = useState<{ - succeeded: number; - failed: number; - skipped: number; - duration: number; - } | null>(null); - const [batchRegenerating, setBatchRegenerating] = useState(false); - - // Recently imported (this session) - const [_recentImports, setRecentImports] = useState([]); - - // Bulk delete - const [confirmBulkDelete, setConfirmBulkDelete] = useState(false); - - // Dashboard stats - const [stats, setStats] = useState(null); - - // Batch progress (SSE) - const [batchProgress, setBatchProgress] = useState | null>(null); - const [batchSummary, setBatchSummary] = useState(null); - - // Nitro regeneration - const [regeneratingNitro, setRegeneratingNitro] = useState>( - new Set(), - ); - - // Item deletion - const [deletingItem, setDeletingItem] = useState(null); - - // Nitro editor - const [editingClassname, setEditingClassname] = useState(null); - - const [confirmRecentResync, setConfirmRecentResync] = useState(false); - const [recentResyncing, setRecentResyncing] = useState(false); - const [recentResyncResult, setRecentResyncResult] = - useState(null); - const [verifyingInteractions, setVerifyingInteractions] = useState(false); - const [addingSize32, setAddingSize32] = useState(false); - const [verifyResult, setVerifyResult] = - useState(null); - const searchRef = useRef(null); - const batchAbortRef = useRef(null); - - // Confirmation dialogs - const [confirmBatch, setConfirmBatch] = useState(false); - const [confirmDelete, setConfirmDelete] = useState(null); - - const fetchStats = useCallback(async () => { - try { - const res = await adminFetch("/api/admin/import/furni?action=stats"); - const data = await res.json(); - if (res.ok) { - setStats({ - totalInDb: data.totalInDb ?? 0, - inCatalog: data.inCatalog ?? 0, - missingNitro: data.missingNitro ?? 0, - notInCatalog: data.notInCatalog ?? 0, - }); - } - } catch { - console.error("[Import] Failed to load stats"); - } - }, []); - - useEffect(() => { - fetchStats(); - }, [fetchStats]); - - // Keyboard shortcuts: "/" focus search, Ctrl+A select all, Escape deselect - useEffect(() => { - function onKeyDown(e: KeyboardEvent) { - const tag = (e.target as HTMLElement)?.tagName; - const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; - - if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) { - e.preventDefault(); - searchRef.current?.focus(); - } - if (e.key === "a" && (e.metaKey || e.ctrlKey) && !isInput) { - e.preventDefault(); - toggleSelectAll(); - } - if (e.key === "Escape" && !isInput) { - setSelected(new Set()); - } - } - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - // biome-ignore lint/correctness/useExhaustiveDependencies: toggleSelectAll is a state setter, stable - }, [toggleSelectAll]); - - // Derived: unique categories from loaded items - const categories = useMemo(() => { - const cats = new Set(items.map((i) => i.category).filter(Boolean)); - return ["all", ...Array.from(cats).sort()]; - }, [items]); - - // Derived: filtered + sorted items - const filteredItems = useMemo(() => { - let result: FurniItem[]; - if (statusFilter === "missing-nitro") { - result = items.filter((i) => { - if (typeFilter !== "all" && i.type !== typeFilter) return false; - if (categoryFilter !== "all" && i.category !== categoryFilter) - return false; - return true; - }); - } else { - result = items.filter((i) => { - if (typeFilter !== "all" && i.type !== typeFilter) return false; - if (categoryFilter !== "all" && i.category !== categoryFilter) - return false; - if (statusFilter === "not-imported" && i.alreadyImported) return false; - if (statusFilter === "imported" && !i.alreadyImported) return false; - if (statusFilter === "ok" && !(i.alreadyImported && i.nitroExists)) - return false; - return true; - }); - } - - // Sort - if (sortBy === "name") result.sort((a, b) => a.name.localeCompare(b.name)); - else if (sortBy === "classname") - result.sort((a, b) => a.classname.localeCompare(b.classname)); - else if (sortBy === "category") - result.sort((a, b) => (a.category || "").localeCompare(b.category || "")); - else if (sortBy === "imported-first") - result.sort( - (a, b) => (b.alreadyImported ? 1 : 0) - (a.alreadyImported ? 1 : 0), - ); - else if (sortBy === "not-imported-first") - result.sort( - (a, b) => (a.alreadyImported ? 1 : 0) - (b.alreadyImported ? 1 : 0), - ); - - return result; - }, [items, typeFilter, categoryFilter, statusFilter, sortBy]); - - // Derived: importable (not yet imported) count in filtered set - const importableCount = useMemo( - () => filteredItems.filter((i) => !i.alreadyImported).length, - [filteredItems], - ); - - const fetchItems = useCallback( - async (search: string, p: number, status?: string) => { - setLoading(true); - setError(""); - try { - const params = new URLSearchParams({ page: String(p) }); - if (search) params.set("search", search); - // Server-side filter for missing-nitro — much faster than client-side - if (status === "missing-nitro") { - params.set("action", "missing-nitro"); - } - const res = await adminFetch(`/api/admin/import/furni?${params}`); - const data = await res.json(); - if (!res.ok) { - setError(data.error || "Failed to load"); - return; - } - const loadedItems = data.items || []; - setItems(loadedItems); - setMeta(data.meta || null); - setSelected(new Set()); - } catch { - setError("Network error"); - } finally { - setLoading(false); - } - }, - [], - ); - - // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code - useEffect(() => { - fetchItems("", 1, statusFilter); - }, [fetchItems]); - - // Auto-search with debounce (500ms) - const debounceRef = useRef | null>(null); - useEffect(() => { - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - if (searchTerm !== activeSearch) { - setActiveSearch(searchTerm); - setPage(1); - fetchItems(searchTerm, 1, statusFilter); - } - }, 500); - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [searchTerm, fetchItems, activeSearch, statusFilter]); - - // Re-fetch when status filter changes to/from missing-nitro - const prevStatusRef = useRef(statusFilter); - useEffect(() => { - if (prevStatusRef.current !== statusFilter) { - const wasMissing = prevStatusRef.current === "missing-nitro"; - const isMissing = statusFilter === "missing-nitro"; - prevStatusRef.current = statusFilter; - if (wasMissing || isMissing) { - setPage(1); - fetchItems(activeSearch, 1, statusFilter); - } - } - }, [statusFilter, activeSearch, fetchItems]); - - function goPage(p: number) { - setPage(p); - fetchItems(activeSearch, p, statusFilter); - } - - function toggleSelect(classname: string) { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(classname)) next.delete(classname); - else next.add(classname); - return next; - }); - } - - function toggleSelectAll() { - const allSelected = - filteredItems.length > 0 && - filteredItems.every((i) => selected.has(i.classname)); - if (allSelected) { - setSelected(new Set()); - } else { - setSelected(new Set(filteredItems.map((i) => i.classname))); - } - } - - async function doImport(item: FurniItem) { - setImportingId(item.classname); - try { - const res = await adminFetch("/api/admin/import/furni", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - id: item.id, - classname: item.classname, - name: item.name, - description: item.description, - type: item.type, - revision: item.revision, - category: item.category, - }), - }); - const data = await res.json(); - if (data.ok) { - toast.success(`${item.name} imported`); - setRecentImports((prev) => - [item.classname, ...prev.filter((c) => c !== item.classname)].slice( - 0, - 20, - ), - ); - setItems((prev) => - prev.map((i) => - i.classname === item.classname - ? { ...i, alreadyImported: true, nitroExists: true } - : i, - ), - ); - setSelected((prev) => { - const n = new Set(prev); - n.delete(item.classname); - return n; - }); - fetchStats(); - } else { - toast.error(data.error || `Failed to import ${item.name}`); - } - } catch (err) { - console.error("[Import] Single import failed:", err); - toast.error(`Network error importing ${item.name}`); - } finally { - setImportingId(null); - } - } - - async function doBatchImport() { - const toImport = filteredItems.filter( - (i) => selected.has(i.classname) && !i.alreadyImported, - ); - if (toImport.length === 0) return; - - setBatchImporting(true); - setBatchSummary(null); - - // Initialize progress map - const initialProgress = new Map(); - for (const i of toImport) { - initialProgress.set(i.classname, { - classname: i.classname, - status: "pending", - }); - } - setBatchProgress(new Map(initialProgress)); - - const abort = new AbortController(); - batchAbortRef.current = abort; - - try { - const res = await adminFetch("/api/admin/import/furni/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - items: toImport.map((i) => ({ - id: i.id, - classname: i.classname, - name: i.name, - description: i.description, - type: i.type, - revision: i.revision, - category: i.category, - })), - concurrency: 3, - }), - signal: abort.signal, - }); - - if (!res.ok || !res.body) { - setBatchImporting(false); - return; - } - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - try { - const event = JSON.parse(line.slice(6)); - - if (event.type === "item_progress") { - setBatchProgress((prev) => { - if (!prev) return prev; - const next = new Map(prev); - next.set(event.classname, { - classname: event.classname, - status: event.status, - message: event.message, - warnings: event.warnings, - itemId: event.itemId, - }); - return next; - }); - - if (event.status === "done") { - setItems((prev) => - prev.map((i) => - i.classname === event.classname - ? { ...i, alreadyImported: true, nitroExists: true } - : i, - ), - ); - } - } else if (event.type === "batch_complete") { - setBatchSummary({ - succeeded: event.succeeded, - failed: event.failed, - warnings: event.warnings, - duration: event.duration, - }); - } - } catch { - /* skip malformed events */ - } - } - } - } catch (err) { - if ((err as Error).name === "AbortError") { - toast.info("Batch import cancelled"); - } else { - console.error("[Import] Batch import error:", err); - toast.error( - "Connection lost during batch import. Check results above.", - ); - } - } finally { - setBatchImporting(false); - batchAbortRef.current = null; - setSelected(new Set()); - fetchStats(); - } - } - - async function cleanSwf() { - setCleaningSwf(true); - try { - const result = await cleanSwfFiles(); - if (result.ok) { - const data = result.data as Record; - toast.success( - `Deleted ${data.deleted} SWF files (${data.remaining} remaining)`, - ); - } else { - toast.error(result.error || "Failed to clean SWF files"); - } - } catch { - toast.error("Network error"); - } finally { - setCleaningSwf(false); - } - } - - async function regenNitro(item: FurniItem) { - setRegeneratingNitro((prev) => new Set(prev).add(item.classname)); - try { - const res = await adminFetch("/api/admin/import/furni", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - items: [{ classname: item.classname, revision: item.revision }], - }), - }); - const data = await res.json(); - if (res.ok && data.succeeded > 0) { - setItems((prev) => - prev.map((i) => - i.classname === item.classname ? { ...i, nitroExists: true } : i, - ), - ); - toast.success(`Nitro file regenerated for ${item.classname}`); - } else { - const warning = - data.results?.[0]?.warning || data.error || "Unknown error"; - toast.error(`Failed: ${warning}`); - } - } catch { - toast.error("Network error"); - } finally { - setRegeneratingNitro((prev) => { - const next = new Set(prev); - next.delete(item.classname); - return next; - }); - } - } - - const missingNitroItems = useMemo(() => { - return filteredItems.filter((i) => i.alreadyImported && !i.nitroExists); - }, [filteredItems]); - - async function regenAllMissing() { - if (missingNitroItems.length === 0) return; - - setBatchRegenerating(true); - setRegenSummary(null); - const initialProgress = new Map< - string, - { status: string; message?: string } - >(); - for (const i of missingNitroItems) { - initialProgress.set(i.classname, { status: "pending" }); - } - setRegenProgress(new Map(initialProgress)); - - try { - const res = await adminFetch("/api/admin/import/furni/batch-regen", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - items: missingNitroItems.map((i) => ({ - classname: i.classname, - revision: i.revision, - })), - concurrency: 3, - }), - }); - - if (!res.ok || !res.body) { - toast.error("Failed to start batch regen"); - setBatchRegenerating(false); - return; - } - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - try { - const event = JSON.parse(line.slice(6)); - if (event.type === "regen_progress") { - setRegenProgress((prev) => { - if (!prev) return prev; - const next = new Map(prev); - next.set(event.classname, { - status: event.status, - message: event.message, - }); - return next; - }); - if (event.status === "done") { - setItems((prev) => - prev.map((i) => - i.classname === event.classname - ? { ...i, nitroExists: true } - : i, - ), - ); - } - } else if (event.type === "regen_complete") { - setRegenSummary({ - succeeded: event.succeeded, - failed: event.failed, - skipped: event.skipped, - duration: event.duration, - }); - } - } catch { - /* skip */ - } - } - } - } catch { - toast.error("Connection lost during regen"); - } finally { - setBatchRegenerating(false); - setRegeneratingNitro(new Set()); - fetchStats(); - } - } - - // Bulk delete selected imported items - async function doBulkDelete() { - const toDelete = filteredItems.filter( - (i) => selected.has(i.classname) && i.alreadyImported, - ); - if (toDelete.length === 0) return; - setConfirmBulkDelete(false); - let ok = 0, - fail = 0; - for (const item of toDelete) { - setDeletingItem(item.classname); - try { - const result = await deleteImportedFurni({ classname: item.classname }); - if (result.ok) { - ok++; - setItems((prev) => - prev.map((i) => - i.classname === item.classname - ? { ...i, alreadyImported: false, nitroExists: false } - : i, - ), - ); - } else { - fail++; - } - } catch { - fail++; - } - } - setDeletingItem(null); - setSelected(new Set()); - if (ok > 0) toast.success(`${ok} item(s) deleted`); - if (fail > 0) toast.error(`${fail} deletion(s) failed`); - fetchStats(); - } - - async function resyncRecentImports() { - setConfirmRecentResync(false); - setRecentResyncing(true); - setRecentResyncResult(null); - try { - const result = await requestRecentFurniResync(adminFetch); - setRecentResyncResult(result); - if (result.examined === 0) { - toast.info("No imported furni found in the last 7 days"); - } else if (result.failed > 0 || !result.rconOk) { - toast.warning("Furni resync completed with warnings"); - } else { - toast.success(`Updated ${result.resynced} imported furni`); - } - fetchStats(); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Furni resync failed", - ); - } finally { - setRecentResyncing(false); - } - } - - async function verifyAllInteractions() { - setVerifyingInteractions(true); - setVerifyResult(null); - try { - const result = await requestVerifyInteractions(adminFetch); - setVerifyResult(result); - if (result.fixed === 0 && result.errors.length === 0) { - toast.success( - `Checked ${result.checked.toLocaleString()} items — all consistent`, - ); - } else if (result.errors.length > 0) { - toast.warning( - `Verified ${result.checked.toLocaleString()} items, ${result.errors.length} errors`, - ); - } else { - toast.success(`Fixed ${result.fixed} items from real furniture data`); - } - fetchStats(); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Interaction verification failed", - ); - } finally { - setVerifyingInteractions(false); - } - } - - async function addSize32All() { - if ( - !confirm( - "Add a size-32 visualization to every existing .nitro that lacks one? This rewrites the live furniture bundles (incl. /var/www/Gamedata) and can take a while.", - ) - ) { - return; - } - setAddingSize32(true); - try { - const res = await adminFetch("/api/admin/import/furni/add-size32", { - method: "POST", - }); - const d = await res.json(); - if (res.ok) { - toast.success( - d.alreadyRunning - ? "Size-32 conversion is already running in the background" - : "Started size-32 conversion in the background — watch the server log for progress", - ); - } else { - toast.error(d.error || "Failed to start size-32 conversion"); - } - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Size-32 conversion failed", - ); - } finally { - setAddingSize32(false); - } - } - - async function doDeleteItem(item: FurniItem) { - setConfirmDelete(null); - setDeletingItem(item.classname); - try { - const result = await deleteImportedFurni({ classname: item.classname }); - if (result.ok) { - const data = result.data as Record; - setItems((prev) => - prev.map((i) => - i.classname === item.classname - ? { ...i, alreadyImported: false, nitroExists: false } - : i, - ), - ); - const files = (data.deletedFiles as string[])?.join(", ") || "none"; - toast.success(`Deleted ${item.classname} (files: ${files})`); - if ((data.errors as string[])?.length > 0) { - toast.warning(`Some errors: ${(data.errors as string[]).join(", ")}`); - } - } else { - toast.error(result.error || "Delete failed"); - } - } catch { - toast.error("Network error"); - } finally { - setDeletingItem(null); - } - } - - // ── Batch progress helpers ────────────────────────────────────────── - const progressStats = useMemo(() => { - if (!batchProgress) return null; - const arr = Array.from(batchProgress.values()); - const done = arr.filter((s) => s.status === "done").length; - const failed = arr.filter((s) => s.status === "failed").length; - const completed = done + failed; - const percent = - arr.length > 0 ? Math.round((completed / arr.length) * 100) : 0; - return { completed, done, failed, total: arr.length, percent }; - }, [batchProgress]); - - return ( -
-
-
-
- -
-
-

- Official furnidata source -

-

{source.label}

-

- Furniture assets: {source.assetCdnHost} -

-
-
- - {source.host} - - -
- {/* Dashboard Statistics */} - {!stats && ( -
- {Array.from({ length: 5 }, (_, i) => i).map((n) => ( -
-
-
-
- ))} -
- )} - {stats && ( -
-
-
- - Total in DB -
-
- {stats.totalInDb.toLocaleString()} -
-
-
-
- - In Catalog -
-
- {stats.inCatalog.toLocaleString()} -
-
-
-
- - Imported -
-
- {items.filter((i) => i.alreadyImported).length.toLocaleString()} -
-
- on this page -
-
-
-
- - Missing .nitro -
-
- {stats.missingNitro.toLocaleString()} -
-
-
-
- - Not in Catalog -
-
- {stats.notInCatalog.toLocaleString()} -
-
-
- )} - - {error && ( -
- - {error} -
- )} - - {recentResyncResult && ( -
-
-

Imported Furni Update

- -
-
- {recentResyncResult.examined} examined - - {recentResyncResult.resynced} updated - - - {recentResyncResult.failed} failed - - - RCON {recentResyncResult.rconOk ? "refreshed" : "not refreshed"} - -
- {recentResyncResult.errors.length > 0 && ( -
- - View errors - -
- {recentResyncResult.errors.map((error) => ( -

- {error.classname}:{" "} - {error.message} -

- ))} -
-
- )} -
- )} - - {verifyResult && ( -
-
-

Interaction Verification

- -
-
- {verifyResult.checked.toLocaleString()} checked - 0 - ? "text-[var(--admin-warning)]" - : "text-[var(--admin-success)]" - } - > - {verifyResult.fixed} fixed - - - {verifyResult.skipped.toLocaleString()} skipped (no real data) - -
- {verifyResult.errors.length > 0 && ( -
- - View errors - -
- {verifyResult.errors.map((error) => ( -

- {error} -

- ))} -
-
- )} -
- )} - - {/* Batch Summary */} - {batchSummary && ( -
-

Import Complete

-
- - {batchSummary.succeeded} succeeded - - {batchSummary.failed > 0 && ( - - {batchSummary.failed} failed - - )} - {batchSummary.warnings > 0 && ( - - {batchSummary.warnings} with warnings - - )} - - {(batchSummary.duration / 1000).toFixed(1)}s - -
- {batchProgress && ( -
- - View details - -
- {Array.from(batchProgress.values()) - .filter( - (s) => - s.status === "failed" || - (s.warnings && s.warnings.length > 0), - ) - .map((item) => ( -
- {item.status === "failed" ? ( - - ) : ( - - )} -
- {item.classname} - {item.message && ( -

{item.message}

- )} - {item.warnings - ?.map((w, i) => ({ w, n: i })) - .map(({ w, n }) => ( -

- {w} -

- ))} -
-
- ))} -
-
- )} - -
- )} - - {/* Batch Progress (during import) */} - {batchProgress && batchImporting && progressStats && ( -
-
- -
-
-
- - {progressStats.percent}% - -
-
- - {progressStats.completed}/{progressStats.total} completed - - {progressStats.done > 0 && ( - - {progressStats.done} OK - - )} - {progressStats.failed > 0 && ( - - {progressStats.failed} failed - - )} - -
-
- {Array.from(batchProgress.values()).map((item) => ( -
- {item.status === "done" && ( - - )} - {item.status === "failed" && ( - - )} - {[ - "downloading", - "converting", - "writing_db", - "started", - ].includes(item.status) && ( - - )} - {item.status === "pending" && ( -
- )} - - - {item.classname} - - - {STATUS_LABELS[item.status] || item.status} - - {item.message && ( - - {item.message} - - )} -
- ))} -
-
- )} - - {/* Regen SSE Progress */} - {regenProgress && - batchRegenerating && - (() => { - const arr = Array.from(regenProgress.values()); - const done = arr.filter((s) => s.status === "done").length; - const failed = arr.filter((s) => s.status === "failed").length; - const completed = - done + failed + arr.filter((s) => s.status === "skipped").length; - const percent = - arr.length > 0 ? Math.round((completed / arr.length) * 100) : 0; - return ( -
-
- - - Regenerating .nitro files... - -
-
-
- - {percent}% - -
-
- - {completed}/{arr.length} - - {done > 0 && ( - {done} OK - )} - {failed > 0 && ( - {failed} failed - )} -
-
- ); - })()} - - {/* Regen Summary */} - {regenSummary && !batchRegenerating && ( -
-

Regeneration Complete

-
- {regenSummary.succeeded > 0 && ( - - {regenSummary.succeeded} regenerated - - )} - {regenSummary.failed > 0 && ( - - {regenSummary.failed} failed - - )} - {regenSummary.skipped > 0 && ( - - {regenSummary.skipped} skipped - - )} - - {(regenSummary.duration / 1000).toFixed(1)}s - -
- {/* Show failed items details */} - {regenProgress && - (() => { - const failedItems = Array.from(regenProgress.entries()).filter( - ([, v]) => v.status === "failed", - ); - if (failedItems.length === 0) return null; - return ( -
- - View {failedItems.length} failed item - {failedItems.length > 1 ? "s" : ""} - -
- {failedItems.map(([classname, info]) => ( -
- -
- {classname} - {info.message && ( -

- {info.message} -

- )} -
-
- ))} -
-
- ); - })()} - -
- )} - - {/* Toolbar: Search + View toggle */} -
-
- - setSearchTerm(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - setSearchTerm(""); - setActiveSearch(""); - searchRef.current?.blur(); - } - }} - className="pl-9 pr-8" - /> - {searchTerm && ( - - )} -
- {meta && ( - - {meta.total.toLocaleString()} furni - - )} - {/* View toggle */} -
- - -
-
- - {/* Filters + Sort row */} -
- - - - - - {filteredItems.length} / {items.length} - -
- - {/* Actions row */} -
- {importableCount > 0 && ( - - )} - {selected.size > 0 && - (() => { - const hasImportable = filteredItems.some( - (i) => selected.has(i.classname) && !i.alreadyImported, - ); - const hasImported = filteredItems.some( - (i) => selected.has(i.classname) && i.alreadyImported, - ); - return ( - <> - {hasImportable && ( - - )} - {batchImporting && ( - - )} - {hasImported && ( - - )} - - - ); - })()} - {missingNitroItems.length > 0 && ( - - )} - - - - - - - setConfirmRecentResync(true)} - disabled={recentResyncing} - > - {recentResyncing ? ( - - ) : ( - - )} - {recentResyncing - ? "Updating imported furni..." - : "Update imported furni"} - - - - {addingSize32 ? "Adding size 32..." : "Add size 32 to all .nitro"} - - - {verifyingInteractions ? ( - - ) : ( - - )} - {verifyingInteractions - ? "Verifying interactions..." - : "Verify & fix all interactions"} - - - - - {cleaningSwf ? "Cleaning..." : "Clean SWF Files"} - - - -
- - {/* Keyboard shortcuts hint */} - {selected.size === 0 && !loading && items.length > 0 && ( -
- - /{" "} - Search - - - Ctrl+A{" "} - Select all - - - Esc{" "} - Deselect - -
- )} - - {/* Furni grid */} - {loading && items.length === 0 && ( -
- {Array.from({ length: 12 }, (_, i) => i).map((n) => ( -
-
-
-
-
-
-
- ))} -
- )} - {!loading && items.length === 0 && !error && ( -
-

- No furniture found{activeSearch ? ` for "${activeSearch}"` : ""}. -

-
- )} - {filteredItems.length > 0 && viewMode === "list" && ( -
- - - - - - - - - - - - - - {filteredItems.map((item) => ( - - - - - - - - - - - ))} - -
- 0 && selected.size === importableCount - } - onCheckedChange={toggleSelectAll} - /> - - NameClassnameTypeCategoryStatusActions
- {!item.alreadyImported && ( - toggleSelect(item.classname)} - /> - )} - - - handleImgError(e, item.classname, item.revision) - } - /> - - {item.name} - - {item.classname} - - {item.type === "wallitem" ? "Wall" : "Floor"} - - {item.category || "-"} - - {item.alreadyImported ? ( - item.nitroExists ? ( - - OK - - ) : ( - - No .nitro - - ) - ) : ( - — - )} - -
- {!item.alreadyImported && ( - - )} - {item.alreadyImported && item.nitroExists && ( - - )} - {item.alreadyImported && !item.nitroExists && ( - - )} - {item.alreadyImported && ( - - )} -
-
-
- )} - - {filteredItems.length > 0 && viewMode === "grid" && ( -
- {filteredItems.map((item) => ( -
- {/* Preview area with furni floor bg */} -
- {/* Checkbox top-left (for non-imported AND imported) */} -
- toggleSelect(item.classname)} - /> -
- {/* Import button top-right on hover */} - {!item.alreadyImported && ( -
- -
- )} - {/* Hover detail tooltip */} -
-
- {item.description && ( -

{item.description}

- )} -
- Rev: {item.revision} - {item.type === "wallitem" ? "Wall" : "Floor"} - {item.category || "credit"} -
-
-
- {/* Furni image */} - {item.name} - handleImgError(e, item.classname, item.revision) - } - /> -
- - {/* Info bar */} -
-
-
- - {item.name} - - {item.alreadyImported && ( - <> - {item.nitroExists ? ( - - - Imported - - ) : ( - - - No .nitro - - )} - {item.nitroExists ? ( - - ) : ( - - )} - - - )} -
- - {item.type === "wallitem" ? "Wall" : "Room"} -{" "} - {item.category || "credit"} - -
- {/* Small icon */} -
- - handleImgError(e, item.classname, item.revision) - } - /> -
-
-
- ))} -
- )} - {items.length > 0 && filteredItems.length === 0 && ( -
-

No furniture matches the selected filters.

-
- )} - - {/* Pagination */} - - - {/* Nitro Editor Dialog */} - { - if (!open) setEditingClassname(null); - }} - /> - - {/* Batch Import Confirmation */} - - - - Confirm Batch Import - - Import {selected.size} furniture items? This will - download SWFs, convert to .nitro, and add to the catalog. - - - - - - - - - - - - - Update imported furni? - - This updates FurnitureData for furni imported during the last 7 - days, then refreshes the emulator catalog and item caches. No - database rows or asset files are deleted. - - - - - - - - - - {/* Delete Confirmation */} - setConfirmDelete(null)} - > - - - Delete Imported Item - - Delete {confirmDelete?.name} ( - {confirmDelete?.classname})? This removes database records, - FurnitureData entry, .nitro, .swf, and icon files. - - - - - - - - - - {/* Bulk Delete Confirmation */} - - - - Bulk Delete - - Delete{" "} - - { - filteredItems.filter( - (i) => selected.has(i.classname) && i.alreadyImported, - ).length - } - {" "} - imported item(s)? This removes database records, FurnitureData - entries, .nitro, .swf, and icon files for each. - - - - - - - - -
- ); -} diff --git a/src/app/admin/import/furni/import-furni-icons.ts b/src/app/admin/import/furni/import-furni-icons.ts deleted file mode 100644 index 66db8c0e..00000000 --- a/src/app/admin/import/furni/import-furni-icons.ts +++ /dev/null @@ -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, - 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,'; - } -} - -export const STATUS_LABELS: Record = { - pending: "Pending", - started: "Starting", - downloading: "Downloading", - converting: "Converting", - writing_db: "Writing DB", - done: "Done", - failed: "Failed", -}; diff --git a/src/app/admin/import/furni/import-furni-types.ts b/src/app/admin/import/furni/import-furni-types.ts deleted file mode 100644 index aab52b33..00000000 --- a/src/app/admin/import/furni/import-furni-types.ts +++ /dev/null @@ -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; -} diff --git a/src/app/admin/import/furni/loading.tsx b/src/app/admin/import/furni/loading.tsx deleted file mode 100644 index 6112b0b3..00000000 --- a/src/app/admin/import/furni/loading.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function ImportFurniLoading() { - return ( -
-
- - -
-
- {Array.from({ length: 6 }, (_, i) => i).map((n) => ( -
- - -
- ))} - -
-
- ); -} diff --git a/src/app/admin/import/furni/pagination.tsx b/src/app/admin/import/furni/pagination.tsx deleted file mode 100644 index 3e9cceeb..00000000 --- a/src/app/admin/import/furni/pagination.tsx +++ /dev/null @@ -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( - , - ); - } - for (let p = 1; p <= meta.lastPage; p++) { - if (p === meta.currentPage) { - nodes.push( - , - ); - } else if ( - Math.abs(p - meta.currentPage) <= 2 || - p === 1 || - p === meta.lastPage - ) { - nodes.push( - , - ); - } else if (Math.abs(p - meta.currentPage) === 3) { - nodes.push( - - … - , - ); - } - } - if (meta.currentPage < meta.lastPage) { - nodes.push( - , - ); - } - return ( -
- {nodes} -
- ); -} diff --git a/src/app/admin/import/page.tsx b/src/app/admin/import/page.tsx deleted file mode 100644 index ed2a61ac..00000000 --- a/src/app/admin/import/page.tsx +++ /dev/null @@ -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 ( -
-
- {SECTIONS.map(({ href, label, icon: Icon, description }) => ( - -
-
- -
-
-

- {label} -

-

{description}

-
-
- - ))} -
-
- ); -} diff --git a/src/app/admin/import/pets/loading.tsx b/src/app/admin/import/pets/loading.tsx deleted file mode 100644 index 6a8001fa..00000000 --- a/src/app/admin/import/pets/loading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export default function Loading() { - return ( -
- {Array.from({ length: 18 }, (_, i) => i).map((n) => ( -
-
-
-
-
- ))} -
- ); -} diff --git a/src/app/admin/import/pets/page.tsx b/src/app/admin/import/pets/page.tsx deleted file mode 100644 index e10646c1..00000000 --- a/src/app/admin/import/pets/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/src/app/admin/settings/cms-settings-config.ts b/src/app/admin/settings/cms-settings-config.ts index 94aecd15..825ee15b 100644 --- a/src/app/admin/settings/cms-settings-config.ts +++ b/src/app/admin/settings/cms-settings-config.ts @@ -164,6 +164,14 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [ icon: "music", description: "Public URLs and overrides for SWF / Nitro asset packages.", fields: [ + { + key: "furnidata_translate_enabled", + label: "Translate furniture names", + description: + "Rebuild FurnitureData_.json with translated names after each import. Disable to skip the LibreTranslate / deep-clone work and save CPU & RAM when you don't need localized furniture names. You can still rebuild on demand via the import 'build languages' action.", + type: "boolean", + defaultValue: "1", + }, { key: "habbo_gamedata_hotel", label: "Habbo hotel for furni names", diff --git a/src/app/admin/import/audit/page.tsx b/src/app/admin/studio/audit/page.tsx similarity index 69% rename from src/app/admin/import/audit/page.tsx rename to src/app/admin/studio/audit/page.tsx index 6a930f4d..d655b260 100644 --- a/src/app/admin/import/audit/page.tsx +++ b/src/app/admin/studio/audit/page.tsx @@ -1,18 +1,19 @@ import { redirect } from "next/navigation"; +import { AuditClient } from "@/app/admin/import/audit/audit-client"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { AuditClient } from "./audit-client"; -export default async function AuditPage() { +export default async function StudioAuditPage(_props: { + params: Promise<{ locale: string }>; +}) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } - return ( -
+

Catalog Audit

-

+

Scan your entire furniture catalog for missing entries, broken references, missing files, and items available from clone sources.

diff --git a/src/app/admin/import/badges/page.tsx b/src/app/admin/studio/badges/page.tsx similarity index 59% rename from src/app/admin/import/badges/page.tsx rename to src/app/admin/studio/badges/page.tsx index bb505934..de779611 100644 --- a/src/app/admin/import/badges/page.tsx +++ b/src/app/admin/studio/badges/page.tsx @@ -1,14 +1,17 @@ import { redirect } from "next/navigation"; +import { ImportBadgesClient } from "@/app/admin/import/badges/import-badges-client"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { ImportBadgesClient } from "./import-badges-client"; -export default async function ImportBadgesPage(_props: { +export default async function StudioBadgesPage(_props: { params: Promise<{ locale: string }>; }) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } - - return ; + return ( +
+ +
+ ); } diff --git a/src/app/admin/import/clothing/page.tsx b/src/app/admin/studio/clone/page.tsx similarity index 60% rename from src/app/admin/import/clothing/page.tsx rename to src/app/admin/studio/clone/page.tsx index 72de5f65..a626098e 100644 --- a/src/app/admin/import/clothing/page.tsx +++ b/src/app/admin/studio/clone/page.tsx @@ -1,13 +1,17 @@ import { redirect } from "next/navigation"; +import { ImportCloneClient } from "@/app/admin/import/clone/import-clone-client"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { ImportClothingClient } from "./import-clothing-client"; -export default async function ImportClothingPage(_props: { +export default async function StudioClonePage(_props: { params: Promise<{ locale: string }>; }) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } - return ; + return ( +
+ +
+ ); } diff --git a/src/app/admin/studio/clothing/page.tsx b/src/app/admin/studio/clothing/page.tsx new file mode 100644 index 00000000..342bfee7 --- /dev/null +++ b/src/app/admin/studio/clothing/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/app/admin/import/clone/page.tsx b/src/app/admin/studio/effects/page.tsx similarity index 59% rename from src/app/admin/import/clone/page.tsx rename to src/app/admin/studio/effects/page.tsx index 568306c6..3ef39a6c 100644 --- a/src/app/admin/import/clone/page.tsx +++ b/src/app/admin/studio/effects/page.tsx @@ -1,13 +1,17 @@ import { redirect } from "next/navigation"; +import { ImportEffectsClient } from "@/app/admin/import/effects/import-effects-client"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { ImportCloneClient } from "./import-clone-client"; -export default async function ImportClonePage(_props: { +export default async function StudioEffectsPage(_props: { params: Promise<{ locale: string }>; }) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } - return ; + return ( +
+ +
+ ); } diff --git a/src/app/admin/import/furni/page.tsx b/src/app/admin/studio/furni/page.tsx similarity index 54% rename from src/app/admin/import/furni/page.tsx rename to src/app/admin/studio/furni/page.tsx index 59ebb61e..2bc64d61 100644 --- a/src/app/admin/import/furni/page.tsx +++ b/src/app/admin/studio/furni/page.tsx @@ -1,10 +1,11 @@ import { redirect } from "next/navigation"; +import { StudioClient } from "@/components/admin/studio/studio-client"; import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { getTreeFlat } from "@/lib/services/catalog-tree"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; -import { ImportFurniClient } from "./import-furni-client"; -export default async function ImportFurniPage(_props: { +export default async function StudioFurniPage(_props: { params: Promise<{ locale: string }>; }) { const { session, permissions } = await getAdminContext(); @@ -12,6 +13,15 @@ export default async function ImportFurniPage(_props: { redirect("/admin"); } - const hotel = await getHabboGamedataHotel(); - return ; + const [hotel, catalogTree] = await Promise.all([ + getHabboGamedataHotel(), + getTreeFlat(), + ]); + + return ( + + ); } diff --git a/src/app/admin/studio/layout.tsx b/src/app/admin/studio/layout.tsx new file mode 100644 index 00000000..bda38189 --- /dev/null +++ b/src/app/admin/studio/layout.tsx @@ -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 ( +
+
+ {TABS.map(({ href, label, icon: Icon }) => { + const active = pathname === href || pathname.startsWith(`${href}/`); + return ( + + + {label} + + ); + })} +
+
{children}
+
+ ); +} diff --git a/src/app/admin/studio/maintenance/page.tsx b/src/app/admin/studio/maintenance/page.tsx new file mode 100644 index 00000000..eeaaf587 --- /dev/null +++ b/src/app/admin/studio/maintenance/page.tsx @@ -0,0 +1,9 @@ +import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel"; + +export default function StudioCatalogMaintenancePage() { + return ( +
+ +
+ ); +} diff --git a/src/app/admin/studio/page.tsx b/src/app/admin/studio/page.tsx index bc97280b..89697d90 100644 --- a/src/app/admin/studio/page.tsx +++ b/src/app/admin/studio/page.tsx @@ -1,27 +1,5 @@ import { redirect } from "next/navigation"; -import { StudioClient } from "@/components/admin/studio/studio-client"; -import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel"; -import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { getTreeFlat } from "@/lib/services/catalog-tree"; -import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; -export default async function StudioPage(_props: { - params: Promise<{ locale: string }>; -}) { - const { session, permissions } = await getAdminContext(); - if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { - redirect("/admin"); - } - - const [hotel, catalogTree] = await Promise.all([ - getHabboGamedataHotel(), - getTreeFlat(), - ]); - - return ( - - ); +export default function StudioIndexPage() { + redirect("/admin/studio/furni"); } diff --git a/src/app/admin/import/effects/page.tsx b/src/app/admin/studio/pets/page.tsx similarity index 60% rename from src/app/admin/import/effects/page.tsx rename to src/app/admin/studio/pets/page.tsx index b23e5ace..b95d3925 100644 --- a/src/app/admin/import/effects/page.tsx +++ b/src/app/admin/studio/pets/page.tsx @@ -1,14 +1,17 @@ -// src/app/[locale]/admin/import/effects/page.tsx import { redirect } from "next/navigation"; +import { ImportPetsClient } from "@/app/admin/import/pets/import-pets-client"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { ImportEffectsClient } from "./import-effects-client"; -export default async function ImportEffectsPage(_props: { +export default async function StudioPetsPage(_props: { params: Promise<{ locale: string }>; }) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } - return ; + return ( +
+ +
+ ); } diff --git a/src/app/admin/import/repair-icons/page.tsx b/src/app/admin/studio/repair-icons/page.tsx similarity index 68% rename from src/app/admin/import/repair-icons/page.tsx rename to src/app/admin/studio/repair-icons/page.tsx index 66189cb0..c4b428ec 100644 --- a/src/app/admin/import/repair-icons/page.tsx +++ b/src/app/admin/studio/repair-icons/page.tsx @@ -1,20 +1,21 @@ import { redirect } from "next/navigation"; +import { RepairIconsClient } from "@/app/admin/import/repair-icons/repair-icons-client"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { RepairIconsClient } from "./repair-icons-client"; -export default async function RepairIconsPage() { +export default async function StudioRepairIconsPage(_props: { + params: Promise<{ locale: string }>; +}) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } - return ( -
+

Repair Missing Icons

-

+

Scan all existing furniture and download any missing icon files. Icons are extracted from local .nitro bundles or fetched from configured clone sources. diff --git a/src/app/admin/import/sync/page.tsx b/src/app/admin/studio/sync/page.tsx similarity index 72% rename from src/app/admin/import/sync/page.tsx rename to src/app/admin/studio/sync/page.tsx index f99ca767..2990f48d 100644 --- a/src/app/admin/import/sync/page.tsx +++ b/src/app/admin/studio/sync/page.tsx @@ -1,18 +1,19 @@ import { redirect } from "next/navigation"; +import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { SyncAllClient } from "./sync-all-client"; -export default async function SyncAllPage() { +export default async function StudioSyncPage(_props: { + params: Promise<{ locale: string }>; +}) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } - return ( -

+

Sync All Sources

-

+

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 diff --git a/src/app/admin/import/furni/upload/page.tsx b/src/app/admin/studio/upload/page.tsx similarity index 69% rename from src/app/admin/import/furni/upload/page.tsx rename to src/app/admin/studio/upload/page.tsx index 915d13e8..3a8ebddd 100644 --- a/src/app/admin/import/furni/upload/page.tsx +++ b/src/app/admin/studio/upload/page.tsx @@ -1,20 +1,21 @@ import { redirect } from "next/navigation"; +import { UploadFurniClient } from "@/app/admin/import/furni/upload/upload-furni-client"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { UploadFurniClient } from "./upload-furni-client"; -export default async function UploadFurniPage() { +export default async function StudioUploadPage(_props: { + params: Promise<{ locale: string }>; +}) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank)) { redirect("/admin"); } - return ( -

+

Upload .nitro Furniture

-

+

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. diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index ca4fde4f..86a9c824 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -220,7 +220,7 @@ export const GET = withAdmin( } if (action === "build-languages") { - const languages = await buildLocalizedFurniDataFiles(); + const languages = await buildLocalizedFurniDataFiles(true); return apiOk({ languages }); } @@ -680,6 +680,7 @@ export const POST = withAdmin( furniDataFixedIds: furniReconcile.fixedIds, furniDataFixedOfferIds: furniReconcile.fixedOfferIds, furniDataMissing: furniReconcile.missing, + furniDataFixedConflicts: furniReconcile.fixedConflicts, furniDataConflicts: furniReconcile.conflicts, offerRebuildChecked: offerRebuild.checked, offerRebuildFixed: offerRebuild.fixed, diff --git a/src/components/admin/catalog/catalog-maintenance-panel.tsx b/src/components/admin/catalog/catalog-maintenance-panel.tsx new file mode 100644 index 00000000..abc21091 --- /dev/null +++ b/src/components/admin/catalog/catalog-maintenance-panel.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(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 ( +

+
+
{label}
+
{hint}
+
+ + {ok ? : value} + +
+ ); + } + + return ( +
+
+
+

+ Catalog maintenance +

+

+ Force the furniture tables to 100% consistency with + FurnitureData.json. Every action runs inside a database transaction + and rolls back on error. +

+
+ +
+ + + + Current health + + {health + ? `${health.itemsBaseTotal} items in items_base` + : "Loading…"} + + + + {health && ( + <> + + + + + + + + )} + + + +
+ + + + +
+ + + + Align items_base IDs to FurnitureData + + Moves every items_base.id to the FurnitureData sprite id (source of + truth). Rewrites all referencing tables. Preview first. + + + + + {alignPreview && ( +
+
+ Planned ID moves: {alignPreview.moves} +
+
+ New items to insert: {alignPreview.inserted} +
+ {alignPreview.skipped.length > 0 && ( +
+ Skipped (id occupied): {alignPreview.skipped.join(", ")} +
+ )} +
+ )} + +
+
+ + + + + Fix everything + + + Runs sprite fix, offer fix, reconcile, dedup and ID alignment in + order. Heavy operation — make a backup first. + + + + + + +
+ ); +} diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 7c2daaa6..a8aca4e0 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -602,10 +602,7 @@ export function StudioClient({ ); return ( -
+
{/* ── Header ─────────────────────────────────────────── */}
diff --git a/src/lib/admin-nav.ts b/src/lib/admin-nav.ts index bd305374..7da41783 100644 --- a/src/lib/admin-nav.ts +++ b/src/lib/admin-nav.ts @@ -13,7 +13,6 @@ import { Gavel, HelpCircle, Image, - Import, KeyRound, Languages, LayoutDashboard, @@ -35,7 +34,6 @@ import { Terminal, Ticket, Trophy, - Upload, Users, UsersRound, Volume2, @@ -219,38 +217,6 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [ { href: "/admin/devops/errors", labelKey: "errors" }, ], }, - { - id: "import", - titleKey: "import", - subtitleKey: "importSubtitle", - icon: Upload, - prefixes: ["/admin/import"], - tabs: [ - { - href: "/admin/import", - labelKey: "importOverview", - match: ["/admin/import"], - }, - { href: "/admin/import/badges", labelKey: "importBadges" }, - { - href: "/admin/import/furni/upload", - labelKey: "importUpload", - match: ["/admin/import/furni/upload"], - }, - { - href: "/admin/import/furni", - labelKey: "importFurni", - match: ["/admin/import/furni"], - }, - { href: "/admin/import/clothing", labelKey: "importClothing" }, - { href: "/admin/import/effects", labelKey: "importEffects" }, - { href: "/admin/import/pets", labelKey: "importPets" }, - { href: "/admin/import/clone", labelKey: "importClone" }, - { href: "/admin/import/sync", labelKey: "importSync" }, - { href: "/admin/import/repair-icons", labelKey: "importRepairIcons" }, - { href: "/admin/import/audit", labelKey: "importAudit" }, - ], - }, ]; /** Longest-prefix match so nested routes (e.g. /admin/logs/audit) win. */ @@ -637,13 +603,6 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [ permission: PERMS.ASSETS_IMPORT, matchPrefixes: ["/admin/studio"], }, - { - href: "/admin/import", - labelKey: "import", - icon: Import, - permission: PERMS.ASSETS_IMPORT, - matchPrefixes: ["/admin/import"], - }, { href: "/admin/translations", labelKey: "translations", diff --git a/src/lib/admin-theme-source-audit.test.ts b/src/lib/admin-theme-source-audit.test.ts index 8ec73e9f..fc683238 100644 --- a/src/lib/admin-theme-source-audit.test.ts +++ b/src/lib/admin-theme-source-audit.test.ts @@ -6,7 +6,6 @@ const ROOTS = ["src/app/admin", "src/components/admin"]; const GRAPHICAL_ALLOWLIST = [ "src/app/admin/favicon/favicon-generator.tsx", "src/app/admin/import/clone/import-clone-client.tsx", - "src/app/admin/import/furni/import-furni-client.tsx", "src/components/admin/catalog/items-shop-preview.tsx", "src/components/admin/media-grid.tsx", ]; diff --git a/src/lib/services/furni-data-i18n.ts b/src/lib/services/furni-data-i18n.ts index 13c3709f..357430a2 100644 --- a/src/lib/services/furni-data-i18n.ts +++ b/src/lib/services/furni-data-i18n.ts @@ -41,6 +41,22 @@ export const FURNIDATA_LANGUAGES = [ export type FurnidataLang = (typeof FURNIDATA_LANGUAGES)[number]["lang"]; +/** + * Master switch for FurnitureData localization. Rebuilding the per-language + * files deep-clones the 100k+ entry master 13× and hits LibreTranslate, which + * can spike RAM/CPU on every import. Admins can disable it in settings to skip + * the work entirely (the master FurnitureData.json is still written by the + * import itself). The manual "build languages" action passes `force` to run + * regardless. + */ +async function translationEnabled(): Promise { + try { + return await siteSettings.getBool("furnidata_translate_enabled", true); + } catch { + return true; + } +} + export interface LanguageFileResult { lang: string; hotel: string; @@ -415,9 +431,15 @@ function cloneFurniData( * translation exists for that classname. Runs under the furnidata lock so it * never interleaves with concurrent imports. */ -export async function buildLocalizedFurniDataFiles(): Promise< - LanguageFileResult[] -> { +export async function buildLocalizedFurniDataFiles( + force = false, +): Promise { + if (!force && !(await translationEnabled())) { + logger.debug( + "[furni-i18n] FurnitureData translation disabled by setting; skipping localized build.", + ); + return []; + } return withFurniDataLock(async () => { const master = (await readFurniData()) as Record< string, @@ -554,8 +576,15 @@ export async function buildLocalizedFurniDataFiles(): Promise< */ export async function patchLocalizedFurniDataEntries( entries: Array<{ classname: string; name?: string; description?: string }>, + force = false, ): Promise { if (entries.length === 0) return []; + if (!force && !(await translationEnabled())) { + logger.debug( + "[furni-i18n] FurnitureData translation disabled by setting; skipping localized patch.", + ); + return []; + } return withFurniDataLock(async () => { const maps = await Promise.all( diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index 40abc06e..9cccecba 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -485,7 +485,12 @@ export interface FurniDataReconcileResult { normalizedIds: number; /** items_base rows with no matching furnitype entry (need re-import). */ missing: number; - /** spriteIds used by a different classname in the file; left untouched. */ + /** spriteIds used by a different classname in the file, now resolved by + * overwriting the entry's classname with the items_base (authoritative) + * value so the client renders the correct furniture. */ + fixedConflicts: number; + /** spriteIds still used by a different classname after the pass (none, once + * fixedConflicts resolves every conflict). */ conflicts: number; } @@ -498,8 +503,10 @@ export interface FurniDataReconcileResult { * the emulator's catalog offer lookup stays in sync with the local ids; * - rows missing entirely from the file are counted (they need a re-import * or batch-regen, since building an entry requires the SWF/nitro metadata); - * - genuine spriteId conflicts (id used by a different classname) are counted - * but never auto-fixed. + * - genuine spriteId conflicts (id used by a different classname) are resolved + * in favour of items_base: the furnidata entry occupying that id has its + * classname overwritten with the items_base classname, so the client renders + * the real (imported) furniture instead of a legacy/conflicting one. */ export async function reconcileFurniDataWithItemsBase(): Promise { return withFurniDataLock(async () => { @@ -539,7 +546,8 @@ export async function reconcileFurniDataWithItemsBase(): Promise): number => { const v = Number(entry.offerid); @@ -551,7 +559,17 @@ export async function reconcileFurniDataWithItemsBase(): Promise 0 || fixedOfferIds > 0) { + if (fixedIds > 0 || fixedOfferIds > 0 || fixedConflicts > 0) { await writeFurniData(furniData as Record); } - return { fixedIds, fixedOfferIds, normalizedIds, missing, conflicts }; + return { + fixedIds, + fixedOfferIds, + normalizedIds, + missing, + fixedConflicts, + conflicts, + }; }); } diff --git a/src/lib/services/import-live.test.ts b/src/lib/services/import-live.test.ts index 61bd872e..25eb5b73 100644 --- a/src/lib/services/import-live.test.ts +++ b/src/lib/services/import-live.test.ts @@ -180,8 +180,8 @@ describeGuard("live furni import / catalog ID consistency (sandbox DB)", () => { const before = buildMaps(await readFurniData()); // Genuine id conflicts (our spriteId is owned by a different classname - // in the file) are left untouched by design — they are a separate - // pre-existing data problem, not part of this fix. + // in the file) are resolved in favour of items_base: the entry keeps its + // id but its classname is overwritten with the items_base classname. let conflicts = 0; let missing = 0; for (const row of rows) { @@ -205,7 +205,8 @@ describeGuard("live furni import / catalog ID consistency (sandbox DB)", () => { const result = await reconcileFurniDataWithItemsBase(); expect(result.fixedIds).toBe(idMisBefore); - expect(result.conflicts).toBe(conflicts); + expect(result.fixedConflicts).toBe(conflicts); + expect(result.conflicts).toBe(0); expect(result.missing).toBe(missing); // Mirror the function's contract exactly: rows whose id IS keyed in the @@ -218,7 +219,8 @@ describeGuard("live furni import / catalog ID consistency (sandbox DB)", () => { // furnidata file can only hold one id per classname, so a duplicate // classname can never satisfy this invariant for all its rows. Those // are repaired by the dedicated duplicate-classname tool, not by this - // function. Genuine id conflicts are likewise out of scope. + // function. Genuine id conflicts are now resolved by overwriting the + // entry's classname with the items_base value (see fixedConflicts). const dupClassnames = new Map(); for (const row of rows) dupClassnames.set( diff --git a/src/lib/staff-smoke-contract.test.ts b/src/lib/staff-smoke-contract.test.ts index 04d97023..c86e0381 100644 --- a/src/lib/staff-smoke-contract.test.ts +++ b/src/lib/staff-smoke-contract.test.ts @@ -243,11 +243,11 @@ describe("staff smoke contract", () => { ); }); - it("ships import hub with synced badge import path", () => { + it("ships studio hub with synced badge import path", () => { const nav = readFileSync("src/lib/admin-nav.ts", "utf8"); - expect(nav).toContain('id: "import"'); - expect(nav).toContain("importSubtitle"); - expect(nav).toContain("/admin/import"); + expect(nav).toContain('href: "/admin/studio"'); + expect(nav).toContain('labelKey: "studio"'); + expect(nav).toContain('"/admin/studio"'); expect(existsSync("src/lib/services/import-badge.ts")).toBe(true); const badgeService = readFileSync( "src/lib/services/import-badge.ts",