@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.
- package/package.json +5 -5
- package/src/api/handler.ts +4 -4
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useCategories.ts +76 -0
- package/src/index.tsx +5 -27
- package/src/init.tsx +0 -9
- package/src/lib/mappers/apiMapper.ts +53 -22
- package/src/registry/BlockRegistry.ts +1 -4
- package/src/state/EditorContext.tsx +39 -33
- package/src/state/types.ts +1 -1
- package/src/types/post.ts +4 -0
- package/src/views/CanvasEditor/BlockWrapper.tsx +7 -8
- package/src/views/CanvasEditor/CanvasEditorView.tsx +208 -794
- package/src/views/CanvasEditor/EditorBody.tsx +317 -127
- package/src/views/CanvasEditor/EditorHeader.tsx +106 -17
- package/src/views/CanvasEditor/LayoutContainer.tsx +208 -380
- package/src/views/CanvasEditor/components/EditorCanvas.tsx +160 -0
- package/src/views/CanvasEditor/components/EditorLibrary.tsx +122 -0
- package/src/views/CanvasEditor/components/EditorSidebar.tsx +181 -0
- package/src/views/CanvasEditor/components/ErrorBanner.tsx +31 -0
- package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +229 -46
- package/src/views/CanvasEditor/components/index.ts +11 -0
- package/src/views/CanvasEditor/hooks/index.ts +10 -0
- package/src/views/CanvasEditor/hooks/useHeroBlock.ts +103 -0
- package/src/views/CanvasEditor/hooks/useKeyboardShortcuts.ts +142 -0
- package/src/views/CanvasEditor/hooks/usePostLoader.ts +39 -0
- package/src/views/CanvasEditor/hooks/useRegisteredBlocks.ts +55 -0
- package/src/views/CanvasEditor/hooks/useUnsavedChanges.ts +339 -0
- package/src/views/PostManager/PostCards.tsx +18 -13
- package/src/views/PostManager/PostFilters.tsx +15 -0
- package/src/views/PostManager/PostManagerView.tsx +21 -15
- 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
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
93
|
+
id: imageId,
|
|
44
94
|
alt: image.alt || image.filename,
|
|
45
|
-
brightness:
|
|
46
|
-
blur:
|
|
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
|
-
{
|
|
188
|
+
{featuredImage?.id ? (
|
|
63
189
|
<div className="relative group">
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
</
|
|
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-
|
|
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={() =>
|
|
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={
|
|
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
|
+
}
|