diff --git a/e2e/ui/fixtures/entry.tsx b/e2e/ui/fixtures/entry.tsx
index 38033824..9997a8ca 100644
--- a/e2e/ui/fixtures/entry.tsx
+++ b/e2e/ui/fixtures/entry.tsx
@@ -137,7 +137,6 @@ createRoot(root).render(
>
diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts
index 721434ff..1b1c1f00 100644
--- a/src/actions/catalog.ts
+++ b/src/actions/catalog.ts
@@ -351,6 +351,30 @@ export async function createAutoCategory(input: {
const MAX_ORGANIZE_GROUPS = 50;
const MAX_ORGANIZE_ITEMS = 500;
+/**
+ * One page the run wrote to, described well enough for the client to fold the
+ * result into its live catalog tree. `isNew` is false when the group reused an
+ * existing destination, in which case the page already exists and every field
+ * that describes a *new* page (`parentId`, `pageLayout`, the icon) is only the
+ * requested value, not a verified read-back of the stored row.
+ */
+export interface OrganizedPageChange {
+ pageId: number;
+ caption: string;
+ parentId: number;
+ pageLayout: string;
+ iconImage: number;
+ iconColor: number;
+ orderNum: number;
+ visible: string;
+ enabled: string;
+ isNew: boolean;
+ moved: number;
+ added: number;
+ /** Import pages the offers were taken from, so counts can be rebalanced. */
+ movedFrom: Array<{ fromPageId: number; count: number }>;
+}
+
export interface OrganizeImportGroup {
caption: string;
pageLayout: string;
@@ -378,12 +402,7 @@ export async function organizeImportFurni(input: {
groups: OrganizeImportGroup[];
}): Promise<
ActionResult<{
- created: Array<{
- pageId: number;
- caption: string;
- moved: number;
- added: number;
- }>;
+ created: OrganizedPageChange[];
}>
> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
@@ -412,12 +431,7 @@ export async function organizeImportFurni(input: {
);
const importPageIds = new Set(await getImportedCategoryPageIds());
- const created: Array<{
- pageId: number;
- caption: string;
- moved: number;
- added: number;
- }> = [];
+ const created: OrganizedPageChange[] = [];
let totalMoved = 0;
let totalAdded = 0;
@@ -432,15 +446,18 @@ export async function organizeImportFurni(input: {
error: "Invalid destination page id",
};
}
+ const isNew = destinationPageId == null;
+ const pageLayout = group.pageLayout || "default_3x3";
+ const iconImage = group.iconImage ?? 0;
const pageId =
destinationPageId ??
Number(
await createPageCommand("normal", {
caption: group.caption,
parentId: input.parentId,
- pageLayout: group.pageLayout || "default_3x3",
+ pageLayout,
captionSave: group.caption.slice(0, 25),
- iconImage: group.iconImage ?? 0,
+ iconImage,
iconColor: 0,
minRank: 1,
orderNum: 0,
@@ -492,6 +509,7 @@ export async function organizeImportFurni(input: {
),
];
const validMoveIds = new Set();
+ const hostPageByItemId = new Map();
if (moverIds.length > 0) {
const hostRows = await queryRows<{ id: number; page_id: number }>(sql`
SELECT id, page_id FROM catalog_items WHERE id IN (${sql.join(
@@ -500,14 +518,17 @@ export async function organizeImportFurni(input: {
)})
`);
for (const row of hostRows) {
- if (importPageIds.has(Number(row.page_id))) {
+ const pageId = Number(row.page_id);
+ if (importPageIds.has(pageId)) {
validMoveIds.add(Number(row.id));
+ hostPageByItemId.set(Number(row.id), pageId);
}
}
}
let moved = 0;
let added = 0;
+ const movedFromCounts = new Map();
const moveCaseOrder: string[] = [];
const moveCaseName: string[] = [];
@@ -534,6 +555,13 @@ export async function organizeImportFurni(input: {
)}'`,
);
moveIds.push(Number(row.catalogItemId));
+ const hostPageId = hostPageByItemId.get(Number(row.catalogItemId));
+ if (hostPageId != null) {
+ movedFromCounts.set(
+ hostPageId,
+ (movedFromCounts.get(hostPageId) ?? 0) + 1,
+ );
+ }
moved++;
} else {
await insertCatalogItemRow({
@@ -586,7 +614,24 @@ export async function organizeImportFurni(input: {
targetType: "catalog_page",
targetId: pageId,
});
- created.push({ pageId, caption, moved, added });
+ created.push({
+ pageId,
+ caption,
+ parentId: input.parentId,
+ pageLayout,
+ iconImage,
+ iconColor: 0,
+ orderNum: 0,
+ visible: "1",
+ enabled: "1",
+ isNew,
+ moved,
+ added,
+ movedFrom: [...movedFromCounts].map(([fromPageId, count]) => ({
+ fromPageId,
+ count,
+ })),
+ });
totalMoved += moved;
totalAdded += added;
}
diff --git a/src/app/admin/import/clone/import-clone-client.tsx b/src/app/admin/import/clone/import-clone-client.tsx
index 87e7093f..8bee2541 100644
--- a/src/app/admin/import/clone/import-clone-client.tsx
+++ b/src/app/admin/import/clone/import-clone-client.tsx
@@ -400,6 +400,10 @@ function FurniGrid({ source }: FurniGridProps) {
const [busyClassname, setBusyClassname] = useState(null);
const [filter, setFilter] = useState<"all" | "missing" | "present">("all");
const batchAbortRef = useRef(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>(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,
diff --git a/src/app/admin/studio/furni/page.tsx b/src/app/admin/studio/furni/page.tsx
index b3540d65..dc63c57b 100644
--- a/src/app/admin/studio/furni/page.tsx
+++ b/src/app/admin/studio/furni/page.tsx
@@ -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: {
);
diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx
index c8b31999..bd2543f2 100644
--- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx
+++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx
@@ -12,6 +12,10 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
+import {
+ seedCatalogTotals,
+ useLiveCatalog,
+} from "@/features/catalog/client/use-live-catalog";
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
import type { CatalogSearchResult } from "@/features/catalog/domain/search";
import type { RootTab } from "@/types/catalog";
@@ -164,6 +168,12 @@ function CatalogManagerInner({
totals?: CatalogStatsTotals | null;
}) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
+ // Server totals are the baseline; after an import the live store holds
+ // counts that are newer than both the prop and its 30s server cache.
+ const liveTotals = useLiveCatalog().totals[catalogType];
+ useEffect(() => {
+ seedCatalogTotals(catalogType, totals);
+ }, [catalogType, totals]);
const catQs = catalogTreeQs(catalogType);
const rootRequests = useLatestRequest();
const searchNavigation = useLatestRequest();
@@ -518,7 +528,7 @@ function CatalogManagerInner({
-
+
);
}
diff --git a/src/components/admin/studio/organize-imports-dialog.tsx b/src/components/admin/studio/organize-imports-dialog.tsx
index f848eca7..6f04d9d5 100644
--- a/src/components/admin/studio/organize-imports-dialog.tsx
+++ b/src/components/admin/studio/organize-imports-dialog.tsx
@@ -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"));
diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx
index 720b47b1..32da1a2f 100644
--- a/src/components/admin/studio/studio-client.tsx
+++ b/src/components/admin/studio/studio-client.tsx
@@ -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(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(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();
}
}
diff --git a/src/features/catalog/client/live-catalog-merge.test.ts b/src/features/catalog/client/live-catalog-merge.test.ts
new file mode 100644
index 00000000..ab35ccc2
--- /dev/null
+++ b/src/features/catalog/client/live-catalog-merge.test.ts
@@ -0,0 +1,240 @@
+import { describe, expect, it } from "vitest";
+import type { TreeNode } from "@/types/catalog";
+import {
+ applyCatalogTotals,
+ applyCatalogTreeDelta,
+ type CatalogTreeDelta,
+ type CreatedCatalogPage,
+ EMPTY_CATALOG_DELTA,
+ isEmptyCatalogDelta,
+ normalizeTreePages,
+ recomputeDepth,
+} from "./live-catalog-merge";
+
+function node(over: Partial & { id: number }): TreeNode {
+ return {
+ caption: `page ${over.id}`,
+ parentId: -1,
+ depth: 0,
+ orderNum: 0,
+ enabled: "1",
+ visible: "1",
+ iconImage: 0,
+ iconColor: 0,
+ pageLayout: "default_3x3",
+ childCount: 0,
+ itemCount: 0,
+ ...over,
+ };
+}
+
+function created(
+ over: Partial & { id: number },
+): CreatedCatalogPage {
+ return {
+ caption: `new ${over.id}`,
+ parentId: -1,
+ pageLayout: "default_3x3",
+ iconImage: 0,
+ iconColor: 0,
+ orderNum: 0,
+ enabled: "1",
+ visible: "1",
+ ...over,
+ };
+}
+
+const delta = (over: Partial = {}): CatalogTreeDelta => ({
+ ...EMPTY_CATALOG_DELTA,
+ ...over,
+});
+
+describe("recomputeDepth", () => {
+ it("nests children below their parent and keeps roots at 0", () => {
+ expect(
+ recomputeDepth([
+ node({ id: 1, parentId: -1 }),
+ node({ id: 2, parentId: 1 }),
+ node({ id: 3, parentId: 2 }),
+ node({ id: 4, parentId: -1 }),
+ ]).map((n) => n.depth),
+ ).toEqual([0, 1, 2, 0]);
+ });
+
+ it("terminates on a parent cycle instead of recursing forever", () => {
+ // A messy DB can contain a looping parent chain. The server's getTreeFlat
+ // resolves such a cycle to a finite depth too, so the client only has to
+ // agree that it terminates and never goes negative.
+ for (const input of [
+ [node({ id: 1, parentId: 1 })],
+ [node({ id: 1, parentId: 2 }), node({ id: 2, parentId: 1 })],
+ [
+ node({ id: 1, parentId: 3 }),
+ node({ id: 2, parentId: 1 }),
+ node({ id: 3, parentId: 2 }),
+ ],
+ ]) {
+ const depths = recomputeDepth(input).map((n) => n.depth);
+ expect(depths.every((d) => Number.isInteger(d) && d >= 0)).toBe(true);
+ expect(depths).toHaveLength(input.length);
+ }
+ });
+
+ it("returns the same node objects when depths are already correct", () => {
+ const input = [
+ node({ id: 1, depth: 0 }),
+ node({ id: 2, parentId: 1, depth: 1 }),
+ ];
+ expect(recomputeDepth(input)[1]).toBe(input[1]);
+ });
+});
+
+describe("applyCatalogTreeDelta", () => {
+ const tree = [
+ node({ id: 1, parentId: -1, orderNum: 1, childCount: 1, itemCount: 10 }),
+ node({ id: 2, parentId: 1, orderNum: 1, itemCount: 4 }),
+ ];
+
+ it("returns the same array when the delta is empty", () => {
+ expect(applyCatalogTreeDelta(tree, delta())).toBe(tree);
+ });
+
+ it("inserts a new page in sibling order with the right depth", () => {
+ const next = applyCatalogTreeDelta(
+ tree,
+ delta({ pages: [created({ id: 3, parentId: 1, orderNum: 0 })] }),
+ );
+ expect(next.map((n) => [n.id, n.orderNum, n.depth])).toEqual([
+ [3, 0, 1],
+ [1, 1, 0],
+ [2, 1, 1],
+ ]);
+ });
+
+ it("bumps the parent child count and the total item count", () => {
+ const next = applyCatalogTreeDelta(
+ tree,
+ delta({
+ pages: [created({ id: 3, parentId: 1 })],
+ addedItems: [{ pageId: 3, count: 6 }],
+ }),
+ );
+ expect(next.find((n) => n.id === 1)?.childCount).toBe(2);
+ expect(next.find((n) => n.id === 3)?.itemCount).toBe(6);
+ });
+
+ it("skips a page the tree already knows about", () => {
+ const next = applyCatalogTreeDelta(
+ tree,
+ delta({
+ pages: [created({ id: 2, parentId: 1 })],
+ addedItems: [{ pageId: 2, count: 3 }],
+ }),
+ );
+ expect(next).toHaveLength(2);
+ expect(next.find((n) => n.id === 2)?.itemCount).toBe(7);
+ // A reused destination is not a new child, so childCount must not grow.
+ expect(next.find((n) => n.id === 1)?.childCount).toBe(1);
+ });
+
+ it("shifts item counts between pages for a move without changing the total", () => {
+ const next = applyCatalogTreeDelta(
+ tree,
+ delta({ movedItems: [{ fromPageId: 2, toPageId: 1, count: 4 }] }),
+ );
+ expect(next.find((n) => n.id === 1)?.itemCount).toBe(14);
+ expect(next.find((n) => n.id === 2)?.itemCount).toBe(0);
+ });
+
+ it("never lets an item count go negative", () => {
+ const next = applyCatalogTreeDelta(
+ tree,
+ delta({ movedItems: [{ fromPageId: 2, toPageId: 1, count: 99 }] }),
+ );
+ expect(next.find((n) => n.id === 2)?.itemCount).toBe(0);
+ });
+
+ it("inserts several pages in one pass with correct depths", () => {
+ const next = applyCatalogTreeDelta(
+ tree,
+ delta({
+ pages: [
+ created({ id: 3, parentId: 1, orderNum: 2 }),
+ created({ id: 4, parentId: 3, orderNum: 1 }),
+ ],
+ }),
+ );
+ expect(next.find((n) => n.id === 4)?.depth).toBe(2);
+ expect(next.find((n) => n.id === 3)?.childCount).toBe(1);
+ });
+});
+
+describe("applyCatalogTotals", () => {
+ const totals = { totalPages: 9, enabledPages: 8, totalItems: 100 };
+
+ it("counts new pages and new offers, but not moves", () => {
+ expect(
+ applyCatalogTotals(
+ totals,
+ delta({
+ pages: [
+ created({ id: 20, enabled: "1" }),
+ created({ id: 21, enabled: "0" }),
+ ],
+ addedItems: [{ pageId: 20, count: 5 }],
+ movedItems: [{ fromPageId: 1, toPageId: 20, count: 3 }],
+ }),
+ ),
+ ).toEqual({ totalPages: 11, enabledPages: 9, totalItems: 105 });
+ });
+
+ it("returns the same object for an empty delta", () => {
+ expect(applyCatalogTotals(totals, delta())).toBe(totals);
+ });
+});
+
+describe("normalizeTreePages", () => {
+ it("coerces an API payload into TreeNode and drops unusable rows", () => {
+ expect(
+ normalizeTreePages([
+ { id: "4", caption: "Wired", parentId: "0", orderNum: "2" },
+ { id: 0, caption: "bogus" },
+ { caption: "no id" },
+ null,
+ "nope",
+ ]),
+ ).toEqual([
+ {
+ id: 4,
+ caption: "Wired",
+ parentId: -1,
+ depth: 0,
+ orderNum: 2,
+ enabled: "0",
+ visible: "0",
+ iconImage: 0,
+ iconColor: 0,
+ pageLayout: "default_3x3",
+ childCount: 0,
+ itemCount: 0,
+ },
+ ]);
+ });
+
+ it("returns an empty list for a non-array payload", () => {
+ expect(normalizeTreePages(undefined)).toEqual([]);
+ });
+});
+
+describe("isEmptyCatalogDelta", () => {
+ it("ignores zero-count entries", () => {
+ expect(
+ isEmptyCatalogDelta({
+ pages: [],
+ addedItems: [{ pageId: 1, count: 0 }],
+ movedItems: [],
+ }),
+ ).toBe(false);
+ expect(isEmptyCatalogDelta(EMPTY_CATALOG_DELTA)).toBe(true);
+ });
+});
diff --git a/src/features/catalog/client/live-catalog-merge.ts b/src/features/catalog/client/live-catalog-merge.ts
new file mode 100644
index 00000000..87ca8efd
--- /dev/null
+++ b/src/features/catalog/client/live-catalog-merge.ts
@@ -0,0 +1,206 @@
+import type { TreeNode } from "@/types/catalog";
+
+/** A catalog page the server just created, in the shape the live tree needs. */
+export interface CreatedCatalogPage {
+ id: number;
+ caption: string;
+ parentId: number;
+ pageLayout: string;
+ iconImage: number;
+ iconColor: number;
+ orderNum: number;
+ enabled: string;
+ visible: string;
+}
+
+/** catalog_items rows freshly inserted onto a page. */
+export interface AddedCatalogItems {
+ pageId: number;
+ count: number;
+}
+
+/** catalog_items rows re-parented from one page onto another. */
+export interface MovedCatalogItems {
+ fromPageId: number;
+ toPageId: number;
+ count: number;
+}
+
+/**
+ * Everything an import run changed in the catalog, expressed so the client can
+ * fold it into the tree it already holds — no page reload needed.
+ */
+export interface CatalogTreeDelta {
+ /** Genuinely new pages. Destination pages that already existed are omitted. */
+ pages: CreatedCatalogPage[];
+ /** Newly inserted offers, keyed by the page that received them. */
+ addedItems: AddedCatalogItems[];
+ /** Re-parented offers; these do not change the total item count. */
+ movedItems: MovedCatalogItems[];
+}
+
+export const EMPTY_CATALOG_DELTA: CatalogTreeDelta = {
+ pages: [],
+ addedItems: [],
+ movedItems: [],
+};
+
+export interface CatalogTotals {
+ totalPages: number;
+ enabledPages: number;
+ totalItems: number;
+}
+
+export function isEmptyCatalogDelta(delta: CatalogTreeDelta): boolean {
+ return (
+ delta.pages.length === 0 &&
+ delta.addedItems.length === 0 &&
+ delta.movedItems.length === 0
+ );
+}
+
+function toCount(value: unknown): number {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : 0;
+}
+
+/**
+ * Coerce an API tree payload into TreeNode. The route already answers with
+ * TreeNode, but this keeps a malformed row from poisoning depth/count maths.
+ */
+export function normalizeTreePages(raw: unknown): TreeNode[] {
+ if (!Array.isArray(raw)) return [];
+ const nodes: TreeNode[] = [];
+ for (const entry of raw) {
+ if (!entry || typeof entry !== "object") continue;
+ const row = entry as Record;
+ const id = toCount(row.id);
+ if (id <= 0) continue;
+ const parentId = toCount(row.parentId ?? -1);
+ nodes.push({
+ id,
+ caption: String(row.caption ?? id),
+ parentId: parentId || -1,
+ depth: toCount(row.depth),
+ orderNum: toCount(row.orderNum),
+ enabled: String(row.enabled ?? "0"),
+ visible: String(row.visible ?? "0"),
+ iconImage: toCount(row.iconImage),
+ iconColor: toCount(row.iconColor),
+ pageLayout: String(row.pageLayout ?? "default_3x3"),
+ childCount: toCount(row.childCount),
+ itemCount: toCount(row.itemCount),
+ });
+ }
+ return nodes;
+}
+
+/**
+ * Recompute depth from parent links, mirroring the server's getTreeFlat:
+ * roots sit at 0 and a looping parent chain stops at 0 instead of recursing
+ * forever. The shared `chain` set is safe because every exit removes its id.
+ */
+export function recomputeDepth(nodes: TreeNode[]): TreeNode[] {
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ const depthOf = new Map();
+ const chain = new Set();
+
+ const resolve = (id: number): number => {
+ const cached = depthOf.get(id);
+ if (cached !== undefined) return cached;
+ if (chain.has(id)) return 0;
+ chain.add(id);
+ const parentId = byId.get(id)?.parentId ?? -1;
+ const depth =
+ parentId > 0 && byId.has(parentId) ? resolve(parentId) + 1 : 0;
+ chain.delete(id);
+ depthOf.set(id, depth);
+ return depth;
+ };
+
+ return nodes.map((node) => {
+ const depth = resolve(node.id);
+ return node.depth === depth ? node : { ...node, depth };
+ });
+}
+
+function itemDeltas(delta: CatalogTreeDelta): Map {
+ const deltas = new Map();
+ const bump = (pageId: number, amount: number) => {
+ if (amount === 0) return;
+ deltas.set(pageId, (deltas.get(pageId) ?? 0) + amount);
+ };
+ for (const entry of delta.addedItems) {
+ if (Number.isFinite(entry.pageId)) bump(entry.pageId, toCount(entry.count));
+ }
+ for (const entry of delta.movedItems) {
+ if (entry.count <= 0) continue;
+ if (Number.isFinite(entry.fromPageId)) bump(entry.fromPageId, -entry.count);
+ if (Number.isFinite(entry.toPageId)) bump(entry.toPageId, entry.count);
+ }
+ return deltas;
+}
+
+/**
+ * Fold a delta into a flat tree, returning the same array reference when the
+ * delta changes nothing so consumers can rely on identity for memoisation.
+ */
+export function applyCatalogTreeDelta(
+ tree: TreeNode[],
+ delta: CatalogTreeDelta,
+): TreeNode[] {
+ if (isEmptyCatalogDelta(delta)) return tree;
+
+ const counts = itemDeltas(delta);
+ const known = new Set(tree.map((n) => n.id));
+ const fresh: TreeNode[] = [];
+ const childAdds = new Map();
+ for (const page of delta.pages) {
+ if (known.has(page.id)) continue;
+ known.add(page.id);
+ fresh.push({ ...page, depth: 0, childCount: 0, itemCount: 0 });
+ childAdds.set(page.parentId, (childAdds.get(page.parentId) ?? 0) + 1);
+ }
+
+ let next = tree;
+ if (fresh.length > 0) {
+ next = [...next, ...fresh].sort(
+ (a, b) => a.orderNum - b.orderNum || a.id - b.id,
+ );
+ next = recomputeDepth(next);
+ }
+ if (counts.size === 0 && childAdds.size === 0) return next;
+
+ return next.map((node) => {
+ const itemDelta = counts.get(node.id) ?? 0;
+ const childDelta = childAdds.get(node.id) ?? 0;
+ if (itemDelta === 0 && childDelta === 0) return node;
+ return {
+ ...node,
+ itemCount: Math.max(0, node.itemCount + itemDelta),
+ childCount: Math.max(0, node.childCount + childDelta),
+ };
+ });
+}
+
+/**
+ * New offers raise the item total; re-parented ones only shift between pages.
+ * Counts here are not derived from the tree, so totals stay correct even when
+ * the live tree has not been loaded yet.
+ */
+export function applyCatalogTotals(
+ totals: CatalogTotals,
+ delta: CatalogTreeDelta,
+): CatalogTotals {
+ if (isEmptyCatalogDelta(delta)) return totals;
+ const enabled = delta.pages.filter((p) => p.enabled !== "0").length;
+ const items = delta.addedItems.reduce(
+ (sum, entry) => sum + Math.max(0, toCount(entry.count)),
+ 0,
+ );
+ return {
+ totalPages: totals.totalPages + delta.pages.length,
+ enabledPages: totals.enabledPages + enabled,
+ totalItems: totals.totalItems + items,
+ };
+}
diff --git a/src/features/catalog/client/use-live-catalog.ts b/src/features/catalog/client/use-live-catalog.ts
new file mode 100644
index 00000000..f97eebe7
--- /dev/null
+++ b/src/features/catalog/client/use-live-catalog.ts
@@ -0,0 +1,128 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { adminFetch } from "@/lib/admin-fetch";
+import type { TreeNode } from "@/types/catalog";
+import {
+ applyCatalogTotals,
+ applyCatalogTreeDelta,
+ type CatalogTotals,
+ type CatalogTreeDelta,
+ isEmptyCatalogDelta,
+ normalizeTreePages,
+} from "./live-catalog-merge";
+
+export type CatalogMode = "normal" | "bc";
+
+interface LiveCatalogSnapshot {
+ tree: TreeNode[];
+ totals: Record;
+ treeLoaded: boolean;
+}
+
+const EMPTY_SNAPSHOT: LiveCatalogSnapshot = {
+ tree: [],
+ totals: { normal: null, bc: null },
+ treeLoaded: false,
+};
+
+// A module-level external store keeps the catalog tree alive across client
+// navigations and — more importantly — lets an import push its delta straight
+// into the mounted tree instead of re-rendering the whole route.
+let snapshot: LiveCatalogSnapshot = EMPTY_SNAPSHOT;
+const listeners = new Set<() => void>();
+
+function emit(next: LiveCatalogSnapshot) {
+ snapshot = next;
+ for (const listener of listeners) listener();
+}
+
+function subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+const getSnapshot = () => snapshot;
+const getServerSnapshot = () => EMPTY_SNAPSHOT;
+
+export function useLiveCatalog(): LiveCatalogSnapshot {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
+
+// ── Reads ──────────────────────────────────────────────────────────────────
+
+/**
+ * Adopt server-rendered totals as the baseline. Only the first value per mode
+ * wins: after an import the store holds counts that are newer than any re-render
+ * of the server prop (which is itself `unstable_cache`d for 30s).
+ */
+export function seedCatalogTotals(
+ mode: CatalogMode,
+ totals: CatalogTotals | null | undefined,
+): void {
+ if (!totals) return;
+ if (snapshot.totals[mode]) return;
+ emit({
+ ...snapshot,
+ totals: { ...snapshot.totals, [mode]: { ...totals } },
+ });
+}
+
+/**
+ * Fold an import run's changes into the live tree and totals. Safe to call from
+ * an event handler; a delta that changes nothing leaves the snapshot untouched.
+ */
+export function applyCatalogDelta(delta: CatalogTreeDelta): void {
+ if (isEmptyCatalogDelta(delta)) return;
+ const normal = snapshot.totals.normal;
+ emit({
+ ...snapshot,
+ tree: applyCatalogTreeDelta(snapshot.tree, delta),
+ totals: {
+ ...snapshot.totals,
+ normal: normal ? applyCatalogTotals(normal, delta) : null,
+ },
+ });
+}
+
+async function loadTree(): Promise {
+ const res = await adminFetch("/api/admin/catalog/tree?mode=full");
+ if (!res.ok) throw new Error(`Catalog tree refresh failed (${res.status})`);
+ const data = await res.json();
+ emit({
+ ...snapshot,
+ tree: normalizeTreePages(data?.pages),
+ treeLoaded: true,
+ });
+}
+
+let treeRequest: Promise | null = null;
+
+/**
+ * Reload the whole tree over the API. Used where the server decides the shape of
+ * the result — the furniture importers derive their category pages from
+ * furnidata, so there is nothing for the client to predict. Still a plain
+ * in-place data update: no route re-render, no remount, no lost editor state.
+ */
+export function refreshCatalogTree(): Promise {
+ if (treeRequest) return treeRequest;
+ treeRequest = loadTree()
+ .catch(() => undefined)
+ .finally(() => {
+ treeRequest = null;
+ });
+ return treeRequest;
+}
+
+/** Load the tree once per session; later calls are no-ops. */
+export function ensureCatalogTreeLoaded(): Promise {
+ return snapshot.treeLoaded ? Promise.resolve() : refreshCatalogTree();
+}
+
+/** Test seam: drop all live catalog state between cases. */
+export function resetLiveCatalogForTests(): void {
+ treeRequest = null;
+ emit(EMPTY_SNAPSHOT);
+}