From 1bbd809b431ea946bf03e10815a333bbb7869803 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 14 Sep 2026 17:20:52 +0200 Subject: [PATCH] Replace standalone catalog editor with the unified Visual Catalog studio Make /admin/catalog a full-screen catalog studio that replaces the old listing plus separate [id]/builder-club detail pages: - Embed CatalogManagerWorkspace on /admin/catalog with a Normal/Builder Club toggle, Catalog Sync status, packages (normal), Organize imports and a diagnostics link to /admin/studio/maintenance. - Manage BC items directly in the studio Items tab (new BcItemsEditor, CRUD via existing bc actions; /api/admin/catalog/items now serves BC). - Inline editor: add pageTextTeaser field for both catalogs and remove the legacy full-editor links. - Remove the 'Open full editor' context action from the tree. - Move catalog-items-table (dir + barrel) and catalog-translate-tab out of the app route into src/components/admin/catalog and update all importers. - Keep /admin/catalog/[id], builder-club/[id] and /admin/catalog/maintenance as redirects into the new studio; consolidate maintenance panels into /admin/studio/maintenance and point the nav item there. - Delete the old listing/table/tabs/forms and the standalone bc-manager. --- .../catalog/[id]/catalog-detail-tabs.tsx | 128 --- .../admin/catalog/[id]/catalog-page-form.tsx | 746 ------------------ src/app/admin/catalog/[id]/loading.tsx | 44 -- src/app/admin/catalog/[id]/page.tsx | 117 +-- .../admin/catalog/builder-club/[id]/page.tsx | 64 +- .../admin/catalog/builder-club/bc-manager.tsx | 608 -------------- src/app/admin/catalog/catalog-actions.tsx | 158 ---- src/app/admin/catalog/catalog-table.tsx | 211 ----- src/app/admin/catalog/catalog-view-toggle.tsx | 107 --- src/app/admin/catalog/maintenance/page.tsx | 27 +- src/app/admin/catalog/page.tsx | 601 +++----------- src/app/admin/studio/maintenance/page.tsx | 23 +- src/app/api/admin/catalog/items/route.ts | 11 + .../catalog-manager/auto-category-dialog.tsx | 2 +- .../admin/catalog-manager/auto-organize.tsx | 2 +- .../admin/catalog-manager/bc-items-editor.tsx | 333 ++++++++ .../catalog-manager-dialog.tsx | 2 +- .../admin/catalog-manager/inline-editor.tsx | 51 +- .../catalog-manager/live-catalog-preview.tsx | 2 +- .../admin/catalog-manager/sortable-tree.tsx | 10 +- .../admin/catalog-manager/tree-item.tsx | 7 - .../admin/catalog}/catalog-items-table.tsx | 0 .../catalog-items-table.tsx | 0 .../catalog-items-table/field-helpers.tsx | 0 .../catalog-items-table/furni-icon.tsx | 0 .../catalog-items-table/song-picker.tsx | 0 .../catalog}/catalog-items-table/types.ts | 0 .../admin/catalog}/catalog-translate-tab.tsx | 0 .../admin/catalog/items-shop-preview.tsx | 2 +- src/components/admin/catalog/shop-tile.tsx | 4 +- .../catalog/components/bulk-offer-editor.tsx | 4 +- .../components/duplicate-category-dialog.tsx | 2 +- .../catalog/domain/offer-discovery.ts | 2 +- src/features/catalog/domain/organize.test.ts | 2 +- src/features/catalog/domain/organize.ts | 2 +- src/lib/admin-nav.ts | 4 +- 36 files changed, 526 insertions(+), 2750 deletions(-) delete mode 100644 src/app/admin/catalog/[id]/catalog-detail-tabs.tsx delete mode 100644 src/app/admin/catalog/[id]/catalog-page-form.tsx delete mode 100644 src/app/admin/catalog/[id]/loading.tsx delete mode 100644 src/app/admin/catalog/builder-club/bc-manager.tsx delete mode 100644 src/app/admin/catalog/catalog-actions.tsx delete mode 100644 src/app/admin/catalog/catalog-table.tsx delete mode 100644 src/app/admin/catalog/catalog-view-toggle.tsx create mode 100644 src/components/admin/catalog-manager/bc-items-editor.tsx rename src/{app/admin/catalog/[id] => components/admin/catalog}/catalog-items-table.tsx (100%) rename src/{app/admin/catalog/[id] => components/admin/catalog}/catalog-items-table/catalog-items-table.tsx (100%) rename src/{app/admin/catalog/[id] => components/admin/catalog}/catalog-items-table/field-helpers.tsx (100%) rename src/{app/admin/catalog/[id] => components/admin/catalog}/catalog-items-table/furni-icon.tsx (100%) rename src/{app/admin/catalog/[id] => components/admin/catalog}/catalog-items-table/song-picker.tsx (100%) rename src/{app/admin/catalog/[id] => components/admin/catalog}/catalog-items-table/types.ts (100%) rename src/{app/admin/catalog/[id] => components/admin/catalog}/catalog-translate-tab.tsx (100%) diff --git a/src/app/admin/catalog/[id]/catalog-detail-tabs.tsx b/src/app/admin/catalog/[id]/catalog-detail-tabs.tsx deleted file mode 100644 index c68756dc..00000000 --- a/src/app/admin/catalog/[id]/catalog-detail-tabs.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import { Languages, Package, Settings } from "lucide-react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { type CatalogItemData, CatalogItemsTable } from "./catalog-items-table"; -import { CatalogPageForm } from "./catalog-page-form"; -import { CatalogTranslateTab } from "./catalog-translate-tab"; - -interface CatalogPageData { - id: number; - caption: string; - parentId: number; - pageLayout: string; - enabled: string; - visible: string; - minRank: number; - clubOnly: string; - orderNum: number; - iconImage: number; - iconColor: number; - pageHeadline: string; - pageTeaser: string; - pageSpecial: string | null; - pageText1: string | null; - pageText2: string | null; - pageTextDetails: string | null; - pageTextTeaser: string | null; -} - -interface BaseItemData { - id: number; - publicName: string; - itemName: string; - spriteId: number; - type: string; -} - -interface CatalogDetailTabsProps { - catalogPage: CatalogPageData; - items: CatalogItemData[]; - baseItems: BaseItemData[]; - catalogNameMap: Record; - childPages: { id: number; caption: string; enabled: string }[]; - pageId: number; - canEdit: boolean; - furniDataIdList: number[]; - furniDescriptionMap: Record; - furniRevisionMap: Record; - allPages: { id: number; caption: string }[]; - interactionTypes: string[]; - gamedataHotel: string; -} - -export function CatalogDetailTabs({ - catalogPage, - items, - baseItems, - catalogNameMap, - childPages, - pageId, - canEdit, - furniDataIdList, - furniDescriptionMap, - furniRevisionMap, - allPages, - interactionTypes, - gamedataHotel, -}: CatalogDetailTabsProps) { - return ( - - - - - Settings - - - - Items ({items.length}) - - - - Translate ({baseItems.length}){(() => { - const furniSet = new Set(furniDataIdList); - const missing = baseItems.filter((b) => !furniSet.has(b.id)).length; - return missing > 0 ? ( - - {missing} - - ) : null; - })()} - - - - - - - - - - - - - - - - ); -} diff --git a/src/app/admin/catalog/[id]/catalog-page-form.tsx b/src/app/admin/catalog/[id]/catalog-page-form.tsx deleted file mode 100644 index cfaddb9d..00000000 --- a/src/app/admin/catalog/[id]/catalog-page-form.tsx +++ /dev/null @@ -1,746 +0,0 @@ -"use client"; - -import { - Check, - ChevronsUpDown, - FileText, - FolderTree, - Loader2, - Package, - Palette, - Save, - Settings, - Trash2, -} from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { deleteCatalogPage, updateCatalogPage } from "@/actions/catalog"; -import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker"; -import { IconPicker } from "@/components/admin/catalog/icon-picker"; -import { ImagePreview } from "@/components/admin/catalog-manager/image-preview"; -import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree"; -import { useConfirmDialog } from "@/components/admin/confirm-dialog"; -import Link from "@/components/link"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; -import { useServerAction } from "@/hooks/use-server-action"; -import { - CATALOG_LAYOUTS, - type CatalogLayout, - LAYOUT_DESCRIPTIONS, -} from "@/lib/catalog-layouts"; -import { cn } from "@/lib/utils"; - -interface CatalogPageData { - id: number; - caption: string; - parentId: number; - pageLayout: string; - enabled: string; - visible: string; - minRank: number; - clubOnly: string; - orderNum: number; - iconImage: number; - iconColor: number; - pageHeadline: string; - pageTeaser: string; - pageSpecial: string | null; - pageText1: string | null; - pageText2: string | null; - pageTextDetails: string | null; - pageTextTeaser: string | null; -} - -interface CatalogPageFormProps { - catalogPage: CatalogPageData; - childPages: { id: number; caption: string; enabled: string }[]; - canEdit: boolean; - allPages: { id: number; caption: string }[]; -} - -export function CatalogPageForm({ - catalogPage, - childPages, - canEdit, - allPages, -}: CatalogPageFormProps) { - const { isPending, run } = useServerAction(); - const { confirm, dialog: confirmDialog } = useConfirmDialog(); - const formRef = useRef(null); - - const [form, setForm] = useState({ - caption: catalogPage.caption, - parentId: catalogPage.parentId, - pageLayout: catalogPage.pageLayout, - enabled: catalogPage.enabled as "0" | "1", - visible: catalogPage.visible as "0" | "1", - minRank: catalogPage.minRank, - clubOnly: catalogPage.clubOnly as "0" | "1", - orderNum: catalogPage.orderNum, - iconImage: catalogPage.iconImage, - iconColor: catalogPage.iconColor, - pageHeadline: catalogPage.pageHeadline, - pageTeaser: catalogPage.pageTeaser, - pageSpecial: catalogPage.pageSpecial || "", - pageText1: catalogPage.pageText1 || "", - pageText2: catalogPage.pageText2 || "", - pageTextDetails: catalogPage.pageTextDetails || "", - pageTextTeaser: catalogPage.pageTextTeaser || "", - }); - - // Ctrl+S / Cmd+S to save - useEffect(() => { - if (!canEdit) return; - function onKeyDown(e: KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.key === "s") { - e.preventDefault(); - formRef.current?.requestSubmit(); - } - } - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [canEdit]); - - function update( - key: K, - value: (typeof form)[K], - ) { - setForm((prev) => ({ ...prev, [key]: value })); - } - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - run( - () => - updateCatalogPage({ - id: catalogPage.id, - ...form, - expected: catalogPage as unknown as Record, - }), - { - successMessage: "Page saved.", - errorMessage: "Failed to update.", - }, - ); - } - - async function handleDelete() { - const childCount = childPages.length; - const description = - childCount > 0 - ? `Delete this catalog page? Its ${childCount} child page(s) will be moved to the parent. All items on this page will be deleted.` - : "Delete this catalog page and all its items?"; - const ok = await confirm({ - title: "Delete catalog page", - description, - confirmLabel: "Delete", - }); - if (!ok) return; - run(() => deleteCatalogPage({ id: catalogPage.id }), { - successMessage: "Page deleted.", - redirectTo: "/admin/catalog", - }); - } - - const layoutColorClass = - LAYOUT_COLORS[form.pageLayout] || "bg-muted text-muted-foreground"; - - return ( - <> - {confirmDialog} -
-
- {/* ── Left column (2/3): tabs ─────────────────────────── */} -
- - - - - - - General - - - - Texts - - - - Appearance - - - - {/* ── General ──────────────────────────────────── */} - -
-
- - update("caption", e.target.value)} - disabled={!canEdit} - /> -
-
- - update("parentId", id)} - pages={allPages} - currentPageId={catalogPage.id} - disabled={!canEdit} - /> -
-
- -
- - update("pageLayout", v)} - disabled={!canEdit} - /> -

