@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.
Files changed (39) hide show
  1. package/package.json +16 -16
  2. package/src/api/config-handler.ts +76 -0
  3. package/src/api/handler.ts +4 -4
  4. package/src/api/router.ts +17 -0
  5. package/src/hooks/index.ts +1 -0
  6. package/src/hooks/useCategories.ts +76 -0
  7. package/src/index.tsx +8 -27
  8. package/src/init.tsx +0 -9
  9. package/src/lib/config-storage.ts +65 -0
  10. package/src/lib/layouts/blocks/ColumnsBlock.tsx +177 -13
  11. package/src/lib/layouts/blocks/ColumnsBlock.tsx.tmp +81 -0
  12. package/src/lib/layouts/registerLayoutBlocks.ts +6 -1
  13. package/src/lib/mappers/apiMapper.ts +53 -22
  14. package/src/registry/BlockRegistry.ts +1 -4
  15. package/src/state/EditorContext.tsx +39 -33
  16. package/src/state/types.ts +1 -1
  17. package/src/types/index.ts +2 -0
  18. package/src/types/post.ts +4 -0
  19. package/src/views/CanvasEditor/BlockWrapper.tsx +87 -24
  20. package/src/views/CanvasEditor/CanvasEditorView.tsx +214 -794
  21. package/src/views/CanvasEditor/EditorBody.tsx +317 -127
  22. package/src/views/CanvasEditor/EditorHeader.tsx +106 -17
  23. package/src/views/CanvasEditor/LayoutContainer.tsx +208 -380
  24. package/src/views/CanvasEditor/components/EditorCanvas.tsx +160 -0
  25. package/src/views/CanvasEditor/components/EditorLibrary.tsx +122 -0
  26. package/src/views/CanvasEditor/components/EditorSidebar.tsx +181 -0
  27. package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
  28. package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +260 -49
  29. package/src/views/CanvasEditor/components/index.ts +11 -0
  30. package/src/views/CanvasEditor/hooks/index.ts +10 -0
  31. package/src/views/CanvasEditor/hooks/useHeroBlock.ts +103 -0
  32. package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +142 -0
  33. package/src/views/CanvasEditor/hooks/usePostLoader.ts +39 -0
  34. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +55 -0
  35. package/src/views/CanvasEditor/hooks/useUnsavedChanges.ts +339 -0
  36. package/src/views/PostManager/PostCards.tsx +18 -13
  37. package/src/views/PostManager/PostFilters.tsx +15 -0
  38. package/src/views/PostManager/PostManagerView.tsx +21 -15
  39. package/src/views/PostManager/PostTable.tsx +7 -4
@@ -6,6 +6,7 @@
6
6
  'use client';
7
7
 
8
8
  import React from 'react';
9
+ import { Plus, Trash2 } from 'lucide-react';
9
10
  import { BlockEditProps, BlockPreviewProps } from '../../../types/block';
10
11
  import { LayoutContainer } from '../../../views/CanvasEditor/LayoutContainer';
11
12
  import { COLUMN_LAYOUTS, ColumnLayout } from '../index';
@@ -30,9 +31,45 @@ export const ColumnsEdit: React.FC<BlockEditProps & {
30
31
  onChildBlockDelete,
31
32
  onChildBlockMove,
32
33
  }) => {
33
- const layout = (block.data.layout as ColumnLayout) || '50-50';
34
- const layoutConfig = COLUMN_LAYOUTS[layout];
35
- const numColumns = layoutConfig.widths.length;
34
+ // Support both old layout-based system and new dynamic column count
35
+ const columnCount = block.data.columnCount as number | undefined;
36
+ const layout: ColumnLayout | undefined = block.data.layout as ColumnLayout | undefined;
37
+
38
+ // Determine number of columns: use columnCount if set, otherwise derive from layout
39
+ let numColumns: number;
40
+ let gridClass: string;
41
+ let columnWidths: number[];
42
+
43
+ // Grid class mapping for Tailwind (must be explicit for dynamic classes)
44
+ const gridClassMap: Record<number, string> = {
45
+ 1: 'grid-cols-1',
46
+ 2: 'grid-cols-2',
47
+ 3: 'grid-cols-3',
48
+ 4: 'grid-cols-4',
49
+ 5: 'grid-cols-5',
50
+ 6: 'grid-cols-6',
51
+ };
52
+
53
+ if (columnCount !== undefined && columnCount > 0) {
54
+ // Dynamic column system
55
+ numColumns = columnCount;
56
+ // Create equal-width columns
57
+ const widthPercent = Math.floor(100 / numColumns);
58
+ columnWidths = Array(numColumns).fill(widthPercent);
59
+ // Use explicit grid class from map, fallback to inline style if needed
60
+ gridClass = gridClassMap[numColumns] || `grid-cols-${numColumns}`;
61
+ } else if (layout && COLUMN_LAYOUTS[layout]) {
62
+ // Legacy layout-based system
63
+ const layoutConfig = COLUMN_LAYOUTS[layout];
64
+ numColumns = layoutConfig.widths.length;
65
+ gridClass = layoutConfig.grid;
66
+ columnWidths = layoutConfig.widths;
67
+ } else {
68
+ // Default to 2 columns
69
+ numColumns = 2;
70
+ gridClass = 'grid-cols-2';
71
+ columnWidths = [50, 50];
72
+ }
36
73
 
37
74
  // Split child blocks into columns based on columnIndex in meta, or round-robin
38
75
  const columns: Block[][] = Array.from({ length: numColumns }, () => []);
@@ -47,14 +84,72 @@ export const ColumnsEdit: React.FC<BlockEditProps & {
47
84
  }
48
85
  });
