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

@@ -17,11 +17,11 @@ import {
Wand2,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
type OrganizedPageChange,
type OrganizeImportGroup as OrganizeGroupPayload,
organizeImportFurni,
} from "@/actions/catalog";
@@ -51,6 +51,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog";
import {
CATALOG_LAYOUTS,
type CatalogLayout,
@@ -155,7 +156,6 @@ export function OrganizeImportsDialog({
onOpenChange,
}: OrganizeImportsDialogProps) {
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
const router = useRouter();
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
@@ -683,8 +683,7 @@ export function OrganizeImportsDialog({
const runs = chunkGroups(payload);
setIsCreating(true);
try {
const created: Array<{ pageId: number; moved: number; added: number }> =
[];
const created: OrganizedPageChange[] = [];
let failedRuns = 0;
for (const run of runs) {
const result = await organizeImportFurni({
@@ -707,6 +706,34 @@ export function OrganizeImportsDialog({
} catch {
// ignore storage errors
}
// Fold the result straight into the mounted catalog tree instead of
// router.refresh()-ing the route, which would rebuild the whole
// workspace and throw away scroll position and open panels.
applyCatalogDelta({
pages: created
.filter((c) => c.isNew)
.map((c) => ({
id: c.pageId,
caption: c.caption,
parentId: c.parentId,
pageLayout: c.pageLayout,
iconImage: c.iconImage,
iconColor: c.iconColor,
orderNum: c.orderNum,
enabled: c.enabled,
visible: c.visible,
})),
addedItems: created
.filter((c) => c.added > 0)
.map((c) => ({ pageId: c.pageId, count: c.added })),
movedItems: created.flatMap((c) =>
c.movedFrom.map((m) => ({
fromPageId: m.fromPageId,
toPageId: c.pageId,
count: m.count,
})),
),
});
}
if (failedRuns > 0) {
toast.error(
@@ -733,7 +760,6 @@ export function OrganizeImportsDialog({
);
}
onOpenChange(false);
router.refresh();
} catch (error) {
console.error("[OrganizeImports] create failed:", error);
toast.error(t("errorFailed"));
+13 -5
View File
@@ -63,11 +63,15 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
ensureCatalogTreeLoaded,
refreshCatalogTree,
useLiveCatalog,
} from "@/features/catalog/client/use-live-catalog";
import { adminFetch } from "@/lib/admin-fetch";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { readSseStream } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress";
import { CatalogRail } from "./catalog-rail";
import { CheckboxDot } from "./checkbox-dot";
@@ -169,12 +173,10 @@ const TABLE_ROW_H = 40;
export function StudioClient({
actorId,
source,
initialTree,
defaultTranslate,
}: {
actorId?: number;
source: FurniImportSource;
initialTree: TreeNode[];
defaultTranslate: boolean;
}) {
const [review, setReview] = useState<{
@@ -191,12 +193,14 @@ export function StudioClient({
actorId,
);
const [preparingReview, setPreparingReview] = useState(false);
// Catalog tree
const [tree] = useState<TreeNode[]>(initialTree);
// Catalog tree — read from the live store so pages created by an import show
// up without re-rendering the route.
const tree = useLiveCatalog().tree;
const [selectedPage, setSelectedPage] = useState<number | null>(null);
const [railOpen, setRailOpen] = useState(false);
useEffect(() => {
setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
void ensureCatalogTreeLoaded();
}, []);
// Search & list
@@ -957,6 +961,10 @@ export function StudioClient({
setBatchCancelling(false);
setSelected(new Set());
fetchStats();
// The importers derive their "Imported Furniture" category pages from
// furnidata, so the client cannot predict which pages appeared. Pull the
// tree over the API instead of re-rendering the route.
void refreshCatalogTree();
if (!batchDoneRef.current) markBatchDone();
}
}