@jhits/plugin-blog 0.0.5 → 0.0.7

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 (39) hide show
  1. package/package.json +16 -16
  2. package/src/api/config-handler.ts +76 -0
  3. package/src/api/handler.ts +4 -4
  4. package/src/api/router.ts +17 -0
  5. package/src/hooks/index.ts +1 -0
  6. package/src/hooks/useCategories.ts +76 -0
  7. package/src/index.tsx +8 -27
  8. package/src/init.tsx +0 -9
  9. package/src/lib/config-storage.ts +65 -0
  10. package/src/lib/layouts/blocks/ColumnsBlock.tsx +177 -13
  11. package/src/lib/layouts/blocks/ColumnsBlock.tsx.tmp +81 -0
  12. package/src/lib/layouts/registerLayoutBlocks.ts +6 -1
  13. package/src/lib/mappers/apiMapper.ts +53 -22
  14. package/src/registry/BlockRegistry.ts +1 -4
  15. package/src/state/EditorContext.tsx +39 -33
  16. package/src/state/types.ts +1 -1
  17. package/src/types/index.ts +2 -0
  18. package/src/types/post.ts +4 -0
  19. package/src/views/CanvasEditor/BlockWrapper.tsx +87 -24
  20. package/src/views/CanvasEditor/CanvasEditorView.tsx +214 -794
  21. package/src/views/CanvasEditor/EditorBody.tsx +317 -127
  22. package/src/views/CanvasEditor/EditorHeader.tsx +106 -17
  23. package/src/views/CanvasEditor/LayoutContainer.tsx +208 -380
  24. package/src/views/CanvasEditor/components/EditorCanvas.tsx +160 -0
  25. package/src/views/CanvasEditor/components/EditorLibrary.tsx +122 -0
  26. package/src/views/CanvasEditor/components/EditorSidebar.tsx +181 -0
  27. package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
  28. package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +260 -49
  29. package/src/views/CanvasEditor/components/index.ts +11 -0
  30. package/src/views/CanvasEditor/hooks/index.ts +10 -0
  31. package/src/views/CanvasEditor/hooks/useHeroBlock.ts +103 -0
  32. package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +142 -0
  33. package/src/views/CanvasEditor/hooks/usePostLoader.ts +39 -0
  34. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +55 -0
  35. package/src/views/CanvasEditor/hooks/useUnsavedChanges.ts +339 -0
  36. package/src/views/PostManager/PostCards.tsx +18 -13
  37. package/src/views/PostManager/PostFilters.tsx +15 -0
  38. package/src/views/PostManager/PostManagerView.tsx +21 -15
  39. package/src/views/PostManager/PostTable.tsx +7 -4
@@ -1,49 +1,27 @@
1
1
  /**
2
2
  * Layout Container Component
3
- * Recursive drop zone for nested blocks (used in Section, Columns, etc.)
3
+ * Recursive drop zone for nested blocks
4
4
  */
5
5
 
6
6
  'use client';
7
7
 
8
- import React, { useState, Fragment, useEffect } from 'react';
8
+ import React, { useState, useEffect, useRef } from 'react';
9
9
  import { Plus } from 'lucide-react';
10
10
  import { Block } from '../../types/block';
11
11
  import { BlockWrapper } from './BlockWrapper';
12
12
  import { useEditor } from '../../state/EditorContext';
13
13
 
14
14
  export interface LayoutContainerProps {
15
- /** Child blocks to render */
16
15
  blocks: Block[];
17
-
18
- /** Container ID (for nested operations) */
19
16
  containerId: string;
20
-
21
- /** Callback when a block is added to this container */
22
17
  onBlockAdd: (type: string, index: number, containerId: string) => void;
23
-
24
- /** Callback when a block is updated */
25
18
  onBlockUpdate: (id: string, data: Partial<Block['data']>, containerId: string) => void;
26
-
27
- /** Callback when a block is deleted */
28
19
  onBlockDelete: (id: string, containerId: string) => void;
29
-
30
- /** Callback when a block is moved */
31
20
  onBlockMove: (id: string, newIndex: number, containerId: string) => void;
32
-
33
- /** Additional CSS classes for styling */
34
21
  className?: string;
35
-
36
- /** Whether this is an empty state (show drop zone) */
37
- isEmpty?: boolean;
38
-
39
- /** Label for empty state */
40
22
  emptyLabel?: string;
41
23
  }
