@jhits/plugin-blog 0.0.4 → 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.
Files changed (36) hide show
  1. package/package.json +55 -58
  2. package/src/api/handler.ts +10 -11
  3. package/src/api/router.ts +1 -4
  4. package/src/hooks/index.ts +1 -0
  5. package/src/hooks/useCategories.ts +76 -0
  6. package/src/index.tsx +5 -27
  7. package/src/init.tsx +0 -9
  8. package/src/lib/mappers/apiMapper.ts +53 -22
  9. package/src/registry/BlockRegistry.ts +1 -4
  10. package/src/state/EditorContext.tsx +39 -33
  11. package/src/state/types.ts +1 -1
  12. package/src/types/post.ts +4 -0
  13. package/src/utils/index.ts +2 -1
  14. package/src/views/CanvasEditor/BlockWrapper.tsx +7 -8
  15. package/src/views/CanvasEditor/CanvasEditorView.tsx +208 -794
  16. package/src/views/CanvasEditor/EditorBody.tsx +317 -127
  17. package/src/views/CanvasEditor/EditorHeader.tsx +106 -17
  18. package/src/views/CanvasEditor/LayoutContainer.tsx +208 -380
  19. package/src/views/CanvasEditor/components/EditorCanvas.tsx +160 -0
  20. package/src/views/CanvasEditor/components/EditorLibrary.tsx +122 -0
  21. package/src/views/CanvasEditor/components/EditorSidebar.tsx +181 -0
  22. package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
  23. package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +229 -46
  24. package/src/views/CanvasEditor/components/index.ts +11 -0
  25. package/src/views/CanvasEditor/hooks/index.ts +10 -0
  26. package/src/views/CanvasEditor/hooks/useHeroBlock.ts +103 -0
  27. package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +142 -0
  28. package/src/views/CanvasEditor/hooks/usePostLoader.ts +39 -0
  29. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +55 -0
  30. package/src/views/CanvasEditor/hooks/useUnsavedChanges.ts +339 -0
  31. package/src/views/PostManager/PostActionsMenu.tsx +1 -1
  32. package/src/views/PostManager/PostCards.tsx +18 -13
  33. package/src/views/PostManager/PostFilters.tsx +15 -0
  34. package/src/views/PostManager/PostManagerView.tsx +29 -20
  35. package/src/views/PostManager/PostStats.tsx +5 -5
  36. package/src/views/PostManager/PostTable.tsx +10 -5
package/package.json CHANGED
@@ -1,60 +1,57 @@
1
1
  {
2
- "name": "@jhits/plugin-blog",
3
- "version": "0.0.4",
4
- "description": "Professional blog management system for the JHITS ecosystem",
5
- "publishConfig": {
6
- "access": "public"
2
+ "name": "@jhits/plugin-blog",
3
+ "version": "0.0.6",
4
+ "description": "Professional blog management system for the JHITS ecosystem",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "main": "./src/index.tsx",
9
+ "types": "./src/index.tsx",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./src/index.tsx",
13
+ "default": "./src/index.tsx"
7
14
  },
8
- "main": "./src/index.tsx",
9
- "types": "./src/index.tsx",
10
- "exports": {
11
- ".": {
12
- "types": "./src/index.tsx",
13
- "default": "./src/index.tsx"
14
- },
15
- "./server": {
16
- "types": "./src/index.server.ts",
17
- "default": "./src/index.server.ts"
18
- }
19
- },
20
- "dependencies": {
21
- "@jhits/plugin-core": "^0.0.1",
22
- "@jhits/plugin-content": "^0.0.1",
23
- "@jhits/plugin-images": "^0.0.1",
24
- "bcrypt": "^6.0.0",
25
- "framer-motion": "^12.23.26",
26
- "lucide-react": "^0.562.0",
27
- "mongodb": "^7.0.0",
28
- "next-auth": "^4.24.13"
29
- },
30
- "peerDependencies": {
31
- "next": ">=15.0.0",
32
- "next-intl": ">=4.0.0",
33
- "next-themes": ">=0.4.0",
34
- "react": ">=18.0.0",
35
- "react-dom": ">=18.0.0"
36
- },
37
- "devDependencies": {
38
- "@tailwindcss/postcss": "^4",
39
- "@types/bcrypt": "^6.0.0",
40
- "@types/node": "^20.19.27",
41
- "@types/react": "^19",
42
- "@types/react-dom": "^19",
43
- "eslint": "^9",
44
- "eslint-config-next": "16.1.1",
45
- "next": "16.1.1",
46
- "next-intl": "4.6.1",
47
- "next-themes": "0.4.6",
48
- "react": "19.2.3",
49
- "react-dom": "19.2.3",
50
- "tailwindcss": "^4",
51
- "typescript": "^5"
52
- },
53
- "files": [
54
- "src/**/*.{ts,tsx,json}",
55
- "!src/**/*.md",
56
- "!src/**/README.md",
57
- "!README.md",
58
- "package.json"
59
- ]
60
- }
15
+ "./server": {
16
+ "types": "./src/index.server.ts",
17
+ "default": "./src/index.server.ts"
18
+ }
19
+ },
20
+ "dependencies": {
21
+ "bcrypt": "^6.0.0",
22
+ "framer-motion": "^12.23.26",
23
+ "lucide-react": "^0.562.0",
24
+ "mongodb": "^7.0.0",
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
+ },
30
+ "peerDependencies": {
31
+ "next": ">=15.0.0",
32
+ "next-intl": ">=4.0.0",
33
+ "next-themes": ">=0.4.0",
34
+ "react": ">=18.0.0",
35
+ "react-dom": ">=18.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@tailwindcss/postcss": "^4",
39
+ "@types/bcrypt": "^6.0.0",
40
+ "@types/node": "^20.19.27",
41
+ "@types/react": "^19",
42
+ "@types/react-dom": "^19",
43
+ "eslint": "^9",
44
+ "eslint-config-next": "16.1.1",
45
+ "next": "16.1.1",
46
+ "next-intl": "4.6.1",
47
+ "next-themes": "0.4.6",
48
+ "react": "19.2.3",
49
+ "react-dom": "19.2.3",
50
+ "tailwindcss": "^4",
51
+ "typescript": "^5"
52
+ },
53
+ "files": [
54
+ "src",
55
+ "package.json"
56
+ ]
57
+ }
@@ -8,7 +8,6 @@
8
8
  */
