@jhits/plugin-blog 0.0.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhits/plugin-blog",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Professional blog management system for the JHITS ecosystem",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -19,12 +19,12 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "bcrypt": "^6.0.0",
22
- "framer-motion": "^12.23.26",
22
+ "framer-motion": "^12.27.5",
23
23
  "lucide-react": "^0.562.0",
24
24
  "mongodb": "^7.0.0",
25
25
  "next-auth": "^4.24.13",
26
- "@jhits/plugin-core": "0.0.1",
27
26
  "@jhits/plugin-content": "0.0.3",
27
+ "@jhits/plugin-core": "0.0.1",
28
28
  "@jhits/plugin-images": "0.0.5"
29
29
  },
30
30
  "peerDependencies": {
@@ -35,20 +35,20 @@
35
35
  "react-dom": ">=18.0.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@tailwindcss/postcss": "^4",
38
+ "@tailwindcss/postcss": "^4.1.18",
39
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",
40
+ "@types/node": "^25.0.9",
41
+ "@types/react": "^19.2.9",
42
+ "@types/react-dom": "^19.2.3",
43
+ "eslint": "^9.39.2",
44
+ "eslint-config-next": "16.1.4",
45
+ "next": "16.1.4",
46
+ "next-intl": "4.7.0",
47
47
  "next-themes": "0.4.6",
48
48
  "react": "19.2.3",
49
49
  "react-dom": "19.2.3",
50
- "tailwindcss": "^4",
51
- "typescript": "^5"
50
+ "tailwindcss": "^4.1.18",
51
+ "typescript": "^5.9.3"
52
52
  },
