Compare commits
2
Commits
7507c3b55c
...
4be7eaed59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4be7eaed59 | ||
|
|
9cc57cddfc |
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
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertParent,
|
||||
collectSubtree,
|
||||
nextFreeSiblingOrder,
|
||||
validateSiblingOrder,
|
||||
} from "./hierarchy";
|
||||
|
||||
@@ -66,3 +67,37 @@ it("orders mixed root conventions with a stable ID tie breaker", () => {
|
||||
expect(() => validateSiblingOrder(roots, -1, [3, 2], [2, 3])).not.toThrow();
|
||||
expect(() => validateSiblingOrder(roots, -1, [2, 3], [3, 2])).toThrow();
|
||||
});
|
||||
|
||||
describe("nextFreeSiblingOrder", () => {
|
||||
it("keeps an explicit order that no sibling holds", () => {
|
||||
expect(nextFreeSiblingOrder(pages, 1, 7)).toBe(7);
|
||||
// Parent 1's siblings sit at order 1 and 2, so 0 is still free.
|
||||
expect(nextFreeSiblingOrder(pages, 1, 0)).toBe(0);
|
||||
});
|
||||
it("appends instead of duplicating a taken order", () => {
|
||||
// Requesting an order a sibling already holds would make the emulator
|
||||
// reject the whole catalog, so the page lands after the last sibling.
|
||||
expect(nextFreeSiblingOrder(pages, 1, 1)).toBe(3);
|
||||
expect(nextFreeSiblingOrder(pages, 1, 2)).toBe(3);
|
||||
expect(nextFreeSiblingOrder(pages, 2, 1)).toBe(2);
|
||||
});
|
||||
it("appends when the caller gives no order at all", () => {
|
||||
expect(nextFreeSiblingOrder(pages, 1)).toBe(3);
|
||||
expect(nextFreeSiblingOrder(pages, 2)).toBe(2);
|
||||
});
|
||||
it("counts parent -1 and parent 0 as the same sibling set", () => {
|
||||
expect(
|
||||
nextFreeSiblingOrder([{ id: 2, parentId: 0, orderNum: 1 }], -1, 1),
|
||||
).toBe(2);
|
||||
expect(
|
||||
nextFreeSiblingOrder([{ id: 2, parentId: -1, orderNum: 1 }], 0, 1),
|
||||
).toBe(2);
|
||||
});
|
||||
it("treats a missing orderNum as 0 when looking for collisions", () => {
|
||||
expect(nextFreeSiblingOrder([{ id: 5, parentId: 1 }], 1, 0)).toBe(1);
|
||||
});
|
||||
it("gives the first child of an empty parent the requested order", () => {
|
||||
expect(nextFreeSiblingOrder([], -1, 0)).toBe(0);
|
||||
expect(nextFreeSiblingOrder([], -1)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,12 @@ export interface HierarchyPage {
|
||||
parentId: number;
|
||||
orderNum?: number;
|
||||
}
|
||||
/** Root pages live under parent -1 or 0; both address the same sibling set. */
|
||||
function isSiblingOf(page: HierarchyPage, parentId: number) {
|
||||
return parentId <= 0
|
||||
? page.parentId === -1 || page.parentId === 0
|
||||
: page.parentId === parentId;
|
||||
}
|
||||
export class CatalogConflict extends Error {}
|
||||
export class CatalogInputError extends Error {}
|
||||
export class CatalogNotFound extends Error {}
|
||||
@@ -58,11 +64,7 @@ export function validateSiblingOrder(
|
||||
expected: readonly number[],
|
||||
) {
|
||||
const current = pages
|
||||
.filter((page) =>
|
||||
parentId <= 0
|
||||
? page.parentId === -1 || page.parentId === 0
|
||||
: page.parentId === parentId,
|
||||
)
|
||||
.filter((page) => isSiblingOf(page, parentId))
|
||||
.sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0) || a.id - b.id)
|
||||
.map((page) => page.id);
|
||||
if (
|
||||
@@ -81,3 +83,27 @@ export function validateSiblingOrder(
|
||||
"A reorder must include every sibling exactly once",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the order number a newly created page should take under `parentId`.
|
||||
*
|
||||
* The emulator refuses to load a catalog whose siblings share an order number
|
||||
* ("sibling order N is used more than once"), yet nearly every creation path
|
||||
* passes a fixed 0. Honour an explicit request while it is still free, and
|
||||
* otherwise append after the last sibling so creating a page can never
|
||||
* introduce a duplicate in the first place.
|
||||
*/
|
||||
export function nextFreeSiblingOrder(
|
||||
pages: readonly HierarchyPage[],
|
||||
parentId: number,
|
||||
requested?: number,
|
||||
): number {
|
||||
const orders = pages
|
||||
.filter((page) => isSiblingOf(page, parentId))
|
||||
.map((page) => page.orderNum ?? 0);
|
||||
const highest = orders.length ? Math.max(...orders) : 0;
|
||||
if (requested === undefined || !Number.isFinite(requested))
|
||||
return highest + 1;
|
||||
const wanted = Number(requested);
|
||||
return orders.includes(wanted) ? highest + 1 : wanted;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CatalogNotFound,
|
||||
collectSubtree,
|
||||
type HierarchyPage,
|
||||
nextFreeSiblingOrder,
|
||||
positiveId,
|
||||
validateSiblingOrder,
|
||||
} from "../domain/hierarchy";
|
||||
@@ -102,9 +103,13 @@ export async function createPageCommand(
|
||||
if (!data.caption) throw new CatalogInputError("Category name is required");
|
||||
return db.transaction(async (tx) => {
|
||||
const rows = await lockedPages(tx, kind);
|
||||
assertParent(rows, 0, data.parentId ?? -1);
|
||||
const parentId = data.parentId ?? -1;
|
||||
assertParent(rows, 0, parentId);
|
||||
const assignments = fieldsSql(kind, {
|
||||
...data,
|
||||
// Never trust a caller-supplied order number: the emulator rejects a
|
||||
// catalog where two siblings share one.
|
||||
orderNum: nextFreeSiblingOrder(rows, parentId, data.orderNum),
|
||||
captionSave: data.captionSave ?? data.caption?.slice(0, 25),
|
||||
});
|
||||
const [result] = await tx.execute(
|
||||
|
||||
Reference in new issue
Block a user