9
9
 
10
10
  import { NextRequest, NextResponse } from 'next/server';
11
- import { APIBlogDocument, apiToBlogPost, editorStateToAPI } from '../lib/mappers/apiMapper';
12
11
  import { slugify } from '../lib/utils/slugify';
13
12
 
14
13
  export interface BlogApiConfig {
@@ -68,7 +67,7 @@ export async function GET(req: NextRequest, config: BlogApiConfig): Promise<Next
68
67
  .find(query)
69
68
  .sort({ 'publicationData.date': -1 })
70
69
  .skip(skip)
71
- .limit(limit)
70
+ .limit(isAdminView ? 0 : limit)
72
71
  .toArray(),
73
72
  blogs.countDocuments(query),
74
73
  ]);
@@ -125,7 +124,7 @@ export async function POST(req: NextRequest, config: BlogApiConfig): Promise<Nex
125
124
  if (isPublishing) {
126
125
  // Publishing requires all fields
127
126
  if (!summary?.trim()) errors.push('Summary is required for publishing');
128
- if (!image?.src?.trim()) errors.push('Featured image is required for publishing');
127
+ if (!image?.id?.trim()) errors.push('Featured image is required for publishing');
129
128
  // Only require category if it's explicitly provided and empty
130
129
  // If categoryTags is undefined or category is undefined, that's also missing
131
130
  if (!categoryTags || !categoryTags.category || !categoryTags.category.trim()) {
@@ -169,7 +168,7 @@ export async function POST(req: NextRequest, config: BlogApiConfig): Promise<Nex
169
168
  summary: (summary || '').trim(),
170
169
  contentBlocks: contentBlocks || [],
171
170
  content: content || [],
172
- image: image || { src: '', alt: '', brightness: 100, blur: 0 },
171
+ image: image || { id: '', alt: '' }, // Only store id and alt - plugin-images handles transforms
173
172
  categoryTags: {
174
173
  category: categoryTags?.category?.trim() || '',
175
174
  tags: categoryTags?.tags || [],
@@ -292,33 +291,33 @@ export async function PUT_BY_SLUG(
292
291
  const hasContent =
293
292
  (contentBlocks && Array.isArray(contentBlocks) && contentBlocks.length > 0) ||
294
293
  (content && Array.isArray(content) && content.length > 0);
295
-
294
+
296
295
  // Collect all missing fields for better error messages
297
296
  const missingFields: string[] = [];
298
297
  if (!summary?.trim()) missingFields.push('summary');
299
- if (!image?.src?.trim()) missingFields.push('featured image');
298
+ if (!image?.id?.trim()) missingFields.push('featured image');
300
299
  // Only require category if it's explicitly provided and empty
301
300
  // If categoryTags is undefined or category is undefined, that's also missing
302
301
  if (!categoryTags || !categoryTags.category || !categoryTags.category.trim()) {
303
302
  missingFields.push('category');
304
303
  }
305
304
  if (!hasContent) missingFields.push('content');
306
-
305
+
307
306
  if (missingFields.length > 0) {
308
307
  console.log('[BlogAPI] PUT_BY_SLUG validation failed:', {
309
308
  isPublishing,
310
309
  missingFields,
311
310
  summary: summary?.trim() || 'missing',
312
- imageSrc: image?.src?.trim() || 'missing',
311
+ imageId: image?.id?.trim() || 'missing',
313
312
  category: categoryTags?.category?.trim() || 'missing',
314
313
  hasContent,
315
314
  contentBlocksLength: contentBlocks?.length || 0,
316
315
  contentLength: content?.length || 0,
317
316
  });
318
317
  return NextResponse.json(
319
- {
318
+ {
320
319
  message: `Missing required fields for publishing: ${missingFields.join(', ')}`,
321
- missingFields
320
+ missingFields
322
321
  },
323
322
  { status: 400 }
324
323
  );
@@ -341,7 +340,7 @@ export async function PUT_BY_SLUG(
341
340
  } else if (publicationData?.status === 'draft') {
342
341
  finalStatus = 'concept';
343
342
  }
344
-
343
+
345
344
  const updateData = {
346
345
  title: title.trim(),
347
346
  summary: (summary || '').trim(),
package/src/api/router.ts CHANGED
@@ -37,21 +37,18 @@ export async function handleBlogApi(
37
37
  getUserId: config.getUserId,
38
38
  collectionName: config.collectionName || 'blogs',
39
39
  });
40
-
40
+
41
41
  const method = req.method;
42
42
  // Handle empty path array - means we're at /api/plugin-blog
43
43
  // Ensure path is always an array
44
44
  const safePath = Array.isArray(path) ? path : [];
45
45
  const route = safePath.length > 0 ? safePath[0] : '';
46
46
 
47
- console.log(`[BlogApiRouter] method=${method}, path=${JSON.stringify(safePath)}, route=${route}, url=${req.url}`);
48
-
49
47
  try {
50
48
  // Route: /api/plugin-blog (list/create) - empty path or 'list'
51
49
  // This handles both /api/plugin-blog and /api/plugin-blog?limit=3
52
50
  if (!route || route === 'list') {
53
51
  if (method === 'GET') {
54
- console.log('[BlogApiRouter] Routing to BlogListHandler');
55
52
  return await BlogListHandler(req, blogApiConfig);
56
53
  }
57
54
  if (method === 'POST') {
@@ -5,4 +5,5 @@
5
5
 
6
6
  export { useBlogs, type UseBlogsOptions, type UseBlogsResult } from './useBlogs';
7
7
  export { useBlog, type UseBlogOptions, type UseBlogResult } from './useBlog';
8
+ export { useCategories } from './useCategories';
8
9
 
@@ -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
- console.log('[BlogPlugin] Post state:', {
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
- src?: string;
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
- brightness?: number;
24
- blur?: number;
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: doc.image.src,
78
+ // Prefer id (semantic ID) over src (legacy)
79
+ id: doc.image.id || doc.image.src,
75
80
  alt: doc.image.alt,
76
- brightness: doc.image.brightness,
77
- blur: doc.image.blur,
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
- src: post.metadata.featuredImage.src,
131
+ id: post.metadata.featuredImage.id,
126
132
  alt: post.metadata.featuredImage.alt,
127
- brightness: post.metadata.featuredImage.brightness,
128
- blur: post.metadata.featuredImage.blur,
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 heroBlock = state.blocks.find(block => block.type === 'hero');
179
- if (heroBlock && heroBlock.data && typeof heroBlock.data === 'object') {
180
- const heroCategory = (heroBlock.data as any).category;
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: state.blocks,
225
+ contentBlocks: allBlocks,
197
226
  summary: state.metadata.excerpt,
198
- image: state.metadata.featuredImage ? {
199
- src: state.metadata.featuredImage.src,
200
- alt: state.metadata.featuredImage.alt,
201
- brightness: state.metadata.featuredImage.brightness,
202
- blur: state.metadata.featuredImage.blur,
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 (this._types.has(definition.type)) {
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