@jhits/plugin-blog 0.0.5 → 0.0.6

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 (32) hide show
  1. package/package.json +5 -5
  2. package/src/api/handler.ts +4 -4
  3. package/src/hooks/index.ts +1 -0
  4. package/src/hooks/useCategories.ts +76 -0
  5. package/src/index.tsx +5 -27
  6. package/src/init.tsx +0 -9
  7. package/src/lib/mappers/apiMapper.ts +53 -22
  8. package/src/registry/BlockRegistry.ts +1 -4
  9. package/src/state/EditorContext.tsx +39 -33
  10. package/src/state/types.ts +1 -1
  11. package/src/types/post.ts +4 -0
  12. package/src/views/CanvasEditor/BlockWrapper.tsx +7 -8
  13. package/src/views/CanvasEditor/CanvasEditorView.tsx +208 -794
  14. package/src/views/CanvasEditor/EditorBody.tsx +317 -127
  15. package/src/views/CanvasEditor/EditorHeader.tsx +106 -17
  16. package/src/views/CanvasEditor/LayoutContainer.tsx +208 -380
  17. package/src/views/CanvasEditor/components/EditorCanvas.tsx +160 -0
  18. package/src/views/CanvasEditor/components/EditorLibrary.tsx +122 -0
  19. package/src/views/CanvasEditor/components/EditorSidebar.tsx +181 -0
  20. package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
  21. package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +229 -46
  22. package/src/views/CanvasEditor/components/index.ts +11 -0
  23. package/src/views/CanvasEditor/hooks/index.ts +10 -0
  24. package/src/views/CanvasEditor/hooks/useHeroBlock.ts +103 -0
  25. package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +142 -0
  26. package/src/views/CanvasEditor/hooks/usePostLoader.ts +39 -0
  27. package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +55 -0
  28. package/src/views/CanvasEditor/hooks/useUnsavedChanges.ts +339 -0
  29. package/src/views/PostManager/PostCards.tsx +18 -13
  30. package/src/views/PostManager/PostFilters.tsx +15 -0
  31. package/src/views/PostManager/PostManagerView.tsx +21 -15
  32. package/src/views/PostManager/PostTable.tsx +7 -4
@@ -1,55 +1,181 @@
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 - just update blog metadata with semantic ID
80
+ // Plugin-images Image component will automatically resolve the semantic ID when it renders
81
+ // GlobalImageEditor will handle saving transform data when editing
82
+ // NO API CALLS HERE - plugin-images handles everything
83
+ const handleImageChange = useCallback((image: ImageMetadata | null) => {
39
84
  if (image) {
85
+ // Extract filename from image URL for reference (not saved to API)
40
86
  const isUploadedImage = image.url.startsWith('/api/uploads/');
41
- const src = isUploadedImage ? image.filename : image.url;
87
+ const filename = isUploadedImage ? image.filename : image.url.split('/').pop()?.split('?')[0] || image.id;
88
+
89
+ // Save initial mapping to plugin-images API via GlobalImageEditor when image is first edited
90
+ // For now, just update blog metadata with semantic ID
91
+ // The Image component will handle resolution, and GlobalImageEditor will save the mapping when user edits
42
92
  onUpdate({
43
- src,
93
+ id: imageId,
44
94
  alt: image.alt || image.filename,
45
- brightness: featuredImage?.brightness ?? 100,
46
- blur: featuredImage?.blur ?? 0,
47
- });
95
+ brightness: 100,
96
+ blur: 0,
97
+ scale: 1.0,
98
+ positionX: 0,
99
+ positionY: 0,
100
+ isCustom: true,
101
+ } as FeaturedImage);
48
102
  } else {
103
+ // If removed, set to undefined
49
104
  onUpdate(undefined);
50
105
  }
51
106
  setShowImagePicker(false);
