@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.
Files changed (57) hide show
  1. package/package.json +3 -2
  2. package/src/api/email-utils.ts +165 -0
  3. package/src/api/handler.ts +28 -0
  4. package/src/api/handlers/index.ts +44 -0
  5. package/src/api/handlers/newsletters.ts +332 -0
  6. package/src/api/handlers/send-newsletter.ts +288 -0
  7. package/src/api/handlers/settings.ts +403 -0
  8. package/src/api/handlers/subscribers.ts +152 -0
  9. package/src/api/handlers/upload.ts +47 -0
  10. package/src/api/handlers/welcome-email.ts +210 -0
  11. package/src/api/router.ts +166 -0
  12. package/src/index.server.ts +12 -0
  13. package/src/index.tsx +353 -0
  14. package/src/index.tsx.patch +98 -0
  15. package/src/init.tsx +72 -0
  16. package/src/lib/blocks/BlockRenderer.tsx +125 -0
  17. package/src/lib/email/EmailRenderer.tsx +420 -0
  18. package/src/lib/email/index.ts +6 -0
  19. package/src/lib/i18n.ts +82 -0
  20. package/src/lib/mappers/apiMapper.ts +57 -0
  21. package/src/lib/utils/blockHelpers.ts +71 -0
  22. package/src/lib/utils/slugify.ts +43 -0
  23. package/src/registry/BlockRegistry.ts +53 -0
  24. package/src/registry/index.ts +5 -0
  25. package/src/state/EditorContext.tsx +278 -0
  26. package/src/state/index.ts +10 -0
  27. package/src/state/reducer.ts +561 -0
  28. package/src/state/types.ts +154 -0
  29. package/src/types/block.ts +275 -0
  30. package/src/types/newsletter.ts +152 -0
  31. package/src/types/registry.ts +14 -0
  32. package/src/views/CanvasEditor/BlockWrapper.tsx +143 -0
  33. package/src/views/CanvasEditor/CanvasEditorView.tsx +343 -0
  34. package/src/views/CanvasEditor/EditorBody.tsx +95 -0
  35. package/src/views/CanvasEditor/EditorHeader.tsx +255 -0
  36. package/src/views/CanvasEditor/components/CustomBlockItem.tsx +83 -0
  37. package/src/views/CanvasEditor/components/EditorCanvas.tsx +674 -0
  38. package/src/views/CanvasEditor/components/EditorLibrary.tsx +120 -0
  39. package/src/views/CanvasEditor/components/EditorSidebar.tsx +139 -0
  40. package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
  41. package/src/views/CanvasEditor/components/LibraryItem.tsx +71 -0
  42. package/src/views/CanvasEditor/components/SlashCommandDetector.tsx +196 -0
  43. package/src/views/CanvasEditor/components/SlashCommandMenu.tsx +131 -0
  44. package/src/views/CanvasEditor/components/index.ts +16 -0
  45. package/src/views/CanvasEditor/hooks/index.ts +7 -0
  46. package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +136 -0
  47. package/src/views/CanvasEditor/hooks/useNewsletterLoader.ts +73 -0
  48. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +54 -0
  49. package/src/views/CanvasEditor/hooks/useSlashCommand.ts +106 -0
  50. package/src/views/CanvasEditor/index.ts +12 -0
  51. package/src/views/NewsletterEditor.tsx +42 -0
  52. package/src/views/NewsletterManager.tsx +483 -0
  53. package/src/views/SettingsView.tsx +216 -0
  54. package/src/views/SubscribersView.tsx +269 -0
  55. package/src/views/components/SendNewsletterModal.tsx +322 -0
  56. package/src/views/components/SmtpSettingsModal.tsx +433 -0
  57. 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
+ }