@firecms/core 3.0.0-canary.248 → 3.0.0-canary.249

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