@jhits/plugin-blog 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +16 -16
- package/src/api/config-handler.ts +76 -0
- package/src/api/handler.ts +4 -4
- package/src/api/router.ts +17 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useCategories.ts +76 -0
- package/src/index.tsx +8 -27
- package/src/init.tsx +0 -9
- package/src/lib/config-storage.ts +65 -0
- package/src/lib/layouts/blocks/ColumnsBlock.tsx +177 -13
- package/src/lib/layouts/blocks/ColumnsBlock.tsx.tmp +81 -0
- package/src/lib/layouts/registerLayoutBlocks.ts +6 -1
- package/src/lib/mappers/apiMapper.ts +53 -22
- package/src/registry/BlockRegistry.ts +1 -4
- package/src/state/EditorContext.tsx +39 -33
- package/src/state/types.ts +1 -1
- package/src/types/index.ts +2 -0
- package/src/types/post.ts +4 -0
- package/src/views/CanvasEditor/BlockWrapper.tsx +87 -24
- package/src/views/CanvasEditor/CanvasEditorView.tsx +214 -794
- package/src/views/CanvasEditor/EditorBody.tsx +317 -127
- package/src/views/CanvasEditor/EditorHeader.tsx +106 -17
- package/src/views/CanvasEditor/LayoutContainer.tsx +208 -380
- package/src/views/CanvasEditor/components/EditorCanvas.tsx +160 -0
- package/src/views/CanvasEditor/components/EditorLibrary.tsx +122 -0
- package/src/views/CanvasEditor/components/EditorSidebar.tsx +181 -0
- package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
- package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +260 -49
- package/src/views/CanvasEditor/components/index.ts +11 -0
- package/src/views/CanvasEditor/hooks/index.ts +10 -0
- package/src/views/CanvasEditor/hooks/useHeroBlock.ts +103 -0
- package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +142 -0
- package/src/views/CanvasEditor/hooks/usePostLoader.ts +39 -0
- package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +55 -0
- package/src/views/CanvasEditor/hooks/useUnsavedChanges.ts +339 -0
- package/src/views/PostManager/PostCards.tsx +18 -13
- package/src/views/PostManager/PostFilters.tsx +15 -0
- package/src/views/PostManager/PostManagerView.tsx +21 -15
- package/src/views/PostManager/PostTable.tsx +7 -4
|
@@ -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
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
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
|
|
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
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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/index.ts
CHANGED
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 */
|