@firecms/core 3.0.0-beta.13 → 3.0.0-beta.15

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.
Files changed (124) hide show
  1. package/dist/components/ArrayContainer.d.ts +7 -12
  2. package/dist/components/EntityCollectionTable/EntityCollectionRowActions.d.ts +2 -1
  3. package/dist/components/EntityPreview.d.ts +4 -2
  4. package/dist/components/HomePage/DefaultHomePage.d.ts +2 -15
  5. package/dist/components/HomePage/HomePageDnD.d.ts +76 -0
  6. package/dist/components/HomePage/NavigationCard.d.ts +3 -1
  7. package/dist/components/HomePage/NavigationCardBinding.d.ts +3 -2
  8. package/dist/components/HomePage/NavigationGroup.d.ts +7 -1
  9. package/dist/components/HomePage/RenameGroupDialog.d.ts +9 -0
  10. package/dist/components/SelectableTable/SelectableTable.d.ts +1 -1
  11. package/dist/components/VirtualTable/VirtualTableProps.d.ts +0 -4
  12. package/dist/components/index.d.ts +1 -0
  13. package/dist/core/EntityEditView.d.ts +3 -0
  14. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  15. package/dist/core/field_configs.d.ts +1 -1
  16. package/dist/form/EntityForm.d.ts +2 -1
  17. package/dist/form/EntityFormActions.d.ts +6 -2
  18. package/dist/form/field_bindings/MapFieldBinding.d.ts +1 -1
  19. package/dist/form/field_bindings/ReferenceAsStringFieldBinding.d.ts +9 -0
  20. package/dist/form/field_bindings/StorageUploadFieldBinding.d.ts +3 -9
  21. package/dist/form/index.d.ts +1 -0
  22. package/dist/hooks/useBuildNavigationController.d.ts +50 -8
  23. package/dist/index.es.js +3259 -1649
  24. package/dist/index.es.js.map +1 -1
  25. package/dist/index.umd.js +3265 -1658
  26. package/dist/index.umd.js.map +1 -1
  27. package/dist/types/analytics.d.ts +1 -1
  28. package/dist/types/collections.d.ts +28 -4
  29. package/dist/types/customization_controller.d.ts +8 -0
  30. package/dist/types/entity_actions.d.ts +46 -6
  31. package/dist/types/firecms.d.ts +10 -1
  32. package/dist/types/navigation.d.ts +26 -5
  33. package/dist/types/plugins.d.ts +32 -1
  34. package/dist/types/properties.d.ts +7 -0
  35. package/dist/types/property_config.d.ts +1 -1
  36. package/dist/types/side_entity_controller.d.ts +8 -0
  37. package/dist/util/callbacks.d.ts +2 -0
  38. package/dist/util/icons.d.ts +2 -2
  39. package/dist/util/index.d.ts +1 -0
  40. package/dist/util/navigation_from_path.d.ts +4 -0
  41. package/dist/util/resolutions.d.ts +2 -1
  42. package/package.json +8 -6
  43. package/src/components/ArrayContainer.tsx +409 -294
  44. package/src/components/ClearFilterSortButton.tsx +1 -1
  45. package/src/components/ConfirmationDialog.tsx +10 -9
  46. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +6 -0
  47. package/src/components/EntityCollectionTable/PropertyTableCell.tsx +26 -4
  48. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +3 -3
  49. package/src/components/EntityCollectionTable/internal/EntityTableCell.tsx +13 -2
  50. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +53 -27
  51. package/src/components/EntityCollectionTable/internal/popup_field/useDraggable.tsx +9 -9
  52. package/src/components/EntityCollectionView/EntityCollectionView.tsx +11 -4
  53. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +3 -2
  54. package/src/components/EntityPreview.tsx +18 -14
  55. package/src/components/ErrorView.tsx +1 -1
  56. package/src/components/FireCMSLogo.tsx +7 -51
  57. package/src/components/HomePage/DefaultHomePage.tsx +491 -156
  58. package/src/components/HomePage/FavouritesView.tsx +3 -3
  59. package/src/components/HomePage/HomePageDnD.tsx +599 -0
  60. package/src/components/HomePage/NavigationCard.tsx +47 -38
  61. package/src/components/HomePage/NavigationCardBinding.tsx +13 -7
  62. package/src/components/HomePage/NavigationGroup.tsx +63 -29
  63. package/src/components/HomePage/RenameGroupDialog.tsx +117 -0
  64. package/src/components/SelectableTable/SelectableTable.tsx +140 -143
  65. package/src/components/UnsavedChangesDialog.tsx +6 -2
  66. package/src/components/VirtualTable/VirtualTable.tsx +8 -30
  67. package/src/components/VirtualTable/VirtualTableProps.tsx +0 -5
  68. package/src/components/VirtualTable/fields/VirtualTableInput.tsx +0 -1
  69. package/src/components/common/default_entity_actions.tsx +25 -9
  70. package/src/components/common/useDataSourceTableController.tsx +2 -2
  71. package/src/components/index.tsx +2 -0
  72. package/src/core/DefaultDrawer.tsx +8 -8
  73. package/src/core/DrawerNavigationItem.tsx +1 -1
  74. package/src/core/EntityEditView.tsx +65 -18
  75. package/src/core/EntityEditViewFormActions.tsx +154 -29
  76. package/src/core/EntitySidePanel.tsx +19 -21
  77. package/src/core/FireCMS.tsx +9 -1
  78. package/src/core/field_configs.tsx +15 -1
  79. package/src/form/EntityForm.tsx +37 -5
  80. package/src/form/EntityFormActions.tsx +51 -9
  81. package/src/form/PropertyFieldBinding.tsx +0 -1
  82. package/src/form/components/CustomIdField.tsx +3 -1
  83. package/src/form/components/LabelWithIcon.tsx +1 -1
  84. package/src/form/components/StorageItemPreview.tsx +1 -1
  85. package/src/form/components/StorageUploadProgress.tsx +3 -3
  86. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +3 -2
  87. package/src/form/field_bindings/MapFieldBinding.tsx +5 -4
  88. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +4 -2
  89. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +1 -1
  90. package/src/form/field_bindings/ReferenceAsStringFieldBinding.tsx +135 -0
  91. package/src/form/field_bindings/SelectFieldBinding.tsx +2 -1
  92. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +216 -153
  93. package/src/form/index.tsx +1 -0
  94. package/src/hooks/data/save.ts +0 -6
  95. package/src/hooks/useBuildNavigationController.tsx +299 -96
  96. package/src/internal/useBuildSideEntityController.tsx +8 -5
  97. package/src/preview/PropertyPreview.tsx +14 -0
  98. package/src/preview/components/ImagePreview.tsx +2 -2
  99. package/src/preview/components/ReferencePreview.tsx +1 -1
  100. package/src/preview/property_previews/ArrayOfMapsPreview.tsx +1 -1
  101. package/src/preview/property_previews/MapPropertyPreview.tsx +1 -1
  102. package/src/preview/property_previews/SkeletonPropertyComponent.tsx +1 -1
  103. package/src/preview/property_previews/StringPropertyPreview.tsx +1 -1
  104. package/src/routes/FireCMSRoute.tsx +3 -3
  105. package/src/types/analytics.ts +3 -0
  106. package/src/types/collections.ts +32 -4
  107. package/src/types/customization_controller.tsx +9 -0
  108. package/src/types/entity_actions.tsx +57 -6
  109. package/src/types/firecms.tsx +11 -2
  110. package/src/types/navigation.ts +34 -6
  111. package/src/types/plugins.tsx +40 -1
  112. package/src/types/properties.ts +8 -0
  113. package/src/types/property_config.tsx +1 -0
  114. package/src/types/side_entity_controller.tsx +10 -5
  115. package/src/util/callbacks.ts +119 -0
  116. package/src/util/icons.tsx +22 -7
  117. package/src/util/index.ts +1 -0
  118. package/src/util/join_collections.ts +3 -1
  119. package/src/util/navigation_from_path.ts +15 -5
  120. package/src/util/navigation_utils.ts +72 -57
  121. package/src/util/objects.ts +53 -20
  122. package/src/util/resolutions.ts +13 -1
  123. package/dist/components/EntityCollectionTable/internal/popup_field/ElementResizeListener.d.ts +0 -5
  124. package/src/components/EntityCollectionTable/internal/popup_field/ElementResizeListener.tsx +0 -59
