feat(catalog): add reviewed bulk edits and complete category duplication
This commit is contained in:
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" />
|
||||
|
||||
Reference in new issue
Block a user