create-nextblock 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
  3. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
  4. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
  5. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
  6. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
  7. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
  8. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
  9. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
  10. package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
  11. package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
  12. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
  13. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
  14. package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
  15. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
  16. package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
  17. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
  18. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
  19. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
  20. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
  21. package/templates/nextblock-template/app/lib/homepage.ts +36 -0
  22. package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
  23. package/templates/nextblock-template/app/page.tsx +55 -12
  24. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
  25. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
  26. package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
  27. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
  28. package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
  29. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
  30. package/templates/nextblock-template/lib/search/server.ts +11 -1
  31. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
  32. package/templates/nextblock-template/next-env.d.ts +1 -1
  33. package/templates/nextblock-template/package.json +1 -1
  34. 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 [{ data: storedKeyRow }, { data: selectedModelRow }, isPackageActive] = await Promise.all([
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
- * - `home` / `accueil` back the locale-resolved homepage served at "/"
34
- * (see getHomepageSlugForLocale in app/page.tsx); listing them as `/home`
35
- * and `/accueil` would duplicate the canonical "/" entry.
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', 'home', 'accueil']);
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) => page.slug && !EXCLUDED_PAGE_SLUGS.has(page.slug),
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
- async function getHomepageSlugForLocale(locale: string): Promise<string> {
33
- if (locale === 'fr') {
34
- return 'accueil';
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
- return 'home';
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 homepageSlug = await getHomepageSlugForLocale(preferredLocale);
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 homepageSlug = await getHomepageSlugForLocale(preferredLocale);
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