@jhits/plugin-newsletter 0.0.6 → 0.0.8
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 +8 -9
- package/src/api/handler.ts +0 -693
- package/src/api/router.ts +0 -111
- package/src/index.server.ts +0 -12
- package/src/index.tsx +0 -313
- package/src/index.tsx.patch +0 -98
- package/src/init.tsx +0 -72
- package/src/lib/blocks/BlockRenderer.tsx +0 -125
- package/src/lib/email/EmailRenderer.tsx +0 -425
- package/src/lib/email/index.ts +0 -6
- package/src/lib/mappers/apiMapper.ts +0 -57
- package/src/lib/utils/blockHelpers.ts +0 -71
- package/src/lib/utils/slugify.ts +0 -43
- package/src/registry/BlockRegistry.ts +0 -53
- package/src/registry/index.ts +0 -5
- package/src/state/EditorContext.tsx +0 -279
- package/src/state/index.ts +0 -10
- package/src/state/reducer.ts +0 -561
- package/src/state/types.ts +0 -154
- package/src/types/block.ts +0 -275
- package/src/types/newsletter.ts +0 -151
- package/src/types/registry.ts +0 -14
- package/src/views/CanvasEditor/BlockWrapper.tsx +0 -143
- package/src/views/CanvasEditor/CanvasEditorView.tsx +0 -249
- package/src/views/CanvasEditor/EditorBody.tsx +0 -95
- package/src/views/CanvasEditor/EditorHeader.tsx +0 -139
- package/src/views/CanvasEditor/components/CustomBlockItem.tsx +0 -83
- package/src/views/CanvasEditor/components/EditorCanvas.tsx +0 -674
- package/src/views/CanvasEditor/components/EditorLibrary.tsx +0 -120
- package/src/views/CanvasEditor/components/EditorSidebar.tsx +0 -156
- package/src/views/CanvasEditor/components/ErrorBanner.tsx +0 -31
- package/src/views/CanvasEditor/components/LibraryItem.tsx +0 -71
- package/src/views/CanvasEditor/components/SlashCommandDetector.tsx +0 -196
- package/src/views/CanvasEditor/components/SlashCommandMenu.tsx +0 -131
- package/src/views/CanvasEditor/components/index.ts +0 -16
- package/src/views/CanvasEditor/hooks/index.ts +0 -7
- package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +0 -136
- package/src/views/CanvasEditor/hooks/useNewsletterLoader.ts +0 -34
- package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +0 -54
- package/src/views/CanvasEditor/hooks/useSlashCommand.ts +0 -106
- package/src/views/CanvasEditor/index.ts +0 -12
- package/src/views/NewsletterEditor.tsx +0 -38
- package/src/views/NewsletterManager.tsx +0 -240
- package/src/views/SettingsView.tsx +0 -216
- package/src/views/SubscribersView.tsx +0 -269
|
@@ -1,249 +0,0 @@
|
|
|
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
|
-
newsletterSlug?: 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
|
-
}
|
|
28
|
-
|
|
29
|
-
export function CanvasEditorView({ newsletterSlug, darkMode, backgroundColors: propsBackgroundColors, siteId, locale }: CanvasEditorViewProps) {
|
|
30
|
-
const { state, helpers, dispatch, darkMode: contextDarkMode, backgroundColors: contextBackgroundColors, canUndo, canRedo } = useEditor();
|
|
31
|
-
const effectiveDarkMode = darkMode !== undefined ? darkMode : contextDarkMode;
|
|
32
|
-
const effectiveBackgroundColors = propsBackgroundColors || contextBackgroundColors;
|
|
33
|
-
const [isSidebarOpen, setSidebarOpen] = useState(true);
|
|
34
|
-
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
|
35
|
-
const [isSaving, setIsSaving] = useState(false);
|
|
36
|
-
const [saveError, setSaveError] = useState<string | null>(null);
|
|
37
|
-
|
|
38
|
-
// Get registered blocks
|
|
39
|
-
const registeredBlocks = useRegisteredBlocks();
|
|
40
|
-
|
|
41
|
-
// Newsletter loading
|
|
42
|
-
const { isLoadingNewsletter } = useNewsletterLoader(
|
|
43
|
-
newsletterSlug,
|
|
44
|
-
state.newsletterId,
|
|
45
|
-
(newsletter) => {
|
|
46
|
-
helpers.loadNewsletter(newsletter);
|
|
47
|
-
setTimeout(() => {
|
|
48
|
-
dispatch({ type: 'MARK_CLEAN' });
|
|
49
|
-
}, 0);
|
|
50
|
-
}
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
// Ensure at least one paragraph block exists when creating new newsletter
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (!newsletterSlug && state.blocks.length === 0 && !isLoadingNewsletter) {
|
|
56
|
-
helpers.addBlock('paragraph', 0, undefined);
|
|
57
|
-
}
|
|
58
|
-
}, [newsletterSlug, state.blocks.length, isLoadingNewsletter, helpers]);
|
|
59
|
-
|
|
60
|
-
// Track if we just loaded a newsletter to prevent marking as dirty during cleanup
|
|
61
|
-
const justLoadedRef = useRef(false);
|
|
62
|
-
const previousIsLoadingRef = useRef<boolean>(false);
|
|
63
|
-
const loadingCleanupTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
64
|
-
|
|
65
|
-
// Mark when newsletter loading completes and ensure it stays clean after all effects
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
const loadingJustFinished = previousIsLoadingRef.current && !isLoadingNewsletter && state.newsletterId;
|
|
68
|
-
|
|
69
|
-
if (loadingJustFinished) {
|
|
70
|
-
justLoadedRef.current = true;
|
|
71
|
-
|
|
72
|
-
if (loadingCleanupTimerRef.current) {
|
|
73
|
-
clearTimeout(loadingCleanupTimerRef.current);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
requestAnimationFrame(() => {
|
|
77
|
-
requestAnimationFrame(() => {
|
|
78
|
-
loadingCleanupTimerRef.current = setTimeout(() => {
|
|
79
|
-
console.log('[CanvasEditorView] Newsletter loading complete - ensuring clean state');
|
|
80
|
-
dispatch({ type: 'MARK_CLEAN' });
|
|
81
|
-
justLoadedRef.current = false;
|
|
82
|
-
loadingCleanupTimerRef.current = null;
|
|
83
|
-
}, 500);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
previousIsLoadingRef.current = isLoadingNewsletter;
|
|
89
|
-
|
|
90
|
-
return () => {
|
|
91
|
-
if (loadingCleanupTimerRef.current) {
|
|
92
|
-
clearTimeout(loadingCleanupTimerRef.current);
|
|
93
|
-
loadingCleanupTimerRef.current = null;
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
}, [isLoadingNewsletter, state.newsletterId, dispatch]);
|
|
97
|
-
|
|
98
|
-
// Keyboard shortcuts
|
|
99
|
-
useKeyboardShortcuts(state, dispatch, canUndo, canRedo, helpers.undo, helpers.redo);
|
|
100
|
-
|
|
101
|
-
// Slash command handler - always replaces the current paragraph block
|
|
102
|
-
const handleSlashCommandSelect = useCallback((blockType: string, replaceBlockId?: string) => {
|
|
103
|
-
if (replaceBlockId) {
|
|
104
|
-
// Replace existing block (the paragraph that triggered the slash command)
|
|
105
|
-
const blockIndex = state.blocks.findIndex(b => b.id === replaceBlockId);
|
|
106
|
-
if (blockIndex === -1) {
|
|
107
|
-
console.warn(`[CanvasEditorView] Block with ID "${replaceBlockId}" not found. Available blocks:`, state.blocks.map(b => b.id));
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const blockDefinition = blockRegistry.get(blockType);
|
|
112
|
-
if (!blockDefinition) {
|
|
113
|
-
console.warn(`[CanvasEditorView] Block type "${blockType}" not found in registry`);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Replace the block in place by updating the blocks array directly
|
|
118
|
-
// This preserves the position and ID
|
|
119
|
-
const newBlocks = state.blocks.map((block, idx) => {
|
|
120
|
-
if (idx === blockIndex) {
|
|
121
|
-
// Replace this block with the new type
|
|
122
|
-
return {
|
|
123
|
-
...block,
|
|
124
|
-
type: blockType,
|
|
125
|
-
data: { ...blockDefinition.defaultData },
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
// Keep all other blocks unchanged
|
|
129
|
-
return block;
|
|
130
|
-
});
|
|
131
|
-
dispatch({ type: 'SET_BLOCKS', payload: newBlocks });
|
|
132
|
-
} else {
|
|
133
|
-
console.warn('[CanvasEditorView] No replaceBlockId provided to handleSlashCommandSelect');
|
|
134
|
-
}
|
|
135
|
-
}, [state.blocks, dispatch]);
|
|
136
|
-
|
|
137
|
-
// Slash command hook
|
|
138
|
-
const slashCommand = useSlashCommand(registeredBlocks, handleSlashCommandSelect);
|
|
139
|
-
|
|
140
|
-
// Handle save
|
|
141
|
-
const handleSave = async () => {
|
|
142
|
-
setIsSaving(true);
|
|
143
|
-
setSaveError(null);
|
|
144
|
-
try {
|
|
145
|
-
await helpers.save();
|
|
146
|
-
setIsSaving(false);
|
|
147
|
-
} catch (error: any) {
|
|
148
|
-
console.error('[CanvasEditorView] Save error:', error);
|
|
149
|
-
let errorMessage = error.message || 'Failed to save newsletter';
|
|
150
|
-
if (errorMessage.includes('Unauthorized')) {
|
|
151
|
-
errorMessage = 'You are not authorized to save. Please log in again.';
|
|
152
|
-
}
|
|
153
|
-
setSaveError(errorMessage);
|
|
154
|
-
setIsSaving(false);
|
|
155
|
-
throw error;
|
|
156
|
-
}
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
if (isLoadingNewsletter) {
|
|
160
|
-
return (
|
|
161
|
-
<div className="h-full w-full bg-dashboard-card text-dashboard-text flex items-center justify-center">
|
|
162
|
-
<div className="text-center">
|
|
163
|
-
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin mx-auto mb-4" />
|
|
164
|
-
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading newsletter...</p>
|
|
165
|
-
</div>
|
|
166
|
-
</div>
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return (
|
|
171
|
-
<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">
|
|
172
|
-
<main className="flex flex-1 flex-col relative min-h-0">
|
|
173
|
-
{/* Error Banner */}
|
|
174
|
-
<ErrorBanner error={saveError} onDismiss={() => setSaveError(null)} />
|
|
175
|
-
|
|
176
|
-
<EditorHeader
|
|
177
|
-
isPreviewMode={isPreviewMode}
|
|
178
|
-
onPreviewToggle={() => setIsPreviewMode(!isPreviewMode)}
|
|
179
|
-
isSidebarOpen={isSidebarOpen}
|
|
180
|
-
onSidebarToggle={() => setSidebarOpen(!isSidebarOpen)}
|
|
181
|
-
isSaving={isSaving}
|
|
182
|
-
onSave={handleSave}
|
|
183
|
-
onSaveError={(error) => {
|
|
184
|
-
if (error) {
|
|
185
|
-
setSaveError(error);
|
|
186
|
-
} else {
|
|
187
|
-
setSaveError(null);
|
|
188
|
-
}
|
|
189
|
-
}}
|
|
190
|
-
isDirty={state.isDirty}
|
|
191
|
-
/>
|
|
192
|
-
|
|
193
|
-
{/* Editor Content Wrapper */}
|
|
194
|
-
<div className="flex flex-1 relative overflow-hidden min-h-0 flex-nowrap">
|
|
195
|
-
{/* CENTER: THE WRITING CANVAS */}
|
|
196
|
-
<EditorCanvas
|
|
197
|
-
isPreviewMode={isPreviewMode}
|
|
198
|
-
contentBlocks={state.blocks}
|
|
199
|
-
title={state.title}
|
|
200
|
-
siteId={siteId}
|
|
201
|
-
locale={locale}
|
|
202
|
-
darkMode={effectiveDarkMode}
|
|
203
|
-
backgroundColors={effectiveBackgroundColors}
|
|
204
|
-
metadata={state.metadata}
|
|
205
|
-
onTitleChange={(title: string) => dispatch({ type: 'SET_TITLE', payload: title })}
|
|
206
|
-
onMetadataChange={(metadata) => dispatch({ type: 'SET_METADATA', payload: metadata })}
|
|
207
|
-
onBlockAdd={(type: string, index: number, containerId?: string) => helpers.addBlock(type, index, containerId)}
|
|
208
|
-
onBlockUpdate={(id: string, data: Partial<Block['data']>) => helpers.updateBlock(id, data)}
|
|
209
|
-
onBlockDelete={(id: string) => helpers.deleteBlock(id)}
|
|
210
|
-
onBlockMove={(id: string, newIndex: number, containerId?: string) => helpers.moveBlock(id, newIndex, containerId)}
|
|
211
|
-
slashCommand={slashCommand}
|
|
212
|
-
/>
|
|
213
|
-
|
|
214
|
-
{/* RIGHT SIDEBAR: THE "DESK" (SETTINGS) */}
|
|
215
|
-
{!isPreviewMode && (
|
|
216
|
-
<aside
|
|
217
|
-
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'
|
|
218
|
-
}`}
|
|
219
|
-
>
|
|
220
|
-
<EditorSidebar
|
|
221
|
-
slug={state.slug}
|
|
222
|
-
metadata={state.metadata}
|
|
223
|
-
status={state.status}
|
|
224
|
-
onMetadataUpdate={(metadata: Partial<NewsletterMetadata>) => dispatch({ type: 'SET_METADATA', payload: metadata })}
|
|
225
|
-
/>
|
|
226
|
-
</aside>
|
|
227
|
-
)}
|
|
228
|
-
</div>
|
|
229
|
-
|
|
230
|
-
{/* Slash Command Menu */}
|
|
231
|
-
{slashCommand.isOpen && slashCommand.position && (
|
|
232
|
-
<SlashCommandMenu
|
|
233
|
-
blocks={slashCommand.filteredBlocks}
|
|
234
|
-
query={slashCommand.query}
|
|
235
|
-
selectedIndex={slashCommand.selectedIndex}
|
|
236
|
-
onSelect={(replaceBlockId) => {
|
|
237
|
-
// Use the replaceBlockId passed from the menu (which comes from state)
|
|
238
|
-
// This ensures we use the correct block ID that was set when the menu opened
|
|
239
|
-
slashCommand.selectCurrent(replaceBlockId);
|
|
240
|
-
}}
|
|
241
|
-
position={slashCommand.position}
|
|
242
|
-
onClose={slashCommand.closeMenu}
|
|
243
|
-
replaceBlockId={slashCommand.replaceBlockId}
|
|
244
|
-
/>
|
|
245
|
-
)}
|
|
246
|
-
</main>
|
|
247
|
-
</div>
|
|
248
|
-
);
|
|
249
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import React, { useState } from 'react';
|
|
4
|
-
import { ArrowLeft, Settings2, Save, Edit, Eye } from 'lucide-react';
|
|
5
|
-
import { useEditor } from '../../state/EditorContext';
|
|
6
|
-
|
|
7
|
-
export interface EditorHeaderProps {
|
|
8
|
-
isPreviewMode: boolean;
|
|
9
|
-
onPreviewToggle: () => void;
|
|
10
|
-
isSidebarOpen: boolean;
|
|
11
|
-
onSidebarToggle: () => void;
|
|
12
|
-
isSaving: boolean;
|
|
13
|
-
onSave: () => Promise<void>;
|
|
14
|
-
onSaveError: (error: string | null) => void;
|
|
15
|
-
isDirty?: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function EditorHeader({
|
|
19
|
-
isPreviewMode,
|
|
20
|
-
onPreviewToggle,
|
|
21
|
-
isSidebarOpen,
|
|
22
|
-
onSidebarToggle,
|
|
23
|
-
isSaving,
|
|
24
|
-
onSave,
|
|
25
|
-
onSaveError,
|
|
26
|
-
isDirty = false,
|
|
27
|
-
}: EditorHeaderProps) {
|
|
28
|
-
const { state } = useEditor();
|
|
29
|
-
const [saveError, setSaveError] = useState<string | null>(null);
|
|
30
|
-
|
|
31
|
-
const handleSave = async () => {
|
|
32
|
-
try {
|
|
33
|
-
setSaveError(null);
|
|
34
|
-
await onSave();
|
|
35
|
-
} catch (error: any) {
|
|
36
|
-
console.error('[EditorHeader] Failed to save newsletter:', error);
|
|
37
|
-
let errorMessage = error.message || 'Failed to save newsletter';
|
|
38
|
-
if (errorMessage.includes('Unauthorized')) {
|
|
39
|
-
errorMessage = 'You are not authorized to save. Please log in again.';
|
|
40
|
-
}
|
|
41
|
-
setSaveError(errorMessage);
|
|
42
|
-
onSaveError(errorMessage);
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<header className="flex items-center justify-between px-6 py-3 bg-dashboard-sidebar backdrop-blur-md border-b border-dashboard-border flex-none shrink-0">
|
|
48
|
-
<div className="flex items-center gap-6">
|
|
49
|
-
<button
|
|
50
|
-
onClick={() => {
|
|
51
|
-
if (isDirty) {
|
|
52
|
-
const confirmed = window.confirm(
|
|
53
|
-
'You have unsaved changes. Are you sure you want to leave? Your changes will be lost.'
|
|
54
|
-
);
|
|
55
|
-
if (!confirmed) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
window.location.href = '/dashboard/newsletter';
|
|
60
|
-
}}
|
|
61
|
-
className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white transition-colors"
|
|
62
|
-
>
|
|
63
|
-
<ArrowLeft size={20} strokeWidth={1.5} />
|
|
64
|
-
</button>
|
|
65
|
-
</div>
|
|
66
|
-
|
|
67
|
-
<div className="flex items-center gap-4">
|
|
68
|
-
{/* Edit/Preview Toggle */}
|
|
69
|
-
<div className="flex items-center bg-dashboard-bg border border-dashboard-border rounded-full p-1 gap-1">
|
|
70
|
-
<button
|
|
71
|
-
onClick={() => {
|
|
72
|
-
if (isPreviewMode) {
|
|
73
|
-
onPreviewToggle();
|
|
74
|
-
}
|
|
75
|
-
}}
|
|
76
|
-
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${
|
|
77
|
-
!isPreviewMode
|
|
78
|
-
? 'bg-primary text-white shadow-sm'
|
|
79
|
-
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
|
|
80
|
-
}`}
|
|
81
|
-
title="Edit mode"
|
|
82
|
-
>
|
|
83
|
-
<Edit size={12} strokeWidth={2.5} />
|
|
84
|
-
<span>Edit</span>
|
|
85
|
-
</button>
|
|
86
|
-
<button
|
|
87
|
-
onClick={() => {
|
|
88
|
-
if (!isPreviewMode) {
|
|
89
|
-
onPreviewToggle();
|
|
90
|
-
}
|
|
91
|
-
}}
|
|
92
|
-
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${
|
|
93
|
-
isPreviewMode
|
|
94
|
-
? 'bg-primary text-white shadow-sm'
|
|
95
|
-
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
|
|
96
|
-
}`}
|
|
97
|
-
title="Preview mode"
|
|
98
|
-
>
|
|
99
|
-
<Eye size={12} strokeWidth={2.5} />
|
|
100
|
-
<span>Preview</span>
|
|
101
|
-
</button>
|
|
102
|
-
</div>
|
|
103
|
-
|
|
104
|
-
{/* Settings Toggle */}
|
|
105
|
-
<button
|
|
106
|
-
onClick={onSidebarToggle}
|
|
107
|
-
className={`flex items-center gap-2 text-[10px] uppercase tracking-widest font-black transition-all ${isSidebarOpen ? 'text-dashboard-text' : 'text-neutral-500 dark:text-neutral-400'
|
|
108
|
-
}`}
|
|
109
|
-
>
|
|
110
|
-
<Settings2 size={16} strokeWidth={1.5} />
|
|
111
|
-
Settings
|
|
112
|
-
</button>
|
|
113
|
-
|
|
114
|
-
{/* Save Button */}
|
|
115
|
-
<button
|
|
116
|
-
onClick={handleSave}
|
|
117
|
-
disabled={isSaving || !isDirty}
|
|
118
|
-
className={`inline-flex items-center gap-2 px-4 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-colors shadow-lg ${
|
|
119
|
-
isSaving || !isDirty
|
|
120
|
-
? 'bg-neutral-400 text-white cursor-not-allowed'
|
|
121
|
-
: 'bg-primary text-white hover:bg-primary/90'
|
|
122
|
-
}`}
|
|
123
|
-
>
|
|
124
|
-
{isSaving ? (
|
|
125
|
-
<>
|
|
126
|
-
<div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
127
|
-
Saving...
|
|
128
|
-
</>
|
|
129
|
-
) : (
|
|
130
|
-
<>
|
|
131
|
-
<Save size={14} />
|
|
132
|
-
{isDirty ? 'Save Changes' : 'Saved'}
|
|
133
|
-
</>
|
|
134
|
-
)}
|
|
135
|
-
</button>
|
|
136
|
-
</div>
|
|
137
|
-
</header>
|
|
138
|
-
);
|
|
139
|
-
}
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import React, { useState, useRef } from 'react';
|
|
4
|
-
import { GripVertical } from 'lucide-react';
|
|
5
|
-
|
|
6
|
-
export interface CustomBlockItemProps {
|
|
7
|
-
blockType: string;
|
|
8
|
-
name: string;
|
|
9
|
-
description?: string;
|
|
10
|
-
icon: React.ReactNode;
|
|
11
|
-
onAddBlock?: (blockType: string) => void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function CustomBlockItem({
|
|
15
|
-
blockType,
|
|
16
|
-
name,
|
|
17
|
-
description,
|
|
18
|
-
icon,
|
|
19
|
-
onAddBlock
|
|
20
|
-
}: CustomBlockItemProps) {
|
|
21
|
-
const [hasDragged, setHasDragged] = useState(false);
|
|
22
|
-
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
|
23
|
-
|
|
24
|
-
const handleDragStart = (e: React.DragEvent) => {
|
|
25
|
-
e.dataTransfer.setData('block-type', blockType);
|
|
26
|
-
e.dataTransfer.effectAllowed = 'move';
|
|
27
|
-
setHasDragged(true);
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const handleMouseDown = (e: React.MouseEvent) => {
|
|
31
|
-
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
|
32
|
-
setHasDragged(false);
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const handleMouseMove = (e: React.MouseEvent) => {
|
|
36
|
-
if (mouseDownRef.current) {
|
|
37
|
-
const dx = Math.abs(e.clientX - mouseDownRef.current.x);
|
|
38
|
-
const dy = Math.abs(e.clientY - mouseDownRef.current.y);
|
|
39
|
-
if (dx > 5 || dy > 5) {
|
|
40
|
-
setHasDragged(true);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const handleClick = (e: React.MouseEvent) => {
|
|
46
|
-
if (!hasDragged && onAddBlock) {
|
|
47
|
-
e.preventDefault();
|
|
48
|
-
e.stopPropagation();
|
|
49
|
-
onAddBlock(blockType);
|
|
50
|
-
}
|
|
51
|
-
mouseDownRef.current = null;
|
|
52
|
-
setTimeout(() => setHasDragged(false), 100);
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
<div
|
|
57
|
-
draggable
|
|
58
|
-
onDragStart={handleDragStart}
|
|
59
|
-
onMouseDown={handleMouseDown}
|
|
60
|
-
onMouseMove={handleMouseMove}
|
|
61
|
-
onClick={handleClick}
|
|
62
|
-
className="p-4 rounded-xl border border-dashboard-border bg-dashboard-bg hover:border-primary cursor-pointer transition-all group"
|
|
63
|
-
title={description}
|
|
64
|
-
>
|
|
65
|
-
<div className="flex items-center justify-between mb-2">
|
|
66
|
-
<div className="flex items-center gap-2">
|
|
67
|
-
<div className="text-neutral-500 dark:text-neutral-400 group-hover:text-primary dark:group-hover:text-primary transition-colors">
|
|
68
|
-
{icon}
|
|
69
|
-
</div>
|
|
70
|
-
<span className="text-[10px] font-bold uppercase tracking-wider text-neutral-700 dark:text-neutral-300 group-hover:text-neutral-950 dark:group-hover:text-white transition-colors">
|
|
71
|
-
{name}
|
|
72
|
-
</span>
|
|
73
|
-
</div>
|
|
74
|
-
<GripVertical size={12} className="text-neutral-400 dark:text-neutral-500 group-hover:text-neutral-600 dark:group-hover:text-neutral-400" />
|
|
75
|
-
</div>
|
|
76
|
-
{description && (
|
|
77
|
-
<p className="text-[9px] text-neutral-500 dark:text-neutral-400 leading-relaxed line-clamp-2">
|
|
78
|
-
{description}
|
|
79
|
-
</p>
|
|
80
|
-
)}
|
|
81
|
-
</div>
|
|
82
|
-
);
|
|
83
|
-
}
|