From e47bee16bbd7aba4cd9e84405f1e0fcffe99fe01 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 11 Sep 2026 23:41:23 +0200 Subject: [PATCH] feat(studio): add visual layout previews, automated unit tests and polish organize imports --- .../admin/studio/organize-imports-dialog.tsx | 1223 +++++++++-------- src/features/catalog/domain/organize.test.ts | 14 + 2 files changed, 684 insertions(+), 553 deletions(-) diff --git a/src/components/admin/studio/organize-imports-dialog.tsx b/src/components/admin/studio/organize-imports-dialog.tsx index b94d6bbf..08725839 100644 --- a/src/components/admin/studio/organize-imports-dialog.tsx +++ b/src/components/admin/studio/organize-imports-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { + Eye, Folder, FolderTree, Home, @@ -38,6 +39,7 @@ import { CATALOG_LAYOUTS, type CatalogLayout, isValidCatalogLayout, + LAYOUT_DESCRIPTIONS, } from "@/lib/catalog-layouts"; // import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts"; import { getLocalIconUrl } from "@/lib/furni/classname"; @@ -112,12 +114,18 @@ export function OrganizeImportsDialog({ const [overrides, setOverrides] = useState< Record >({}); + const [previewGroup, setPreviewGroup] = useState( + null, + ); const [isCreating, setIsCreating] = useState(false); const [freeMode, setFreeMode] = useState(true); const [credits, setCredits] = useState(0); const [points, setPoints] = useState(0); const [pointsType, setPointsType] = useState(0); + const [groupSearchQuery, setGroupSearchQuery] = useState(""); + const [batchLayout, setBatchLayout] = useState("default_3x3"); + const [batchPrefix, setBatchPrefix] = useState(""); const load = useCallback(async () => { setLoading(true); @@ -238,6 +246,48 @@ export function OrganizeImportsDialog({ setOverrides((prev) => ({ ...prev, [name]: { ...prev[name], ...patch } })); }; + const applyBatchLayout = (layout: CatalogLayout) => { + setOverrides((prev) => { + const next = { ...prev }; + for (const g of groups) { + if (approved[g.name] !== false) { + next[g.name] = { ...next[g.name], layout }; + } + } + return next; + }); + }; + + const applyBatchPrefix = () => { + if (!batchPrefix.trim()) return; + setOverrides((prev) => { + const next = { ...prev }; + for (const g of groups) { + if (approved[g.name] !== false) { + const currentCaption = next[g.name]?.caption ?? g.name; + if (!currentCaption.startsWith(batchPrefix)) { + next[g.name] = { + ...next[g.name], + caption: `${batchPrefix}${currentCaption}`, + }; + } + } + } + return next; + }); + }; + + const filteredGroups = useMemo(() => { + const q = groupSearchQuery.trim().toLowerCase(); + if (!q) return groups; + return groups.filter((g) => { + if (g.name.toLowerCase().includes(q)) return true; + const caption = overrides[g.name]?.caption?.toLowerCase(); + if (caption?.includes(q)) return true; + return g.thumbs.some((t) => t.itemName.toLowerCase().includes(q)); + }); + }, [groups, groupSearchQuery, overrides]); + const handleCreate = async () => { if (isCreating) return; if (moveMode && destinationPageId == null) { @@ -297,600 +347,667 @@ export function OrganizeImportsDialog({ }; return ( - - - - - - {t("title")} - {totalSelected > 0 && !loading && ( - - {t("itemsBadge", { count: totalSelected })} - - )} - - - -
- {/* Price (new offers only) */} - {!loading && groups.length > 0 && ( -
- - {t("price")} - - - - {!freeMode && ( - <> - - - - {t("pointsType")} - - - + <> + + + + + + {t("title")} + {totalSelected > 0 && !loading && ( + + {t("itemsBadge", { count: totalSelected })} + )} -

- {t("newPriceHint")} -

-
- )} + + - {/* Mode: create new pages vs move into an existing page */} - {!loading && groups.length > 0 && ( -
- - {t("mode")} - - - -
- )} - - {/* Destination page (move mode) */} - {moveMode && !loading && groups.length > 0 && ( -
- {t("destination")} - {destinationPageId != null ? ( -
- - - {destinationCaption} - - - {t("destinationChosen")} - - -
- ) : ( -
- - setDestQuery(e.target.value)} - /> - {destSearchLoading && ( - - )} -
- )} - {destinationPageId == null && destDebounced.length >= 2 && ( -
- {destResults.length === 0 && !destSearchLoading ? ( -

- {t("noDestResults")} -

- ) : ( - destResults.map((p) => ( - + + {!freeMode && ( + <> + + + + {t("pointsType")} + setParentId(Number(v))} - > - - - - - - - - {t("root")} + + + + + + Duckets + + + Diamonds + + + - - {importRootPageId != null && ( - - - - {importRootCaption ?? t("importedRoot")} - - - )} - {parentOptions.map((p) => ( - - - - {p.caption} - - - ))} - - -
- )} + + )} +

+ {t("newPriceHint")} +

+
+ )} - {/* Loading / empty / groups */} - {loading ? ( -
- - {t("loading")} -
- ) : groups.length === 0 ? ( -
- {loadError ? t("loadError") : t("empty")} - -
- ) : ( - <> -
-
-
+ {/* Mode: create new pages vs move into an existing page */} + {!loading && groups.length > 0 && ( +
+ + {t("mode")} + + + +
+ )} + + {/* Destination page (move mode) */} + {moveMode && !loading && groups.length > 0 && ( +
+ {t("destination")} + {destinationPageId != null ? ( +
+ + + {destinationCaption} + + + {t("destinationChosen")} + + +
+ ) : ( +
setGroupSearchQuery(e.target.value)} + placeholder={t("destinationSearchPlaceholder")} + value={destQuery} + onChange={(e) => setDestQuery(e.target.value)} /> + {destSearchLoading && ( + + )}
- -
+ )} + {destinationPageId == null && destDebounced.length >= 2 && ( +
+ {destResults.length === 0 && !destSearchLoading ? ( +

+ {t("noDestResults")} +

+ ) : ( + destResults.map((p) => ( + + )) + )} +
+ )} +

+ {t("destinationHint")} +

+
+ )} - {/* Batch Actions Bar */} -
- - Batch: - - + {/* Parent */} + {!moveMode && !loading && groups.length > 0 && ( +
+ {t("parent")} + +
+ )} -
- setBatchPrefix(e.target.value)} - /> + {/* Loading / empty / groups */} + {loading ? ( +
+ + {t("loading")} +
+ ) : groups.length === 0 ? ( +
+ {loadError ? t("loadError") : t("empty")} + +
+ ) : ( + <> +
+
+
+ + setGroupSearchQuery(e.target.value)} + /> +
-
-
- {groups.map((group) => { - const o = overrides[group.name] ?? {}; - const caption = o.caption ?? group.name; - const icon = o.icon ?? group.icon; - const layout = o.layout ?? group.layout ?? "default_3x3"; - const isApproved = approved[group.name] !== false; - const moved = group.moved; - const added = group.added; - return ( - + ); + })} + )} - {moveMode - ? t("move", { count: totalSelected }) - : t("create", { count: totalSelected })} - -
- -
+ + +
+ + +
+ + + + {/* Visual Preview Sub-Dialog */} + !open && setPreviewGroup(null)} + > + + + + + {previewGroup?.name} + + + {previewGroup && ( +
+
+
+ {t("layout")}:{" "} + + {previewGroup.layout ?? "default_3x3"} + +
+ + {previewGroup.total} items + +
+ {previewGroup.layout && + LAYOUT_DESCRIPTIONS[previewGroup.layout] && ( +

+ {LAYOUT_DESCRIPTIONS[previewGroup.layout]} +

+ )} +
+
+ {previewGroup.thumbs.map((thumb) => ( +
+ + + {thumb.itemName} + +
+ ))} +
+
+
+ )} +
+
+ ); } diff --git a/src/features/catalog/domain/organize.test.ts b/src/features/catalog/domain/organize.test.ts index 18acee14..d9673fcd 100644 --- a/src/features/catalog/domain/organize.test.ts +++ b/src/features/catalog/domain/organize.test.ts @@ -289,4 +289,18 @@ describe("groupSuggestedCategories", () => { ])[0]; expect(group?.name).toBe("Trophies"); }); + + it("detects special keyword groups like NFTs, Dragons, and Lasers", () => { + const groups = groupSuggestedCategories([ + entry(1, "nft_trophy_gold"), + entry(2, "nft_avatar_statue"), + entry(3, "dragon_fire_lamp"), + entry(4, "laser_gate_cyan"), + ]); + expect(groups.map((g) => g.name)).toContain("NFTs"); + expect(groups.map((g) => g.name)).toContain("Dragons"); + expect(groups.map((g) => g.name)).toContain("Lasers"); + const nftGroup = groups.find((g) => g.name === "NFTs"); + expect(nftGroup?.items.map((i) => i.id)).toEqual([1, 2]); + }); });