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); +}