49
86
 
87
+ // Get column widths from data or use equal widths
88
+ const storedWidths = block.data.columnWidths as number[] | undefined;
89
+ const currentWidths = storedWidths && storedWidths.length === numColumns
90
+ ? storedWidths
91
+ : columnWidths; // Use calculated equal widths if not set
92
+
93
+ // Add column handler
94
+ const addColumn = () => {
95
+ if (numColumns >= 4) return; // Max 4 columns
96
+ const newColumnCount = numColumns + 1;
97
+ // Calculate new equal widths
98
+ const newWidthPercent = Math.floor(100 / newColumnCount);
99
+ const newWidths = Array(newColumnCount).fill(newWidthPercent);
100
+ onUpdate({
101
+ ...block.data,
102
+ columnCount: newColumnCount,
103
+ columnWidths: newWidths,
104
+ // Clear layout if using dynamic columns
105
+ layout: undefined,
106
+ });
107
+ };
108
+
109
+ // Delete column handler
110
+ const deleteColumn = (colIndex: number) => {
111
+ if (numColumns <= 1) return; // Don't allow deleting the last column
112
+
113
+ // Move blocks from deleted column to the last column (or previous column if deleting last)
114
+ const blocksToMove = columns[colIndex] || [];
115
+ const targetColumnIndex = colIndex === numColumns - 1 ? numColumns - 2 : numColumns - 1;
116
+ const targetColumn = columns[targetColumnIndex] || [];
117
+
118
+ // Move each block to the target column using moveBlock (which will update meta.columnIndex)
119
+ blocksToMove.forEach((blockToMove, blockIndex) => {
120
+ const newIndex = targetColumn.length + blockIndex;
121
+ onChildBlockMove(blockToMove.id, newIndex, `${block.id}-col-${targetColumnIndex}`);
122
+ });
123
+
124
+ // Update column count and widths after moving blocks
125
+ const newColumnCount = numColumns - 1;
126
+ // Remove the deleted column's width and redistribute
127
+ const newWidths = currentWidths.filter((_, i) => i !== colIndex);
128
+ // Normalize to ensure they sum to 100
129
+ const total = newWidths.reduce((sum, w) => sum + w, 0);
130
+ const normalizedWidths = newWidths.map(w => Math.round((w / total) * 100));
131
+
132
+ onUpdate({
133
+ ...block.data,
134
+ columnCount: newColumnCount,
135
+ columnWidths: normalizedWidths,
136
+ layout: undefined,
137
+ });
138
+ };
139
+
50
140
  return (
51
- <div className="rounded-xl bg-white">
141
+ <div className="rounded-xl bg-white relative">
52
142
  {/* Column Grid */}
53
- <div className={`grid ${layoutConfig.grid} gap-8 p-6`}>
143
+ <div
144
+ className="grid gap-8 p-6"
145
+ style={{
146
+ gridTemplateColumns: currentWidths.map(w => `${w}%`).join(' '),
147
+ }}
148
+ >
54
149
  {Array.from({ length: numColumns }).map((_, colIndex) => (
55
150
  <div
56
151
  key={colIndex}
57
- className={`min-h-[200px] rounded-xl border border-dashed transition-all ${isSelected
152
+ className={`group/col min-h-[200px] rounded-xl border border-dashed transition-all relative ${isSelected
58
153
  ? 'border-primary/20'
59
154
  : 'border-gray-200/50'
60
155
  }`}
@@ -64,9 +159,25 @@ export const ColumnsEdit: React.FC<BlockEditProps & {
64
159
  <span className="text-[10px] font-black uppercase tracking-widest text-gray-400">
65
160
  Column {colIndex + 1}
66
161
  </span>
67
- <span className="text-[9px] text-gray-500">
68
- {layoutConfig.widths[colIndex]}%
69
- </span>
162
+ <div className="flex items-center gap-2">
163
+ <span className="text-[9px] text-gray-500">
164
+ {currentWidths[colIndex]}%
165
+ </span>
166
+ {/* Delete Column Button - Similar to table, appears on column hover */}
167
+ {numColumns > 1 && (
168
+ <button
169
+ onClick={(e) => {
170
+ e.stopPropagation();
171
+ deleteColumn(colIndex);
172
+ }}
173
+ className="opacity-0 group-hover/col:opacity-100 p-1 text-neutral-400 hover:text-red-500 transition-all rounded"
174
+ title="Delete Column"
175
+ aria-label="Delete Column"
176
+ >
177
+ <Trash2 size={10} />
178
+ </button>
179
+ )}
180
+ </div>
70
181
  </div>
71
182
 
72
183
  <LayoutContainer
@@ -82,6 +193,17 @@ export const ColumnsEdit: React.FC<BlockEditProps & {
82
193
  </div>
83
194
  ))}
84
195
  </div>
196
+
197
+ {/* Add Column Button - Similar to table, pinned top right */}
198
+ {numColumns < 4 && (
199
+ <button
200
+ onClick={addColumn}
201
+ className="absolute top-0 right-0 h-8 w-8 flex items-center justify-center bg-white border-l border-b border-gray-200 text-primary hover:bg-primary hover:text-white transition-all z-10 rounded-br-xl"
202
+ title="Add Column"
203
+ >
204
+ <Plus size={16} />
205
+ </button>
206
+ )}
85
207
  </div>
86
208
  );
87
209
  };
@@ -93,9 +215,46 @@ export const ColumnsPreview: React.FC<BlockPreviewProps & {
93
215
  childBlocks?: Block[];
94
216
  renderChild?: (block: Block) => React.ReactNode;
95
217
  }> = ({ block, childBlocks = [], renderChild, context }) => {
96
- const layout = (block.data.layout as ColumnLayout) || '50-50';
97
- const layoutConfig = COLUMN_LAYOUTS[layout];
98
- const numColumns = layoutConfig.widths.length;
218
+ // Support both old layout-based system and new dynamic column count
219
+ const columnCount = block.data.columnCount as number | undefined;
220
+ const layout: ColumnLayout | undefined = block.data.layout as ColumnLayout | undefined;
221
+
222
+ // Determine number of columns: use columnCount if set, otherwise derive from layout
223
+ let numColumns: number;
224
+ let gridClass: string;
225
+
226
+ // Grid class mapping for Tailwind (must be explicit for dynamic classes)
227
+ const gridClassMap: Record<number, string> = {
228
+ 1: 'grid-cols-1',
229
+ 2: 'grid-cols-2',
230
+ 3: 'grid-cols-3',
231
+ 4: 'grid-cols-4',
232
+ 5: 'grid-cols-5',
233
+ 6: 'grid-cols-6',
234
+ };
235
+
236
+ // Get column widths
237
+ const storedWidths = block.data.columnWidths as number[] | undefined;
238
+
239
+ if (columnCount !== undefined && columnCount > 0) {
240
+ // Dynamic column system
241
+ numColumns = columnCount;
242
+ gridClass = gridClassMap[numColumns] || `grid-cols-${numColumns}`;
243
+ } else if (layout && COLUMN_LAYOUTS[layout]) {
244
+ // Legacy layout-based system
245
+ const layoutConfig = COLUMN_LAYOUTS[layout];
246
+ numColumns = layoutConfig.widths.length;
247
+ gridClass = layoutConfig.grid;
248
+ } else {
249
+ // Default to 2 columns
250
+ numColumns = 2;
251
+ gridClass = 'grid-cols-2';
252
+ }
253
+
254
+ // Use stored widths if available, otherwise use equal widths
255
+ const columnWidths = storedWidths && storedWidths.length === numColumns
256
+ ? storedWidths
257
+ : Array(numColumns).fill(Math.floor(100 / numColumns));
99
258
 
100
259
  // If childBlocks are provided, use them; otherwise get from block.children
101
260
  const children = childBlocks.length > 0
@@ -118,7 +277,12 @@ export const ColumnsPreview: React.FC<BlockPreviewProps & {
118
277
  });
119
278
 
120
279
  return (
121
- <div className={`grid ${layoutConfig.grid} gap-8 my-8`}>
280
+ <div
281
+ className="grid gap-8 my-8"
282
+ style={{
283
+ gridTemplateColumns: columnWidths.map(w => `${w}%`).join(' '),
284
+ }}
285
+ >
122
286
  {Array.from({ length: numColumns }).map((_, colIndex) => (
123
287
  <div key={colIndex} className="min-h-[100px]">
124
288
  {columns[colIndex]?.map((childBlock) => (
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Columns Block
3
+ * Flex/grid container with configurable column layouts
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+ import { Plus, Trash2 } from 'lucide-react';
10
+ import { BlockEditProps, BlockPreviewProps } from '../../../types/block';
11
+ import { LayoutContainer } from '../../../views/CanvasEditor/LayoutContainer';
12
+ import { COLUMN_LAYOUTS, ColumnLayout } from '../index';
13
+ import { Block } from '../../../types/block';
14
+
15
+ /**
16
+ * Columns Block Edit Component
17
+ */
18
+ export const ColumnsEdit: React.FC<BlockEditProps & {
19
+ childBlocks: Block[];
20
+ onChildBlockAdd: (type: string, index: number, containerId: string) => void;
21
+ onChildBlockDelete: (blockId: string) => void;
22
+ onChildBlockMove: (blockId: string, newIndex: number) => void;
23
+ }> = ({
24
+ block,
25
+ childBlocks,
26
+ onChildBlockAdd,
27
+ onChildBlockDelete,
28
+ onChildBlockMove,
29
+ }) => {
30
+ // Support both old layout-based system and new dynamic column count
31
+ const columnCount = block.data.columnCount as number | undefined;
32
+ const layout: ColumnLayout | undefined = block.data.layout as ColumnLayout | undefined;
33
+
34
+ // Determine number of columns: use columnCount if set, otherwise derive from layout
35
+ let numColumns: number;
36
+ let gridClass: string;
37
+ let columnWidths: number[];
38
+
39
+ // Grid class mapping for Tailwind (must be explicit for dynamic classes)
40
+ const gridClassMap: Record<number, string> = {
41
+ 1: 'grid-cols-1',
42
+ 2: 'grid-cols-2',
43
+ 3: 'grid-cols-3',
44
+ 4: 'grid-cols-4',
45
+ 5: 'grid-cols-5',
46
+ 6: 'grid-cols-6',
47
+ };
48
+
49
+ if (columnCount !== undefined && columnCount > 0) {
50
+ // Dynamic column system
51
+ numColumns = columnCount;
52
+ // Create equal-width columns
53
+ const widthPercent = Math.floor(100 / numColumns);
54
+ columnWidths = Array(numColumns).fill(widthPercent);
55
+ // Use explicit grid class from map, fallback to inline style if needed
56
+ gridClass = gridClassMap[numColumns] || \`grid-cols-\${numColumns}\`;
57
+ } else if (layout && COLUMN_LAYOUTS[layout]) {
58
+ // Legacy layout-based system
59
+ const layoutConfig = COLUMN_LAYOUTS[layout];
60
+ numColumns = layoutConfig.widths.length;
61
+ gridClass = layoutConfig.grid;
62
+ columnWidths = layoutConfig.widths;
63
+ } else {
64
+ // Default to 2 columns
65
+ numColumns = 2;
66
+ gridClass = 'grid-cols-2';
67
+ columnWidths = [50, 50];
68
+ }
69
+
70
+ // Split child blocks into columns based on columnIndex in meta, or round-robin
71
+ const columns: Block[][] = Array.from({ length: numColumns }, () => []);
72
+ childBlocks.forEach((childBlock) => {
73
+ const columnIndex = childBlock.meta?.columnIndex;
74
+ if (typeof columnIndex === 'number' && columnIndex >= 0 && columnIndex < numColumns) {
75
+ columns[columnIndex].push(childBlock);
76
+ } else {
77
+ // Fallback to round-robin if no columnIndex specified
78
+ const index = childBlocks.indexOf(childBlock);
79
+ columns[index % numColumns].push(childBlock);
80
+ }
81
+ });
@@ -42,11 +42,16 @@ export function registerLayoutBlocks() {
42
42
  description: 'Flex/grid container with configurable column layouts (50/50, 33/66, etc.)',
43
43
  icon: Columns,
44
44
  defaultData: {
45
- layout: '50-50',
45
+ columnCount: 2, // Start with 2 columns, can be dynamically added/removed
46
+ columnWidths: [50, 50], // Equal width columns by default
46
47
  },
47
48
  category: 'layout',
48
49
  isContainer: true,
49
50
  validate: (data) => {
51
+ // Support both new dynamic system (columnCount) and legacy layout system
52
+ if (data.columnCount !== undefined) {
53
+ return typeof data.columnCount === 'number' && data.columnCount > 0 && data.columnCount <= 6;
54
+ }
50
55
  return ['50-50', '33-66', '66-33', '25-25-25-25', '25-75', '75-25'].includes(data.layout as string);
51
56
  },
52
57
  components: {
@@ -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;
@@ -13,3 +13,5 @@ export type {
13
13
  ClientBlockDefinition,
14
14
  } from './block';
15
15
 
16
+
17
+ export type { BlockRegistry } from './block';
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 */