@jhits/plugin-blog 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +16 -16
  2. package/src/api/config-handler.ts +76 -0
  3. package/src/api/handler.ts +4 -4
  4. package/src/api/router.ts +17 -0
  5. package/src/hooks/index.ts +1 -0
  6. package/src/hooks/useCategories.ts +76 -0
  7. package/src/index.tsx +8 -27
  8. package/src/init.tsx +0 -9
  9. package/src/lib/config-storage.ts +65 -0
  10. package/src/lib/layouts/blocks/ColumnsBlock.tsx +177 -13
  11. package/src/lib/layouts/blocks/ColumnsBlock.tsx.tmp +81 -0
  12. package/src/lib/layouts/registerLayoutBlocks.ts +6 -1
  13. package/src/lib/mappers/apiMapper.ts +53 -22
  14. package/src/registry/BlockRegistry.ts +1 -4
  15. package/src/state/EditorContext.tsx +39 -33
  16. package/src/state/types.ts +1 -1
  17. package/src/types/index.ts +2 -0
  18. package/src/types/post.ts +4 -0
  19. package/src/views/CanvasEditor/BlockWrapper.tsx +87 -24
  20. package/src/views/CanvasEditor/CanvasEditorView.tsx +214 -794
  21. package/src/views/CanvasEditor/EditorBody.tsx +317 -127
  22. package/src/views/CanvasEditor/EditorHeader.tsx +106 -17
  23. package/src/views/CanvasEditor/LayoutContainer.tsx +208 -380
  24. package/src/views/CanvasEditor/components/EditorCanvas.tsx +160 -0
  25. package/src/views/CanvasEditor/components/EditorLibrary.tsx +122 -0
  26. package/src/views/CanvasEditor/components/EditorSidebar.tsx +181 -0
  27. package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
  28. package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +260 -49
  29. package/src/views/CanvasEditor/components/index.ts +11 -0
  30. package/src/views/CanvasEditor/hooks/index.ts +10 -0
  31. package/src/views/CanvasEditor/hooks/useHeroBlock.ts +103 -0
  32. package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +142 -0
  33. package/src/views/CanvasEditor/hooks/usePostLoader.ts +39 -0
  34. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +55 -0
  35. package/src/views/CanvasEditor/hooks/useUnsavedChanges.ts +339 -0
  36. package/src/views/PostManager/PostCards.tsx +18 -13
  37. package/src/views/PostManager/PostFilters.tsx +15 -0
  38. package/src/views/PostManager/PostManagerView.tsx +21 -15
  39. package/src/views/PostManager/PostTable.tsx +7 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhits/plugin-blog",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Professional blog management system for the JHITS ecosystem",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -18,14 +18,14 @@
18
18
  }
19
19
  },
