14 Commits
Author SHA1 Message Date
remco 8a75e7e5b6 chore(deps): update All dependencies
CI / check (pull_request) Failing after 13s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-18 19:00:36 +00:00
openhands 635abfbc9f Surface post-import verification results in the Studio
CI / check (push) Successful in 45s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m17s
After a batch import the progress panel now shows a verification
summary (offer_id fixed, furnidata ids fixed / missing / conflicts,
nitros and icons synced, folders chowned). Single imports include the
key counts in the success toast.
2026-08-18 20:54:16 +02:00
openhands b4968a9967 Reconcile FurnitureData.json with items_base after import
CI / check (push) Successful in 45s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m16s
After every single import, batch import and batch-regen, cross-check all
items_base rows against the local FurnitureData.json: entries whose
spriteId drifted are corrected to the DB id, and missing entries plus
real id conflicts are reported in the API/SSE response. Entries that are
missing entirely are counted (rebuilding them needs SWF/nitro metadata).
2026-08-18 20:43:41 +02:00
openhands b4021f6b42 Backfill icons too in the Gamedata bundle sync
CI / check (push) Successful in 38s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m28s
Extend the post-import backfill to also copy missing _icon.png files
into /var/www/Gamedata/icons (in addition to nitros into
bundled/furniture) so all imported furniture shows an icon in-game.
Report copied nitros and icons separately in the API/SSE responses.
2026-08-18 20:35:48 +02:00
openhands bcd24bfca4 Backfill missing nitros into the Gamedata bundle on import
CI / check (push) Successful in 43s
CI / release (push) Skipped
CI / deploy (push) Failing after 1m19s
After each single import, batch import and batch-regen, copy every
.nitro file that is missing from /var/www/Gamedata/bundled/furniture so
the emulator can render all imported furniture even when the mirror write
was skipped. Reports the copied files in the API/SSE response.
2026-08-18 20:29:30 +02:00
openhands 99e77d9872 Fix ownership reconcile on the batch import route
CI / check (push) Successful in 54s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m27s
The Studio's batch import uses /api/admin/import/furni/batch, which never
ran the offer_id reconciliation or the www-data ownership fix (only the
single/legacy-batch route did). Run both after the batch finishes and
report the counts in the batch_complete SSE event.
2026-08-18 20:01:18 +02:00
openhands 18825115a4 Auto-fix furni asset ownership after import
CI / check (push) Successful in 44s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m17s
After each single or batch import, chown the swf/icon/nitro asset
directories and the FurnitureData.json folders to www-data:www-data so
nginx and the emulator can read newly written files. Best-effort and
non-blocking; the API response reports which folders were fixed.
2026-08-18 19:52:32 +02:00
openhands c95ad4a37f Reconcile offer_id after every import
CI / check (push) Successful in 40s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m12s
After each single or batch import, walk all catalog items under the
Imported Furniture tree and set offer_id to the furnidata sprite id,
repairing any stale values (e.g. legacy -1 rows). Reports how many
rows were fixed in the API response.
2026-08-18 19:40:17 +02:00
openhands 305a0c42ff Derive catalog offer_id from furnidata sprite id
CI / check (push) Successful in 44s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m19s
Import used a hardcoded offer_id of -1, while the matching
FurnitureData.json entry already carries offerid = spriteId.
Set catalog_items.offer_id to the sprite id on insert and update so
purchases resolve to the correct furni offer.
2026-08-18 19:34:34 +02:00
openhands 7257a7b0f4 Fix duplicate catalog pages from import race condition
CI / check (push) Failing after 49s
CI / release (push) Skipped
CI / deploy (push) Skipped
A concurrent import could create the same catalog sub-page twice
(getOrCreateCategoryPage / getOrCreateImportedParentPage do a
SELECT-then-INSERT with no unique constraint). Deduplicate right after
insert by keeping the lowest-id page and removing the duplicate, while
never deleting a page that already received catalog items.
2026-08-18 19:30:28 +02:00
openhands 8a6c596727 Polish Studio visuals
CI / check (push) Successful in 48s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m27s
- Card-shaped loading skeletons with shimmer text lines
- Hover lift and accent shadow on furni cards
- Subtle dotted floor pattern behind previews and detail image
- Sticky action footer in the detail drawer
- Theme-aware slim scrollbars inside the admin panel
- Nicer empty state with icon tile
2026-08-18 19:22:32 +02:00
openhands f18735cf3e Make Studio catalog rail collapsible
CI / check (push) Successful in 46s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m29s
The catalog tree was decorative and always consumed sidebar width.
Add a toolbar toggle so admins can hide or show the catalog rail.
2026-08-18 19:13:34 +02:00
openhands 7c9602c5cd Add search, sorting, filters and list view to Studio
CI / check (push) Successful in 32s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m19s
- Add debounced live search with keyboard shortcuts (/ focus,
  Ctrl/Cmd+A select all, Esc clear selection)
- Add category filter and sort controls (name, classname, category,
  imported-first)
- Add grid/list view toggle with a compact list table
- Add regenerate .nitro action for missing-nitro items
- Show result count in the header and a missing-nitro shortcut chip
2026-08-18 19:05:54 +02:00
openhands 3b8bf74e35 Fix Studio card markup and improve furni browsing UX
CI / check (push) Successful in 31s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m16s
- Fix invalid nested button elements in furni grid cards that broke
  card borders, hover styles and selection rendering
- Fix stale-closure bug where switching furni source fetched from the
  previously selected source
- Add progress bar plus Cancel/Dismiss controls to batch imports
- Make the detail drawer an overlay on small screens
- Add clear-search and clear-selection actions in the toolbar
- Use pixelated image rendering and keyboard focus rings on cards
2026-08-18 18:49:53 +02:00
12 changed files with 1161 additions and 130 deletions

No files matched your search

