The previous commit made imports update the Studio without a reload, but the
guarantee only held inside the tab that started the import and only as long as
every read succeeded. Four holes were left, and this closes them.
A session that mounted the tree before an import kept the pre-import tree for
the rest of its life, because ensureCatalogTreeLoaded() was a once-per-session
no-op. It now asks the server whether what it holds is still current. The answer
is a revision: sendCatalogUpdate() already runs after every catalog write, so it
bumps one, and clients read it on mount, on focus, on a 20s poll and from other
tabs over a BroadcastChannel. An import that finishes in another tab, another
browser or the job worker now lands here too.
A failed read used to be swallowed, which is the worst outcome available: the
rail kept showing pre-import counts as if they were current and nothing said so.
The snapshot now carries the error, the rail shows it with a retry, and the
previous tree stays on screen because stale beats empty.
Every settled import pulled the entire flat tree, which is the one payload that
grows with the size of the catalog. The revision doubles as the ETag on
mode=full, so an unchanged catalog answers 304 and the poll costs a file read.
An import could also report success for an offer the hotel will never sell: a
hidden or disabled page, an item_ids that misses the furni id, a zero amount.
importSingleFurni reads its own row back and reports each of those as a warning,
where the import report already is, instead of leaving it to surface as "the
import did not work" in the client.
Finally, the catalog items table no longer falls back to router.refresh() —
onRefresh is now required, so every mutation ends in a refresh of the caller's
own data instead of a route re-render that threw away editor state and scroll
position. useServerAction keeps its default, because 47 callers across the app
depend on it. The 750-line CatalogTree in catalog-tree.tsx was dead code that
kept its own stale tree and three more router.refresh() calls; only CatalogIcon
and LAYOUT_COLORS are still imported, so the rest is gone.
Tests: the store now covers revisions, 304s, probe failures and error recovery;
a jsdom test mounts a consumer and asserts the tree updates in place with no
navigation; the old organize-imports e2e asserted nothing about the endpoints
the code actually calls, and is replaced by one that asserts a cross-tab write
lands in the mounted categories without a reload.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The live catalog store only covered part of the import surface. A durable
job settled, a sync queue drained, a .nitro upload or a clone run left the
Studio rail and the stats bar showing pre-import numbers until the page was
reloaded, and the Catalog Manager kept a second tree that never saw writes
made elsewhere in the session.
Every one of those paths now pulls the tree again, and the refresh carries
the totals with it: importing writes catalog rows server-side, so the counts
the store holds were stale for the rest of the session.
- refreshCatalogTree shares one request between concurrent callers and queues
a single follow-up read when a write lands mid-flight, so a burst of edits
costs at most one extra read.
- useFurnitureJobs treats its first payload as a baseline, so a page load no
longer replays every past import as "just settled", and hands the settled
jobs to the callback.
- The Catalog Manager pushes its own mutations into the store and re-reads its
active tab when the store changes.
- The 30s unstable_cache on the admin totals is now tagged and invalidated from
every catalog write, including the import worker, so it no longer survives an
import even across a hard reload.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Organising imports, the Studio furni batch, the catalog totals and the
"import from a source" stats all used to need a full page reload, or at
best a router.refresh() that re-rendered the whole admin route, before
anything on screen reflected what the import had just written.
- live-catalog-merge.ts (new): pure tree and total arithmetic. Applies a
delta of created pages, added offers and moved offers, recomputes depth
for the touched subtree, bumps parent child counts and the item totals.
Returns the input untouched when a delta is empty, so subscribers can
bail out instead of re-rendering. Depth resolution tolerates a parent
cycle in a dirty DB and still terminates, matching getTreeFlat.
- use-live-catalog.ts (new): one module-level store exposed through
useSyncExternalStore, so every consumer shares a single instance without
threading a provider through the admin layout. Deltas only apply to the
"normal" catalog, so public and public_handlers trees stay separate.
seedCatalogTotals() takes the first server value per mode and never
overwrites it afterwards, so a later hard render cannot make the header
totals jump backwards.
- actions/catalog.ts: organizeImportFurni now reports each group through
the new OrganizedPageChange, carrying parentId, pageLayout, the icon,
isNew and the per-source movedFrom counts, so the client can fold the
result into the tree without reading the page back.
- organize-imports-dialog.tsx: drops useRouter and router.refresh(); the
response is applied as a delta the moment the run finishes.
- studio-client.tsx: reads the tree from the store instead of freezing it
with useState(initialTree), loads it on mount when empty, and refreshes
it once a batch import settles. The batch is server-side and derives its
import pages from furnidata, so that one path re-reads the tree via
GET /api/admin/catalog/tree?mode=full rather than trusting the delta.
- studio/furni/page.tsx: stops calling getTreeFlat() and no longer passes
initialTree; the store is the single source of truth for the rail.
- import-clone-client.tsx: tracks which items are already present, so
present and clonable update per cloned row instead of only at the end.
- catalog-manager-dialog.tsx: seeds the totals once and renders the live
values, so the header reflects an import that just ran.
- e2e/ui/fixtures/entry.tsx: drops the removed initialTree prop.
Make /admin/catalog a full-screen catalog studio that replaces the old
listing plus separate [id]/builder-club detail pages:
- Embed CatalogManagerWorkspace on /admin/catalog with a Normal/Builder
Club toggle, Catalog Sync status, packages (normal), Organize imports
and a diagnostics link to /admin/studio/maintenance.
- Manage BC items directly in the studio Items tab (new BcItemsEditor,
CRUD via existing bc actions; /api/admin/catalog/items now serves BC).
- Inline editor: add pageTextTeaser field for both catalogs and remove
the legacy full-editor links.
- Remove the 'Open full editor' context action from the tree.
- Move catalog-items-table (dir + barrel) and catalog-translate-tab out
of the app route into src/components/admin/catalog and update all
importers.
- Keep /admin/catalog/[id], builder-club/[id] and /admin/catalog/maintenance
as redirects into the new studio; consolidate maintenance panels into
/admin/studio/maintenance and point the nav item there.
- Delete the old listing/table/tabs/forms and the standalone bc-manager.
Catalog Studio:
- Cross-parent drag & drop now uses optimistic updates with rollback
on failure (no more full tree reload / visible delay)
- Subpage creation adds the node optimistically then refreshes parent
only (was full tree reload)
- Single page deletion refreshes only the affected parent (was full
tree reload)
- Root page creation replaces native prompt() with an inline input
in the root tab bar
- Escape key no longer closes the dialog when an input field is focused
- TreeNodeUpdate type now supports parentId and orderNum for
optimistic structural changes
Docker:
- docker-prune.sh default mode now aggressively cleans all unreferenced
build cache, images >1h old, and stopped containers >1h old
(was 72h/7d/24h which let cache grow past 80% on every push)
The catalog manager (with auto-category wizard) now lives inside the Studio
navigation for teams that manage furniture inline. The button is gated on
CATALOG_EDIT; only users with that permission see the launcher.
- Split server/client Studio layout to derive permissions server-side
- Add optional triggerLabel prop to CatalogManagerDialog for custom labels
- Wire the Catalog manager button in the Studio nav right cluster
- Keep existing /admin/catalog entry points unchanged
- Add AutoCategoryDialog: pick furni (or empty page), suggest caption/icon/layout, live preview
- Add createAutoCategory server action (page + offers in one export) with CatalogKind
- Extend furni search API with interactionType
- Share ShopTile and refactor inline-editor/items-shop-preview to use it
- Add suggestion heuristics (suggestCategoryName/dIcon/layout) with tests
- Add autoCategory translations (en/nl)
Pass DB totals into StatsBar, mirror Catalog Structure delete copy, and open New Page with ?newPageParent= for shop and BC.
Co-authored-by: Cursor <[email protected]>
Mount BC Visual Manager, thread catalogType through tree/editor actions, and stop hardcoding canEdit=true in items/translate tabs.
Co-authored-by: Cursor <[email protected]>
Use an opaque admin-canvas portal and lazy parentId fetches instead of mode=full, which fails on large catalogs. Search no longer loads the entire tree.
Co-authored-by: Cursor <[email protected]>
Seed root tabs from SSR, load the full tree without CLEAR_TREE races, coerce parent ids, and tolerate catalog_items page_id type mismatches so category pages actually appear.
Co-authored-by: Cursor <[email protected]>
Base UI Dialog broke the Radix-era full-viewport manager. Use a body portal shell like habbo-next behavior, without DialogTitle/Popup, so tree + editor mount and nested pickers stay usable.
Co-authored-by: Cursor <[email protected]>
Move trigger outside Dialog.Root, use portal confirms and inline create forms, and load the catalog tree atomically so the manager matches the designed full-viewport UX.
Co-authored-by: Cursor <[email protected]>