create-nextblock 0.13.1 → 0.13.3
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 +1 -1
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
- package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
- package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
- package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
- package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
- package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
- package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
- package/templates/nextblock-template/app/lib/homepage.ts +36 -0
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
- package/templates/nextblock-template/app/page.tsx +55 -12
- package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
- package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
- package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +14 -2
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
- package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
- package/templates/nextblock-template/lib/search/server.ts +11 -1
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +5 -0
|
@@ -6,16 +6,23 @@ import { redirect } from 'next/navigation';
|
|
|
6
6
|
import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
CORTEX_AI_AGENT_SETTINGS_DEFAULTS,
|
|
10
|
+
CORTEX_AI_AGENT_SETTINGS_KEY,
|
|
9
11
|
CORTEX_AI_OPENROUTER_MODEL_SELECTION_SETTING_KEY,
|
|
10
12
|
CORTEX_AI_OPENROUTER_SETTING_KEY,
|
|
11
13
|
CORTEX_AI_PACKAGE_ID,
|
|
14
|
+
CORTEX_AI_PEXELS_SETTING_KEY,
|
|
15
|
+
CORTEX_AI_UNSPLASH_APP_NAME_SETTING_KEY,
|
|
16
|
+
CORTEX_AI_UNSPLASH_SETTING_KEY,
|
|
12
17
|
createCortexAiStoredModelSelection,
|
|
13
18
|
encryptStoredOpenRouterApiKey,
|
|
14
19
|
getCortexAiEnvConfig,
|
|
15
20
|
getEnvOpenRouterKeyStatus,
|
|
16
21
|
getStoredOpenRouterKeyStatus,
|
|
17
22
|
listCortexAiCompatibleOpenRouterModels,
|
|
23
|
+
normalizeCortexAiAgentSettings,
|
|
18
24
|
safeParseCortexAiModelSelection,
|
|
25
|
+
type CortexAiAgentSettings,
|
|
19
26
|
type CortexAiStoredModelSelection,
|
|
20
27
|
} from '@nextblock-cms/cortex';
|
|
21
28
|
|
|
@@ -23,14 +30,24 @@ const CORTEX_AI_SETTINGS_PATH = '/cms/settings/cortex-ai';
|
|
|
23
30
|
|
|
24
31
|
type CortexAiSettingsStatus = {
|
|
25
32
|
activeKeySource: 'env' | 'stored' | 'none';
|
|
33
|
+
activeStockProvider: 'pexels' | 'unsplash' | null;
|
|
34
|
+
agentSettings: CortexAiAgentSettings;
|
|
26
35
|
hasEncryptionKey: boolean;
|
|
27
36
|
hasEnvOpenRouterKey: boolean;
|
|
37
|
+
hasEnvPexelsKey: boolean;
|
|
38
|
+
hasEnvUnsplashKey: boolean;
|
|
28
39
|
hasStoredOpenRouterKey: boolean;
|
|
40
|
+
hasStoredPexelsKey: boolean;
|
|
41
|
+
hasStoredUnsplashKey: boolean;
|
|
29
42
|
isPackageActive: boolean;
|
|
30
43
|
maskedEnvOpenRouterKey: string | null;
|
|
31
44
|
maskedStoredOpenRouterKey: string | null;
|
|
45
|
+
maskedStoredPexelsKey: string | null;
|
|
46
|
+
maskedStoredUnsplashKey: string | null;
|
|
32
47
|
selectedModel: CortexAiStoredModelSelection | null;
|
|
48
|
+
stockKeysUpdatedAt: string | null;
|
|
33
49
|
storedOpenRouterKeyUpdatedAt: string | null;
|
|
50
|
+
unsplashAppName: string | null;
|
|
34
51
|
};
|
|
35
52
|
|
|
36
53
|
function redirectWithStatus(status: 'success' | 'error', message: string): never {
|
|
@@ -66,7 +83,15 @@ export async function getCortexAiSettingsStatus(): Promise<CortexAiSettingsStatu
|
|
|
66
83
|
const env = getCortexAiEnvConfig();
|
|
67
84
|
const envKeyStatus = getEnvOpenRouterKeyStatus();
|
|
68
85
|
|
|
69
|
-
const [
|
|
86
|
+
const [
|
|
87
|
+
{ data: storedKeyRow },
|
|
88
|
+
{ data: selectedModelRow },
|
|
89
|
+
{ data: storedPexelsRow },
|
|
90
|
+
{ data: storedUnsplashRow },
|
|
91
|
+
{ data: unsplashAppNameRow },
|
|
92
|
+
{ data: agentSettingsRow },
|
|
93
|
+
isPackageActive,
|
|
94
|
+
] = await Promise.all([
|
|
70
95
|
supabase
|
|
71
96
|
.from('site_settings')
|
|
72
97
|
.select('value')
|
|
@@ -77,11 +102,44 @@ export async function getCortexAiSettingsStatus(): Promise<CortexAiSettingsStatu
|
|
|
77
102
|
.select('value')
|
|
78
103
|
.eq('key', CORTEX_AI_OPENROUTER_MODEL_SELECTION_SETTING_KEY)
|
|
79
104
|
.maybeSingle(),
|
|
105
|
+
supabase
|
|
106
|
+
.from('site_settings')
|
|
107
|
+
.select('value')
|
|
108
|
+
.eq('key', CORTEX_AI_PEXELS_SETTING_KEY)
|
|
109
|
+
.maybeSingle(),
|
|
110
|
+
supabase
|
|
111
|
+
.from('site_settings')
|
|
112
|
+
.select('value')
|
|
113
|
+
.eq('key', CORTEX_AI_UNSPLASH_SETTING_KEY)
|
|
114
|
+
.maybeSingle(),
|
|
115
|
+
supabase
|
|
116
|
+
.from('site_settings')
|
|
117
|
+
.select('value')
|
|
118
|
+
.eq('key', CORTEX_AI_UNSPLASH_APP_NAME_SETTING_KEY)
|
|
119
|
+
.maybeSingle(),
|
|
120
|
+
supabase
|
|
121
|
+
.from('site_settings')
|
|
122
|
+
.select('value')
|
|
123
|
+
.eq('key', CORTEX_AI_AGENT_SETTINGS_KEY)
|
|
124
|
+
.maybeSingle(),
|
|
80
125
|
verifyPackageOnline(CORTEX_AI_PACKAGE_ID).catch(() => false),
|
|
81
126
|
]);
|
|
82
127
|
|
|
83
128
|
const storedKeyStatus = getStoredOpenRouterKeyStatus(storedKeyRow?.value);
|
|
84
129
|
const selectedModel = safeParseCortexAiModelSelection(selectedModelRow?.value);
|
|
130
|
+
const pexelsStatus = getStoredOpenRouterKeyStatus(storedPexelsRow?.value);
|
|
131
|
+
const unsplashStatus = getStoredOpenRouterKeyStatus(storedUnsplashRow?.value);
|
|
132
|
+
const hasEnvPexelsKey = Boolean(process.env.PEXELS_API_KEY?.trim());
|
|
133
|
+
const hasEnvUnsplashKey = Boolean(process.env.UNSPLASH_ACCESS_KEY?.trim());
|
|
134
|
+
const activeStockProvider: 'pexels' | 'unsplash' | null = pexelsStatus.hasStoredKey
|
|
135
|
+
? 'pexels'
|
|
136
|
+
: unsplashStatus.hasStoredKey
|
|
137
|
+
? 'unsplash'
|
|
138
|
+
: hasEnvPexelsKey
|
|
139
|
+
? 'pexels'
|
|
140
|
+
: hasEnvUnsplashKey
|
|
141
|
+
? 'unsplash'
|
|
142
|
+
: null;
|
|
85
143
|
|
|
86
144
|
return {
|
|
87
145
|
activeKeySource: storedKeyStatus.hasStoredKey
|
|
@@ -89,14 +147,27 @@ export async function getCortexAiSettingsStatus(): Promise<CortexAiSettingsStatu
|
|
|
89
147
|
: env.hasOpenRouterEnvKey
|
|
90
148
|
? 'env'
|
|
91
149
|
: 'none',
|
|
150
|
+
activeStockProvider,
|
|
92
151
|
hasEncryptionKey: env.hasEncryptionKey,
|
|
93
152
|
hasEnvOpenRouterKey: env.hasOpenRouterEnvKey,
|
|
153
|
+
hasEnvPexelsKey,
|
|
154
|
+
hasEnvUnsplashKey,
|
|
94
155
|
hasStoredOpenRouterKey: storedKeyStatus.hasStoredKey,
|
|
156
|
+
hasStoredPexelsKey: pexelsStatus.hasStoredKey,
|
|
157
|
+
hasStoredUnsplashKey: unsplashStatus.hasStoredKey,
|
|
95
158
|
isPackageActive,
|
|
96
159
|
maskedEnvOpenRouterKey: envKeyStatus.maskedEnvOpenRouterKey,
|
|
97
160
|
maskedStoredOpenRouterKey: storedKeyStatus.maskedKey,
|
|
161
|
+
maskedStoredPexelsKey: pexelsStatus.maskedKey,
|
|
162
|
+
maskedStoredUnsplashKey: unsplashStatus.maskedKey,
|
|
163
|
+
agentSettings: normalizeCortexAiAgentSettings(agentSettingsRow?.value),
|
|
98
164
|
selectedModel: storedKeyStatus.hasStoredKey ? selectedModel : null,
|
|
165
|
+
stockKeysUpdatedAt: pexelsStatus.updatedAt || unsplashStatus.updatedAt || null,
|
|
99
166
|
storedOpenRouterKeyUpdatedAt: storedKeyStatus.updatedAt,
|
|
167
|
+
unsplashAppName:
|
|
168
|
+
typeof unsplashAppNameRow?.value === 'string' && unsplashAppNameRow.value.trim()
|
|
169
|
+
? unsplashAppNameRow.value.trim()
|
|
170
|
+
: null,
|
|
100
171
|
};
|
|
101
172
|
}
|
|
102
173
|
|
|
@@ -162,6 +233,154 @@ export async function clearOpenRouterApiKeyAction() {
|
|
|
162
233
|
redirectWithStatus('success', 'Stored OpenRouter key cleared.');
|
|
163
234
|
}
|
|
164
235
|
|
|
236
|
+
export async function saveStockPhotoKeysAction(formData: FormData) {
|
|
237
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
238
|
+
throw new Error('Sandbox environment cannot save keys to the database.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const supabase = await requireAdminSupabaseClient();
|
|
243
|
+
const pexelsKey = String(formData.get('pexels_api_key') || '').trim();
|
|
244
|
+
const unsplashKey = String(formData.get('unsplash_access_key') || '').trim();
|
|
245
|
+
const unsplashAppName = String(formData.get('unsplash_app_name') || '').trim();
|
|
246
|
+
|
|
247
|
+
if (!pexelsKey && !unsplashKey && !unsplashAppName) {
|
|
248
|
+
throw new Error('Enter a Pexels or Unsplash API key, or an Unsplash app name.');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const rows: Array<{ key: string; value: unknown }> = [];
|
|
252
|
+
|
|
253
|
+
if (pexelsKey) {
|
|
254
|
+
rows.push({
|
|
255
|
+
key: CORTEX_AI_PEXELS_SETTING_KEY,
|
|
256
|
+
value: encryptStoredOpenRouterApiKey(pexelsKey),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (unsplashKey) {
|
|
261
|
+
rows.push({
|
|
262
|
+
key: CORTEX_AI_UNSPLASH_SETTING_KEY,
|
|
263
|
+
value: encryptStoredOpenRouterApiKey(unsplashKey),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Non-secret: the registered Unsplash app name for attribution utm_source.
|
|
268
|
+
if (unsplashAppName) {
|
|
269
|
+
rows.push({ key: CORTEX_AI_UNSPLASH_APP_NAME_SETTING_KEY, value: unsplashAppName });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const { error } = await supabase.from('site_settings').upsert(rows);
|
|
273
|
+
|
|
274
|
+
if (error) {
|
|
275
|
+
throw new Error(error.message);
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
redirectWithStatus(
|
|
279
|
+
'error',
|
|
280
|
+
error instanceof Error ? error.message : 'Failed to save stock photo keys.'
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
revalidatePath(CORTEX_AI_SETTINGS_PATH);
|
|
285
|
+
redirectWithStatus('success', 'Stock photo API key saved.');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function clearStockPhotoKeysAction() {
|
|
289
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
290
|
+
throw new Error('Sandbox environment cannot clear keys from the database.');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const supabase = await requireAdminSupabaseClient();
|
|
295
|
+
const { error } = await supabase
|
|
296
|
+
.from('site_settings')
|
|
297
|
+
.delete()
|
|
298
|
+
.in('key', [CORTEX_AI_PEXELS_SETTING_KEY, CORTEX_AI_UNSPLASH_SETTING_KEY]);
|
|
299
|
+
|
|
300
|
+
if (error) {
|
|
301
|
+
throw new Error(error.message);
|
|
302
|
+
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
redirectWithStatus(
|
|
305
|
+
'error',
|
|
306
|
+
error instanceof Error ? error.message : 'Failed to clear stock photo keys.'
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
revalidatePath(CORTEX_AI_SETTINGS_PATH);
|
|
311
|
+
redirectWithStatus('success', 'Stock photo API keys cleared.');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function saveCortexAiAgentSettingsAction(formData: FormData) {
|
|
315
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
316
|
+
throw new Error('Sandbox environment cannot save settings to the database.');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const supabase = await requireAdminSupabaseClient();
|
|
321
|
+
const unlimited = String(formData.get('max_output_unlimited') || '') === 'on';
|
|
322
|
+
const rawMaxOutput = String(formData.get('max_output_tokens') || '').trim();
|
|
323
|
+
const rawTimeoutSeconds = Number(formData.get('response_timeout_seconds'));
|
|
324
|
+
|
|
325
|
+
// normalizeCortexAiAgentSettings clamps everything to safe bounds.
|
|
326
|
+
const settings = normalizeCortexAiAgentSettings({
|
|
327
|
+
maxOutputTokens: unlimited
|
|
328
|
+
? null
|
|
329
|
+
: rawMaxOutput
|
|
330
|
+
? Number(rawMaxOutput)
|
|
331
|
+
: CORTEX_AI_AGENT_SETTINGS_DEFAULTS.maxOutputTokens,
|
|
332
|
+
maxSteps: Number(formData.get('max_steps')),
|
|
333
|
+
responseTimeoutMs: Number.isFinite(rawTimeoutSeconds)
|
|
334
|
+
? rawTimeoutSeconds * 1000
|
|
335
|
+
: CORTEX_AI_AGENT_SETTINGS_DEFAULTS.responseTimeoutMs,
|
|
336
|
+
temperature: Number(formData.get('temperature')),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const { error } = await supabase.from('site_settings').upsert({
|
|
340
|
+
key: CORTEX_AI_AGENT_SETTINGS_KEY,
|
|
341
|
+
value: settings,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (error) {
|
|
345
|
+
throw new Error(error.message);
|
|
346
|
+
}
|
|
347
|
+
} catch (error) {
|
|
348
|
+
redirectWithStatus(
|
|
349
|
+
'error',
|
|
350
|
+
error instanceof Error ? error.message : 'Failed to save advanced settings.'
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
revalidatePath(CORTEX_AI_SETTINGS_PATH);
|
|
355
|
+
redirectWithStatus('success', 'Advanced agent settings saved.');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function resetCortexAiAgentSettingsAction() {
|
|
359
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
360
|
+
throw new Error('Sandbox environment cannot change settings in the database.');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const supabase = await requireAdminSupabaseClient();
|
|
365
|
+
const { error } = await supabase
|
|
366
|
+
.from('site_settings')
|
|
367
|
+
.delete()
|
|
368
|
+
.eq('key', CORTEX_AI_AGENT_SETTINGS_KEY);
|
|
369
|
+
|
|
370
|
+
if (error) {
|
|
371
|
+
throw new Error(error.message);
|
|
372
|
+
}
|
|
373
|
+
} catch (error) {
|
|
374
|
+
redirectWithStatus(
|
|
375
|
+
'error',
|
|
376
|
+
error instanceof Error ? error.message : 'Failed to reset advanced settings.'
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
revalidatePath(CORTEX_AI_SETTINGS_PATH);
|
|
381
|
+
redirectWithStatus('success', 'Advanced agent settings reset to defaults.');
|
|
382
|
+
}
|
|
383
|
+
|
|
165
384
|
export async function saveCortexAiModelSelectionAction(formData: FormData) {
|
|
166
385
|
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
167
386
|
throw new Error('Sandbox environment cannot save model selection to the database.');
|
|
@@ -36,6 +36,7 @@ export default async function CortexAiSettingsPage({
|
|
|
36
36
|
: {};
|
|
37
37
|
const storedKeyUpdatedAt = formatDate(status.storedOpenRouterKeyUpdatedAt);
|
|
38
38
|
const selectedModelUpdatedAt = formatDate(status.selectedModel?.updatedAt || null);
|
|
39
|
+
const stockKeysUpdatedAt = formatDate(status.stockKeysUpdatedAt);
|
|
39
40
|
let compatibleModels: Awaited<ReturnType<typeof listCortexAiCompatibleOpenRouterModels>> = [];
|
|
40
41
|
let modelCatalogError: string | null = null;
|
|
41
42
|
|
|
@@ -73,6 +74,16 @@ export default async function CortexAiSettingsPage({
|
|
|
73
74
|
selectedModelUpdatedAt={selectedModelUpdatedAt}
|
|
74
75
|
hasEncryptionKey={status.hasEncryptionKey}
|
|
75
76
|
modelCatalogError={modelCatalogError}
|
|
77
|
+
activeStockProvider={status.activeStockProvider}
|
|
78
|
+
hasStoredPexelsKey={status.hasStoredPexelsKey}
|
|
79
|
+
maskedStoredPexelsKey={status.maskedStoredPexelsKey}
|
|
80
|
+
hasStoredUnsplashKey={status.hasStoredUnsplashKey}
|
|
81
|
+
maskedStoredUnsplashKey={status.maskedStoredUnsplashKey}
|
|
82
|
+
hasEnvPexelsKey={status.hasEnvPexelsKey}
|
|
83
|
+
hasEnvUnsplashKey={status.hasEnvUnsplashKey}
|
|
84
|
+
stockKeysUpdatedAt={stockKeysUpdatedAt}
|
|
85
|
+
unsplashAppName={status.unsplashAppName}
|
|
86
|
+
agentSettings={status.agentSettings}
|
|
76
87
|
successMessage={params.success}
|
|
77
88
|
errorMessage={params.error}
|
|
78
89
|
/>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import type { Database } from '@nextblock-cms/db';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The homepage is the default-language page at slug "home". Every one of its
|
|
6
|
+
* translation-group siblings — whatever slug each one uses (e.g. "accueil",
|
|
7
|
+
* "startseite", "inicio") — is ALSO served at "/". This returns that shared
|
|
8
|
+
* `translation_group_id` so callers can recognise every language variation of
|
|
9
|
+
* the homepage instead of hardcoding per-locale slugs.
|
|
10
|
+
*
|
|
11
|
+
* Returns `null` when there is no default-language "home" page (e.g. a fresh
|
|
12
|
+
* install), in which case callers should fall back to their prior slug-literal
|
|
13
|
+
* behaviour.
|
|
14
|
+
*/
|
|
15
|
+
export async function getHomepageTranslationGroupId(
|
|
16
|
+
supabase: SupabaseClient<Database>,
|
|
17
|
+
): Promise<string | null> {
|
|
18
|
+
const { data: defaultLang } = await supabase
|
|
19
|
+
.from('languages')
|
|
20
|
+
.select('id')
|
|
21
|
+
.eq('is_default', true)
|
|
22
|
+
.maybeSingle();
|
|
23
|
+
|
|
24
|
+
if (!defaultLang) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { data: home } = await supabase
|
|
29
|
+
.from('pages')
|
|
30
|
+
.select('translation_group_id')
|
|
31
|
+
.eq('slug', 'home')
|
|
32
|
+
.eq('language_id', defaultLang.id)
|
|
33
|
+
.maybeSingle();
|
|
34
|
+
|
|
35
|
+
return home?.translation_group_id ?? null;
|
|
36
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getSsgSupabaseClient } from '@nextblock-cms/db/server';
|
|
2
|
+
import { getHomepageTranslationGroupId } from './homepage';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* A single, language-aware entry destined for the XML sitemap.
|
|
@@ -30,11 +31,13 @@ export interface SitemapEntry {
|
|
|
30
31
|
* re-advertised under the generic `/{slug}` catch-all:
|
|
31
32
|
* - `product-template` backs the product layout (app/product/[slug]); it is
|
|
32
33
|
* not a public page.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
*
|
|
35
|
+
* The homepage and its translations are excluded separately, by translation
|
|
36
|
+
* group (see `getHomepageTranslationGroupId`), because every language variation
|
|
37
|
+
* of the homepage is served at "/" regardless of the slug it uses — listing it
|
|
38
|
+
* under `/{slug}` would duplicate the canonical "/" entry.
|
|
36
39
|
*/
|
|
37
|
-
const EXCLUDED_PAGE_SLUGS = new Set(['product-template'
|
|
40
|
+
const EXCLUDED_PAGE_SLUGS = new Set(['product-template']);
|
|
38
41
|
|
|
39
42
|
type SupabaseLikeClient = ReturnType<typeof getSsgSupabaseClient>;
|
|
40
43
|
|
|
@@ -157,12 +160,13 @@ function rowsToEntries(
|
|
|
157
160
|
export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
|
|
158
161
|
const supabase = getSsgSupabaseClient();
|
|
159
162
|
try {
|
|
160
|
-
const [{ data: pages, error }, languageMap] = await Promise.all([
|
|
163
|
+
const [{ data: pages, error }, languageMap, homepageGroupId] = await Promise.all([
|
|
161
164
|
supabase
|
|
162
165
|
.from('pages')
|
|
163
166
|
.select('slug, updated_at, language_id, translation_group_id')
|
|
164
167
|
.eq('status', 'published'),
|
|
165
168
|
fetchLanguageMap(supabase),
|
|
169
|
+
getHomepageTranslationGroupId(supabase),
|
|
166
170
|
]);
|
|
167
171
|
|
|
168
172
|
if (error) {
|
|
@@ -171,7 +175,10 @@ export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
|
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
const rows = (pages ?? []).filter(
|
|
174
|
-
(page) =>
|
|
178
|
+
(page) =>
|
|
179
|
+
page.slug &&
|
|
180
|
+
!EXCLUDED_PAGE_SLUGS.has(page.slug) &&
|
|
181
|
+
!(homepageGroupId && page.translation_group_id === homepageGroupId),
|
|
175
182
|
);
|
|
176
183
|
|
|
177
184
|
return rowsToEntries(
|
|
@@ -2,7 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { cookies, draftMode, headers } from 'next/headers';
|
|
3
3
|
import { notFound } from 'next/navigation';
|
|
4
4
|
import type { Metadata } from 'next';
|
|
5
|
-
import { getSsgSupabaseClient } from '@nextblock-cms/db/server';
|
|
5
|
+
import { createClient, getSsgSupabaseClient } from '@nextblock-cms/db/server';
|
|
6
6
|
import PageClientContent from './[slug]/PageClientContent';
|
|
7
7
|
import { getPageDataBySlug } from './[slug]/page.utils';
|
|
8
8
|
import BlockRenderer from '../components/BlockRenderer';
|
|
@@ -29,12 +29,57 @@ interface PageTranslation {
|
|
|
29
29
|
}[];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// Resolve the homepage for a given locale WITHOUT assuming a per-locale slug.
|
|
33
|
+
// The homepage is, by convention, the default-language page at slug "home".
|
|
34
|
+
// Its translated versions may use ANY slug (e.g. "accueil"), so we find the
|
|
35
|
+
// localized version through the shared translation group. This lets "/" serve
|
|
36
|
+
// every language variation of the homepage regardless of what slug it uses.
|
|
37
|
+
async function resolveHomepageData(preferredLocale: string) {
|
|
38
|
+
const defaultHome = await getPageDataBySlug('home', DEFAULT_LOCALE);
|
|
39
|
+
|
|
40
|
+
// Default locale (or a homepage with no linked translations): serve it directly.
|
|
41
|
+
if (defaultHome && (preferredLocale === DEFAULT_LOCALE || !defaultHome.translation_group_id)) {
|
|
42
|
+
return defaultHome;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
|
|
45
|
+
// Resolve the localized homepage via the shared translation group (any slug),
|
|
46
|
+
// so "/" serves every language variation regardless of the slug it uses. In
|
|
47
|
+
// draft (preview) mode we include unpublished siblings; otherwise only published.
|
|
48
|
+
if (defaultHome?.translation_group_id) {
|
|
49
|
+
const draft = await draftMode();
|
|
50
|
+
const supabase = draft.isEnabled ? createClient() : getSsgSupabaseClient();
|
|
51
|
+
let siblingQuery = supabase
|
|
52
|
+
.from('pages')
|
|
53
|
+
.select('slug, languages!inner(code)')
|
|
54
|
+
.eq('translation_group_id', defaultHome.translation_group_id)
|
|
55
|
+
.eq('languages.code', preferredLocale)
|
|
56
|
+
.limit(1);
|
|
57
|
+
|
|
58
|
+
if (!draft.isEnabled) {
|
|
59
|
+
siblingQuery = siblingQuery.eq('status', 'published');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { data: sibling } = await siblingQuery.maybeSingle();
|
|
63
|
+
const localizedSlug = (sibling as { slug?: string } | null)?.slug;
|
|
64
|
+
if (localizedSlug) {
|
|
65
|
+
const localized = await getPageDataBySlug(localizedSlug, preferredLocale);
|
|
66
|
+
if (localized) {
|
|
67
|
+
return localized;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fallbacks: the preferred locale's own "home" slug (covers a missing or
|
|
73
|
+
// renamed default-language home that the group lookup couldn't anchor on),
|
|
74
|
+
// then the default home. Either may be null — the caller renders notFound().
|
|
75
|
+
if (preferredLocale !== DEFAULT_LOCALE) {
|
|
76
|
+
const localizedHome = await getPageDataBySlug('home', preferredLocale);
|
|
77
|
+
if (localizedHome) {
|
|
78
|
+
return localizedHome;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return defaultHome;
|
|
38
83
|
}
|
|
39
84
|
|
|
40
85
|
async function getPreferredLocale() {
|
|
@@ -71,8 +116,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
71
116
|
}
|
|
72
117
|
|
|
73
118
|
const preferredLocale = await getPreferredLocale();
|
|
74
|
-
const
|
|
75
|
-
const pageData = await getPageDataBySlug(homepageSlug, preferredLocale);
|
|
119
|
+
const pageData = await resolveHomepageData(preferredLocale);
|
|
76
120
|
|
|
77
121
|
if (!pageData) {
|
|
78
122
|
return { title: 'Homepage Not Found' };
|
|
@@ -128,16 +172,15 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
128
172
|
|
|
129
173
|
export default async function RootPage() {
|
|
130
174
|
const preferredLocale = await getPreferredLocale();
|
|
131
|
-
const
|
|
132
|
-
const pageData = await getPageDataBySlug(homepageSlug, preferredLocale);
|
|
175
|
+
const pageData = await resolveHomepageData(preferredLocale);
|
|
133
176
|
|
|
134
177
|
if (!pageData) {
|
|
135
|
-
console.error(
|
|
136
|
-
`Homepage data not found for slug: ${homepageSlug} (locale: ${preferredLocale})`
|
|
137
|
-
);
|
|
178
|
+
console.error(`Homepage data not found (locale: ${preferredLocale})`);
|
|
138
179
|
notFound();
|
|
139
180
|
}
|
|
140
181
|
|
|
182
|
+
const homepageSlug = pageData.slug;
|
|
183
|
+
|
|
141
184
|
const translatedSlugs: { [key: string]: string } = {};
|
|
142
185
|
if (pageData.translation_group_id) {
|
|
143
186
|
const supabase = getSsgSupabaseClient();
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import Image from "next/image";
|
|
3
3
|
import type { VisualEditAttributes } from "../../../lib/visual-editing/types";
|
|
4
|
+
import { StockPhotoCredit, isStockPhotoCreditCaption, type StockPhotoAttribution } from "./StockPhotoCredit";
|
|
4
5
|
|
|
5
6
|
export type ImageBlockContent = {
|
|
6
7
|
media_id: string | null;
|
|
7
8
|
object_key: string | null;
|
|
9
|
+
external_url?: string | null;
|
|
10
|
+
attribution?: StockPhotoAttribution | null;
|
|
8
11
|
alt_text: string | null;
|
|
9
12
|
caption: string | null;
|
|
10
13
|
width: number | null;
|
|
@@ -12,6 +15,10 @@ export type ImageBlockContent = {
|
|
|
12
15
|
blur_data_url: string | null;
|
|
13
16
|
};
|
|
14
17
|
|
|
18
|
+
function isRenderableExternalImageUrl(value: unknown): value is string {
|
|
19
|
+
return typeof value === "string" && /^https?:\/\//i.test(value.trim());
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || "";
|
|
16
23
|
|
|
17
24
|
interface ImageBlockRendererProps {
|
|
@@ -28,6 +35,55 @@ const ImageBlockRenderer: React.FC<ImageBlockRendererProps> = ({
|
|
|
28
35
|
visualEditAttributes,
|
|
29
36
|
}) => {
|
|
30
37
|
void languageId;
|
|
38
|
+
|
|
39
|
+
// External URL (e.g. a stock photo the AI inserted). Rendered with a plain
|
|
40
|
+
// <img> so it works for any allowlisted https host without Next image
|
|
41
|
+
// remotePatterns config. The caption/figure structure matches the R2 path.
|
|
42
|
+
if (isRenderableExternalImageUrl(content.external_url)) {
|
|
43
|
+
const hasDimensions =
|
|
44
|
+
typeof content.width === "number" &&
|
|
45
|
+
typeof content.height === "number" &&
|
|
46
|
+
content.width > 0 &&
|
|
47
|
+
content.height > 0;
|
|
48
|
+
|
|
49
|
+
// Don't render a caption that merely repeats the attribution credit — the
|
|
50
|
+
// StockPhotoCredit below already renders "Photo by … on {Provider}". (Stock
|
|
51
|
+
// photos historically stored the credit string in `caption` too, which showed
|
|
52
|
+
// the same line twice.)
|
|
53
|
+
const captionText = content.caption?.trim() ?? "";
|
|
54
|
+
const showCaption =
|
|
55
|
+
captionText.length > 0 && !isStockPhotoCreditCaption(captionText, content.attribution);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="w-full" {...visualEditAttributes}>
|
|
59
|
+
<figure className="my-6 text-center mx-auto max-w-full">
|
|
60
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
61
|
+
<img
|
|
62
|
+
src={content.external_url as string}
|
|
63
|
+
alt={content.alt_text || ""}
|
|
64
|
+
{...(hasDimensions
|
|
65
|
+
? { width: content.width as number, height: content.height as number }
|
|
66
|
+
: {})}
|
|
67
|
+
loading={priority ? "eager" : "lazy"}
|
|
68
|
+
decoding="async"
|
|
69
|
+
className="rounded-md border max-w-full h-auto mx-auto"
|
|
70
|
+
/>
|
|
71
|
+
{showCaption && (
|
|
72
|
+
<figcaption className="text-sm text-muted-foreground mt-2">
|
|
73
|
+
{content.caption}
|
|
74
|
+
</figcaption>
|
|
75
|
+
)}
|
|
76
|
+
{content.attribution && (
|
|
77
|
+
<StockPhotoCredit
|
|
78
|
+
attribution={content.attribution}
|
|
79
|
+
className="mt-1 block text-xs text-muted-foreground"
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
</figure>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
31
87
|
if (!content.media_id || !content.object_key) {
|
|
32
88
|
return (
|
|
33
89
|
<div
|