@jhits/plugin-blog 0.0.16 → 0.0.18
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/dist/api/categories.d.ts.map +1 -1
- package/dist/api/categories.js +43 -12
- package/dist/api/handler.d.ts +1 -0
- package/dist/api/handler.d.ts.map +1 -1
- package/dist/api/handler.js +259 -32
- package/dist/hooks/useBlogs.d.ts +2 -0
- package/dist/hooks/useBlogs.d.ts.map +1 -1
- package/dist/hooks/useBlogs.js +10 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/lib/i18n.d.ts +14 -0
- package/dist/lib/i18n.d.ts.map +1 -0
- package/dist/lib/i18n.js +58 -0
- package/dist/lib/mappers/apiMapper.d.ts +18 -0
- package/dist/lib/mappers/apiMapper.d.ts.map +1 -1
- package/dist/lib/mappers/apiMapper.js +1 -0
- package/dist/state/reducer.d.ts.map +1 -1
- package/dist/state/reducer.js +11 -6
- package/dist/state/types.d.ts +5 -0
- package/dist/state/types.d.ts.map +1 -1
- package/dist/state/types.js +1 -0
- package/dist/types/post.d.ts +25 -0
- package/dist/types/post.d.ts.map +1 -1
- package/dist/utils/client.d.ts +2 -0
- package/dist/utils/client.d.ts.map +1 -1
- package/dist/utils/client.js +3 -1
- package/dist/views/CanvasEditor/CanvasEditorView.d.ts.map +1 -1
- package/dist/views/CanvasEditor/CanvasEditorView.js +130 -4
- package/dist/views/CanvasEditor/EditorHeader.d.ts +5 -1
- package/dist/views/CanvasEditor/EditorHeader.d.ts.map +1 -1
- package/dist/views/CanvasEditor/EditorHeader.js +23 -5
- package/dist/views/CanvasEditor/hooks/usePostLoader.d.ts +1 -1
- package/dist/views/CanvasEditor/hooks/usePostLoader.d.ts.map +1 -1
- package/dist/views/CanvasEditor/hooks/usePostLoader.js +14 -4
- package/dist/views/PostManager/LanguageFlags.d.ts +11 -0
- package/dist/views/PostManager/LanguageFlags.d.ts.map +1 -0
- package/dist/views/PostManager/LanguageFlags.js +60 -0
- package/dist/views/PostManager/PostCards.d.ts.map +1 -1
- package/dist/views/PostManager/PostCards.js +4 -1
- package/dist/views/PostManager/PostFilters.d.ts +4 -1
- package/dist/views/PostManager/PostFilters.d.ts.map +1 -1
- package/dist/views/PostManager/PostFilters.js +13 -3
- package/dist/views/PostManager/PostManagerView.d.ts.map +1 -1
- package/dist/views/PostManager/PostManagerView.js +24 -3
- package/dist/views/PostManager/PostTable.d.ts.map +1 -1
- package/dist/views/PostManager/PostTable.js +4 -1
- package/package.json +4 -4
- package/src/api/categories.ts +58 -11
- package/src/api/handler.ts +286 -31
- package/src/hooks/useBlogs.ts +12 -1
- package/src/index.tsx +7 -3
- package/src/lib/i18n.ts +78 -0
- package/src/lib/mappers/apiMapper.ts +20 -0
- package/src/state/reducer.ts +12 -6
- package/src/state/types.ts +5 -0
- package/src/types/post.ts +28 -0
- package/src/utils/client.ts +4 -0
- package/src/views/CanvasEditor/CanvasEditorView.tsx +164 -20
- package/src/views/CanvasEditor/EditorHeader.tsx +93 -18
- package/src/views/CanvasEditor/hooks/usePostLoader.ts +15 -4
- package/src/views/PostManager/LanguageFlags.tsx +136 -0
- package/src/views/PostManager/PostCards.tsx +22 -12
- package/src/views/PostManager/PostFilters.tsx +38 -1
- package/src/views/PostManager/PostManagerView.tsx +25 -2
- package/src/views/PostManager/PostTable.tsx +12 -1
- package/src/lib/layouts/blocks/ColumnsBlock.tsx.tmp +0 -81
package/src/types/post.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface PrivacySettings {
|
|
|
68
68
|
* Post Metadata
|
|
69
69
|
*/
|
|
70
70
|
export interface PostMetadata {
|
|
71
|
+
/** Language-specific title */
|
|
72
|
+
title?: string;
|
|
71
73
|
/** Featured image */
|
|
72
74
|
featuredImage?: {
|
|
73
75
|
id?: string; // Image ID (preferred, for plugin-images system)
|
|
@@ -123,6 +125,9 @@ export interface BlogPost {
|
|
|
123
125
|
/** Additional metadata */
|
|
124
126
|
metadata: PostMetadata;
|
|
125
127
|
|
|
128
|
+
/** Content per language (for multi-language posts) */
|
|
129
|
+
languages?: BlogPostLanguages;
|
|
130
|
+
|
|
126
131
|
/** Creation timestamp */
|
|
127
132
|
createdAt: string;
|
|
128
133
|
|
|
@@ -148,6 +153,9 @@ export interface PostListItem {
|
|
|
148
153
|
authorId?: string;
|
|
149
154
|
updatedAt: string;
|
|
150
155
|
category?: string;
|
|
156
|
+
lang?: string;
|
|
157
|
+
availableLanguages?: string[];
|
|
158
|
+
languages?: BlogPostLanguages;
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
/**
|
|
@@ -159,6 +167,7 @@ export interface PostFilterOptions {
|
|
|
159
167
|
tag?: string;
|
|
160
168
|
authorId?: string;
|
|
161
169
|
search?: string;
|
|
170
|
+
language?: string;
|
|
162
171
|
dateFrom?: string;
|
|
163
172
|
dateTo?: string;
|
|
164
173
|
limit?: number;
|
|
@@ -167,3 +176,22 @@ export interface PostFilterOptions {
|
|
|
167
176
|
sortOrder?: 'asc' | 'desc';
|
|
168
177
|
}
|
|
169
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Blog post language content (blocks + metadata per language)
|
|
181
|
+
*/
|
|
182
|
+
export interface BlogPostLanguageContent {
|
|
183
|
+
blocks: Block[];
|
|
184
|
+
metadata: PostMetadata;
|
|
185
|
+
/** Per-language last update timestamp */
|
|
186
|
+
updatedAt?: string;
|
|
187
|
+
/** Per-language publication status */
|
|
188
|
+
status?: PostStatus;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Languages object storing content per language
|
|
193
|
+
*/
|
|
194
|
+
export interface BlogPostLanguages {
|
|
195
|
+
[key: string]: BlogPostLanguageContent;
|
|
196
|
+
}
|
|
197
|
+
|
package/src/utils/client.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface FetchBlogsOptions {
|
|
|
13
13
|
skip?: number;
|
|
14
14
|
/** Filter by status (published, draft, concept) */
|
|
15
15
|
status?: string;
|
|
16
|
+
/** Filter by language */
|
|
17
|
+
language?: string;
|
|
16
18
|
/** Whether to fetch all posts for admin (includes drafts) */
|
|
17
19
|
admin?: boolean;
|
|
18
20
|
/** API base URL (default: '/api/blogs') */
|
|
@@ -39,6 +41,7 @@ export async function fetchBlogs(options: FetchBlogsOptions = {}): Promise<Fetch
|
|
|
39
41
|
limit = 10,
|
|
40
42
|
skip = 0,
|
|
41
43
|
status,
|
|
44
|
+
language,
|
|
42
45
|
admin = false,
|
|
43
46
|
apiBaseUrl = '/api/plugin-blog',
|
|
44
47
|
} = options;
|
|
@@ -47,6 +50,7 @@ export async function fetchBlogs(options: FetchBlogsOptions = {}): Promise<Fetch
|
|
|
47
50
|
if (limit) params.set('limit', limit.toString());
|
|
48
51
|
if (skip) params.set('skip', skip.toString());
|
|
49
52
|
if (status) params.set('status', status);
|
|
53
|
+
if (language) params.set('language', language);
|
|
50
54
|
if (admin) params.set('admin', 'true');
|
|
51
55
|
|
|
52
56
|
const url = `${apiBaseUrl}?${params.toString()}`;
|
|
@@ -34,45 +34,182 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
34
34
|
const [isSaving, setIsSaving] = useState(false);
|
|
35
35
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
36
36
|
|
|
37
|
+
// Language state for multilingual support
|
|
38
|
+
const [primaryLanguage, setPrimaryLanguage] = useState<string>(locale || 'en');
|
|
39
|
+
const [isLoadingLanguage, setIsLoadingLanguage] = useState(true);
|
|
40
|
+
const [availableLanguages, setAvailableLanguages] = useState<string[]>([locale || 'en']);
|
|
41
|
+
const [currentLanguage, setCurrentLanguage] = useState<string>(locale || 'en');
|
|
42
|
+
|
|
37
43
|
// Get registered blocks
|
|
38
44
|
const registeredBlocks = useRegisteredBlocks();
|
|
39
45
|
|
|
40
46
|
// Hero block management
|
|
41
47
|
const { heroBlock, setHeroBlock, heroBlockDefinition } = useHeroBlock(state, registeredBlocks);
|
|
42
48
|
|
|
43
|
-
// Post loading
|
|
49
|
+
// Post loading - wait for language settings to be loaded first
|
|
44
50
|
const { isLoadingPost } = usePostLoader(
|
|
45
51
|
postId,
|
|
46
52
|
state.postId,
|
|
47
53
|
(post) => {
|
|
48
54
|
helpers.loadPost(post);
|
|
55
|
+
// Don't reset current language when loading - preserve user's selection
|
|
56
|
+
// This allows switching to a new language even if no content exists yet
|
|
57
|
+
// Update available languages from post's languages object
|
|
58
|
+
if (post.languages && Object.keys(post.languages).length > 0) {
|
|
59
|
+
const langs = Object.keys(post.languages);
|
|
60
|
+
// Add current language to available if not already there
|
|
61
|
+
if (!langs.includes(currentLanguage)) {
|
|
62
|
+
langs.push(currentLanguage);
|
|
63
|
+
}
|
|
64
|
+
setAvailableLanguages(langs);
|
|
65
|
+
}
|
|
49
66
|
// After loading, ensure we're marked as clean
|
|
50
67
|
// Use setTimeout to ensure this runs after the reducer has processed LOAD_POST
|
|
51
68
|
setTimeout(() => {
|
|
52
69
|
dispatch({ type: 'MARK_CLEAN' });
|
|
53
70
|
}, 0);
|
|
54
71
|
},
|
|
55
|
-
() => setHeroBlock(null)
|
|
72
|
+
() => setHeroBlock(null),
|
|
73
|
+
!isLoadingLanguage ? currentLanguage : undefined
|
|
56
74
|
);
|
|
57
75
|
|
|
76
|
+
// Sync currentLanguage to editor state on mount so onSave always knows the active language
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
dispatch({ type: 'SET_CURRENT_LANGUAGE', payload: currentLanguage });
|
|
79
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount
|
|
80
|
+
|
|
81
|
+
// Fetch primary language from settings (simulated - in real app would come from config)
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const fetchLanguageSettings = async () => {
|
|
84
|
+
try {
|
|
85
|
+
// Use the locale prop as the initial language
|
|
86
|
+
// Only set this once on initial load, don't override if user already selected a language
|
|
87
|
+
if (!currentLanguage || currentLanguage === 'en') {
|
|
88
|
+
setPrimaryLanguage(locale || 'nl');
|
|
89
|
+
if (!availableLanguages.includes(locale || 'nl')) {
|
|
90
|
+
setAvailableLanguages([locale || 'nl']);
|
|
91
|
+
}
|
|
92
|
+
setCurrentLanguage(locale || 'nl');
|
|
93
|
+
dispatch({ type: 'SET_CURRENT_LANGUAGE', payload: locale || 'nl' });
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('Failed to fetch language settings:', error);
|
|
97
|
+
} finally {
|
|
98
|
+
setIsLoadingLanguage(false);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
fetchLanguageSettings();
|
|
102
|
+
}, []); // Empty dependency - only run once on mount
|
|
103
|
+
|
|
104
|
+
// Handle language change
|
|
105
|
+
const handleLanguageChange = async (newLanguage: string) => {
|
|
106
|
+
// Save current content first if dirty
|
|
107
|
+
if (state.isDirty) {
|
|
108
|
+
const confirmed = window.confirm('You have unsaved changes. Do you want to save them first?');
|
|
109
|
+
if (confirmed) {
|
|
110
|
+
await handleSave();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setCurrentLanguage(newLanguage);
|
|
115
|
+
dispatch({ type: 'SET_CURRENT_LANGUAGE', payload: newLanguage });
|
|
116
|
+
|
|
117
|
+
// Reload with new language
|
|
118
|
+
if (postId) {
|
|
119
|
+
try {
|
|
120
|
+
const response = await fetch(`/api/plugin-blog/${postId}?language=${newLanguage}`);
|
|
121
|
+
if (response && response.ok) {
|
|
122
|
+
const apiDoc = await response.json();
|
|
123
|
+
|
|
124
|
+
// Manually update state instead of going through loadPost
|
|
125
|
+
// This avoids re-triggering the usePostLoader hook
|
|
126
|
+
const blocks = apiDoc.contentBlocks || apiDoc.blocks || [];
|
|
127
|
+
dispatch({ type: 'SET_TITLE', payload: apiDoc.title || '' });
|
|
128
|
+
|
|
129
|
+
// Replace all blocks
|
|
130
|
+
// First clear, then set new blocks via LOAD_POST-like behavior
|
|
131
|
+
// We use a custom dispatch to update blocks without resetting postId
|
|
132
|
+
dispatch({
|
|
133
|
+
type: 'LOAD_POST',
|
|
134
|
+
payload: {
|
|
135
|
+
id: state.postId || apiDoc._id || apiDoc.id,
|
|
136
|
+
title: apiDoc.title || '',
|
|
137
|
+
slug: apiDoc.slug || state.slug,
|
|
138
|
+
blocks: blocks,
|
|
139
|
+
seo: apiDoc.seo || {},
|
|
140
|
+
publication: {
|
|
141
|
+
status: apiDoc.publicationData?.status === 'concept' ? 'draft' : (apiDoc.publicationData?.status || state.status),
|
|
142
|
+
date: apiDoc.publicationData?.date,
|
|
143
|
+
authorId: apiDoc.authorId,
|
|
144
|
+
},
|
|
145
|
+
metadata: {
|
|
146
|
+
featuredImage: apiDoc.image ? {
|
|
147
|
+
id: apiDoc.image.id || apiDoc.image.src,
|
|
148
|
+
alt: apiDoc.image.alt,
|
|
149
|
+
isCustom: apiDoc.image.isCustom,
|
|
150
|
+
} : state.metadata?.featuredImage,
|
|
151
|
+
categories: apiDoc.categoryTags?.category ? [apiDoc.categoryTags.category] : [],
|
|
152
|
+
tags: apiDoc.categoryTags?.tags || [],
|
|
153
|
+
excerpt: apiDoc.summary || '',
|
|
154
|
+
lang: newLanguage,
|
|
155
|
+
},
|
|
156
|
+
languages: apiDoc.languages,
|
|
157
|
+
createdAt: apiDoc.createdAt || new Date().toISOString(),
|
|
158
|
+
updatedAt: apiDoc.updatedAt || new Date().toISOString(),
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Update available languages
|
|
163
|
+
if (apiDoc.availableLanguages) {
|
|
164
|
+
const langs = [...apiDoc.availableLanguages];
|
|
165
|
+
if (!langs.includes(newLanguage)) {
|
|
166
|
+
langs.push(newLanguage);
|
|
167
|
+
}
|
|
168
|
+
setAvailableLanguages(langs);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Reset hero block so it re-initializes from new blocks
|
|
172
|
+
setHeroBlock(null);
|
|
173
|
+
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
dispatch({ type: 'MARK_CLEAN' });
|
|
176
|
+
}, 100);
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error('Failed to switch language:', error);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Handle adding a new language
|
|
185
|
+
const handleAddLanguage = async (newLanguage: string) => {
|
|
186
|
+
if (availableLanguages.includes(newLanguage)) return;
|
|
187
|
+
|
|
188
|
+
// Add the new language to the list
|
|
189
|
+
setAvailableLanguages([...availableLanguages, newLanguage]);
|
|
190
|
+
|
|
191
|
+
// Switch to the new language (it will copy from primary language)
|
|
192
|
+
await handleLanguageChange(newLanguage);
|
|
193
|
+
};
|
|
194
|
+
|
|
58
195
|
// Track if we just loaded a post to prevent marking as dirty during cleanup
|
|
59
196
|
const justLoadedRef = useRef(false);
|
|
60
197
|
const previousIsLoadingRef = useRef<boolean>(false);
|
|
61
198
|
const loadingCleanupTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
62
|
-
|
|
199
|
+
|
|
63
200
|
// Mark when post loading completes and ensure it stays clean after all effects
|
|
64
201
|
useEffect(() => {
|
|
65
202
|
// Detect when loading just finished (was loading, now not loading, and we have a postId)
|
|
66
203
|
const loadingJustFinished = previousIsLoadingRef.current && !isLoadingPost && state.postId;
|
|
67
|
-
|
|
204
|
+
|
|
68
205
|
if (loadingJustFinished) {
|
|
69
206
|
justLoadedRef.current = true;
|
|
70
|
-
|
|
207
|
+
|
|
71
208
|
// Clear any existing cleanup timer
|
|
72
209
|
if (loadingCleanupTimerRef.current) {
|
|
73
210
|
clearTimeout(loadingCleanupTimerRef.current);
|
|
74
211
|
}
|
|
75
|
-
|
|
212
|
+
|
|
76
213
|
// Wait for all effects to complete, then ensure we're marked as clean
|
|
77
214
|
// Use multiple animation frames + setTimeout to ensure all effects have run
|
|
78
215
|
requestAnimationFrame(() => {
|
|
@@ -87,10 +224,10 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
87
224
|
});
|
|
88
225
|
});
|
|
89
226
|
}
|
|
90
|
-
|
|
227
|
+
|
|
91
228
|
// Update ref
|
|
92
229
|
previousIsLoadingRef.current = isLoadingPost;
|
|
93
|
-
|
|
230
|
+
|
|
94
231
|
return () => {
|
|
95
232
|
if (loadingCleanupTimerRef.current) {
|
|
96
233
|
clearTimeout(loadingCleanupTimerRef.current);
|
|
@@ -170,7 +307,7 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
170
307
|
}
|
|
171
308
|
|
|
172
309
|
console.log('[CanvasEditorView] Final status before save:', state.status);
|
|
173
|
-
|
|
310
|
+
|
|
174
311
|
// Pass hero block to save function so it can be included in the saved data
|
|
175
312
|
await helpers.save(heroBlock);
|
|
176
313
|
setIsSaving(false);
|
|
@@ -199,17 +336,17 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
199
336
|
// Handle hero block update
|
|
200
337
|
const handleHeroBlockUpdate = (data: Partial<Block['data']>) => {
|
|
201
338
|
if (!heroBlock) return;
|
|
202
|
-
|
|
339
|
+
|
|
203
340
|
setHeroBlock({
|
|
204
341
|
...heroBlock,
|
|
205
342
|
data: { ...heroBlock.data, ...data },
|
|
206
343
|
});
|
|
207
|
-
|
|
344
|
+
|
|
208
345
|
// Sync title to editor state
|
|
209
346
|
if (data.title !== undefined && typeof data.title === 'string') {
|
|
210
347
|
dispatch({ type: 'SET_TITLE', payload: data.title });
|
|
211
348
|
}
|
|
212
|
-
|
|
349
|
+
|
|
213
350
|
// Sync summary to editor state metadata
|
|
214
351
|
if (data.summary !== undefined && typeof data.summary === 'string') {
|
|
215
352
|
dispatch({
|
|
@@ -217,11 +354,11 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
217
354
|
payload: { excerpt: data.summary }
|
|
218
355
|
});
|
|
219
356
|
}
|
|
220
|
-
|
|
357
|
+
|
|
221
358
|
// Hero image and featured image are completely independent
|
|
222
359
|
// Do NOT sync hero image to featured image
|
|
223
360
|
// The featured image is a separate thumbnail that the client adjusts independently
|
|
224
|
-
|
|
361
|
+
|
|
225
362
|
// Sync category to editor state metadata
|
|
226
363
|
if (data.category !== undefined && typeof data.category === 'string') {
|
|
227
364
|
dispatch({
|
|
@@ -244,11 +381,9 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
244
381
|
};
|
|
245
382
|
|
|
246
383
|
return (
|
|
247
|
-
<div className="h-full rounded-[2.5rem] w-full bg-dashboard-card text-dashboard-text flex flex-col font-sans transition-colors duration-300 overflow-hidden
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
<ErrorBanner error={saveError} onDismiss={() => setSaveError(null)} />
|
|
251
|
-
|
|
384
|
+
<div className="h-full rounded-[2.5rem] w-full bg-dashboard-card text-dashboard-text flex flex-col font-sans transition-colors duration-300 overflow-hidden">
|
|
385
|
+
{/* Header needs overflow-visible for dropdown to escape the rounded container */}
|
|
386
|
+
<header className="overflow-visible flex-none shrink-0 z-10">
|
|
252
387
|
<EditorHeader
|
|
253
388
|
isLibraryOpen={isLibraryOpen}
|
|
254
389
|
onLibraryToggle={() => setLibraryOpen(!isLibraryOpen)}
|
|
@@ -275,9 +410,18 @@ export function CanvasEditorView({ postId, darkMode, backgroundColors: propsBack
|
|
|
275
410
|
isDirty={state.isDirty}
|
|
276
411
|
autoSaveCountdown={countdown}
|
|
277
412
|
autoSaveStatus={saveStatus}
|
|
413
|
+
languages={availableLanguages}
|
|
414
|
+
currentLanguage={currentLanguage}
|
|
415
|
+
onLanguageChange={handleLanguageChange}
|
|
416
|
+
onAddLanguage={handleAddLanguage}
|
|
278
417
|
/>
|
|
418
|
+
</header>
|
|
419
|
+
|
|
420
|
+
{/* Error Banner */}
|
|
421
|
+
<ErrorBanner error={saveError} onDismiss={() => setSaveError(null)} />
|
|
279
422
|
|
|
280
|
-
|
|
423
|
+
{/* Editor Content Wrapper */}
|
|
424
|
+
<main className="flex flex-1 flex-col relative min-h-0 overflow-hidden">
|
|
281
425
|
<div className="flex flex-1 relative overflow-hidden min-h-0 flex-nowrap">
|
|
282
426
|
{/* LEFT SIDEBAR: COMPONENT LIBRARY */}
|
|
283
427
|
{!isPreviewMode && (
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { useState } from 'react';
|
|
4
|
-
import { ArrowLeft, Library, Settings2, Save, Clock, Edit, Eye } from 'lucide-react';
|
|
4
|
+
import { ArrowLeft, Library, Settings2, Save, Clock, Edit, Eye, Globe, Plus, ChevronDown } from 'lucide-react';
|
|
5
5
|
import { useEditor } from '../../state/EditorContext';
|
|
6
6
|
import { SaveConfirmationModal } from './SaveConfirmationModal';
|
|
7
7
|
|
|
@@ -20,6 +20,10 @@ export interface EditorHeaderProps {
|
|
|
20
20
|
isDirty?: boolean;
|
|
21
21
|
autoSaveCountdown?: number | null;
|
|
22
22
|
autoSaveStatus?: 'idle' | 'saving' | 'saved' | 'error';
|
|
23
|
+
languages?: string[];
|
|
24
|
+
currentLanguage?: string;
|
|
25
|
+
onLanguageChange?: (language: string) => void;
|
|
26
|
+
onAddLanguage?: (language: string) => void;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export function EditorHeader({
|
|
@@ -37,11 +41,27 @@ export function EditorHeader({
|
|
|
37
41
|
isDirty = false,
|
|
38
42
|
autoSaveCountdown = null,
|
|
39
43
|
autoSaveStatus = 'idle',
|
|
44
|
+
languages = [],
|
|
45
|
+
currentLanguage = 'en',
|
|
46
|
+
onLanguageChange,
|
|
47
|
+
onAddLanguage,
|
|
40
48
|
}: EditorHeaderProps) {
|
|
41
49
|
const { state, dispatch } = useEditor();
|
|
42
50
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
|
43
51
|
const [saveAsDraft, setSaveAsDraft] = useState(false);
|
|
44
52
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
53
|
+
const [showLanguageDropdown, setShowLanguageDropdown] = useState(false);
|
|
54
|
+
|
|
55
|
+
const languageLabels: Record<string, string> = {
|
|
56
|
+
en: 'English',
|
|
57
|
+
nl: 'Nederlands',
|
|
58
|
+
sv: 'Svenska',
|
|
59
|
+
de: 'Deutsch',
|
|
60
|
+
fr: 'Français',
|
|
61
|
+
es: 'Español',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const availableToAdd = ['en', 'nl', 'sv', 'de', 'fr', 'es'].filter(lang => !languages.includes(lang));
|
|
45
65
|
|
|
46
66
|
const handleSaveDraftClick = () => {
|
|
47
67
|
setSaveAsDraft(true);
|
|
@@ -104,7 +124,7 @@ export function EditorHeader({
|
|
|
104
124
|
};
|
|
105
125
|
|
|
106
126
|
return (
|
|
107
|
-
<
|
|
127
|
+
<div className="flex items-center justify-between px-6 py-3 bg-dashboard-sidebar backdrop-blur-md border-b border-dashboard-border flex-none shrink-0">
|
|
108
128
|
<div className="flex items-center gap-6">
|
|
109
129
|
<button
|
|
110
130
|
onClick={() => {
|
|
@@ -131,6 +151,64 @@ export function EditorHeader({
|
|
|
131
151
|
<Library size={16} strokeWidth={1.5} />
|
|
132
152
|
Library
|
|
133
153
|
</button>
|
|
154
|
+
{/* Language Switcher */}
|
|
155
|
+
{languages.length > 0 && onLanguageChange && (
|
|
156
|
+
<>
|
|
157
|
+
<div className="h-4 w-[1px] bg-neutral-300 dark:border-neutral-700" />
|
|
158
|
+
<div className="relative">
|
|
159
|
+
<button
|
|
160
|
+
onClick={() => setShowLanguageDropdown(!showLanguageDropdown)}
|
|
161
|
+
className="flex items-center gap-2 px-3 py-2 text-[10px] uppercase tracking-widest font-bold bg-dashboard-card border border-dashboard-border rounded-lg text-neutral-600 dark:text-neutral-400 hover:text-dashboard-text hover:border-primary/50 transition-all shadow-sm"
|
|
162
|
+
>
|
|
163
|
+
<Globe size={14} strokeWidth={1.5} />
|
|
164
|
+
<span>{languageLabels[currentLanguage] || currentLanguage.toUpperCase()}</span>
|
|
165
|
+
<ChevronDown size={12} className={`transition-transform ${showLanguageDropdown ? 'rotate-180' : ''}`} />
|
|
166
|
+
</button>
|
|
167
|
+
{showLanguageDropdown && (
|
|
168
|
+
<div className="absolute top-full left-0 mt-2 py-1 bg-dashboard-card border border-dashboard-border rounded-lg shadow-xl z-[9999] min-w-[160px] overflow-hidden">
|
|
169
|
+
<div className="px-3 py-2 text-[9px] text-neutral-500 uppercase tracking-wider border-b border-dashboard-border">
|
|
170
|
+
Beschikbare Talen
|
|
171
|
+
</div>
|
|
172
|
+
{languages.map(lang => (
|
|
173
|
+
<button
|
|
174
|
+
key={lang}
|
|
175
|
+
onClick={() => {
|
|
176
|
+
onLanguageChange(lang);
|
|
177
|
+
setShowLanguageDropdown(false);
|
|
178
|
+
}}
|
|
179
|
+
className={`w-full text-left px-3 py-2 text-[10px] uppercase tracking-wider transition-colors ${lang === currentLanguage
|
|
180
|
+
? 'bg-primary/10 text-primary font-bold'
|
|
181
|
+
: 'text-neutral-600 dark:text-neutral-400 hover:bg-dashboard-bg hover:text-dashboard-text'
|
|
182
|
+
}`}
|
|
183
|
+
>
|
|
184
|
+
{languageLabels[lang] || lang.toUpperCase()}
|
|
185
|
+
</button>
|
|
186
|
+
))}
|
|
187
|
+
{availableToAdd.length > 0 && onAddLanguage && (
|
|
188
|
+
<>
|
|
189
|
+
<div className="my-1 border-t border-dashboard-border" />
|
|
190
|
+
<div className="px-3 py-1 text-[9px] text-neutral-500 uppercase tracking-wider">
|
|
191
|
+
Add Language
|
|
192
|
+
</div>
|
|
193
|
+
{availableToAdd.slice(0, 3).map(lang => (
|
|
194
|
+
<button
|
|
195
|
+
key={lang}
|
|
196
|
+
onClick={() => {
|
|
197
|
+
onAddLanguage(lang);
|
|
198
|
+
setShowLanguageDropdown(false);
|
|
199
|
+
}}
|
|
200
|
+
className="w-full text-left px-3 py-2 text-[10px] uppercase tracking-wider text-neutral-600 dark:text-neutral-400 hover:bg-dashboard-bg hover:text-dashboard-text transition-colors"
|
|
201
|
+
>
|
|
202
|
+
+ {languageLabels[lang] || lang.toUpperCase()}
|
|
203
|
+
</button>
|
|
204
|
+
))}
|
|
205
|
+
</>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
134
212
|
</div>
|
|
135
213
|
|
|
136
214
|
<div className="flex items-center gap-4">
|
|
@@ -139,11 +217,10 @@ export function EditorHeader({
|
|
|
139
217
|
<div className="flex items-center gap-2">
|
|
140
218
|
<button
|
|
141
219
|
onClick={() => onAutoSaveToggle(!autoSaveEnabled)}
|
|
142
|
-
className={`relative flex items-center gap-2 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}`}
|
|
220
|
+
className={`relative flex items-center gap-2 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${autoSaveEnabled
|
|
221
|
+
? 'bg-primary/20 text-primary border border-primary/30'
|
|
222
|
+
: 'bg-dashboard-bg text-neutral-600 dark:text-neutral-400 border border-dashboard-border hover:text-neutral-950 dark:hover:text-white'
|
|
223
|
+
}`}
|
|
147
224
|
title={autoSaveEnabled ? 'Auto-save enabled (saves after 10s of inactivity)' : 'Click to enable auto-save'}
|
|
148
225
|
>
|
|
149
226
|
<Clock size={12} className={autoSaveEnabled && autoSaveStatus !== 'saving' ? 'animate-pulse' : ''} />
|
|
@@ -185,11 +262,10 @@ export function EditorHeader({
|
|
|
185
262
|
onPreviewToggle();
|
|
186
263
|
}
|
|
187
264
|
}}
|
|
188
|
-
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}`}
|
|
265
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${!isPreviewMode
|
|
266
|
+
? 'bg-primary text-white shadow-sm'
|
|
267
|
+
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
|
|
268
|
+
}`}
|
|
193
269
|
title="Edit mode - Make changes to your post"
|
|
194
270
|
>
|
|
195
271
|
<Edit size={12} strokeWidth={2.5} />
|
|
@@ -201,11 +277,10 @@ export function EditorHeader({
|
|
|
201
277
|
onPreviewToggle();
|
|
202
278
|
}
|
|
203
279
|
}}
|
|
204
|
-
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}`}
|
|
280
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] uppercase tracking-widest font-bold transition-all ${isPreviewMode
|
|
281
|
+
? 'bg-primary text-white shadow-sm'
|
|
282
|
+
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-950 dark:hover:text-white'
|
|
283
|
+
}`}
|
|
209
284
|
title="Preview mode - See how your post will look"
|
|
210
285
|
>
|
|
211
286
|
<Eye size={12} strokeWidth={2.5} />
|
|
@@ -262,7 +337,7 @@ export function EditorHeader({
|
|
|
262
337
|
saveAsDraft={saveAsDraft}
|
|
263
338
|
error={saveError}
|
|
264
339
|
/>
|
|
265
|
-
</
|
|
340
|
+
</div>
|
|
266
341
|
);
|
|
267
342
|
}
|
|
268
343
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
1
|
+
import { useEffect, useState, useRef } from 'react';
|
|
2
2
|
import { apiToBlogPost, type APIBlogDocument } from '../../../lib/mappers/apiMapper';
|
|
3
3
|
import type { BlogPost } from '../../../types/post';
|
|
4
4
|
|
|
@@ -6,9 +6,13 @@ export function usePostLoader(
|
|
|
6
6
|
postId: string | undefined,
|
|
7
7
|
currentPostId: string | null,
|
|
8
8
|
loadPost: (post: BlogPost) => void,
|
|
9
|
-
resetHeroBlock: () => void
|
|
9
|
+
resetHeroBlock: () => void,
|
|
10
|
+
language?: string
|
|
10
11
|
) {
|
|
11
12
|
const [isLoadingPost, setIsLoadingPost] = useState(false);
|
|
13
|
+
// Use a ref to track language so changes don't re-trigger initial load
|
|
14
|
+
const languageRef = useRef(language);
|
|
15
|
+
languageRef.current = language;
|
|
12
16
|
|
|
13
17
|
useEffect(() => {
|
|
14
18
|
if (postId && !currentPostId) {
|
|
@@ -17,7 +21,11 @@ export function usePostLoader(
|
|
|
17
21
|
setIsLoadingPost(true);
|
|
18
22
|
// Reset hero block before loading new post so it gets re-initialized from the new post's blocks
|
|
19
23
|
resetHeroBlock();
|
|
20
|
-
const
|
|
24
|
+
const lang = languageRef.current;
|
|
25
|
+
const url = lang
|
|
26
|
+
? `/api/plugin-blog/${postId}?language=${lang}`
|
|
27
|
+
: `/api/plugin-blog/${postId}`;
|
|
28
|
+
const response = await fetch(url);
|
|
21
29
|
if (!response.ok) {
|
|
22
30
|
throw new Error('Failed to load post');
|
|
23
31
|
}
|
|
@@ -33,7 +41,10 @@ export function usePostLoader(
|
|
|
33
41
|
};
|
|
34
42
|
loadPostData();
|
|
35
43
|
}
|
|
36
|
-
|
|
44
|
+
// Only re-run on initial load (postId / currentPostId change), NOT on language change
|
|
45
|
+
// Language switching is handled separately by handleLanguageChange
|
|
46
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
47
|
+
}, [postId, currentPostId]);
|
|
37
48
|
|
|
38
49
|
return { isLoadingPost };
|
|
39
50
|
}
|