- {LAYOUT_DESCRIPTIONS[ - form.pageLayout as CatalogLayout - ] ?? "Custom layout"} -

-
- -
-
- - - update( - "orderNum", - parseInt(e.target.value, 10) || 0, - ) - } - disabled={!canEdit} - /> -
-
- - - update("minRank", parseInt(e.target.value, 10) || 1) - } - disabled={!canEdit} - /> -
-
-
- - {/* ── Texts ────────────────────────────────────── */} - -
- - update("pageHeadline", v)} - type="header" - label="Headline image" - disabled={!canEdit} - /> - -
-
- - update("pageTeaser", v)} - type="teaser" - label="Teaser image" - disabled={!canEdit} - /> - -
-
- update("pageText1", v)} - disabled={!canEdit} - rows={4} - max={5000} - /> - update("pageText2", v)} - disabled={!canEdit} - rows={4} - max={5000} - /> -
-
- update("pageTextDetails", v)} - disabled={!canEdit} - rows={3} - max={5000} - /> - update("pageTextTeaser", v)} - disabled={!canEdit} - rows={3} - max={5000} - /> -
- update("pageSpecial", v)} - disabled={!canEdit} - rows={2} - max={2048} - /> -
- - {/* ── Appearance ───────────────────────────────── */} - -
- -
- update("iconImage", id)} - disabled={!canEdit} - /> -