53
53
  "files": [
54
54
  "src",
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Blog Config API Handler
3
+ * Handles saving/loading plugin configuration
4
+ */
5
+
6
+ import { NextRequest, NextResponse } from 'next/server';
7
+ import { getPluginConfig, savePluginConfig } from '../lib/config-storage';
8
+
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ export interface ConfigApiConfig {
11
+ getDb: () => Promise<{ db: any }>;
12
+ getUserId: (req: NextRequest) => Promise<string | null>;
13
+ siteId?: string;
14
+ }
15
+
16
+ /**
17
+ * GET /api/plugin-blog/config - Get plugin config
18
+ */
19
+ export async function GET(req: NextRequest, config: ConfigApiConfig): Promise<NextResponse> {
20
+ try {
21
+ const siteId = config.siteId || 'default';
22
+
23
+ const pluginConfig = await getPluginConfig(
24
+ config.getDb,
25
+ 'plugin-blog',
26
+ siteId
27
+ );
28
+
29
+ if (!pluginConfig) {
30
+ return NextResponse.json({ config: null });
31
+ }
32
+
33
+ return NextResponse.json({ config: pluginConfig.config });
34
+ } catch (err: any) {
35
+ console.error('[BlogConfigAPI] GET error:', err);
36
+ return NextResponse.json(
37
+ { error: 'Failed to get config', detail: err.message },
38
+ { status: 500 }
39
+ );
40
+ }
41
+ }
42
+
43
+ /**
44
+ * POST /api/plugin-blog/config - Save plugin config
45
+ */
46
+ export async function POST(req: NextRequest, config: ConfigApiConfig): Promise<NextResponse> {
47
+ try {
48
+ const userId = await config.getUserId(req);
49
+ if (!userId) {
50
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
51
+ }
52
+
53
+ const body = await req.json();
54
+ const siteId = body.siteId || config.siteId || 'default';
55
+ const pluginConfig = body.config;
56
+
57
+ if (!pluginConfig) {
58
+ return NextResponse.json({ error: 'Config is required' }, { status: 400 });
59
+ }
60
+
61
+ await savePluginConfig(
62
+ config.getDb,
63
+ 'plugin-blog',
64
+ siteId,
65
+ pluginConfig
66
+ );
67
+
68
+ return NextResponse.json({ message: 'Config saved successfully' });
69
+ } catch (err: any) {
70
+ console.error('[BlogConfigAPI] POST error:', err);
71
+ return NextResponse.json(
72
+ { error: 'Failed to save config', detail: err.message },
73
+ { status: 500 }
74
+ );
75
+ }
76
+ }
package/src/api/router.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import { NextRequest, NextResponse } from 'next/server';
12
12
  import { GET as BlogListHandler, POST as BlogCreateHandler } from './handler';
13
13
  import { GET as BlogGetHandler, PUT as BlogUpdateHandler, DELETE as BlogDeleteHandler, createBlogApiConfig } from './route';
14
+ import { GET as ConfigGetHandler, POST as ConfigPostHandler } from './config-handler';
14
15
 
15
16
  export interface BlogApiRouterConfig {
16
17
  /** MongoDB client promise - should return { db: () => Database } */
@@ -19,6 +20,8 @@ export interface BlogApiRouterConfig {
19
20
  getUserId: (req: NextRequest) => Promise<string | null>;
20
21
  /** Collection name (default: 'blogs') */
21
22
  collectionName?: string;
23
+ /** Site ID for multi-site setups */
24
+ siteId?: string;
22
25
  }
23
26
 
24
27
  /**
@@ -81,6 +84,20 @@ export async function handleBlogApi(
81
84
  return await checkTitleModule.GET(req, blogApiConfig);
82
85
  }
83
86
  }
87
+ // Route: /api/plugin-blog/config (get/save plugin config)
88
+ else if (route === 'config') {
89
+ const configApiConfig = {
90
+ getDb: config.getDb,
91
+ getUserId: config.getUserId,
92
+ siteId: config.siteId || 'default',
93
+ };
94
+ if (method === 'GET') {
95
+ return await ConfigGetHandler(req, configApiConfig);
96
+ }
97
+ if (method === 'POST') {
98
+ return await ConfigPostHandler(req, configApiConfig);
99
+ }
100
+ }
84
101
  // Route: /api/plugin-blog/[slug] (get/update/delete by slug)
85
102
  else {
86
103
  const slug = route;
package/src/index.tsx CHANGED
@@ -320,6 +320,9 @@ export { blockRegistry } from './registry';
320
320
  // Export layout block registration
321
321
  export { registerLayoutBlocks } from './lib/layouts/registerLayoutBlocks';
322
322
 
323
+ // Export config storage utilities
324
+ export { getPluginConfig, savePluginConfig } from './lib/config-storage';
325
+
323
326
  // Export editor state management
324
327
  export { EditorProvider, useEditor } from './state/EditorContext';
325
328
  export type { EditorProviderProps, EditorState, EditorContextValue } from './state';
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Plugin Configuration Storage
3
+ * Stores plugin-specific configuration in MongoDB
4
+ * Used by both client apps and dashboard
5
+ */
6
+
7
+ export interface PluginConfigDocument {
8
+ _id?: string;
9
+ pluginId: string;
10
+ siteId: string;
11
+ config: {
12
+ customBlocks?: unknown[];
13
+ darkMode?: boolean;
14
+ backgroundColors?: {
15
+ light: string;
16
+ dark?: string;
17
+ };
18
+ [key: string]: unknown;
19
+ };
20
+ updatedAt: Date;
21
+ }
22
+
23
+ const COLLECTION_NAME = 'pluginConfigs';
24
+
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ export async function getPluginConfigCollection(getDb: () => Promise<{ db: any }>) {
27
+ const { db } = await getDb();
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ return (db as any).collection(COLLECTION_NAME);
30
+ }
31
+
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ export async function getPluginConfig(
34
+ getDb: () => Promise<{ db: any }>,
35
+ pluginId: string,
36
+ siteId: string = 'default'
37
+ ): Promise<PluginConfigDocument | null> {
38
+ const collection = await getPluginConfigCollection(getDb);
39
+ return collection.findOne({ pluginId, siteId }) as Promise<PluginConfigDocument | null>;
40
+ }
41
+
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ export async function savePluginConfig(
44
+ getDb: () => Promise<{ db: any }>,
45
+ pluginId: string,
46
+ siteId: string,
47
+ config: PluginConfigDocument['config']
48
+ ): Promise<void> {
49
+ const collection = await getPluginConfigCollection(getDb);
50
+
51
+ await collection.updateOne(
52
+ { pluginId, siteId },
53
+ {
54
+ $set: {
55
+ config,
56
+ updatedAt: new Date()
57
+ },
58
+ $setOnInsert: {
59
+ pluginId,
60
+ siteId
61
+ }
62
+ },
63
+ { upsert: true }
64
+ );
65
+ }
@@ -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: {
@@ -13,3 +13,5 @@ export type {
13
13
  ClientBlockDefinition,
14
14
  } from './block';
15
15
 
16
+
17
+ export type { BlockRegistry } from './block';
@@ -365,21 +365,85 @@ export function BlockWrapper({
365
365
  </div>
366
366
  )} */}
367
367
 
368
- {block.type === 'columns' && (
369
- <select
370
- value={(block.data.layout as string) || '50-50'}
371
- onChange={(e) => onUpdate({ layout: e.target.value })}
372
- onClick={(e) => e.stopPropagation()}
373
- className="text-[10px] font-bold bg-white dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-700 px-2 py-1 rounded outline-none focus:border-primary transition-all dark:text-neutral-100 ml-4"
374
- >
375
- <option value="50-50">50% / 50%</option>
376
- <option value="33-66">33% / 66%</option>
377
- <option value="66-33">66% / 33%</option>
378
- <option value="25-25-25-25">25% / 25% / 25% / 25%</option>
379
- <option value="25-75">25% / 75%</option>
380
- <option value="75-25">75% / 25%</option>
381
- </select>
382
- )}
368
+ {block.type === 'columns' && (() => {
369
+ const columnCount = block.data.columnCount as number | undefined;
370
+ const layout = block.data.layout as string | undefined;
371
+
372
+ // Determine number of columns
373
+ let numColumns: number;
374
+ if (columnCount !== undefined && columnCount > 0) {
375
+ numColumns = columnCount;
376
+ } else if (layout) {
377
+ // Legacy layout system
378
+ const layoutMap: Record<string, number> = {
379
+ '50-50': 2,
380
+ '33-66': 2,
381
+ '66-33': 2,
382
+ '25-25-25-25': 4,
383
+ '25-75': 2,
384
+ '75-25': 2,
385
+ };
386
+ numColumns = layoutMap[layout] || 2;
387
+ } else {
388
+ numColumns = 2;
389
+ }
390
+
391
+ // Get column widths
392
+ const storedWidths = block.data.columnWidths as number[] | undefined;
393
+ const columnWidths = storedWidths && storedWidths.length === numColumns
394
+ ? storedWidths
395
+ : Array(numColumns).fill(Math.floor(100 / numColumns));
396
+
397
+ return (
398
+ <div className="flex items-center gap-1.5 ml-4 flex-1 min-w-0">
399
+ {Array.from({ length: numColumns }).map((_, colIndex) => (
400
+ <div key={colIndex} className="flex items-center gap-1">
401
+ <input
402
+ type="number"
403
+ min="10"
404
+ max="90"
405
+ step="1"
406
+ value={columnWidths[colIndex] || 50}
407
+ onChange={(e) => {
408
+ const newWidth = parseInt(e.target.value) || 50;
409
+ const newWidths = [...columnWidths];
410
+ newWidths[colIndex] = Math.max(10, Math.min(90, newWidth));
411
+
412
+ // Normalize remaining columns to sum to 100
413
+ const remainingTotal = newWidths.reduce((sum, w, i) => i === colIndex ? sum : sum + w, 0);
414
+ const remainingTarget = 100 - newWidths[colIndex];
415
+ if (remainingTotal > 0 && remainingTarget > 0) {
416
+ const scale = remainingTarget / remainingTotal;
417
+ newWidths.forEach((w, i) => {
418
+ if (i !== colIndex) {
419
+ newWidths[i] = Math.round(w * scale);
420
+ }
421
+ });
422
+ }
423
+
424
+ // Ensure sum is exactly 100
425
+ const finalTotal = newWidths.reduce((sum, w) => sum + w, 0);
426
+ if (finalTotal !== 100) {
427
+ const diff = 100 - finalTotal;
428
+ const lastIndex = newWidths.length - 1;
429
+ newWidths[lastIndex] = Math.max(10, newWidths[lastIndex] + diff);
430
+ }
431
+
432
+ onUpdate({
433
+ ...block.data,
434
+ columnWidths: newWidths,
435
+ layout: undefined, // Clear layout when using custom widths
436
+ });
437
+ }}
438
+ onClick={(e) => e.stopPropagation()}
439
+ className="w-12 text-[10px] font-bold bg-white dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-700 px-1.5 py-0.5 rounded outline-none focus:border-primary transition-all dark:text-neutral-100 text-center"
440
+ />
441
+ <span className="text-[9px] text-neutral-400 dark:text-neutral-500">%</span>
442
+ </div>
443
+ ))}
444
+ </div>
445
+ );
446
+ })()}
383
447
  </div>
384
448
  <div className="relative shrink-0 z-20" ref={settingsMenuRef}>
385
449
  <button
@@ -107,7 +107,10 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
107
107
  state,
108
108
  isDirty: state.isDirty,
109
109
  onSave: async () => {
110
- await handleSave(false); // Auto-save as draft
110
+ // Preserve current status: if already published, keep it published
111
+ // Otherwise save as draft
112
+ const shouldPublish = state.status === 'published';
113
+ await handleSave(shouldPublish);
111
114
  },
112
115
  heroBlock,
113
116
  postId: state.postId,
@@ -152,12 +155,15 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
152
155
  // Status should already be set in EditorHeader, but verify and log
153
156
  console.log('[CanvasEditorView] onSave called with publish:', publish, 'current status:', state.status);
154
157
 
155
- // Double-check status is set correctly before saving
158
+ // Only change status if explicitly requested (publish is true or false)
159
+ // If publish is undefined, preserve the current status (used for autosave)
156
160
  if (publish === true && state.status !== 'published') {
157
161
  console.warn('[CanvasEditorView] Status mismatch! Setting to published...');
158
162
  dispatch({ type: 'SET_STATUS', payload: 'published' });
159
163
  await new Promise(resolve => setTimeout(resolve, 100));
160
- } else if (publish === false && state.status !== 'draft') {
164
+ } else if (publish === false && state.status !== 'draft' && state.status !== 'published') {
165
+ // Only set to draft if not already published (preserve published status)
166
+ // This prevents autosave from changing published posts back to draft
161
167
  console.warn('[CanvasEditorView] Status mismatch! Setting to draft...');
162
168
  dispatch({ type: 'SET_STATUS', payload: 'draft' });
163
169
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -76,19 +76,46 @@ export function FeaturedMediaSection({
76
76
  const positionX = featuredImage?.positionX ?? 0;
77
77
  const positionY = featuredImage?.positionY ?? 0;
78
78
 
79
- // Handle image selection - just update blog metadata with semantic ID
79
+ // Handle image selection - create initial mapping and update blog metadata with semantic ID
80
80
  // Plugin-images Image component will automatically resolve the semantic ID when it renders
81
- // GlobalImageEditor will handle saving transform data when editing
82
- // NO API CALLS HERE - plugin-images handles everything
83
- const handleImageChange = useCallback((image: ImageMetadata | null) => {
81
+ const handleImageChange = useCallback(async (image: ImageMetadata | null) => {
84
82
  if (image) {
85
- // Extract filename from image URL for reference (not saved to API)
83
+ // Extract filename from image URL for reference
86
84
  const isUploadedImage = image.url.startsWith('/api/uploads/');
87
- const filename = isUploadedImage ? image.filename : image.url.split('/').pop()?.split('?')[0] || image.id;
85
+ let filename = image.filename;
88
86
 
89
- // Save initial mapping to plugin-images API via GlobalImageEditor when image is first edited
90
- // For now, just update blog metadata with semantic ID
91
- // The Image component will handle resolution, and GlobalImageEditor will save the mapping when user edits
87
+ if (!filename && isUploadedImage) {
88
+ // Extract filename from URL if not provided
89
+ filename = image.url.split('/api/uploads/')[1]?.split('?')[0] || image.id;
90
+ } else if (!filename) {
91
+ // For external URLs, use the image ID or extract from URL
92
+ filename = image.id || image.url.split('/').pop()?.split('?')[0] || `external-${Date.now()}`;
93
+ }
94
+
95
+ // Create initial mapping in plugin-images API immediately
96
+ // This ensures the semantic ID resolves correctly
97
+ try {
98
+ const saveData = {
99
+ id: imageId,
100
+ filename: filename,
101
+ scale: 1.0,
102
+ positionX: 0,
103
+ positionY: 0,
104
+ brightness: 100,
105
+ blur: 0,
106
+ };
107
+
108
+ await fetch('/api/plugin-images/resolve', {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify(saveData),
112
+ });
113
+ } catch (error) {
114
+ console.error('[FeaturedMediaSection] Failed to create initial mapping:', error);
115
+ // Continue anyway - the mapping might be created later
116
+ }
117
+
118
+ // Update blog metadata with semantic ID
92
119
  onUpdate({
93
120
  id: imageId,
94
121
  alt: image.alt || image.filename,
@@ -250,7 +277,7 @@ export function FeaturedMediaSection({
250
277
  setShowImagePicker(false);
251
278
  setOpenEditorDirectly(false); // Reset flag when closing
252
279
  }}>
253
- <div className="bg-dashboard-card rounded-2xl w-full max-w-2xl mx-4 p-6 shadow-2xl max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
280
+ <div className="bg-white dark:bg-neutral-900 rounded-2xl w-full max-w-2xl mx-4 p-6 shadow-2xl max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
254
281
  <div className="flex items-center justify-between mb-6">
255
282
  <h3 className="text-lg font-bold text-neutral-900 dark:text-neutral-100">
256
283
  {openEditorDirectly ? 'Edit Featured Image' : 'Select Featured Image'}
@@ -260,13 +287,14 @@ export function FeaturedMediaSection({
260
287
  setShowImagePicker(false);
261
288
  setOpenEditorDirectly(false); // Reset flag when closing
262
289
  }}
263
- className="p-2 hover:bg-dashboard-bg rounded-lg transition-colors"
290
+ className="p-2 hover:bg-dashboard-bg dark:hover:bg-neutral-800 rounded-lg transition-colors text-neutral-700 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white"
291
+ aria-label="Close"
264
292
  >
265
- <X size={20} />
293
+ <X size={20} className="transition-colors" />
266
294
  </button>
267
295
  </div>
268
296
  <ImagePicker
269
- value={imageId}
297
+ value={featuredImage?.id ? imageId : undefined}
270
298
  onChange={handleImageChange}
271
299
  brightness={brightness}
272
300
  blur={blur}