@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
@@ -0,0 +1,160 @@
1
+ 'use client';
2
+
3
+ import React, { useRef, useEffect } from 'react';
4
+ import { BlockWrapper } from '../BlockWrapper';
5
+ import { EditorBody } from '../EditorBody';
6
+ import { BlockRenderer } from '../../../lib/blocks/BlockRenderer';
7
+ import type { Block } from '../../../types/block';
8
+ import type { BlockTypeDefinition } from '../../../types/block';
9
+
10
+ export interface EditorCanvasProps {
11
+ isPreviewMode: boolean;
12
+ heroBlock: Block | null;
13
+ heroBlockDefinition: BlockTypeDefinition | undefined;
14
+ contentBlocks: Block[];
15
+ title: string;
16
+ siteId: string;
17
+ locale: string;
18
+ darkMode: boolean;
19
+ backgroundColors?: {
20
+ light: string;
21
+ dark?: string;
22
+ };
23
+ featuredImage?: {
24
+ id?: string; // Semantic ID - plugin-images handles transforms
25
+ alt?: string;
26
+ };
27
+ onTitleChange: (title: string) => void;
28
+ onHeroBlockUpdate: (data: Partial<Block['data']>) => void;
29
+ onHeroBlockDelete: () => void;
30
+ onBlockAdd: (type: string, index: number, containerId?: string) => void;
31
+ onBlockUpdate: (id: string, data: Partial<Block['data']>) => void;
32
+ onBlockDelete: (id: string) => void;
33
+ onBlockMove: (id: string, newIndex: number, containerId?: string) => void;
34
+ }
35
+
36
+ export function EditorCanvas({
37
+ isPreviewMode,
38
+ heroBlock,
39
+ heroBlockDefinition,
40
+ contentBlocks,
41
+ title,
42
+ siteId,
43
+ locale,
44
+ darkMode,
45
+ backgroundColors,
46
+ featuredImage,
47
+ onTitleChange,
48
+ onHeroBlockUpdate,
49
+ onHeroBlockDelete,
50
+ onBlockAdd,
51
+ onBlockUpdate,
52
+ onBlockDelete,
53
+ onBlockMove,
54
+ }: EditorCanvasProps) {
55
+ const titleRef = useRef<HTMLTextAreaElement>(null);
56
+
57
+ // Handle Title Auto-resize
58
+ useEffect(() => {
59
+ if (titleRef.current) {
60
+ titleRef.current.style.height = 'auto';
61
+ titleRef.current.style.height = `${titleRef.current.scrollHeight}px`;
62
+ }
63
+ }, [title]);
64
+
65
+ return (
66
+ <div
67
+ className="flex-1 overflow-y-auto overflow-x-hidden pb-40 px-6 custom-scrollbar selection:bg-primary/20 dark:selection:bg-primary/30 min-h-0"
68
+ style={{
69
+ backgroundColor: backgroundColors
70
+ ? (darkMode && backgroundColors.dark
71
+ ? backgroundColors.dark
72
+ : backgroundColors.light)
73
+ : undefined,
74
+ }}
75
+ >
76
+ <div className={`mx-auto transition-all duration-500 max-w-7xl w-full`}>
77
+ {/* Hero Block - Only show editable version when NOT in preview mode */}
78
+ {!isPreviewMode && heroBlockDefinition && heroBlock && (
79
+ <div className="mb-12">
80
+ <BlockWrapper
81
+ block={heroBlock}
82
+ onUpdate={onHeroBlockUpdate}
83
+ onDelete={onHeroBlockDelete}
84
+ onMoveUp={() => { }}
85
+ onMoveDown={() => { }}
86
+ allBlocks={[heroBlock]}
87
+ />
88
+ </div>
89
+ )}
90
+
91
+ {isPreviewMode ? (
92
+ <div className="space-y-8">
93
+ {heroBlockDefinition && heroBlock && (
94
+ <BlockRenderer
95
+ block={heroBlock}
96
+ context={{
97
+ siteId,
98
+ locale,
99
+ // Pass featured image as fallback for hero block
100
+ // Only pass id and alt - plugin-images handles transforms
101
+ fallbackImage: featuredImage ? {
102
+ id: featuredImage.id,
103
+ alt: featuredImage.alt,
104
+ } : undefined,
105
+ }}
106
+ />
107
+ )}
108
+
109
+ {!heroBlockDefinition && title && (
110
+ <h1 className="text-5xl font-serif font-medium text-neutral-950 dark:text-white leading-tight mb-12">
111
+ {title}
112
+ </h1>
113
+ )}
114
+
115
+ {contentBlocks.length > 0 ? (
116
+ <div className="space-y-8">
117
+ {contentBlocks.map((block) => (
118
+ <BlockRenderer
119
+ key={block.id}
120
+ block={block}
121
+ context={{ siteId, locale }}
122
+ />
123
+ ))}
124
+ </div>
125
+ ) : (
126
+ <div className="text-center py-20 text-neutral-400 dark:text-neutral-500">
127
+ <p className="text-sm">No content blocks yet. Switch to Edit mode to add blocks.</p>
128
+ </div>
129
+ )}
130
+ </div>
131
+ ) : (
132
+ <>
133
+ {!heroBlockDefinition && (
134
+ <div className="mb-12">
135
+ <textarea
136
+ ref={titleRef}
137
+ rows={1}
138
+ value={title}
139
+ onChange={(e) => onTitleChange(e.target.value)}
140
+ placeholder="The title of your story..."
141
+ className="w-full bg-transparent border-none outline-none text-5xl font-serif font-medium placeholder:text-neutral-500 dark:placeholder:text-neutral-500 resize-none leading-tight transition-colors duration-300 text-neutral-950 dark:text-white"
142
+ />
143
+ </div>
144
+ )}
145
+
146
+ <EditorBody
147
+ blocks={contentBlocks}
148
+ darkMode={darkMode}
149
+ backgroundColors={backgroundColors}
150
+ onBlockAdd={onBlockAdd}
151
+ onBlockUpdate={onBlockUpdate}
152
+ onBlockDelete={onBlockDelete}
153
+ onBlockMove={onBlockMove}
154
+ />
155
+ </>
156
+ )}
157
+ </div>
158
+ </div>
159
+ );
160
+ }
@@ -0,0 +1,122 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Library, Image as ImageIcon, LayoutTemplate, Type, Box } from 'lucide-react';
5
+ import { LibraryItem, CustomBlockItem } from './index';
6
+ import type { BlockTypeDefinition } from '../../../types/block';
7
+
8
+ export interface EditorLibraryProps {
9
+ registeredBlocks: BlockTypeDefinition[];
10
+ onAddBlock: (type: string) => void;
11
+ }
12
+
13
+ export function EditorLibrary({ registeredBlocks, onAddBlock }: EditorLibraryProps) {
14
+ // Get all registered blocks from state (excluding Hero block from sidebar)
15
+ const allBlocks = registeredBlocks.filter(block => block.type !== 'hero');
16
+ const textBlocks = allBlocks.filter(block => block.category === 'text');
17
+ const customBlocks = allBlocks.filter(block => block.category === 'custom');
18
+ const mediaBlocks = allBlocks.filter(block => block.category === 'media');
19
+ const layoutBlocks = allBlocks.filter(block => block.category === 'layout');
20
+
21
+ return (
22
+ <div className="p-6 w-72 min-w-0 max-w-full">
23
+ {/* Text Blocks */}
24
+ {textBlocks.length > 0 && (
25
+ <div className="mb-10">
26
+ <h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Text</h3>
27
+ <div className="grid grid-cols-2 gap-3">
28
+ {textBlocks.map((block) => {
29
+ const IconComponent = block.icon || block.components.Icon || Type;
30
+ return (
31
+ <LibraryItem
32
+ key={block.type}
33
+ icon={<IconComponent size={16} />}
34
+ label={block.name}
35
+ blockType={block.type}
36
+ description={block.description}
37
+ onAddBlock={onAddBlock}
38
+ />
39
+ );
40
+ })}
41
+ </div>
42
+ </div>
43
+ )}
44
+
45
+ {/* Media Blocks */}
46
+ {mediaBlocks.length > 0 && (
47
+ <div className="mb-10">
48
+ <h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Media</h3>
49
+ <div className="grid grid-cols-2 gap-3">
50
+ {mediaBlocks.map((block) => {
51
+ const IconComponent = block.icon || block.components.Icon || ImageIcon;
52
+ return (
53
+ <LibraryItem
54
+ key={block.type}
55
+ icon={<IconComponent size={16} />}
56
+ label={block.name}
57
+ blockType={block.type}
58
+ description={block.description}
59
+ onAddBlock={onAddBlock}
60
+ />
61
+ );
62
+ })}
63
+ </div>
64
+ </div>
65
+ )}
66
+
67
+ {/* Layout Blocks */}
68
+ {layoutBlocks.length > 0 && (
69
+ <div className="mb-10">
70
+ <h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Layout</h3>
71
+ <div className="grid grid-cols-2 gap-3">
72
+ {layoutBlocks.map((block) => {
73
+ const IconComponent = block.icon || block.components.Icon || LayoutTemplate;
74
+ return (
75
+ <LibraryItem
76
+ key={block.type}
77
+ icon={<IconComponent size={16} />}
78
+ label={block.name}
79
+ blockType={block.type}
80
+ description={block.description}
81
+ onAddBlock={onAddBlock}
82
+ />
83
+ );
84
+ })}
85
+ </div>
86
+ </div>
87
+ )}
88
+
89
+ {/* Custom Blocks */}
90
+ {customBlocks.length > 0 && (
91
+ <div>
92
+ <h3 className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black mb-6">Custom Blocks</h3>
93
+ <div className="space-y-3">
94
+ {customBlocks.map((block) => {
95
+ const IconComponent = block.icon || block.components.Icon || Box;
96
+ return (
97
+ <CustomBlockItem
98
+ key={block.type}
99
+ blockType={block.type}
100
+ name={block.name}
101
+ description={block.description}
102
+ icon={<IconComponent size={14} />}
103
+ onAddBlock={onAddBlock}
104
+ />
105
+ );
106
+ })}
107
+ </div>
108
+ </div>
109
+ )}
110
+
111
+ {/* Empty State */}
112
+ {allBlocks.length === 0 && (
113
+ <div className="text-center py-12">
114
+ <Library size={32} className="mx-auto text-neutral-300 dark:text-neutral-700 mb-4" />
115
+ <p className="text-xs text-neutral-500 dark:text-neutral-400">
116
+ No blocks registered yet.
117
+ </p>
118
+ </div>
119
+ )}
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,181 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Globe, Search, Box } from 'lucide-react';
5
+ import { FeaturedMediaSection, PrivacySettingsSection } from './index';
6
+ import type { Block } from '../../../types/block';
7
+ import type { PostMetadata, SEOMetadata } from '../../../types/post';
8
+
9
+ export interface EditorSidebarProps {
10
+ slug: string;
11
+ seo: SEOMetadata;
12
+ metadata: PostMetadata;
13
+ heroBlock: Block | null;
14
+ status: string;
15
+ onSEOUpdate: (seo: Partial<SEOMetadata>) => void;
16
+ onMetadataUpdate: (metadata: Partial<PostMetadata>) => void;
17
+ }
18
+
19
+ export function EditorSidebar({
20
+ slug,
21
+ seo,
22
+ metadata,
23
+ heroBlock,
24
+ status,
25
+ onSEOUpdate,
26
+ onMetadataUpdate,
27
+ }: EditorSidebarProps) {
28
+ return (
29
+ <div className="p-8 w-80 min-w-0 max-w-full space-y-12 overflow-y-auto max-h-full">
30
+ {/* SEO Section */}
31
+ <section>
32
+ <div className="flex items-center gap-3 mb-6">
33
+ <Search size={14} className="text-neutral-500 dark:text-neutral-400" />
34
+ <label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
35
+ SEO Settings
36
+ </label>
37
+ </div>
38
+ <div className="space-y-4">
39
+ <div>
40
+ <label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
41
+ SEO Title
42
+ </label>
43
+ <input
44
+ type="text"
45
+ value={seo.title || ''}
46
+ onChange={(e) => onSEOUpdate({ title: e.target.value })}
47
+ placeholder="SEO title (defaults to post title)"
48
+ className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text"
49
+ />
50
+ <p className="text-[9px] text-neutral-400 dark:text-neutral-500 mt-1">
51
+ {seo.title?.length || 0} / 60 characters
52
+ </p>
53
+ </div>
54
+ <div>
55
+ <label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
56
+ Meta Description
57
+ </label>
58
+ <textarea
59
+ value={seo.description || ''}
60
+ onChange={(e) => onSEOUpdate({ description: e.target.value })}
61
+ placeholder="Brief description for search engines"
62
+ rows={3}
63
+ className="w-full px-3 py-2 text-xs bg-white dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-700 rounded-lg outline-none focus:border-primary transition-all dark:text-neutral-100 resize-none"
64
+ />
65
+ <p className="text-[9px] text-neutral-400 dark:text-neutral-500 mt-1">
66
+ {seo.description?.length || 0} / 160 characters
67
+ </p>
68
+ </div>
69
+ <div>
70
+ <label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
71
+ Keywords (comma-separated)
72
+ </label>
73
+ <input
74
+ type="text"
75
+ value={seo.keywords?.join(', ') || ''}
76
+ onChange={(e) => {
77
+ const keywords = e.target.value.split(',').map(k => k.trim()).filter(k => k);
78
+ onSEOUpdate({ keywords });
79
+ }}
80
+ placeholder="keyword1, keyword2, keyword3"
81
+ className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text"
82
+ />
83
+ </div>
84
+ <div>
85
+ <label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
86
+ Open Graph Image URL
87
+ </label>
88
+ <input
89
+ type="url"
90
+ value={seo.ogImage || ''}
91
+ onChange={(e) => onSEOUpdate({ ogImage: e.target.value })}
92
+ placeholder="https://example.com/image.jpg"
93
+ className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text"
94
+ />
95
+ </div>
96
+ </div>
97
+ </section>
98
+
99
+ {/* Publishing Section */}
100
+ <section className="pt-8 border-t border-neutral-200 dark:border-neutral-800">
101
+ <div className="flex items-center gap-3 mb-6">
102
+ <Globe size={14} className="text-neutral-500 dark:text-neutral-400" />
103
+ <label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
104
+ Publishing
105
+ </label>
106
+ </div>
107
+ <div className="bg-dashboard-bg p-5 rounded-2xl border border-dashboard-border">
108
+ <span className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-3">Slug / Permalink</span>
109
+ <div className="text-xs font-mono break-all text-neutral-600 dark:text-neutral-400 leading-relaxed">
110
+ /blog/<span className="text-neutral-950 dark:text-white bg-amber-50 dark:bg-amber-900/20 px-1 rounded">{slug || 'untitled-post'}</span>
111
+ </div>
112
+ </div>
113
+ </section>
114
+
115
+ {/* Category Section */}
116
+ <section className="pt-8 border-t border-neutral-200 dark:border-neutral-800">
117
+ <div className="flex items-center gap-3 mb-6">
118
+ <Box size={14} className="text-neutral-500 dark:text-neutral-400" />
119
+ <label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
120
+ Category
121
+ </label>
122
+ </div>
123
+ <div className="space-y-4">
124
+ <div>
125
+ <label className="text-[10px] text-neutral-500 dark:text-neutral-400 uppercase font-bold block mb-2">
126
+ Category
127
+ </label>
128
+ <input
129
+ type="text"
130
+ value={metadata.categories?.[0] || ''}
131
+ onChange={(e) => {
132
+ const category = e.target.value.trim();
133
+ onMetadataUpdate({
134
+ categories: category ? [category] : []
135
+ });
136
+ }}
137
+ placeholder="Enter category (required for publishing)"
138
+ className="w-full px-3 py-2 text-xs bg-dashboard-card border border-dashboard-border rounded-lg outline-none focus:border-primary transition-all text-dashboard-text"
139
+ />
140
+ <p className="text-[9px] text-neutral-400 dark:text-neutral-500 mt-1">
141
+ {metadata.categories?.[0] ? 'Category set' : 'No category set'}
142
+ </p>
143
+ </div>
144
+ </div>
145
+ </section>
146
+
147
+ {/* Featured Media Section */}
148
+ <FeaturedMediaSection
149
+ featuredImage={metadata.featuredImage}
150
+ heroBlock={heroBlock}
151
+ slug={slug}
152
+ onUpdate={(image) => onMetadataUpdate({ featuredImage: image })}
153
+ />
154
+
155
+ {/* Privacy Settings Section */}
156
+ <PrivacySettingsSection
157
+ privacy={metadata.privacy}
158
+ onUpdate={(privacy) => onMetadataUpdate({ privacy })}
159
+ />
160
+
161
+ {/* Post Status Section */}
162
+ <section className="pt-8 border-t border-neutral-200 dark:border-neutral-800">
163
+ <div className="flex items-center justify-between mb-4">
164
+ <label className="text-[10px] uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 font-black">
165
+ Post Status
166
+ </label>
167
+ <span className="text-[10px] font-black text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-2.5 py-1 rounded-full uppercase tracking-tighter">
168
+ {status}
169
+ </span>
170
+ </div>
171
+ <p className="text-[11px] text-neutral-500 dark:text-neutral-400 leading-relaxed italic">
172
+ {status === 'draft'
173
+ ? 'This post is private. Only you can see it until you hit publish.'
174
+ : status === 'published'
175
+ ? 'This post is live and visible to everyone.'
176
+ : 'This post is scheduled for publication.'}
177
+ </p>
178
+ </section>
179
+ </div>
180
+ );
181
+ }
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { AlertTriangle, X } from 'lucide-react';
5
+
6
+ export interface ErrorBannerProps {
7
+ error: string | null;
8
+ onDismiss: () => void;
9
+ }
10
+
11
+ export function ErrorBanner({ error, onDismiss }: ErrorBannerProps) {
12
+ if (!error) return null;
13
+
14
+ return (
15
+ <div className="bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800 px-6 py-3 flex items-center justify-between">
16
+ <div className="flex items-center gap-3 flex-1">
17
+ <AlertTriangle className="text-red-600 dark:text-red-400 flex-shrink-0" size={20} />
18
+ <p className="text-red-800 dark:text-red-300 text-sm font-medium">
19
+ {error}
20
+ </p>
21
+ </div>
22
+ <button
23
+ onClick={onDismiss}
24
+ className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200 transition-colors"
25
+ aria-label="Dismiss error"
26
+ >
27
+ <X size={18} />
28
+ </button>
29
+ </div>
30
+ );
31
+ }