feat(catalog): add reviewed bulk edits and complete category duplication
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m11s

This commit is contained in:
Simo committed 2026-09-06 20:25:19 +02:00
1 parent 0bedc04692
commit 55a47949f1
26 files changed
+2504 -85

No files matched your search

@@ -116,13 +116,13 @@ export function CatalogManagerDialog({
</Button>
{mounted && open
? createPortal(
<div className="fixed inset-0 z-[200]">
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-foreground/70" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="visual-catalog-manager-title"
className="fixed top-[2.5vh] left-[1vw] z-[201] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
className="fixed top-[2.5vh] left-[1vw] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<CatalogManagerProvider
@@ -17,7 +17,16 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Loader2, Pencil, Plus, Power, PowerOff, Trash2 } from "lucide-react";
import {
Copy,
Loader2,
Pencil,
Plus,
Power,
PowerOff,
Trash2,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
@@ -41,6 +50,8 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
import { cn } from "@/lib/utils";
import { useCatalogManager } from "./use-catalog-store";
@@ -67,8 +78,12 @@ export function RootTabs({
loading,
onMutation,
}: RootTabsProps) {
const { catalogType, canEdit } = useCatalogManager();
const { catalogType, canEdit, state } = useCatalogManager();
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [duplicateSource, setDuplicateSource] = useState<DuplicateInput | null>(
null,
);
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -158,6 +173,13 @@ export function RootTabs({
style={{ backgroundColor: "var(--admin-surface)" }}
>
{confirmDialog}
{duplicateSource && (
<DuplicateCategoryDialog
source={duplicateSource}
onClose={() => setDuplicateSource(null)}
onComplete={() => onMutation?.()}
/>
)}
{loading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground mx-2" />
)}
@@ -180,6 +202,19 @@ export function RootTabs({
onEdit={() => onEditTab(tab.id)}
onToggle={() => handleToggle(tab)}
onDelete={() => handleDelete(tab)}
onDuplicate={() => {
if (state.editorDirty) {
toast.error(t("draftBlocked"));
return;
}
if (canEdit && requestCatalogNavigation())
setDuplicateSource({
kind: catalogType,
sourceId: tab.id,
parentId: -1,
name: `${tab.caption.slice(0, 121)} (copy)`,
});
}}
/>
))}
</SortableContext>
@@ -218,6 +253,7 @@ function SortableRootTab({
onEdit,
onToggle,
onDelete,
onDuplicate,
}: {
tab: RootTab;
isActive: boolean;
@@ -225,6 +261,7 @@ function SortableRootTab({
onEdit: () => void;
onToggle: () => void;
onDelete: () => void;
onDuplicate: () => void;
}) {
const {
attributes,
@@ -237,6 +274,8 @@ function SortableRootTab({
id: tab.id,
});
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
const { canEdit } = useCatalogManager();
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
@@ -256,6 +295,12 @@ function SortableRootTab({
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
{canEdit && (
<ContextMenuItem onClick={onDuplicate}>
<Copy className="mr-2 h-3.5 w-3.5" />
{t("title")}
</ContextMenuItem>
)}
<ContextMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit page
@@ -31,6 +31,7 @@ import {
Trash2,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
@@ -39,7 +40,6 @@ import {
reorderCatalogPages,
reorderTreePage,
toggleCatalogPage,
updateCatalogPage,
} from "@/actions/catalog";
import {
createBcPage,
@@ -47,7 +47,6 @@ import {
reorderBcCatalogPages,
reorderBcTreePage,
toggleBcPage,
updateBcPage,
} from "@/actions/catalog-bc";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Badge } from "@/components/ui/badge";
@@ -58,6 +57,9 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
import { cn } from "@/lib/utils";
import {
type FlatTreeNode,
@@ -93,6 +95,7 @@ export function SortableTree({
nodeUpdate,
}: SortableTreeProps) {
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";
@@ -107,6 +110,9 @@ export function SortableTree({
sortMode,
} = state;
const [duplicateSource, setDuplicateSource] = useState<DuplicateInput | null>(
null,
);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<TreeNodeData[] | null>(
null,
@@ -331,6 +337,7 @@ export function SortableTree({
const handleDeleteRef = useRef<(id: number) => Promise<void>>(async () => {});
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.target as HTMLElement)?.closest('[role="dialog"]')) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
if (e.key === "/") {
@@ -590,67 +597,24 @@ export function SortableTree({
};
handleDeleteRef.current = handleDelete;
const handleDuplicate = async (id: number) => {
if (!canEdit) return;
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
if (!res.ok) throw new Error();
const { page } = await res.json();
const result =
catalogType === "bc"
? await createBcPage({
parentId: page.parentId ?? -1,
caption: `${page.caption} (copy)`,
pageLayout: page.pageLayout ?? "default_3x3",
iconImage: page.iconImage ?? 0,
iconColor: page.iconColor ?? 0,
enabled: "0",
visible: page.visible ?? "1",
orderNum: (page.orderNum ?? 0) + 1,
})
: await createCatalogPage({
parentId: page.parentId ?? -1,
caption: `${page.caption} (copy)`,
pageLayout: page.pageLayout ?? "default_3x3",
iconImage: page.iconImage ?? 0,
iconColor: page.iconColor ?? 0,
enabled: "0",
visible: page.visible ?? "1",
minRank: page.minRank ?? 1,
orderNum: (page.orderNum ?? 0) + 1,
});
if (!result.ok) throw new Error(result.error);
const newId = (result.data as Record<string, unknown>).id as number;
if (catalogType === "bc") {
await updateBcPage({
id: newId,
pageHeadline: page.pageHeadline ?? "",
pageTeaser: page.pageTeaser ?? "",
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
});
} else {
await updateCatalogPage({
id: newId,
pageHeadline: page.pageHeadline ?? "",
pageTeaser: page.pageTeaser ?? "",
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
});
const handleDuplicate = useCallback(
(id: number) => {
if (state.editorDirty) {
toast.error(t("draftBlocked"));
return;
}
await loadFullTree();
toast.success(`Duplicated "${page.caption}" (items not copied)`);
onSelectPage(newId);
onTreeMutation();
} catch {
toast.error("Failed to duplicate page");
}
};
if (!canEdit || !requestCatalogNavigation()) return;
const node = nodes.get(id);
if (!node) return;
setDuplicateSource({
kind: catalogType,
sourceId: id,
parentId: node.parentId,
name: `${node.caption.slice(0, 121)} (copy)`,
});
},
[state.editorDirty, t, canEdit, nodes, catalogType],
);
const handleOpenFull = (id: number) =>
window.open(`${pageBase}/${id}`, "_blank");
@@ -780,6 +744,7 @@ export function SortableTree({
loadingIds,
handleToggleExpand,
handleSelect,
handleDuplicate,
],
); // eslint-disable-line react-hooks/exhaustive-deps
@@ -792,6 +757,16 @@ export function SortableTree({
style={{ backgroundColor: "var(--admin-surface)" }}
>
{confirmDialog}
{duplicateSource && (
<DuplicateCategoryDialog
source={duplicateSource}
onClose={() => setDuplicateSource(null)}
onComplete={() => {
void loadFullTree();
onTreeMutation();
}}
/>
)}
{/* Header */}
<div className="border-b px-3 py-2 space-y-2 shrink-0">
{subpageParentId !== null ? (
@@ -25,7 +25,6 @@ import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
@@ -251,13 +250,13 @@ export const TreeItemContent = React.memo(
<ContextMenu>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuLabel className="flex items-center gap-2">
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-muted-foreground">
<CatalogIcon iconImage={node.iconImage} size={14} />
{node.caption}
<span className="text-muted-foreground font-normal ml-auto">
#{node.id}
</span>
</ContextMenuLabel>
</div>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onSelect(node.id)}>
<Pencil className="mr-2 h-3.5 w-3.5" />