@jhits/plugin-blog 0.0.6 → 0.0.8
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 +28 -24
- package/src/api/config-handler.ts +76 -0
- package/src/api/router.ts +17 -0
- package/src/index.tsx +3 -0
- 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/types/index.ts +2 -0
- package/src/utils/index.ts +0 -2
- package/src/views/CanvasEditor/BlockWrapper.tsx +79 -15
- package/src/views/CanvasEditor/CanvasEditorView.tsx +9 -3
- package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +41 -13
package/package.json
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhits/plugin-blog",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "Professional blog management system for the JHITS ecosystem",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
|
-
"main": "./
|
|
9
|
-
"types": "./
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": {
|
|
12
|
-
"types": "./
|
|
13
|
-
"default": "./
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
14
|
},
|
|
15
15
|
"./server": {
|
|
16
|
-
"types": "./
|
|
17
|
-
"default": "./
|
|
16
|
+
"types": "./dist/index.server.d.ts",
|
|
17
|
+
"default": "./dist/index.server.js"
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"bcrypt": "^6.0.0",
|
|
22
|
-
"framer-motion": "^12.
|
|
23
|
-
"lucide-react": "^0.
|
|
24
|
-
"mongodb": "^7.
|
|
22
|
+
"framer-motion": "^12.34.0",
|
|
23
|
+
"lucide-react": "^0.564.0",
|
|
24
|
+
"mongodb": "^7.1.0",
|
|
25
25
|
"next-auth": "^4.24.13",
|
|
26
|
+
"@jhits/plugin-content": "0.0.4",
|
|
26
27
|
"@jhits/plugin-core": "0.0.1",
|
|
27
|
-
"@jhits/plugin-content": "0.0.3",
|
|
28
28
|
"@jhits/plugin-images": "0.0.5"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
@@ -35,23 +35,27 @@
|
|
|
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": "^
|
|
41
|
-
"@types/react": "^19",
|
|
42
|
-
"@types/react-dom": "^19",
|
|
43
|
-
"eslint": "^
|
|
44
|
-
"eslint-config-next": "16.1.
|
|
45
|
-
"next": "16.1.
|
|
46
|
-
"next-intl": "4.
|
|
40
|
+
"@types/node": "^25.2.3",
|
|
41
|
+
"@types/react": "^19.2.14",
|
|
42
|
+
"@types/react-dom": "^19.2.3",
|
|
43
|
+
"eslint": "^10.0.0",
|
|
44
|
+
"eslint-config-next": "16.1.6",
|
|
45
|
+
"next": "16.1.6",
|
|
46
|
+
"next-intl": "4.8.2",
|
|
47
47
|
"next-themes": "0.4.6",
|
|
48
|
-
"react": "19.2.
|
|
49
|
-
"react-dom": "19.2.
|
|
50
|
-
"tailwindcss": "^4",
|
|
51
|
-
"typescript": "^5"
|
|
48
|
+
"react": "19.2.4",
|
|
49
|
+
"react-dom": "19.2.4",
|
|
50
|
+
"tailwindcss": "^4.1.18",
|
|
51
|
+
"typescript": "^5.9.3"
|
|
52
52
|
},
|
|
53
53
|
"files": [
|
|
54
|
+
"dist",
|
|
54
55
|
"src",
|
|
55
56
|
"package.json"
|
|
56
|
-
]
|
|
57
|
+
],
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsc"
|
|
60
|
+
}
|
|
57
61
|
}
|
|
@@ -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
|
-
|
|
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: {
|
package/src/types/index.ts
CHANGED
package/src/utils/index.ts
CHANGED
|
@@ -365,21 +365,85 @@ export function BlockWrapper({
|
|
|
365
365
|
</div>
|
|
366
366
|
)} */}
|
|
367
367
|
|
|
368
|
-
{block.type === 'columns' && (
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
>
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
<
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 -
|
|
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
|
-
|
|
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
|
|
83
|
+
// Extract filename from image URL for reference
|
|
86
84
|
const isUploadedImage = image.url.startsWith('/api/uploads/');
|
|
87
|
-
|
|
85
|
+
let filename = image.filename;
|
|
88
86
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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-
|
|
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}
|