@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.
- package/package.json +16 -16
- package/src/api/config-handler.ts +76 -0
- package/src/api/handler.ts +4 -4
- package/src/api/router.ts +17 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useCategories.ts +76 -0
- package/src/index.tsx +8 -27
- package/src/init.tsx +0 -9
- package/src/lib/config-storage.ts +65 -0
- package/src/lib/layouts/blocks/ColumnsBlock.tsx +177 -13
- package/src/lib/layouts/blocks/ColumnsBlock.tsx.tmp +81 -0
- package/src/lib/layouts/registerLayoutBlocks.ts +6 -1
- 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/index.ts +2 -0
- package/src/types/post.ts +4 -0
- package/src/views/CanvasEditor/BlockWrapper.tsx +87 -24
- package/src/views/CanvasEditor/CanvasEditorView.tsx +214 -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 +260 -49
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { blockRegistry } from '../../../registry/BlockRegistry';
|
|
3
|
+
|
|
4
|
+
export function useRegisteredBlocks() {
|
|
5
|
+
const [registeredBlocks, setRegisteredBlocks] = useState(() => {
|
|
6
|
+
const initial = blockRegistry.getAll();
|
|
7
|
+
return initial;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Watch for registry changes and update state
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
// Check immediately
|
|
13
|
+
const checkBlocks = () => {
|
|
14
|
+
const currentBlocks = blockRegistry.getAll();
|
|
15
|
+
const hasChanged = currentBlocks.length !== registeredBlocks.length ||
|
|
16
|
+
currentBlocks.some((b, i) => b.type !== registeredBlocks[i]?.type) ||
|
|
17
|
+
registeredBlocks.some((b, i) => b.type !== currentBlocks[i]?.type);
|
|
18
|
+
|
|
19
|
+
if (hasChanged) {
|
|
20
|
+
setRegisteredBlocks([...currentBlocks]);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Initial check
|
|
25
|
+
checkBlocks();
|
|
26
|
+
|
|
27
|
+
// Poll for registry changes (blocks are registered asynchronously in useEffect)
|
|
28
|
+
// Use a shorter interval initially, then longer
|
|
29
|
+
let pollCount = 0;
|
|
30
|
+
const interval = setInterval(() => {
|
|
31
|
+
pollCount++;
|
|
32
|
+
checkBlocks();
|
|
33
|
+
// Stop polling after 5 seconds (25 checks at 200ms)
|
|
34
|
+
if (pollCount > 25) {
|
|
35
|
+
clearInterval(interval);
|
|
36
|
+
}
|
|
37
|
+
}, 200);
|
|
38
|
+
|
|
39
|
+
// Also check after delays to catch initial registrations
|
|
40
|
+
const timeouts = [
|
|
41
|
+
setTimeout(checkBlocks, 50),
|
|
42
|
+
setTimeout(checkBlocks, 100),
|
|
43
|
+
setTimeout(checkBlocks, 300),
|
|
44
|
+
setTimeout(checkBlocks, 500),
|
|
45
|
+
setTimeout(checkBlocks, 1000),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
clearInterval(interval);
|
|
50
|
+
timeouts.forEach(clearTimeout);
|
|
51
|
+
};
|
|
52
|
+
}, [registeredBlocks.length]);
|
|
53
|
+
|
|
54
|
+
return registeredBlocks;
|
|
55
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for managing unsaved changes warnings and auto-save
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use client';
|
|
6
|
+
|
|
7
|
+
import { useEffect, useRef, useCallback, useState } from 'react';
|
|
8
|
+
import type { EditorState } from '../../../state/types';
|
|
9
|
+
import type { Block } from '../../../types/block';
|
|
10
|
+
|
|
11
|
+
interface UseUnsavedChangesOptions {
|
|
12
|
+
state: EditorState;
|
|
13
|
+
isDirty: boolean;
|
|
14
|
+
onSave: (heroBlock?: Block | null) => Promise<void>;
|
|
15
|
+
heroBlock?: Block | null;
|
|
16
|
+
autoSaveEnabled?: boolean;
|
|
17
|
+
autoSaveDelay?: number; // in milliseconds
|
|
18
|
+
postId?: string | null; // Post ID to detect when a post is loaded
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const AUTO_SAVE_STORAGE_KEY = 'blog-editor-autosave-enabled';
|
|
22
|
+
const DEFAULT_AUTO_SAVE_DELAY = 10000; // 10 seconds
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Hook to manage unsaved changes warnings and auto-save
|
|
26
|
+
*/
|
|
27
|
+
export function useUnsavedChanges({
|
|
28
|
+
state,
|
|
29
|
+
isDirty,
|
|
30
|
+
onSave,
|
|
31
|
+
heroBlock,
|
|
32
|
+
autoSaveEnabled: propAutoSaveEnabled,
|
|
33
|
+
autoSaveDelay = DEFAULT_AUTO_SAVE_DELAY,
|
|
34
|
+
postId,
|
|
35
|
+
}: UseUnsavedChangesOptions) {
|
|
36
|
+
const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
37
|
+
const isSavingRef = useRef(false);
|
|
38
|
+
const lastSavedStateRef = useRef<string>('');
|
|
39
|
+
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
40
|
+
const [countdown, setCountdown] = useState<number | null>(null);
|
|
41
|
+
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
|
42
|
+
const saveStatusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
43
|
+
const previousIsDirtyRef = useRef<boolean>(false);
|
|
44
|
+
const countdownStartTimeRef = useRef<number | null>(null);
|
|
45
|
+
const performAutoSaveRef = useRef<(() => Promise<void>) | null>(null);
|
|
46
|
+
|
|
47
|
+
// Get auto-save preference from localStorage
|
|
48
|
+
const getAutoSavePreference = useCallback((): boolean => {
|
|
49
|
+
if (typeof window === 'undefined') return false;
|
|
50
|
+
try {
|
|
51
|
+
const stored = localStorage.getItem(AUTO_SAVE_STORAGE_KEY);
|
|
52
|
+
if (stored !== null) {
|
|
53
|
+
return stored === 'true';
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('[useUnsavedChanges] Failed to read auto-save preference:', error);
|
|
57
|
+
}
|
|
58
|
+
// Default to false if not set
|
|
59
|
+
return false;
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
// State to track auto-save enabled status (reactive)
|
|
63
|
+
const [autoSaveEnabledState, setAutoSaveEnabledState] = useState<boolean>(() => {
|
|
64
|
+
// Initialize from prop or localStorage
|
|
65
|
+
if (propAutoSaveEnabled !== undefined) {
|
|
66
|
+
return propAutoSaveEnabled;
|
|
67
|
+
}
|
|
68
|
+
return getAutoSavePreference();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Sync with prop changes
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (propAutoSaveEnabled !== undefined) {
|
|
74
|
+
setAutoSaveEnabledState(propAutoSaveEnabled);
|
|
75
|
+
}
|
|
76
|
+
}, [propAutoSaveEnabled]);
|
|
77
|
+
|
|
78
|
+
// Initialize from localStorage on mount (if no prop provided)
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (propAutoSaveEnabled === undefined && typeof window !== 'undefined') {
|
|
81
|
+
const stored = getAutoSavePreference();
|
|
82
|
+
setAutoSaveEnabledState(stored);
|
|
83
|
+
}
|
|
84
|
+
}, []); // Only run on mount
|
|
85
|
+
|
|
86
|
+
// Set auto-save preference in localStorage and state
|
|
87
|
+
const setAutoSavePreference = useCallback((enabled: boolean) => {
|
|
88
|
+
if (typeof window === 'undefined') return;
|
|
89
|
+
try {
|
|
90
|
+
localStorage.setItem(AUTO_SAVE_STORAGE_KEY, enabled.toString());
|
|
91
|
+
setAutoSaveEnabledState(enabled);
|
|
92
|
+
console.log('[useUnsavedChanges] Auto-save preference updated:', enabled);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error('[useUnsavedChanges] Failed to save auto-save preference:', error);
|
|
95
|
+
}
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
// Determine if auto-save is enabled (prop takes precedence over state)
|
|
99
|
+
const autoSaveEnabled = propAutoSaveEnabled !== undefined
|
|
100
|
+
? propAutoSaveEnabled
|
|
101
|
+
: autoSaveEnabledState;
|
|
102
|
+
|
|
103
|
+
// Create a stable reference of the current state for comparison
|
|
104
|
+
const getStateSnapshot = useCallback(() => {
|
|
105
|
+
return JSON.stringify({
|
|
106
|
+
title: state.title,
|
|
107
|
+
slug: state.slug,
|
|
108
|
+
blocks: state.blocks,
|
|
109
|
+
seo: state.seo,
|
|
110
|
+
metadata: state.metadata,
|
|
111
|
+
status: state.status,
|
|
112
|
+
});
|
|
113
|
+
}, [state]);
|
|
114
|
+
|
|
115
|
+
// Initialize lastSavedStateRef when a post is loaded and marked as clean
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
// When a post is loaded (postId exists) and isDirty is false, update the saved state reference
|
|
118
|
+
if (postId && !isDirty && lastSavedStateRef.current === '') {
|
|
119
|
+
lastSavedStateRef.current = getStateSnapshot();
|
|
120
|
+
console.log('[useUnsavedChanges] Initialized saved state reference after post load');
|
|
121
|
+
}
|
|
122
|
+
// Also update if isDirty becomes false after being true (e.g., after save or MARK_CLEAN)
|
|
123
|
+
if (!isDirty && lastSavedStateRef.current !== getStateSnapshot()) {
|
|
124
|
+
lastSavedStateRef.current = getStateSnapshot();
|
|
125
|
+
}
|
|
126
|
+
}, [postId, isDirty, getStateSnapshot]);
|
|
127
|
+
|
|
128
|
+
// Auto-save function
|
|
129
|
+
const performAutoSave = useCallback(async () => {
|
|
130
|
+
if (isSavingRef.current || !isDirty) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
isSavingRef.current = true;
|
|
136
|
+
setSaveStatus('saving');
|
|
137
|
+
setCountdown(null);
|
|
138
|
+
countdownStartTimeRef.current = null;
|
|
139
|
+
console.log('[useUnsavedChanges] Auto-saving...');
|
|
140
|
+
await onSave(heroBlock);
|
|
141
|
+
lastSavedStateRef.current = getStateSnapshot();
|
|
142
|
+
setSaveStatus('saved');
|
|
143
|
+
console.log('[useUnsavedChanges] Auto-save completed');
|
|
144
|
+
|
|
145
|
+
// Clear save status after 2 seconds
|
|
146
|
+
if (saveStatusTimeoutRef.current) {
|
|
147
|
+
clearTimeout(saveStatusTimeoutRef.current);
|
|
148
|
+
}
|
|
149
|
+
saveStatusTimeoutRef.current = setTimeout(() => {
|
|
150
|
+
setSaveStatus('idle');
|
|
151
|
+
}, 2000);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error('[useUnsavedChanges] Auto-save failed:', error);
|
|
154
|
+
setSaveStatus('error');
|
|
155
|
+
// Clear error status after 3 seconds
|
|
156
|
+
if (saveStatusTimeoutRef.current) {
|
|
157
|
+
clearTimeout(saveStatusTimeoutRef.current);
|
|
158
|
+
}
|
|
159
|
+
saveStatusTimeoutRef.current = setTimeout(() => {
|
|
160
|
+
setSaveStatus('idle');
|
|
161
|
+
}, 3000);
|
|
162
|
+
} finally {
|
|
163
|
+
isSavingRef.current = false;
|
|
164
|
+
}
|
|
165
|
+
}, [isDirty, onSave, heroBlock, getStateSnapshot]);
|
|
166
|
+
|
|
167
|
+
// Keep ref updated with latest function
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
performAutoSaveRef.current = performAutoSave;
|
|
170
|
+
}, [performAutoSave]);
|
|
171
|
+
|
|
172
|
+
// Set up auto-save timer and countdown when state changes
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
// Only reset countdown if isDirty changed from false to true, or if auto-save was just disabled
|
|
175
|
+
const isDirtyChanged = previousIsDirtyRef.current !== isDirty;
|
|
176
|
+
const becameDirty = !previousIsDirtyRef.current && isDirty;
|
|
177
|
+
|
|
178
|
+
// Update previous isDirty ref
|
|
179
|
+
previousIsDirtyRef.current = isDirty;
|
|
180
|
+
|
|
181
|
+
// Clear existing countdown interval
|
|
182
|
+
if (countdownIntervalRef.current) {
|
|
183
|
+
clearInterval(countdownIntervalRef.current);
|
|
184
|
+
countdownIntervalRef.current = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!autoSaveEnabled || !isDirty || isSavingRef.current) {
|
|
188
|
+
setCountdown(null);
|
|
189
|
+
countdownStartTimeRef.current = null;
|
|
190
|
+
// Clear timeout if auto-save is disabled or not dirty
|
|
191
|
+
if (autoSaveTimeoutRef.current) {
|
|
192
|
+
clearTimeout(autoSaveTimeoutRef.current);
|
|
193
|
+
autoSaveTimeoutRef.current = null;
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Only reset countdown if:
|
|
199
|
+
// 1. isDirty just became true (new changes)
|
|
200
|
+
// 2. Or if there's no active countdown
|
|
201
|
+
const shouldResetCountdown = becameDirty || countdownStartTimeRef.current === null;
|
|
202
|
+
|
|
203
|
+
if (shouldResetCountdown) {
|
|
204
|
+
// Clear existing timeout
|
|
205
|
+
if (autoSaveTimeoutRef.current) {
|
|
206
|
+
clearTimeout(autoSaveTimeoutRef.current);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Initialize countdown
|
|
210
|
+
const secondsRemaining = Math.ceil(autoSaveDelay / 1000);
|
|
211
|
+
setCountdown(secondsRemaining);
|
|
212
|
+
countdownStartTimeRef.current = Date.now();
|
|
213
|
+
|
|
214
|
+
// Set new timeout for auto-save
|
|
215
|
+
autoSaveTimeoutRef.current = setTimeout(() => {
|
|
216
|
+
if (performAutoSaveRef.current) {
|
|
217
|
+
performAutoSaveRef.current();
|
|
218
|
+
}
|
|
219
|
+
}, autoSaveDelay);
|
|
220
|
+
} else {
|
|
221
|
+
// Countdown is already running, just update it based on elapsed time
|
|
222
|
+
if (countdownStartTimeRef.current && autoSaveTimeoutRef.current) {
|
|
223
|
+
const elapsed = Date.now() - countdownStartTimeRef.current;
|
|
224
|
+
const remaining = Math.max(0, autoSaveDelay - elapsed);
|
|
225
|
+
const secondsRemaining = Math.ceil(remaining / 1000);
|
|
226
|
+
setCountdown(secondsRemaining);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Update countdown every second (only if we have an active countdown)
|
|
231
|
+
if (shouldResetCountdown || countdownIntervalRef.current === null) {
|
|
232
|
+
countdownIntervalRef.current = setInterval(() => {
|
|
233
|
+
if (countdownStartTimeRef.current && autoSaveTimeoutRef.current) {
|
|
234
|
+
const elapsed = Date.now() - countdownStartTimeRef.current;
|
|
235
|
+
const remaining = Math.max(0, autoSaveDelay - elapsed);
|
|
236
|
+
const secondsRemaining = Math.ceil(remaining / 1000);
|
|
237
|
+
|
|
238
|
+
if (secondsRemaining <= 0) {
|
|
239
|
+
setCountdown(null);
|
|
240
|
+
if (countdownIntervalRef.current) {
|
|
241
|
+
clearInterval(countdownIntervalRef.current);
|
|
242
|
+
countdownIntervalRef.current = null;
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
setCountdown(secondsRemaining);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}, 1000);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return () => {
|
|
252
|
+
if (autoSaveTimeoutRef.current) {
|
|
253
|
+
clearTimeout(autoSaveTimeoutRef.current);
|
|
254
|
+
}
|
|
255
|
+
if (countdownIntervalRef.current) {
|
|
256
|
+
clearInterval(countdownIntervalRef.current);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}, [autoSaveEnabled, isDirty, autoSaveDelay]);
|
|
260
|
+
|
|
261
|
+
// Handle browser beforeunload event (page refresh/close)
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (!isDirty) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
// Modern browsers ignore custom messages, but we still need to set returnValue
|
|
270
|
+
e.returnValue = '';
|
|
271
|
+
return '';
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
275
|
+
|
|
276
|
+
return () => {
|
|
277
|
+
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
278
|
+
};
|
|
279
|
+
}, [isDirty]);
|
|
280
|
+
|
|
281
|
+
// Handle link clicks (for Next.js App Router)
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (!isDirty) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const handleLinkClick = (e: MouseEvent) => {
|
|
288
|
+
const target = e.target as HTMLElement;
|
|
289
|
+
const link = target.closest('a');
|
|
290
|
+
|
|
291
|
+
if (link && link.href) {
|
|
292
|
+
const url = new URL(link.href);
|
|
293
|
+
const currentPath = window.location.pathname;
|
|
294
|
+
|
|
295
|
+
// Check if navigating away from editor
|
|
296
|
+
if (!url.pathname.includes('/blog/editor') && !url.pathname.includes('/blog/new')) {
|
|
297
|
+
const confirmed = window.confirm(
|
|
298
|
+
'You have unsaved changes. Are you sure you want to leave? Your changes will be lost.'
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (!confirmed) {
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
e.stopPropagation();
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// Listen for clicks on links
|
|
311
|
+
document.addEventListener('click', handleLinkClick, true);
|
|
312
|
+
|
|
313
|
+
return () => {
|
|
314
|
+
document.removeEventListener('click', handleLinkClick, true);
|
|
315
|
+
};
|
|
316
|
+
}, [isDirty]);
|
|
317
|
+
|
|
318
|
+
// Cleanup on unmount
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
return () => {
|
|
321
|
+
if (autoSaveTimeoutRef.current) {
|
|
322
|
+
clearTimeout(autoSaveTimeoutRef.current);
|
|
323
|
+
}
|
|
324
|
+
if (countdownIntervalRef.current) {
|
|
325
|
+
clearInterval(countdownIntervalRef.current);
|
|
326
|
+
}
|
|
327
|
+
if (saveStatusTimeoutRef.current) {
|
|
328
|
+
clearTimeout(saveStatusTimeoutRef.current);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
}, []);
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
autoSaveEnabled,
|
|
335
|
+
setAutoSaveEnabled: setAutoSavePreference,
|
|
336
|
+
countdown,
|
|
337
|
+
saveStatus,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
@@ -129,14 +129,7 @@ export function PostCards({
|
|
|
129
129
|
</div>
|
|
130
130
|
)}
|
|
131
131
|
{/* Status Badge Overlay */}
|
|
132
|
-
<div className="absolute top-4 right-4
|
|
133
|
-
{/* Owner Badge */}
|
|
134
|
-
{isPostOwner(post) && (
|
|
135
|
-
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border backdrop-blur-sm bg-primary/10 text-primary border-primary/20">
|
|
136
|
-
<UserCheck size={12} />
|
|
137
|
-
Mijn artikel
|
|
138
|
-
</span>
|
|
139
|
-
)}
|
|
132
|
+
<div className="absolute top-4 right-4">
|
|
140
133
|
<span
|
|
141
134
|
className={`inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border backdrop-blur-sm ${getStatusBadgeColor(post.status)}`}
|
|
142
135
|
>
|
|
@@ -149,9 +142,14 @@ export function PostCards({
|
|
|
149
142
|
<div className="p-6">
|
|
150
143
|
{/* Title & Slug */}
|
|
151
144
|
<div className="mb-4">
|
|
152
|
-
<
|
|
153
|
-
{post.
|
|
154
|
-
|
|
145
|
+
<button
|
|
146
|
+
onClick={() => onEdit(post.id)}
|
|
147
|
+
className="text-left w-full hover:cursor-pointer"
|
|
148
|
+
>
|
|
149
|
+
<h3 className="font-bold text-lg text-neutral-950 dark:text-white mb-2 line-clamp-2 group-hover:text-primary transition-colors hover:underline">
|
|
150
|
+
{post.title}
|
|
151
|
+
</h3>
|
|
152
|
+
</button>
|
|
155
153
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 font-mono">
|
|
156
154
|
/{post.slug}
|
|
157
155
|
</p>
|
|
@@ -168,9 +166,16 @@ export function PostCards({
|
|
|
168
166
|
<div className="space-y-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
|
169
167
|
{/* Author */}
|
|
170
168
|
<div className="flex items-center gap-2">
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
{isPostOwner(post) ? (
|
|
170
|
+
<UserCheck size={14} className="text-primary" />
|
|
171
|
+
) : (
|
|
172
|
+
<User size={14} className="text-neutral-400" />
|
|
173
|
+
)}
|
|
174
|
+
<span className={`text-xs ${isPostOwner(post) ? 'text-primary font-semibold' : 'text-neutral-600 dark:text-neutral-400'}`}>
|
|
173
175
|
{getAuthorName(post.authorId)}
|
|
176
|
+
{isPostOwner(post) && (
|
|
177
|
+
<span className="ml-1">(You)</span>
|
|
178
|
+
)}
|
|
174
179
|
</span>
|
|
175
180
|
</div>
|
|
176
181
|
|
|
@@ -32,8 +32,13 @@ export function PostFilters({
|
|
|
32
32
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
|
33
33
|
{/* Search Input */}
|
|
34
34
|
<div className="relative flex-1">
|
|
35
|
+
<label htmlFor="blog-post-search" className="absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0" style={{ clip: 'rect(0, 0, 0, 0)', clipPath: 'inset(50%)' }}>
|
|
36
|
+
Search posts by title or content
|
|
37
|
+
</label>
|
|
35
38
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-400 size-4" />
|
|
36
39
|
<input
|
|
40
|
+
id="blog-post-search"
|
|
41
|
+
name="blog-post-search"
|
|
37
42
|
type="text"
|
|
38
43
|
value={search}
|
|
39
44
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
@@ -44,8 +49,13 @@ export function PostFilters({
|
|
|
44
49
|
|
|
45
50
|
{/* Status Filter */}
|
|
46
51
|
<div className="relative">
|
|
52
|
+
<label htmlFor="blog-post-status-filter" className="absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0" style={{ clip: 'rect(0, 0, 0, 0)', clipPath: 'inset(50%)' }}>
|
|
53
|
+
Filter by status
|
|
54
|
+
</label>
|
|
47
55
|
<Filter className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-400 size-4 pointer-events-none" />
|
|
48
56
|
<select
|
|
57
|
+
id="blog-post-status-filter"
|
|
58
|
+
name="blog-post-status-filter"
|
|
49
59
|
value={statusFilter}
|
|
50
60
|
onChange={(e) => onStatusFilterChange(e.target.value as PostStatus | 'all')}
|
|
51
61
|
className="pl-11 pr-8 py-3 bg-neutral-100 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 rounded-2xl text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary appearance-none outline-none cursor-pointer min-w-[160px]"
|
|
@@ -60,8 +70,13 @@ export function PostFilters({
|
|
|
60
70
|
|
|
61
71
|
{/* Category Filter */}
|
|
62
72
|
<div className="relative">
|
|
73
|
+
<label htmlFor="blog-post-category-filter" className="absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0" style={{ clip: 'rect(0, 0, 0, 0)', clipPath: 'inset(50%)' }}>
|
|
74
|
+
Filter by category
|
|
75
|
+
</label>
|
|
63
76
|
<Tag className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-400 size-4 pointer-events-none" />
|
|
64
77
|
<select
|
|
78
|
+
id="blog-post-category-filter"
|
|
79
|
+
name="blog-post-category-filter"
|
|
65
80
|
value={categoryFilter}
|
|
66
81
|
onChange={(e) => onCategoryFilterChange(e.target.value)}
|
|
67
82
|
className="pl-11 pr-8 py-3 bg-neutral-100 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 rounded-2xl text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary appearance-none outline-none cursor-pointer min-w-[160px]"
|
|
@@ -23,6 +23,8 @@ export interface PostManagerViewProps {
|
|
|
23
23
|
|
|
24
24
|
type ViewMode = 'list' | 'cards';
|
|
25
25
|
|
|
26
|
+
const STORAGE_KEY_PREFIX = 'blog-view-mode';
|
|
27
|
+
|
|
26
28
|
export function PostManagerView({ siteId, locale }: PostManagerViewProps) {
|
|
27
29
|
const [posts, setPosts] = useState<PostListItem[]>([]);
|
|
28
30
|
const [totalPosts, setTotalPosts] = useState<number>(0);
|
|
@@ -30,7 +32,22 @@ export function PostManagerView({ siteId, locale }: PostManagerViewProps) {
|
|
|
30
32
|
const [search, setSearch] = useState('');
|
|
31
33
|
const [statusFilter, setStatusFilter] = useState<PostStatus | 'all'>('all');
|
|
32
34
|
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
|
33
|
-
|
|
35
|
+
|
|
36
|
+
// Load view mode preference from localStorage
|
|
37
|
+
const getStoredViewMode = (): ViewMode => {
|
|
38
|
+
if (typeof window === 'undefined') return 'list';
|
|
39
|
+
const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}-${siteId}`);
|
|
40
|
+
return (stored === 'list' || stored === 'cards') ? stored : 'list';
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const [viewMode, setViewMode] = useState<ViewMode>(getStoredViewMode);
|
|
44
|
+
|
|
45
|
+
// Save view mode preference to localStorage when it changes
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (typeof window !== 'undefined') {
|
|
48
|
+
localStorage.setItem(`${STORAGE_KEY_PREFIX}-${siteId}`, viewMode);
|
|
49
|
+
}
|
|
50
|
+
}, [viewMode, siteId]);
|
|
34
51
|
|
|
35
52
|
// Fetch posts from API
|
|
36
53
|
useEffect(() => {
|
|
@@ -44,20 +61,9 @@ export function PostManagerView({ siteId, locale }: PostManagerViewProps) {
|
|
|
44
61
|
// Convert API format to PostListItem format
|
|
45
62
|
const postListItems: PostListItem[] = data.blogs.map((doc: APIBlogDocument) => {
|
|
46
63
|
const blogPost = apiToBlogPost(doc);
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
if (blogPost.metadata.featuredImage?.src) {
|
|
51
|
-
const src = blogPost.metadata.featuredImage.src;
|
|
52
|
-
// If it's a URL, extract the filename
|
|
53
|
-
if (src.includes('/')) {
|
|
54
|
-
const parts = src.split('/');
|
|
55
|
-
featuredImageId = parts[parts.length - 1];
|
|
56
|
-
} else {
|
|
57
|
-
// Already a filename/ID
|
|
58
|
-
featuredImageId = src;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
64
|
+
// Use semantic ID (id) - plugin-images handles resolution
|
|
65
|
+
// The id is the semantic ID (e.g., "blog-featured-{slug}") which plugin-images resolves
|
|
66
|
+
const featuredImageId = blogPost.metadata.featuredImage?.id;
|
|
61
67
|
// Extract category from metadata or hero block
|
|
62
68
|
let category: string | undefined = undefined;
|
|
63
69
|
if (blogPost.metadata.categories && blogPost.metadata.categories.length > 0) {
|
|
@@ -144,12 +144,15 @@ export function PostTable({
|
|
|
144
144
|
</div>
|
|
145
145
|
)}
|
|
146
146
|
<div className="min-w-0 flex-1">
|
|
147
|
-
<button
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
<button
|
|
148
|
+
onClick={() => onEdit(post.id)}
|
|
149
|
+
className="text-left w-full hover:cursor-pointer p-0 m-0 border-0 bg-transparent"
|
|
150
|
+
>
|
|
151
|
+
<h3 className="font-bold hover:underline text-neutral-950 dark:text-white mb-1 line-clamp-1 text-left">
|
|
152
|
+
{post.title.trim()}
|
|
150
153
|
</h3>
|
|
151
154
|
</button>
|
|
152
|
-
<p className="text-xs text-neutral-500 dark:text-neutral-400 font-mono">
|
|
155
|
+
<p className="text-xs text-neutral-500 dark:text-neutral-400 font-mono text-left">
|
|
153
156
|
/{post.slug}
|
|
154
157
|
</p>
|
|
155
158
|
</div>
|