@jhits/plugin-blog 0.0.5 → 0.0.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 +16 -16
- package/src/api/config-handler.ts +76 -0
- package/src/api/handler.ts +4 -4
- package/src/api/router.ts +17 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useCategories.ts +76 -0
- package/src/index.tsx +8 -27
- package/src/init.tsx +0 -9
- package/src/lib/config-storage.ts +65 -0
- package/src/lib/layouts/blocks/ColumnsBlock.tsx +177 -13
- package/src/lib/layouts/blocks/ColumnsBlock.tsx.tmp +81 -0
- package/src/lib/layouts/registerLayoutBlocks.ts +6 -1
- package/src/lib/mappers/apiMapper.ts +53 -22
- package/src/registry/BlockRegistry.ts +1 -4
- package/src/state/EditorContext.tsx +39 -33
- package/src/state/types.ts +1 -1
- package/src/types/index.ts +2 -0
- package/src/types/post.ts +4 -0
- package/src/views/CanvasEditor/BlockWrapper.tsx +87 -24
- package/src/views/CanvasEditor/CanvasEditorView.tsx +214 -794
- package/src/views/CanvasEditor/EditorBody.tsx +317 -127
- package/src/views/CanvasEditor/EditorHeader.tsx +106 -17
- package/src/views/CanvasEditor/LayoutContainer.tsx +208 -380
- package/src/views/CanvasEditor/components/EditorCanvas.tsx +160 -0
- package/src/views/CanvasEditor/components/EditorLibrary.tsx +122 -0
- package/src/views/CanvasEditor/components/EditorSidebar.tsx +181 -0
- package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
- package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +260 -49
- package/src/views/CanvasEditor/components/index.ts +11 -0
- package/src/views/CanvasEditor/hooks/index.ts +10 -0
- package/src/views/CanvasEditor/hooks/useHeroBlock.ts +103 -0
- package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +142 -0
- package/src/views/CanvasEditor/hooks/usePostLoader.ts +39 -0
- package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +55 -0
- package/src/views/CanvasEditor/hooks/useUnsavedChanges.ts +339 -0
- package/src/views/PostManager/PostCards.tsx +18 -13
- package/src/views/PostManager/PostFilters.tsx +15 -0
- package/src/views/PostManager/PostManagerView.tsx +21 -15
- package/src/views/PostManager/PostTable.tsx +7 -4
|
@@ -1,37 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect, useRef
|
|
4
|
-
import {
|
|
5
|
-
Globe,
|
|
6
|
-
Image as ImageIcon, Library,
|
|
7
|
-
LayoutTemplate, Type,
|
|
8
|
-
Box,
|
|
9
|
-
Search,
|
|
10
|
-
AlertTriangle, X
|
|
11
|
-
} from 'lucide-react';
|
|
3
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
12
4
|
import { useEditor } from '../../state/EditorContext';
|
|
13
|
-
import { EditorBody } from './EditorBody';
|
|
14
|
-
import { BlockWrapper } from './BlockWrapper';
|
|
15
5
|
import { EditorHeader } from './EditorHeader';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
FeaturedMediaSection,
|
|
24
|
-
PrivacySettingsSection
|
|
25
|
-
} from './components';
|
|
26
|
-
|
|
27
|
-
// Generate a unique block ID
|
|
28
|
-
function generateBlockId(): string {
|
|
29
|
-
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
30
|
-
return crypto.randomUUID();
|
|
31
|
-
}
|
|
32
|
-
return `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
6
|
+
import { ErrorBanner } from './components/ErrorBanner';
|
|
7
|
+
import { EditorLibrary } from './components/EditorLibrary';
|
|
8
|
+
import { EditorCanvas } from './components/EditorCanvas';
|
|
9
|
+
import { EditorSidebar } from './components/EditorSidebar';
|
|
10
|
+
import { usePostLoader, useHeroBlock, useRegisteredBlocks, useKeyboardShortcuts, useUnsavedChanges } from './hooks';
|
|
11
|
+
import type { Block } from '../../types/block';
|
|
12
|
+
import type { SEOMetadata, PostMetadata } from '../../types/post';
|
|
35
13
|
|
|
36
14
|
export interface CanvasEditorViewProps {
|
|
37
15
|
postId?: string;
|
|
@@ -53,190 +31,90 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
53
31
|
const [isSidebarOpen, setSidebarOpen] = useState(true);
|
|
54
32
|
const [isLibraryOpen, setLibraryOpen] = useState(true);
|
|
55
33
|
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
|
56
|
-
const [isLoadingPost, setIsLoadingPost] = useState(false);
|
|
57
34
|
const [isSaving, setIsSaving] = useState(false);
|
|
58
35
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
59
|
-
const titleRef = useRef<HTMLTextAreaElement>(null);
|
|
60
|
-
|
|
61
|
-
// Load post when postId (slug) is provided
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
if (postId && !state.postId) {
|
|
64
|
-
const loadPost = async () => {
|
|
65
|
-
try {
|
|
66
|
-
setIsLoadingPost(true);
|
|
67
|
-
const response = await fetch(`/api/plugin-blog/${postId}`);
|
|
68
|
-
if (!response.ok) {
|
|
69
|
-
throw new Error('Failed to load post');
|
|
70
|
-
}
|
|
71
|
-
const apiDoc: APIBlogDocument = await response.json();
|
|
72
|
-
console.log('[CanvasEditorView] Loaded API document:', {
|
|
73
|
-
title: apiDoc.title,
|
|
74
|
-
slug: apiDoc.slug,
|
|
75
|
-
contentBlocksCount: apiDoc.contentBlocks?.length || 0,
|
|
76
|
-
contentBlocks: apiDoc.contentBlocks?.map((b: any) => ({ id: b.id, type: b.type })) || [],
|
|
77
|
-
hasContent: !!apiDoc.contentBlocks,
|
|
78
|
-
hasLegacyContent: !!apiDoc.content,
|
|
79
|
-
});
|
|
80
|
-
const blogPost = apiToBlogPost(apiDoc);
|
|
81
|
-
console.log('[CanvasEditorView] Converted to BlogPost:', {
|
|
82
|
-
id: blogPost.id,
|
|
83
|
-
title: blogPost.title,
|
|
84
|
-
blocksCount: blogPost.blocks.length,
|
|
85
|
-
blocks: blogPost.blocks.map(b => ({ id: b.id, type: b.type })),
|
|
86
|
-
});
|
|
87
|
-
helpers.loadPost(blogPost);
|
|
88
|
-
} catch (error) {
|
|
89
|
-
console.error('Failed to load post:', error);
|
|
90
|
-
alert('Failed to load post. Please try again.');
|
|
91
|
-
} finally {
|
|
92
|
-
setIsLoadingPost(false);
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
loadPost();
|
|
96
|
-
}
|
|
97
|
-
}, [postId, state.postId, helpers]);
|
|
98
36
|
|
|
99
|
-
//
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
37
|
+
// Get registered blocks
|
|
38
|
+
const registeredBlocks = useRegisteredBlocks();
|
|
39
|
+
|
|
40
|
+
// Hero block management
|
|
41
|
+
const { heroBlock, setHeroBlock, heroBlockDefinition } = useHeroBlock(state, registeredBlocks);
|
|
42
|
+
|
|
43
|
+
// Post loading
|
|
44
|
+
const { isLoadingPost } = usePostLoader(
|
|
45
|
+
postId,
|
|
46
|
+
state.postId,
|
|
47
|
+
(post) => {
|
|
48
|
+
helpers.loadPost(post);
|
|
49
|
+
// After loading, ensure we're marked as clean
|
|
50
|
+
// Use setTimeout to ensure this runs after the reducer has processed LOAD_POST
|
|
51
|
+
setTimeout(() => {
|
|
52
|
+
dispatch({ type: 'MARK_CLEAN' });
|
|
53
|
+
}, 0);
|
|
54
|
+
},
|
|
55
|
+
() => setHeroBlock(null)
|
|
56
|
+
);
|
|
105
57
|
|
|
106
|
-
//
|
|
58
|
+
// Track if we just loaded a post to prevent marking as dirty during cleanup
|
|
59
|
+
const justLoadedRef = useRef(false);
|
|
60
|
+
const previousIsLoadingRef = useRef<boolean>(false);
|
|
61
|
+
const loadingCleanupTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
62
|
+
|
|
63
|
+
// Mark when post loading completes and ensure it stays clean after all effects
|
|
107
64
|
useEffect(() => {
|
|
108
|
-
//
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
setRegisteredBlocks([...currentBlocks]);
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
// Initial check
|
|
122
|
-
checkBlocks();
|
|
123
|
-
|
|
124
|
-
// Poll for registry changes (blocks are registered asynchronously in useEffect)
|
|
125
|
-
// Use a shorter interval initially, then longer
|
|
126
|
-
let pollCount = 0;
|
|
127
|
-
const interval = setInterval(() => {
|
|
128
|
-
pollCount++;
|
|
129
|
-
checkBlocks();
|
|
130
|
-
// Stop polling after 5 seconds (25 checks at 200ms)
|
|
131
|
-
if (pollCount > 25) {
|
|
132
|
-
clearInterval(interval);
|
|
65
|
+
// Detect when loading just finished (was loading, now not loading, and we have a postId)
|
|
66
|
+
const loadingJustFinished = previousIsLoadingRef.current && !isLoadingPost && state.postId;
|
|
67
|
+
|
|
68
|
+
if (loadingJustFinished) {
|
|
69
|
+
justLoadedRef.current = true;
|
|
70
|
+
|
|
71
|
+
// Clear any existing cleanup timer
|
|
72
|
+
if (loadingCleanupTimerRef.current) {
|
|
73
|
+
clearTimeout(loadingCleanupTimerRef.current);
|
|
133
74
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
timeouts.forEach(clearTimeout);
|
|
148
|
-
};
|
|
149
|
-
}, [registeredBlocks.length]);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// Get Hero block definition from registered blocks (REQUIRED)
|
|
153
|
-
const heroBlockDefinition = useMemo(() => {
|
|
154
|
-
return blockRegistry.get('hero');
|
|
155
|
-
}, [registeredBlocks]);
|
|
156
|
-
|
|
157
|
-
// Hero block state - separate from content blocks (statically positioned at top)
|
|
158
|
-
const [heroBlock, setHeroBlock] = useState<Block | null>(null);
|
|
159
|
-
|
|
160
|
-
// Initialize hero block if Hero block definition exists
|
|
161
|
-
useEffect(() => {
|
|
162
|
-
if (heroBlockDefinition) {
|
|
163
|
-
// Get default data from block definition
|
|
164
|
-
const heroData = heroBlockDefinition.defaultData || {};
|
|
165
|
-
|
|
166
|
-
// Initialize hero block only if it doesn't exist yet
|
|
167
|
-
setHeroBlock(prev => {
|
|
168
|
-
if (!prev) {
|
|
169
|
-
// Initialize hero block from editor state (if editing existing post) or use defaults
|
|
170
|
-
const initialData = {
|
|
171
|
-
...heroData,
|
|
172
|
-
title: state.title || heroData.title || '',
|
|
173
|
-
summary: state.metadata.excerpt || heroData.summary || '',
|
|
174
|
-
image: state.metadata.featuredImage ? {
|
|
175
|
-
src: state.metadata.featuredImage.src,
|
|
176
|
-
alt: state.metadata.featuredImage.alt,
|
|
177
|
-
brightness: state.metadata.featuredImage.brightness,
|
|
178
|
-
blur: state.metadata.featuredImage.blur,
|
|
179
|
-
} : heroData.image,
|
|
180
|
-
};
|
|
181
|
-
return {
|
|
182
|
-
id: generateBlockId(),
|
|
183
|
-
type: 'hero',
|
|
184
|
-
data: initialData,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
// Keep existing hero block data - let the Edit component manage it
|
|
188
|
-
return prev;
|
|
75
|
+
|
|
76
|
+
// Wait for all effects to complete, then ensure we're marked as clean
|
|
77
|
+
// Use multiple animation frames + setTimeout to ensure all effects have run
|
|
78
|
+
requestAnimationFrame(() => {
|
|
79
|
+
requestAnimationFrame(() => {
|
|
80
|
+
loadingCleanupTimerRef.current = setTimeout(() => {
|
|
81
|
+
// Force mark as clean after loading - this ensures cleanup effects don't leave us dirty
|
|
82
|
+
console.log('[CanvasEditorView] Post loading complete - ensuring clean state');
|
|
83
|
+
dispatch({ type: 'MARK_CLEAN' });
|
|
84
|
+
justLoadedRef.current = false;
|
|
85
|
+
loadingCleanupTimerRef.current = null;
|
|
86
|
+
}, 500); // Delay to ensure all effects complete
|
|
87
|
+
});
|
|
189
88
|
});
|
|
190
|
-
} else {
|
|
191
|
-
setHeroBlock(null);
|
|
192
89
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const currentSummary = (heroBlock.data as any)?.summary || '';
|
|
202
|
-
const currentImage = (heroBlock.data as any)?.image;
|
|
203
|
-
const currentCategory = (heroBlock.data as any)?.category || '';
|
|
204
|
-
|
|
205
|
-
const stateTitle = state.title || '';
|
|
206
|
-
const stateSummary = state.metadata.excerpt || '';
|
|
207
|
-
const stateImage = state.metadata.featuredImage;
|
|
208
|
-
const stateCategory = state.metadata.categories?.[0] || '';
|
|
209
|
-
|
|
210
|
-
// Check if hero block is out of sync with editor state
|
|
211
|
-
const titleMismatch = currentTitle !== stateTitle;
|
|
212
|
-
const summaryMismatch = currentSummary !== stateSummary;
|
|
213
|
-
const imageMismatch = !currentImage && stateImage ||
|
|
214
|
-
currentImage?.src !== stateImage?.src ||
|
|
215
|
-
currentImage?.alt !== stateImage?.alt ||
|
|
216
|
-
currentImage?.brightness !== stateImage?.brightness ||
|
|
217
|
-
currentImage?.blur !== stateImage?.blur;
|
|
218
|
-
const categoryMismatch = currentCategory !== stateCategory;
|
|
219
|
-
|
|
220
|
-
// Only update if there's a mismatch and the editor state has data (post was loaded)
|
|
221
|
-
if ((titleMismatch || summaryMismatch || imageMismatch || categoryMismatch) && (stateTitle || stateSummary || stateImage || stateCategory)) {
|
|
222
|
-
setHeroBlock({
|
|
223
|
-
...heroBlock,
|
|
224
|
-
data: {
|
|
225
|
-
...heroBlock.data,
|
|
226
|
-
title: stateTitle || (heroBlock.data as any)?.title || '',
|
|
227
|
-
summary: stateSummary || (heroBlock.data as any)?.summary || '',
|
|
228
|
-
image: stateImage ? {
|
|
229
|
-
src: stateImage.src,
|
|
230
|
-
alt: stateImage.alt,
|
|
231
|
-
brightness: stateImage.brightness,
|
|
232
|
-
blur: stateImage.blur,
|
|
233
|
-
} : (heroBlock.data as any)?.image,
|
|
234
|
-
category: stateCategory || (heroBlock.data as any)?.category || '',
|
|
235
|
-
},
|
|
236
|
-
});
|
|
90
|
+
|
|
91
|
+
// Update ref
|
|
92
|
+
previousIsLoadingRef.current = isLoadingPost;
|
|
93
|
+
|
|
94
|
+
return () => {
|
|
95
|
+
if (loadingCleanupTimerRef.current) {
|
|
96
|
+
clearTimeout(loadingCleanupTimerRef.current);
|
|
97
|
+
loadingCleanupTimerRef.current = null;
|
|
237
98
|
}
|
|
238
|
-
}
|
|
239
|
-
}, [
|
|
99
|
+
};
|
|
100
|
+
}, [isLoadingPost, state.postId, dispatch]);
|
|
101
|
+
|
|
102
|
+
// Keyboard shortcuts
|
|
103
|
+
useKeyboardShortcuts(state, dispatch, canUndo, canRedo, helpers.undo, helpers.redo);
|
|
104
|
+
|
|
105
|
+
// Unsaved changes warning and auto-save
|
|
106
|
+
const { autoSaveEnabled, setAutoSaveEnabled, countdown, saveStatus } = useUnsavedChanges({
|
|
107
|
+
state,
|
|
108
|
+
isDirty: state.isDirty,
|
|
109
|
+
onSave: async () => {
|
|
110
|
+
// Preserve current status: if already published, keep it published
|
|
111
|
+
// Otherwise save as draft
|
|
112
|
+
const shouldPublish = state.status === 'published';
|
|
113
|
+
await handleSave(shouldPublish);
|
|
114
|
+
},
|
|
115
|
+
heroBlock,
|
|
116
|
+
postId: state.postId,
|
|
117
|
+
});
|
|
240
118
|
|
|
241
119
|
// Listen for hero title updates from HeroBlock (if it dispatches events)
|
|
242
120
|
useEffect(() => {
|
|
@@ -248,192 +126,129 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
248
126
|
}, [dispatch]);
|
|
249
127
|
|
|
250
128
|
// Remove any hero blocks from the content blocks array
|
|
129
|
+
// Note: This effect will mark as dirty, but the loading cleanup effect will fix it
|
|
251
130
|
useEffect(() => {
|
|
252
131
|
const heroBlocksInContent = state.blocks.filter(b => b.type === 'hero');
|
|
253
132
|
if (heroBlocksInContent.length > 0) {
|
|
254
133
|
heroBlocksInContent.forEach(block => {
|
|
255
134
|
dispatch({ type: 'DELETE_BLOCK', payload: { id: block.id } });
|
|
256
135
|
});
|
|
136
|
+
// Don't mark as clean here - let the loading cleanup effect handle it
|
|
137
|
+
// This ensures we wait for all effects to complete before marking clean
|
|
257
138
|
}
|
|
258
139
|
}, [state.blocks, dispatch]);
|
|
259
140
|
|
|
260
141
|
// Filter out hero blocks from content blocks
|
|
261
142
|
const contentBlocks = state.blocks.filter(b => b.type !== 'hero');
|
|
262
143
|
|
|
263
|
-
// Get all registered blocks from state (excluding Hero block from sidebar)
|
|
264
|
-
const allBlocks = registeredBlocks.filter(block => block.type !== 'hero');
|
|
265
|
-
const textBlocks = allBlocks.filter(block => block.category === 'text');
|
|
266
|
-
const customBlocks = allBlocks.filter(block => block.category === 'custom');
|
|
267
|
-
const mediaBlocks = allBlocks.filter(block => block.category === 'media');
|
|
268
|
-
const layoutBlocks = allBlocks.filter(block => block.category === 'layout');
|
|
269
|
-
|
|
270
144
|
// Handler to add block at the bottom when clicking (not dragging)
|
|
271
145
|
const handleAddBlockAtBottom = (blockType: string) => {
|
|
272
146
|
// Add at the end of content blocks (excluding hero)
|
|
273
147
|
helpers.addBlock(blockType, contentBlocks.length, undefined);
|
|
274
148
|
};
|
|
275
149
|
|
|
276
|
-
// Handle
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (!isEditableElement) {
|
|
297
|
-
e.preventDefault();
|
|
298
|
-
e.stopPropagation();
|
|
299
|
-
if (canUndo) {
|
|
300
|
-
helpers.undo();
|
|
301
|
-
}
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
150
|
+
// Handle save
|
|
151
|
+
const handleSave = async (publish?: boolean) => {
|
|
152
|
+
setIsSaving(true);
|
|
153
|
+
setSaveError(null);
|
|
154
|
+
try {
|
|
155
|
+
// Status should already be set in EditorHeader, but verify and log
|
|
156
|
+
console.log('[CanvasEditorView] onSave called with publish:', publish, 'current status:', state.status);
|
|
157
|
+
|
|
158
|
+
// Only change status if explicitly requested (publish is true or false)
|
|
159
|
+
// If publish is undefined, preserve the current status (used for autosave)
|
|
160
|
+
if (publish === true && state.status !== 'published') {
|
|
161
|
+
console.warn('[CanvasEditorView] Status mismatch! Setting to published...');
|
|
162
|
+
dispatch({ type: 'SET_STATUS', payload: 'published' });
|
|
163
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
164
|
+
} else if (publish === false && state.status !== 'draft' && state.status !== 'published') {
|
|
165
|
+
// Only set to draft if not already published (preserve published status)
|
|
166
|
+
// This prevents autosave from changing published posts back to draft
|
|
167
|
+
console.warn('[CanvasEditorView] Status mismatch! Setting to draft...');
|
|
168
|
+
dispatch({ type: 'SET_STATUS', payload: 'draft' });
|
|
169
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
304
170
|
}
|
|
305
171
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
172
|
+
console.log('[CanvasEditorView] Final status before save:', state.status);
|
|
173
|
+
|
|
174
|
+
// Pass hero block to save function so it can be included in the saved data
|
|
175
|
+
await helpers.save(heroBlock);
|
|
176
|
+
setIsSaving(false);
|
|
177
|
+
} catch (error: any) {
|
|
178
|
+
console.error('[CanvasEditorView] Save error:', error);
|
|
179
|
+
// Extract and format user-friendly error message
|
|
180
|
+
let errorMessage = error.message || 'Failed to save post';
|
|
181
|
+
|
|
182
|
+
// Make error messages more user-friendly
|
|
183
|
+
if (errorMessage.includes('Missing required fields')) {
|
|
184
|
+
errorMessage = errorMessage.replace('Missing required fields for publishing:', 'To publish, please fill in:');
|
|
185
|
+
} else if (errorMessage.includes('All required fields')) {
|
|
186
|
+
errorMessage = 'To publish, please fill in all required fields: summary, featured image, category, and content.';
|
|
187
|
+
} else if (errorMessage.includes('Unauthorized')) {
|
|
188
|
+
errorMessage = 'You are not authorized to save this post. Please log in again.';
|
|
189
|
+
} else if (errorMessage.includes('Failed to save')) {
|
|
190
|
+
errorMessage = 'Unable to save the post. Please check your connection and try again.';
|
|
316
191
|
}
|
|
317
192
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
target.tagName === 'TEXTAREA' ||
|
|
324
|
-
target.isContentEditable ||
|
|
325
|
-
target.closest('input, textarea, [contenteditable="true"]');
|
|
326
|
-
|
|
327
|
-
if (isEditableElement) {
|
|
328
|
-
return; // Let the browser handle paste in editable elements
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Check if there's a copied block
|
|
332
|
-
if (typeof window !== 'undefined') {
|
|
333
|
-
const copiedBlockJson = localStorage.getItem('__BLOG_EDITOR_COPIED_BLOCK__');
|
|
334
|
-
if (copiedBlockJson) {
|
|
335
|
-
try {
|
|
336
|
-
e.preventDefault();
|
|
337
|
-
e.stopPropagation();
|
|
338
|
-
|
|
339
|
-
const copiedBlock = JSON.parse(copiedBlockJson) as Block;
|
|
340
|
-
|
|
341
|
-
// Generate a unique block ID
|
|
342
|
-
const generateBlockId = (): string => {
|
|
343
|
-
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
344
|
-
return crypto.randomUUID();
|
|
345
|
-
}
|
|
346
|
-
return `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
// Clone a block with new IDs (recursive for nested blocks)
|
|
350
|
-
const cloneBlock = (blockToClone: Block): Block => {
|
|
351
|
-
const cloned: Block = {
|
|
352
|
-
...blockToClone,
|
|
353
|
-
id: generateBlockId(),
|
|
354
|
-
data: { ...blockToClone.data },
|
|
355
|
-
meta: blockToClone.meta ? { ...blockToClone.meta } : undefined,
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
// Handle children if they exist
|
|
359
|
-
if (blockToClone.children) {
|
|
360
|
-
if (Array.isArray(blockToClone.children) && blockToClone.children.length > 0) {
|
|
361
|
-
if (typeof blockToClone.children[0] === 'object') {
|
|
362
|
-
cloned.children = (blockToClone.children as Block[]).map(cloneBlock);
|
|
363
|
-
} else {
|
|
364
|
-
// If children are IDs, find and clone the actual blocks
|
|
365
|
-
const allBlocks = state.blocks;
|
|
366
|
-
const childIds = blockToClone.children as string[];
|
|
367
|
-
const childBlocks = childIds
|
|
368
|
-
.map((childId: string) => allBlocks.find(b => b.id === childId))
|
|
369
|
-
.filter((b): b is Block => b !== undefined);
|
|
370
|
-
cloned.children = childBlocks.map(cloneBlock);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
return cloned;
|
|
376
|
-
};
|
|
377
|
-
|
|
378
|
-
const pastedBlock = cloneBlock(copiedBlock);
|
|
379
|
-
|
|
380
|
-
// Find where to paste - use hovered block or selected block, or paste at end
|
|
381
|
-
const hoveredBlockId = (window as any).__BLOG_EDITOR_HOVERED_BLOCK_ID__;
|
|
382
|
-
const targetBlockId = hoveredBlockId || state.selectedBlockId;
|
|
383
|
-
|
|
384
|
-
let pasteIndex: number | undefined;
|
|
385
|
-
if (targetBlockId) {
|
|
386
|
-
const targetIndex = state.blocks.findIndex(b => b.id === targetBlockId);
|
|
387
|
-
if (targetIndex !== -1) {
|
|
388
|
-
pasteIndex = targetIndex + 1;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
193
|
+
setSaveError(errorMessage);
|
|
194
|
+
setIsSaving(false); // Always reset saving state on error
|
|
195
|
+
throw error; // Re-throw so EditorHeader can handle it
|
|
196
|
+
}
|
|
197
|
+
};
|
|
391
198
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
199
|
+
// Handle hero block update
|
|
200
|
+
const handleHeroBlockUpdate = (data: Partial<Block['data']>) => {
|
|
201
|
+
if (!heroBlock) return;
|
|
202
|
+
|
|
203
|
+
setHeroBlock({
|
|
204
|
+
...heroBlock,
|
|
205
|
+
data: { ...heroBlock.data, ...data },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Sync title to editor state
|
|
209
|
+
if (data.title !== undefined && typeof data.title === 'string') {
|
|
210
|
+
dispatch({ type: 'SET_TITLE', payload: data.title });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Sync summary to editor state metadata
|
|
214
|
+
if (data.summary !== undefined && typeof data.summary === 'string') {
|
|
215
|
+
dispatch({
|
|
216
|
+
type: 'SET_METADATA',
|
|
217
|
+
payload: { excerpt: data.summary }
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Hero image and featured image are completely independent
|
|
222
|
+
// Do NOT sync hero image to featured image
|
|
223
|
+
// The featured image is a separate thumbnail that the client adjusts independently
|
|
224
|
+
|
|
225
|
+
// Sync category to editor state metadata
|
|
226
|
+
if (data.category !== undefined && typeof data.category === 'string') {
|
|
227
|
+
dispatch({
|
|
228
|
+
type: 'SET_METADATA',
|
|
229
|
+
payload: {
|
|
230
|
+
categories: data.category.trim() ? [data.category.trim()] : []
|
|
405
231
|
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
};
|
|
408
235
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
};
|
|
413
|
-
|
|
236
|
+
// Handle hero block delete/reset
|
|
237
|
+
const handleHeroBlockDelete = () => {
|
|
238
|
+
if (!heroBlock || !heroBlockDefinition) return;
|
|
239
|
+
const defaultData = heroBlockDefinition.defaultData || {};
|
|
240
|
+
setHeroBlock({
|
|
241
|
+
...heroBlock,
|
|
242
|
+
data: { ...defaultData },
|
|
243
|
+
});
|
|
244
|
+
};
|
|
414
245
|
|
|
415
246
|
return (
|
|
416
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">
|
|
417
|
-
|
|
418
248
|
<main className="flex flex-1 flex-col relative min-h-0">
|
|
419
249
|
{/* Error Banner */}
|
|
420
|
-
{saveError
|
|
421
|
-
|
|
422
|
-
<div className="flex items-center gap-3 flex-1">
|
|
423
|
-
<AlertTriangle className="text-red-600 dark:text-red-400 flex-shrink-0" size={20} />
|
|
424
|
-
<p className="text-red-800 dark:text-red-300 text-sm font-medium">
|
|
425
|
-
{saveError}
|
|
426
|
-
</p>
|
|
427
|
-
</div>
|
|
428
|
-
<button
|
|
429
|
-
onClick={() => setSaveError(null)}
|
|
430
|
-
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200 transition-colors"
|
|
431
|
-
aria-label="Dismiss error"
|
|
432
|
-
>
|
|
433
|
-
<X size={18} />
|
|
434
|
-
</button>
|
|
435
|
-
</div>
|
|
436
|
-
)}
|
|
250
|
+
<ErrorBanner error={saveError} onDismiss={() => setSaveError(null)} />
|
|
251
|
+
|
|
437
252
|
<EditorHeader
|
|
438
253
|
isLibraryOpen={isLibraryOpen}
|
|
439
254
|
onLibraryToggle={() => setLibraryOpen(!isLibraryOpen)}
|
|
@@ -442,48 +257,7 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
442
257
|
isSidebarOpen={isSidebarOpen}
|
|
443
258
|
onSidebarToggle={() => setSidebarOpen(!isSidebarOpen)}
|
|
444
259
|
isSaving={isSaving}
|
|
445
|
-
onSave={
|
|
446
|
-
setIsSaving(true);
|
|
447
|
-
setSaveError(null);
|
|
448
|
-
try {
|
|
449
|
-
// Status should already be set in EditorHeader, but verify and log
|
|
450
|
-
console.log('[CanvasEditorView] onSave called with publish:', publish, 'current status:', state.status);
|
|
451
|
-
|
|
452
|
-
// Double-check status is set correctly before saving
|
|
453
|
-
if (publish === true && state.status !== 'published') {
|
|
454
|
-
console.warn('[CanvasEditorView] Status mismatch! Setting to published...');
|
|
455
|
-
dispatch({ type: 'SET_STATUS', payload: 'published' });
|
|
456
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
457
|
-
} else if (publish === false && state.status !== 'draft') {
|
|
458
|
-
console.warn('[CanvasEditorView] Status mismatch! Setting to draft...');
|
|
459
|
-
dispatch({ type: 'SET_STATUS', payload: 'draft' });
|
|
460
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
console.log('[CanvasEditorView] Final status before save:', state.status);
|
|
464
|
-
await helpers.save();
|
|
465
|
-
setIsSaving(false);
|
|
466
|
-
} catch (error: any) {
|
|
467
|
-
console.error('[CanvasEditorView] Save error:', error);
|
|
468
|
-
// Extract and format user-friendly error message
|
|
469
|
-
let errorMessage = error.message || 'Failed to save post';
|
|
470
|
-
|
|
471
|
-
// Make error messages more user-friendly
|
|
472
|
-
if (errorMessage.includes('Missing required fields')) {
|
|
473
|
-
errorMessage = errorMessage.replace('Missing required fields for publishing:', 'To publish, please fill in:');
|
|
474
|
-
} else if (errorMessage.includes('All required fields')) {
|
|
475
|
-
errorMessage = 'To publish, please fill in all required fields: summary, featured image, category, and content.';
|
|
476
|
-
} else if (errorMessage.includes('Unauthorized')) {
|
|
477
|
-
errorMessage = 'You are not authorized to save this post. Please log in again.';
|
|
478
|
-
} else if (errorMessage.includes('Failed to save')) {
|
|
479
|
-
errorMessage = 'Unable to save the post. Please check your connection and try again.';
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
setSaveError(errorMessage);
|
|
483
|
-
setIsSaving(false); // Always reset saving state on error
|
|
484
|
-
throw error; // Re-throw so EditorHeader can handle it
|
|
485
|
-
}
|
|
486
|
-
}}
|
|
260
|
+
onSave={handleSave}
|
|
487
261
|
onSaveError={(error) => {
|
|
488
262
|
// Format error message for display
|
|
489
263
|
if (error) {
|
|
@@ -496,258 +270,48 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
496
270
|
setSaveError(null);
|
|
497
271
|
}
|
|
498
272
|
}}
|
|
273
|
+
autoSaveEnabled={autoSaveEnabled}
|
|
274
|
+
onAutoSaveToggle={setAutoSaveEnabled}
|
|
275
|
+
isDirty={state.isDirty}
|
|
276
|
+
autoSaveCountdown={countdown}
|
|
277
|
+
autoSaveStatus={saveStatus}
|
|
499
278
|
/>
|
|
500
279
|
|
|
501
280
|
{/* Editor Content Wrapper */}
|
|
502
281
|
<div className="flex flex-1 relative overflow-hidden min-h-0 flex-nowrap">
|
|
503
|
-
|
|
504
282
|
{/* LEFT SIDEBAR: COMPONENT LIBRARY */}
|
|
505
283
|
{!isPreviewMode && (
|
|
506
284
|
<aside
|
|
507
285
|
className={`transition-all duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] border-r border-dashboard-border bg-dashboard-sidebar overflow-y-auto overflow-x-hidden h-full ${isLibraryOpen ? 'w-72' : 'w-0 opacity-0 pointer-events-none'
|
|
508
286
|
}`}
|
|
509
287
|
>
|
|
510
|
-
<
|
|
511
|
-
{
|
|
512
|
-
{
|
|
513
|
-
|
|
514
|
-
<h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Text</h3>
|
|
515
|
-
<div className="grid grid-cols-2 gap-3">
|
|
516
|
-
{textBlocks.map((block) => {
|
|
517
|
-
const IconComponent = block.icon || block.components.Icon || Type;
|
|
518
|
-
return (
|
|
519
|
-
<LibraryItem
|
|
520
|
-
key={block.type}
|
|
521
|
-
icon={<IconComponent size={16} />}
|
|
522
|
-
label={block.name}
|
|
523
|
-
blockType={block.type}
|
|
524
|
-
description={block.description}
|
|
525
|
-
onAddBlock={handleAddBlockAtBottom}
|
|
526
|
-
/>
|
|
527
|
-
);
|
|
528
|
-
})}
|
|
529
|
-
</div>
|
|
530
|
-
</div>
|
|
531
|
-
)}
|
|
532
|
-
|
|
533
|
-
{/* Media Blocks */}
|
|
534
|
-
{mediaBlocks.length > 0 && (
|
|
535
|
-
<div className="mb-10">
|
|
536
|
-
<h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Media</h3>
|
|
537
|
-
<div className="grid grid-cols-2 gap-3">
|
|
538
|
-
{mediaBlocks.map((block) => {
|
|
539
|
-
const IconComponent = block.icon || block.components.Icon || ImageIcon;
|
|
540
|
-
return (
|
|
541
|
-
<LibraryItem
|
|
542
|
-
key={block.type}
|
|
543
|
-
icon={<IconComponent size={16} />}
|
|
544
|
-
label={block.name}
|
|
545
|
-
blockType={block.type}
|
|
546
|
-
description={block.description}
|
|
547
|
-
onAddBlock={handleAddBlockAtBottom}
|
|
548
|
-
/>
|
|
549
|
-
);
|
|
550
|
-
})}
|
|
551
|
-
</div>
|
|
552
|
-
</div>
|
|
553
|
-
)}
|
|
554
|
-
|
|
555
|
-
{/* Layout Blocks */}
|
|
556
|
-
{layoutBlocks.length > 0 && (
|
|
557
|
-
<div className="mb-10">
|
|
558
|
-
<h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Layout</h3>
|
|
559
|
-
<div className="grid grid-cols-2 gap-3">
|
|
560
|
-
{layoutBlocks.map((block) => {
|
|
561
|
-
const IconComponent = block.icon || block.components.Icon || LayoutTemplate;
|
|
562
|
-
return (
|
|
563
|
-
<LibraryItem
|
|
564
|
-
key={block.type}
|
|
565
|
-
icon={<IconComponent size={16} />}
|
|
566
|
-
label={block.name}
|
|
567
|
-
blockType={block.type}
|
|
568
|
-
description={block.description}
|
|
569
|
-
onAddBlock={handleAddBlockAtBottom}
|
|
570
|
-
/>
|
|
571
|
-
);
|
|
572
|
-
})}
|
|
573
|
-
</div>
|
|
574
|
-
</div>
|
|
575
|
-
)}
|
|
576
|
-
|
|
577
|
-
{/* Custom Blocks */}
|
|
578
|
-
{customBlocks.length > 0 && (
|
|
579
|
-
<div>
|
|
580
|
-
<h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Custom Blocks</h3>
|
|
581
|
-
<div className="space-y-3">
|
|
582
|
-
{customBlocks.map((block) => {
|
|
583
|
-
const IconComponent = block.icon || block.components.Icon || Box;
|
|
584
|
-
return (
|
|
585
|
-
<CustomBlockItem
|
|
586
|
-
key={block.type}
|
|
587
|
-
blockType={block.type}
|
|
588
|
-
name={block.name}
|
|
589
|
-
description={block.description}
|
|
590
|
-
icon={<IconComponent size={14} />}
|
|
591
|
-
onAddBlock={handleAddBlockAtBottom}
|
|
592
|
-
/>
|
|
593
|
-
);
|
|
594
|
-
})}
|
|
595
|
-
</div>
|
|
596
|
-
</div>
|
|
597
|
-
)}
|
|
598
|
-
|
|
599
|
-
{/* Empty State */}
|
|
600
|
-
{allBlocks.length === 0 && (
|
|
601
|
-
<div className="text-center py-12">
|
|
602
|
-
<Library size={32} className="mx-auto text-neutral-300 dark:text-neutral-700 mb-4" />
|
|
603
|
-
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
|
604
|
-
No blocks registered yet.
|
|
605
|
-
</p>
|
|
606
|
-
</div>
|
|
607
|
-
)}
|
|
608
|
-
</div>
|
|
288
|
+
<EditorLibrary
|
|
289
|
+
registeredBlocks={registeredBlocks}
|
|
290
|
+
onAddBlock={handleAddBlockAtBottom}
|
|
291
|
+
/>
|
|
609
292
|
</aside>
|
|
610
293
|
)}
|
|
611
294
|
|
|
612
295
|
{/* CENTER: THE WRITING CANVAS */}
|
|
613
|
-
<
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
data: { ...heroBlock.data, ...data },
|
|
633
|
-
});
|
|
634
|
-
// Sync title to editor state
|
|
635
|
-
if (data.title !== undefined && typeof data.title === 'string') {
|
|
636
|
-
dispatch({ type: 'SET_TITLE', payload: data.title });
|
|
637
|
-
}
|
|
638
|
-
// Sync summary to editor state metadata
|
|
639
|
-
if (data.summary !== undefined && typeof data.summary === 'string') {
|
|
640
|
-
dispatch({
|
|
641
|
-
type: 'SET_METADATA',
|
|
642
|
-
payload: { excerpt: data.summary }
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
// Sync image to editor state metadata
|
|
646
|
-
if (data.image !== undefined) {
|
|
647
|
-
const imageData = data.image as any;
|
|
648
|
-
dispatch({
|
|
649
|
-
type: 'SET_METADATA',
|
|
650
|
-
payload: {
|
|
651
|
-
featuredImage: imageData ? {
|
|
652
|
-
src: imageData.src || '',
|
|
653
|
-
alt: imageData.alt || '',
|
|
654
|
-
brightness: imageData.brightness ?? 100,
|
|
655
|
-
blur: imageData.blur ?? 0,
|
|
656
|
-
} : undefined
|
|
657
|
-
}
|
|
658
|
-
});
|
|
659
|
-
}
|
|
660
|
-
// Sync category to editor state metadata
|
|
661
|
-
if (data.category !== undefined && typeof data.category === 'string') {
|
|
662
|
-
dispatch({
|
|
663
|
-
type: 'SET_METADATA',
|
|
664
|
-
payload: {
|
|
665
|
-
categories: data.category.trim() ? [data.category.trim()] : []
|
|
666
|
-
}
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
}}
|
|
670
|
-
onDelete={() => {
|
|
671
|
-
const defaultData = heroBlockDefinition.defaultData || {};
|
|
672
|
-
setHeroBlock({
|
|
673
|
-
...heroBlock,
|
|
674
|
-
data: { ...defaultData },
|
|
675
|
-
});
|
|
676
|
-
}}
|
|
677
|
-
onMoveUp={() => { }}
|
|
678
|
-
onMoveDown={() => { }}
|
|
679
|
-
allBlocks={[heroBlock]}
|
|
680
|
-
/>
|
|
681
|
-
</div>
|
|
682
|
-
)}
|
|
683
|
-
|
|
684
|
-
{isPreviewMode ? (
|
|
685
|
-
<div className="space-y-8">
|
|
686
|
-
{heroBlockDefinition && heroBlock && (
|
|
687
|
-
<BlockRenderer
|
|
688
|
-
block={heroBlock}
|
|
689
|
-
context={{ siteId, locale }}
|
|
690
|
-
/>
|
|
691
|
-
)}
|
|
692
|
-
|
|
693
|
-
{!heroBlockDefinition && state.title && (
|
|
694
|
-
<h1 className="text-5xl font-serif font-medium text-neutral-950 dark:text-white leading-tight mb-12">
|
|
695
|
-
{state.title}
|
|
696
|
-
</h1>
|
|
697
|
-
)}
|
|
698
|
-
|
|
699
|
-
{contentBlocks.length > 0 ? (
|
|
700
|
-
<div className="space-y-8">
|
|
701
|
-
{contentBlocks.map((block) => (
|
|
702
|
-
<BlockRenderer
|
|
703
|
-
key={block.id}
|
|
704
|
-
block={block}
|
|
705
|
-
context={{ siteId, locale }}
|
|
706
|
-
/>
|
|
707
|
-
))}
|
|
708
|
-
</div>
|
|
709
|
-
) : (
|
|
710
|
-
<div className="text-center py-20 text-neutral-400 dark:text-neutral-500">
|
|
711
|
-
<p className="text-sm">No content blocks yet. Switch to Edit mode to add blocks.</p>
|
|
712
|
-
</div>
|
|
713
|
-
)}
|
|
714
|
-
</div>
|
|
715
|
-
) : (
|
|
716
|
-
<>
|
|
717
|
-
{!heroBlockDefinition && (
|
|
718
|
-
<div className="mb-12">
|
|
719
|
-
<textarea
|
|
720
|
-
ref={titleRef}
|
|
721
|
-
rows={1}
|
|
722
|
-
value={state.title}
|
|
723
|
-
onChange={(e) => dispatch({ type: 'SET_TITLE', payload: e.target.value })}
|
|
724
|
-
placeholder="The title of your story..."
|
|
725
|
-
className="w-full bg-transparent border-none outline-none text-5xl font-serif font-medium placeholder:text-neutral-500 dark:placeholder:text-neutral-500 resize-none leading-tight transition-colors duration-300 text-neutral-950 dark:text-white"
|
|
726
|
-
/>
|
|
727
|
-
</div>
|
|
728
|
-
)}
|
|
729
|
-
|
|
730
|
-
<EditorBody
|
|
731
|
-
blocks={contentBlocks}
|
|
732
|
-
darkMode={effectiveDarkMode}
|
|
733
|
-
backgroundColors={effectiveBackgroundColors}
|
|
734
|
-
onBlockAdd={(type, index, containerId) => {
|
|
735
|
-
helpers.addBlock(type, index, containerId);
|
|
736
|
-
}}
|
|
737
|
-
onBlockUpdate={(id, data) => {
|
|
738
|
-
helpers.updateBlock(id, data);
|
|
739
|
-
}}
|
|
740
|
-
onBlockDelete={(id) => {
|
|
741
|
-
helpers.deleteBlock(id);
|
|
742
|
-
}}
|
|
743
|
-
onBlockMove={(id, newIndex, containerId) => {
|
|
744
|
-
helpers.moveBlock(id, newIndex, containerId);
|
|
745
|
-
}}
|
|
746
|
-
/>
|
|
747
|
-
</>
|
|
748
|
-
)}
|
|
749
|
-
</div>
|
|
750
|
-
</div>
|
|
296
|
+
<EditorCanvas
|
|
297
|
+
isPreviewMode={isPreviewMode}
|
|
298
|
+
heroBlock={heroBlock}
|
|
299
|
+
heroBlockDefinition={heroBlockDefinition}
|
|
300
|
+
contentBlocks={contentBlocks}
|
|
301
|
+
title={state.title}
|
|
302
|
+
siteId={siteId}
|
|
303
|
+
locale={locale}
|
|
304
|
+
darkMode={effectiveDarkMode}
|
|
305
|
+
backgroundColors={effectiveBackgroundColors}
|
|
306
|
+
featuredImage={state.metadata.featuredImage}
|
|
307
|
+
onTitleChange={(title: string) => dispatch({ type: 'SET_TITLE', payload: title })}
|
|
308
|
+
onHeroBlockUpdate={handleHeroBlockUpdate}
|
|
309
|
+
onHeroBlockDelete={handleHeroBlockDelete}
|
|
310
|
+
onBlockAdd={(type: string, index: number, containerId?: string) => helpers.addBlock(type, index, containerId)}
|
|
311
|
+
onBlockUpdate={(id: string, data: Partial<Block['data']>) => helpers.updateBlock(id, data)}
|
|
312
|
+
onBlockDelete={(id: string) => helpers.deleteBlock(id)}
|
|
313
|
+
onBlockMove={(id: string, newIndex: number, containerId?: string) => helpers.moveBlock(id, newIndex, containerId)}
|
|
314
|
+
/>
|
|
751
315
|
|
|
752
316
|
{/* RIGHT SIDEBAR: THE "DESK" (SETTINGS) */}
|
|
753
317
|
{!isPreviewMode && (
|
|
@@ -755,158 +319,15 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
755
319
|
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'
|
|
756
320
|
}`}
|
|
757
321
|
>
|
|
758
|
-
<
|
|
759
|
-
{
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
<div className="space-y-4">
|
|
768
|
-
<div>
|
|
769
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
770
|
-
SEO Title
|
|
771
|
-
</label>
|
|
772
|
-
<input
|
|
773
|
-
type="text"
|
|
774
|
-
value={state.seo.title || ''}
|
|
775
|
-
onChange={(e) => dispatch({ type: 'SET_SEO', payload: { title: e.target.value } })}
|
|
776
|
-
placeholder="SEO title (defaults to post title)"
|
|
777
|
-
className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text"
|
|
778
|
-
/>
|
|
779
|
-
<p className="text-[9px] text-neutral-400 dark:text-neutral-500 mt-1">
|
|
780
|
-
{state.seo.title?.length || 0} / 60 characters
|
|
781
|
-
</p>
|
|
782
|
-
</div>
|
|
783
|
-
<div>
|
|
784
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
785
|
-
Meta Description
|
|
786
|
-
</label>
|
|
787
|
-
<textarea
|
|
788
|
-
value={state.seo.description || ''}
|
|
789
|
-
onChange={(e) => dispatch({ type: 'SET_SEO', payload: { description: e.target.value } })}
|
|
790
|
-
placeholder="Brief description for search engines"
|
|
791
|
-
rows={3}
|
|
792
|
-
className="w-full px-3 py-2 text-xs bg-white dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-700 rounded-lg outline-none focus:border-primary transition-all dark:text-neutral-100 resize-none"
|
|
793
|
-
/>
|
|
794
|
-
<p className="text-[9px] text-neutral-400 dark:text-neutral-500 mt-1">
|
|
795
|
-
{state.seo.description?.length || 0} / 160 characters
|
|
796
|
-
</p>
|
|
797
|
-
</div>
|
|
798
|
-
<div>
|
|
799
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
800
|
-
Keywords (comma-separated)
|
|
801
|
-
</label>
|
|
802
|
-
<input
|
|
803
|
-
type="text"
|
|
804
|
-
value={state.seo.keywords?.join(', ') || ''}
|
|
805
|
-
onChange={(e) => {
|
|
806
|
-
const keywords = e.target.value.split(',').map(k => k.trim()).filter(k => k);
|
|
807
|
-
dispatch({ type: 'SET_SEO', payload: { keywords } });
|
|
808
|
-
}}
|
|
809
|
-
placeholder="keyword1, keyword2, keyword3"
|
|
810
|
-
className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text"
|
|
811
|
-
/>
|
|
812
|
-
</div>
|
|
813
|
-
<div>
|
|
814
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
815
|
-
Open Graph Image URL
|
|
816
|
-
</label>
|
|
817
|
-
<input
|
|
818
|
-
type="url"
|
|
819
|
-
value={state.seo.ogImage || ''}
|
|
820
|
-
onChange={(e) => dispatch({ type: 'SET_SEO', payload: { ogImage: e.target.value } })}
|
|
821
|
-
placeholder="https://example.com/image.jpg"
|
|
822
|
-
className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text"
|
|
823
|
-
/>
|
|
824
|
-
</div>
|
|
825
|
-
</div>
|
|
826
|
-
</section>
|
|
827
|
-
|
|
828
|
-
{/* Publishing Section */}
|
|
829
|
-
<section className="pt-8 border-t border-neutral-200 dark:border-neutral-800">
|
|
830
|
-
<div className="flex items-center gap-3 mb-6">
|
|
831
|
-
<Globe size={14} className="text-neutral-500 dark:text-neutral-400" />
|
|
832
|
-
<label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
|
|
833
|
-
Publishing
|
|
834
|
-
</label>
|
|
835
|
-
</div>
|
|
836
|
-
<div className="bg-dashboard-bg p-5 rounded-2xl border border-dashboard-border">
|
|
837
|
-
<span className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-3">Slug / Permalink</span>
|
|
838
|
-
<div className="text-xs font-mono break-all text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
|
839
|
-
/blog/<span className="text-neutral-950 dark:text-white bg-amber-50 dark:bg-amber-900/20 px-1 rounded">{state.slug || 'untitled-post'}</span>
|
|
840
|
-
</div>
|
|
841
|
-
</div>
|
|
842
|
-
</section>
|
|
843
|
-
|
|
844
|
-
{/* Category Section */}
|
|
845
|
-
<section className="pt-8 border-t border-neutral-200 dark:border-neutral-800">
|
|
846
|
-
<div className="flex items-center gap-3 mb-6">
|
|
847
|
-
<Box size={14} className="text-neutral-500 dark:text-neutral-400" />
|
|
848
|
-
<label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
|
|
849
|
-
Category
|
|
850
|
-
</label>
|
|
851
|
-
</div>
|
|
852
|
-
<div className="space-y-4">
|
|
853
|
-
<div>
|
|
854
|
-
<label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
|
|
855
|
-
Category
|
|
856
|
-
</label>
|
|
857
|
-
<input
|
|
858
|
-
type="text"
|
|
859
|
-
value={state.metadata.categories?.[0] || ''}
|
|
860
|
-
onChange={(e) => {
|
|
861
|
-
const category = e.target.value.trim();
|
|
862
|
-
dispatch({
|
|
863
|
-
type: 'SET_METADATA',
|
|
864
|
-
payload: {
|
|
865
|
-
categories: category ? [category] : []
|
|
866
|
-
}
|
|
867
|
-
});
|
|
868
|
-
}}
|
|
869
|
-
placeholder="Enter category (required for publishing)"
|
|
870
|
-
className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text"
|
|
871
|
-
/>
|
|
872
|
-
<p className="text-[9px] text-neutral-400 dark:text-neutral-500 mt-1">
|
|
873
|
-
{state.metadata.categories?.[0] ? 'Category set' : 'No category set'}
|
|
874
|
-
</p>
|
|
875
|
-
</div>
|
|
876
|
-
</div>
|
|
877
|
-
</section>
|
|
878
|
-
|
|
879
|
-
{/* Featured Media Section */}
|
|
880
|
-
<FeaturedMediaSection
|
|
881
|
-
featuredImage={state.metadata.featuredImage}
|
|
882
|
-
onUpdate={(image) => dispatch({ type: 'SET_METADATA', payload: { featuredImage: image } })}
|
|
883
|
-
/>
|
|
884
|
-
|
|
885
|
-
{/* Privacy Settings Section */}
|
|
886
|
-
<PrivacySettingsSection
|
|
887
|
-
privacy={state.metadata.privacy}
|
|
888
|
-
onUpdate={(privacy) => dispatch({ type: 'SET_METADATA', payload: { privacy } })}
|
|
889
|
-
/>
|
|
890
|
-
|
|
891
|
-
{/* Post Status Section */}
|
|
892
|
-
<section className="pt-8 border-t border-neutral-200 dark:border-neutral-800">
|
|
893
|
-
<div className="flex items-center justify-between mb-4">
|
|
894
|
-
<label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
|
|
895
|
-
Post Status
|
|
896
|
-
</label>
|
|
897
|
-
<span className="text-[10px] font-black text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-2.5 py-1 rounded-full uppercase tracking-tighter">
|
|
898
|
-
{state.status}
|
|
899
|
-
</span>
|
|
900
|
-
</div>
|
|
901
|
-
<p className="text-[11px] text-neutral-500 dark:text-neutral-400 leading-relaxed italic">
|
|
902
|
-
{state.status === 'draft'
|
|
903
|
-
? 'This post is private. Only you can see it until you hit publish.'
|
|
904
|
-
: state.status === 'published'
|
|
905
|
-
? 'This post is live and visible to everyone.'
|
|
906
|
-
: 'This post is scheduled for publication.'}
|
|
907
|
-
</p>
|
|
908
|
-
</section>
|
|
909
|
-
</div>
|
|
322
|
+
<EditorSidebar
|
|
323
|
+
slug={state.slug}
|
|
324
|
+
seo={state.seo}
|
|
325
|
+
metadata={state.metadata}
|
|
326
|
+
heroBlock={heroBlock}
|
|
327
|
+
status={state.status}
|
|
328
|
+
onSEOUpdate={(seo: Partial<SEOMetadata>) => dispatch({ type: 'SET_SEO', payload: seo })}
|
|
329
|
+
onMetadataUpdate={(metadata: Partial<PostMetadata>) => dispatch({ type: 'SET_METADATA', payload: metadata })}
|
|
330
|
+
/>
|
|
910
331
|
</aside>
|
|
911
332
|
)}
|
|
912
333
|
</div>
|
|
@@ -914,4 +335,3 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
914
335
|
</div>
|
|
915
336
|
);
|
|
916
337
|
}
|
|
917
|
-
|