@jhits/plugin-newsletter 0.0.10 → 0.0.11
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 +3 -2
- package/src/api/email-utils.ts +165 -0
- package/src/api/handler.ts +28 -0
- package/src/api/handlers/index.ts +44 -0
- package/src/api/handlers/newsletters.ts +332 -0
- package/src/api/handlers/send-newsletter.ts +288 -0
- package/src/api/handlers/settings.ts +403 -0
- package/src/api/handlers/subscribers.ts +152 -0
- package/src/api/handlers/upload.ts +47 -0
- package/src/api/handlers/welcome-email.ts +210 -0
- package/src/api/router.ts +166 -0
- package/src/index.server.ts +12 -0
- package/src/index.tsx +353 -0
- package/src/index.tsx.patch +98 -0
- package/src/init.tsx +72 -0
- package/src/lib/blocks/BlockRenderer.tsx +125 -0
- package/src/lib/email/EmailRenderer.tsx +420 -0
- package/src/lib/email/index.ts +6 -0
- package/src/lib/i18n.ts +82 -0
- package/src/lib/mappers/apiMapper.ts +57 -0
- package/src/lib/utils/blockHelpers.ts +71 -0
- package/src/lib/utils/slugify.ts +43 -0
- package/src/registry/BlockRegistry.ts +53 -0
- package/src/registry/index.ts +5 -0
- package/src/state/EditorContext.tsx +278 -0
- package/src/state/index.ts +10 -0
- package/src/state/reducer.ts +561 -0
- package/src/state/types.ts +154 -0
- package/src/types/block.ts +275 -0
- package/src/types/newsletter.ts +152 -0
- package/src/types/registry.ts +14 -0
- package/src/views/CanvasEditor/BlockWrapper.tsx +143 -0
- package/src/views/CanvasEditor/CanvasEditorView.tsx +343 -0
- package/src/views/CanvasEditor/EditorBody.tsx +95 -0
- package/src/views/CanvasEditor/EditorHeader.tsx +255 -0
- package/src/views/CanvasEditor/components/CustomBlockItem.tsx +83 -0
- package/src/views/CanvasEditor/components/EditorCanvas.tsx +674 -0
- package/src/views/CanvasEditor/components/EditorLibrary.tsx +120 -0
- package/src/views/CanvasEditor/components/EditorSidebar.tsx +139 -0
- package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
- package/src/views/CanvasEditor/components/LibraryItem.tsx +71 -0
- package/src/views/CanvasEditor/components/SlashCommandDetector.tsx +196 -0
- package/src/views/CanvasEditor/components/SlashCommandMenu.tsx +131 -0
- package/src/views/CanvasEditor/components/index.ts +16 -0
- package/src/views/CanvasEditor/hooks/index.ts +7 -0
- package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +136 -0
- package/src/views/CanvasEditor/hooks/useNewsletterLoader.ts +73 -0
- package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +54 -0
- package/src/views/CanvasEditor/hooks/useSlashCommand.ts +106 -0
- package/src/views/CanvasEditor/index.ts +12 -0
- package/src/views/NewsletterEditor.tsx +42 -0
- package/src/views/NewsletterManager.tsx +483 -0
- package/src/views/SettingsView.tsx +216 -0
- package/src/views/SubscribersView.tsx +269 -0
- package/src/views/components/SendNewsletterModal.tsx +322 -0
- package/src/views/components/SmtpSettingsModal.tsx +433 -0
- package/src/views/components/TestEmailModal.tsx +268 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import { useEditor } from '../../state/EditorContext';
|
|
5
|
+
import { EditorHeader } from './EditorHeader';
|
|
6
|
+
import { ErrorBanner } from './components/ErrorBanner';
|
|
7
|
+
import { EditorCanvas } from './components/EditorCanvas';
|
|
8
|
+
import { EditorSidebar } from './components/EditorSidebar';
|
|
9
|
+
import { SlashCommandMenu } from './components/SlashCommandMenu';
|
|
10
|
+
import { useSlashCommand } from './hooks/useSlashCommand';
|
|
11
|
+
import { useNewsletterLoader, useRegisteredBlocks, useKeyboardShortcuts } from './hooks';
|
|
12
|
+
import { blockRegistry } from '../../registry';
|
|
13
|
+
import type { Block } from '../../types/block';
|
|
14
|
+
import type { NewsletterMetadata } from '../../types/newsletter';
|
|
15
|
+
|
|
16
|
+
export interface CanvasEditorViewProps {
|
|
17
|
+
newsletterId?: string;
|
|
18
|
+
siteId: string;
|
|
19
|
+
locale: string;
|
|
20
|
+
/** Enable dark mode for content area and wrappers (default: true) */
|
|
21
|
+
darkMode?: boolean;
|
|
22
|
+
/** Background colors for the editor */
|
|
23
|
+
backgroundColors?: {
|
|
24
|
+
light: string;
|
|
25
|
+
dark?: string;
|
|
26
|
+
};
|
|
27
|
+
/** If true, this editor is for the welcome email */
|
|
28
|
+
isWelcomeEmail?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function CanvasEditorView({ newsletterId, darkMode, backgroundColors: propsBackgroundColors, siteId, locale, isWelcomeEmail }: CanvasEditorViewProps) {
|
|
32
|
+
const { state, helpers, dispatch, darkMode: contextDarkMode, backgroundColors: contextBackgroundColors, canUndo, canRedo } = useEditor();
|
|
33
|
+
const effectiveDarkMode = darkMode !== undefined ? darkMode : contextDarkMode;
|
|
34
|
+
const effectiveBackgroundColors = propsBackgroundColors || contextBackgroundColors;
|
|
35
|
+
const [isSidebarOpen, setSidebarOpen] = useState(true);
|
|
36
|
+
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
|
37
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
38
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
39
|
+
|
|
40
|
+
// Primary language from SMTP settings (used for welcome email)
|
|
41
|
+
const [primaryLanguage, setPrimaryLanguage] = useState<string>(locale || 'en');
|
|
42
|
+
const [isLoadingLanguage, setIsLoadingLanguage] = useState(true);
|
|
43
|
+
const [availableLanguages, setAvailableLanguages] = useState<string[]>([locale || 'en']);
|
|
44
|
+
const [currentLanguage, setCurrentLanguage] = useState<string>(locale || 'en');
|
|
45
|
+
|
|
46
|
+
// Get registered blocks
|
|
47
|
+
const registeredBlocks = useRegisteredBlocks();
|
|
48
|
+
|
|
49
|
+
// Newsletter loading - use current language for welcome email (wait until language settings are loaded)
|
|
50
|
+
const { isLoadingNewsletter } = useNewsletterLoader(
|
|
51
|
+
newsletterId,
|
|
52
|
+
state.newsletterId,
|
|
53
|
+
(newsletter) => {
|
|
54
|
+
helpers.loadNewsletter(newsletter);
|
|
55
|
+
// Set current language from loaded newsletter metadata
|
|
56
|
+
if (newsletter.metadata?.lang && !isWelcomeEmail) {
|
|
57
|
+
setCurrentLanguage(newsletter.metadata.lang);
|
|
58
|
+
}
|
|
59
|
+
setTimeout(() => {
|
|
60
|
+
dispatch({ type: 'MARK_CLEAN' });
|
|
61
|
+
}, 0);
|
|
62
|
+
},
|
|
63
|
+
isWelcomeEmail,
|
|
64
|
+
!isLoadingLanguage ? currentLanguage : undefined
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Fetch primary language and available languages from SMTP/welcome-email settings
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const fetchLanguageSettings = async () => {
|
|
70
|
+
try {
|
|
71
|
+
const [smtpResponse, welcomeResponse] = await Promise.all([
|
|
72
|
+
fetch('/api/plugin-newsletter/smtp'),
|
|
73
|
+
fetch('/api/plugin-newsletter/welcome-email/status')
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const smtpData = await smtpResponse.json();
|
|
77
|
+
const welcomeData = await welcomeResponse.json();
|
|
78
|
+
|
|
79
|
+
const primary = smtpData.primaryLanguage || 'en';
|
|
80
|
+
const available = welcomeData.availableLanguages || [primary];
|
|
81
|
+
|
|
82
|
+
if (!available.includes(primary)) {
|
|
83
|
+
available.unshift(primary);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setPrimaryLanguage(primary);
|
|
87
|
+
setAvailableLanguages(available);
|
|
88
|
+
setCurrentLanguage(primary);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error('Failed to fetch language settings:', error);
|
|
91
|
+
} finally {
|
|
92
|
+
setIsLoadingLanguage(false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
fetchLanguageSettings();
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
// Handle language change for welcome email
|
|
99
|
+
const handleLanguageChange = async (newLanguage: string) => {
|
|
100
|
+
// Save current content first if dirty
|
|
101
|
+
if (state.isDirty) {
|
|
102
|
+
const confirmed = window.confirm('You have unsaved changes. Do you want to save them first?');
|
|
103
|
+
if (confirmed) {
|
|
104
|
+
await handleSave();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setCurrentLanguage(newLanguage);
|
|
109
|
+
|
|
110
|
+
// Reload with new language
|
|
111
|
+
const response = await fetch(`/api/plugin-newsletter/welcome-email?language=${newLanguage}`);
|
|
112
|
+
if (response.ok) {
|
|
113
|
+
const newsletter = await response.json();
|
|
114
|
+
helpers.loadNewsletter(newsletter);
|
|
115
|
+
dispatch({ type: 'MARK_CLEAN' });
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Handle adding a new language
|
|
120
|
+
const handleAddLanguage = async (newLanguage: string) => {
|
|
121
|
+
if (availableLanguages.includes(newLanguage)) return;
|
|
122
|
+
|
|
123
|
+
// Add the new language to the list
|
|
124
|
+
setAvailableLanguages([...availableLanguages, newLanguage]);
|
|
125
|
+
|
|
126
|
+
// Switch to the new language (it will copy from primary language)
|
|
127
|
+
await handleLanguageChange(newLanguage);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Ensure at least one paragraph block exists when creating new newsletter
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!newsletterId && !isWelcomeEmail && state.blocks.length === 0 && !isLoadingNewsletter) {
|
|
133
|
+
helpers.addBlock('paragraph', 0, undefined);
|
|
134
|
+
}
|
|
135
|
+
}, [newsletterId, state.blocks.length, isLoadingNewsletter, helpers, isWelcomeEmail]);
|
|
136
|
+
|
|
137
|
+
// Track if we just loaded a newsletter to prevent marking as dirty during cleanup
|
|
138
|
+
const justLoadedRef = useRef(false);
|
|
139
|
+
const previousIsLoadingRef = useRef<boolean>(false);
|
|
140
|
+
const loadingCleanupTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
141
|
+
|
|
142
|
+
// Mark when newsletter loading completes and ensure it stays clean after all effects
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const loadingJustFinished = previousIsLoadingRef.current && !isLoadingNewsletter && state.newsletterId;
|
|
145
|
+
|
|
146
|
+
if (loadingJustFinished) {
|
|
147
|
+
justLoadedRef.current = true;
|
|
148
|
+
|
|
149
|
+
if (loadingCleanupTimerRef.current) {
|
|
150
|
+
clearTimeout(loadingCleanupTimerRef.current);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
requestAnimationFrame(() => {
|
|
154
|
+
requestAnimationFrame(() => {
|
|
155
|
+
loadingCleanupTimerRef.current = setTimeout(() => {
|
|
156
|
+
dispatch({ type: 'MARK_CLEAN' });
|
|
157
|
+
justLoadedRef.current = false;
|
|
158
|
+
loadingCleanupTimerRef.current = null;
|
|
159
|
+
}, 500);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
previousIsLoadingRef.current = isLoadingNewsletter;
|
|
165
|
+
|
|
166
|
+
return () => {
|
|
167
|
+
if (loadingCleanupTimerRef.current) {
|
|
168
|
+
clearTimeout(loadingCleanupTimerRef.current);
|
|
169
|
+
loadingCleanupTimerRef.current = null;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}, [isLoadingNewsletter, state.newsletterId, dispatch]);
|
|
173
|
+
|
|
174
|
+
// Keyboard shortcuts
|
|
175
|
+
useKeyboardShortcuts(state, dispatch, canUndo, canRedo, helpers.undo, helpers.redo);
|
|
176
|
+
|
|
177
|
+
// Slash command handler - always replaces the current paragraph block
|
|
178
|
+
const handleSlashCommandSelect = useCallback((blockType: string, replaceBlockId?: string) => {
|
|
179
|
+
if (replaceBlockId) {
|
|
180
|
+
// Replace existing block (the paragraph that triggered the slash command)
|
|
181
|
+
const blockIndex = state.blocks.findIndex(b => b.id === replaceBlockId);
|
|
182
|
+
if (blockIndex === -1) {
|
|
183
|
+
console.warn(`[CanvasEditorView] Block with ID "${replaceBlockId}" not found. Available blocks:`, state.blocks.map(b => b.id));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const blockDefinition = blockRegistry.get(blockType);
|
|
188
|
+
if (!blockDefinition) {
|
|
189
|
+
console.warn(`[CanvasEditorView] Block type "${blockType}" not found in registry`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Replace the block in place by updating the blocks array directly
|
|
194
|
+
// This preserves the position and ID
|
|
195
|
+
const newBlocks = state.blocks.map((block, idx) => {
|
|
196
|
+
if (idx === blockIndex) {
|
|
197
|
+
// Replace this block with the new type
|
|
198
|
+
return {
|
|
199
|
+
...block,
|
|
200
|
+
type: blockType,
|
|
201
|
+
data: { ...blockDefinition.defaultData },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// Keep all other blocks unchanged
|
|
205
|
+
return block;
|
|
206
|
+
});
|
|
207
|
+
dispatch({ type: 'SET_BLOCKS', payload: newBlocks });
|
|
208
|
+
} else {
|
|
209
|
+
console.warn('[CanvasEditorView] No replaceBlockId provided to handleSlashCommandSelect');
|
|
210
|
+
}
|
|
211
|
+
}, [state.blocks, dispatch]);
|
|
212
|
+
|
|
213
|
+
// Slash command hook
|
|
214
|
+
const slashCommand = useSlashCommand(registeredBlocks, handleSlashCommandSelect);
|
|
215
|
+
|
|
216
|
+
// Handle save
|
|
217
|
+
const handleSave = async () => {
|
|
218
|
+
setIsSaving(true);
|
|
219
|
+
setSaveError(null);
|
|
220
|
+
try {
|
|
221
|
+
await helpers.save();
|
|
222
|
+
setIsSaving(false);
|
|
223
|
+
} catch (error: any) {
|
|
224
|
+
console.error('[CanvasEditorView] Save error:', error);
|
|
225
|
+
let errorMessage = error.message || 'Failed to save newsletter';
|
|
226
|
+
if (errorMessage.includes('Unauthorized')) {
|
|
227
|
+
errorMessage = 'You are not authorized to save. Please log in again.';
|
|
228
|
+
}
|
|
229
|
+
setSaveError(errorMessage);
|
|
230
|
+
setIsSaving(false);
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
if (isLoadingNewsletter) {
|
|
236
|
+
return (
|
|
237
|
+
<div className="h-full w-full bg-dashboard-card text-dashboard-text flex items-center justify-center">
|
|
238
|
+
<div className="text-center">
|
|
239
|
+
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin mx-auto mb-4" />
|
|
240
|
+
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading newsletter...</p>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<div className="h-full rounded-[2.5rem] w-full bg-dashboard-card text-dashboard-text flex flex-col font-sans transition-colors duration-300 overflow-hidden relative">
|
|
248
|
+
{isLoadingLanguage ? (
|
|
249
|
+
<div className="h-full w-full flex items-center justify-center">
|
|
250
|
+
<div className="text-center">
|
|
251
|
+
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
|
252
|
+
<p className="text-sm text-dashboard-text-secondary">Loading...</p>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
) : (
|
|
256
|
+
<main className="flex flex-1 flex-col relative min-h-0">
|
|
257
|
+
{/* Error Banner */}
|
|
258
|
+
<ErrorBanner error={saveError} onDismiss={() => setSaveError(null)} />
|
|
259
|
+
|
|
260
|
+
<EditorHeader
|
|
261
|
+
isPreviewMode={isPreviewMode}
|
|
262
|
+
onPreviewToggle={() => setIsPreviewMode(!isPreviewMode)}
|
|
263
|
+
isSidebarOpen={isSidebarOpen}
|
|
264
|
+
onSidebarToggle={() => setSidebarOpen(!isSidebarOpen)}
|
|
265
|
+
isSaving={isSaving}
|
|
266
|
+
onSave={handleSave}
|
|
267
|
+
onSaveError={(error) => {
|
|
268
|
+
if (error) {
|
|
269
|
+
setSaveError(error);
|
|
270
|
+
} else {
|
|
271
|
+
setSaveError(null);
|
|
272
|
+
}
|
|
273
|
+
}}
|
|
274
|
+
isDirty={state.isDirty}
|
|
275
|
+
isWelcomeEmail={isWelcomeEmail}
|
|
276
|
+
languages={availableLanguages}
|
|
277
|
+
currentLanguage={currentLanguage}
|
|
278
|
+
onLanguageChange={handleLanguageChange}
|
|
279
|
+
onAddLanguage={handleAddLanguage}
|
|
280
|
+
/>
|
|
281
|
+
|
|
282
|
+
{/* Editor Content Wrapper */}
|
|
283
|
+
<div className="flex flex-1 relative overflow-hidden min-h-0 flex-nowrap">
|
|
284
|
+
{/* CENTER: THE WRITING CANVAS */}
|
|
285
|
+
<EditorCanvas
|
|
286
|
+
isPreviewMode={isPreviewMode}
|
|
287
|
+
contentBlocks={state.blocks}
|
|
288
|
+
title={state.title}
|
|
289
|
+
siteId={siteId}
|
|
290
|
+
locale={locale}
|
|
291
|
+
darkMode={effectiveDarkMode}
|
|
292
|
+
backgroundColors={effectiveBackgroundColors}
|
|
293
|
+
metadata={state.metadata}
|
|
294
|
+
onTitleChange={(title: string) => dispatch({ type: 'SET_TITLE', payload: title })}
|
|
295
|
+
onMetadataChange={(metadata) => dispatch({ type: 'SET_METADATA', payload: metadata })}
|
|
296
|
+
onBlockAdd={(type: string, index: number, containerId?: string) => helpers.addBlock(type, index, containerId)}
|
|
297
|
+
onBlockUpdate={(id: string, data: Partial<Block['data']>) => helpers.updateBlock(id, data)}
|
|
298
|
+
onBlockDelete={(id: string) => helpers.deleteBlock(id)}
|
|
299
|
+
onBlockMove={(id: string, newIndex: number, containerId?: string) => helpers.moveBlock(id, newIndex, containerId)}
|
|
300
|
+
slashCommand={slashCommand}
|
|
301
|
+
/>
|
|
302
|
+
|
|
303
|
+
{/* RIGHT SIDEBAR: THE "DESK" (SETTINGS) */}
|
|
304
|
+
{!isPreviewMode && (
|
|
305
|
+
<aside
|
|
306
|
+
className={`transition-all duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] border-l border-dashboard-border bg-dashboard-sidebar overflow-y-auto overflow-x-hidden h-full ${isSidebarOpen ? 'w-80' : 'w-0 opacity-0 pointer-events-none'
|
|
307
|
+
}`}
|
|
308
|
+
>
|
|
309
|
+
<EditorSidebar
|
|
310
|
+
metadata={state.metadata}
|
|
311
|
+
status={state.status}
|
|
312
|
+
onMetadataUpdate={(metadata: Partial<NewsletterMetadata>) => dispatch({ type: 'SET_METADATA', payload: metadata })}
|
|
313
|
+
defaultLanguage={primaryLanguage}
|
|
314
|
+
isWelcomeEmail={isWelcomeEmail}
|
|
315
|
+
languages={availableLanguages}
|
|
316
|
+
currentLanguage={currentLanguage}
|
|
317
|
+
onLanguageChange={handleLanguageChange}
|
|
318
|
+
/>
|
|
319
|
+
</aside>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
{/* Slash Command Menu */}
|
|
324
|
+
{slashCommand.isOpen && slashCommand.position && (
|
|
325
|
+
<SlashCommandMenu
|
|
326
|
+
blocks={slashCommand.filteredBlocks}
|
|
327
|
+
query={slashCommand.query}
|
|
328
|
+
selectedIndex={slashCommand.selectedIndex}
|
|
329
|
+
onSelect={(replaceBlockId) => {
|
|
330
|
+
// Use the replaceBlockId passed from the menu (which comes from state)
|
|
331
|
+
// This ensures we use the correct block ID that was set when the menu opened
|
|
332
|
+
slashCommand.selectCurrent(replaceBlockId);
|
|
333
|
+
}}
|
|
334
|
+
position={slashCommand.position}
|
|
335
|
+
onClose={slashCommand.closeMenu}
|
|
336
|
+
replaceBlockId={slashCommand.replaceBlockId}
|
|
337
|
+
/>
|
|
338
|
+
)}
|
|
339
|
+
</main>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor Body Component
|
|
3
|
+
* Simplified Notion-style editor with single paragraph by default
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React, { useState, useRef } from 'react';
|
|
9
|
+
import { Plus } from 'lucide-react';
|
|
10
|
+
import { Block } from '../../types/block';
|
|
11
|
+
import { BlockWrapper } from './BlockWrapper';
|
|
12
|
+
import type { useSlashCommand } from './hooks/useSlashCommand';
|
|
13
|
+
|
|
14
|
+
export interface EditorBodyProps {
|
|
15
|
+
blocks: Block[];
|
|
16
|
+
onBlockAdd: (type: string, index: number, containerId?: string) => void;
|
|
17
|
+
onBlockUpdate: (id: string, data: Partial<Block['data']>) => void;
|
|
18
|
+
onBlockDelete: (id: string) => void;
|
|
19
|
+
onBlockMove: (id: string, newIndex: number, containerId?: string) => void;
|
|
20
|
+
/** Enable dark mode for content area and wrappers (default: true) */
|
|
21
|
+
darkMode?: boolean;
|
|
22
|
+
/** Background colors for the editor */
|
|
23
|
+
backgroundColors?: {
|
|
24
|
+
light: string;
|
|
25
|
+
dark?: string;
|
|
26
|
+
};
|
|
27
|
+
/** Slash command handler */
|
|
28
|
+
slashCommand?: ReturnType<typeof useSlashCommand>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function EditorBody({
|
|
32
|
+
blocks,
|
|
33
|
+
onBlockAdd,
|
|
34
|
+
onBlockUpdate,
|
|
35
|
+
onBlockDelete,
|
|
36
|
+
onBlockMove,
|
|
37
|
+
darkMode = true,
|
|
38
|
+
backgroundColors,
|
|
39
|
+
slashCommand,
|
|
40
|
+
}: EditorBodyProps) {
|
|
41
|
+
const handleDelete = (blockId: string, index: number) => {
|
|
42
|
+
onBlockDelete(blockId);
|
|
43
|
+
// If deleting the last block, ensure we still have at least one paragraph
|
|
44
|
+
if (blocks.length === 1) {
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
onBlockAdd('paragraph', 0);
|
|
47
|
+
}, 0);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="relative">
|
|
53
|
+
{blocks.map((block, index) => (
|
|
54
|
+
<React.Fragment key={block.id}>
|
|
55
|
+
{/* Add Block Button Above */}
|
|
56
|
+
<div className="relative h-2 group/add-button">
|
|
57
|
+
<button
|
|
58
|
+
onClick={() => onBlockAdd('paragraph', index)}
|
|
59
|
+
className="absolute left-0 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center opacity-0 group-hover/add-button:opacity-100 transition-opacity hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded"
|
|
60
|
+
title="Add block"
|
|
61
|
+
>
|
|
62
|
+
<Plus size={14} className="text-neutral-400 dark:text-neutral-500" />
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Block */}
|
|
67
|
+
<div className="relative">
|
|
68
|
+
<BlockWrapper
|
|
69
|
+
block={block}
|
|
70
|
+
onUpdate={(data) => onBlockUpdate(block.id, data)}
|
|
71
|
+
onDelete={() => handleDelete(block.id, index)}
|
|
72
|
+
onMoveUp={index > 0 ? () => onBlockMove(block.id, index - 1) : undefined}
|
|
73
|
+
onMoveDown={index < blocks.length - 1 ? () => onBlockMove(block.id, index + 1) : undefined}
|
|
74
|
+
allBlocks={blocks}
|
|
75
|
+
slashCommand={slashCommand}
|
|
76
|
+
blockIndex={index}
|
|
77
|
+
onAddBlockBelow={(blockType) => onBlockAdd(blockType, index + 1)}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
</React.Fragment>
|
|
81
|
+
))}
|
|
82
|
+
|
|
83
|
+
{/* Add Block Button at Bottom */}
|
|
84
|
+
<div className="relative h-8 group/add-button-bottom mt-2">
|
|
85
|
+
<button
|
|
86
|
+
onClick={() => onBlockAdd('paragraph', blocks.length)}
|
|
87
|
+
className="absolute left-0 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center opacity-0 group-hover/add-button-bottom:opacity-100 transition-opacity hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded"
|
|
88
|
+
title="Add block"
|
|
89
|
+
>
|
|
90
|
+
<Plus size={14} className="text-neutral-400 dark:text-neutral-500" />
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|