20
20
  "dependencies": {
21
- "@jhits/plugin-core": "^0.0.1",
22
- "@jhits/plugin-content": "^0.0.1",
23
- "@jhits/plugin-images": "^0.0.1",
24
21
  "bcrypt": "^6.0.0",
25
- "framer-motion": "^12.23.26",
22
+ "framer-motion": "^12.27.5",
26
23
  "lucide-react": "^0.562.0",
27
24
  "mongodb": "^7.0.0",
28
- "next-auth": "^4.24.13"
25
+ "next-auth": "^4.24.13",
26
+ "@jhits/plugin-content": "0.0.3",
27
+ "@jhits/plugin-core": "0.0.1",
28
+ "@jhits/plugin-images": "0.0.5"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "next": ">=15.0.0",
@@ -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
+ }
@@ -124,7 +124,7 @@ export async function POST(req: NextRequest, config: BlogApiConfig): Promise<Nex
124
124
  if (isPublishing) {
125
125
  // Publishing requires all fields
126
126
  if (!summary?.trim()) errors.push('Summary is required for publishing');
127
- if (!image?.src?.trim()) errors.push('Featured image is required for publishing');
127
+ if (!image?.id?.trim()) errors.push('Featured image is required for publishing');
128
128
  // Only require category if it's explicitly provided and empty
129
129
  // If categoryTags is undefined or category is undefined, that's also missing
130
130
  if (!categoryTags || !categoryTags.category || !categoryTags.category.trim()) {
@@ -168,7 +168,7 @@ export async function POST(req: NextRequest, config: BlogApiConfig): Promise<Nex
168
168
  summary: (summary || '').trim(),
169
169
  contentBlocks: contentBlocks || [],
170
170
  content: content || [],
171
- image: image || { src: '', alt: '', brightness: 100, blur: 0 },
171
+ image: image || { id: '', alt: '' }, // Only store id and alt - plugin-images handles transforms
172
172
  categoryTags: {
173
173
  category: categoryTags?.category?.trim() || '',
174
174
  tags: categoryTags?.tags || [],
@@ -295,7 +295,7 @@ export async function PUT_BY_SLUG(
295
295
  // Collect all missing fields for better error messages
296
296
  const missingFields: string[] = [];
297
297
  if (!summary?.trim()) missingFields.push('summary');
298
- if (!image?.src?.trim()) missingFields.push('featured image');
298
+ if (!image?.id?.trim()) missingFields.push('featured image');
299
299
  // Only require category if it's explicitly provided and empty
300
300
  // If categoryTags is undefined or category is undefined, that's also missing
301
301
  if (!categoryTags || !categoryTags.category || !categoryTags.category.trim()) {
@@ -308,7 +308,7 @@ export async function PUT_BY_SLUG(
308
308
  isPublishing,
309
309
  missingFields,
310
310
  summary: summary?.trim() || 'missing',
311
- imageSrc: image?.src?.trim() || 'missing',
311
+ imageId: image?.id?.trim() || 'missing',
312
312
  category: categoryTags?.category?.trim() || 'missing',
313
313
  hasContent,
314
314
  contentBlocksLength: contentBlocks?.length || 0,
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;
@@ -5,4 +5,5 @@
5
5
 
6
6
  export { useBlogs, type UseBlogsOptions, type UseBlogsResult } from './useBlogs';
7
7
  export { useBlog, type UseBlogOptions, type UseBlogResult } from './useBlog';
8
+ export { useCategories } from './useCategories';
8
9
 
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Hook to fetch categories from existing blog posts
3
+ * Extracts categories from Hero blocks in all posts
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import { useState, useEffect } from 'react';
9
+
10
+ export function useCategories() {
11
+ const [categories, setCategories] = useState<string[]>([]);
12
+ const [loading, setLoading] = useState(true);
13
+
14
+ useEffect(() => {
15
+ const fetchCategories = async () => {
16
+ try {
17
+ const categorySet = new Set<string>();
18
+
19
+ // 1. Fetch from categories endpoint (legacy categoryTags.category)
20
+ try {
21
+ const categoriesResponse = await fetch('/api/plugin-blog/categories');
22
+ if (categoriesResponse.ok) {
23
+ const categoriesData = await categoriesResponse.json();
24
+ if (Array.isArray(categoriesData.categories)) {
25
+ categoriesData.categories.forEach((cat: string) => {
26
+ if (cat && cat.trim()) {
27
+ categorySet.add(cat.trim());
28
+ }
29
+ });
30
+ }
31
+ }
32
+ } catch (e) {
33
+ // Categories endpoint not available, continue
34
+ }
35
+
36
+ // 2. Fetch all blog posts and extract categories from Hero blocks
37
+ try {
38
+ const response = await fetch('/api/plugin-blog?admin=true&limit=1000');
39
+ if (response.ok) {
40
+ const data = await response.json();
41
+ const posts = Array.isArray(data.blogs) ? data.blogs : (Array.isArray(data) ? data : []);
42
+
43
+ posts.forEach((post: any) => {
44
+ if (post.blocks && Array.isArray(post.blocks)) {
45
+ // Find Hero block
46
+ const heroBlock = post.blocks.find((block: any) => block.type === 'hero');
47
+ if (heroBlock && heroBlock.data && heroBlock.data.category) {
48
+ const category = heroBlock.data.category.trim();
49
+ if (category) {
50
+ categorySet.add(category);
51
+ }
52
+ }
53
+ }
54
+ });
55
+ }
56
+ } catch (e) {
57
+ console.error('Failed to fetch posts for categories:', e);
58
+ }
59
+
60
+ // Convert to sorted array
61
+ const sortedCategories = Array.from(categorySet).sort();
62
+ setCategories(sortedCategories);
63
+ } catch (error) {
64
+ console.error('Failed to fetch categories:', error);
65
+ // Fallback to empty array
66
+ setCategories([]);
67
+ } finally {
68
+ setLoading(false);
69
+ }
70
+ };
71
+
72
+ fetchCategories();
73
+ }, []);
74
+
75
+ return { categories, loading };
76
+ }
package/src/index.tsx CHANGED
@@ -49,7 +49,6 @@ export interface PluginProps {
49
49
  */
50
50
  export default function BlogPlugin(props: PluginProps) {
51
51
  const { subPath, siteId, locale, customBlocks: propsCustomBlocks, darkMode: propsDarkMode, backgroundColors: propsBackgroundColors } = props;
52
- console.log('[BlogPlugin] Component rendering, propsDarkMode:', propsDarkMode);
53
52
 
54
53
  // Get custom blocks from props or window global (client app injection point)
55
54
  const customBlocks = useMemo(() => {
@@ -79,7 +78,6 @@ export default function BlogPlugin(props: PluginProps) {
79
78
  if (saved) {
80
79
  const config = JSON.parse(saved);
81
80
  if (config.darkMode !== undefined) {
82
- console.log('[BlogPlugin] Using darkMode from localStorage (dev settings):', config.darkMode);
83
81
  return config.darkMode as boolean;
84
82
  }
85
83
  }
@@ -90,7 +88,6 @@ export default function BlogPlugin(props: PluginProps) {
90
88
 
91
89
  // Then try props
92
90
  if (propsDarkMode !== undefined) {
93
- console.log('[BlogPlugin] Using darkMode from props:', propsDarkMode);
94
91
  return propsDarkMode;
95
92
  }
96
93
 
@@ -98,12 +95,10 @@ export default function BlogPlugin(props: PluginProps) {
98
95
  if (typeof window !== 'undefined' && (window as any).__JHITS_PLUGIN_PROPS__) {
99
96
  const pluginProps = (window as any).__JHITS_PLUGIN_PROPS__['plugin-blog'];
100
97
  if (pluginProps?.darkMode !== undefined) {
101
- console.log('[BlogPlugin] Using darkMode from window global (fallback):', pluginProps.darkMode);
102
98
  return pluginProps.darkMode as boolean;
103
99
  }
104
100
  }
105
101
 
106
- console.log('[BlogPlugin] darkMode not found, defaulting to true');
107
102
  return true; // Default to dark mode enabled
108
103
  }, [propsDarkMode]);
109
104
 
@@ -167,8 +162,6 @@ export default function BlogPlugin(props: PluginProps) {
167
162
  };
168
163
  }, []);
169
164
 
170
- console.log('[BlogPlugin] Final darkMode value:', darkMode);
171
- console.log('[BlogPlugin] Background colors:', backgroundColors);
172
165
  if (heroBlockDefinition !== undefined) {
173
166
  console.log('[BlogPlugin] Hero block definition:', heroBlockDefinition ? 'found' : 'not found (REQUIRED)');
174
167
  }
@@ -180,13 +173,12 @@ export default function BlogPlugin(props: PluginProps) {
180
173
 
181
174
  case 'editor':
182
175
  const postId = subPath[1]; // This is actually the slug, not the ID
183
- console.log('[BlogPlugin] Rendering editor route with postId (slug):', postId, 'darkMode:', darkMode);
184
176
  return (
185
177
  <EditorProvider
186
178
  customBlocks={customBlocks}
187
179
  darkMode={darkMode}
188
180
  backgroundColors={backgroundColors}
189
- onSave={async (state) => {
181
+ onSave={async (state, heroBlock) => {
190
182
  // Save to API - update existing post
191
183
  // Use the route postId (original slug) to identify which blog to update
192
184
  // If route postId is missing, use state.slug or state.postId as fallback
@@ -195,22 +187,7 @@ export default function BlogPlugin(props: PluginProps) {
195
187
  throw new Error('Cannot save: no post identifier available. Please reload the page.');
196
188
  }
197
189
  console.log('[BlogPlugin] Saving post with slug:', originalSlug);
198
- console.log('[BlogPlugin] Post state:', {
199
- title: state.title,
200
- status: state.status,
201
- blocksCount: state.blocks.length,
202
- blocks: state.blocks.map(b => ({ id: b.id, type: b.type, hasData: !!b.data })),
203
- hasFeaturedImage: !!state.metadata.featuredImage,
204
- });
205
- const apiData = editorStateToAPI(state);
206
- console.log('[BlogPlugin] API data being sent:', {
207
- title: apiData.title,
208
- status: apiData.publicationData?.status,
209
- contentBlocksCount: apiData.contentBlocks?.length || 0,
210
- contentBlocks: apiData.contentBlocks?.map((b: any) => ({ id: b.id, type: b.type, hasData: !!b.data })) || [],
211
- hasImage: !!apiData.image,
212
- fullApiData: JSON.stringify(apiData, null, 2),
213
- });
190
+ const apiData = editorStateToAPI(state, undefined, heroBlock);
214
191
  const response = await fetch(`/api/plugin-blog/${originalSlug}`, {
215
192
  method: 'PUT',
216
193
  headers: { 'Content-Type': 'application/json' },
@@ -233,7 +210,6 @@ export default function BlogPlugin(props: PluginProps) {
233
210
  throw new Error(errorMessage + missingFieldsMsg);
234
211
  }
235
212
  const result = await response.json();
236
- console.log('[BlogPlugin] Save successful:', result);
237
213
  // If the slug changed, update the URL
238
214
  if (result.slug && result.slug !== originalSlug) {
239
215
  window.history.replaceState(null, '', `/dashboard/blog/editor/${result.slug}`);
@@ -246,7 +222,6 @@ export default function BlogPlugin(props: PluginProps) {
246
222
  );
247
223
 
248
224
  case 'new':
249
- console.log('[BlogPlugin] Rendering new route with darkMode:', darkMode);
250
225
  return (
251
226
  <EditorProvider
252
227
  customBlocks={customBlocks}
@@ -345,10 +320,16 @@ export { blockRegistry } from './registry';
345
320
  // Export layout block registration
346
321
  export { registerLayoutBlocks } from './lib/layouts/registerLayoutBlocks';
347
322
 
323
+ // Export config storage utilities
324
+ export { getPluginConfig, savePluginConfig } from './lib/config-storage';
325
+
348
326
  // Export editor state management
349
327
  export { EditorProvider, useEditor } from './state/EditorContext';
350
328
  export type { EditorProviderProps, EditorState, EditorContextValue } from './state';
351
329
 
330
+ // Export hooks
331
+ export { useCategories } from './hooks/useCategories';
332
+
352
333
  // Note: API handlers are server-only and exported from ./index.ts (server entry point)
353
334
  // They are NOT exported here to prevent client/server context mixing
354
335
 
package/src/init.tsx CHANGED
@@ -59,14 +59,5 @@ export function initBlogPlugin(config: BlogPluginConfig): void {
59
59
  darkMode: config.darkMode !== undefined ? config.darkMode : true, // Default to true
60
60
  backgroundColors: config.backgroundColors || undefined,
61
61
  };
62
-
63
- console.log('[BlogPlugin] Initialized with config:', {
64
- customBlocks: config.customBlocks?.length || 0,
65
- darkMode: config.darkMode !== undefined ? config.darkMode : true,
66
- backgroundColors: config.backgroundColors ? {
67
- light: config.backgroundColors.light,
68
- dark: config.backgroundColors.dark || 'not set',
69
- } : 'not set',
70
- });
71
62
  }
72
63
 
@@ -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
+ }