42
24
 
43
- /**
44
- * Layout Container
45
- * Recursive drop zone that can contain nested blocks
46
- */
47
25
  export function LayoutContainer({
48
26
  blocks,
49
27
  containerId,
@@ -52,443 +30,293 @@ export function LayoutContainer({
52
30
  onBlockDelete,
53
31
  onBlockMove,
54
32
  className = '',
55
- isEmpty = false,
56
33
  emptyLabel = 'Drop blocks here',
57
34
  }: LayoutContainerProps) {
58
35
  const { darkMode } = useEditor();
36
+
37
+ // --- State ---
59
38
  const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
39
+ const [dropAtEnd, setDropAtEnd] = useState(false);
60
40
  const [isDragging, setIsDragging] = useState(false);
61
- const [draggedBlockId, setDraggedBlockId] = useState<string | null>(null);
62
41
  const [dropIndicatorPosition, setDropIndicatorPosition] = useState<{ top: number; left: number; width: number } | null>(null);
63
- const containerRef = React.useRef<HTMLDivElement>(null);
64
42
 
65
- // Listen for clear-drop-indicator events from nested containers
43
+ const containerRef = useRef<HTMLDivElement>(null);
44
+ const blockRefs = useRef<Map<string, HTMLDivElement>>(new Map());
45
+ // Use ref to ensure we always have the latest dropAtEnd value (React state updates are async)
46
+ const dropAtEndRef = useRef(false);
47
+
48
+ // --- Cleanup & Event Listeners ---
66
49
  useEffect(() => {
67
- const handleClearIndicator = () => {
50
+ const resetState = () => {
68
51
  setDropIndicatorPosition(null);
69
52
  setDragOverIndex(null);
53
+ setDropAtEnd(false);
54
+ dropAtEndRef.current = false; // Reset ref too
55
+ setIsDragging(false);
56
+ // Clear global dragged block ID on dragend (in case drag was cancelled)
57
+ if (typeof window !== 'undefined') {
58
+ (window as any).__DRAGGED_BLOCK_ID__ = null;
59
+ }
70
60
  };
71
61
 
72
62
  const container = containerRef.current;
73
63
  if (container) {
74
- container.addEventListener('clear-drop-indicator', handleClearIndicator);
64
+ container.addEventListener('clear-drop-indicator', resetState);
65
+ document.addEventListener('dragend', resetState);
75
66
  return () => {
76
- container.removeEventListener('clear-drop-indicator', handleClearIndicator);
67
+ container.removeEventListener('clear-drop-indicator', resetState);
68
+ document.removeEventListener('dragend', resetState);
77
69
  };
78
70
  }
79
71
  }, []);
80
72
 
81
- // Global drag end handler - clear all indicators when drag ends
82
- useEffect(() => {
83
- const handleGlobalDragEnd = () => {
84
- setDropIndicatorPosition(null);
85
- setDragOverIndex(null);
86
- setIsDragging(false);
87
- setDraggedBlockId(null);
88
- };
89
-
90
- document.addEventListener('dragend', handleGlobalDragEnd);
91
- return () => {
92
- document.removeEventListener('dragend', handleGlobalDragEnd);
93
- };
94
- }, []);
95
-
96
- // Store refs for all blocks - use callback refs to avoid hook violations
97
- const blockRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
98
-
99
- const setBlockRef = (blockId: string) => (el: HTMLDivElement | null) => {
100
- if (el) {
101
- blockRefs.current.set(blockId, el);
102
- } else {
103
- blockRefs.current.delete(blockId);
104
- }
105
- };
73
+ // --- Drag & Drop Logic ---
106
74
 
107
- const handleDragStart = (e: React.DragEvent) => {
108
- // This is called when dragging starts from the container
109
- // The actual block drag start is handled in BlockWrapper
110
- setIsDragging(true);
111
- };
112
-
113
- const handleDragOver = (e: React.DragEvent, index: number, element?: HTMLElement) => {
75
+ const handleDragOverBlock = (e: React.DragEvent, index: number, element: HTMLElement) => {
114
76
  e.preventDefault();
115
- e.stopPropagation(); // Prevent parent containers from also handling this
77
+ e.stopPropagation();
116
78
 
117
- // Check if we're dragging over a deeper nested container - if so, clear our indicator
79
+ // 1. Check for deeper nested containers
118
80
  const target = e.target as HTMLElement;
119
81
  const deeperContainer = target.closest('[data-layout-container]');
120
82
  if (deeperContainer && deeperContainer !== containerRef.current) {
121
- // We're over a deeper nested container, clear our indicator
122
83
  setDropIndicatorPosition(null);
123
- setDragOverIndex(null);
124
84
  return;
125
85
  }
126
86
 
127
- // Clear any parent container indicators by dispatching a custom event
128
- // This ensures only the active (deepest) container shows an indicator
129
- const customEvent = new CustomEvent('clear-drop-indicator', { bubbles: true });
130
- e.currentTarget.dispatchEvent(customEvent);
87
+ // 2. Notify parent containers to hide their indicators
88
+ e.currentTarget.dispatchEvent(new CustomEvent('clear-drop-indicator', { bubbles: true }));
131
89
 
132
- setDragOverIndex(index);
90
+ // 3. Calculate "Above" vs "Below" and position indicator between blocks
91
+ const containerRect = containerRef.current!.getBoundingClientRect();
92
+ const elementRect = element.getBoundingClientRect();
93
+ const mouseRelativeToBlock = e.clientY - elementRect.top;
94
+ const isBottomHalf = mouseRelativeToBlock > (elementRect.height / 2);
133
95
 
134
- // Calculate position for absolute drop indicator using mouse position
135
- const container = containerRef.current;
136
- if (container) {
137
- requestAnimationFrame(() => {
138
- // Double-check refs are still valid inside RAF callback
139
- if (!containerRef.current) return;
140
-
141
- const containerRect = containerRef.current.getBoundingClientRect();
142
- // Use mouse Y position relative to container for accurate positioning
143
- const mouseY = e.clientY;
144
- const relativeTop = mouseY - containerRect.top;
96
+ setDragOverIndex(index);
97
+ setDropAtEnd(isBottomHalf);
98
+ dropAtEndRef.current = isBottomHalf; // Update ref immediately
99
+ setIsDragging(true);
145
100
 
146
- // Determine if we should show above or below the element
147
- let finalTop = relativeTop;
148
- if (element) {
149
- const elementRect = element.getBoundingClientRect();
150
- const elementTop = elementRect.top - containerRect.top;
151
- const elementCenter = elementTop + elementRect.height / 2;
101
+ // 4. Update Visual Indicator - always show between blocks
102
+ const elementTop = elementRect.top - containerRect.top;
103
+ const elementBottom = elementRect.bottom - containerRect.top;
104
+ let indicatorTop: number;
152
105
 
153
- // If mouse is in upper half of element, show above; otherwise show below
154
- if (relativeTop < elementCenter) {
155
- finalTop = elementTop;
156
- } else {
157
- finalTop = elementTop + elementRect.height;
158
- }
106
+ if (isBottomHalf) {
107
+ // Show below this block - position between current block and next block
108
+ if (index === blocks.length - 1) {
109
+ // Last block - show after it
110
+ indicatorTop = elementBottom;
111
+ } else {
112
+ // Get next block to find the gap
113
+ const nextBlock = blocks[index + 1];
114
+ const nextBlockEl = blockRefs.current.get(nextBlock.id);
115
+ if (nextBlockEl) {
116
+ const nextBlockRect = nextBlockEl.getBoundingClientRect();
117
+ const nextBlockTop = nextBlockRect.top - containerRect.top;
118
+ // Position in the middle of the gap (mb-4 = 16px margin)
119
+ indicatorTop = elementBottom + (nextBlockTop - elementBottom) / 2;
120
+ } else {
121
+ indicatorTop = elementBottom;
159
122
  }
160
-
161
- setDropIndicatorPosition({
162
- top: finalTop,
163
- left: 0,
164
- width: containerRect.width,
165
- });
166
- });
167
- }
168
- };
169
-
170
- const handleDragLeave = (e: React.DragEvent) => {
171
- // Only clear if we're actually leaving the container
172
- // Check if we're moving to a child element (nested container)
173
- const relatedTarget = e.relatedTarget as Node;
174
- if (!e.currentTarget.contains(relatedTarget)) {
175
- // Check if relatedTarget is inside a nested LayoutContainer
176
- const isMovingToNested = relatedTarget && (relatedTarget as HTMLElement).closest('[data-layout-container]');
177
- if (!isMovingToNested) {
178
- setDragOverIndex(null);
179
- setDropIndicatorPosition(null);
180
123
  }
181
- }
182
- };
183
-
184
- const handleDrop = (e: React.DragEvent, index: number) => {
185
- e.preventDefault();
186
- e.stopPropagation();
187
-
188
- const dataTransferBlockId = e.dataTransfer.getData('block-id');
189
- const globalBlockId = typeof window !== 'undefined' ? (window as any).__DRAGGED_BLOCK_ID__ : null;
190
- const blockId = dataTransferBlockId || globalBlockId;
191
- const blockType = e.dataTransfer.getData('block-type');
192
-
193
- console.log('[LayoutContainer] Drop Event:', {
194
- containerId,
195
- index,
196
- dataTransferBlockId,
197
- globalBlockId,
198
- resolvedBlockId: blockId,
199
- blockType,
200
- currentContainerBlocks: blocks.map(b => ({ id: b.id, type: b.type })),
201
- containerBlockIds: blocks.map(b => b.id),
202
- });
203
-
204
- // Clear the global dragged block ID
205
- if (typeof window !== 'undefined') {
206
- (window as any).__DRAGGED_BLOCK_ID__ = null;
207
- }
208
-
209
- if (blockId) {
210
- // Moving existing block - check if it's in this container or elsewhere
211
- const currentIndex = blocks.findIndex(b => b.id === blockId);
212
- console.log('[LayoutContainer] Block location check:', {
213
- blockId,
214
- currentIndex,
215
- isInThisContainer: currentIndex !== -1,
216
- targetIndex: index,
217
- containerId,
218
- });
219
-
220
- if (currentIndex !== -1) {
221
- // Block is in this container - move within container
222
- if (currentIndex !== index) {
223
- const targetIndex = currentIndex < index ? index - 1 : index;
224
- console.log('[LayoutContainer] Moving within container:', { blockId, currentIndex, targetIndex, containerId });
225
- onBlockMove(blockId, targetIndex, containerId);
124
+ } else {
125
+ // Show above this block - position between previous block and current block
126
+ if (index === 0) {
127
+ // First block - show at top of container (before first block)
128
+ indicatorTop = 0;
129
+ } else {
130
+ // Get previous block to find the gap
131
+ const prevBlock = blocks[index - 1];
132
+ const prevBlockEl = blockRefs.current.get(prevBlock.id);
133
+ if (prevBlockEl) {
134
+ const prevBlockRect = prevBlockEl.getBoundingClientRect();
135
+ const prevBlockBottom = prevBlockRect.bottom - containerRect.top;
136
+ // Position in the middle of the gap (mb-4 = 16px margin)
137
+ indicatorTop = prevBlockBottom + (elementTop - prevBlockBottom) / 2;
226
138
  } else {
227
- console.log('[LayoutContainer] Block already at target position in container');
139
+ indicatorTop = elementTop;
228
140
  }
229
- } else {
230
- // Block is in root or another container - move it here
231
- console.log('[LayoutContainer] Moving block from elsewhere to container:', { blockId, index, containerId });
232
- onBlockMove(blockId, index, containerId);
233
141
  }
234
- } else if (blockType) {
235
- // Adding new block from library
236
- console.log('[LayoutContainer] Adding new block to container:', { blockType, index, containerId });
237
- onBlockAdd(blockType, index, containerId);
238
- } else {
239
- console.warn('[LayoutContainer] Drop event with no block ID or type!');
240
- }
241
-
242
- setDragOverIndex(null);
243
- setIsDragging(false);
244
- setDraggedBlockId(null);
245
- setDropIndicatorPosition(null);
246
- };
247
-
248
- const handleBodyDragOver = (e: React.DragEvent) => {
249
- e.preventDefault();
250
- e.stopPropagation(); // Prevent parent containers from also handling this
251
-
252
- // Check if we're dragging over a deeper nested container - if so, clear our indicator
253
- const target = e.target as HTMLElement;
254
- const deeperContainer = target.closest('[data-layout-container]');
255
- if (deeperContainer && deeperContainer !== containerRef.current) {
256
- // We're over a deeper nested container, clear our indicator
257
- setDropIndicatorPosition(null);
258
- setDragOverIndex(null);
259
- setIsDragging(false);
260
- return;
261
142
  }
262
143
 
263
- // Clear any parent container indicators by dispatching a custom event
264
- const customEvent = new CustomEvent('clear-drop-indicator', { bubbles: true });
265
- e.currentTarget.dispatchEvent(customEvent);
266
-
267
- setIsDragging(true);
268
-
269
- // Calculate drop indicator position for body drop zone (at the end)
270
- const container = containerRef.current;
271
- if (container) {
272
- requestAnimationFrame(() => {
273
- if (!containerRef.current) return;
274
-
275
- const containerRect = containerRef.current.getBoundingClientRect();
276
- // Position at the bottom of the container
277
- const relativeTop = containerRect.height;
278
-
279
- setDropIndicatorPosition({
280
- top: relativeTop,
281
- left: 0,
282
- width: containerRect.width,
283
- });
284
- });
285
- }
144
+ setDropIndicatorPosition({
145
+ top: indicatorTop,
146
+ left: 0,
147
+ width: containerRect.width,
148
+ });
286
149
  };
287
150
 
288
- const handleBodyDrop = (e: React.DragEvent) => {
151
+ const handleDrop = (e: React.DragEvent, index: number | null) => {
289
152
  e.preventDefault();
290
153
  e.stopPropagation();
291
154
 
292
- const dataTransferBlockId = e.dataTransfer.getData('block-id');
293
- const globalBlockId = typeof window !== 'undefined' ? (window as any).__DRAGGED_BLOCK_ID__ : null;
294
- const blockId = dataTransferBlockId || globalBlockId;
155
+ const blockId = e.dataTransfer.getData('block-id') || (window as any).__DRAGGED_BLOCK_ID__;
295
156
  const blockType = e.dataTransfer.getData('block-type');
296
157
 
297
- console.log('[LayoutContainer] Body Drop Event (empty state):', {
298
- containerId,
299
- dataTransferBlockId,
300
- globalBlockId,
301
- resolvedBlockId: blockId,
302
- blockType,
303
- currentContainerBlocks: blocks.map(b => ({ id: b.id, type: b.type })),
304
- containerBlockIds: blocks.map(b => b.id),
305
- });
306
-
307
- // Clear the global dragged block ID
158
+ // Clear the global dragged block ID immediately to prevent it from being used for new blocks
308
159
  if (typeof window !== 'undefined') {
309
160
  (window as any).__DRAGGED_BLOCK_ID__ = null;
310
161
  }
311
162
 
163
+ // Logic: index is null when dropping on the container background (appends to end)
164
+ // When dropAtEnd is true, we want to place it AFTER the block at index, so targetIndex = index + 1
165
+ // When dropAtEnd is false, we want to place it BEFORE the block at index, so targetIndex = index
166
+ // Use ref to get the latest value (React state updates are async)
167
+ const isDropAtEnd = dropAtEndRef.current;
168
+ let targetIndex = index === null ? blocks.length : (isDropAtEnd ? index + 1 : index);
169
+
312
170
  if (blockId) {
313
- // Moving existing block - check if it's in this container or elsewhere
314
171
  const currentIndex = blocks.findIndex(b => b.id === blockId);
315
- const targetIndex = blocks.length; // When empty, this is 0, otherwise it's the end
316
-
317
- console.log('[LayoutContainer] Body drop - block location:', {
318
- blockId,
319
- currentIndex,
320
- isInThisContainer: currentIndex !== -1,
321
- targetIndex,
322
- containerId,
323
- isEmpty: blocks.length === 0,
324
- });
325
-
326
172
  if (currentIndex !== -1) {
327
- // Block is already in this container
328
- if (blocks.length === 0) {
329
- // Empty container - shouldn't happen, but handle it
330
- console.log('[LayoutContainer] Block already in empty container (should not happen)');
331
- } else if (currentIndex !== blocks.length - 1) {
332
- // Move to end
333
- console.log('[LayoutContainer] Moving within container to end:', { blockId, currentIndex, targetIndex: blocks.length - 1, containerId });
334
- onBlockMove(blockId, blocks.length - 1, containerId);
335
- } else {
336
- console.log('[LayoutContainer] Block already at end of container');
173
+ // Moving within the same array - need to adjust for removal
174
+ let finalMoveIndex = targetIndex;
175
+
176
+ if (currentIndex < targetIndex) {
177
+ // Moving forward: when we remove the item from currentIndex, everything after it shifts down by 1.
178
+ if (isDropAtEnd) {
179
+ // Dropping below: we want it at index + 1 in the original array
180
+ // If currentIndex <= index: after removal, block at index stays at index, so we want index + 1 = targetIndex
181
+ // If index < currentIndex < targetIndex: after removal, we still want index + 1, but since we removed
182
+ // an item before targetIndex, the position targetIndex in original = targetIndex - 1 in new array
183
+ if (index !== null && currentIndex <= index) {
184
+ // Item is at or before target block - no adjustment needed
185
+ finalMoveIndex = targetIndex;
186
+ } else {
187
+ // Item is after target block but before targetIndex - need to adjust
188
+ finalMoveIndex = targetIndex - 1;
189
+ }
190
+ } else {
191
+ // Dropping above: targetIndex = index means "before the block at index"
192
+ // After removal, if currentIndex < index, the block at index shifts to index - 1,
193
+ // so we want it at index - 1 in the new array.
194
+ finalMoveIndex = targetIndex - 1;
195
+ }
337
196
  }
197
+ // If currentIndex >= targetIndex, no adjustment needed (moving backward or same position)
198
+
199
+ console.log('[LayoutContainer] Drop calculation:', {
200
+ blockId,
201
+ index,
202
+ dropAtEnd: isDropAtEnd,
203
+ currentIndex,
204
+ targetIndex,
205
+ finalMoveIndex,
206
+ blocksCount: blocks.length
207
+ });
208
+
209
+ onBlockMove(blockId, Math.max(0, finalMoveIndex), containerId);
338
210
  } else {
339
- // Block is in root or another container - move it here
340
- console.log('[LayoutContainer] Moving block from elsewhere to container:', { blockId, targetIndex, containerId, isEmpty: blocks.length === 0 });
211
+ // Moving from another container - no adjustment needed
341
212
  onBlockMove(blockId, targetIndex, containerId);
342
213
  }
343
214
  } else if (blockType) {
344
- // Adding new block
345
- const targetIndex = blocks.length; // When empty, this is 0
346
- console.log('[LayoutContainer] Adding new block to container:', { blockType, index: targetIndex, containerId, isEmpty: blocks.length === 0 });
215
+ // Adding new block - use targetIndex as-is
347
216
  onBlockAdd(blockType, targetIndex, containerId);
348
- } else {
349
- console.warn('[LayoutContainer] Body drop with no block ID or type!');
350
217
  }
351
218
 
352
- setIsDragging(false);
353
- setDragOverIndex(null);
354
- setDraggedBlockId(null);
219
+ // Clean up
355
220
  setDropIndicatorPosition(null);
221
+ setDragOverIndex(null);
222
+ setDropAtEnd(false);
223
+ };
224
+
225
+ const setBlockRef = (id: string) => (el: HTMLDivElement | null) => {
226
+ if (el) blockRefs.current.set(id, el); else blockRefs.current.delete(id);
356
227
  };
357
228
 
358
229
  return (
359
230
  <div
360
231
  ref={containerRef}
361
232
  data-layout-container={containerId}
362
- className={`relative flex flex-col ${className}`}
363
- onDragOver={handleBodyDragOver}
364
- onDrop={handleBodyDrop}
365
- onDragLeave={handleDragLeave}
366
- onDragStart={handleDragStart}
367
- onDragEnd={() => {
368
- setDropIndicatorPosition(null);
369
- setDragOverIndex(null);
370
- setIsDragging(false);
371
- setDraggedBlockId(null);
233
+ className={`relative flex flex-col min-h-[40px] transition-colors ${className}`}
234
+ onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
235
+ onDrop={(e) => handleDrop(e, null)}
236
+ onDragLeave={(e) => {
237
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
238
+ setDropIndicatorPosition(null);
239
+ }
372
240
  }}
373
241
  >
374
- {/* Enhanced Drop Indicator - Visual drop zone */}
375
- {dropIndicatorPosition && (
376
- <div
377
- className="absolute z-50 pointer-events-none"
378
- style={{
379
- top: `${dropIndicatorPosition.top - 12}px`,
380
- left: `${dropIndicatorPosition.left}px`,
381
- width: `${dropIndicatorPosition.width}px`,
382
- height: '24px',
383
- }}
384
- >
385
- {/* Drop zone background */}
386
- <div className={`absolute inset-0 rounded-lg border border-dashed backdrop-blur-sm ${darkMode
387
- ? 'bg-primary/10 dark:bg-primary/20 border-primary/30 dark:border-primary/40'
388
- : 'bg-primary/10 border-primary/30'
389
- }`} />
390
-
391
- {/* Center line indicator */}
392
- <div className={`absolute top-1/2 left-0 right-0 h-0.5 rounded-full transform -translate-y-1/2 ${darkMode ? 'bg-primary dark:bg-primary/80' : 'bg-primary'
393
- }`} />
394
-
395
- {/* Drop icon indicator */}
396
- <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
397
- <div className={`w-6 h-6 rounded-full flex items-center justify-center shadow-lg shadow-primary/30 ${darkMode ? 'bg-primary dark:bg-primary/90' : 'bg-primary'
398
- }`}>
399
- <div className={`w-2 h-2 rounded-full ${darkMode ? 'bg-white dark:bg-neutral-900' : 'bg-white'
400
- }`} />
401
- </div>
402
- </div>
403
- </div>
242
+ {/* 1. Visual Indicator Overlay */}
243
+ {dropIndicatorPosition && isDragging && (
244
+ <DropIndicator position={dropIndicatorPosition} darkMode={darkMode} />
404
245
  )}
405
246
 
247
+ {/* 2. Content Area */}
406
248
  {blocks.length === 0 ? (
407
- /* Empty State - Drop Zone */
408
- <div
409
- className={`flex flex-col items-center justify-center py-12 px-6 rounded-2xl border border-dashed transition-all ${darkMode
410
- ? isDragging
411
- ? 'border-primary/30 bg-primary/5 dark:bg-primary/10'
412
- : 'border-gray-200/50 dark:border-neutral-700/50 bg-neutral-50/30 dark:bg-neutral-800/20'
413
- : isDragging
414
- ? 'border-primary/30 bg-primary/5'
415
- : 'border-gray-200/50 bg-neutral-50/30'
416
- }`}
417
- >
249
+ <EmptyState isDragging={isDragging} darkMode={darkMode} label={emptyLabel} />
250
+ ) : (
251
+ blocks.map((block, index) => (
418
252
  <div
419
- className={`p-3 rounded-full mb-3 transition-colors ${darkMode
420
- ? isDragging
421
- ? 'bg-primary/10 dark:bg-primary/20'
422
- : 'bg-neutral-100 dark:bg-neutral-800'
423
- : isDragging
424
- ? 'bg-primary/10'
425
- : 'bg-neutral-100'
426
- }`}
253
+ key={block.id}
254
+ ref={setBlockRef(block.id)}
255
+ onDragOver={(e) => handleDragOverBlock(e, index, blockRefs.current.get(block.id)!)}
256
+ onDrop={(e) => handleDrop(e, index)}
257
+ className="relative mb-4 last:mb-0"
427
258
  >
428
- <Plus
429
- size={20}
430
- className={`transition-colors ${darkMode
431
- ? isDragging
432
- ? 'text-primary'
433
- : 'text-neutral-400 dark:text-neutral-500'
434
- : isDragging
435
- ? 'text-primary'
436
- : 'text-neutral-400'
437
- }`}
259
+ <BlockWrapper
260
+ block={block}
261
+ onUpdate={(data) => onBlockUpdate(block.id, data, containerId)}
262
+ onDelete={() => onBlockDelete(block.id, containerId)}
263
+ onMoveUp={index > 0 ? () => onBlockMove(block.id, index - 1, containerId) : undefined}
264
+ onMoveDown={index < blocks.length - 1 ? () => onBlockMove(block.id, index + 1, containerId) : undefined}
438
265
  />
439
266
  </div>
440
- <p
441
- className={`text-xs font-black uppercase tracking-wider transition-colors ${darkMode
442
- ? isDragging
443
- ? 'text-primary'
444
- : 'text-neutral-500 dark:text-neutral-400'
445
- : isDragging
446
- ? 'text-primary'
447
- : 'text-neutral-500'
448
- }`}
449
- >
450
- {isDragging ? 'Drop Block Here' : emptyLabel}
451
- </p>
452
- </div>
453
- ) : (
454
- /* Blocks with visual drop zones between them */
455
- blocks.map((block, index) => (
456
- <Fragment key={block.id}>
457
- {/* Visual Drop Zone - appears between blocks when dragging */}
458
- {index > 0 && (
459
- <div
460
- className={`h-6 transition-all duration-200 ${isDragging && dragOverIndex === index
461
- ? 'bg-primary/5 border-y border-dashed border-primary/20 rounded-lg'
462
- : 'bg-transparent'
463
- }`}
464
- />
465
- )}
466
-
467
- {/* Block Container */}
468
- <div
469
- ref={setBlockRef(block.id)}
470
- onDragOver={(e) => {
471
- const blockEl = blockRefs.current.get(block.id);
472
- if (blockEl) {
473
- handleDragOver(e, index, blockEl);
474
- }
475
- }}
476
- onDragLeave={handleDragLeave}
477
- onDrop={(e) => handleDrop(e, index)}
478
- className="relative mb-4 last:mb-0"
479
- >
480
- <BlockWrapper
481
- block={block}
482
- onUpdate={(data) => onBlockUpdate(block.id, data, containerId)}
483
- onDelete={() => onBlockDelete(block.id, containerId)}
484
- onMoveUp={index > 0 ? () => onBlockMove(block.id, index - 1, containerId) : undefined}
485
- onMoveDown={index < blocks.length - 1 ? () => onBlockMove(block.id, index + 1, containerId) : undefined}
486
- />
487
- </div>
488
- </Fragment>
489
267
  ))
490
268
  )}
491
269
  </div>
492
270
  );
493
271
  }
494
272
 
273
+ /**
274
+ * Visual Line that shows where the block will land
275
+ */
276
+ function DropIndicator({ position, darkMode }: { position: any; darkMode: boolean }) {
277
+ return (
278
+ <div
279
+ className="absolute z-50 pointer-events-none"
280
+ style={{
281
+ top: `${position.top - 12}px`,
282
+ left: `${position.left}px`,
283
+ width: `${position.width}px`,
284
+ height: '24px',
285
+ }}
286
+ >
287
+ <div className={`absolute inset-0 rounded-lg border border-dashed backdrop-blur-sm
288
+ ${darkMode ? 'bg-primary/20 border-primary/40' : 'bg-primary/10 border-primary/30'}`}
289
+ />
290
+ <div className={`absolute top-1/2 left-0 right-0 h-0.5 transform -translate-y-1/2
291
+ ${darkMode ? 'bg-primary' : 'bg-primary'}`}
292
+ />
293
+ <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
294
+ <div className="w-6 h-6 rounded-full flex items-center justify-center bg-primary shadow-lg">
295
+ <div className="w-2 h-2 rounded-full bg-white" />
296
+ </div>
297
+ </div>
298
+ </div>
299
+ );
300
+ }
301
+
302
+ /**
303
+ * Placeholder when the container is empty
304
+ */
305
+ function EmptyState({ isDragging, darkMode, label }: { isDragging: boolean; darkMode: boolean; label: string }) {
306
+ return (
307
+ <div className={`flex flex-col items-center justify-center py-12 px-6 rounded-2xl border border-dashed transition-all
308
+ ${darkMode
309
+ ? isDragging ? 'border-primary/50 bg-primary/10' : 'border-neutral-700 bg-neutral-800/20'
310
+ : isDragging ? 'border-primary/50 bg-primary/5' : 'border-neutral-200 bg-neutral-50/30'
311
+ }`}
312
+ >
313
+ <div className={`p-3 rounded-full mb-3 ${darkMode ? 'bg-neutral-800' : 'bg-neutral-100'}`}>
314
+ <Plus size={20} className={isDragging ? 'text-primary' : 'text-neutral-400'} />
315
+ </div>
316
+ <p className={`text-xs font-black uppercase tracking-wider
317
+ ${isDragging ? 'text-primary' : 'text-neutral-500'}`}>
318
+ {isDragging ? 'Drop Block Here' : label}
319
+ </p>
320
+ </div>
321
+ );
322
+ }