@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
@@ -1,55 +1,208 @@
1
1
  'use client';
2
2
 
3
- import React, { useState } from 'react';
3
+ import React, { useState, useCallback, useEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
4
5
  import { Image as ImageIcon, Plus, X } from 'lucide-react';
5
- import NextImage from 'next/image';
6
- import { ImagePicker } from '@jhits/plugin-images';
6
+ import { ImagePicker, Image } from '@jhits/plugin-images';
7
7
  import type { ImageMetadata } from '@jhits/plugin-images';
8
+ import type { Block } from '../../../types/block';
8
9
 
9
10
  export interface FeaturedImage {
11
+ // Store only the semantic ID - plugin-images handles everything else
10
12
  id?: string;
11
- src?: string;
12
13
  alt?: string;
14
+ // Transform values (stored locally for UI, but plugin-images API is source of truth)
13
15
  brightness?: number;
14
16
  blur?: number;
17
+ scale?: number;
18
+ positionX?: number;
19
+ positionY?: number;
20
+ // Indicates if this is a custom featured image (not synced from hero)
21
+ isCustom?: boolean;
15
22
  }
16
23
 
17
24
  export interface FeaturedMediaSectionProps {
18
25
  featuredImage?: FeaturedImage;
26
+ heroBlock?: Block | null; // Hero block to get default image from
27
+ slug?: string; // Blog post slug for semantic ID
19
28
  onUpdate: (image: FeaturedImage | undefined) => void;
20
29
  }
21
30
 
22
31
  /**
23
32
  * Featured Media Section Component
24
- * Handles featured image selection using ImagePicker
33
+ * Handles featured image selection - completely independent from hero image
34
+ * Featured image is a thumbnail used for blog post cards
35
+ * Hero image is separate and managed in the hero block
25
36
  */
26
37
  export function FeaturedMediaSection({
27
38
  featuredImage,
39
+ heroBlock,
40
+ slug,
28
41
  onUpdate,
29
42
  }: FeaturedMediaSectionProps) {
30
43
  const [showImagePicker, setShowImagePicker] = useState(false);
44
+ const [openEditorDirectly, setOpenEditorDirectly] = useState(false);
45
+ const [mounted, setMounted] = useState(false);
31
46
 
32
- const imageSrc = featuredImage?.src
33
- ? (featuredImage.src.startsWith('http') ? featuredImage.src : `/api/uploads/${featuredImage.src}`)
34
- : null;
47
+ // Handle SSR - ensure we only render portal on client
48
+ useEffect(() => {
49
+ setMounted(true);
50
+ }, []);
51
+
52
+ // Create semantic ID for this featured image - plugin-images will handle everything
53
+ const semanticId = slug ? `blog-featured-${slug}` : `blog-featured-${Date.now()}`;
54
+
55
+ // Use semantic ID from featuredImage if it exists, otherwise use generated one
56
+ // IMPORTANT: Always use the actual id from featuredImage if available, otherwise the semanticId
57
+ // This ensures the id is stable and doesn't change on re-renders
58
+ const imageId = featuredImage?.id || semanticId;
59
+
60
+ // Ensure featuredImage always has an id when it exists
61
+ // This prevents the "missing featured image" issue on save
62
+ useEffect(() => {
63
+ if (featuredImage && !featuredImage.id) {
64
+ // If featuredImage exists but has no id, set it to the semanticId
65
+ onUpdate({
66
+ ...featuredImage,
67
+ id: semanticId,
68
+ });
69
+ }
70
+ }, [featuredImage, semanticId, onUpdate]);
71
+
72
+ // Get transform values from featuredImage or use defaults
35
73
  const brightness = featuredImage?.brightness ?? 100;
36
74
  const blur = featuredImage?.blur ?? 0;
75
+ const scale = featuredImage?.scale ?? 1.0;
76
+ const positionX = featuredImage?.positionX ?? 0;
77
+ const positionY = featuredImage?.positionY ?? 0;
37
78
 
38
- const handleImageChange = (image: ImageMetadata | null) => {
79
+ // Handle image selection - create initial mapping and update blog metadata with semantic ID
80
+ // Plugin-images Image component will automatically resolve the semantic ID when it renders
81
+ const handleImageChange = useCallback(async (image: ImageMetadata | null) => {
39
82
  if (image) {
83
+ // Extract filename from image URL for reference
40
84
  const isUploadedImage = image.url.startsWith('/api/uploads/');
41
- const src = isUploadedImage ? image.filename : image.url;
85
+ let filename = image.filename;
86
+
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
42
119
  onUpdate({
43
- src,
120
+ id: imageId,
44
121
  alt: image.alt || image.filename,
45
- brightness: featuredImage?.brightness ?? 100,
46
- blur: featuredImage?.blur ?? 0,
47
- });
122
+ brightness: 100,
123
+ blur: 0,
124
+ scale: 1.0,
125
+ positionX: 0,
126
+ positionY: 0,
127
+ isCustom: true,
128
+ } as FeaturedImage);
48
129
  } else {
130
+ // If removed, set to undefined
49
131
  onUpdate(undefined);
50
132
  }
51
133
  setShowImagePicker(false);
52
- };
134
+ }, [imageId, onUpdate]);
135
+
136
+ // Handle editor save from ImagePicker - save to plugin-images API
137
+ const handleEditorSave = useCallback(async (
138
+ finalScale: number,
139
+ finalPositionX: number,
140
+ finalPositionY: number,
141
+ finalBrightness?: number,
142
+ finalBlur?: number
143
+ ) => {
144
+ if (!featuredImage?.id) return;
145
+
146
+ // Reset the auto-open flag immediately to prevent reopening
147
+ setOpenEditorDirectly(false);
148
+
149
+ // Get the actual filename from the API (resolve the semantic ID)
150
+ let filename = imageId; // Fallback to semantic ID
151
+ try {
152
+ const response = await fetch(`/api/plugin-images/resolve?id=${encodeURIComponent(imageId)}`);
153
+ if (response.ok) {
154
+ const data = await response.json();
155
+ filename = data.filename || imageId;
156
+ }
157
+ } catch (error) {
158
+ console.error('Failed to resolve filename:', error);
159
+ }
160
+
161
+ // Normalize position values
162
+ const normalizedPositionX = finalPositionX === -50 ? 0 : finalPositionX;
163
+ const normalizedPositionY = finalPositionY === -50 ? 0 : finalPositionY;
164
+ const finalBrightnessValue = finalBrightness ?? brightness;
165
+ const finalBlurValue = finalBlur ?? blur;
166
+
167
+ // Save to plugin-images API
168
+ try {
169
+ const saveData = {
170
+ id: imageId,
171
+ filename: filename,
172
+ scale: finalScale,
173
+ positionX: normalizedPositionX,
174
+ positionY: normalizedPositionY,
175
+ brightness: finalBrightnessValue,
176
+ blur: finalBlurValue,
177
+ };
178
+
179
+ const response = await fetch('/api/plugin-images/resolve', {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify(saveData),
183
+ });
184
+
185
+ if (response.ok) {
186
+ // Update local featured image data - ensure id is preserved
187
+ onUpdate({
188
+ ...featuredImage,
189
+ id: featuredImage.id || imageId, // Ensure id is always preserved
190
+ scale: finalScale,
191
+ positionX: normalizedPositionX,
192
+ positionY: normalizedPositionY,
193
+ brightness: finalBrightnessValue,
194
+ blur: finalBlurValue,
195
+ });
196
+
197
+ // Dispatch event to notify Image components
198
+ window.dispatchEvent(new CustomEvent('image-mapping-updated', {
199
+ detail: saveData
200
+ }));
201
+ }
202
+ } catch (error) {
203
+ console.error('Failed to save image transform:', error);
204
+ }
205
+ }, [imageId, featuredImage, brightness, blur, onUpdate]);
53
206
 
