create-nextblock 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-nextblock.js +997 -0
- package/package.json +25 -0
- package/scripts/sync-template.js +284 -0
- package/templates/nextblock-template/.env.example +37 -0
- package/templates/nextblock-template/.swcrc +30 -0
- package/templates/nextblock-template/README.md +194 -0
- package/templates/nextblock-template/app/(auth-pages)/forgot-password/page.tsx +57 -0
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -0
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +28 -0
- package/templates/nextblock-template/app/(auth-pages)/sign-in/page.tsx +67 -0
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +70 -0
- package/templates/nextblock-template/app/ToasterProvider.tsx +17 -0
- package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +147 -0
- package/templates/nextblock-template/app/[slug]/page.tsx +145 -0
- package/templates/nextblock-template/app/[slug]/page.utils.ts +183 -0
- package/templates/nextblock-template/app/actions/email.ts +31 -0
- package/templates/nextblock-template/app/actions/formActions.ts +65 -0
- package/templates/nextblock-template/app/actions/languageActions.ts +130 -0
- package/templates/nextblock-template/app/actions/postActions.ts +80 -0
- package/templates/nextblock-template/app/actions.ts +146 -0
- package/templates/nextblock-template/app/api/process-image/route.ts +210 -0
- package/templates/nextblock-template/app/api/revalidate/route.ts +86 -0
- package/templates/nextblock-template/app/api/revalidate-log/route.ts +23 -0
- package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +106 -0
- package/templates/nextblock-template/app/api/upload/proxy/route.ts +84 -0
- package/templates/nextblock-template/app/auth/callback/route.ts +58 -0
- package/templates/nextblock-template/app/blog/[slug]/PostClientContent.tsx +169 -0
- package/templates/nextblock-template/app/blog/[slug]/page.tsx +177 -0
- package/templates/nextblock-template/app/blog/[slug]/page.utils.ts +136 -0
- package/templates/nextblock-template/app/blog/page.tsx +77 -0
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +321 -0
- package/templates/nextblock-template/app/cms/blocks/actions.ts +434 -0
- package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +348 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +567 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +98 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockTypeCard.tsx +58 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +62 -0
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +276 -0
- package/templates/nextblock-template/app/cms/blocks/components/DeleteBlockButtonClient.tsx +47 -0
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +182 -0
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +120 -0
- package/templates/nextblock-template/app/cms/blocks/components/SectionConfigPanel.tsx +133 -0
- package/templates/nextblock-template/app/cms/blocks/components/SortableBlockItem.tsx +46 -0
- package/templates/nextblock-template/app/cms/blocks/editors/ButtonBlockEditor.tsx +85 -0
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +182 -0
- package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +111 -0
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +150 -0
- package/templates/nextblock-template/app/cms/blocks/editors/PostsGridBlockEditor.tsx +79 -0
- package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +337 -0
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +81 -0
- package/templates/nextblock-template/app/cms/blocks/editors/VideoEmbedBlockEditor.tsx +64 -0
- package/templates/nextblock-template/app/cms/components/ConfirmationModal.tsx +51 -0
- package/templates/nextblock-template/app/cms/components/ContentLanguageSwitcher.tsx +145 -0
- package/templates/nextblock-template/app/cms/components/CopyContentFromLanguage.tsx +203 -0
- package/templates/nextblock-template/app/cms/components/LanguageFilterSelect.tsx +69 -0
- package/templates/nextblock-template/app/cms/dashboard/page.tsx +247 -0
- package/templates/nextblock-template/app/cms/layout.tsx +10 -0
- package/templates/nextblock-template/app/cms/media/UploadFolderContext.tsx +22 -0
- package/templates/nextblock-template/app/cms/media/[id]/edit/page.tsx +80 -0
- package/templates/nextblock-template/app/cms/media/actions.ts +577 -0
- package/templates/nextblock-template/app/cms/media/components/DeleteMediaButtonClient.tsx +53 -0
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -0
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -0
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +157 -0
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +275 -0
- package/templates/nextblock-template/app/cms/media/components/MediaImage.tsx +70 -0
- package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +195 -0
- package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +362 -0
- package/templates/nextblock-template/app/cms/media/page.tsx +120 -0
- package/templates/nextblock-template/app/cms/navigation/[id]/edit/page.tsx +101 -0
- package/templates/nextblock-template/app/cms/navigation/actions.ts +358 -0
- package/templates/nextblock-template/app/cms/navigation/components/DeleteNavItemButton.tsx +52 -0
- package/templates/nextblock-template/app/cms/navigation/components/NavigationItemForm.tsx +248 -0
- package/templates/nextblock-template/app/cms/navigation/components/NavigationLanguageSwitcher.tsx +132 -0
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +701 -0
- package/templates/nextblock-template/app/cms/navigation/components/SortableNavItem.tsx +98 -0
- package/templates/nextblock-template/app/cms/navigation/new/page.tsx +26 -0
- package/templates/nextblock-template/app/cms/navigation/page.tsx +102 -0
- package/templates/nextblock-template/app/cms/navigation/utils.ts +51 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +121 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +79 -0
- package/templates/nextblock-template/app/cms/pages/actions.ts +241 -0
- package/templates/nextblock-template/app/cms/pages/components/DeletePageButtonClient.tsx +47 -0
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +253 -0
- package/templates/nextblock-template/app/cms/pages/new/page.tsx +52 -0
- package/templates/nextblock-template/app/cms/pages/page.tsx +232 -0
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +183 -0
- package/templates/nextblock-template/app/cms/posts/actions.ts +309 -0
- package/templates/nextblock-template/app/cms/posts/components/DeletePostButtonClient.tsx +55 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +419 -0
- package/templates/nextblock-template/app/cms/posts/new/page.tsx +21 -0
- package/templates/nextblock-template/app/cms/posts/page.tsx +192 -0
- package/templates/nextblock-template/app/cms/revisions/JsonDiffView.tsx +86 -0
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +201 -0
- package/templates/nextblock-template/app/cms/revisions/actions.ts +84 -0
- package/templates/nextblock-template/app/cms/revisions/service.ts +344 -0
- package/templates/nextblock-template/app/cms/revisions/utils.ts +127 -0
- package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +68 -0
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +78 -0
- package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +32 -0
- package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +117 -0
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +216 -0
- package/templates/nextblock-template/app/cms/settings/languages/[id]/edit/page.tsx +77 -0
- package/templates/nextblock-template/app/cms/settings/languages/actions.ts +261 -0
- package/templates/nextblock-template/app/cms/settings/languages/components/DeleteLanguageButton.tsx +76 -0
- package/templates/nextblock-template/app/cms/settings/languages/components/LanguageForm.tsx +167 -0
- package/templates/nextblock-template/app/cms/settings/languages/new/page.tsx +34 -0
- package/templates/nextblock-template/app/cms/settings/languages/page.tsx +156 -0
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +19 -0
- package/templates/nextblock-template/app/cms/settings/logos/actions.ts +114 -0
- package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +177 -0
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +11 -0
- package/templates/nextblock-template/app/cms/settings/logos/page.tsx +118 -0
- package/templates/nextblock-template/app/cms/settings/logos/types.ts +8 -0
- package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +91 -0
- package/templates/nextblock-template/app/cms/users/actions.ts +156 -0
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +71 -0
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +138 -0
- package/templates/nextblock-template/app/cms/users/page.tsx +183 -0
- package/templates/nextblock-template/app/favicon.ico +0 -0
- package/templates/nextblock-template/app/globals.css +401 -0
- package/templates/nextblock-template/app/layout.tsx +191 -0
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +68 -0
- package/templates/nextblock-template/app/page.tsx +109 -0
- package/templates/nextblock-template/app/providers.tsx +43 -0
- package/templates/nextblock-template/app/robots.txt/route.ts +19 -0
- package/templates/nextblock-template/app/sitemap.xml/route.ts +63 -0
- package/templates/nextblock-template/app/unauthorized/page.tsx +27 -0
- package/templates/nextblock-template/backup/backup_2025-06-19.sql +8057 -0
- package/templates/nextblock-template/backup/backup_2025-06-20.sql +8159 -0
- package/templates/nextblock-template/backup/backup_2025-07-08.sql +8411 -0
- package/templates/nextblock-template/backup/backup_2025-07-09.sql +8442 -0
- package/templates/nextblock-template/backup/backup_2025-07-10.sql +8442 -0
- package/templates/nextblock-template/backup/backup_2025-10-01.sql +8803 -0
- package/templates/nextblock-template/backup/backup_2025-10-02.sql +9749 -0
- package/templates/nextblock-template/components/BlockRenderer.tsx +119 -0
- package/templates/nextblock-template/components/FooterNavigation.tsx +33 -0
- package/templates/nextblock-template/components/Header.tsx +42 -0
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -0
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +103 -0
- package/templates/nextblock-template/components/ResponsiveNav.tsx +372 -0
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +17 -0
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +93 -0
- package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +180 -0
- package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -0
- package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +69 -0
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +98 -0
- package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +41 -0
- package/templates/nextblock-template/components/blocks/renderers/HeroBlockRenderer.tsx +240 -0
- package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +79 -0
- package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +33 -0
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +189 -0
- package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +31 -0
- package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +59 -0
- package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +51 -0
- package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +40 -0
- package/templates/nextblock-template/components/blocks/types.ts +8 -0
- package/templates/nextblock-template/components/env-var-warning.tsx +33 -0
- package/templates/nextblock-template/components/form-message.tsx +26 -0
- package/templates/nextblock-template/components/header-auth.tsx +71 -0
- package/templates/nextblock-template/components/submit-button.tsx +23 -0
- package/templates/nextblock-template/components/theme-switcher.tsx +78 -0
- package/templates/nextblock-template/context/AuthContext.tsx +138 -0
- package/templates/nextblock-template/context/CurrentContentContext.tsx +42 -0
- package/templates/nextblock-template/context/LanguageContext.tsx +206 -0
- package/templates/nextblock-template/docs/cms-application-overview.md +56 -0
- package/templates/nextblock-template/docs/cms-architecture-overview.md +73 -0
- package/templates/nextblock-template/docs/files-structure.md +426 -0
- package/templates/nextblock-template/docs/tiptap-bundle-optimization-summary.md +174 -0
- package/templates/nextblock-template/eslint.config.mjs +28 -0
- package/templates/nextblock-template/index.d.ts +5 -0
- package/templates/nextblock-template/lib/blocks/README.md +670 -0
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +1001 -0
- package/templates/nextblock-template/lib/ui/ColorPicker.ts +1 -0
- package/templates/nextblock-template/lib/ui/ConfirmationDialog.ts +1 -0
- package/templates/nextblock-template/lib/ui/CustomSelectWithInput.ts +1 -0
- package/templates/nextblock-template/lib/ui/Skeleton.ts +1 -0
- package/templates/nextblock-template/lib/ui/avatar.ts +1 -0
- package/templates/nextblock-template/lib/ui/badge.ts +1 -0
- package/templates/nextblock-template/lib/ui/button.ts +1 -0
- package/templates/nextblock-template/lib/ui/card.ts +1 -0
- package/templates/nextblock-template/lib/ui/checkbox.ts +1 -0
- package/templates/nextblock-template/lib/ui/dialog.ts +1 -0
- package/templates/nextblock-template/lib/ui/dropdown-menu.ts +1 -0
- package/templates/nextblock-template/lib/ui/input.ts +1 -0
- package/templates/nextblock-template/lib/ui/label.ts +1 -0
- package/templates/nextblock-template/lib/ui/popover.ts +1 -0
- package/templates/nextblock-template/lib/ui/progress.ts +1 -0
- package/templates/nextblock-template/lib/ui/select.ts +1 -0
- package/templates/nextblock-template/lib/ui/separator.ts +1 -0
- package/templates/nextblock-template/lib/ui/table.ts +1 -0
- package/templates/nextblock-template/lib/ui/textarea.ts +1 -0
- package/templates/nextblock-template/lib/ui/tooltip.ts +1 -0
- package/templates/nextblock-template/lib/ui/ui.ts +1 -0
- package/templates/nextblock-template/middleware.ts +206 -0
- package/templates/nextblock-template/next-env.d.ts +6 -0
- package/templates/nextblock-template/next.config.js +99 -0
- package/templates/nextblock-template/package.json +52 -0
- package/templates/nextblock-template/postcss.config.js +6 -0
- package/templates/nextblock-template/project.json +7 -0
- package/templates/nextblock-template/public/.gitkeep +0 -0
- package/templates/nextblock-template/scripts/backfill-image-meta.ts +149 -0
- package/templates/nextblock-template/scripts/backup.js +53 -0
- package/templates/nextblock-template/scripts/test-bundle-optimization.js +114 -0
- package/templates/nextblock-template/tailwind.config.ts +19 -0
- package/templates/nextblock-template/tsconfig.json +62 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
// app/cms/navigation/components/NavigationMenuDnd.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import React, { useState, useTransition, useCallback, useMemo, JSX, useEffect } from 'react';
|
|
5
|
+
import {
|
|
6
|
+
DndContext,
|
|
7
|
+
closestCenter,
|
|
8
|
+
KeyboardSensor,
|
|
9
|
+
PointerSensor,
|
|
10
|
+
useSensor,
|
|
11
|
+
useSensors,
|
|
12
|
+
DragEndEvent,
|
|
13
|
+
DragOverlay,
|
|
14
|
+
DragStartEvent,
|
|
15
|
+
DragMoveEvent,
|
|
16
|
+
UniqueIdentifier,
|
|
17
|
+
MeasuringStrategy,
|
|
18
|
+
DropAnimation,
|
|
19
|
+
defaultDropAnimationSideEffects,
|
|
20
|
+
DragOverEvent,
|
|
21
|
+
} from '@dnd-kit/core';
|
|
22
|
+
import {
|
|
23
|
+
SortableContext,
|
|
24
|
+
sortableKeyboardCoordinates,
|
|
25
|
+
verticalListSortingStrategy,
|
|
26
|
+
} from '@dnd-kit/sortable';
|
|
27
|
+
import {
|
|
28
|
+
Table,
|
|
29
|
+
TableBody,
|
|
30
|
+
TableHead,
|
|
31
|
+
TableHeader,
|
|
32
|
+
TableRow,
|
|
33
|
+
TableCell,
|
|
34
|
+
} from "@nextblock-cms/ui";
|
|
35
|
+
import { Button } from "@nextblock-cms/ui";
|
|
36
|
+
import { HierarchicalNavItem, SortableNavItem } from './SortableNavItem';
|
|
37
|
+
import type { Database } from "@nextblock-cms/db";
|
|
38
|
+
import { updateNavigationStructureBatch } from '../actions';
|
|
39
|
+
import { GripVertical, MoreHorizontal } from 'lucide-react';
|
|
40
|
+
|
|
41
|
+
type NavigationItem = Database['public']['Tables']['navigation_items']['Row'];
|
|
42
|
+
type MenuLocation = Database['public']['Enums']['menu_location'];
|
|
43
|
+
import { Badge } from "@nextblock-cms/ui";
|
|
44
|
+
import { createPortal } from 'react-dom';
|
|
45
|
+
|
|
46
|
+
const INDENTATION_WIDTH = 25;
|
|
47
|
+
|
|
48
|
+
interface FoundItemInfo {
|
|
49
|
+
item: HierarchicalNavItem;
|
|
50
|
+
parent: HierarchicalNavItem | null;
|
|
51
|
+
siblings: HierarchicalNavItem[];
|
|
52
|
+
index: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const buildTree = (
|
|
56
|
+
items: NavigationItem[],
|
|
57
|
+
parentId: number | null = null,
|
|
58
|
+
depth = 0,
|
|
59
|
+
languageCode: string
|
|
60
|
+
): HierarchicalNavItem[] => {
|
|
61
|
+
return items
|
|
62
|
+
.filter(item => item.parent_id === parentId)
|
|
63
|
+
.sort((a, b) => a.order - b.order)
|
|
64
|
+
.map(item => {
|
|
65
|
+
const navItem = item as NavigationItem;
|
|
66
|
+
return {
|
|
67
|
+
...navItem,
|
|
68
|
+
id: Number(navItem.id),
|
|
69
|
+
depth,
|
|
70
|
+
children: buildTree(items, navItem.id, depth + 1, languageCode),
|
|
71
|
+
languageCode: languageCode,
|
|
72
|
+
parentLabel: items.find(p => p.id === navItem.parent_id)?.label || null,
|
|
73
|
+
pageSlug: 'pages' in navItem && navItem.pages && typeof navItem.pages === 'object' && 'slug' in navItem.pages ? (navItem.pages as { slug: string }).slug : null,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const flattenTree = (nodes: HierarchicalNavItem[]): HierarchicalNavItem[] => {
|
|
79
|
+
const result: HierarchicalNavItem[] = [];
|
|
80
|
+
const stack = [...nodes];
|
|
81
|
+
while (stack.length) {
|
|
82
|
+
const node = stack.shift();
|
|
83
|
+
if (node) {
|
|
84
|
+
result.push(node);
|
|
85
|
+
if (node.children && node.children.length) {
|
|
86
|
+
stack.unshift(...node.children);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function findItemDeep(
|
|
94
|
+
items: HierarchicalNavItem[],
|
|
95
|
+
itemId: UniqueIdentifier,
|
|
96
|
+
parent: HierarchicalNavItem | null = null
|
|
97
|
+
): FoundItemInfo | null {
|
|
98
|
+
for (let i = 0; i < items.length; i++) {
|
|
99
|
+
const item = items[i];
|
|
100
|
+
if (item.id === itemId) {
|
|
101
|
+
return { item, parent, siblings: parent ? parent.children : items, index: i };
|
|
102
|
+
}
|
|
103
|
+
if (item.children && item.children.length > 0) {
|
|
104
|
+
const found = findItemDeep(item.children, itemId, item);
|
|
105
|
+
if (found) return found;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function removeItemFromTree(
|
|
112
|
+
tree: HierarchicalNavItem[],
|
|
113
|
+
itemId: UniqueIdentifier
|
|
114
|
+
): { newTree: HierarchicalNavItem[]; removedItemBranch: HierarchicalNavItem | null } {
|
|
115
|
+
let removedItemBranch: HierarchicalNavItem | null = null;
|
|
116
|
+
function filterRecursive(items: HierarchicalNavItem[]): HierarchicalNavItem[] {
|
|
117
|
+
return items.filter(item => {
|
|
118
|
+
if (item.id === itemId) {
|
|
119
|
+
removedItemBranch = item;
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
if (item.children) {
|
|
123
|
+
item.children = filterRecursive(item.children);
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const newTree = filterRecursive(structuredClone(tree));
|
|
129
|
+
return { newTree, removedItemBranch };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function addItemToTree(
|
|
133
|
+
tree: HierarchicalNavItem[],
|
|
134
|
+
itemToAdd: HierarchicalNavItem,
|
|
135
|
+
targetParentId: UniqueIdentifier | null,
|
|
136
|
+
targetIndex: number
|
|
137
|
+
): HierarchicalNavItem[] {
|
|
138
|
+
if (targetParentId === null) {
|
|
139
|
+
const newTree = [...tree];
|
|
140
|
+
newTree.splice(targetIndex, 0, itemToAdd);
|
|
141
|
+
return newTree;
|
|
142
|
+
}
|
|
143
|
+
return tree.map(node => {
|
|
144
|
+
if (node.id === targetParentId) {
|
|
145
|
+
const newChildren = [...(node.children || [])];
|
|
146
|
+
newChildren.splice(targetIndex, 0, itemToAdd);
|
|
147
|
+
return { ...node, children: newChildren };
|
|
148
|
+
}
|
|
149
|
+
if (node.children && node.children.length > 0) {
|
|
150
|
+
return { ...node, children: addItemToTree(node.children, itemToAdd, targetParentId, targetIndex) };
|
|
151
|
+
}
|
|
152
|
+
return node;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeTree(items: HierarchicalNavItem[], depth = 0, parentId: number | null = null): HierarchicalNavItem[] {
|
|
157
|
+
return items.map((item, index) => {
|
|
158
|
+
const normalizedItem: HierarchicalNavItem = {
|
|
159
|
+
...item,
|
|
160
|
+
order: index,
|
|
161
|
+
parent_id: parentId,
|
|
162
|
+
depth,
|
|
163
|
+
};
|
|
164
|
+
if (normalizedItem.children && normalizedItem.children.length > 0) {
|
|
165
|
+
normalizedItem.children = normalizeTree(normalizedItem.children, depth + 1, normalizedItem.id as number);
|
|
166
|
+
} else {
|
|
167
|
+
normalizedItem.children = [];
|
|
168
|
+
}
|
|
169
|
+
return normalizedItem;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface NavigationMenuDndProps {
|
|
174
|
+
menuKey: MenuLocation;
|
|
175
|
+
languageCode: string;
|
|
176
|
+
initialItems: NavigationItem[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export default function NavigationMenuDnd({ menuKey, languageCode, initialItems }: NavigationMenuDndProps) {
|
|
180
|
+
void menuKey;
|
|
181
|
+
// Prevent SSR/hydration mismatches from dnd-kit by rendering on client only
|
|
182
|
+
// Keep hooks order stable; move early return after hooks
|
|
183
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
184
|
+
useEffect(() => { setIsMounted(true); }, []);
|
|
185
|
+
const [, startTransition] = useTransition();
|
|
186
|
+
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
|
|
187
|
+
const [hierarchicalItems, setHierarchicalItems] = useState<HierarchicalNavItem[]>(() => buildTree(initialItems, null, 0, languageCode));
|
|
188
|
+
const [projected, setProjected] = useState<{ parentId: UniqueIdentifier | null; index: number; depth: number; overId: UniqueIdentifier | null } | null>(null);
|
|
189
|
+
|
|
190
|
+
const sensors = useSensors(
|
|
191
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 10 } }),
|
|
192
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const flatItemIds = useMemo(() => flattenTree(hierarchicalItems).map(item => item.id), [hierarchicalItems]);
|
|
196
|
+
const activeItemDataForOverlay = activeId ? findItemDeep(hierarchicalItems, activeId) : null;
|
|
197
|
+
|
|
198
|
+
const getDragDepth = (offset: number, initialDepth: number) => {
|
|
199
|
+
const newDepth = initialDepth + Math.round(offset / INDENTATION_WIDTH);
|
|
200
|
+
return Math.max(0, newDepth);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const getProjectedDropPosition = useCallback((
|
|
204
|
+
activeItemLocal: HierarchicalNavItem,
|
|
205
|
+
overItemLocal: HierarchicalNavItem | null, // This is the item we are hovering "over"
|
|
206
|
+
dragDeltaX: number,
|
|
207
|
+
currentTree: HierarchicalNavItem[],
|
|
208
|
+
rawEvent: DragMoveEvent | DragOverEvent // Pass the raw event for pointer coordinates
|
|
209
|
+
): { parentId: UniqueIdentifier | null; index: number; depth: number; } => {
|
|
210
|
+
|
|
211
|
+
const initialDepth = activeItemLocal.depth;
|
|
212
|
+
const flatCurrentTree = flattenTree(currentTree);
|
|
213
|
+
const activeItemFlatIndex = flatCurrentTree.findIndex(i => i.id === activeItemLocal.id);
|
|
214
|
+
|
|
215
|
+
// Define thresholds for explicit horizontal gesture
|
|
216
|
+
const HORIZONTAL_GESTURE_DRAG_X_THRESHOLD = INDENTATION_WIDTH * 0.4; // More sensitive: 10px
|
|
217
|
+
const HORIZONTAL_GESTURE_MAX_Y_DRIFT = 12; // Slightly more forgiving Y drift
|
|
218
|
+
|
|
219
|
+
let newProjectedDepth = getDragDepth(dragDeltaX, initialDepth); // Default calculation
|
|
220
|
+
let isHorizontalGestureIntent = false;
|
|
221
|
+
|
|
222
|
+
// --- Explicit Horizontal Gesture Detection (Highest Priority) ---
|
|
223
|
+
if (Math.abs(rawEvent.delta.y) < HORIZONTAL_GESTURE_MAX_Y_DRIFT) {
|
|
224
|
+
if (dragDeltaX > HORIZONTAL_GESTURE_DRAG_X_THRESHOLD) { // Intent to indent (drag right)
|
|
225
|
+
newProjectedDepth = initialDepth + 1; // Set intended depth for this gesture
|
|
226
|
+
isHorizontalGestureIntent = true;
|
|
227
|
+
if (activeItemFlatIndex > 0) {
|
|
228
|
+
const itemAbove = flatCurrentTree[activeItemFlatIndex - 1];
|
|
229
|
+
if (itemAbove.depth === initialDepth) { // Item above is a potential parent
|
|
230
|
+
const activeBranchIds = new Set(flattenTree([activeItemLocal]).map(i => i.id));
|
|
231
|
+
if (!activeBranchIds.has(itemAbove.id as number)) { // Avoid self-nesting
|
|
232
|
+
return { parentId: itemAbove.id, index: itemAbove.children.length, depth: newProjectedDepth };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// If not returned, newProjectedDepth (initialDepth + 1) is carried to fallback logic.
|
|
237
|
+
} else if (dragDeltaX < -HORIZONTAL_GESTURE_DRAG_X_THRESHOLD) { // Intent to outdent (drag left)
|
|
238
|
+
if (activeItemLocal.parent_id) { // Can only outdent if it has a parent
|
|
239
|
+
const parentInfo = findItemDeep(currentTree, activeItemLocal.parent_id);
|
|
240
|
+
if (parentInfo) {
|
|
241
|
+
newProjectedDepth = Math.max(0, initialDepth - 1); // Set intended depth
|
|
242
|
+
isHorizontalGestureIntent = true;
|
|
243
|
+
const grandParentId = parentInfo.parent?.id ?? null;
|
|
244
|
+
const newIndex = parentInfo.index + 1; // Place after original parent
|
|
245
|
+
return { parentId: grandParentId, index: newIndex, depth: newProjectedDepth };
|
|
246
|
+
}
|
|
247
|
+
} else { // Already at root, but a clear left drag was made
|
|
248
|
+
newProjectedDepth = 0; // Stay at root
|
|
249
|
+
isHorizontalGestureIntent = true;
|
|
250
|
+
// No immediate return, let fallback logic handle placing at root if needed,
|
|
251
|
+
// but with depth 0 confirmed.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- Fallback Logic ---
|
|
257
|
+
// `newProjectedDepth` now holds:
|
|
258
|
+
// 1. The result of an explicit horizontal gesture's intended depth (if gesture occurred & didn't return).
|
|
259
|
+
// 2. Or the default `getDragDepth` if no such gesture was detected.
|
|
260
|
+
|
|
261
|
+
if (!overItemLocal) {
|
|
262
|
+
// Dropping in empty space
|
|
263
|
+
if (newProjectedDepth === 0) {
|
|
264
|
+
return { parentId: null, index: currentTree.length, depth: 0 };
|
|
265
|
+
}
|
|
266
|
+
// Try to find a logical parent if indenting into empty space
|
|
267
|
+
const activeItemOriginalParentInfo = activeItemLocal.parent_id ? findItemDeep(currentTree, activeItemLocal.parent_id) : null;
|
|
268
|
+
if (activeItemOriginalParentInfo && activeItemOriginalParentInfo.item.depth + 1 === newProjectedDepth) {
|
|
269
|
+
const activeBranchIds = new Set(flattenTree([activeItemLocal]).map(i => i.id));
|
|
270
|
+
if (!activeBranchIds.has(activeItemOriginalParentInfo.item.id as number)) {
|
|
271
|
+
return { parentId: activeItemOriginalParentInfo.item.id, index: activeItemOriginalParentInfo.item.children.length, depth: newProjectedDepth };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const potentialParentsAtCorrectDepth = flatCurrentTree.filter(p => p.depth === newProjectedDepth - 1);
|
|
275
|
+
if (potentialParentsAtCorrectDepth.length > 0) {
|
|
276
|
+
const lastPotentialParent = potentialParentsAtCorrectDepth[potentialParentsAtCorrectDepth.length - 1];
|
|
277
|
+
const activeBranchIds = new Set(flattenTree([activeItemLocal]).map(i => i.id));
|
|
278
|
+
if (!activeBranchIds.has(lastPotentialParent.id as number)) {
|
|
279
|
+
return { parentId: lastPotentialParent.id, index: lastPotentialParent.children.length, depth: newProjectedDepth };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { parentId: null, index: currentTree.length, depth: Math.max(0, newProjectedDepth) }; // Ensure depth is not negative
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Dropping over an existing item (`overItemLocal` is not null)
|
|
286
|
+
const overItemInfo = findItemDeep(currentTree, overItemLocal.id);
|
|
287
|
+
if (!overItemInfo) { // Should not happen if overItemLocal is valid
|
|
288
|
+
return { parentId: null, index: currentTree.length, depth: newProjectedDepth };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// If an explicit horizontal gesture did not set the depth (i.e., isHorizontalGestureIntent is false),
|
|
292
|
+
// then newProjectedDepth is still from the initial getDragDepth().
|
|
293
|
+
// If isHorizontalGestureIntent is true, newProjectedDepth reflects that intent.
|
|
294
|
+
// Now, cap this depth if we are dropping over an item.
|
|
295
|
+
// If an explicit horizontal gesture was intended, newProjectedDepth already reflects that.
|
|
296
|
+
// Only cap based on overItemInfo if it was NOT an explicit horizontal gesture that set the depth.
|
|
297
|
+
if (overItemInfo && !isHorizontalGestureIntent) {
|
|
298
|
+
newProjectedDepth = Math.min(newProjectedDepth, overItemInfo.item.depth + 1);
|
|
299
|
+
} else if (overItemInfo && isHorizontalGestureIntent) {
|
|
300
|
+
// If it WAS a horizontal gesture, we still might need to cap it if overItem is relevant
|
|
301
|
+
// and would lead to an invalid depth (e.g., indenting too far under overItem).
|
|
302
|
+
// However, the primary target of the gesture (itemAbove/originalParent) should have taken precedence.
|
|
303
|
+
// If we are here, it means the gesture's primary target wasn't met, and we are now considering overItem.
|
|
304
|
+
// In this specific case, capping is appropriate.
|
|
305
|
+
newProjectedDepth = Math.min(newProjectedDepth, overItemInfo.item.depth + 1);
|
|
306
|
+
}
|
|
307
|
+
newProjectedDepth = Math.max(0, newProjectedDepth); // Ensure depth is not negative
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
let targetParentId: UniqueIdentifier | null;
|
|
311
|
+
let targetIndex: number;
|
|
312
|
+
|
|
313
|
+
const overItemRect = rawEvent.over?.rect;
|
|
314
|
+
const pointerY = rawEvent.delta.y + (rawEvent.active.rect.current.initial?.top || 0);
|
|
315
|
+
const isDroppingBeforeOverItem = overItemRect ? pointerY < overItemRect.top + overItemRect.height / 2 : false;
|
|
316
|
+
|
|
317
|
+
if (newProjectedDepth > overItemInfo.item.depth) {
|
|
318
|
+
// Nesting: Make activeItem a child of overItemLocal
|
|
319
|
+
targetParentId = overItemInfo.item.id;
|
|
320
|
+
targetIndex = overItemInfo.item.children.length; // Default to last child
|
|
321
|
+
} else if (newProjectedDepth === overItemInfo.item.depth) {
|
|
322
|
+
// Reordering at the same level as overItemLocal
|
|
323
|
+
targetParentId = overItemInfo.parent?.id ?? null;
|
|
324
|
+
targetIndex = isDroppingBeforeOverItem ? overItemInfo.index : overItemInfo.index + 1;
|
|
325
|
+
} else { // newProjectedDepth < overItemInfo.item.depth (Outdenting relative to overItemLocal)
|
|
326
|
+
let ancestor = overItemInfo.parent; // Start with overItem's parent
|
|
327
|
+
|
|
328
|
+
while (ancestor && ancestor.depth > newProjectedDepth) {
|
|
329
|
+
const grandParentInfo = ancestor.parent_id ? findItemDeep(currentTree, ancestor.parent_id) : null;
|
|
330
|
+
ancestor = grandParentInfo?.item ?? null; // This is the new potential parent
|
|
331
|
+
ancestor = grandParentInfo?.item ?? null;
|
|
332
|
+
}
|
|
333
|
+
// ancestor is now the target parent (or null if root).
|
|
334
|
+
// ancestorParentInfo.item (if ancestor is not null) is the item whose child list we are inserting into.
|
|
335
|
+
// or currentTree if ancestor is null.
|
|
336
|
+
// The item we are placing relative to is the one whose depth is newProjectedDepth + 1
|
|
337
|
+
// which was the child of 'ancestor'. This is `overItemInfo.item` if it was already at that level,
|
|
338
|
+
// or the parent of `overItemInfo.item` if `overItemInfo.item` was deeper.
|
|
339
|
+
|
|
340
|
+
targetParentId = ancestor?.id ?? null;
|
|
341
|
+
let referenceItemForSiblingPlacement: HierarchicalNavItem | undefined | null;
|
|
342
|
+
|
|
343
|
+
if (targetParentId) { // Outdenting to a non-root level
|
|
344
|
+
// We need to find which child of 'ancestor' was the block we outdented from.
|
|
345
|
+
// This would be the item at depth `newProjectedDepth` that was an ancestor of `overItemLocal` or `overItemLocal` itself.
|
|
346
|
+
let itemToPlaceAfter = overItemInfo.item;
|
|
347
|
+
while(itemToPlaceAfter.parent_id !== targetParentId && typeof itemToPlaceAfter.parent_id === 'number') {
|
|
348
|
+
const parent = findItemDeep(currentTree, itemToPlaceAfter.parent_id as number); // Cast as number after check
|
|
349
|
+
if (!parent) break;
|
|
350
|
+
itemToPlaceAfter = parent.item;
|
|
351
|
+
}
|
|
352
|
+
referenceItemForSiblingPlacement = itemToPlaceAfter;
|
|
353
|
+
const siblings = ancestor?.children ?? [];
|
|
354
|
+
const refIndex = siblings.findIndex(s => s.id === referenceItemForSiblingPlacement?.id);
|
|
355
|
+
targetIndex = refIndex !== -1 ? refIndex + 1 : siblings.length;
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
} else { // Outdenting to root
|
|
359
|
+
// Find the root item that was the ancestor block of overItemLocal
|
|
360
|
+
let rootAncestor = overItemInfo.item;
|
|
361
|
+
while(typeof rootAncestor.parent_id === 'number') {
|
|
362
|
+
const parent = findItemDeep(currentTree, rootAncestor.parent_id as number); // Cast as number after check
|
|
363
|
+
if(!parent) break;
|
|
364
|
+
rootAncestor = parent.item;
|
|
365
|
+
}
|
|
366
|
+
referenceItemForSiblingPlacement = rootAncestor;
|
|
367
|
+
const rootItems = currentTree.filter(i => i.parent_id === null);
|
|
368
|
+
const refIndex = rootItems.findIndex(s => s.id === referenceItemForSiblingPlacement?.id);
|
|
369
|
+
targetIndex = refIndex !== -1 ? refIndex + 1 : rootItems.length;
|
|
370
|
+
}
|
|
371
|
+
// If dropping before the item that was the "parent block"
|
|
372
|
+
if (isDroppingBeforeOverItem && overItemLocal.id === referenceItemForSiblingPlacement?.id) {
|
|
373
|
+
targetIndex--;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Ensure index is not out of bounds
|
|
378
|
+
const parentNodeForIndexCheck = targetParentId ? findItemDeep(currentTree, targetParentId)?.item : null;
|
|
379
|
+
const siblingsForIndexCheck = parentNodeForIndexCheck ? parentNodeForIndexCheck.children : currentTree.filter(i => i.parent_id === null);
|
|
380
|
+
targetIndex = Math.max(0, Math.min(targetIndex, siblingsForIndexCheck.length));
|
|
381
|
+
|
|
382
|
+
// Special fix for dragging to be the very first item at root
|
|
383
|
+
if (targetParentId === null && isDroppingBeforeOverItem && overItemInfo.parent === null && overItemInfo.index === 0) {
|
|
384
|
+
targetIndex = 0;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return { parentId: targetParentId, index: targetIndex, depth: newProjectedDepth };
|
|
388
|
+
}, []);
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
const onDragMove = (event: DragMoveEvent) => {
|
|
392
|
+
const { active, over, delta } = event;
|
|
393
|
+
if (!active) {
|
|
394
|
+
setProjected(null);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const activeItemSearchResult = findItemDeep(hierarchicalItems, active.id);
|
|
399
|
+
if (!activeItemSearchResult) {
|
|
400
|
+
setProjected(null);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const activeItem = activeItemSearchResult.item;
|
|
404
|
+
|
|
405
|
+
// Pure horizontal drag detection
|
|
406
|
+
const isPureHorizontalDrag = Math.abs(delta.x) > INDENTATION_WIDTH * 0.6 && Math.abs(delta.y) < 8;
|
|
407
|
+
|
|
408
|
+
if (isPureHorizontalDrag) {
|
|
409
|
+
const flatItems = flattenTree(hierarchicalItems);
|
|
410
|
+
const activeItemIndexInFlatList = flatItems.findIndex(item => item.id === active.id);
|
|
411
|
+
|
|
412
|
+
if (delta.x > 0) { // Right-drag (indent)
|
|
413
|
+
if (activeItemIndexInFlatList > 0) {
|
|
414
|
+
const itemAbove = flatItems[activeItemIndexInFlatList - 1];
|
|
415
|
+
// Ensure itemAbove is a valid potential parent (e.g., not a child of activeItem, and at a suitable depth)
|
|
416
|
+
// For simplicity here, we assume any item above can be a parent if it's not the active item itself or its descendant.
|
|
417
|
+
// More robust checks might be needed depending on exact requirements (e.g. itemAbove.depth === activeItem.depth)
|
|
418
|
+
// For now, we allow indenting under any item above it, if it's not part of the active branch.
|
|
419
|
+
const activeBranchIds = new Set(flattenTree([activeItem]).map(i => i.id));
|
|
420
|
+
if (!activeBranchIds.has(itemAbove.id as number) && itemAbove.depth >= activeItem.depth -1) { // Allow indenting under same level or one level up
|
|
421
|
+
setProjected({
|
|
422
|
+
parentId: itemAbove.id,
|
|
423
|
+
index: itemAbove.children.length,
|
|
424
|
+
depth: activeItem.depth + 1,
|
|
425
|
+
overId: itemAbove.id, // Set overId to the new parent
|
|
426
|
+
});
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} else { // Left-drag (outdent)
|
|
431
|
+
const foundItem = findItemDeep(hierarchicalItems, active.id);
|
|
432
|
+
if (!foundItem) return;
|
|
433
|
+
const { parent: currentParent } = foundItem;
|
|
434
|
+
const grandParentId = currentParent?.parent_id ?? null;
|
|
435
|
+
|
|
436
|
+
let insertIndex;
|
|
437
|
+
if (currentParent) {
|
|
438
|
+
const parentInfo = findItemDeep(hierarchicalItems, currentParent.id);
|
|
439
|
+
insertIndex = parentInfo ? parentInfo.index + 1 : hierarchicalItems.length;
|
|
440
|
+
} else {
|
|
441
|
+
// If no current parent, it's a root item. Outdenting means staying at root.
|
|
442
|
+
// We need to find its index among root items to place it correctly if it were to be moved.
|
|
443
|
+
// However, for a pure outdent, it just stays at its level.
|
|
444
|
+
// The setProjected below handles depth. The index might need adjustment if we allow reordering at root via pure outdent.
|
|
445
|
+
// For now, if it's a root item and outdenting, it effectively does nothing to its position, only confirms depth.
|
|
446
|
+
const rootItems = hierarchicalItems.filter(item => item.parent_id === null);
|
|
447
|
+
const currentRootIndex = rootItems.findIndex(item => item.id === active.id);
|
|
448
|
+
insertIndex = currentRootIndex +1; // This might not be ideal, depends on desired behavior for outdenting root.
|
|
449
|
+
// Let's assume it means placing it after itself if it's the last root, or after its original position.
|
|
450
|
+
// A more robust way would be to find its original parent's siblings.
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// If currentParent is null, it's a root item. grandParentId is null.
|
|
454
|
+
// insertIndex calculation for root items:
|
|
455
|
+
if (!currentParent) {
|
|
456
|
+
const rootItems = hierarchicalItems.filter(i => i.parent_id === null);
|
|
457
|
+
const activeRootIndex = rootItems.findIndex(i => i.id === active.id);
|
|
458
|
+
// Place it after its current position among root items if outdenting from root
|
|
459
|
+
// Or at the end if it's somehow not found (should not happen)
|
|
460
|
+
insertIndex = activeRootIndex !== -1 ? activeRootIndex + 1 : rootItems.length;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
setProjected({
|
|
465
|
+
parentId: grandParentId,
|
|
466
|
+
index: insertIndex,
|
|
467
|
+
depth: Math.max(0, activeItem.depth - 1),
|
|
468
|
+
overId: null, // No specific item is "over" in a pure outdent
|
|
469
|
+
});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Fallback to existing logic if not a pure horizontal drag or if conditions for indent/outdent weren't met
|
|
475
|
+
const overItemSearchResult = over ? findItemDeep(hierarchicalItems, over.id) : null;
|
|
476
|
+
const overItem = overItemSearchResult?.item ?? null;
|
|
477
|
+
|
|
478
|
+
if (over && activeItem) {
|
|
479
|
+
const activeBranchIds = new Set(flattenTree([activeItem]).map(i => i.id));
|
|
480
|
+
if (activeBranchIds.has(over.id as number)) {
|
|
481
|
+
setProjected(null);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const newProjection = getProjectedDropPosition(activeItem, overItem, delta.x, hierarchicalItems, event);
|
|
487
|
+
setProjected({ ...newProjection, overId: over ? over.id : null });
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
|
|
491
|
+
const { active } = event;
|
|
492
|
+
setActiveId(null);
|
|
493
|
+
|
|
494
|
+
if (!projected || !active.id) {
|
|
495
|
+
setProjected(null);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const originalTree = structuredClone(hierarchicalItems);
|
|
500
|
+
|
|
501
|
+
const { newTree: treeWithoutActive, removedItemBranch } = removeItemFromTree(hierarchicalItems, active.id);
|
|
502
|
+
if (!removedItemBranch) {
|
|
503
|
+
setProjected(null);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
let tempParentId = projected.parentId;
|
|
508
|
+
let isSelfNesting = false;
|
|
509
|
+
while(tempParentId !== null){
|
|
510
|
+
if(tempParentId === active.id){
|
|
511
|
+
isSelfNesting = true;
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
const parentNodeInfo = findItemDeep(treeWithoutActive, tempParentId);
|
|
515
|
+
if (!parentNodeInfo) break;
|
|
516
|
+
tempParentId = parentNodeInfo.item.parent_id ?? null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if(isSelfNesting){
|
|
520
|
+
setProjected(null);
|
|
521
|
+
setHierarchicalItems(originalTree);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function updateDepthRecursive(item: HierarchicalNavItem, newBaseDepth: number): HierarchicalNavItem {
|
|
526
|
+
const updatedItem = { ...item, depth: newBaseDepth };
|
|
527
|
+
if (updatedItem.children && updatedItem.children.length > 0) {
|
|
528
|
+
updatedItem.children = updatedItem.children.map(child => updateDepthRecursive(child, newBaseDepth + 1));
|
|
529
|
+
}
|
|
530
|
+
return updatedItem;
|
|
531
|
+
}
|
|
532
|
+
const branchWithCorrectedDepth = updateDepthRecursive(removedItemBranch, projected.depth);
|
|
533
|
+
|
|
534
|
+
const finalTreeWithAddedItem = addItemToTree(treeWithoutActive, branchWithCorrectedDepth, projected.parentId, projected.index);
|
|
535
|
+
const normalizedFinalTree = normalizeTree(finalTreeWithAddedItem);
|
|
536
|
+
|
|
537
|
+
setHierarchicalItems(normalizedFinalTree);
|
|
538
|
+
|
|
539
|
+
const itemsToUpdateDb = flattenTree(normalizedFinalTree).map(item => ({
|
|
540
|
+
id: item.id,
|
|
541
|
+
order: item.order,
|
|
542
|
+
parent_id: item.parent_id ?? null, // Coalesce undefined to null
|
|
543
|
+
}));
|
|
544
|
+
|
|
545
|
+
startTransition(async () => {
|
|
546
|
+
try {
|
|
547
|
+
const result = await updateNavigationStructureBatch(itemsToUpdateDb);
|
|
548
|
+
if (result?.error) {
|
|
549
|
+
console.error("Failed to update navigation structure:", result.error);
|
|
550
|
+
setHierarchicalItems(originalTree);
|
|
551
|
+
}
|
|
552
|
+
} catch (error) {
|
|
553
|
+
console.error("Exception during navigation update:", error);
|
|
554
|
+
setHierarchicalItems(originalTree);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
setProjected(null);
|
|
558
|
+
}, [projected, hierarchicalItems, startTransition]);
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
const _handleDragStart = (event: DragStartEvent) => {
|
|
562
|
+
setActiveId(event.active.id);
|
|
563
|
+
setProjected(null);
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
const renderItemsRecursive = (itemsToRender: HierarchicalNavItem[]): (JSX.Element | null)[] => {
|
|
568
|
+
return itemsToRender.map((item) => {
|
|
569
|
+
if (!item) return null;
|
|
570
|
+
|
|
571
|
+
const itemInfo = findItemDeep(hierarchicalItems, item.id); // Get fresh info for index calculation
|
|
572
|
+
|
|
573
|
+
// Condition 1: Projected to be the SIBLING AFTER this item
|
|
574
|
+
const isProjectedSiblingAfterThis = projected &&
|
|
575
|
+
activeId && activeId !== item.id &&
|
|
576
|
+
projected.overId === item.id && // 'item' is what we are hovering/dragging past
|
|
577
|
+
projected.parentId === (item.parent_id ?? null) && // Target parent is this item's parent
|
|
578
|
+
itemInfo && projected.index === itemInfo.index + 1; // Target index is right after this item
|
|
579
|
+
|
|
580
|
+
// Condition 2: Projected to be the FIRST CHILD of this item
|
|
581
|
+
// This is for non-indent specific drops (e.g. from getProjectedDropPosition)
|
|
582
|
+
const isProjectedAsFirstChildOfThis = projected &&
|
|
583
|
+
activeId && activeId !== item.id &&
|
|
584
|
+
projected.parentId === item.id && // This item is the target parent
|
|
585
|
+
projected.index === 0 &&
|
|
586
|
+
// Ensure this isn't the pure indent case where item has no children (which is handled by LastChildIndent)
|
|
587
|
+
!(projected.overId === item.id && item.children.length === 0 && projected.index === 0);
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
// Condition 3: Projected to be the LAST CHILD of this item (specifically for pure horizontal indent)
|
|
591
|
+
// This is when 'item' is the 'itemAbove' from the indent logic, which becomes the parent.
|
|
592
|
+
const isProjectedAsLastChildOfThisIndent = projected &&
|
|
593
|
+
activeId && activeId !== item.id &&
|
|
594
|
+
projected.parentId === item.id && // This item is the target parent
|
|
595
|
+
projected.overId === item.id && // Crucially, the 'overId' points to this item (from pure indent logic)
|
|
596
|
+
projected.index === item.children.length; // Target index is at the end of current children
|
|
597
|
+
|
|
598
|
+
return (
|
|
599
|
+
<React.Fragment key={item.id}>
|
|
600
|
+
<SortableNavItem item={item} />
|
|
601
|
+
|
|
602
|
+
{/* Render projection line if it's a SIBLING AFTER this, OR if it's the FIRST CHILD of this (non-indent case) */}
|
|
603
|
+
{/* This line appears directly under 'item', before its children are rendered. */}
|
|
604
|
+
{(isProjectedSiblingAfterThis || isProjectedAsFirstChildOfThis) && (
|
|
605
|
+
<TableRow style={{opacity: 0.5}} className="pointer-events-none !p-0 h-1">
|
|
606
|
+
<TableCell style={{ paddingLeft: `${projected.depth * INDENTATION_WIDTH + 16}px`, height: '2px' }} className="py-0 border-none">
|
|
607
|
+
<div className="bg-primary h-0.5 w-full rounded-full"></div>
|
|
608
|
+
</TableCell>
|
|
609
|
+
<TableCell colSpan={5} className="py-0 border-none h-1"><div className="bg-primary h-0.5 w-full rounded-full"></div></TableCell>
|
|
610
|
+
</TableRow>
|
|
611
|
+
)}
|
|
612
|
+
|
|
613
|
+
{item.children && item.children.length > 0 && renderItemsRecursive(item.children)}
|
|
614
|
+
|
|
615
|
+
{/* Render projection line if it's the LAST CHILD of this (due to indent) */}
|
|
616
|
+
{/* This line appears after all of 'item's children, effectively at the bottom of 'item's group. */}
|
|
617
|
+
{isProjectedAsLastChildOfThisIndent && (
|
|
618
|
+
<TableRow style={{opacity: 0.5}} className="pointer-events-none !p-0 h-1">
|
|
619
|
+
<TableCell style={{ paddingLeft: `${projected.depth * INDENTATION_WIDTH + 16}px`, height: '2px' }} className="py-0 border-none">
|
|
620
|
+
<div className="bg-primary h-0.5 w-full rounded-full"></div>
|
|
621
|
+
</TableCell>
|
|
622
|
+
<TableCell colSpan={5} className="py-0 border-none h-1"><div className="bg-primary h-0.5 w-full rounded-full"></div></TableCell>
|
|
623
|
+
</TableRow>
|
|
624
|
+
)}
|
|
625
|
+
</React.Fragment>
|
|
626
|
+
);
|
|
627
|
+
});
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const dropAnimation: DropAnimation = {
|
|
631
|
+
sideEffects: defaultDropAnimationSideEffects({
|
|
632
|
+
styles: { active: { opacity: '0.4' } },
|
|
633
|
+
}),
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
if (!isMounted) return null;
|
|
637
|
+
|
|
638
|
+
return (
|
|
639
|
+
<DndContext
|
|
640
|
+
sensors={sensors}
|
|
641
|
+
collisionDetection={closestCenter} // Consider other strategies if needed for precise boundary detection
|
|
642
|
+
onDragStart={_handleDragStart}
|
|
643
|
+
onDragEnd={handleDragEnd}
|
|
644
|
+
onDragMove={onDragMove}
|
|
645
|
+
onDragOver={onDragMove} // Use onDragOver as well if move isn't frequent enough
|
|
646
|
+
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
|
|
647
|
+
>
|
|
648
|
+
<SortableContext items={flatItemIds} strategy={verticalListSortingStrategy}>
|
|
649
|
+
<div className="rounded-lg border overflow-hidden dark:border-slate-700">
|
|
650
|
+
<Table>
|
|
651
|
+
<TableHeader>
|
|
652
|
+
<TableRow className="dark:border-slate-700">
|
|
653
|
+
<TableHead className="w-[250px] sm:w-[350px] py-2.5">Label</TableHead>
|
|
654
|
+
<TableHead className="py-2.5">URL</TableHead>
|
|
655
|
+
<TableHead className="py-2.5">Order</TableHead>
|
|
656
|
+
<TableHead className="py-2.5">Parent</TableHead>
|
|
657
|
+
<TableHead className="py-2.5">Linked Page</TableHead>
|
|
658
|
+
<TableHead className="text-right w-[80px] py-2.5">Actions</TableHead>
|
|
659
|
+
</TableRow>
|
|
660
|
+
</TableHeader>
|
|
661
|
+
<TableBody>
|
|
662
|
+
{renderItemsRecursive(hierarchicalItems)}
|
|
663
|
+
{projected && projected.parentId === null && activeId && projected.index === hierarchicalItems.filter(i=>i.parent_id === null).length && (
|
|
664
|
+
<TableRow style={{opacity: 0.5}} className="pointer-events-none !p-0 h-1">
|
|
665
|
+
<TableCell style={{ paddingLeft: `${projected.depth * INDENTATION_WIDTH + 16}px`, height: '2px' }} className="py-0 border-none">
|
|
666
|
+
<div className="bg-primary h-0.5 w-full rounded-full"></div>
|
|
667
|
+
</TableCell>
|
|
668
|
+
<TableCell colSpan={5} className="py-0 border-none h-1"><div className="bg-primary h-0.5 w-full rounded-full"></div></TableCell>
|
|
669
|
+
</TableRow>
|
|
670
|
+
)}
|
|
671
|
+
</TableBody>
|
|
672
|
+
</Table>
|
|
673
|
+
</div>
|
|
674
|
+
{typeof document !== 'undefined' && createPortal(
|
|
675
|
+
<DragOverlay dropAnimation={dropAnimation} zIndex={1000}>
|
|
676
|
+
{activeId && activeItemDataForOverlay?.item ? (
|
|
677
|
+
<Table className="shadow-xl opacity-100 bg-card w-full">
|
|
678
|
+
<TableBody>
|
|
679
|
+
<TableRow className="bg-card hover:bg-card">
|
|
680
|
+
<TableCell style={{ paddingLeft: `${(projected?.depth ?? activeItemDataForOverlay.item.depth) * INDENTATION_WIDTH + 16}px` }} className="py-2">
|
|
681
|
+
<div className="flex items-center">
|
|
682
|
+
<Button variant="ghost" size="sm" className="cursor-grabbing mr-2 p-1 opacity-50"><GripVertical className="h-4 w-4 text-muted-foreground" /></Button>
|
|
683
|
+
<span className="font-medium">{activeItemDataForOverlay.item.label}</span>
|
|
684
|
+
</div>
|
|
685
|
+
</TableCell>
|
|
686
|
+
<TableCell className="text-xs text-muted-foreground max-w-[150px] truncate py-2" title={activeItemDataForOverlay.item.url}>{activeItemDataForOverlay.item.url}</TableCell>
|
|
687
|
+
<TableCell className="py-2"><Badge variant="outline">{activeItemDataForOverlay.item.order}</Badge></TableCell>
|
|
688
|
+
<TableCell className="text-xs text-muted-foreground py-2">{activeItemDataForOverlay.parent?.label || 'None'}</TableCell>
|
|
689
|
+
<TableCell className="text-xs text-muted-foreground py-2">{activeItemDataForOverlay.item.pageSlug ? `/${activeItemDataForOverlay.item.pageSlug}` : 'Manual URL'}</TableCell>
|
|
690
|
+
<TableCell className="text-right py-2"> <MoreHorizontal className="h-4 w-4 text-muted-foreground opacity-50" /> </TableCell>
|
|
691
|
+
</TableRow>
|
|
692
|
+
</TableBody>
|
|
693
|
+
</Table>
|
|
694
|
+
) : null}
|
|
695
|
+
</DragOverlay>,
|
|
696
|
+
document.body
|
|
697
|
+
)}
|
|
698
|
+
</SortableContext>
|
|
699
|
+
</DndContext>
|
|
700
|
+
);
|
|
701
|
+
}
|