@jhits/plugin-blog 0.0.5 → 0.0.6
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 +5 -5
- package/src/api/handler.ts +4 -4
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useCategories.ts +76 -0
- package/src/index.tsx +5 -27
- package/src/init.tsx +0 -9
- 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/post.ts +4 -0
- package/src/views/CanvasEditor/BlockWrapper.tsx +7 -8
- package/src/views/CanvasEditor/CanvasEditorView.tsx +208 -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 +229 -46
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhits/plugin-blog",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Professional blog management system for the JHITS ecosystem",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@jhits/plugin-core": "^0.0.1",
|
|
22
|
-
"@jhits/plugin-content": "^0.0.1",
|
|
23
|
-
"@jhits/plugin-images": "^0.0.1",
|
|
24
21
|
"bcrypt": "^6.0.0",
|
|
25
22
|
"framer-motion": "^12.23.26",
|
|
26
23
|
"lucide-react": "^0.562.0",
|
|
27
24
|
"mongodb": "^7.0.0",
|
|
28
|
-
"next-auth": "^4.24.13"
|
|
25
|
+
"next-auth": "^4.24.13",
|
|
26
|
+
"@jhits/plugin-core": "0.0.1",
|
|
27
|
+
"@jhits/plugin-content": "0.0.3",
|
|
28
|
+
"@jhits/plugin-images": "0.0.5"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"next": ">=15.0.0",
|
package/src/api/handler.ts
CHANGED
|
@@ -124,7 +124,7 @@ export async function POST(req: NextRequest, config: BlogApiConfig): Promise<Nex
|
|
|
124
124
|
if (isPublishing) {
|
|
125
125
|
// Publishing requires all fields
|
|
126
126
|
if (!summary?.trim()) errors.push('Summary is required for publishing');
|
|
127
|
-
if (!image?.
|
|
127
|
+
if (!image?.id?.trim()) errors.push('Featured image is required for publishing');
|
|
128
128
|
// Only require category if it's explicitly provided and empty
|
|
129
129
|
// If categoryTags is undefined or category is undefined, that's also missing
|
|
130
130
|
if (!categoryTags || !categoryTags.category || !categoryTags.category.trim()) {
|
|
@@ -168,7 +168,7 @@ export async function POST(req: NextRequest, config: BlogApiConfig): Promise<Nex
|
|
|
168
168
|
summary: (summary || '').trim(),
|
|
169
169
|
contentBlocks: contentBlocks || [],
|
|
170
170
|
content: content || [],
|
|
171
|
-
image: image || {
|
|
171
|
+
image: image || { id: '', alt: '' }, // Only store id and alt - plugin-images handles transforms
|
|
172
172
|
categoryTags: {
|
|
173
173
|
category: categoryTags?.category?.trim() || '',
|
|
174
174
|
tags: categoryTags?.tags || [],
|
|
@@ -295,7 +295,7 @@ export async function PUT_BY_SLUG(
|
|
|
295
295
|
// Collect all missing fields for better error messages
|
|
296
296
|
const missingFields: string[] = [];
|
|
297
297
|
if (!summary?.trim()) missingFields.push('summary');
|
|
298
|
-
if (!image?.
|
|
298
|
+
if (!image?.id?.trim()) missingFields.push('featured image');
|
|
299
299
|
// Only require category if it's explicitly provided and empty
|
|
300
300
|
// If categoryTags is undefined or category is undefined, that's also missing
|
|
301
301
|
if (!categoryTags || !categoryTags.category || !categoryTags.category.trim()) {
|
|
@@ -308,7 +308,7 @@ export async function PUT_BY_SLUG(
|
|
|
308
308
|
isPublishing,
|
|
309
309
|
missingFields,
|
|
310
310
|
summary: summary?.trim() || 'missing',
|
|
311
|
-
|
|
311
|
+
imageId: image?.id?.trim() || 'missing',
|
|
312
312
|
category: categoryTags?.category?.trim() || 'missing',
|
|
313
313
|
hasContent,
|
|
314
314
|
contentBlocksLength: contentBlocks?.length || 0,
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook to fetch categories from existing blog posts
|
|
3
|
+
* Extracts categories from Hero blocks in all posts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect } from 'react';
|
|
9
|
+
|
|
10
|
+
export function useCategories() {
|
|
11
|
+
const [categories, setCategories] = useState<string[]>([]);
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const fetchCategories = async () => {
|
|
16
|
+
try {
|
|
17
|
+
const categorySet = new Set<string>();
|
|
18
|
+
|
|
19
|
+
// 1. Fetch from categories endpoint (legacy categoryTags.category)
|
|
20
|
+
try {
|
|
21
|
+
const categoriesResponse = await fetch('/api/plugin-blog/categories');
|
|
22
|
+
if (categoriesResponse.ok) {
|
|
23
|
+
const categoriesData = await categoriesResponse.json();
|
|
24
|
+
if (Array.isArray(categoriesData.categories)) {
|
|
25
|
+
categoriesData.categories.forEach((cat: string) => {
|
|
26
|
+
if (cat && cat.trim()) {
|
|
27
|
+
categorySet.add(cat.trim());
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch (e) {
|
|
33
|
+
// Categories endpoint not available, continue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Fetch all blog posts and extract categories from Hero blocks
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch('/api/plugin-blog?admin=true&limit=1000');
|
|
39
|
+
if (response.ok) {
|
|
40
|
+
const data = await response.json();
|
|
41
|
+
const posts = Array.isArray(data.blogs) ? data.blogs : (Array.isArray(data) ? data : []);
|
|
42
|
+
|
|
43
|
+
posts.forEach((post: any) => {
|
|
44
|
+
if (post.blocks && Array.isArray(post.blocks)) {
|
|
45
|
+
// Find Hero block
|
|
46
|
+
const heroBlock = post.blocks.find((block: any) => block.type === 'hero');
|
|
47
|
+
if (heroBlock && heroBlock.data && heroBlock.data.category) {
|
|
48
|
+
const category = heroBlock.data.category.trim();
|
|
49
|
+
if (category) {
|
|
50
|
+
categorySet.add(category);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error('Failed to fetch posts for categories:', e);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Convert to sorted array
|
|
61
|
+
const sortedCategories = Array.from(categorySet).sort();
|
|
62
|
+
setCategories(sortedCategories);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error('Failed to fetch categories:', error);
|
|
65
|
+
// Fallback to empty array
|
|
66
|
+
setCategories([]);
|
|
67
|
+
} finally {
|
|
68
|
+
setLoading(false);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
fetchCategories();
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
return { categories, loading };
|
|
76
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -49,7 +49,6 @@ export interface PluginProps {
|
|
|
49
49
|
*/
|
|
50
50
|
export default function BlogPlugin(props: PluginProps) {
|
|
51
51
|
const { subPath, siteId, locale, customBlocks: propsCustomBlocks, darkMode: propsDarkMode, backgroundColors: propsBackgroundColors } = props;
|
|
52
|
-
console.log('[BlogPlugin] Component rendering, propsDarkMode:', propsDarkMode);
|
|
53
52
|
|
|
54
53
|
// Get custom blocks from props or window global (client app injection point)
|
|
55
54
|
const customBlocks = useMemo(() => {
|
|
@@ -79,7 +78,6 @@ export default function BlogPlugin(props: PluginProps) {
|
|
|
79
78
|
if (saved) {
|
|
80
79
|
const config = JSON.parse(saved);
|
|
81
80
|
if (config.darkMode !== undefined) {
|
|
82
|
-
console.log('[BlogPlugin] Using darkMode from localStorage (dev settings):', config.darkMode);
|
|
83
81
|
return config.darkMode as boolean;
|
|
84
82
|
}
|
|
85
83
|
}
|
|
@@ -90,7 +88,6 @@ export default function BlogPlugin(props: PluginProps) {
|
|
|
90
88
|
|
|
91
89
|
// Then try props
|
|
92
90
|
if (propsDarkMode !== undefined) {
|
|
93
|
-
console.log('[BlogPlugin] Using darkMode from props:', propsDarkMode);
|
|
94
91
|
return propsDarkMode;
|
|
95
92
|
}
|
|
96
93
|
|
|
@@ -98,12 +95,10 @@ export default function BlogPlugin(props: PluginProps) {
|
|
|
98
95
|
if (typeof window !== 'undefined' && (window as any).__JHITS_PLUGIN_PROPS__) {
|
|
99
96
|
const pluginProps = (window as any).__JHITS_PLUGIN_PROPS__['plugin-blog'];
|
|
100
97
|
if (pluginProps?.darkMode !== undefined) {
|
|
101
|
-
console.log('[BlogPlugin] Using darkMode from window global (fallback):', pluginProps.darkMode);
|
|
102
98
|
return pluginProps.darkMode as boolean;
|
|
103
99
|
}
|
|
104
100
|
}
|
|
105
101
|
|
|
106
|
-
console.log('[BlogPlugin] darkMode not found, defaulting to true');
|
|
107
102
|
return true; // Default to dark mode enabled
|
|
108
103
|
}, [propsDarkMode]);
|
|
109
104
|
|
|
@@ -167,8 +162,6 @@ export default function BlogPlugin(props: PluginProps) {
|
|
|
167
162
|
};
|
|
168
163
|
}, []);
|
|
169
164
|
|
|
170
|
-
console.log('[BlogPlugin] Final darkMode value:', darkMode);
|
|
171
|
-
console.log('[BlogPlugin] Background colors:', backgroundColors);
|
|
172
165
|
if (heroBlockDefinition !== undefined) {
|
|
173
166
|
console.log('[BlogPlugin] Hero block definition:', heroBlockDefinition ? 'found' : 'not found (REQUIRED)');
|
|
174
167
|
}
|
|
@@ -180,13 +173,12 @@ export default function BlogPlugin(props: PluginProps) {
|
|
|
180
173
|
|
|
181
174
|
case 'editor':
|
|
182
175
|
const postId = subPath[1]; // This is actually the slug, not the ID
|
|
183
|
-
console.log('[BlogPlugin] Rendering editor route with postId (slug):', postId, 'darkMode:', darkMode);
|
|
184
176
|
return (
|
|
185
177
|
<EditorProvider
|
|
186
178
|
customBlocks={customBlocks}
|
|
187
179
|
darkMode={darkMode}
|
|
188
180
|
backgroundColors={backgroundColors}
|
|
189
|
-
onSave={async (state) => {
|
|
181
|
+
onSave={async (state, heroBlock) => {
|
|
190
182
|
// Save to API - update existing post
|
|
191
183
|
// Use the route postId (original slug) to identify which blog to update
|
|
192
184
|
// If route postId is missing, use state.slug or state.postId as fallback
|
|
@@ -195,22 +187,7 @@ export default function BlogPlugin(props: PluginProps) {
|
|
|
195
187
|
throw new Error('Cannot save: no post identifier available. Please reload the page.');
|
|
196
188
|
}
|
|
197
189
|
console.log('[BlogPlugin] Saving post with slug:', originalSlug);
|
|
198
|
-
|
|
199
|
-
title: state.title,
|
|
200
|
-
status: state.status,
|
|
201
|
-
blocksCount: state.blocks.length,
|
|
202
|
-
blocks: state.blocks.map(b => ({ id: b.id, type: b.type, hasData: !!b.data })),
|
|
203
|
-
hasFeaturedImage: !!state.metadata.featuredImage,
|
|
204
|
-
});
|
|
205
|
-
const apiData = editorStateToAPI(state);
|
|
206
|
-
console.log('[BlogPlugin] API data being sent:', {
|
|
207
|
-
title: apiData.title,
|
|
208
|
-
status: apiData.publicationData?.status,
|
|
209
|
-
contentBlocksCount: apiData.contentBlocks?.length || 0,
|
|
210
|
-
contentBlocks: apiData.contentBlocks?.map((b: any) => ({ id: b.id, type: b.type, hasData: !!b.data })) || [],
|
|
211
|
-
hasImage: !!apiData.image,
|
|
212
|
-
fullApiData: JSON.stringify(apiData, null, 2),
|
|
213
|
-
});
|
|
190
|
+
const apiData = editorStateToAPI(state, undefined, heroBlock);
|
|
214
191
|
const response = await fetch(`/api/plugin-blog/${originalSlug}`, {
|
|
215
192
|
method: 'PUT',
|
|
216
193
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -233,7 +210,6 @@ export default function BlogPlugin(props: PluginProps) {
|
|
|
233
210
|
throw new Error(errorMessage + missingFieldsMsg);
|
|
234
211
|
}
|
|
235
212
|
const result = await response.json();
|
|
236
|
-
console.log('[BlogPlugin] Save successful:', result);
|
|
237
213
|
// If the slug changed, update the URL
|
|
238
214
|
if (result.slug && result.slug !== originalSlug) {
|
|
239
215
|
window.history.replaceState(null, '', `/dashboard/blog/editor/${result.slug}`);
|
|
@@ -246,7 +222,6 @@ export default function BlogPlugin(props: PluginProps) {
|
|
|
246
222
|
);
|
|
247
223
|
|
|
248
224
|
case 'new':
|
|
249
|
-
console.log('[BlogPlugin] Rendering new route with darkMode:', darkMode);
|
|
250
225
|
return (
|
|
251
226
|
<EditorProvider
|
|
252
227
|
customBlocks={customBlocks}
|
|
@@ -349,6 +324,9 @@ export { registerLayoutBlocks } from './lib/layouts/registerLayoutBlocks';
|
|
|
349
324
|
export { EditorProvider, useEditor } from './state/EditorContext';
|
|
350
325
|
export type { EditorProviderProps, EditorState, EditorContextValue } from './state';
|
|
351
326
|
|
|
327
|
+
// Export hooks
|
|
328
|
+
export { useCategories } from './hooks/useCategories';
|
|
329
|
+
|
|
352
330
|
// Note: API handlers are server-only and exported from ./index.ts (server entry point)
|
|
353
331
|
// They are NOT exported here to prevent client/server context mixing
|
|
354
332
|
|
package/src/init.tsx
CHANGED
|
@@ -59,14 +59,5 @@ export function initBlogPlugin(config: BlogPluginConfig): void {
|
|
|
59
59
|
darkMode: config.darkMode !== undefined ? config.darkMode : true, // Default to true
|
|
60
60
|
backgroundColors: config.backgroundColors || undefined,
|
|
61
61
|
};
|
|
62
|
-
|
|
63
|
-
console.log('[BlogPlugin] Initialized with config:', {
|
|
64
|
-
customBlocks: config.customBlocks?.length || 0,
|
|
65
|
-
darkMode: config.darkMode !== undefined ? config.darkMode : true,
|
|
66
|
-
backgroundColors: config.backgroundColors ? {
|
|
67
|
-
light: config.backgroundColors.light,
|
|
68
|
-
dark: config.backgroundColors.dark || 'not set',
|
|
69
|
-
} : 'not set',
|
|
70
|
-
});
|
|
71
62
|
}
|
|
72
63
|
|
|
@@ -18,10 +18,12 @@ export interface APIBlogDocument {
|
|
|
18
18
|
content?: any[]; // Legacy format
|
|
19
19
|
summary?: string;
|
|
20
20
|
image?: {
|
|
21
|
-
|
|
21
|
+
id?: string; // Semantic ID (preferred) - plugin-images handles everything else
|
|
22
|
+
src?: string; // Legacy support - will be converted to id when loading
|
|
22
23
|
alt?: string;
|
|
23
|
-
|
|
24
|
-
blur
|
|
24
|
+
isCustom?: boolean;
|
|
25
|
+
// Transform fields (brightness, blur, scale, positionX, positionY) are NOT stored here
|
|
26
|
+
// They are handled by plugin-images API only
|
|
25
27
|
};
|
|
26
28
|
categoryTags?: {
|
|
27
29
|
category?: string;
|
|
@@ -50,6 +52,7 @@ export function apiToBlogPost(doc: APIBlogDocument): BlogPost {
|
|
|
50
52
|
const id = doc._id?.toString() || doc.id || '';
|
|
51
53
|
|
|
52
54
|
// Use contentBlocks if available, otherwise fallback to content (legacy)
|
|
55
|
+
// Hero block is included in contentBlocks
|
|
53
56
|
const blocks = doc.contentBlocks || [];
|
|
54
57
|
|
|
55
58
|
// Convert publication data
|
|
@@ -69,12 +72,14 @@ export function apiToBlogPost(doc: APIBlogDocument): BlogPost {
|
|
|
69
72
|
};
|
|
70
73
|
|
|
71
74
|
// Convert metadata
|
|
75
|
+
// Only store semantic ID (id) and alt - plugin-images handles everything else
|
|
72
76
|
const metadata: PostMetadata = {
|
|
73
77
|
featuredImage: doc.image ? {
|
|
74
|
-
src
|
|
78
|
+
// Prefer id (semantic ID) over src (legacy)
|
|
79
|
+
id: doc.image.id || doc.image.src,
|
|
75
80
|
alt: doc.image.alt,
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
isCustom: doc.image.isCustom,
|
|
82
|
+
// Don't load transform fields - plugin-images handles those
|
|
78
83
|
} : undefined,
|
|
79
84
|
categories: doc.categoryTags?.category ? [doc.categoryTags.category] : [],
|
|
80
85
|
tags: doc.categoryTags?.tags || [],
|
|
@@ -121,11 +126,12 @@ export function blogPostToAPI(post: BlogPost, authorId?: string): Partial<APIBlo
|
|
|
121
126
|
slug: post.slug,
|
|
122
127
|
contentBlocks: post.blocks, // Use new block format
|
|
123
128
|
summary: post.metadata.excerpt,
|
|
129
|
+
// Only save semantic ID (id) and alt - plugin-images handles transform data
|
|
124
130
|
image: post.metadata.featuredImage ? {
|
|
125
|
-
|
|
131
|
+
id: post.metadata.featuredImage.id,
|
|
126
132
|
alt: post.metadata.featuredImage.alt,
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
isCustom: post.metadata.featuredImage.isCustom,
|
|
134
|
+
// Don't save transform fields - plugin-images API handles those
|
|
129
135
|
} : undefined,
|
|
130
136
|
categoryTags: {
|
|
131
137
|
category: post.metadata.categories?.[0] || '',
|
|
@@ -150,6 +156,9 @@ export function blogPostToAPI(post: BlogPost, authorId?: string): Partial<APIBlo
|
|
|
150
156
|
|
|
151
157
|
/**
|
|
152
158
|
* Convert EditorState to API format for saving
|
|
159
|
+
* @param state - Editor state
|
|
160
|
+
* @param authorId - Optional author ID
|
|
161
|
+
* @param heroBlock - Optional hero block (stored separately from content blocks)
|
|
153
162
|
*/
|
|
154
163
|
export function editorStateToAPI(state: {
|
|
155
164
|
title: string;
|
|
@@ -159,7 +168,7 @@ export function editorStateToAPI(state: {
|
|
|
159
168
|
metadata: PostMetadata;
|
|
160
169
|
status: PostStatus;
|
|
161
170
|
postId?: string | null;
|
|
162
|
-
}, authorId?: string): Partial<APIBlogDocument> {
|
|
171
|
+
}, authorId?: string, heroBlock?: Block | null): Partial<APIBlogDocument> {
|
|
163
172
|
// Map status: draft -> concept, published -> published, everything else stays as-is
|
|
164
173
|
const apiStatus = state.status === 'draft' ? 'concept' : state.status;
|
|
165
174
|
|
|
@@ -174,10 +183,10 @@ export function editorStateToAPI(state: {
|
|
|
174
183
|
if (state.metadata.categories && state.metadata.categories.length > 0 && state.metadata.categories[0]?.trim()) {
|
|
175
184
|
category = state.metadata.categories[0].trim();
|
|
176
185
|
} else {
|
|
177
|
-
// Check hero block for category
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
180
|
-
const heroCategory = (
|
|
186
|
+
// Check hero block for category - use the passed heroBlock parameter first, then check state.blocks
|
|
187
|
+
const heroBlockToCheck = heroBlock || state.blocks.find(block => block.type === 'hero');
|
|
188
|
+
if (heroBlockToCheck && heroBlockToCheck.data && typeof heroBlockToCheck.data === 'object') {
|
|
189
|
+
const heroCategory = (heroBlockToCheck.data as any).category;
|
|
181
190
|
if (heroCategory && typeof heroCategory === 'string' && heroCategory.trim()) {
|
|
182
191
|
category = heroCategory.trim();
|
|
183
192
|
}
|
|
@@ -186,20 +195,42 @@ export function editorStateToAPI(state: {
|
|
|
186
195
|
|
|
187
196
|
console.log('[editorStateToAPI] Category resolution:', {
|
|
188
197
|
fromMetadata: state.metadata.categories?.[0],
|
|
189
|
-
fromHeroBlock: state.blocks.find(b => b.type === 'hero')?.data ? (state.blocks.find(b => b.type === 'hero')!.data as any).category : undefined,
|
|
190
|
-
finalCategory: category
|
|
198
|
+
fromHeroBlock: (heroBlock || state.blocks.find(b => b.type === 'hero'))?.data ? ((heroBlock || state.blocks.find(b => b.type === 'hero'))!.data as any).category : undefined,
|
|
199
|
+
finalCategory: category,
|
|
200
|
+
hasHeroBlock: !!heroBlock,
|
|
201
|
+
heroBlockImage: heroBlock?.data ? (heroBlock.data as any)?.image : undefined,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Include hero block in contentBlocks if it exists
|
|
205
|
+
// Filter out any existing hero blocks from state.blocks first, then add the current hero block
|
|
206
|
+
const contentBlocksWithoutHero = state.blocks.filter(block => block.type !== 'hero');
|
|
207
|
+
const allBlocks = heroBlock
|
|
208
|
+
? [heroBlock, ...contentBlocksWithoutHero]
|
|
209
|
+
: contentBlocksWithoutHero;
|
|
210
|
+
|
|
211
|
+
console.log('[editorStateToAPI] Hero block details:', {
|
|
212
|
+
hasHeroBlock: !!heroBlock,
|
|
213
|
+
heroBlockType: heroBlock?.type,
|
|
214
|
+
heroBlockId: heroBlock?.id,
|
|
215
|
+
heroBlockImage: heroBlock?.data ? (heroBlock.data as any)?.image : undefined,
|
|
216
|
+
heroBlockImageSrc: heroBlock?.data ? (heroBlock.data as any)?.image?.src : undefined,
|
|
217
|
+
contentBlocksCount: allBlocks.length,
|
|
218
|
+
contentBlocksTypes: allBlocks.map(b => b.type),
|
|
219
|
+
heroBlockInContentBlocks: allBlocks.find(b => b.type === 'hero')?.data ? (allBlocks.find(b => b.type === 'hero')!.data as any)?.image : undefined,
|
|
191
220
|
});
|
|
192
221
|
|
|
193
222
|
return {
|
|
194
223
|
title: state.title,
|
|
195
224
|
slug: state.slug,
|
|
196
|
-
contentBlocks:
|
|
225
|
+
contentBlocks: allBlocks,
|
|
197
226
|
summary: state.metadata.excerpt,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
227
|
+
// Only save semantic ID (id) and alt - plugin-images handles transform data
|
|
228
|
+
// Only create image object if id exists and is not empty
|
|
229
|
+
image: state.metadata.featuredImage?.id?.trim() ? {
|
|
230
|
+
id: state.metadata.featuredImage.id.trim(),
|
|
231
|
+
alt: state.metadata.featuredImage.alt || '',
|
|
232
|
+
isCustom: state.metadata.featuredImage.isCustom,
|
|
233
|
+
// Don't save transform fields - plugin-images API handles those
|
|
203
234
|
} : undefined,
|
|
204
235
|
categoryTags: {
|
|
205
236
|
category: category,
|
|
@@ -35,10 +35,7 @@ class BlockRegistryImpl implements IBlockRegistry {
|
|
|
35
35
|
);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
if (
|
|
39
|
-
console.warn(`Block type "${definition.type}" is already registered. Overwriting...`);
|
|
40
|
-
}
|
|
41
|
-
|
|
38
|
+
// Silently overwrite if already registered (expected in React 18 Strict Mode)
|
|
42
39
|
this._types.set(definition.type, definition);
|
|
43
40
|
}
|
|
44
41
|
|
|
@@ -26,7 +26,7 @@ export interface EditorProviderProps {
|
|
|
26
26
|
/** Initial state (optional) */
|
|
27
27
|
initialState?: Partial<EditorState>;
|
|
28
28
|
/** Callback when save is triggered */
|
|
29
|
-
onSave?: (state: EditorState) => Promise<void>;
|
|
29
|
+
onSave?: (state: EditorState, heroBlock?: Block | null) => Promise<void>;
|
|
30
30
|
/**
|
|
31
31
|
* Custom blocks from client application
|
|
32
32
|
* These blocks will be registered in the BlockRegistry on mount
|
|
@@ -56,29 +56,19 @@ export function EditorProvider({
|
|
|
56
56
|
darkMode = true,
|
|
57
57
|
backgroundColors
|
|
58
58
|
}: EditorProviderProps) {
|
|
59
|
-
console.log('[EditorProvider] Rendering with darkMode:', darkMode);
|
|
60
|
-
|
|
61
59
|
// Register core layout blocks on mount
|
|
62
60
|
useEffect(() => {
|
|
63
|
-
console.log('[EditorContext] Registering layout blocks...');
|
|
64
61
|
registerLayoutBlocks();
|
|
65
|
-
const layoutBlocks = blockRegistry.getByCategory('layout');
|
|
66
|
-
console.log('[EditorContext] Layout blocks registered:', layoutBlocks.length, layoutBlocks.map(b => b.type));
|
|
67
62
|
}, []);
|
|
68
63
|
|
|
69
64
|
// Register client blocks on mount
|
|
70
65
|
useEffect(() => {
|
|
71
66
|
if (customBlocks && customBlocks.length > 0) {
|
|
72
67
|
try {
|
|
73
|
-
console.log('[EditorContext] Registering custom blocks:', customBlocks.length, customBlocks.map(b => b.type));
|
|
74
68
|
blockRegistry.registerClientBlocks(customBlocks);
|
|
75
|
-
const allBlocks = blockRegistry.getAll();
|
|
76
|
-
console.log('[EditorContext] Total blocks after registration:', allBlocks.length, allBlocks.map(b => b.type));
|
|
77
69
|
} catch (error) {
|
|
78
70
|
console.error('[EditorContext] Failed to register custom blocks:', error);
|
|
79
71
|
}
|
|
80
|
-
} else {
|
|
81
|
-
console.log('[EditorContext] No custom blocks provided');
|
|
82
72
|
}
|
|
83
73
|
}, [customBlocks]);
|
|
84
74
|
|
|
@@ -98,33 +88,50 @@ export function EditorProvider({
|
|
|
98
88
|
const MAX_HISTORY = 50; // Limit history to prevent memory issues
|
|
99
89
|
|
|
100
90
|
// Save current state to history after state changes (but not during undo/redo)
|
|
91
|
+
// Debounce history updates to avoid excessive re-renders
|
|
92
|
+
const historyTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
93
|
+
|
|
101
94
|
useEffect(() => {
|
|
102
95
|
if (isRestoringRef.current) {
|
|
103
96
|
isRestoringRef.current = false;
|
|
104
97
|
return;
|
|
105
98
|
}
|
|
106
99
|
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
newHistory.
|
|
100
|
+
// Clear existing timeout
|
|
101
|
+
if (historyTimeoutRef.current) {
|
|
102
|
+
clearTimeout(historyTimeoutRef.current);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Debounce history updates to reduce re-renders
|
|
106
|
+
historyTimeoutRef.current = setTimeout(() => {
|
|
107
|
+
// Save current state to history
|
|
108
|
+
setHistory(prev => {
|
|
109
|
+
const newHistory = [...prev];
|
|
110
|
+
// Remove any future history if we're not at the end
|
|
111
|
+
if (historyIndex < newHistory.length - 1) {
|
|
112
|
+
newHistory.splice(historyIndex + 1);
|
|
113
|
+
}
|
|
114
|
+
// Add current state
|
|
115
|
+
newHistory.push({ ...state });
|
|
116
|
+
// Limit history size
|
|
117
|
+
if (newHistory.length > MAX_HISTORY) {
|
|
118
|
+
newHistory.shift();
|
|
119
|
+
return newHistory;
|
|
120
|
+
}
|
|
119
121
|
return newHistory;
|
|
122
|
+
});
|
|
123
|
+
setHistoryIndex(prev => {
|
|
124
|
+
const newIndex = prev + 1;
|
|
125
|
+
return newIndex >= MAX_HISTORY ? MAX_HISTORY - 1 : newIndex;
|
|
126
|
+
});
|
|
127
|
+
}, 300); // Debounce by 300ms
|
|
128
|
+
|
|
129
|
+
return () => {
|
|
130
|
+
if (historyTimeoutRef.current) {
|
|
131
|
+
clearTimeout(historyTimeoutRef.current);
|
|
120
132
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
setHistoryIndex(prev => {
|
|
124
|
-
const newIndex = prev + 1;
|
|
125
|
-
return newIndex >= MAX_HISTORY ? MAX_HISTORY - 1 : newIndex;
|
|
126
|
-
});
|
|
127
|
-
}, [state.blocks, state.title, state.slug, state.seo, state.metadata, state.status]);
|
|
133
|
+
};
|
|
134
|
+
}, [state.blocks, state.title, state.slug, state.seo, state.metadata, state.status, historyIndex]);
|
|
128
135
|
|
|
129
136
|
// Helper: Add a new block (supports nested containers)
|
|
130
137
|
const addBlock = useCallback((type: string, index?: number, containerId?: string) => {
|
|
@@ -176,12 +183,11 @@ export function EditorProvider({
|
|
|
176
183
|
|
|
177
184
|
// Helper: Save
|
|
178
185
|
// Uses stateRef to always get the latest state, avoiding stale closure issues
|
|
179
|
-
const save = useCallback(async () => {
|
|
186
|
+
const save = useCallback(async (heroBlock?: Block | null) => {
|
|
180
187
|
if (onSave) {
|
|
181
188
|
// Use stateRef.current to get the absolute latest state
|
|
182
189
|
// This ensures we don't have stale closure issues with React state updates
|
|
183
|
-
|
|
184
|
-
await onSave(stateRef.current);
|
|
190
|
+
await onSave(stateRef.current, heroBlock);
|
|
185
191
|
dispatch({ type: 'MARK_CLEAN' });
|
|
186
192
|
}
|
|
187
193
|
}, [onSave]);
|
package/src/state/types.ts
CHANGED
|
@@ -118,7 +118,7 @@ export interface EditorContextValue {
|
|
|
118
118
|
resetEditor: () => void;
|
|
119
119
|
|
|
120
120
|
/** Save current state (triggers save callback) */
|
|
121
|
-
save: () => Promise<void>;
|
|
121
|
+
save: (heroBlock?: Block | null) => Promise<void>;
|
|
122
122
|
|
|
123
123
|
/** Undo last action */
|
|
124
124
|
undo: () => void;
|
package/src/types/post.ts
CHANGED
|
@@ -75,6 +75,10 @@ export interface PostMetadata {
|
|
|
75
75
|
alt?: string;
|
|
76
76
|
brightness?: number; // 0-200, 100 = normal
|
|
77
77
|
blur?: number; // 0-20
|
|
78
|
+
scale?: number; // 0.1-3.0, 1.0 = normal
|
|
79
|
+
positionX?: number; // -100 to 100, 0 = center
|
|
80
|
+
positionY?: number; // -100 to 100, 0 = center
|
|
81
|
+
isCustom?: boolean; // true if edited independently from hero image
|
|
78
82
|
};
|
|
79
83
|
|
|
80
84
|
/** Categories */
|
|
@@ -274,7 +274,7 @@ export function BlockWrapper({
|
|
|
274
274
|
>
|
|
275
275
|
{/* Left Margin Controls - Visible on Hover */}
|
|
276
276
|
<div
|
|
277
|
-
className={`absolute -left-16 top-1/2 -translate-y-1/2 flex flex-col gap-2 transition-all duration-200
|
|
277
|
+
className={`absolute -left-16 top-1/2 -translate-y-1/2 flex flex-col gap-2 transition-all duration-200 ${showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
278
278
|
}`}
|
|
279
279
|
onMouseEnter={() => setIsControlsHovered(true)}
|
|
280
280
|
onMouseLeave={() => setIsControlsHovered(false)}
|
|
@@ -321,7 +321,7 @@ export function BlockWrapper({
|
|
|
321
321
|
|
|
322
322
|
{/* Block Header - Positioned above the component */}
|
|
323
323
|
<div
|
|
324
|
-
className={`mb-2 transition-all relative
|
|
324
|
+
className={`mb-2 transition-all relative ${isHovered || showControls || showSettingsMenu
|
|
325
325
|
? 'opacity-100 translate-y-0'
|
|
326
326
|
: 'opacity-0 -translate-y-2 pointer-events-none'
|
|
327
327
|
}`}
|
|
@@ -339,7 +339,7 @@ export function BlockWrapper({
|
|
|
339
339
|
</span>
|
|
340
340
|
|
|
341
341
|
{/* Layout Block Settings - Inline in header */}
|
|
342
|
-
{block.type === 'section' && (
|
|
342
|
+
{/* {block.type === 'section' && (
|
|
343
343
|
<div className="flex items-center gap-2 ml-4 flex-1 min-w-0">
|
|
344
344
|
<select
|
|
345
345
|
value={(block.data.padding as string) || 'md'}
|
|
@@ -363,7 +363,7 @@ export function BlockWrapper({
|
|
|
363
363
|
<option value="CREAM">Cream</option>
|
|
364
364
|
</select>
|
|
365
365
|
</div>
|
|
366
|
-
)}
|
|
366
|
+
)} */}
|
|
367
367
|
|
|
368
368
|
{block.type === 'columns' && (
|
|
369
369
|
<select
|
|
@@ -381,13 +381,13 @@ export function BlockWrapper({
|
|
|
381
381
|
</select>
|
|
382
382
|
)}
|
|
383
383
|
</div>
|
|
384
|
-
<div className="relative shrink-0 z-
|
|
384
|
+
<div className="relative shrink-0 z-20" ref={settingsMenuRef}>
|
|
385
385
|
<button
|
|
386
386
|
onClick={(e) => {
|
|
387
387
|
e.stopPropagation();
|
|
388
388
|
setShowSettingsMenu(!showSettingsMenu);
|
|
389
389
|
}}
|
|
390
|
-
className="p-1 rounded transition-colors text-neutral-400 dark:text-neutral-500 hover:text-neutral-950 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 relative z-
|
|
390
|
+
className="p-1 rounded transition-colors text-neutral-400 dark:text-neutral-500 hover:text-neutral-950 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 relative z-20"
|
|
391
391
|
title="Block settings"
|
|
392
392
|
>
|
|
393
393
|
<Settings2 size={12} />
|
|
@@ -395,7 +395,7 @@ export function BlockWrapper({
|
|
|
395
395
|
|
|
396
396
|
{/* Settings Dropdown Menu */}
|
|
397
397
|
{showSettingsMenu && (
|
|
398
|
-
<div className="absolute right-0 top-full mt-1 w-40 bg-white dark:bg-neutral-900 border border-neutral-300 dark:border-neutral-700 rounded-lg shadow-xl z-
|
|
398
|
+
<div className="absolute right-0 top-full mt-1 w-40 bg-white dark:bg-neutral-900 border border-neutral-300 dark:border-neutral-700 rounded-lg shadow-xl z-20 overflow-hidden">
|
|
399
399
|
<button
|
|
400
400
|
onClick={(e) => {
|
|
401
401
|
e.stopPropagation();
|
|
@@ -418,7 +418,6 @@ export function BlockWrapper({
|
|
|
418
418
|
? 'border-primary/60 dark:border-primary/40 bg-primary/5 dark:bg-primary/10'
|
|
419
419
|
: 'border-neutral-200 dark:border-neutral-700 bg-transparent'
|
|
420
420
|
}`}
|
|
421
|
-
style={{ zIndex: 1 }}
|
|
422
421
|
>
|
|
423
422
|
{/* Edit Component - No padding, let the component control its own spacing */}
|
|
424
423
|
<div className="relative" style={{ userSelect: 'text' }}>
|