@startsimpli/ui 0.4.5 → 0.4.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 (69) hide show
  1. package/package.json +2 -1
  2. package/src/components/ActivityTimeline.tsx +173 -0
  3. package/src/components/LogActivityDialog.tsx +303 -0
  4. package/src/components/QuickLogButtons.tsx +32 -0
  5. package/src/components/badge/StageBadge.tsx +31 -0
  6. package/src/components/badge/index.ts +3 -0
  7. package/src/components/command-palette/CommandPalette.tsx +344 -0
  8. package/src/components/command-palette/command-palette-context.tsx +51 -0
  9. package/src/components/command-palette/index.ts +3 -0
  10. package/src/components/compose/compose-header.tsx +72 -0
  11. package/src/components/compose/compose-loading.tsx +13 -0
  12. package/src/components/compose/index.ts +6 -0
  13. package/src/components/compose/save-status-indicator.tsx +57 -0
  14. package/src/components/compose/send-confirmation-dialog.tsx +87 -0
  15. package/src/components/compose/subject-input.tsx +25 -0
  16. package/src/components/compose/useAutoSave.ts +93 -0
  17. package/src/components/dashboard/DashboardGrid.tsx +32 -0
  18. package/src/components/dashboard/DashboardSection.tsx +32 -0
  19. package/src/components/dashboard/MetricCard.tsx +129 -0
  20. package/src/components/dashboard/PeriodSelector.tsx +55 -0
  21. package/src/components/dashboard/SparklineTrend.tsx +102 -0
  22. package/src/components/dashboard/index.ts +14 -0
  23. package/src/components/email-dialogs/index.ts +14 -0
  24. package/src/components/email-dialogs/merge-fields.tsx +196 -0
  25. package/src/components/email-dialogs/preview-dialog.tsx +194 -0
  26. package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
  27. package/src/components/email-dialogs/template-picker.tsx +225 -0
  28. package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
  29. package/src/components/email-editor/add-block-menu.tsx +151 -0
  30. package/src/components/email-editor/block-toolbar.tsx +73 -0
  31. package/src/components/email-editor/blocks/button-block.tsx +44 -0
  32. package/src/components/email-editor/blocks/divider-block.tsx +43 -0
  33. package/src/components/email-editor/blocks/footer-block.tsx +39 -0
  34. package/src/components/email-editor/blocks/header-block.tsx +39 -0
  35. package/src/components/email-editor/blocks/image-block.tsx +61 -0
  36. package/src/components/email-editor/blocks/index.ts +9 -0
  37. package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
  38. package/src/components/email-editor/blocks/social-block.tsx +75 -0
  39. package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
  40. package/src/components/email-editor/blocks/text-block.tsx +75 -0
  41. package/src/components/email-editor/editor-sidebar.tsx +791 -0
  42. package/src/components/email-editor/email-editor.tsx +886 -0
  43. package/src/components/email-editor/index.ts +50 -0
  44. package/src/components/email-editor/renderer/block-renderers.ts +209 -0
  45. package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
  46. package/src/components/email-editor/types.ts +413 -0
  47. package/src/components/email-editor/utils/defaults.ts +116 -0
  48. package/src/components/email-editor/utils/undo-redo.ts +59 -0
  49. package/src/components/enrichment/EnrichButton.tsx +33 -0
  50. package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
  51. package/src/components/enrichment/QualityBadge.tsx +43 -0
  52. package/src/components/enrichment/index.ts +8 -0
  53. package/src/components/gantt/GanttChart.tsx +25 -25
  54. package/src/components/gantt/types.ts +5 -5
  55. package/src/components/index.ts +46 -0
  56. package/src/components/integrations/ConnectionStatus.tsx +77 -0
  57. package/src/components/integrations/IntegrationCard.tsx +92 -0
  58. package/src/components/integrations/index.ts +5 -0
  59. package/src/components/kanban/KanbanBoard.tsx +103 -0
  60. package/src/components/kanban/index.ts +2 -0
  61. package/src/components/lists/CreateListDialog.tsx +158 -0
  62. package/src/components/lists/ListCard.tsx +77 -0
  63. package/src/components/lists/index.ts +5 -0
  64. package/src/components/pipeline/StageTransitionModal.tsx +146 -0
  65. package/src/components/pipeline/index.ts +2 -0
  66. package/src/components/settings/SettingsCard.tsx +33 -0
  67. package/src/components/settings/SettingsLayout.tsx +28 -0
  68. package/src/components/settings/SettingsNav.tsx +42 -0
  69. package/src/components/settings/index.ts +6 -0