52
- };
107
+ }, [imageId, onUpdate]);
108
+
109
+ // Handle editor save from ImagePicker - save to plugin-images API
110
+ const handleEditorSave = useCallback(async (
111
+ finalScale: number,
112
+ finalPositionX: number,
113
+ finalPositionY: number,
114
+ finalBrightness?: number,
115
+ finalBlur?: number
116
+ ) => {
117
+ if (!featuredImage?.id) return;
118
+
119
+ // Reset the auto-open flag immediately to prevent reopening
120
+ setOpenEditorDirectly(false);
121
+
122
+ // Get the actual filename from the API (resolve the semantic ID)
123
+ let filename = imageId; // Fallback to semantic ID
124
+ try {
125
+ const response = await fetch(`/api/plugin-images/resolve?id=${encodeURIComponent(imageId)}`);
126
+ if (response.ok) {
127
+ const data = await response.json();
128
+ filename = data.filename || imageId;
129
+ }
130
+ } catch (error) {
131
+ console.error('Failed to resolve filename:', error);
132
+ }
133
+
134
+ // Normalize position values
135
+ const normalizedPositionX = finalPositionX === -50 ? 0 : finalPositionX;
136
+ const normalizedPositionY = finalPositionY === -50 ? 0 : finalPositionY;
137
+ const finalBrightnessValue = finalBrightness ?? brightness;
138
+ const finalBlurValue = finalBlur ?? blur;
139
+
140
+ // Save to plugin-images API
141
+ try {
142
+ const saveData = {
143
+ id: imageId,
144
+ filename: filename,
145
+ scale: finalScale,
146
+ positionX: normalizedPositionX,
147
+ positionY: normalizedPositionY,
148
+ brightness: finalBrightnessValue,
149
+ blur: finalBlurValue,
150
+ };
151
+
152
+ const response = await fetch('/api/plugin-images/resolve', {
153
+ method: 'POST',
154
+ headers: { 'Content-Type': 'application/json' },
155
+ body: JSON.stringify(saveData),
156
+ });
157
+
158
+ if (response.ok) {
159
+ // Update local featured image data - ensure id is preserved
160
+ onUpdate({
161
+ ...featuredImage,
162
+ id: featuredImage.id || imageId, // Ensure id is always preserved
163
+ scale: finalScale,
164
+ positionX: normalizedPositionX,
165
+ positionY: normalizedPositionY,
166
+ brightness: finalBrightnessValue,
167
+ blur: finalBlurValue,
168
+ });
169
+
170
+ // Dispatch event to notify Image components
171
+ window.dispatchEvent(new CustomEvent('image-mapping-updated', {
172
+ detail: saveData
173
+ }));
174
+ }
175
+ } catch (error) {
176
+ console.error('Failed to save image transform:', error);
177
+ }
178
+ }, [imageId, featuredImage, brightness, blur, onUpdate]);
53
179
 