+1 -1
View File
@@ -48,7 +48,7 @@
"lucide-react": "1.32.0",
"lzma": "^2.3.2",
"motion": "13.1.0",
"music-metadata": "11.14.0",
"music-metadata": "11.15.0",
"mysql2": "3.23.3",
"next": "16.3.1",
"next-auth": "5.0.0-beta.32",
+9 -9
View File
@@ -89,8 +89,8 @@ importers:
specifier: 13.1.0
version: 13.1.0([email protected]([email protected]))([email protected])
music-metadata:
specifier: 11.14.0
version: 11.14.0([email protected])
specifier: 11.15.0
version: 11.15.0([email protected])
mysql2:
specifier: 3.23.3
version: 3.23.3(@types/[email protected])
@@ -1962,8 +1962,8 @@ packages:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
content-type@2.0.0:
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
content-type@2.1.0:
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
engines: {node: '>=18'}
[email protected]:
@@ -2531,8 +2531,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
[email protected]4.0:
resolution: {integrity: sha512-RyOSq98kuVfXB1emJ+NjBF0av8Ph3oBuqNy+Z5sFFfLhjYrkBQEB53V8u+U0RNTVwNo20WoPUwNkfKwZfrOqmQ==}
[email protected]5.0:
resolution: {integrity: sha512-TN+kO1/oOc8UzDW5N3vSDncBcv9WyNnQQe/NFMcFWq6G1+zVeYUkGvkYpQ/S8wb8vKtUD7i78dIaY0cv+cmPRA==}
engines: {node: '>=18'}
[email protected]:
@@ -4310,7 +4310,7 @@ snapshots:
[email protected]: {}
content-type@2.0.0: {}
content-type@2.1.0: {}
[email protected]: {}
@@ -4720,11 +4720,11 @@ snapshots:
[email protected]: {}
[email protected]4.0([email protected]):
[email protected]5.0([email protected]):
dependencies:
'@borewit/text-codec': 0.2.2
'@tokenizer/token': 0.3.0
content-type: 2.0.0
content-type: 2.1.0
debug: 4.4.3([email protected])
file-type: 21.3.4([email protected])
media-typer: 2.0.0
@@ -10,8 +10,14 @@ import {
appendFurniEntriesBatch,
buildFurniEntry,
readFurniData,
reconcileFurniDataWithItemsBase,
} from "@/lib/services/furni-data";
import { downloadFile, ensureDirectories } from "@/lib/services/furni-import";
import {
downloadFile,
ensureDirectories,
ensureFurniOwnership,
syncAssetsToGamedataBundle,
} from "@/lib/services/furni-import";
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
import { rcon } from "@/lib/services/rcon";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
@@ -254,12 +260,27 @@ export const POST = withAdmin(
/* non-fatal */
}
// Keep regenerated assets readable by the web server
const ownership = await ensureFurniOwnership();
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
const assetsSynced = await syncAssetsToGamedataBundle();
// Verify the furnidata matches the DB ids
const furniReconcile = await reconcileFurniDataWithItemsBase();
send({
type: "regen_complete",
succeeded,
failed,
skipped,
furniDataAdded: furniDataEntries.length,
ownershipFixed: ownership.fixed,
nitrosSynced: assetsSynced.copiedNitros,
iconsSynced: assetsSynced.copiedIcons,
furniDataFixedIds: furniReconcile.fixedIds,
furniDataMissing: furniReconcile.missing,
furniDataConflicts: furniReconcile.conflicts,
duration: Date.now() - startTime,
});
+39 -1
View File
@@ -3,10 +3,16 @@ import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getSource } from "@/lib/services/clone-sources";
import { appendFurniEntriesBatch } from "@/lib/services/furni-data";
import {
appendFurniEntriesBatch,
reconcileFurniDataWithItemsBase,
} from "@/lib/services/furni-data";
import {
ensureDirectories,
ensureFurniOwnership,
importSingleFurni,
reconcileImportedOfferIds,
syncAssetsToGamedataBundle,
} from "@/lib/services/furni-import";
import { rcon } from "@/lib/services/rcon";
import type { ImportSingleResult } from "@/types/furni";
@@ -211,11 +217,43 @@ export const POST = withAdmin(
}
}
// Reconcile offer_id and fix asset ownership across the whole tree
let offerIdsFixed = 0;
let ownershipFixed: string[] = [];
let nitrosSynced: string[] = [];
let iconsSynced: string[] = [];
let furniDataFixedIds = 0;
let furniDataMissing = 0;
let furniDataConflicts = 0;
try {
offerIdsFixed = (await reconcileImportedOfferIds()).fixed;
ownershipFixed = (await ensureFurniOwnership()).fixed;
const assetsSynced = await syncAssetsToGamedataBundle();
nitrosSynced = assetsSynced.copiedNitros;
iconsSynced = assetsSynced.copiedIcons;
const furniReconcile = await reconcileFurniDataWithItemsBase();
furniDataFixedIds = furniReconcile.fixedIds;
furniDataMissing = furniReconcile.missing;
furniDataConflicts = furniReconcile.conflicts;
} catch (err) {
console.warn(
"[import-furni] Post-import reconcile/ownership failed:",
(err as Error).message,
);
}
send({
type: "batch_complete",
succeeded,
failed,
warnings: withWarnings,
offerIdsFixed,
ownershipFixed,
nitrosSynced,
iconsSynced,
furniDataFixedIds,
furniDataMissing,
furniDataConflicts,
duration: Date.now() - startTime,
});
+49 -3
View File
@@ -9,6 +9,7 @@ import { logAudit } from "@/lib/services/audit";
import { getCloneList } from "@/lib/services/clone-import";
import { getSource } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { reconcileFurniDataWithItemsBase } from "@/lib/services/furni-data";
import {
autoPriceFurni,
CATEGORY_PAGE,
@@ -17,9 +18,12 @@ import {
deleteImportedItem,
downloadFile,
ensureDirectories,
ensureFurniOwnership,
IMPORTED_PAGE_CAPTION_SAVE,
importSingleFurni,
reconcileImportedOfferIds,
resetCatalogPageCache,
syncAssetsToGamedataBundle,
} from "@/lib/services/furni-import";
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
import { listFurniture } from "@/lib/services/habbofurni";
@@ -403,10 +407,34 @@ export const POST = withAdmin(
);
}
// Reconcile offer_id with the furnidata sprite id across the tree
const reconciled = await reconcileImportedOfferIds();
// Keep asset + furnidata folders readable by the web server
const ownership = await ensureFurniOwnership();
// Backfill any missing nitros into the emulator's Gamedata bundle
const assetsSynced = await syncAssetsToGamedataBundle();
// Verify the furnidata matches the DB ids
const furniReconcile = await reconcileFurniDataWithItemsBase();
const succeeded = results.filter((r) => r.ok).length;
const failed = results.filter((r) => !r.ok).length;
return apiOk({ batch: true, succeeded, failed, results });
return apiOk({
batch: true,
succeeded,
failed,
results,
offerIdsFixed: reconciled.fixed,
ownershipFixed: ownership.fixed,
nitrosSynced: assetsSynced.copiedNitros,
iconsSynced: assetsSynced.copiedIcons,
furniDataFixedIds: furniReconcile.fixedIds,
furniDataMissing: furniReconcile.missing,
furniDataConflicts: furniReconcile.conflicts,
});
}
// ── Single import ────────────────────────────────────────────────
@@ -455,6 +483,18 @@ export const POST = withAdmin(
result.warnings.push("RCON cache refresh failed");
}
// Reconcile offer_id with the furnidata sprite id across the tree
const reconciled = await reconcileImportedOfferIds();
// Keep asset + furnidata folders readable by the web server
const ownership = await ensureFurniOwnership();
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
const assetsSynced = await syncAssetsToGamedataBundle();
// Verify the furnidata matches the DB ids
const furniReconcile = await reconcileFurniDataWithItemsBase();
logAudit({
userId: ctx.session.user.id,
action: "furni_import",
@@ -475,9 +515,15 @@ export const POST = withAdmin(
return apiOk({
id: result.itemId,
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
offerIdsFixed: reconciled.fixed,
ownershipFixed: ownership.fixed,
nitrosSynced: assetsSynced.copiedNitros,
iconsSynced: assetsSynced.copiedIcons,
furniDataFixedIds: furniReconcile.fixedIds,
furniDataMissing: furniReconcile.missing,
furniDataConflicts: furniReconcile.conflicts,
spriteCount: result.spriteCount,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
warnings: result.warnings,
});
},
);
+20
View File
@@ -211,6 +211,26 @@ summary,
background: transparent;
}
/* Theme-aware scrollbars inside the admin panel */
[data-admin] ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
[data-admin] ::-webkit-scrollbar-thumb {
background-color: color-mix(
in srgb,
var(--admin-text-muted) 32%,
transparent
);
}
[data-admin] ::-webkit-scrollbar-thumb:hover {
background-color: color-mix(
in srgb,
var(--admin-text-muted) 55%,
transparent
);
}
/* ── Atom theme components ────────────────────────────────── */
@layer components {
.nav-item {
+128 -9
View File
@@ -1,27 +1,68 @@
"use client";
import { Check, Loader2, X } from "lucide-react";
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { BatchItemStatus } from "./studio-types";
import type { BatchItemStatus, VerificationSummary } from "./studio-types";
function SummaryRow({
label,
value,
tone,
}: {
label: string;
value: string | number;
tone?: "ok" | "warn";
}) {
return (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-[var(--admin-text-muted)]">{label}</span>
<span
className={cn(
"font-mono font-semibold",
tone === "warn"
? "text-[var(--admin-warning)]"
: "text-[var(--admin-success)]",
)}
>
{value}
</span>
</div>
);
}
export function BatchProgress({
progress,
done,
succeeded,
failed,
cancelling,
verification,
onCancel,
onDismiss,
}: {
progress: Map<string, BatchItemStatus>;
done: boolean;
succeeded: number;
failed: number;
cancelling?: boolean;
verification?: VerificationSummary | null;
onCancel?: () => void;
onDismiss?: () => void;
}) {
const entries = [...progress.entries()];
const total = entries.length;
const completed = entries.filter(
([, s]) => s.status === "done" || s.status === "failed",
).length;
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="border-t border-[var(--admin-border)] p-3">
<div className="mb-2 flex items-center justify-between">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Importing…
{done ? "Import finished" : "Importing…"}
</span>
{done ? (
<span
@@ -32,15 +73,31 @@ export function BatchProgress({
: "text-[var(--admin-success)]",
)}
>
✓ {succeeded} ok{failed > 0 ? ` · ${failed} failed` : ""}
{completed}/{total} · {succeeded} ok
{failed > 0 ? ` · ${failed} failed` : ""}
</span>
) : (
<Loader2
size={14}
className="animate-spin text-[var(--admin-accent)]"
/>
<span className="flex items-center gap-2">
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
{completed}/{total}
</span>
<Loader2
size={14}
className="animate-spin text-[var(--admin-accent)]"
/>
</span>
)}
</div>
{!done && (
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
<div
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
)}
<div className="max-h-44 space-y-1 overflow-y-auto pr-1">
{entries.map(([classname, s]) => (
<div key={classname} className="flex items-center gap-2 text-xs">
@@ -66,6 +123,68 @@ export function BatchProgress({
</div>
))}
</div>
{done && verification && (
<div className="mt-2 space-y-1.5 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-2.5">
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<ShieldCheck size={13} className="text-[var(--admin-success)]" />
Post-import verification
</div>
<SummaryRow
label="Catalog offer_id fixed"
value={verification.offerIdsFixed}
/>
<SummaryRow
label="Furnidata ids fixed"
value={verification.furniDataFixedIds}
/>
<SummaryRow
label="Furnidata entries missing"
value={verification.furniDataMissing}
tone={verification.furniDataMissing > 0 ? "warn" : "ok"}
/>
<SummaryRow
label="Furnidata id conflicts"
value={verification.furniDataConflicts}
tone={verification.furniDataConflicts > 0 ? "warn" : "ok"}
/>
<SummaryRow
label="Nitros synced"
value={verification.nitrosSynced.length}
/>
<SummaryRow
label="Icons synced"
value={verification.iconsSynced.length}
/>
<SummaryRow
label="Folders chowned"
value={verification.ownershipFixed.length}
/>
</div>
)}
<div className="mt-2 flex justify-end gap-2">
{done ? (
<Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} />
Dismiss
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={cancelling}
>
{cancelling ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Ban size={14} />
)}
Cancel
</Button>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -67,7 +67,7 @@ export function CatalogRail({
onSelect(selectedId === node.id ? null : node.id);
}}
className={cn(
"group flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition-colors",
"group flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
selectedId === node.id
? "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]",
+612 -98
View File
@@ -1,14 +1,20 @@
"use client";
import {
ArrowUpDown,
Check,
CircleAlert,
CloudDownload,
Download,
Layers,
LayoutGrid,
List,
Loader2,
Package,
PanelLeftClose,
PanelLeftOpen,
Pencil,
RefreshCw,
Search,
Sparkles,
Trash2,
@@ -21,6 +27,7 @@ import { deleteImportedFurni } from "@/actions/import-furni";
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -57,6 +64,7 @@ import type {
FurniItem,
FurniStats,
Meta,
VerificationSummary,
} from "./studio-types";
export function StudioClient({
@@ -69,6 +77,7 @@ export function StudioClient({
// Catalog tree
const [tree] = useState<TreeNode[]>(initialTree);
const [selectedPage, setSelectedPage] = useState<number | null>(null);
const [railOpen, setRailOpen] = useState(true);
// Search & list
const [searchTerm, setSearchTerm] = useState("");
@@ -87,7 +96,12 @@ export function StudioClient({
// Filters
const [typeFilter, setTypeFilter] = useState<string>("all");
const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<string>("default");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const searchRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stats
const [stats, setStats] = useState<FurniStats | null>(null);
@@ -105,6 +119,10 @@ export function StudioClient({
const [batchDone, setBatchDone] = useState(false);
const [batchSucceeded, setBatchSucceeded] = useState(0);
const [batchFailed, setBatchFailed] = useState(0);
const [batchCancelling, setBatchCancelling] = useState(false);
const [verification, setVerification] = useState<VerificationSummary | null>(
null,
);
const batchAbortRef = useRef<AbortController | null>(null);
// Nitro editor
@@ -114,11 +132,24 @@ export function StudioClient({
const [deleting, setDeleting] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
// Nitro regeneration (missing .nitro)
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
new Set(),
);
const categories = useMemo(() => {
const cats = new Set(items.map((i) => i.category).filter(Boolean));
return ["all", ...Array.from(cats).sort()];
}, [items]);
const filteredItems = useMemo(() => {
let result = items;
if (typeFilter !== "all") {
result = result.filter((i) => i.type === typeFilter);
}
if (categoryFilter !== "all") {
result = result.filter((i) => i.category === categoryFilter);
}
if (statusFilter !== "all") {
if (statusFilter === "new")
result = result.filter((i) => !i.alreadyImported);
@@ -129,8 +160,22 @@ export function StudioClient({
else if (statusFilter === "missingNitro")
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
}
return result;
}, [items, typeFilter, statusFilter]);
const sorted = [...result];
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
else if (sortBy === "classname")
sorted.sort((a, b) => a.classname.localeCompare(b.classname));
else if (sortBy === "category")
sorted.sort((a, b) => (a.category || "").localeCompare(b.category || ""));
else if (sortBy === "imported-first")
sorted.sort(
(a, b) => (b.alreadyImported ? 1 : 0) - (a.alreadyImported ? 1 : 0),
);
else if (sortBy === "not-imported-first")
sorted.sort(
(a, b) => (a.alreadyImported ? 1 : 0) - (b.alreadyImported ? 1 : 0),
);
return sorted;
}, [items, typeFilter, categoryFilter, statusFilter, sortBy]);
const allSelected = useMemo(
() =>
@@ -152,13 +197,13 @@ export function StudioClient({
}, []);
const fetchItems = useCallback(
async (search = activeSearch, page = 1) => {
async (search: string, page: number, source?: string) => {
setLoading(true);
setError("");
try {
const qs = new URLSearchParams({ page: String(page) });
if (search) qs.set("search", search);
if (activeSource) qs.set("source", activeSource);
if (source) qs.set("source", source);
const res = await adminFetch(
`/api/admin/import/furni?${qs.toString()}`,
);
@@ -180,7 +225,7 @@ export function StudioClient({
setLoading(false);
}
},
[activeSearch, activeSource],
[],
);
const changeSource = (sourceId: string) => {
@@ -192,7 +237,6 @@ export function StudioClient({
setItems([]);
setActiveSearch("");
setSearchTerm("");
fetchItems("", 1);
};
const fetchSources = useCallback(async () => {
@@ -208,10 +252,24 @@ export function StudioClient({
}, []);
useEffect(() => {
fetchItems();
fetchItems("", 1, activeSource);
fetchStats();
fetchSources();
}, [fetchItems, fetchStats, fetchSources]);
}, [fetchItems, fetchStats, fetchSources, activeSource]);
// Debounced auto-search (also triggered by the "/" shortcut focus).
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
if (searchTerm !== activeSearch) {
setActiveSearch(searchTerm);
fetchItems(searchTerm, 1, activeSource);
}
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchTerm, fetchItems, activeSearch, activeSource]);
// Keep detail in sync with the refreshed list.
useEffect(() => {
@@ -220,9 +278,15 @@ export function StudioClient({
if (updated) setDetail(updated);
}, [items, detail]);
const clearSearch = () => {
setSearchTerm("");
setActiveSearch("");
fetchItems("", 1, activeSource);
};
const submitSearch = () => {
setActiveSearch(searchTerm);
fetchItems(searchTerm, 1);
fetchItems(searchTerm, 1, activeSource);
};
function toggleSelect(classname: string) {
@@ -234,7 +298,7 @@ export function StudioClient({
});
}
const toggleAll = () => {
const toggleAll = useCallback(() => {
if (allSelected) {
const toRemove = new Set(filteredItems.map((i) => i.classname));
setSelected((prev) => {
@@ -249,7 +313,29 @@ export function StudioClient({
return next;
});
}
};
}, [allSelected, filteredItems]);
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
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();
toggleAll();
}
if (e.key === "Escape" && !isInput) {
setSelected(new Set());
searchRef.current?.blur();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [toggleAll]);
async function importSingle(item: FurniItem) {
setImportingId(item.classname);
@@ -281,7 +367,16 @@ export function StudioClient({
setDetail({ ...item, alreadyImported: true, nitroExists: true });
}
fetchStats();
toast.success(`${item.classname} imported into the catalog`);
const parts = [`${item.classname} imported into the catalog`];
if (typeof data.furniDataFixedIds === "number") {
parts.push(
`verified: ${data.offerIdsFixed} offer_id, ${data.furniDataFixedIds} furnidata id`,
);
}
if (data.furniDataMissing > 0) {
parts.push(`${data.furniDataMissing} furnidata missing`);
}
toast.success(parts.join(" · "));
} else {
toast.error(data.error || `Import failed for ${item.classname}`);
}
@@ -305,6 +400,7 @@ export function StudioClient({
setBatchDone(false);
setBatchSucceeded(0);
setBatchFailed(0);
setBatchCancelling(false);
const initial = new Map<string, BatchItemStatus>();
for (const i of toImport) {
@@ -379,6 +475,15 @@ export function StudioClient({
} else if (event.type === "batch_complete") {
setBatchSucceeded(event.succeeded);
setBatchFailed(event.failed);
setVerification({
offerIdsFixed: event.offerIdsFixed ?? 0,
furniDataFixedIds: event.furniDataFixedIds ?? 0,
furniDataMissing: event.furniDataMissing ?? 0,
furniDataConflicts: event.furniDataConflicts ?? 0,
nitrosSynced: event.nitrosSynced ?? [],
iconsSynced: event.iconsSynced ?? [],
ownershipFixed: event.ownershipFixed ?? [],
});
setBatchDone(true);
}
} catch {
@@ -394,12 +499,20 @@ export function StudioClient({
}
} finally {
batchAbortRef.current = null;
setBatchCancelling(false);
setSelected(new Set());
fetchStats();
if (!batchDone) setBatchProgress(null);
}
}
const cancelBatch = () => {
if (batchAbortRef.current) {
setBatchCancelling(true);
batchAbortRef.current.abort();
}
};
async function deleteItem() {
if (!deleting) return;
try {
@@ -426,6 +539,40 @@ export function StudioClient({
}
}
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 {
toast.error(
data.results?.[0]?.warning || data.error || "Regeneration failed",
);
}
} catch {
toast.error("Network error");
} finally {
setRegeneratingNitro((prev) => {
const next = new Set(prev);
next.delete(item.classname);
return next;
});
}
}
const detailPreview = useMemo<AutoCatalogPreview | null>(() => {
if (!detail) return null;
return previewAutoCatalog(
@@ -442,9 +589,15 @@ export function StudioClient({
[filteredItems, selected],
);
const missingNitroCount = useMemo(
() =>
filteredItems.filter((i) => i.alreadyImported && !i.nitroExists).length,
[filteredItems],
);
return (
<div
className="flex h-[calc(100vh-6.5rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
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 ─────────────────────────────────────────── */}
@@ -470,16 +623,32 @@ export function StudioClient({
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)]"
/>
<Input
ref={searchRef}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitSearch()}
placeholder="Search furni, classnames, keywords…"
className="pl-8"
onKeyDown={(e) => {
if (e.key === "Enter") submitSearch();
if (e.key === "Escape") clearSearch();
}}
placeholder="Search furni, classnames, keywords… (/)"
className="pl-8 pr-8"
/>
{searchTerm ? (
<button
type="button"
onClick={clearSearch}
aria-label="Clear search"
className="absolute right-2 top-1/2 -translate-y-1/2 grid h-5 w-5 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
<X size={12} />
</button>
) : null}
</div>
<Button size="sm" variant="secondary" onClick={submitSearch}>
Search
</Button>
{meta && (
<span className="shrink-0 text-xs font-semibold text-[var(--admin-text-muted)]">
{meta.total.toLocaleString()} results
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
@@ -507,6 +676,26 @@ export function StudioClient({
{/* ── Filters + actions ─────────────────────────────── */}
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2">
<button
type="button"
onClick={() => setRailOpen((v) => !v)}
aria-label={railOpen ? "Hide catalog tree" : "Show catalog tree"}
title={railOpen ? "Hide catalog tree" : "Show catalog tree"}
className={cn(
"flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors",
railOpen
? "border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
: "border-[var(--admin-accent)]/40 bg-[var(--admin-accent)]/10 text-[var(--admin-accent)]",
)}
>
{railOpen ? (
<PanelLeftClose size={13} />
) : (
<PanelLeftOpen size={13} />
)}
Catalog
</button>
<Select value={activeSource} onValueChange={changeSource}>
<SelectTrigger className="h-8 w-44 text-xs">
<SelectValue placeholder="Furni source" />
@@ -532,6 +721,19 @@ export function StudioClient({
</SelectContent>
</Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="h-8 w-40 text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat === "all" ? "All categories" : cat}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-8 w-36 text-xs">
<SelectValue placeholder="Status" />
@@ -545,6 +747,68 @@ export function StudioClient({
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="h-8 w-44 text-xs">
<ArrowUpDown size={12} className="mr-1" />
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default order</SelectItem>
<SelectItem value="name">Name A–Z</SelectItem>
<SelectItem value="classname">Classname A–Z</SelectItem>
<SelectItem value="category">Category</SelectItem>
<SelectItem value="imported-first">Imported first</SelectItem>
<SelectItem value="not-imported-first">
Not imported first
</SelectItem>
</SelectContent>
</Select>
{missingNitroCount > 0 && (
<button
type="button"
onClick={() => setStatusFilter("missingNitro")}
className={cn(
"flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors",
statusFilter === "missingNitro"
? "border-[var(--admin-warning)] bg-[var(--admin-warning)]/10 text-[var(--admin-warning)]"
: "border-[var(--admin-border)] text-[var(--admin-warning)] hover:bg-[var(--admin-warning)]/10",
)}
>
<CircleAlert size={12} />
{missingNitroCount} no nitro
</button>
)}
<div className="flex h-8 items-center overflow-hidden rounded-md border border-[var(--admin-border)]">
<button
type="button"
onClick={() => setViewMode("grid")}
aria-label="Grid view"
className={cn(
"grid h-8 w-8 place-items-center transition-colors",
viewMode === "grid"
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
<LayoutGrid size={13} />
</button>
<button
type="button"
onClick={() => setViewMode("list")}
aria-label="List view"
className={cn(
"grid h-8 w-8 place-items-center transition-colors",
viewMode === "list"
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
<List size={13} />
</button>
</div>
<button
type="button"
onClick={toggleAll}
@@ -554,6 +818,23 @@ export function StudioClient({
Select all
</button>
{selected.size > 0 && (
<span className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] bg-[var(--admin-accent)]/5 px-2.5 text-xs font-medium text-[var(--admin-text)]">
{selected.size} selected
</span>
)}
{selected.size > 0 && (
<button
type="button"
onClick={() => setSelected(new Set())}
className="flex h-8 items-center gap-1 rounded-md px-2 text-xs font-medium text-[var(--admin-text-muted)] transition-colors hover:text-[var(--admin-warning)]"
>
<X size={12} />
Clear
</button>
)}
<div className="flex-1" />
{selectedNonImported > 0 && (
@@ -574,12 +855,14 @@ export function StudioClient({
</div>
{/* ── Body: rail + grid + drawer ────────────────────── */}
<div className="flex min-h-0 flex-1">
<CatalogRail
tree={tree}
selectedId={selectedPage}
onSelect={setSelectedPage}
/>
<div className="relative flex min-h-0 flex-1">
{railOpen ? (
<CatalogRail
tree={tree}
selectedId={selectedPage}
onSelect={setSelectedPage}
/>
) : null}
<main className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto p-3">
@@ -588,8 +871,14 @@ export function StudioClient({
{Array.from({ length: 10 }, (_, i) => i).map((n) => (
<div
key={`skeleton-${n}`}
className="h-40 animate-pulse rounded-lg bg-[var(--admin-accent)]/10"
/>
className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-surface)]"
>
<div className="h-24 animate-pulse bg-[var(--admin-accent)]/10" />
<div className="space-y-2 border-t border-[var(--admin-border)] p-2.5">
<div className="h-3 w-3/4 animate-pulse rounded bg-[var(--admin-accent)]/10" />
<div className="h-2.5 w-1/2 animate-pulse rounded bg-[var(--admin-accent)]/10" />
</div>
</div>
))}
</div>
) : error ? (
@@ -597,100 +886,291 @@ export function StudioClient({
{error}
</div>
) : filteredItems.length === 0 ? (
<div className="py-16 text-center">
<Search className="mx-auto mb-2 text-[var(--admin-text-muted)]" />
<p className="text-sm text-[var(--admin-text-muted)]">
<div className="flex flex-col items-center py-16 text-center">
<div className="mb-3 grid h-14 w-14 place-items-center rounded-2xl border border-[var(--admin-border)] bg-[var(--admin-surface)]">
<Search
size={22}
className="text-[var(--admin-text-muted)]"
/>
</div>
<p className="text-sm font-semibold text-[var(--admin-text)]">
{activeSearch
? `No furni found for "${activeSearch}"`
: "No furni to show yet"}
</p>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
{activeSearch
? "Try a different search or clear your filters."
: "Search a furni library to get started."}
</p>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{filteredItems.map((item) => {
const isSel = selected.has(item.classname);
return (
<button
key={item.classname}
type="button"
onClick={() => setDetail(item)}
onDoubleClick={() => toggleSelect(item.classname)}
className={cn(
"group relative flex flex-col overflow-hidden rounded-xl border text-left transition-all",
isSel
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10"
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
)}
>
<div className="relative flex h-24 items-center justify-center overflow-hidden bg-gradient-to-br from-[var(--admin-surface)] to-[var(--admin-canvas)]">
<img
src={getFurniImageUrl(item)}
alt={item.name}
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
loading="lazy"
onError={(e) => handleImgError(e, item)}
/>
{viewMode === "grid" ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{filteredItems.map((item) => {
const isSel = selected.has(item.classname);
return (
<div key={item.classname} className="group relative">
<button
type="button"
onClick={() => setDetail(item)}
onDoubleClick={() => toggleSelect(item.classname)}
className={cn(
"flex w-full flex-col overflow-hidden rounded-xl border text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
isSel
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10"
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:-translate-y-0.5 hover:border-[var(--admin-accent)]/60 hover:shadow-lg hover:shadow-[var(--admin-accent)]/10",
)}
>
<div
className="relative flex h-24 items-center justify-center overflow-hidden"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
backgroundSize: "14px 14px",
}}
>
<img
src={getFurniImageUrl(item)}
alt={item.name}
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
style={{ imageRendering: "pixelated" }}
loading="lazy"
onError={(e) => handleImgError(e, item)}
/>
</div>
<div className="flex items-start justify-between gap-1 border-t border-[var(--admin-border)] px-2 py-1.5">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--admin-text)]">
{item.name}
</p>
<p className="truncate font-mono text-[0.6rem] text-[var(--admin-text-muted)]">
{item.classname}
</p>
</div>
</div>
<div className="flex items-center gap-1 px-2 pb-1.5">
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
{TYPE_LABELS[item.type] ?? item.type}
</Badge>
{item.alreadyImported ? (
<Badge
variant="secondary"
className={cn(
"h-4",
item.nitroExists
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
)}
>
{item.nitroExists ? "Imported" : "No nitro"}
</Badge>
) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
New
</Badge>
)}
</div>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleSelect(item.classname);
}}
className="absolute right-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)]"
aria-label="Select"
className={cn(
"absolute right-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-md border transition-colors",
isSel
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
)}
aria-label={isSel ? "Deselect" : "Select"}
>
{isSel ? (
<Check
size={12}
className="text-[var(--admin-accent)]"
className="text-[var(--admin-accent-foreground)]"
/>
) : null}
</button>
</div>
<div className="flex items-start justify-between gap-1 border-t border-[var(--admin-border)] px-2 py-1.5">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--admin-text)]">
{item.name}
</p>
<p className="truncate font-mono text-[0.6rem] text-[var(--admin-text-muted)]">
{item.classname}
</p>
</div>
</div>
<div className="flex items-center gap-1 px-2 pb-1.5">
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
{TYPE_LABELS[item.type] ?? item.type}
</Badge>
{item.alreadyImported ? (
<Badge
variant="secondary"
className={cn(
"h-4",
item.nitroExists
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
)}
);
})}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-[var(--admin-border)]">
<table className="w-full text-xs">
<thead className="bg-[var(--admin-accent)]/8">
<tr>
<th className="w-9 px-2 py-2 text-left">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</th>
<th className="w-12 px-1 py-2" />
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
Name
</th>
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
Classname
</th>
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
Type
</th>
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
Category
</th>
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
Status
</th>
<th className="w-28 px-2 py-2 text-right font-semibold text-[var(--admin-text-muted)]">
Actions
</th>
</tr>
</thead>
<tbody>
{filteredItems.map((item) => {
const isSel = selected.has(item.classname);
return (
<tr
key={item.classname}
className="border-t border-[var(--admin-border)] transition-colors hover:bg-[var(--admin-accent)]/5"
>
{item.nitroExists ? "Imported" : "No nitro"}
</Badge>
) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
New
</Badge>
)}
</div>
</button>
);
})}
</div>
<td className="px-2 py-1.5">
<Checkbox
checked={isSel}
onCheckedChange={() =>
toggleSelect(item.classname)
}
aria-label={`Select ${item.classname}`}
/>
</td>
<td className="px-1 py-1.5">
<button
type="button"
onClick={() => setDetail(item)}
className="rounded outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
aria-label={`View ${item.name}`}
>
<img
src={getFurniImageUrl(item)}
alt=""
className="h-8 w-8 object-contain"
style={{ imageRendering: "pixelated" }}
loading="lazy"
onError={(e) => handleImgError(e, item)}
/>
</button>
</td>
<td className="max-w-[220px] truncate px-2 py-1.5 font-medium text-[var(--admin-text)]">
{item.name}
</td>
<td className="max-w-[180px] truncate px-2 py-1.5 font-mono text-[0.7rem] text-[var(--admin-text-muted)]">
{item.classname}
</td>
<td className="px-2 py-1.5 text-[var(--admin-text-muted)]">
{TYPE_LABELS[item.type] ?? item.type}
</td>
<td className="max-w-[140px] truncate px-2 py-1.5 text-[var(--admin-text-muted)]">
{item.category || "—"}
</td>
<td className="px-2 py-1.5">
{item.alreadyImported ? (
<Badge
variant="secondary"
className={cn(
"h-4",
item.nitroExists
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
)}
>
{item.nitroExists ? "Imported" : "No nitro"}
</Badge>
) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
New
</Badge>
)}
</td>
<td className="px-2 py-1.5">
<div className="flex items-center justify-end gap-1">
{!item.alreadyImported && (
<button
type="button"
onClick={() => importSingle(item)}
disabled={importingId !== null}
aria-label={`Import ${item.classname}`}
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)] disabled:opacity-50"
>
{importingId === item.classname ? (
<Loader2
size={12}
className="animate-spin"
/>
) : (
<CloudDownload size={12} />
)}
</button>
)}
{item.alreadyImported && item.nitroExists && (
<button
type="button"
onClick={() =>
setEditingClassname(item.classname)
}
aria-label={`Edit nitro ${item.classname}`}
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
<Pencil size={12} />
</button>
)}
{item.alreadyImported &&
!item.nitroExists && (
<button
type="button"
onClick={() => regenNitro(item)}
disabled={regeneratingNitro.has(
item.classname,
)}
aria-label={`Regenerate nitro ${item.classname}`}
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-warning)] transition-colors hover:bg-[var(--admin-warning)]/10 disabled:opacity-50"
>
{regeneratingNitro.has(
item.classname,
) ? (
<Loader2
size={12}
className="animate-spin"
/>
) : (
<RefreshCw size={12} />
)}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{meta && meta.currentPage < meta.lastPage && (
<div className="mt-4 flex justify-center">
<Button
variant="secondary"
onClick={() =>
fetchItems(activeSearch, meta.currentPage + 1)
fetchItems(
activeSearch,
meta.currentPage + 1,
activeSource,
)
}
disabled={loading}
>
@@ -714,13 +1194,21 @@ export function StudioClient({
done={batchDone}
succeeded={batchSucceeded}
failed={batchFailed}
cancelling={batchCancelling}
verification={verification}
onCancel={cancelBatch}
onDismiss={() => {
setBatchProgress(null);
setBatchDone(false);
setVerification(null);
}}
/>
) : null}
</main>
{/* ── Detail drawer ─────────────────────────────── */}
{detail ? (
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)]">
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-80 shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Furni detail
@@ -735,11 +1223,19 @@ export function StudioClient({
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="mb-4 grid h-40 place-items-center rounded-xl border border-[var(--admin-border)] bg-gradient-to-br from-[var(--admin-surface)] to-[var(--admin-canvas)]">
<div
className="mb-4 grid h-40 place-items-center overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
backgroundSize: "14px 14px",
}}
>
<img
src={getFurniImageUrl(detail)}
alt={detail.name}
className="h-28 w-28 object-contain"
style={{ imageRendering: "pixelated" }}
onError={(e) => handleImgError(e, detail)}
/>
</div>
@@ -806,7 +1302,9 @@ export function StudioClient({
</div>
</div>
)}
</div>
<div className="shrink-0 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
<div className="space-y-2">
{!detail.alreadyImported ? (
<Button
@@ -824,9 +1322,25 @@ export function StudioClient({
) : (
<div className="space-y-2">
{!detail.nitroExists && (
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
This furni is in the DB but the .nitro file is missing.
</p>
<>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
This furni is in the DB but the .nitro file is
missing.
</p>
<Button
variant="outline"
className="w-full gap-1.5 text-[var(--admin-warning)]"
disabled={regeneratingNitro.has(detail.classname)}
onClick={() => regenNitro(detail)}
>
{regeneratingNitro.has(detail.classname) ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
Regenerate .nitro
</Button>
</>
)}
<Button
variant="secondary"
@@ -40,3 +40,14 @@ export interface BatchItemStatus {
warnings?: string[];
itemId?: number;
}
/** Post-import verification results reported by the import API. */
export interface VerificationSummary {
offerIdsFixed: number;
furniDataFixedIds: number;
furniDataMissing: number;
furniDataConflicts: number;
nitrosSynced: string[];
iconsSynced: string[];
ownershipFixed: string[];
}
+85 -1
View File
@@ -1,5 +1,6 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { db, ItemsBase } from "@/lib/db";
import {
getGamedataRoot,
getNitroFilesRoot,
@@ -35,7 +36,7 @@ export async function getFurnitureDataPath(): Promise<string> {
return path.join(/*turbopackIgnore: true*/ process.cwd(), "public", relative);
}
async function getFurnitureDataWritePaths(): Promise<string[]> {
export async function getFurnitureDataWritePaths(): Promise<string[]> {
const primary = await getFurnitureDataPath();
const configuredMirror =
(await siteSettings.get("furni_data_mirror_path", "")) ?? "";
@@ -442,3 +443,86 @@ export async function appendFurniEntriesBatch(
await writeFurniData(furniData as Record<string, unknown>);
});
}
export interface FurniDataReconcileResult {
/** Entries whose stale spriteId was corrected to the DB id. */
fixedIds: 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. */
conflicts: number;
}
/**
* Cross-check every items_base row against the local FurnitureData.json and
* repair drift:
* - an entry matching the classname but carrying a stale spriteId is updated
* to the DB id (items_base is authoritative);
* - 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.
*/
export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconcileResult> {
return withFurniDataLock(async () => {
const rows = await db
.select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
.from(ItemsBase);
const furniData = (await readFurniData()) as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>;
const byId = new Map<
number,
{ section: string; index: number; entry: Record<string, unknown> }
>();
const byClass = new Map<
string,
{ section: string; index: number; entry: Record<string, unknown> }
>();
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
const arr = furniData[section]?.furnitype ?? [];
for (let i = 0; i < arr.length; i++) {
const e = arr[i];
const id = Number(e?.id);
if (Number.isFinite(id) && id > 0 && !byId.has(id)) {
byId.set(id, { section, index: i, entry: e });
}
const cn = typeof e?.classname === "string" ? e.classname : "";
if (cn && !byClass.has(cn)) {
byClass.set(cn, { section, index: i, entry: e });
}
}
}
let fixedIds = 0;
let missing = 0;
let conflicts = 0;
for (const row of rows) {
const spriteId = row.id;
const existing = byId.get(spriteId);
if (existing) {
if (existing.entry.classname !== row.itemName) {
conflicts++;
}
continue;
}
const byName = byClass.get(row.itemName);
if (byName) {
byName.entry.id = spriteId;
byId.set(spriteId, byName);
fixedIds++;
} else {
missing++;
}
}
if (fixedIds > 0) {
await writeFurniData(furniData as Record<string, unknown>);
}
return { fixedIds, missing, conflicts };
});
}
+184 -6
View File
@@ -1,5 +1,7 @@
import { execFile } from "node:child_process";
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, ItemsBase, LogsFurniImports } from "@/lib/db";
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
@@ -10,11 +12,13 @@ import {
DEFAULT_FURNI_SWF_DIR,
getFurniAssetDirs,
getFurniAssetWriteTargets,
getGamedataRoot,
} from "@/lib/services/furni-asset-dirs";
import {
appendFurniEntry,
buildFurniEntry,
findFurniDataIdConflict,
getFurnitureDataWritePaths,
removeFurniEntry,
} from "@/lib/services/furni-data";
import {
@@ -126,6 +130,29 @@ export function buildItemsBaseEnrichmentUpdate(params: {
`;
}
/** Keep the lowest-id row for a catalog page and delete any duplicate created
* concurrently by a racing import. Returns the surviving page id. */
async function dedupeCatalogPage(
captionSave: string,
parentId: number,
justCreatedId: number,
): Promise<number> {
const [rows] = (await db.execute(sql`
SELECT MIN(id) AS id FROM catalog_pages
WHERE caption_save = ${captionSave} AND parent_id = ${parentId}
`)) as unknown as [Array<{ id: number }>, unknown];
const winner = rows[0]?.id ?? justCreatedId;
if (winner !== justCreatedId) {
// Never delete a page that already received catalog items.
await db.execute(sql`
DELETE FROM catalog_pages
WHERE id = ${justCreatedId}
AND NOT EXISTS (SELECT 1 FROM catalog_items WHERE page_id = ${justCreatedId})
`);
}
return winner;
}
/** Get or create the root "Imported Furniture" parent page (no items directly on this page).
* Caches the page ID in-memory for the process lifetime to avoid redundant DB reads
* during batch imports */
@@ -158,8 +185,12 @@ export async function getOrCreateImportedParentPage(): Promise<number> {
'Failed to create "Imported Furniture" parent catalog page',
);
}
parentIdCache = created.id;
return created.id;
parentIdCache = await dedupeCatalogPage(
IMPORTED_PAGE_CAPTION_SAVE,
-1,
created.id,
);
return parentIdCache;
}
/** Determine the catalog category for a furniture item based on its classname and type. */
@@ -246,8 +277,9 @@ export async function getOrCreateCategoryPage(
);
}
if (!categoryPageCache) categoryPageCache = new Map();
categoryPageCache.set(captionSave, created.id);
return created.id;
const winner = await dedupeCatalogPage(captionSave, parentId, created.id);
categoryPageCache.set(captionSave, winner);
return winner;
}
export function resetCatalogPageCache(): void {
@@ -256,6 +288,152 @@ export function resetCatalogPageCache(): void {
categoryCacheTimestamp = 0;
}
/**
* Walk every catalog item under the "Imported Furniture" tree and make its
* offer_id match the furnidata sprite id (item_ids). Fixes any stale values
* (e.g. legacy -1 rows) after an import.
*/
export async function reconcileImportedOfferIds(): Promise<{
fixed: number;
}> {
const parentId = await getOrCreateImportedParentPage();
const [result] = (await db.execute(sql`
UPDATE catalog_items ci
JOIN catalog_pages cp ON cp.id = ci.page_id
SET ci.offer_id = CAST(ci.item_ids AS UNSIGNED)
WHERE cp.parent_id = ${parentId}
AND ci.offer_id <> CAST(ci.item_ids AS UNSIGNED)
`)) as unknown as [Record<string, unknown>, unknown];
return { fixed: Number(result.affectedRows ?? 0) };
}
const execFileAsync = promisify(execFile);
/**
* Ensure the furni asset directories (icons, nitros, swf) and the folders
* holding FurnitureData.json are owned by www-data:www-data, so nginx and the
* emulator can always read freshly imported files. Best-effort: if chown is
* unavailable or fails (e.g. running unprivileged), a warning is logged and
* import continues.
*/
export async function ensureFurniOwnership(): Promise<{ fixed: string[] }> {
if (process.platform === "win32") return { fixed: [] };
const { swfDir, iconDir, nitroDir, mirrorDirs } =
await getFurniAssetWriteTargets();
const furniDataPaths = await getFurnitureDataWritePaths();
const dirs = [
swfDir,
iconDir,
nitroDir,
...mirrorDirs.flatMap((m) => [m.swfDir, m.iconDir, m.nitroDir]),
...furniDataPaths.map((p) => path.dirname(p)),
]
.map((d) => path.normalize(d))
.filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
if (dirs.length === 0) return { fixed: [] };
try {
await execFileAsync("chown", ["-R", "www-data:www-data", ...dirs]);
return { fixed: dirs };
} catch (err) {
logger.warn("[import-furni] chown to www-data failed", {
dirs,
error: (err as Error).message,
});
return { fixed: [] };
}
}
/** Copy a single missing file from source dir into target dir; logs on failure. */
async function copyIfMissing(
sourceDir: string,
targetDir: string,
entry: string,
copied: string[],
): Promise<void> {
const dest = path.join(targetDir, entry);
if (existsSync(dest)) return;
try {
await fs.copyFile(path.join(sourceDir, entry), dest);
copied.push(entry);
} catch (err) {
logger.warn("[import-furni] gamedata bundle sync copy failed", {
entry,
error: (err as Error).message,
});
}
}
/** Scan a source dir and copy every file that is missing from the target dir. */
async function syncDirMissingFiles(
sourceDir: string,
targetDir: string,
predicate: (entry: string) => boolean,
): Promise<string[]> {
if (!existsSync(sourceDir)) return [];
try {
await fs.mkdir(targetDir, { recursive: true });
} catch {
return [];
}
const copied: string[] = [];
try {
const entries = await fs.readdir(sourceDir);
for (const entry of entries) {
if (!predicate(entry)) continue;
await copyIfMissing(sourceDir, targetDir, entry, copied);
}
} catch (err) {
logger.warn("[import-furni] gamedata bundle sync scan failed", {
error: (err as Error).message,
});
}
return copied;
}
/**
* Backfill any nitro and icon files missing from the emulator's Gamedata
* bundle (/var/www/Gamedata/bundled/furniture and /var/www/Gamedata/icons)
* so all imported furniture stays renderable in-game even when a mirror
* write was skipped.
*/
export async function syncAssetsToGamedataBundle(): Promise<{
copiedNitros: string[];
copiedIcons: string[];
}> {
if (process.platform === "win32")
return { copiedNitros: [], copiedIcons: [] };
const gamedataRoot = await getGamedataRoot();
if (!gamedataRoot) return { copiedNitros: [], copiedIcons: [] };
const { nitroDir, iconDir } = await getFurniAssetDirs();
const copiedNitros: string[] = [];
const copiedIcons: string[] = [];
const nitroTarget = path.join(gamedataRoot, "bundled/furniture");
if (nitroDir !== nitroTarget) {
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
entry.endsWith(".nitro"),
);
copiedNitros.push(...copied);
}
const iconTarget = path.join(gamedataRoot, "icons");
if (iconDir !== iconTarget) {
const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) =>
entry.endsWith("_icon.png"),
);
copiedIcons.push(...copied);
}
return { copiedNitros, copiedIcons };
}
/**
* Allocate the next `catalog_items.id` and run the INSERT through a
* process-level async mutex so concurrent imports in the same Node process
@@ -755,7 +933,7 @@ export async function importSingleFurni(params: {
const price = autoPriceFurni(classname);
await db.execute(sql`
UPDATE catalog_items
SET page_id = ${pageIdStr}, cost_credits = ${price.credits}, cost_points = ${price.points}, points_type = ${price.pointsType}
SET page_id = ${pageIdStr}, cost_credits = ${price.credits}, cost_points = ${price.points}, points_type = ${price.pointsType}, offer_id = ${spriteId}
WHERE id = ${catalogItemId}
`);
catalogUpdated = true;
@@ -775,7 +953,7 @@ export async function importSingleFurni(params: {
catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => {
await db.execute(sql`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')
VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${spriteId}, '')
`);
return nextCatalogId;
});