Replace standalone catalog editor with the unified Visual Catalog studio
CI / check (push) Failing after 26s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

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.
This commit is contained in:
openhands committed 2026-09-14 17:20:52 +02:00
1 parent 1a9e1e791a
commit 1bbd809b43
36 files changed
+526 -2750

No files matched your search

@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { type CatalogKind, createAutoCategory } from "@/actions/catalog";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { useCatalogManager } from "@/components/admin/catalog-manager/use-catalog-store";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
@@ -4,7 +4,7 @@ import { ArrowDownUp, Loader2, Wand2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { reorderCatalogItems } from "@/actions/catalog-items";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -0,0 +1,333 @@
"use client";
import { Loader2, Package, Pencil, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
createBcItem,
deleteBcItem,
updateBcItem,
} from "@/actions/catalog-bc";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import { useServerAction } from "@/hooks/use-server-action";
interface BcItem {
id: number;
pageId: number;
itemIds: string;
catalogName: string;
orderNumber: number;
extradata: string;
}
interface BcItemsEditorProps {
pageId: number;
canEdit: boolean;
}
export function BcItemsEditor({ pageId, canEdit }: BcItemsEditorProps) {
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const requests = useLatestRequest();
const [items, setItems] = useState<BcItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [editingItem, setEditingItem] = useState<BcItem | null>(null);
const [addingItem, setAddingItem] = useState(false);
const load = useCallback(async () => {
const request = requests.start();
setError(false);
setLoading(true);
try {
const res = await fetch(
`/api/admin/catalog/items?pageId=${pageId}&catalog=bc`,
{ signal: request.signal },
);
if (!res.ok) throw new Error();
const data = await res.json();
if (!request.isCurrent()) return;
setItems(Array.isArray(data.items) ? data.items : []);
} catch {
if (!request.isCurrent()) return;
setError(true);
} finally {
if (request.isCurrent()) setLoading(false);
}
}, [pageId, requests]);
useEffect(() => {
if (pageId > 0) void load();
return () => requests.cancel();
}, [pageId, load, requests]);
return (
<div className="space-y-3">
{confirmDialog}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<Package className="h-4 w-4 text-muted-foreground" />
BC Items ({items.length})
</div>
{canEdit && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setAddingItem(true)}
>
<Plus className="h-3 w-3 mr-1" />
Add Item
</Button>
)}
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" size="sm" onClick={() => void load()}>
Retry
</Button>
</div>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8 rounded-md border border-dashed">
No items in this page.
</p>
) : (
<div className="rounded-md border overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs w-16">ID</TableHead>
<TableHead className="text-xs">Name</TableHead>
<TableHead className="text-xs">Item IDs</TableHead>
<TableHead className="text-xs w-20">Order</TableHead>
<TableHead className="text-xs">Extra Data</TableHead>
{canEdit && <TableHead className="text-xs w-20">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-xs font-mono">{item.id}</TableCell>
<TableCell className="text-xs font-medium">
{item.catalogName}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
{item.itemIds}
</TableCell>
<TableCell className="text-xs">
{item.orderNumber}
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-32 truncate">
{item.extradata || "\u2014"}
</TableCell>
{canEdit && (
<TableCell>
<div className="flex gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => setEditingItem(item)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-[var(--admin-error)] hover:text-[var(--admin-error)]"
onClick={async () => {
const ok = await confirm({
title: "Delete BC item",
description: `Delete item "${item.catalogName}"?`,
confirmLabel: "Delete",
});
if (!ok) return;
const result = await deleteBcItem({
id: item.id,
});
if (result.ok) {
setItems((prev) =>
prev.filter((i) => i.id !== item.id),
);
}
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{editingItem && (
<BcItemDialog
item={editingItem}
mode="edit"
onClose={() => setEditingItem(null)}
onSubmit={async (data) => {
const result = await updateBcItem({ id: editingItem.id, ...data });
if (result.ok) {
setItems((prev) =>
prev.map((i) =>
i.id === editingItem.id ? { ...i, ...data } : i,
),
);
}
return result;
}}
/>
)}
{addingItem && (
<BcItemDialog
item={{
id: 0,
pageId,
itemIds: "",
catalogName: "",
orderNumber: 1,
extradata: "",
}}
mode="add"
onClose={() => setAddingItem(false)}
onSubmit={async (data) => {
const result = await createBcItem({ pageId, ...data });
if (result.ok) await load();
return result;
}}
/>
)}
</div>
);
}
function BcItemDialog({
item,
mode,
onClose,
onSubmit,
}: {
item: BcItem;
mode: "add" | "edit";
onClose: () => void;
onSubmit: (data: {
itemIds: string;
catalogName: string;
orderNumber: number;
extradata: string;
}) => Promise<{ ok: boolean; error?: string }>;
}) {
const { run, isPending } = useServerAction();
const [form, setForm] = useState({
itemIds: item.itemIds,
catalogName: item.catalogName,
orderNumber: item.orderNumber,
extradata: item.extradata,
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{mode === "add" ? "Add BC Item" : `Edit Item #${item.id}`}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Catalog Name</Label>
<Input
value={form.catalogName}
onChange={(e) =>
setForm((f) => ({ ...f, catalogName: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Item IDs (semicolon-separated)</Label>
<Input
value={form.itemIds}
onChange={(e) =>
setForm((f) => ({ ...f, itemIds: e.target.value }))
}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Order</Label>
<Input
type="number"
value={form.orderNumber}
onChange={(e) =>
setForm((f) => ({
...f,
orderNumber: parseInt(e.target.value, 10) || 0,
}))
}
/>
</div>
<div className="space-y-2">
<Label>Extra Data</Label>
<Input
value={form.extradata}
onChange={(e) =>
setForm((f) => ({ ...f, extradata: e.target.value }))
}
placeholder="Optional"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button
onClick={() => {
run(() => onSubmit(form), {
successMessage:
mode === "add" ? "Item created." : "Item updated.",
errorMessage: "Failed to save.",
onSuccess: () => onClose(),
});
}}
disabled={isPending || !form.catalogName || !form.itemIds}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode === "add" ? "Add Item" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -47,7 +47,7 @@ export function CatalogManagerWorkspace({
return (
<section
aria-label={t("manager")}
className="flex h-[calc(100dvh-12rem)] min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]"
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]"
>
<CatalogManagerProvider
key={catalogType}
@@ -4,7 +4,6 @@ import {
AlertCircle,
ChevronDown,
ChevronRight,
ExternalLink,
Languages,
Loader2,
MonitorPlay,
@@ -20,16 +19,16 @@ import { updateBcPage } from "@/actions/catalog-bc";
import {
type CatalogItemData,
CatalogItemsTable,
} from "@/app/admin/catalog/[id]/catalog-items-table";
import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-tab";
} from "@/components/admin/catalog/catalog-items-table";
import { CatalogTranslateTab } from "@/components/admin/catalog/catalog-translate-tab";
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
import { AutoOrganize } from "@/components/admin/catalog-manager/auto-organize";
import { BcItemsEditor } from "@/components/admin/catalog-manager/bc-items-editor";
import { LiveCatalogPreview } from "@/components/admin/catalog-manager/live-catalog-preview";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -133,10 +132,6 @@ function InlineEditorSession({
const [offersDirty, setOffersDirty] = useState(false);
const { dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const pageHref =
catalogType === "bc"
? `/admin/catalog/builder-club/${pageId}`
: `/admin/catalog/${pageId}`;
const [page, setPage] = useState<PageDetail | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -425,17 +420,7 @@ function InlineEditorSession({
{t("preview.title")}
</Button>
)}
<Link href={pageHref} target="_blank">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1 text-[var(--admin-text)]"
>
<ExternalLink className="h-3 w-3" />
{t("fullEditor")}
</Button>
</Link>
</div>
</div>
</div>
{/* Dirty banner */}
@@ -671,7 +656,10 @@ function InlineEditorSession({
<ChevronRight className="h-3.5 w-3.5" />
)}
Text fields
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
{(form.pageText1 ||
form.pageText2 ||
form.pageTextDetails ||
form.pageTextTeaser) && (
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
has content
</Badge>
@@ -713,6 +701,17 @@ function InlineEditorSession({
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Text Teaser</Label>
<Textarea
rows={2}
value={String(form.pageTextTeaser || "")}
onChange={(e) =>
update("pageTextTeaser", e.target.value || null)
}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Special</Label>
<Input
@@ -750,17 +749,7 @@ function InlineEditorSession({
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
>
{catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
<p>
BC items are managed on the full Builder Club page editor.
</p>
<Link href={pageHref} target="_blank">
<Button variant="outline" size="sm" className="gap-1">
<ExternalLink className="h-3 w-3" />
Open BC page editor
</Button>
</Link>
</div>
<BcItemsEditor pageId={page.id} canEdit={canEdit} />
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
@@ -10,7 +10,7 @@ import {
Wand2,
} from "lucide-react";
import { useTranslations } from "next-intl";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import type { CatalogItemData } from "@/components/admin/catalog/catalog-items-table/types";
import {
getShopGridClass,
getShopTileSize,
@@ -97,8 +97,6 @@ export function SortableTree({
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
const catQs = catalogTreeQs(catalogType);
const pageBase =
catalogType === "bc" ? "/admin/catalog/builder-club" : "/admin/catalog";
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const {
nodes,
@@ -713,8 +711,6 @@ export function SortableTree({
},
[state.editorDirty, t, canEdit, nodes, catalogType],
);
const handleOpenFull = (id: number) =>
window.open(`${pageBase}/${id}`, "_blank");
// ── Expand/Collapse all ─────────────────────────────────────
const handleExpandAll = useCallback(() => {
@@ -813,10 +809,8 @@ export function SortableTree({
};
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null;
const noop = () => {};
const noop = () => {};
// Shared props builder for tree items
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
const getItemProps = useCallback(
(node: FlatTreeNode): Omit<TreeItemProps, "sortMode" | "isOverlay"> => ({
node,
@@ -831,7 +825,6 @@ export function SortableTree({
onToggleVisible: handleToggleVisible,
onDuplicate: handleDuplicate,
onDelete: handleDelete,
onOpenFull: handleOpenFull,
isLoading: loadingIds.has(node.id),
}),
[
@@ -1129,7 +1122,6 @@ export function SortableTree({
onToggleVisible={noop}
onDuplicate={noop}
onDelete={noop}
onOpenFull={noop}
/>
)}
</DragOverlay>
@@ -5,7 +5,6 @@ import { CSS } from "@dnd-kit/utilities";
import {
ChevronRight,
Copy as CopyIcon,
ExternalLink,
Eye,
EyeOff,
EyeOff as EyeOffIcon,
@@ -61,7 +60,6 @@ export interface TreeItemProps {
onToggleVisible: (id: number, currentVisible: string) => void;
onDuplicate: (id: number) => void;
onDelete: (id: number) => void;
onOpenFull: (id: number) => void;
isLoading?: boolean;
}
@@ -114,7 +112,6 @@ export const TreeItemContent = React.memo(
onToggleVisible,
onDuplicate,
onDelete,
onOpenFull,
isLoading,
}: TreeItemProps & {
isDragging?: boolean;
@@ -262,10 +259,6 @@ export const TreeItemContent = React.memo(
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => onOpenFull(node.id)}>
<ExternalLink className="mr-2 h-3.5 w-3.5" />
Open full editor
</ContextMenuItem>
<ContextMenuItem
onClick={() => navigator.clipboard.writeText(String(node.id))}
>