54
180
  return (
55
181
  <section>
@@ -59,36 +185,54 @@ export function FeaturedMediaSection({
59
185
  Featured Media
60
186
  </label>
61
187
  </div>
62
- {imageSrc ? (
188
+ {featuredImage?.id ? (
63
189
  <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}
190
+ {/* Use Image component from plugin-images - it handles everything automatically */}
191
+ {/* Blog component only handles the design/styling */}
192
+ <div className="relative aspect-[16/10] bg-dashboard-bg rounded-3xl overflow-hidden border border-dashboard-border group/image">
193
+ <Image
194
+ id={imageId}
72
195
  alt={featuredImage?.alt || 'Featured image'}
73
196
  fill
74
- className="object-cover"
75
- style={{
76
- filter: `brightness(${brightness}%) blur(${blur}px)`
77
- }}
197
+ className="object-cover w-full h-full"
198
+ editable={false} // Disable Image component's overlay - we'll use our own
199
+ {...({
200
+ brightness,
201
+ blur,
202
+ scale,
203
+ positionX,
204
+ positionY,
205
+ } as any)}
78
206
  />
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>
207
+ {/* Custom edit overlay that opens ImagePicker editor */}
208
+ <button
209
+ onClick={() => {
210
+ setOpenEditorDirectly(true);
211
+ setShowImagePicker(true);
212
+ }}
213
+ 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]"
214
+ >
215
+ <div className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-neutral-800 rounded-full shadow-xl">
216
+ <ImageIcon size={14} className="text-primary" />
217
+ <span className="text-[10px] font-bold uppercase tracking-widest">Edit</span>
83
218
  </div>
84
- </div>
219
+ </button>
220
+ </div>
221
+ <div className="mt-2 flex items-center gap-3">
222
+ <button
223
+ onClick={() => setShowImagePicker(true)}
224
+ className="text-[10px] text-neutral-600 dark:text-neutral-400 hover:text-primary font-bold uppercase tracking-wider"
225
+ >
226
+ Change Image
227
+ </button>
228
+ <span className="text-[10px] text-neutral-400">•</span>
229
+ <button
230
+ onClick={() => onUpdate(undefined)}
231
+ className="text-[10px] text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 font-bold uppercase tracking-wider"
232
+ >
233
+ Remove Image
234
+ </button>
85
235
  </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
236
  </div>
93
237
  ) : (
94
238
  <div
@@ -101,28 +245,67 @@ export function FeaturedMediaSection({
101
245
  )}
102
246
 
103
247
  {/* 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)}>
248
+ {showImagePicker && mounted && createPortal(
249
+ <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={() => {
250
+ setShowImagePicker(false);
251
+ setOpenEditorDirectly(false); // Reset flag when closing
252
+ }}>
106
253
  <div className="bg-dashboard-card rounded-2xl w-full max-w-2xl mx-4 p-6 shadow-2xl max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
107
254
  <div className="flex items-center justify-between mb-6">
108
255
  <h3 className="text-lg font-bold text-neutral-900 dark:text-neutral-100">
109
- Select Featured Image
256
+ {openEditorDirectly ? 'Edit Featured Image' : 'Select Featured Image'}
110
257
  </h3>
111
258
  <button
112
- onClick={() => setShowImagePicker(false)}
259
+ onClick={() => {
260
+ setShowImagePicker(false);
261
+ setOpenEditorDirectly(false); // Reset flag when closing
262
+ }}
113
263
  className="p-2 hover:bg-dashboard-bg rounded-lg transition-colors"
114
264
  >
115
265
  <X size={20} />
116
266
  </button>
117
267
  </div>
118
268
  <ImagePicker
119
- value={featuredImage?.src}
269
+ value={imageId}
120
270
  onChange={handleImageChange}
271
+ brightness={brightness}
272
+ blur={blur}
273
+ {...({
274
+ scale,
275
+ positionX,
276
+ positionY,
277
+ } as any)}
278
+ onBrightnessChange={(val) => {
279
+ // Update local state only - don't trigger save
280
+ if (featuredImage) {
281
+ onUpdate({
282
+ ...featuredImage,
283
+ id: featuredImage.id || imageId, // Ensure id is preserved
284
+ brightness: val,
285
+ });
286
+ }
287
+ }}
288
+ onBlurChange={(val) => {
289
+ // Update local state only - don't trigger save
290
+ if (featuredImage) {
291
+ onUpdate({
292
+ ...featuredImage,
293
+ id: featuredImage.id || imageId, // Ensure id is preserved
294
+ blur: val,
295
+ });
296
+ }
297
+ }}
298
+ onEditorSave={handleEditorSave}
121
299
  darkMode={false}
122
- showEffects={true}
300
+ showEffects={true} // Enable effects so editor can be used
301
+ aspectRatio="16/10" // Thumbnail aspect ratio for blog cards
302
+ borderRadius="rounded-3xl"
303
+ objectFit="cover" // Cover for thumbnails
304
+ objectPosition="center"
123
305
  />
124
306
  </div>
125
- </div>
307
+ </div>,
308
+ document.body
126
309
  )}
127
310
  </section>
128
311
  );
@@ -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
+ }
@@ -0,0 +1,39 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { apiToBlogPost, type APIBlogDocument } from '../../../lib/mappers/apiMapper';
3
+ import type { BlogPost } from '../../../types/post';
4
+
5
+ export function usePostLoader(
6
+ postId: string | undefined,
7
+ currentPostId: string | null,
8
+ loadPost: (post: BlogPost) => void,
9
+ resetHeroBlock: () => void
10
+ ) {
11
+ const [isLoadingPost, setIsLoadingPost] = useState(false);
12
+
13
+ useEffect(() => {
14
+ if (postId && !currentPostId) {
15
+ const loadPostData = async () => {
16
+ try {
17
+ setIsLoadingPost(true);
18
+ // Reset hero block before loading new post so it gets re-initialized from the new post's blocks
19
+ resetHeroBlock();
20
+ const response = await fetch(`/api/plugin-blog/${postId}`);
21
+ if (!response.ok) {
22
+ throw new Error('Failed to load post');
23
+ }
24
+ const apiDoc: APIBlogDocument = await response.json();
25
+ const blogPost = apiToBlogPost(apiDoc);
26
+ loadPost(blogPost);
27
+ } catch (error) {
28
+ console.error('Failed to load post:', error);
29
+ alert('Failed to load post. Please try again.');
30
+ } finally {
31
+ setIsLoadingPost(false);
32
+ }
33
+ };
34
+ loadPostData();
35
+ }
36
+ }, [postId, currentPostId, loadPost, resetHeroBlock]);
37
+
38
+ return { isLoadingPost };
39
+ }