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:
1 parent
7507c3b55c
commit
9cc57cddfc
10 files changed
+716
-32
No files matched your search
@@ -137,7 +137,6 @@ createRoot(root).render(
|
|||||||
<OrganizeImportsLauncher />
|
<OrganizeImportsLauncher />
|
||||||
<StudioClient
|
<StudioClient
|
||||||
source={buildFurniImportSource("it")}
|
source={buildFurniImportSource("it")}
|
||||||
initialTree={[]}
|
|
||||||
defaultTranslate={false}
|
defaultTranslate={false}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+61
-16
@@ -351,6 +351,30 @@ export async function createAutoCategory(input: {
|
|||||||
const MAX_ORGANIZE_GROUPS = 50;
|
const MAX_ORGANIZE_GROUPS = 50;
|
||||||
const MAX_ORGANIZE_ITEMS = 500;
|
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 {
|
export interface OrganizeImportGroup {
|
||||||
caption: string;
|
caption: string;
|
||||||
pageLayout: string;
|
pageLayout: string;
|
||||||
@@ -378,12 +402,7 @@ export async function organizeImportFurni(input: {
|
|||||||
groups: OrganizeImportGroup[];
|
groups: OrganizeImportGroup[];
|
||||||
}): Promise<
|
}): Promise<
|
||||||
ActionResult<{
|
ActionResult<{
|
||||||
created: Array<{
|
created: OrganizedPageChange[];
|
||||||
pageId: number;
|
|
||||||
caption: string;
|
|
||||||
moved: number;
|
|
||||||
added: number;
|
|
||||||
}>;
|
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
@@ -412,12 +431,7 @@ export async function organizeImportFurni(input: {
|
|||||||
);
|
);
|
||||||
const importPageIds = new Set(await getImportedCategoryPageIds());
|
const importPageIds = new Set(await getImportedCategoryPageIds());
|
||||||
|
|
||||||
const created: Array<{
|
const created: OrganizedPageChange[] = [];
|
||||||
pageId: number;
|
|
||||||
caption: string;
|
|
||||||
moved: number;
|
|
||||||
added: number;
|
|
||||||
}> = [];
|
|
||||||
let totalMoved = 0;
|
let totalMoved = 0;
|
||||||
let totalAdded = 0;
|
let totalAdded = 0;
|
||||||
|
|
||||||
@@ -432,15 +446,18 @@ export async function organizeImportFurni(input: {
|
|||||||
error: "Invalid destination page id",
|
error: "Invalid destination page id",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const isNew = destinationPageId == null;
|
||||||
|
const pageLayout = group.pageLayout || "default_3x3";
|
||||||
|
const iconImage = group.iconImage ?? 0;
|
||||||
const pageId =
|
const pageId =
|
||||||
destinationPageId ??
|
destinationPageId ??
|
||||||
Number(
|
Number(
|
||||||
await createPageCommand("normal", {
|
await createPageCommand("normal", {
|
||||||
caption: group.caption,
|
caption: group.caption,
|
||||||
parentId: input.parentId,
|
parentId: input.parentId,
|
||||||
pageLayout: group.pageLayout || "default_3x3",
|
pageLayout,
|
||||||
captionSave: group.caption.slice(0, 25),
|
captionSave: group.caption.slice(0, 25),
|
||||||
iconImage: group.iconImage ?? 0,
|
iconImage,
|
||||||
iconColor: 0,
|
iconColor: 0,
|
||||||
minRank: 1,
|
minRank: 1,
|
||||||
orderNum: 0,
|
orderNum: 0,
|
||||||
@@ -492,6 +509,7 @@ export async function organizeImportFurni(input: {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
const validMoveIds = new Set<number>();
|
const validMoveIds = new Set<number>();
|
||||||
|
const hostPageByItemId = new Map<number, number>();
|
||||||
if (moverIds.length > 0) {
|
if (moverIds.length > 0) {
|
||||||
const hostRows = await queryRows<{ id: number; page_id: number }>(sql`
|
const hostRows = await queryRows<{ id: number; page_id: number }>(sql`
|
||||||
SELECT id, page_id FROM catalog_items WHERE id IN (${sql.join(
|
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) {
|
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));
|
validMoveIds.add(Number(row.id));
|
||||||
|
hostPageByItemId.set(Number(row.id), pageId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let moved = 0;
|
let moved = 0;
|
||||||
let added = 0;
|
let added = 0;
|
||||||
|
const movedFromCounts = new Map<number, number>();
|
||||||
|
|
||||||
const moveCaseOrder: string[] = [];
|
const moveCaseOrder: string[] = [];
|
||||||
const moveCaseName: string[] = [];
|
const moveCaseName: string[] = [];
|
||||||
@@ -534,6 +555,13 @@ export async function organizeImportFurni(input: {
|
|||||||
)}'`,
|
)}'`,
|
||||||
);
|
);
|
||||||
moveIds.push(Number(row.catalogItemId));
|
moveIds.push(Number(row.catalogItemId));
|
||||||
|
const hostPageId = hostPageByItemId.get(Number(row.catalogItemId));
|
||||||
|
if (hostPageId != null) {
|
||||||
|
movedFromCounts.set(
|
||||||
|
hostPageId,
|
||||||
|
(movedFromCounts.get(hostPageId) ?? 0) + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
moved++;
|
moved++;
|
||||||
} else {
|
} else {
|
||||||
await insertCatalogItemRow({
|
await insertCatalogItemRow({
|
||||||
@@ -586,7 +614,24 @@ export async function organizeImportFurni(input: {
|
|||||||
targetType: "catalog_page",
|
targetType: "catalog_page",
|
||||||
targetId: pageId,
|
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;
|
totalMoved += moved;
|
||||||
totalAdded += added;
|
totalAdded += added;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -400,6 +400,10 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
const [busyClassname, setBusyClassname] = useState<string | null>(null);
|
const [busyClassname, setBusyClassname] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState<"all" | "missing" | "present">("all");
|
const [filter, setFilter] = useState<"all" | "missing" | "present">("all");
|
||||||
const batchAbortRef = useRef<AbortController | null>(null);
|
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(
|
const fetchItems = useCallback(
|
||||||
async (
|
async (
|
||||||
@@ -426,6 +430,11 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
setItems(data.items || []);
|
setItems(data.items || []);
|
||||||
setMeta(data.meta || null);
|
setMeta(data.meta || null);
|
||||||
setStats(data.stats || null);
|
setStats(data.stats || null);
|
||||||
|
presentRef.current = new Set(
|
||||||
|
(data.items || [])
|
||||||
|
.filter((it: FurniItem) => it.present)
|
||||||
|
.map((it: FurniItem) => it.classname),
|
||||||
|
);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
} catch {
|
} catch {
|
||||||
setError("Network error");
|
setError("Network error");
|
||||||
@@ -479,6 +488,19 @@ function FurniGrid({ source }: FurniGridProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markDone(classname: string) {
|
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) =>
|
setItems((prev) =>
|
||||||
prev.map((it) =>
|
prev.map((it) =>
|
||||||
it.classname === classname ? { ...it, present: true } : it,
|
it.classname === classname ? { ...it, present: true } : it,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
|
|||||||
import { StudioClient } from "@/components/admin/studio/studio-client";
|
import { StudioClient } from "@/components/admin/studio/studio-client";
|
||||||
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { getTreeFlat } from "@/lib/services/catalog-tree";
|
|
||||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -14,9 +13,11 @@ export default async function StudioFurniPage(_props: {
|
|||||||
redirect("/admin");
|
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(),
|
getHabboGamedataHotel(),
|
||||||
getTreeFlat(),
|
|
||||||
siteSettings.getBool("furnidata_translate_enabled", true),
|
siteSettings.getBool("furnidata_translate_enabled", true),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -24,7 +25,6 @@ export default async function StudioFurniPage(_props: {
|
|||||||
<StudioClient
|
<StudioClient
|
||||||
actorId={Number(session.user.id)}
|
actorId={Number(session.user.id)}
|
||||||
source={buildFurniImportSource(hotel)}
|
source={buildFurniImportSource(hotel)}
|
||||||
initialTree={catalogTree}
|
|
||||||
defaultTranslate={translateEnabled}
|
defaultTranslate={translateEnabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||||||
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
|
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
|
||||||
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
||||||
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
|
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 { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
||||||
import type { CatalogSearchResult } from "@/features/catalog/domain/search";
|
import type { CatalogSearchResult } from "@/features/catalog/domain/search";
|
||||||
import type { RootTab } from "@/types/catalog";
|
import type { RootTab } from "@/types/catalog";
|
||||||
@@ -164,6 +168,12 @@ function CatalogManagerInner({
|
|||||||
totals?: CatalogStatsTotals | null;
|
totals?: CatalogStatsTotals | null;
|
||||||
}) {
|
}) {
|
||||||
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
|
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 catQs = catalogTreeQs(catalogType);
|
||||||
const rootRequests = useLatestRequest();
|
const rootRequests = useLatestRequest();
|
||||||
const searchNavigation = useLatestRequest();
|
const searchNavigation = useLatestRequest();
|
||||||
@@ -518,7 +528,7 @@ function CatalogManagerInner({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatsBar totals={totals} />
|
<StatsBar totals={liveTotals ?? totals} />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -17,11 +17,11 @@ import {
|
|||||||
Wand2,
|
Wand2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
type OrganizedPageChange,
|
||||||
type OrganizeImportGroup as OrganizeGroupPayload,
|
type OrganizeImportGroup as OrganizeGroupPayload,
|
||||||
organizeImportFurni,
|
organizeImportFurni,
|
||||||
} from "@/actions/catalog";
|
} from "@/actions/catalog";
|
||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog";
|
||||||
import {
|
import {
|
||||||
CATALOG_LAYOUTS,
|
CATALOG_LAYOUTS,
|
||||||
type CatalogLayout,
|
type CatalogLayout,
|
||||||
@@ -155,7 +156,6 @@ export function OrganizeImportsDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: OrganizeImportsDialogProps) {
|
}: OrganizeImportsDialogProps) {
|
||||||
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
|
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadError, setLoadError] = useState(false);
|
const [loadError, setLoadError] = useState(false);
|
||||||
@@ -683,8 +683,7 @@ export function OrganizeImportsDialog({
|
|||||||
const runs = chunkGroups(payload);
|
const runs = chunkGroups(payload);
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
try {
|
try {
|
||||||
const created: Array<{ pageId: number; moved: number; added: number }> =
|
const created: OrganizedPageChange[] = [];
|
||||||
[];
|
|
||||||
let failedRuns = 0;
|
let failedRuns = 0;
|
||||||
for (const run of runs) {
|
for (const run of runs) {
|
||||||
const result = await organizeImportFurni({
|
const result = await organizeImportFurni({
|
||||||
@@ -707,6 +706,34 @@ export function OrganizeImportsDialog({
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore storage errors
|
// 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) {
|
if (failedRuns > 0) {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -733,7 +760,6 @@ export function OrganizeImportsDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[OrganizeImports] create failed:", error);
|
console.error("[OrganizeImports] create failed:", error);
|
||||||
toast.error(t("errorFailed"));
|
toast.error(t("errorFailed"));
|
||||||
|
|||||||
@@ -63,11 +63,15 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
ensureCatalogTreeLoaded,
|
||||||
|
refreshCatalogTree,
|
||||||
|
useLiveCatalog,
|
||||||
|
} from "@/features/catalog/client/use-live-catalog";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { readSseStream } from "@/lib/sse-client";
|
import { readSseStream } from "@/lib/sse-client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { TreeNode } from "@/types/catalog";
|
|
||||||
import { BatchProgress } from "./batch-progress";
|
import { BatchProgress } from "./batch-progress";
|
||||||
import { CatalogRail } from "./catalog-rail";
|
import { CatalogRail } from "./catalog-rail";
|
||||||
import { CheckboxDot } from "./checkbox-dot";
|
import { CheckboxDot } from "./checkbox-dot";
|
||||||
@@ -169,12 +173,10 @@ const TABLE_ROW_H = 40;
|
|||||||
export function StudioClient({
|
export function StudioClient({
|
||||||
actorId,
|
actorId,
|
||||||
source,
|
source,
|
||||||
initialTree,
|
|
||||||
defaultTranslate,
|
defaultTranslate,
|
||||||
}: {
|
}: {
|
||||||
actorId?: number;
|
actorId?: number;
|
||||||
source: FurniImportSource;
|
source: FurniImportSource;
|
||||||
initialTree: TreeNode[];
|
|
||||||
defaultTranslate: boolean;
|
defaultTranslate: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [review, setReview] = useState<{
|
const [review, setReview] = useState<{
|
||||||
@@ -191,12 +193,14 @@ export function StudioClient({
|
|||||||
actorId,
|
actorId,
|
||||||
);
|
);
|
||||||
const [preparingReview, setPreparingReview] = useState(false);
|
const [preparingReview, setPreparingReview] = useState(false);
|
||||||
// Catalog tree
|
// Catalog tree — read from the live store so pages created by an import show
|
||||||
const [tree] = useState<TreeNode[]>(initialTree);
|
// up without re-rendering the route.
|
||||||
|
const tree = useLiveCatalog().tree;
|
||||||
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
||||||
const [railOpen, setRailOpen] = useState(false);
|
const [railOpen, setRailOpen] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
|
setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
|
||||||
|
void ensureCatalogTreeLoaded();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Search & list
|
// Search & list
|
||||||
@@ -957,6 +961,10 @@ export function StudioClient({
|
|||||||
setBatchCancelling(false);
|
setBatchCancelling(false);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
fetchStats();
|
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();
|
if (!batchDoneRef.current) markBatchDone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<TreeNode> & { 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<CreatedCatalogPage> & { 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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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<number, number>();
|
||||||
|
const chain = new Set<number>();
|
||||||
|
|
||||||
|
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<number, number> {
|
||||||
|
const deltas = new Map<number, number>();
|
||||||
|
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<number, number>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<CatalogMode, CatalogTotals | null>;
|
||||||
|
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<void> {
|
||||||
|
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<void> | 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<void> {
|
||||||
|
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<void> {
|
||||||
|
return snapshot.treeLoaded ? Promise.resolve() : refreshCatalogTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: drop all live catalog state between cases. */
|
||||||
|
export function resetLiveCatalogForTests(): void {
|
||||||
|
treeRequest = null;
|
||||||
|
emit(EMPTY_SNAPSHOT);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user