54
207
  return (
55
208
  <section>
@@ -59,36 +212,54 @@ export function FeaturedMediaSection({
59
212
  Featured Media
60
213
  </label>
61
214
  </div>
62
- {imageSrc ? (
215
+ {featuredImage?.id ? (
63
216
  <div className="relative group">
64
- <div
65
- className="relative aspect-[16/10] bg-dashboard-bg rounded-3xl overflow-hidden border border-dashboard-border cursor-pointer"
66
- onClick={() => setShowImagePicker(true)}
67
- onMouseEnter={(e) => e.currentTarget.classList.add('ring-2', 'ring-primary/50')}
68
- onMouseLeave={(e) => e.currentTarget.classList.remove('ring-2', 'ring-primary/50')}
69
- >
70
- <NextImage
71
- src={imageSrc}
217
+ {/* Use Image component from plugin-images - it handles everything automatically */}
218
+ {/* Blog component only handles the design/styling */}
219
+ <div className="relative aspect-[16/10] bg-dashboard-bg rounded-3xl overflow-hidden border border-dashboard-border group/image">
220
+ <Image
221
+ id={imageId}
72
222
  alt={featuredImage?.alt || 'Featured image'}
73
223
  fill
74
- className="object-cover"
75
- style={{
76
- filter: `brightness(${brightness}%) blur(${blur}px)`
77
- }}
224
+ className="object-cover w-full h-full"
225
+ editable={false} // Disable Image component's overlay - we'll use our own
226
+ {...({
227
+ brightness,
228
+ blur,
229
+ scale,
230
+ positionX,
231
+ positionY,
232
+ } as any)}
78
233
  />
79
- <div className="absolute inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-[2px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
80
- <div className="flex items-center gap-2 px-4 py-2.5 bg-dashboard-card text-dashboard-text rounded-full shadow-2xl border border-dashboard-border pointer-events-auto">
81
- <ImageIcon size={16} className="text-primary" />
82
- <span className="text-sm font-bold">Edit Image</span>
234
+ {/* Custom edit overlay that opens ImagePicker editor */}
235
+ <button
236
+ onClick={() => {
237
+ setOpenEditorDirectly(true);
238
+ setShowImagePicker(true);
239
+ }}
240
+ className="absolute inset-0 z-30 flex items-center justify-center opacity-0 group-hover/image:opacity-100 transition-all duration-300 bg-neutral-900/40 dark:bg-neutral-900/60 backdrop-blur-[2px]"
241
+ >
242
+ <div className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 rounded-full shadow-xl">
243
+ <ImageIcon size={14} className="text-primary" />
244
+ <span className="text-[10px] font-bold uppercase tracking-widest">Edit</span>
83
245
  </div>
84
- </div>
246
+ </button>
247
+ </div>
248
+ <div className="mt-2 flex items-center gap-3">
249
+ <button
250
+ onClick={() => setShowImagePicker(true)}
251
+ className="text-[10px] text-neutral-600 dark:text-neutral-400 hover:text-primary font-bold uppercase tracking-wider"
252
+ >
253
+ Change Image
254
+ </button>
255
+ <span className="text-[10px] text-neutral-400">•</span>
256
+ <button
257
+ onClick={() => onUpdate(undefined)}
258
+ className="text-[10px] text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 font-bold uppercase tracking-wider"
259
+ >
260
+ Remove Image
261
+ </button>
85
262
  </div>
86
- <button
87
- onClick={() => onUpdate(undefined)}
88
- className="mt-2 text-[10px] text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 font-bold uppercase tracking-wider"
89
- >
90
- Remove Image
91
- </button>
92
263
  </div>
93
264
  ) : (
94
265
  <div
@@ -101,28 +272,68 @@ export function FeaturedMediaSection({
101
272
  )}
102
273
 
103
274
  {/* Image Picker Modal */}
104
- {showImagePicker && (
105
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={() => setShowImagePicker(false)}>
106
- <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()}>
275
+ {showImagePicker && mounted && createPortal(
276
+ <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={() => {
277
+ setShowImagePicker(false);
278
+ setOpenEditorDirectly(false); // Reset flag when closing
279
+ }}>
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()}>
107
281
  <div className="flex items-center justify-between mb-6">
108
282
  <h3 className="text-lg font-bold text-neutral-900 dark:text-neutral-100">
109
- Select Featured Image
283
+ {openEditorDirectly ? 'Edit Featured Image' : 'Select Featured Image'}
110
284
  </h3>
111
285
  <button
112
- onClick={() => setShowImagePicker(false)}
113
- className="p-2 hover:bg-dashboard-bg rounded-lg transition-colors"
286
+ onClick={() => {
287
+ setShowImagePicker(false);
288
+ setOpenEditorDirectly(false); // Reset flag when closing
289
+ }}
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"
114
292
  >
115
- <X size={20} />
293
+ <X size={20} className="transition-colors" />
116
294
  </button>
117
295
  </div>
118
296
  <ImagePicker
119
- value={featuredImage?.src}
297
+ value={featuredImage?.id ? imageId : undefined}
120
298
  onChange={handleImageChange}
299
+ brightness={brightness}
300
+ blur={blur}
301
+ {...({
302
+ scale,
303
+ positionX,
304
+ positionY,
305
+ } as any)}
306
+ onBrightnessChange={(val) => {
307
+ // Update local state only - don't trigger save
308
+ if (featuredImage) {
309
+ onUpdate({
310
+ ...featuredImage,
311
+ id: featuredImage.id || imageId, // Ensure id is preserved
312
+ brightness: val,
313
+ });
314
+ }
315
+ }}
316
+ onBlurChange={(val) => {
317
+ // Update local state only - don't trigger save
318
+ if (featuredImage) {
319
+ onUpdate({
320
+ ...featuredImage,
321
+ id: featuredImage.id || imageId, // Ensure id is preserved
322
+ blur: val,
323
+ });
324
+ }
325
+ }}
326
+ onEditorSave={handleEditorSave}
121
327
  darkMode={false}
122
- showEffects={true}
328
+ showEffects={true} // Enable effects so editor can be used
329
+ aspectRatio="16/10" // Thumbnail aspect ratio for blog cards
330
+ borderRadius="rounded-3xl"
331
+ objectFit="cover" // Cover for thumbnails
332
+ objectPosition="center"
123
333
  />
124
334
  </div>
125
- </div>
335
+ </div>,
336
+ document.body
126
337
  )}
127
338
  </section>
128
339
  );
@@ -15,3 +15,14 @@ export type { FeaturedMediaSectionProps, FeaturedImage } from './FeaturedMediaSe
15
15
  export { PrivacySettingsSection } from './PrivacySettingsSection';
16
16
  export type { PrivacySettingsSectionProps, PrivacySettings } from './PrivacySettingsSection';
17
17
 
18
+ export { ErrorBanner } from './ErrorBanner';
19
+ export type { ErrorBannerProps } from './ErrorBanner';
20
+
21
+ export { EditorLibrary } from './EditorLibrary';
22
+ export type { EditorLibraryProps } from './EditorLibrary';
23
+
24
+ export { EditorCanvas } from './EditorCanvas';
25
+ export type { EditorCanvasProps } from './EditorCanvas';
26
+
27
+ export { EditorSidebar } from './EditorSidebar';
28
+ export type { EditorSidebarProps } from './EditorSidebar';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Canvas Editor Hooks
3
+ * Exports all custom hooks used in the Canvas Editor
4
+ */
5
+
6
+ export { usePostLoader } from './usePostLoader';
7
+ export { useHeroBlock } from './useHeroBlock';
8
+ export { useRegisteredBlocks } from './useRegisteredBlocks';
9
+ export { useKeyboardShortcuts } from './useKeyboardShortcuts';
10
+ export { useUnsavedChanges } from './useUnsavedChanges';
@@ -0,0 +1,103 @@
1
+ import { useState, useEffect, useMemo } from 'react';
2
+ import { blockRegistry } from '../../../registry/BlockRegistry';
3
+ import type { Block } from '../../../types/block';
4
+ import type { EditorState } from '../../../state/types';
5
+
6
+ // Generate a unique block ID
7
+ function generateBlockId(): string {
8
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
9
+ return crypto.randomUUID();
10
+ }
11
+ return `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
12
+ }
13
+
14
+ export function useHeroBlock(state: EditorState, registeredBlocks: any[]) {
15
+ const [heroBlock, setHeroBlock] = useState<Block | null>(null);
16
+
17
+ // Get Hero block definition from registered blocks (REQUIRED)
18
+ const heroBlockDefinition = useMemo(() => {
19
+ return blockRegistry.get('hero');
20
+ }, [registeredBlocks]);
21
+
22
+ // Initialize hero block if Hero block definition exists
23
+ useEffect(() => {
24
+ if (heroBlockDefinition) {
25
+ // Get default data from block definition
26
+ const heroData = heroBlockDefinition.defaultData || {};
27
+
28
+ // Initialize hero block only if it doesn't exist yet
29
+ setHeroBlock(prev => {
30
+ if (!prev) {
31
+ // First, try to find hero block in contentBlocks (from loaded post)
32
+ const heroBlockFromContent = state.blocks.find(block => block.type === 'hero');
33
+
34
+ if (heroBlockFromContent) {
35
+ return heroBlockFromContent;
36
+ }
37
+
38
+ // If no hero block in contentBlocks, initialize from defaults
39
+ // Hero image and featured image are completely independent - no syncing
40
+ const initialData = {
41
+ ...heroData,
42
+ title: state.title || heroData.title || '',
43
+ summary: state.metadata.excerpt || heroData.summary || '',
44
+ image: heroData.image, // Use default image, not featured image
45
+ };
46
+ return {
47
+ id: generateBlockId(),
48
+ type: 'hero',
49
+ data: initialData,
50
+ };
51
+ }
52
+ // Keep existing hero block data - let the Edit component manage it
53
+ return prev;
54
+ });
55
+ } else {
56
+ setHeroBlock(null);
57
+ }
58
+ }, [heroBlockDefinition, state.blocks, state.title, state.metadata.excerpt]);
59
+
60
+ // Sync hero block with editor state when post is loaded (for existing posts)
61
+ // BUT: Never sync image from featured image to hero - they are independent
62
+ // Only sync title, summary, and category
63
+ useEffect(() => {
64
+ if (heroBlock && heroBlockDefinition && state.postId) {
65
+ // Only update if the hero block data doesn't match the editor state
66
+ // This prevents overwriting user edits with stale data
67
+ const currentTitle = (heroBlock.data as any)?.title || '';
68
+ const currentSummary = (heroBlock.data as any)?.summary || '';
69
+ const currentCategory = (heroBlock.data as any)?.category || '';
70
+
71
+ const stateTitle = state.title || '';
72
+ const stateSummary = state.metadata.excerpt || '';
73
+ const stateCategory = state.metadata.categories?.[0] || '';
74
+
75
+ // Check if hero block is out of sync with editor state
76
+ // NOTE: We do NOT sync image anymore - hero and featured image are independent
77
+ const titleMismatch = currentTitle !== stateTitle;
78
+ const summaryMismatch = currentSummary !== stateSummary;
79
+ const categoryMismatch = currentCategory !== stateCategory;
80
+
81
+ // Only update title, summary, and category - NEVER update image
82
+ // The hero block image should come from contentBlocks, not from featured image
83
+ if ((titleMismatch || summaryMismatch || categoryMismatch) && (stateTitle || stateSummary || stateCategory)) {
84
+ setHeroBlock({
85
+ ...heroBlock,
86
+ data: {
87
+ ...heroBlock.data,
88
+ title: stateTitle || (heroBlock.data as any)?.title || '',
89
+ summary: stateSummary || (heroBlock.data as any)?.summary || '',
90
+ // DO NOT sync image - keep hero block's own image
91
+ category: stateCategory || (heroBlock.data as any)?.category || '',
92
+ },
93
+ });
94
+ }
95
+ }
96
+ }, [state.postId, state.title, state.metadata.excerpt, state.metadata.categories, heroBlockDefinition, heroBlock]);
97
+
98
+ return {
99
+ heroBlock,
100
+ setHeroBlock,
101
+ heroBlockDefinition,
102
+ };
103
+ }
@@ -0,0 +1,142 @@
1
+ import { useEffect } from 'react';
2
+ import type { Block } from '../../../types/block';
3
+ import type { EditorState } from '../../../state/types';
4
+
5
+ // Generate a unique block ID
6
+ function generateBlockId(): string {
7
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
8
+ return crypto.randomUUID();
9
+ }
10
+ return `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
11
+ }
12
+
13
+ export function useKeyboardShortcuts(
14
+ state: EditorState,
15
+ dispatch: (action: any) => void,
16
+ canUndo: boolean,
17
+ canRedo: boolean,
18
+ undo: () => void,
19
+ redo: () => void
20
+ ) {
21
+ useEffect(() => {
22
+ const handleKeyDown = (e: KeyboardEvent) => {
23
+ // Don't handle shortcuts if user is typing in an input/textarea/contentEditable
24
+ const target = e.target as HTMLElement;
25
+ const isEditableElement = target.tagName === 'INPUT' ||
26
+ target.tagName === 'TEXTAREA' ||
27
+ target.isContentEditable ||
28
+ target.closest('input, textarea, [contenteditable="true"]');
29
+
30
+ // Check for Ctrl+Z / Cmd+Z (Undo)
31
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
32
+ if (!isEditableElement) {
33
+ e.preventDefault();
34
+ e.stopPropagation();
35
+ if (canUndo) {
36
+ undo();
37
+ }
38
+ return;
39
+ }
40
+ }
41
+
42
+ // Check for Ctrl+Shift+Z / Cmd+Shift+Z or Ctrl+Y / Cmd+Y (Redo)
43
+ if (((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') || ((e.ctrlKey || e.metaKey) && e.key === 'y')) {
44
+ if (!isEditableElement) {
45
+ e.preventDefault();
46
+ e.stopPropagation();
47
+ if (canRedo) {
48
+ redo();
49
+ }
50
+ return;
51
+ }
52
+ }
53
+
54
+ // Check for Ctrl+V (Windows/Linux) or Cmd+V (Mac)
55
+ if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
56
+ // Don't paste if user is typing in an input/textarea/contentEditable
57
+ const target = e.target as HTMLElement;
58
+ const isEditableElement = target.tagName === 'INPUT' ||
59
+ target.tagName === 'TEXTAREA' ||
60
+ target.isContentEditable ||
61
+ target.closest('input, textarea, [contenteditable="true"]');
62
+
63
+ if (isEditableElement) {
64
+ return; // Let the browser handle paste in editable elements
65
+ }
66
+
67
+ // Check if there's a copied block
68
+ if (typeof window !== 'undefined') {
69
+ const copiedBlockJson = localStorage.getItem('__BLOG_EDITOR_COPIED_BLOCK__');
70
+ if (copiedBlockJson) {
71
+ try {
72
+ e.preventDefault();
73
+ e.stopPropagation();
74
+
75
+ const copiedBlock = JSON.parse(copiedBlockJson) as Block;
76
+
77
+ // Clone a block with new IDs (recursive for nested blocks)
78
+ const cloneBlock = (blockToClone: Block): Block => {
79
+ const cloned: Block = {
80
+ ...blockToClone,
81
+ id: generateBlockId(),
82
+ data: { ...blockToClone.data },
83
+ meta: blockToClone.meta ? { ...blockToClone.meta } : undefined,
84
+ };
85
+
86
+ // Handle children if they exist
87
+ if (blockToClone.children) {
88
+ if (Array.isArray(blockToClone.children) && blockToClone.children.length > 0) {
89
+ if (typeof blockToClone.children[0] === 'object') {
90
+ cloned.children = (blockToClone.children as Block[]).map(cloneBlock);
91
+ } else {
92
+ // If children are IDs, find and clone the actual blocks
93
+ const allBlocks = state.blocks;
94
+ const childIds = blockToClone.children as string[];
95
+ const childBlocks = childIds
96
+ .map((childId: string) => allBlocks.find(b => b.id === childId))
97
+ .filter((b): b is Block => b !== undefined);
98
+ cloned.children = childBlocks.map(cloneBlock);
99
+ }
100
+ }
101
+ }
102
+
103
+ return cloned;
104
+ };
105
+
106
+ const pastedBlock = cloneBlock(copiedBlock);
107
+
108
+ // Find where to paste - use hovered block or selected block, or paste at end
109
+ const hoveredBlockId = (window as any).__BLOG_EDITOR_HOVERED_BLOCK_ID__;
110
+ const targetBlockId = hoveredBlockId || state.selectedBlockId;
111
+
112
+ let pasteIndex: number | undefined;
113
+ if (targetBlockId) {
114
+ const targetIndex = state.blocks.findIndex(b => b.id === targetBlockId);
115
+ if (targetIndex !== -1) {
116
+ pasteIndex = targetIndex + 1;
117
+ }
118
+ }
119
+
120
+ // Dispatch ADD_BLOCK with the full block structure
121
+ dispatch({
122
+ type: 'ADD_BLOCK',
123
+ payload: {
124
+ block: pastedBlock,
125
+ index: pasteIndex,
126
+ containerId: undefined
127
+ }
128
+ });
129
+ } catch (error) {
130
+ console.error('Failed to paste block:', error);
131
+ }
132
+ }
133
+ }
134
+ }
135
+ };
136
+
137
+ window.addEventListener('keydown', handleKeyDown);
138
+ return () => {
139
+ window.removeEventListener('keydown', handleKeyDown);
140
+ };
141
+ }, [state.blocks, state.selectedBlockId, dispatch, canUndo, canRedo, undo, redo]);
142
+ }