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 />
|
||||
<StudioClient
|
||||
source={buildFurniImportSource("it")}
|
||||
initialTree={[]}
|
||||
defaultTranslate={false}
|
||||
/>
|
||||
</>
|
||||
|
||||
+61
-16
File diff suppressed because it is too large.
Load diff
@@ -400,6 +400,10 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
const [busyClassname, setBusyClassname] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "missing" | "present">("all");
|
||||
const batchAbortRef = useRef<AbortController | null>(null);
|
||||
// Classnames the server already has, mirroring `items`. Lets markDone flip a
|
||||
// card and adjust the counters without a refetch, and idempotently — React
|
||||
// may run a state updater twice in dev.
|
||||
const presentRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async (
|
||||
@@ -426,6 +430,11 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
setItems(data.items || []);
|
||||
setMeta(data.meta || null);
|
||||
setStats(data.stats || null);
|
||||
presentRef.current = new Set(
|
||||
(data.items || [])
|
||||
.filter((it: FurniItem) => it.present)
|
||||
.map((it: FurniItem) => it.classname),
|
||||
);
|
||||
setSelected(new Set());
|
||||
} catch {
|
||||
setError("Network error");
|
||||
@@ -479,6 +488,19 @@ function FurniGrid({ source }: FurniGridProps) {
|
||||
}
|
||||
|
||||
function markDone(classname: string) {
|
||||
// Counters move as each clone lands, so total/present/clonable stay
|
||||
// truthful for the whole run instead of jumping at the end.
|
||||
if (presentRef.current.has(classname)) return;
|
||||
presentRef.current.add(classname);
|
||||
setStats((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
present: prev.present + 1,
|
||||
clonable: Math.max(0, prev.clonable - 1),
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.classname === classname ? { ...it, present: true } : it,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
|
||||
import { StudioClient } from "@/components/admin/studio/studio-client";
|
||||
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { getTreeFlat } from "@/lib/services/catalog-tree";
|
||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -14,9 +13,11 @@ export default async function StudioFurniPage(_props: {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const [hotel, catalogTree, translateEnabled] = await Promise.all([
|
||||
// The catalog tree is not read here: StudioClient pulls it from the live
|
||||
// catalog store, which an import updates in place instead of forcing a
|
||||
// route re-render.
|
||||
const [hotel, translateEnabled] = await Promise.all([
|
||||
getHabboGamedataHotel(),
|
||||
getTreeFlat(),
|
||||
siteSettings.getBool("furnidata_translate_enabled", true),
|
||||
]);
|
||||
|
||||
@@ -24,7 +25,6 @@ export default async function StudioFurniPage(_props: {
|
||||
<StudioClient
|
||||
actorId={Number(session.user.id)}
|
||||
source={buildFurniImportSource(hotel)}
|
||||
initialTree={catalogTree}
|
||||
defaultTranslate={translateEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatsBar totals={totals} />
|
||||
<StatsBar totals={liveTotals ?? totals} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -63,11 +63,15 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
ensureCatalogTreeLoaded,
|
||||
refreshCatalogTree,
|
||||
useLiveCatalog,
|
||||
} from "@/features/catalog/client/use-live-catalog";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { readSseStream } from "@/lib/sse-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TreeNode } from "@/types/catalog";
|
||||
import { BatchProgress } from "./batch-progress";
|
||||
import { CatalogRail } from "./catalog-rail";
|
||||
import { CheckboxDot } from "./checkbox-dot";
|
||||
@@ -169,12 +173,10 @@ const TABLE_ROW_H = 40;
|
||||
export function StudioClient({
|
||||
actorId,
|
||||
source,
|
||||
initialTree,
|
||||
defaultTranslate,
|
||||
}: {
|
||||
actorId?: number;
|
||||
source: FurniImportSource;
|
||||
initialTree: TreeNode[];
|
||||
defaultTranslate: boolean;
|
||||
}) {
|
||||
const [review, setReview] = useState<{
|
||||
@@ -191,12 +193,14 @@ export function StudioClient({
|
||||
actorId,
|
||||
);
|
||||
const [preparingReview, setPreparingReview] = useState(false);
|
||||
// Catalog tree
|
||||
const [tree] = useState<TreeNode[]>(initialTree);
|
||||
// Catalog tree — read from the live store so pages created by an import show
|
||||
// up without re-rendering the route.
|
||||
const tree = useLiveCatalog().tree;
|
||||
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
||||
const [railOpen, setRailOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
|
||||
void ensureCatalogTreeLoaded();
|
||||
}, []);
|
||||
|
||||
// Search & list
|
||||
@@ -957,6 +961,10 @@ export function StudioClient({
|
||||
setBatchCancelling(false);
|
||||
setSelected(new Set());
|
||||
fetchStats();
|
||||
// The importers derive their "Imported Furniture" category pages from
|
||||
// furnidata, so the client cannot predict which pages appeared. Pull the
|
||||
// tree over the API instead of re-rendering the route.
|
||||
void refreshCatalogTree();
|
||||
if (!batchDoneRef.current) markBatchDone();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user