@@ -1,10 +1,10 @@
1
1
  import { useNavigate } from "react-router-dom";
2
2
  import { useNavigationController } from "../../hooks";
3
3
  import { useUserConfigurationPersistence } from "../../hooks/useUserConfigurationPersistence";
4
- import { TopNavigationEntry } from "../../types";
4
+ import { NavigationEntry } from "../../types";
5
5
  import { Chip, Collapse, StarIcon } from "@firecms/ui";
6
6
 
7
- function NavigationChip({ entry }: { entry: TopNavigationEntry }) {
7
+ function NavigationChip({ entry }: { entry: NavigationEntry }) {
8
8
 
9
9
  const navigate = useNavigate();
10
10
  const userConfigurationPersistence = useUserConfigurationPersistence();
@@ -48,7 +48,7 @@ export function FavouritesView({ hidden }: { hidden: boolean }) {
48
48
 
49
49
  const favouriteCollections = (userConfigurationPersistence?.favouritePaths ?? [])
50
50
  .map((path) => navigationController.topLevelNavigation?.navigationEntries.find((entry) => entry.path === path))
51
- .filter(Boolean) as TopNavigationEntry[];
51
+ .filter(Boolean) as NavigationEntry[];
52
52
 
53
53
  return <Collapse in={favouriteCollections.length > 0}>
54
54
  <div className="flex flex-row flex-wrap gap-2 pb-2 min-h-[32px]">
@@ -0,0 +1,599 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ Active,
4
+ closestCenter,
5
+ closestCorners,
6
+ CollisionDetection,
7
+ DropAnimation,
8
+ getFirstCollision,
9
+ KeyboardSensor,
10
+ MouseSensor,
11
+ pointerWithin,
12
+ rectIntersection,
13
+ TouchSensor,
14
+ UniqueIdentifier,
15
+ useDndMonitor,
16
+ useDroppable,
17
+ useSensor,
18
+ useSensors
19
+ } from "@dnd-kit/core";
20
+ import {
21
+ AnimateLayoutChanges,
22
+ arrayMove,
23
+ defaultAnimateLayoutChanges,
24
+ rectSortingStrategy,
25
+ SortableContext,
26
+ useSortable
27
+ } from "@dnd-kit/sortable";
28
+ import { CSS } from "@dnd-kit/utilities";
29
+
30
+ import { NavigationCardBinding } from "./NavigationCardBinding";
31
+ import { NavigationEntry } from "../../types";
32
+ import { cls } from "@firecms/ui";
33
+
34
+ const animateLayoutChanges: AnimateLayoutChanges = (args) =>
35
+ defaultAnimateLayoutChanges({
36
+ ...args,
37
+ wasDragging: true
38
+ });
39
+
40
+ const dropAnimationConfig: DropAnimation = {};
41
+
42
+ const cloneSerializableNavigationEntry = (entry: NavigationEntry): NavigationEntry => {
43
+ const clonedEntry: Partial<NavigationEntry> = {
44
+ id: entry.id,
45
+ path: entry.path,
46
+ url: entry.url,
47
+ name: entry.name,
48
+ type: entry.type,
49
+ collection: entry.collection ? { ...entry.collection } : undefined,
50
+ view: entry.view ? { ...entry.view } : undefined,
51
+ ...(entry.group && { group: entry.group }),
52
+ ...(entry.description && { description: entry.description })
53
+ };
54
+ return clonedEntry as NavigationEntry;
55
+ };
56
+
57
+ const cloneItemsForDnd = (items: { name: string; entries: NavigationEntry[] }[]) =>
58
+ items.map((g) => ({
59
+ name: g.name,
60
+ entries: g.entries.map(cloneSerializableNavigationEntry)
61
+ }));
62
+
63
+ /* ─────────────────────────────────────────────────────────── */
64
+ /* Sortable card & group */
65
+
66
+ /* ─────────────────────────────────────────────────────────── */
67
+ export function SortableNavigationCard({
68
+ entry,
69
+ onClick
70
+ }: {
71
+ entry: NavigationEntry;
72
+ onClick?: () => void;
73
+ }) {
74
+ const {
75
+ setNodeRef,
76
+ listeners,
77
+ attributes,
78
+ transform,
79
+ transition,
80
+ isDragging
81
+ } =
82
+ useSortable({
83
+ id: entry.url,
84
+ animateLayoutChanges
85
+ });
86
+
87
+ const style = {
88
+ transform: transform ? CSS.Transform.toString(transform) : undefined,
89
+ transition,
90
+ opacity: isDragging ? 0 : 1
91
+ };
92
+
93
+ return (
94
+ <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
95
+ <NavigationCardBinding {...entry} onClick={onClick}/>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ export function NavigationGroupDroppable({
101
+ id,
102
+ itemIds,
103
+ children,
104
+ isPotentialCardDropTarget = false
105
+ }: {
106
+ id: UniqueIdentifier;
107
+ itemIds: UniqueIdentifier[];
108
+ children: React.ReactNode;
109
+ isPotentialCardDropTarget?: boolean;
110
+ }) {
111
+ const { setNodeRef } = useDroppable({ id });
112
+
113
+ return (
114
+ <div
115
+ ref={setNodeRef}
116
+ className={cls(
117
+ isPotentialCardDropTarget
118
+ ? "p-2 bg-surface-accent-200 dark:bg-surface-accent-800 rounded-lg"
119
+ : undefined,
120
+ "transition-all duration-200 ease-in-out"
121
+ )}
122
+ >
123
+ <SortableContext items={itemIds} strategy={rectSortingStrategy}>
124
+ {children}
125
+ </SortableContext>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ export function SortableNavigationGroup({
131
+ groupName,
132
+ children,
133
+ disabled
134
+ }: {
135
+ groupName: string;
136
+ children: React.ReactNode;
137
+ disabled?: boolean;
138
+ }) {
139
+ const {
140
+ attributes,
141
+ listeners,
142
+ setNodeRef,
143
+ transform,
144
+ transition,
145
+ isDragging
146
+ } =
147
+ useSortable({
148
+ id: groupName,
149
+ animateLayoutChanges,
150
+ disabled
151
+ });
152
+
153
+ const style = {
154
+ transform: transform ? CSS.Transform.toString(transform) : undefined,
155
+ transition,
156
+ opacity: isDragging ? 0 : 1
157
+ };
158
+
159
+ return (
160
+ <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
161
+ {children}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ /* ─────────────────────────────────────────────────────────── */
167
+ /* Main DnD hook */
168
+
169
+ /* ─────────────────────────────────────────────────────────── */
170
+ export function useHomePageDnd({
171
+ items: dndItems,
172
+ setItems: setDndItems,
173
+ disabled,
174
+ onCardMovedBetweenGroups,
175
+ onGroupMoved,
176
+ onNewGroupDrop,
177
+ onPersist
178
+ }: {
179
+ items: { name: string; entries: NavigationEntry[] }[];
180
+ setItems: (
181
+ newItemsOrUpdater:
182
+ | { name: string; entries: NavigationEntry[] }[]
183
+ | ((
184
+ currentItems: { name: string; entries: NavigationEntry[] }[]
185
+ ) => { name: string; entries: NavigationEntry[] }[])
186
+ ) => void;
187
+ disabled: boolean;
188
+ onCardMovedBetweenGroups?: (card: NavigationEntry) => void;
189
+ onGroupMoved?: (groupName: string, oldIndex: number, newIndex: number) => void;
190
+ onNewGroupDrop?: () => void;
191
+ onPersist?: (latest: { name: string; entries: NavigationEntry[] }[]) => void;
192
+ }) {
193
+ /* ---------------- local state ---------------- */
194
+ const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
195
+ const [activeIsGroup, setActiveIsGroup] = useState(false);
196
+ const [currentDraggingGroupId, setCurrentDraggingGroupId] =
197
+ useState<UniqueIdentifier | null>(null);
198
+ const [dndKitActiveNode, setDndKitActiveNode] = useState<Active | null>(null);
199
+ const [isDraggingCardOnly, setIsDraggingCardOnly] = useState(false);
200
+ const [dialogOpenForGroup, setDialogOpenForGroup] = useState<string | null>(null);
201
+ const [isHoveringNewGroupDropZone, setIsHoveringNewGroupDropZone] =
202
+ useState(false);
203
+
204
+ /* store interim state for cross-group moves */
205
+ const interimItemsRef = useRef<
206
+ { name: string; entries: NavigationEntry[] }[] | null
207
+ >(null);
208
+ useEffect(() => {
209
+ interimItemsRef.current = dndItems;
210
+ }, [dndItems]);
211
+
212
+ /* ---------------- sensors ---------------- */
213
+ const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 10 } });
214
+ const touchSensor = useSensor(TouchSensor, {
215
+ activationConstraint: {
216
+ delay: 150,
217
+ tolerance: 5
218
+ }
219
+ });
220
+ const keyboardSensor = useSensor(KeyboardSensor);
221
+ const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
222
+
223
+ /* ---------------- helpers ---------------- */
224
+ const dndContainers = dndItems.map((g) => g.name);
225
+
226
+ const findDndContainer = useCallback(
227
+ (id: UniqueIdentifier): string | undefined => {
228
+ if (!id) return undefined;
229
+ const group = dndItems.find((g) => g.name === id);
230
+ if (group) return group.name;
231
+ for (const g of dndItems) {
232
+ if (g.entries.some((e) => e.url === id)) return g.name;
233
+ }
234
+ return undefined;
235
+ },
236
+ [dndItems]
237
+ );
238
+
239
+ /* ---------------- collision detection ---------------- */
240
+ const lastOverId = useRef<UniqueIdentifier | null>(null);
241
+ const recentlyMovedToNewContainer = useRef(false);
242
+
243
+ const collisionDetection: CollisionDetection = useCallback(
244
+ (args) => {
245
+ if (disabled || !activeId) return [];
246
+ if (activeIsGroup) {
247
+ const groups = args.droppableContainers.filter((c) =>
248
+ dndItems.some((g) => g.name === c.id)
249
+ );
250
+ if (!groups.length) return [];
251
+ return closestCenter({
252
+ ...args,
253
+ droppableContainers: groups
254
+ });
255
+ }
256
+
257
+ const pointer = pointerWithin(args);
258
+ if (pointer.length) {
259
+ const zone = pointer.find((c) => c.id === "new-group-drop-zone");
260
+ if (zone) return [zone];
261
+
262
+ const container = pointer.find((c) =>
263
+ dndItems.some((g) => g.name === c.id)
264
+ );
265
+ if (container) {
266
+ const itemsIn = dndItems.find((g) => g.name === container.id)
267
+ ?.entries;
268
+ if (itemsIn?.length) {
269
+ const closest = closestCorners({
270
+ ...args,
271
+ droppableContainers: args.droppableContainers.filter(
272
+ (c) => itemsIn.some((e) => e.url === c.id)
273
+ )
274
+ });
275
+ if (closest.length) return closest;
276
+ }
277
+ return [container];
278
+ }
279
+ const first = getFirstCollision(pointer, "id");
280
+ if (first) return [{ id: first }];
281
+ }
282
+
283
+ const rects = rectIntersection(args);
284
+ const zoneRect = rects.find((c) => c.id === "new-group-drop-zone");
285
+ if (zoneRect) return [zoneRect];
286
+
287
+ let overId = getFirstCollision(rects, "id");
288
+ if (overId != null) {
289
+ const overIsContainer = dndItems.some((g) => g.name === overId);
290
+ if (overIsContainer) {
291
+ const itemsIn = dndItems.find((g) => g.name === overId)
292
+ ?.entries;
293
+ if (itemsIn?.length) {
294
+ const closestItem = closestCorners({
295
+ ...args,
296
+ droppableContainers: args.droppableContainers.filter(
297
+ (c) => itemsIn.some((e) => e.url === c.id)
298
+ )
299
+ })[0]?.id;
300
+ if (closestItem) overId = closestItem;
301
+ }
302
+ }
303
+ lastOverId.current = overId;
304
+ return [{ id: overId }];
305
+ }
306
+
307
+ if (
308
+ recentlyMovedToNewContainer.current &&
309
+ lastOverId.current &&
310
+ !activeIsGroup
311
+ )
312
+ return [{ id: lastOverId.current }];
313
+
314
+ return [];
315
+ },
316
+ [activeId, dndItems, disabled, activeIsGroup]
317
+ );
318
+
319
+ /* ---------------- drag handlers ---------------- */
320
+ const handleDragStart = ({ active }: { active: Active }) => {
321
+ setDndKitActiveNode(active);
322
+ if (disabled) return;
323
+
324
+ const isGroup = dndItems.some((g) => g.name === active.id);
325
+ if (!active.data.current) active.data.current = {};
326
+ active.data.current.type = isGroup ? "group" : "item";
327
+
328
+ setActiveId(active.id);
329
+ setActiveIsGroup(isGroup);
330
+ setIsDraggingCardOnly(!isGroup);
331
+ if (isGroup) setCurrentDraggingGroupId(active.id);
332
+ recentlyMovedToNewContainer.current = false;
333
+ };
334
+
335
+ const handleDragOver = ({
336
+ active,
337
+ over
338
+ }: { active: Active; over: any }) => {
339
+ if (disabled || !over) return;
340
+
341
+ const activeIdNow = active.id;
342
+ const overIdNow = over.id;
343
+ if (activeIdNow === overIdNow) return;
344
+ if (activeIsGroup) return;
345
+
346
+ const activeCont = findDndContainer(activeIdNow);
347
+ const overCont = findDndContainer(overIdNow);
348
+ if (!activeCont) return;
349
+
350
+ if (overCont && activeCont !== overCont) {
351
+ recentlyMovedToNewContainer.current = true;
352
+ const newState = cloneItemsForDnd(dndItems);
353
+ const srcIdx = newState.findIndex((g) => g.name === activeCont);
354
+ const tgtIdx = newState.findIndex((g) => g.name === overCont);
355
+ if (srcIdx === -1 || tgtIdx === -1) return;
356
+ const src = newState[srcIdx];
357
+ const tgt = newState[tgtIdx];
358
+ const idxInSrc = src.entries.findIndex((e) => e.url === activeIdNow);
359
+ if (idxInSrc === -1) return;
360
+ const [moved] = src.entries.splice(idxInSrc, 1);
361
+ tgt.entries.push(moved);
362
+ interimItemsRef.current = newState;
363
+ setDndItems(newState);
364
+ } else if (activeCont === overCont) {
365
+ recentlyMovedToNewContainer.current = false;
366
+ }
367
+ };
368
+
369
+ const handleDragEnd = ({
370
+ active,
371
+ over
372
+ }: { active: Active; over: any }) => {
373
+ if (disabled || !over) {
374
+ resetDragState();
375
+ return;
376
+ }
377
+
378
+ const activeIdNow = active.id;
379
+ const overIdNow = over.id;
380
+
381
+ /* ─── group reorder ─── */
382
+ if (activeIsGroup) {
383
+ if (
384
+ activeIdNow !== overIdNow &&
385
+ dndItems.some((g) => g.name === overIdNow)
386
+ ) {
387
+ const from = dndItems.findIndex((g) => g.name === activeIdNow);
388
+ const to = dndItems.findIndex((g) => g.name === overIdNow);
389
+ if (from !== -1 && to !== -1) {
390
+ const newState = arrayMove(dndItems, from, to);
391
+ setDndItems(newState);
392
+ onPersist?.(newState);
393
+ onGroupMoved?.(activeIdNow as string, from, to);
394
+ }
395
+ }
396
+ }
397
+ /* ─── card move ─── */
398
+ else {
399
+ const activeCont = findDndContainer(activeIdNow);
400
+
401
+ /* drop on new-group zone */
402
+ if (overIdNow === "new-group-drop-zone") {
403
+ if (activeCont) {
404
+ const newState = cloneItemsForDnd(dndItems);
405
+ const srcIdx = newState.findIndex((g) => g.name === activeCont);
406
+ if (srcIdx !== -1) {
407
+ const src = newState[srcIdx];
408
+ const idxInSrc = src.entries.findIndex(
409
+ (e) => e.url === activeIdNow
410
+ );
411
+ if (idxInSrc !== -1) {
412
+ const [dragged] = src.entries.splice(idxInSrc, 1);
413
+ if (src.entries.length === 0) newState.splice(srcIdx, 1);
414
+
415
+ let tentative = "New Group";
416
+ let counter = 1;
417
+ while (newState.some((g) => g.name === tentative))
418
+ tentative = `New Group ${counter++}`;
419
+
420
+ newState.push({
421
+ name: tentative,
422
+ entries: [dragged]
423
+ });
424
+ setDndItems(newState);
425
+ onPersist?.(newState);
426
+ setDialogOpenForGroup(tentative);
427
+ onNewGroupDrop?.();
428
+ }
429
+ }
430
+ }
431
+ }
432
+ /* reorder inside same container */
433
+ else {
434
+ const overCont = findDndContainer(overIdNow);
435
+ if (activeCont === overCont) {
436
+ const grpIdx = dndItems.findIndex((g) => g.name === activeCont);
437
+ if (grpIdx !== -1) {
438
+ const group = dndItems[grpIdx];
439
+ const oldIdx = group.entries.findIndex(
440
+ (e) => e.url === activeIdNow
441
+ );
442
+ let newIdx = group.entries.findIndex(
443
+ (e) => e.url === overIdNow
444
+ );
445
+ if (newIdx === -1 && overIdNow === activeCont)
446
+ newIdx = group.entries.length - 1;
447
+ if (
448
+ oldIdx !== -1 &&
449
+ newIdx !== -1 &&
450
+ oldIdx !== newIdx
451
+ ) {
452
+ const reordered = arrayMove(group.entries, oldIdx, newIdx);
453
+ const newState = [...dndItems];
454
+ newState[grpIdx] = {
455
+ ...group,
456
+ entries: reordered
457
+ };
458
+ setDndItems(newState);
459
+ onPersist?.(newState);
460
+ }
461
+ }
462
+ } else if (
463
+ recentlyMovedToNewContainer.current &&
464
+ interimItemsRef.current
465
+ ) {
466
+ onPersist?.(interimItemsRef.current);
467
+ }
468
+
469
+ onCardMovedBetweenGroups?.(
470
+ dndItems
471
+ .flatMap((g) => g.entries)
472
+ .find((e) => e.url === activeIdNow)!
473
+ );
474
+ }
475
+ }
476
+
477
+ resetDragState();
478
+ };
479
+
480
+ const resetDragState = () => {
481
+ setDndKitActiveNode(null);
482
+ setActiveId(null);
483
+ setActiveIsGroup(false);
484
+ setCurrentDraggingGroupId(null);
485
+ setIsDraggingCardOnly(false);
486
+ recentlyMovedToNewContainer.current = false;
487
+ };
488
+
489
+ const handleDragCancel = () => resetDragState();
490
+
491
+ /* ---------------- group rename ---------------- */
492
+ const handleRenameGroup = (oldName: string, newName: string) => {
493
+ setDndItems((current) => {
494
+ const idx = current.findIndex((g) => g.name === oldName);
495
+ if (idx === -1) return current;
496
+ if (current.some((g) => g.name === newName && g.name !== oldName))
497
+ return current;
498
+
499
+ const updated = [...current];
500
+ updated[idx] = {
501
+ ...updated[idx],
502
+ name: newName
503
+ };
504
+ onPersist?.(updated); // <- ensure rename is saved
505
+ return updated;
506
+ });
507
+ };
508
+
509
+ /* ---------------- public API ---------------- */
510
+ const activeItemForOverlay =
511
+ disabled || !activeId || activeIsGroup
512
+ ? null
513
+ : dndItems.flatMap((g) => g.entries).find((e) => e.url === activeId) || null;
514
+
515
+ const activeGroupData =
516
+ disabled || !activeId || !activeIsGroup
517
+ ? null
518
+ : dndItems.find((g) => g.name === activeId) || null;
519
+
520
+ return {
521
+ sensors,
522
+ collisionDetection,
523
+ onDragStart: handleDragStart,
524
+ onDragOver: handleDragOver,
525
+ onDragEnd: handleDragEnd,
526
+ onDragCancel: handleDragCancel,
527
+ dropAnimation: dropAnimationConfig,
528
+ activeItemForOverlay,
529
+ activeGroupData,
530
+ draggingGroupId: currentDraggingGroupId,
531
+ containers: dndContainers,
532
+ dndKitActiveNode,
533
+ isDraggingCardOnly,
534
+ dialogOpenForGroup,
535
+ setDialogOpenForGroup,
536
+ handleRenameGroup,
537
+ isHoveringNewGroupDropZone,
538
+ setIsHoveringNewGroupDropZone
539
+ };
540
+ }
541
+
542
+ /* ─────────────────────────────────────────────────────────── */
543
+ /* New-group drop-zone component */
544
+
545
+ /* ─────────────────────────────────────────────────────────── */
546
+ export function NewGroupDropZone({
547
+ disabled,
548
+ setIsHovering
549
+ }: {
550
+ disabled: boolean;
551
+ setIsHovering: (v: boolean) => void;
552
+ }) {
553
+ const {
554
+ setNodeRef,
555
+ isOver
556
+ } = useDroppable({
557
+ id: "new-group-drop-zone",
558
+ disabled
559
+ });
560
+ const [isVisible, setIsVisible] = useState(false);
561
+
562
+ useDndMonitor({
563
+ onDragStart({ active }) {
564
+ if (disabled) return;
565
+ const tp = active.data.current?.type;
566
+ setIsVisible(tp === "item");
567
+ },
568
+ onDragEnd() {
569
+ setIsVisible(false);
570
+ },
571
+ onDragCancel() {
572
+ setIsVisible(false);
573
+ }
574
+ });
575
+
576
+ useEffect(() => {
577
+ setIsHovering(isOver && isVisible);
578
+ }, [isOver, isVisible, setIsHovering]);
579
+
580
+ if (!isVisible || disabled) return null;
581
+
582
+ return (
583
+ <div
584
+ ref={setNodeRef}
585
+ className={cls(
586
+ "fixed right-8 top-1/2 -translate-y-1/2 w-[200px] h-[120px] border border-dashed rounded-lg flex items-center justify-center transition-all",
587
+ isOver
588
+ ? "bg-surface-accent-100 dark:bg-surface-accent-800 border-surface-300 dark:border-surface-600"
589
+ : "bg-surface-50 dark:bg-surface-900 border-surface-200 dark:border-surface-700"
590
+ )}
591
+ >
592
+ <div className="text-center p-4">
593
+ <span className="block font-medium text-sm">
594
+ Drop here to create a new group
595
+ </span>
596
+ </div>
597
+ </div>
598
+ );
599
+ }