@@ -0,0 +1,886 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useState, useCallback, useRef, useEffect } from 'react'
5
+ import {
6
+ Section,
7
+ Block,
8
+ BlockType,
9
+ ColumnLayout,
10
+ GlobalStyles,
11
+ MergeFieldDefinition,
12
+ DEFAULT_GLOBAL_STYLES,
13
+ createBlock,
14
+ createRow,
15
+ createSection,
16
+ getColumnCount,
17
+ getColumnWidths,
18
+ } from './types'
19
+ import {
20
+ TextBlockEditor,
21
+ MetricsBlockEditor,
22
+ DividerBlockEditor,
23
+ ButtonBlockEditor,
24
+ ImageBlockEditor,
25
+ SpacerBlockEditor,
26
+ SocialBlockEditor,
27
+ HeaderBlockEditor,
28
+ FooterBlockEditor,
29
+ } from './blocks'
30
+ import { BlockToolbar } from './block-toolbar'
31
+ import { AddBlockMenu } from './add-block-menu'
32
+ import { EditorSidebar } from './editor-sidebar'
33
+ import {
34
+ UndoRedoState,
35
+ createUndoRedoState,
36
+ pushState,
37
+ undo as undoState,
38
+ redo as redoState,
39
+ canUndo,
40
+ canRedo,
41
+ } from './utils/undo-redo'
42
+ import { Button } from '../ui/button'
43
+ import {
44
+ Select,
45
+ SelectContent,
46
+ SelectItem,
47
+ SelectTrigger,
48
+ SelectValue,
49
+ } from '../ui/select'
50
+ import {
51
+ Undo,
52
+ Redo,
53
+ Plus,
54
+ Settings,
55
+ Columns2,
56
+ Columns3,
57
+ RectangleHorizontal,
58
+ Trash2,
59
+ } from 'lucide-react'
60
+ import { cn } from '../../lib/utils'
61
+
62
+ // --- Types ---
63
+ interface EditorSelection {
64
+ sectionIndex: number
65
+ rowIndex: number
66
+ columnIndex: number
67
+ blockIndex: number
68
+ }
69
+
70
+ interface EmailEditorProps {
71
+ sections: Section[]
72
+ onChange: (sections: Section[]) => void
73
+ globalStyles?: GlobalStyles
74
+ onGlobalStylesChange?: (styles: GlobalStyles) => void
75
+ mergeFields?: MergeFieldDefinition[]
76
+ readOnly?: boolean
77
+ /** Optional custom rich text editor for the text block. */
78
+ renderTextEditor?: (props: {
79
+ content: string
80
+ onChange: (html: string) => void
81
+ placeholder?: string
82
+ className?: string
83
+ }) => React.ReactNode
84
+ }
85
+
86
+ // --- Main Editor ---
87
+ export function EmailEditor({
88
+ sections,
89
+ onChange,
90
+ globalStyles = DEFAULT_GLOBAL_STYLES,
91
+ onGlobalStylesChange,
92
+ mergeFields,
93
+ readOnly = false,
94
+ renderTextEditor,
95
+ }: EmailEditorProps) {
96
+ const [selection, setSelection] = useState<EditorSelection | null>(null)
97
+ const [showSidebar, setShowSidebar] = useState(false)
98
+ const [dragState, setDragState] = useState<{
99
+ sourceSection: number
100
+ sourceRow: number
101
+ sourceCol: number
102
+ sourceBlock: number
103
+ } | null>(null)
104
+ const [dragOverTarget, setDragOverTarget] = useState<{
105
+ sectionIndex: number
106
+ rowIndex: number
107
+ colIndex: number
108
+ blockIndex: number
109
+ } | null>(null)
110
+
111
+ const historyRef = useRef<UndoRedoState>(createUndoRedoState(sections))
112
+
113
+ // Sync history when sections change externally
114
+ useEffect(() => {
115
+ historyRef.current = { ...historyRef.current, present: sections }
116
+ }, [sections])
117
+
118
+ const commitChange = useCallback(
119
+ (newSections: Section[]) => {
120
+ historyRef.current = pushState(historyRef.current, newSections)
121
+ onChange(newSections)
122
+ },
123
+ [onChange]
124
+ )
125
+
126
+ const handleUndo = useCallback(() => {
127
+ const newState = undoState(historyRef.current)
128
+ historyRef.current = newState
129
+ onChange(newState.present)
130
+ }, [onChange])
131
+
132
+ const handleRedo = useCallback(() => {
133
+ const newState = redoState(historyRef.current)
134
+ historyRef.current = newState
135
+ onChange(newState.present)
136
+ }, [onChange])
137
+
138
+ // Keyboard shortcuts for undo/redo
139
+ useEffect(() => {
140
+ if (readOnly) return
141
+ const handler = (e: KeyboardEvent) => {
142
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
143
+ e.preventDefault()
144
+ handleUndo()
145
+ }
146
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
147
+ e.preventDefault()
148
+ handleRedo()
149
+ }
150
+ }
151
+ window.addEventListener('keydown', handler)
152
+ return () => window.removeEventListener('keydown', handler)
153
+ }, [readOnly, handleUndo, handleRedo])
154
+
155
+ // Get selected block
156
+ const selectedBlock = selection
157
+ ? sections[selection.sectionIndex]?.rows[selection.rowIndex]?.columns[selection.columnIndex]?.[
158
+ selection.blockIndex
159
+ ] ?? null
160
+ : null
161
+
162
+ // --- Mutation helpers ---
163
+
164
+ const updateBlock = useCallback(
165
+ (sel: EditorSelection, updatedBlock: Block) => {
166
+ const newSections = sections.map((section, si) => {
167
+ if (si !== sel.sectionIndex) return section
168
+ return {
169
+ ...section,
170
+ rows: section.rows.map((row, ri) => {
171
+ if (ri !== sel.rowIndex) return row
172
+ return {
173
+ ...row,
174
+ columns: row.columns.map((col, ci) => {
175
+ if (ci !== sel.columnIndex) return col
176
+ return col.map((block, bi) => (bi === sel.blockIndex ? updatedBlock : block))
177
+ }),
178
+ }
179
+ }),
180
+ }
181
+ })
182
+ commitChange(newSections)
183
+ },
184
+ [sections, commitChange]
185
+ )
186
+
187
+ const addBlockToColumn = useCallback(
188
+ (
189
+ sectionIndex: number,
190
+ rowIndex: number,
191
+ colIndex: number,
192
+ blockType: BlockType,
193
+ afterBlockIndex?: number
194
+ ) => {
195
+ const newBlock = createBlock(blockType)
196
+ const newSections = sections.map((section, si) => {
197
+ if (si !== sectionIndex) return section
198
+ return {
199
+ ...section,
200
+ rows: section.rows.map((row, ri) => {
201
+ if (ri !== rowIndex) return row
202
+ return {
203
+ ...row,
204
+ columns: row.columns.map((col, ci) => {
205
+ if (ci !== colIndex) return col
206
+ const insertAt = afterBlockIndex !== undefined ? afterBlockIndex + 1 : col.length
207
+ return [...col.slice(0, insertAt), newBlock, ...col.slice(insertAt)]
208
+ }),
209
+ }
210
+ }),
211
+ }
212
+ })
213
+ commitChange(newSections)
214
+ // Select the new block
215
+ const insertAt = afterBlockIndex !== undefined ? afterBlockIndex + 1 : sections[sectionIndex].rows[rowIndex].columns[colIndex].length
216
+ setSelection({
217
+ sectionIndex,
218
+ rowIndex,
219
+ columnIndex: colIndex,
220
+ blockIndex: insertAt,
221
+ })
222
+ },
223
+ [sections, commitChange]
224
+ )
225
+
226
+ const removeBlock = useCallback(
227
+ (sel: EditorSelection) => {
228
+ const newSections = sections.map((section, si) => {
229
+ if (si !== sel.sectionIndex) return section
230
+ return {
231
+ ...section,
232
+ rows: section.rows.map((row, ri) => {
233
+ if (ri !== sel.rowIndex) return row
234
+ return {
235
+ ...row,
236
+ columns: row.columns.map((col, ci) => {
237
+ if (ci !== sel.columnIndex) return col
238
+ return col.filter((_, bi) => bi !== sel.blockIndex)
239
+ }),
240
+ }
241
+ }),
242
+ }
243
+ })
244
+ commitChange(newSections)
245
+ setSelection(null)
246
+ },
247
+ [sections, commitChange]
248
+ )
249
+
250
+ const duplicateBlock = useCallback(
251
+ (sel: EditorSelection) => {
252
+ const block = sections[sel.sectionIndex].rows[sel.rowIndex].columns[sel.columnIndex][sel.blockIndex]
253
+ if (!block) return
254
+ const copy: Block = { ...JSON.parse(JSON.stringify(block)), id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 6)}` }
255
+ const newSections = sections.map((section, si) => {
256
+ if (si !== sel.sectionIndex) return section
257
+ return {
258
+ ...section,
259
+ rows: section.rows.map((row, ri) => {
260
+ if (ri !== sel.rowIndex) return row
261
+ return {
262
+ ...row,
263
+ columns: row.columns.map((col, ci) => {
264
+ if (ci !== sel.columnIndex) return col
265
+ return [...col.slice(0, sel.blockIndex + 1), copy, ...col.slice(sel.blockIndex + 1)]
266
+ }),
267
+ }
268
+ }),
269
+ }
270
+ })
271
+ commitChange(newSections)
272
+ setSelection({ ...sel, blockIndex: sel.blockIndex + 1 })
273
+ },
274
+ [sections, commitChange]
275
+ )
276
+
277
+ const moveBlockInColumn = useCallback(
278
+ (sel: EditorSelection, direction: 'up' | 'down') => {
279
+ const col = sections[sel.sectionIndex].rows[sel.rowIndex].columns[sel.columnIndex]
280
+ const newIndex = direction === 'up' ? sel.blockIndex - 1 : sel.blockIndex + 1
281
+ if (newIndex < 0 || newIndex >= col.length) return
282
+ const newCol = [...col]
283
+ const [moved] = newCol.splice(sel.blockIndex, 1)
284
+ newCol.splice(newIndex, 0, moved)
285
+ const newSections = sections.map((section, si) => {
286
+ if (si !== sel.sectionIndex) return section
287
+ return {
288
+ ...section,
289
+ rows: section.rows.map((row, ri) => {
290
+ if (ri !== sel.rowIndex) return row
291
+ return {
292
+ ...row,
293
+ columns: row.columns.map((col, ci) =>
294
+ ci === sel.columnIndex ? newCol : col
295
+ ),
296
+ }
297
+ }),
298
+ }
299
+ })
300
+ commitChange(newSections)
301
+ setSelection({ ...sel, blockIndex: newIndex })
302
+ },
303
+ [sections, commitChange]
304
+ )
305
+
306
+ // --- Section/Row operations ---
307
+ const addSection = useCallback(
308
+ (afterIndex: number) => {
309
+ const newSection = createSection()
310
+ const newSections = [
311
+ ...sections.slice(0, afterIndex + 1),
312
+ newSection,
313
+ ...sections.slice(afterIndex + 1),
314
+ ]
315
+ commitChange(newSections)
316
+ },
317
+ [sections, commitChange]
318
+ )
319
+
320
+ const removeSection = useCallback(
321
+ (index: number) => {
322
+ if (sections.length <= 1) return
323
+ commitChange(sections.filter((_, i) => i !== index))
324
+ setSelection(null)
325
+ },
326
+ [sections, commitChange]
327
+ )
328
+
329
+ const addRow = useCallback(
330
+ (sectionIndex: number, layout: ColumnLayout = '1') => {
331
+ const newRow = createRow(layout)
332
+ const newSections = sections.map((section, si) => {
333
+ if (si !== sectionIndex) return section
334
+ return { ...section, rows: [...section.rows, newRow] }
335
+ })
336
+ commitChange(newSections)
337
+ },
338
+ [sections, commitChange]
339
+ )
340
+
341
+ const changeRowLayout = useCallback(
342
+ (sectionIndex: number, rowIndex: number, layout: ColumnLayout) => {
343
+ const newColCount = getColumnCount(layout)
344
+ const newSections = sections.map((section, si) => {
345
+ if (si !== sectionIndex) return section
346
+ return {
347
+ ...section,
348
+ rows: section.rows.map((row, ri) => {
349
+ if (ri !== rowIndex) return row
350
+ const columns = [...row.columns]
351
+ // Add or trim columns as needed
352
+ while (columns.length < newColCount) columns.push([])
353
+ // If reducing columns, merge extra columns into last
354
+ if (columns.length > newColCount) {
355
+ const extra = columns.splice(newColCount - 1)
356
+ columns[newColCount - 1] = extra.flat()
357
+ }
358
+ return { ...row, layout, columns }
359
+ }),
360
+ }
361
+ })
362
+ commitChange(newSections)
363
+ },
364
+ [sections, commitChange]
365
+ )
366
+
367
+ const removeRow = useCallback(
368
+ (sectionIndex: number, rowIndex: number) => {
369
+ const section = sections[sectionIndex]
370
+ if (section.rows.length <= 1) return
371
+ const newSections = sections.map((s, si) => {
372
+ if (si !== sectionIndex) return s
373
+ return { ...s, rows: s.rows.filter((_, ri) => ri !== rowIndex) }
374
+ })
375
+ commitChange(newSections)
376
+ setSelection(null)
377
+ },
378
+ [sections, commitChange]
379
+ )
380
+
381
+ // --- DnD handlers ---
382
+ const handleDragStart = useCallback(
383
+ (sectionIndex: number, rowIndex: number, colIndex: number, blockIndex: number) => {
384
+ setDragState({
385
+ sourceSection: sectionIndex,
386
+ sourceRow: rowIndex,
387
+ sourceCol: colIndex,
388
+ sourceBlock: blockIndex,
389
+ })
390
+ },
391
+ []
392
+ )
393
+
394
+ const handleDragOver = useCallback(
395
+ (
396
+ e: React.DragEvent,
397
+ sectionIndex: number,
398
+ rowIndex: number,
399
+ colIndex: number,
400
+ blockIndex: number
401
+ ) => {
402
+ e.preventDefault()
403
+ setDragOverTarget({ sectionIndex, rowIndex, colIndex, blockIndex })
404
+ },
405
+ []
406
+ )
407
+
408
+ const handleDrop = useCallback(
409
+ (
410
+ e: React.DragEvent,
411
+ targetSection: number,
412
+ targetRow: number,
413
+ targetCol: number,
414
+ targetBlock: number
415
+ ) => {
416
+ e.preventDefault()
417
+ if (!dragState) return
418
+
419
+ const { sourceSection, sourceRow, sourceCol, sourceBlock } = dragState
420
+
421
+ // Get the block being moved
422
+ const block =
423
+ sections[sourceSection]?.rows[sourceRow]?.columns[sourceCol]?.[sourceBlock]
424
+ if (!block) return
425
+
426
+ // Remove from source
427
+ let newSections = sections.map((section, si) => {
428
+ if (si !== sourceSection) return section
429
+ return {
430
+ ...section,
431
+ rows: section.rows.map((row, ri) => {
432
+ if (ri !== sourceRow) return row
433
+ return {
434
+ ...row,
435
+ columns: row.columns.map((col, ci) => {
436
+ if (ci !== sourceCol) return col
437
+ return col.filter((_, bi) => bi !== sourceBlock)
438
+ }),
439
+ }
440
+ }),
441
+ }
442
+ })
443
+
444
+ // Adjust target index if removing from same column before target
445
+ let adjustedTargetBlock = targetBlock
446
+ if (
447
+ sourceSection === targetSection &&
448
+ sourceRow === targetRow &&
449
+ sourceCol === targetCol &&
450
+ sourceBlock < targetBlock
451
+ ) {
452
+ adjustedTargetBlock -= 1
453
+ }
454
+
455
+ // Insert at target
456
+ newSections = newSections.map((section, si) => {
457
+ if (si !== targetSection) return section
458
+ return {
459
+ ...section,
460
+ rows: section.rows.map((row, ri) => {
461
+ if (ri !== targetRow) return row
462
+ return {
463
+ ...row,
464
+ columns: row.columns.map((col, ci) => {
465
+ if (ci !== targetCol) return col
466
+ return [
467
+ ...col.slice(0, adjustedTargetBlock),
468
+ block,
469
+ ...col.slice(adjustedTargetBlock),
470
+ ]
471
+ }),
472
+ }
473
+ }),
474
+ }
475
+ })
476
+
477
+ commitChange(newSections)
478
+ setDragState(null)
479
+ setDragOverTarget(null)
480
+ setSelection({
481
+ sectionIndex: targetSection,
482
+ rowIndex: targetRow,
483
+ columnIndex: targetCol,
484
+ blockIndex: adjustedTargetBlock,
485
+ })
486
+ },
487
+ [dragState, sections, commitChange]
488
+ )
489
+
490
+ const handleDragEnd = useCallback(() => {
491
+ setDragState(null)
492
+ setDragOverTarget(null)
493
+ }, [])
494
+
495
+ // --- Render block by type ---
496
+ const renderBlock = useCallback(
497
+ (block: Block, sel: EditorSelection) => {
498
+ const commonProps = {
499
+ isEditing: !readOnly,
500
+ onChange: (updated: Block) => updateBlock(sel, updated),
501
+ }
502
+
503
+ switch (block.type) {
504
+ case 'text':
505
+ return <TextBlockEditor block={block} {...commonProps} onChange={(b) => updateBlock(sel, b)} renderEditor={renderTextEditor} />
506
+ case 'metrics':
507
+ return <MetricsBlockEditor block={block} {...commonProps} onChange={(b) => updateBlock(sel, b)} />
508
+ case 'divider':
509
+ return <DividerBlockEditor block={block} {...commonProps} onChange={(b) => updateBlock(sel, b)} />
510
+ case 'cta':
511
+ return <ButtonBlockEditor block={block} {...commonProps} onChange={(b) => updateBlock(sel, b)} />
512
+ case 'image':
513
+ return <ImageBlockEditor block={block} {...commonProps} onChange={(b) => updateBlock(sel, b)} />
514
+ case 'spacer':
515
+ return <SpacerBlockEditor block={block} {...commonProps} onChange={(b) => updateBlock(sel, b)} />
516
+ case 'social':
517
+ return <SocialBlockEditor block={block} {...commonProps} onChange={(b) => updateBlock(sel, b)} />
518
+ case 'header':
519
+ return <HeaderBlockEditor block={block} {...commonProps} onChange={(b) => updateBlock(sel, b)} />
520
+ case 'footer':
521
+ return <FooterBlockEditor block={block} {...commonProps} onChange={(b) => updateBlock(sel, b)} />
522
+ default:
523
+ return null
524
+ }
525
+ },
526
+ [readOnly, updateBlock, renderTextEditor]
527
+ )
528
+
529
+ // --- Read-only mode ---
530
+ if (readOnly) {
531
+ return (
532
+ <div
533
+ className="mx-auto"
534
+ style={{
535
+ maxWidth: globalStyles.contentWidth,
536
+ backgroundColor: globalStyles.backgroundColor,
537
+ fontFamily: globalStyles.fontFamily,
538
+ }}
539
+ >
540
+ {sections.map((section, si) => (
541
+ <div
542
+ key={section.id}
543
+ style={{
544
+ backgroundColor: section.backgroundColor || '#ffffff',
545
+ paddingTop: section.paddingTop,
546
+ paddingBottom: section.paddingBottom,
547
+ paddingLeft: section.paddingLeft,
548
+ paddingRight: section.paddingRight,
549
+ borderRadius: 8,
550
+ marginBottom: 4,
551
+ }}
552
+ >
553
+ {section.rows.map((row, ri) => {
554
+ const widths = getColumnWidths(row.layout)
555
+ return (
556
+ <div key={row.id} className="flex gap-2">
557
+ {row.columns.map((col, ci) => (
558
+ <div key={ci} style={{ width: `${widths[ci]}%` }}>
559
+ {col.map((block, bi) => (
560
+ <div key={block.id}>
561
+ {renderBlock(block, { sectionIndex: si, rowIndex: ri, columnIndex: ci, blockIndex: bi })}
562
+ </div>
563
+ ))}
564
+ </div>
565
+ ))}
566
+ </div>
567
+ )
568
+ })}
569
+ </div>
570
+ ))}
571
+ </div>
572
+ )
573
+ }
574
+
575
+ // --- Editor mode ---
576
+ return (
577
+ <div className="flex h-full">
578
+ {/* Main canvas */}
579
+ <div className="flex-1 overflow-y-auto">
580
+ {/* Toolbar */}
581
+ <div className="sticky top-0 z-30 bg-background border-b px-4 py-2 flex items-center gap-2">
582
+ <Button
583
+ variant="ghost"
584
+ size="icon"
585
+ className="h-8 w-8"
586
+ onClick={handleUndo}
587
+ disabled={!canUndo(historyRef.current)}
588
+ title="Undo (Cmd+Z)"
589
+ >
590
+ <Undo className="h-4 w-4" />
591
+ </Button>
592
+ <Button
593
+ variant="ghost"
594
+ size="icon"
595
+ className="h-8 w-8"
596
+ onClick={handleRedo}
597
+ disabled={!canRedo(historyRef.current)}
598
+ title="Redo (Cmd+Shift+Z)"
599
+ >
600
+ <Redo className="h-4 w-4" />
601
+ </Button>
602
+ <div className="w-px h-6 bg-border mx-1" />
603
+ <Button
604
+ variant={showSidebar ? 'secondary' : 'ghost'}
605
+ size="sm"
606
+ className="h-8 gap-1 text-xs"
607
+ onClick={() => setShowSidebar(!showSidebar)}
608
+ >
609
+ <Settings className="h-3.5 w-3.5" />
610
+ Settings
611
+ </Button>
612
+ </div>
613
+
614
+ {/* Canvas area */}
615
+ <div
616
+ className="p-6"
617
+ style={{ backgroundColor: globalStyles.backgroundColor }}
618
+ onClick={() => setSelection(null)}
619
+ >
620
+ <div
621
+ className="mx-auto"
622
+ style={{ maxWidth: globalStyles.contentWidth }}
623
+ >
624
+ {sections.map((section, si) => (
625
+ <div key={section.id} className="mb-2">
626
+ {/* Section wrapper */}
627
+ <div
628
+ className="relative group/section rounded-lg"
629
+ style={{
630
+ backgroundColor: section.backgroundColor || '#ffffff',
631
+ paddingTop: section.paddingTop,
632
+ paddingBottom: section.paddingBottom,
633
+ paddingLeft: section.paddingLeft,
634
+ paddingRight: section.paddingRight,
635
+ }}
636
+ >
637
+ {/* Section controls */}
638
+ <div className="absolute -right-10 top-2 flex flex-col gap-1 opacity-0 group-hover/section:opacity-100 transition-opacity z-20">
639
+ {sections.length > 1 && (
640
+ <Button
641
+ variant="ghost"
642
+ size="icon"
643
+ className="h-6 w-6 text-destructive hover:text-destructive"
644
+ onClick={(e) => {
645
+ e.stopPropagation()
646
+ removeSection(si)
647
+ }}
648
+ title="Remove section"
649
+ >
650
+ <Trash2 className="h-3 w-3" />
651
+ </Button>
652
+ )}
653
+ </div>
654
+
655
+ {/* Rows */}
656
+ {section.rows.map((row, ri) => {
657
+ const widths = getColumnWidths(row.layout)
658
+ return (
659
+ <div key={row.id} className="relative group/row mb-1">
660
+ {/* Row layout controls */}
661
+ <div className="absolute -right-10 top-0 opacity-0 group-hover/row:opacity-100 transition-opacity z-10">
662
+ <Select
663
+ value={row.layout}
664
+ onValueChange={(v) =>
665
+ changeRowLayout(si, ri, v as ColumnLayout)
666
+ }
667
+ >
668
+ <SelectTrigger className="h-6 w-6 p-0 border-0 bg-transparent [&>svg]:hidden">
669
+ {row.layout === '1' ? (
670
+ <RectangleHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
671
+ ) : row.layout === '3' ? (
672
+ <Columns3 className="h-3.5 w-3.5 text-muted-foreground" />
673
+ ) : (
674
+ <Columns2 className="h-3.5 w-3.5 text-muted-foreground" />
675
+ )}
676
+ </SelectTrigger>
677
+ <SelectContent>
678
+ <SelectItem value="1">1 Column</SelectItem>
679
+ <SelectItem value="2">2 Equal</SelectItem>
680
+ <SelectItem value="3">3 Equal</SelectItem>
681
+ <SelectItem value="2-1">2/3 + 1/3</SelectItem>
682
+ <SelectItem value="1-2">1/3 + 2/3</SelectItem>
683
+ </SelectContent>
684
+ </Select>
685
+ </div>
686
+
687
+ {/* Columns */}
688
+ <div className="flex gap-2">
689
+ {row.columns.map((col, ci) => (
690
+ <div
691
+ key={ci}
692
+ style={{ width: `${widths[ci]}%` }}
693
+ className={cn(
694
+ 'min-h-[40px] rounded transition-colors',
695
+ col.length === 0 && 'border-2 border-dashed border-gray-200'
696
+ )}
697
+ onDragOver={(e) => {
698
+ e.preventDefault()
699
+ if (col.length === 0) {
700
+ setDragOverTarget({
701
+ sectionIndex: si,
702
+ rowIndex: ri,
703
+ colIndex: ci,
704
+ blockIndex: 0,
705
+ })
706
+ }
707
+ }}
708
+ onDrop={(e) => {
709
+ if (col.length === 0) {
710
+ handleDrop(e, si, ri, ci, 0)
711
+ }
712
+ }}
713
+ >
714
+ {col.length === 0 && (
715
+ <div className="flex items-center justify-center h-full p-2">
716
+ <AddBlockMenu
717
+ onAdd={(type) => addBlockToColumn(si, ri, ci, type)}
718
+ variant="inline"
719
+ />
720
+ </div>
721
+ )}
722
+
723
+ {col.map((block, bi) => {
724
+ const blockSel: EditorSelection = {
725
+ sectionIndex: si,
726
+ rowIndex: ri,
727
+ columnIndex: ci,
728
+ blockIndex: bi,
729
+ }
730
+ const isSelected =
731
+ selection?.sectionIndex === si &&
732
+ selection?.rowIndex === ri &&
733
+ selection?.columnIndex === ci &&
734
+ selection?.blockIndex === bi
735
+ const isDragOver =
736
+ dragOverTarget?.sectionIndex === si &&
737
+ dragOverTarget?.rowIndex === ri &&
738
+ dragOverTarget?.colIndex === ci &&
739
+ dragOverTarget?.blockIndex === bi
740
+
741
+ return (
742
+ <div key={block.id}>
743
+ {/* Drop zone indicator */}
744
+ {isDragOver && dragState && (
745
+ <div className="h-0.5 bg-primary rounded-full mx-2 my-1" />
746
+ )}
747
+ <div
748
+ className={cn(
749
+ 'group relative rounded transition-all cursor-pointer',
750
+ isSelected
751
+ ? 'ring-2 ring-foreground ring-offset-1'
752
+ : 'hover:ring-1 hover:ring-muted-foreground/30'
753
+ )}
754
+ onClick={(e) => {
755
+ e.stopPropagation()
756
+ setSelection(blockSel)
757
+ }}
758
+ draggable
759
+ onDragStart={(e) => {
760
+ e.stopPropagation()
761
+ handleDragStart(si, ri, ci, bi)
762
+ }}
763
+ onDragOver={(e) =>
764
+ handleDragOver(e, si, ri, ci, bi)
765
+ }
766
+ onDrop={(e) =>
767
+ handleDrop(e, si, ri, ci, bi)
768
+ }
769
+ onDragEnd={handleDragEnd}
770
+ >
771
+ {/* Block toolbar (on hover when selected) */}
772
+ {isSelected && (
773
+ <BlockToolbar
774
+ onDelete={() => removeBlock(blockSel)}
775
+ onDuplicate={() => duplicateBlock(blockSel)}
776
+ onMoveUp={() =>
777
+ moveBlockInColumn(blockSel, 'up')
778
+ }
779
+ onMoveDown={() =>
780
+ moveBlockInColumn(blockSel, 'down')
781
+ }
782
+ canMoveUp={bi > 0}
783
+ canMoveDown={bi < col.length - 1}
784
+ />
785
+ )}
786
+
787
+ {/* Block content */}
788
+ <div className="p-1">
789
+ {renderBlock(block, blockSel)}
790
+ </div>
791
+ </div>
792
+
793
+ {/* Add block between items */}
794
+ {isSelected && (
795
+ <div className="flex justify-center py-1 opacity-0 group-hover:opacity-100 hover:!opacity-100 transition-opacity">
796
+ <AddBlockMenu
797
+ onAdd={(type) =>
798
+ addBlockToColumn(si, ri, ci, type, bi)
799
+ }
800
+ variant="small"
801
+ />
802
+ </div>
803
+ )}
804
+ </div>
805
+ )
806
+ })}
807
+
808
+ {/* Add block at end of column */}
809
+ {col.length > 0 && (
810
+ <div className="flex justify-center py-1 opacity-0 hover:opacity-100 transition-opacity">
811
+ <AddBlockMenu
812
+ onAdd={(type) =>
813
+ addBlockToColumn(
814
+ si,
815
+ ri,
816
+ ci,
817
+ type,
818
+ col.length - 1
819
+ )
820
+ }
821
+ variant="small"
822
+ />
823
+ </div>
824
+ )}
825
+ </div>
826
+ ))}
827
+ </div>
828
+
829
+ {/* Add row button */}
830
+ {ri === section.rows.length - 1 && (
831
+ <div className="flex justify-center py-2 opacity-0 group-hover/section:opacity-100 transition-opacity">
832
+ <Button
833
+ variant="ghost"
834
+ size="sm"
835
+ className="h-6 text-xs gap-1 text-muted-foreground"
836
+ onClick={(e) => {
837
+ e.stopPropagation()
838
+ addRow(si)
839
+ }}
840
+ >
841
+ <Plus className="h-3 w-3" />
842
+ Add Row
843
+ </Button>
844
+ </div>
845
+ )}
846
+ </div>
847
+ )
848
+ })}
849
+ </div>
850
+
851
+ {/* Add section between */}
852
+ <div className="flex justify-center py-2">
853
+ <Button
854
+ variant="ghost"
855
+ size="sm"
856
+ className="h-6 text-xs gap-1 text-muted-foreground opacity-0 hover:opacity-100 transition-opacity"
857
+ onClick={(e) => {
858
+ e.stopPropagation()
859
+ addSection(si)
860
+ }}
861
+ >
862
+ <Plus className="h-3 w-3" />
863
+ Add Section
864
+ </Button>
865
+ </div>
866
+ </div>
867
+ ))}
868
+ </div>
869
+ </div>
870
+ </div>
871
+
872
+ {/* Sidebar */}
873
+ {showSidebar && (
874
+ <EditorSidebar
875
+ selectedBlock={selectedBlock}
876
+ onBlockChange={(block) => {
877
+ if (selection) updateBlock(selection, block)
878
+ }}
879
+ globalStyles={globalStyles}
880
+ onGlobalStylesChange={onGlobalStylesChange || (() => {})}
881
+ onClose={() => setShowSidebar(false)}
882
+ />
883
+ )}
884
+ </div>
885
+ )
886
+ }