@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.
- package/package.json +2 -1
- package/src/components/ActivityTimeline.tsx +173 -0
- package/src/components/LogActivityDialog.tsx +303 -0
- package/src/components/QuickLogButtons.tsx +32 -0
- package/src/components/badge/StageBadge.tsx +31 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/command-palette/CommandPalette.tsx +344 -0
- package/src/components/command-palette/command-palette-context.tsx +51 -0
- package/src/components/command-palette/index.ts +3 -0
- package/src/components/compose/compose-header.tsx +72 -0
- package/src/components/compose/compose-loading.tsx +13 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/save-status-indicator.tsx +57 -0
- package/src/components/compose/send-confirmation-dialog.tsx +87 -0
- package/src/components/compose/subject-input.tsx +25 -0
- package/src/components/compose/useAutoSave.ts +93 -0
- package/src/components/dashboard/DashboardGrid.tsx +32 -0
- package/src/components/dashboard/DashboardSection.tsx +32 -0
- package/src/components/dashboard/MetricCard.tsx +129 -0
- package/src/components/dashboard/PeriodSelector.tsx +55 -0
- package/src/components/dashboard/SparklineTrend.tsx +102 -0
- package/src/components/dashboard/index.ts +14 -0
- package/src/components/email-dialogs/index.ts +14 -0
- package/src/components/email-dialogs/merge-fields.tsx +196 -0
- package/src/components/email-dialogs/preview-dialog.tsx +194 -0
- package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
- package/src/components/email-dialogs/template-picker.tsx +225 -0
- package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
- package/src/components/email-editor/add-block-menu.tsx +151 -0
- package/src/components/email-editor/block-toolbar.tsx +73 -0
- package/src/components/email-editor/blocks/button-block.tsx +44 -0
- package/src/components/email-editor/blocks/divider-block.tsx +43 -0
- package/src/components/email-editor/blocks/footer-block.tsx +39 -0
- package/src/components/email-editor/blocks/header-block.tsx +39 -0
- package/src/components/email-editor/blocks/image-block.tsx +61 -0
- package/src/components/email-editor/blocks/index.ts +9 -0
- package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
- package/src/components/email-editor/blocks/social-block.tsx +75 -0
- package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
- package/src/components/email-editor/blocks/text-block.tsx +75 -0
- package/src/components/email-editor/editor-sidebar.tsx +791 -0
- package/src/components/email-editor/email-editor.tsx +886 -0
- package/src/components/email-editor/index.ts +50 -0
- package/src/components/email-editor/renderer/block-renderers.ts +209 -0
- package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
- package/src/components/email-editor/types.ts +413 -0
- package/src/components/email-editor/utils/defaults.ts +116 -0
- package/src/components/email-editor/utils/undo-redo.ts +59 -0
- package/src/components/enrichment/EnrichButton.tsx +33 -0
- package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
- package/src/components/enrichment/QualityBadge.tsx +43 -0
- package/src/components/enrichment/index.ts +8 -0
- package/src/components/gantt/GanttChart.tsx +25 -25
- package/src/components/gantt/types.ts +5 -5
- package/src/components/index.ts +46 -0
- package/src/components/integrations/ConnectionStatus.tsx +77 -0
- package/src/components/integrations/IntegrationCard.tsx +92 -0
- package/src/components/integrations/index.ts +5 -0
- package/src/components/kanban/KanbanBoard.tsx +103 -0
- package/src/components/kanban/index.ts +2 -0
- package/src/components/lists/CreateListDialog.tsx +158 -0
- package/src/components/lists/ListCard.tsx +77 -0
- package/src/components/lists/index.ts +5 -0
- package/src/components/pipeline/StageTransitionModal.tsx +146 -0
- package/src/components/pipeline/index.ts +2 -0
- package/src/components/settings/SettingsCard.tsx +33 -0
- package/src/components/settings/SettingsLayout.tsx +28 -0
- package/src/components/settings/SettingsNav.tsx +42 -0
- 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
|
+
}
|