@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.
Files changed (32) hide show
  1. package/package.json +5 -5
  2. package/src/api/handler.ts +4 -4
  3. package/src/hooks/index.ts +1 -0
  4. package/src/hooks/useCategories.ts +76 -0
  5. package/src/index.tsx +5 -27
  6. package/src/init.tsx +0 -9
  7. package/src/lib/mappers/apiMapper.ts +53 -22
  8. package/src/registry/BlockRegistry.ts +1 -4
  9. package/src/state/EditorContext.tsx +39 -33
  10. package/src/state/types.ts +1 -1
  11. package/src/types/post.ts +4 -0
  12. package/src/views/CanvasEditor/BlockWrapper.tsx +7 -8
  13. package/src/views/CanvasEditor/CanvasEditorView.tsx +208 -794
  14. package/src/views/CanvasEditor/EditorBody.tsx +317 -127
  15. package/src/views/CanvasEditor/EditorHeader.tsx +106 -17
  16. package/src/views/CanvasEditor/LayoutContainer.tsx +208 -380
  17. package/src/views/CanvasEditor/components/EditorCanvas.tsx +160 -0
  18. package/src/views/CanvasEditor/components/EditorLibrary.tsx +122 -0
  19. package/src/views/CanvasEditor/components/EditorSidebar.tsx +181 -0
  20. package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
  21. package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +229 -46
  22. package/src/views/CanvasEditor/components/index.ts +11 -0
  23. package/src/views/CanvasEditor/hooks/index.ts +10 -0
  24. package/src/views/CanvasEditor/hooks/useHeroBlock.ts +103 -0
  25. package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +142 -0
  26. package/src/views/CanvasEditor/hooks/usePostLoader.ts +39 -0
  27. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +55 -0
  28. package/src/views/CanvasEditor/hooks/useUnsavedChanges.ts +339 -0
  29. package/src/views/PostManager/PostCards.tsx +18 -13
  30. package/src/views/PostManager/PostFilters.tsx +15 -0
  31. package/src/views/PostManager/PostManagerView.tsx +21 -15
  32. 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.5",
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",
@@ -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?.src?.trim()) errors.push('Featured image is required for publishing');
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 || { src: '', alt: '', brightness: 100, blur: 0 },
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?.src?.trim()) missingFields.push('featured 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
- imageSrc: image?.src?.trim() || 'missing',
311
+ imageId: image?.id?.trim() || 'missing',
312
312
  category: categoryTags?.category?.trim() || 'missing',
313
313
  hasContent,
314
314
  contentBlocksLength: contentBlocks?.length || 0,
@@ -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
 
@@ -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
- // 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();
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
- return newHistory;
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
- console.log('[EditorContext] save() called with status:', stateRef.current.status);
184
- await onSave(stateRef.current);
190
+ await onSave(stateRef.current, heroBlock);
185
191
  dispatch({ type: 'MARK_CLEAN' });
186
192
  }
187
193
  }, [onSave]);
@@ -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 z-20 ${showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'
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 z-[100] ${isHovered || showControls || showSettingsMenu
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-[200]" ref={settingsMenuRef}>
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-[200]"
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-[200] overflow-hidden">
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' }}>