- Icon shown in the catalog tree navigation. -

-
-
-
- -
- - update( - "iconColor", - parseInt(e.target.value, 10) || 0, - ) - } - disabled={!canEdit} - /> -

- Legacy tint value. Leave at default unless you need to - match a specific theme. -

-
-
-
-
-
-
- - {/* Child pages list */} - {childPages.length > 0 && ( - - - - - Child Pages ({childPages.length}) - - - -
- {childPages.map((child) => ( - - - {child.caption} - - #{child.id} - - - - {child.enabled === "1" ? "Enabled" : "Disabled"} - - - ))} -
-
-
- )} -
- - {/* ── Right column (1/3): status sticky ────────────────── */} -
- - - Status & Preview - - - {/* Preview */} -
-
- -
-
-

- {form.caption || "Untitled"} -

- - {form.pageLayout} - -
-
- - {/* Toggles */} -
- update("enabled", v ? "1" : "0")} - disabled={!canEdit} - /> - update("visible", v ? "1" : "0")} - disabled={!canEdit} - /> - update("clubOnly", v ? "1" : "0")} - disabled={!canEdit} - /> -
- - {/* Stats */} -
- - - {childPages.length} sub - - - #{catalogPage.id} - -
- - {/* Actions */} - {canEdit && ( -
- - -

- Press Ctrl+S to - save -

-
- )} -
-
-
-
-
- - ); -} - -// ── Toggle row ───────────────────────────────────────────────── - -function ToggleRow({ - id, - label, - description, - checked, - onChange, - disabled, -}: { - id: string; - label: string; - description: string; - checked: boolean; - onChange: (v: boolean) => void; - disabled?: boolean; -}) { - return ( -
-
- -

{description}

-
- -
- ); -} - -function TextareaField({ - id, - label, - value, - onChange, - disabled, - rows = 3, - max, -}: { - id: string; - label: string; - value: string; - onChange: (v: string) => void; - disabled?: boolean; - rows?: number; - max?: number; -}) { - return ( -
-
- - {max && ( - - {value.length}/{max} - - )} -
-