feat(catalog): update the catalog live after an import, no page refresh

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.
This commit is contained in:
openhands committed 2026-09-29 15:34:36 +02:00
1 parent 7507c3b55c
commit 9cc57cddfc
10 files changed
+716 -32

No files matched your search

@@ -400,6 +400,10 @@ function FurniGrid({ source }: FurniGridProps) {
const [busyClassname, setBusyClassname] = useState<string | null>(null);
const [filter, setFilter] = useState<"all" | "missing" | "present">("all");
const batchAbortRef = useRef<AbortController | null>(null);
// Classnames the server already has, mirroring `items`. Lets markDone flip a
// card and adjust the counters without a refetch, and idempotently — React
// may run a state updater twice in dev.
const presentRef = useRef<Set<string>>(new Set());
const fetchItems = useCallback(
async (
@@ -426,6 +430,11 @@ function FurniGrid({ source }: FurniGridProps) {
setItems(data.items || []);
setMeta(data.meta || null);
setStats(data.stats || null);
presentRef.current = new Set(
(data.items || [])
.filter((it: FurniItem) => it.present)
.map((it: FurniItem) => it.classname),
);
setSelected(new Set());
} catch {
setError("Network error");
@@ -479,6 +488,19 @@ function FurniGrid({ source }: FurniGridProps) {
}
function markDone(classname: string) {
// Counters move as each clone lands, so total/present/clonable stay
// truthful for the whole run instead of jumping at the end.
if (presentRef.current.has(classname)) return;
presentRef.current.add(classname);
setStats((prev) =>
prev
? {
...prev,
present: prev.present + 1,
clonable: Math.max(0, prev.clonable - 1),
}
: prev,
);
setItems((prev) =>
prev.map((it) =>
it.classname === classname ? { ...it, present: true } : it,
+4 -4
View File
@@ -2,7 +2,6 @@ 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 { siteSettings } from "@/lib/services/site-settings";
@@ -14,9 +13,11 @@ export default async function StudioFurniPage(_props: {
redirect("/admin");
}
const [hotel, catalogTree, translateEnabled] = await Promise.all([
// The catalog tree is not read here: StudioClient pulls it from the live
// catalog store, which an import updates in place instead of forcing a
// route re-render.
const [hotel, translateEnabled] = await Promise.all([
getHabboGamedataHotel(),
getTreeFlat(),
siteSettings.getBool("furnidata_translate_enabled", true),
]);
@@ -24,7 +25,6 @@ export default async function StudioFurniPage(_props: {
<StudioClient
actorId={Number(session.user.id)}
source={buildFurniImportSource(hotel)}
initialTree={catalogTree}
defaultTranslate={translateEnabled}
/>
);