create-nextblock 0.12.16 → 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 (45) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/actions/interactions.ts +27 -4
  3. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
  5. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
  6. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
  7. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
  8. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
  9. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
  10. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
  11. package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
  12. package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +40 -2
  13. package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -0
  14. package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +138 -71
  15. package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
  16. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
  17. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
  18. package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
  19. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
  20. package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
  21. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
  22. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
  23. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
  24. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
  25. package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +20 -1
  26. package/templates/nextblock-template/app/cms/users/actions.ts +69 -0
  27. package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -0
  28. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +4 -1
  29. package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -0
  30. package/templates/nextblock-template/app/cms/users/page.tsx +12 -3
  31. package/templates/nextblock-template/app/lib/homepage.ts +36 -0
  32. package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
  33. package/templates/nextblock-template/app/page.tsx +55 -12
  34. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
  35. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
  36. package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
  37. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
  38. package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
  39. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
  40. package/templates/nextblock-template/lib/search/server.ts +11 -1
  41. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
  42. package/templates/nextblock-template/next-env.d.ts +1 -1
  43. package/templates/nextblock-template/package.json +1 -1
  44. package/templates/nextblock-template/proxy.ts +5 -0
  45. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,44 @@
1
+ // app/cms/users/new/page.tsx
2
+ import Link from "next/link";
3
+ import { ArrowLeft } from "lucide-react";
4
+ import { Button } from "@nextblock-cms/ui";
5
+ import { createClient } from "@nextblock-cms/db/server";
6
+ import CreateUserForm from "../components/CreateUserForm";
7
+
8
+ export default async function NewUserPage() {
9
+ const supabase = createClient();
10
+ const { data: { user: currentAdmin } } = await supabase.auth.getUser();
11
+
12
+ if (!currentAdmin) {
13
+ return <p>Access Denied. Not authenticated.</p>;
14
+ }
15
+
16
+ // User management is admin-only (mirrors the users list page). The CMS layout already
17
+ // gates ADMIN/WRITER; this blocks writers and direct-access attempts.
18
+ const { data: adminProfile } = await supabase
19
+ .from("profiles")
20
+ .select("role")
21
+ .eq("id", currentAdmin.id)
22
+ .single();
23
+ if (adminProfile?.role !== "ADMIN") {
24
+ return <p>Access Denied. Admin privileges required.</p>;
25
+ }
26
+
27
+ return (
28
+ <div className="max-w-xl mx-auto">
29
+ <div className="flex items-center gap-3 mb-6">
30
+ <Button variant="outline" size="icon" aria-label="Back to users" asChild>
31
+ <Link href="/cms/users">
32
+ <ArrowLeft className="h-4 w-4" />
33
+ </Link>
34
+ </Button>
35
+ <h1 className="text-2xl font-bold">Create User</h1>
36
+ </div>
37
+ <p className="text-sm text-muted-foreground mb-6">
38
+ Create an account directly. After creating, you can fill in the profile, addresses,
39
+ and avatar on the next screen.
40
+ </p>
41
+ <CreateUserForm />
42
+ </div>
43
+ );
44
+ }
@@ -11,7 +11,7 @@ import {
11
11
  TableRow,
12
12
  } from "@nextblock-cms/ui";
13
13
  import { Badge } from "@nextblock-cms/ui";
14
- import { MoreHorizontal, Edit3, Users } from "lucide-react";
14
+ import { MoreHorizontal, Edit3, Users, PlusCircle } from "lucide-react";
15
15
  import {
16
16
  DropdownMenu,
17
17
  DropdownMenuContent,
@@ -110,7 +110,11 @@ export default async function CmsUsersListPage() {
110
110
  <div className="w-full">
111
111
  <div className="flex justify-between items-center mb-6">
112
112
  <h1 className="text-2xl font-semibold">Manage Users</h1>
113
- {/* No "Create New User" button as users are created via sign-up flow. Admins manage roles. */}
113
+ <Button asChild>
114
+ <Link href="/cms/users/new" className="flex items-center">
115
+ <PlusCircle className="mr-2 h-4 w-4" /> Create User
116
+ </Link>
117
+ </Button>
114
118
  </div>
115
119
 
116
120
  {users.length === 0 ? (
@@ -118,8 +122,13 @@ export default async function CmsUsersListPage() {
118
122
  <Users className="mx-auto h-12 w-12 text-muted-foreground" />
119
123
  <h3 className="mt-2 text-sm font-medium text-foreground">No other users found</h3>
120
124
  <p className="mt-1 text-sm text-muted-foreground">
121
- New users will appear here after they sign up.
125
+ Create a user, or new users will appear here after they sign up.
122
126
  </p>
127
+ <Button asChild className="mt-4">
128
+ <Link href="/cms/users/new" className="flex items-center">
129
+ <PlusCircle className="mr-2 h-4 w-4" /> Create User
130
+ </Link>
131
+ </Button>
123
132
  </div>
124
133
  ) : (
125
134
  <div className="rounded-lg border overflow-hidden">
@@ -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
@@ -18,6 +18,7 @@ import TextBlockRenderer from "./TextBlockRenderer";
18
18
  import HeadingBlockRenderer from "./HeadingBlockRenderer";
19
19
  import ImageBlockRenderer from "./ImageBlockRenderer";
20
20
  import ButtonBlockRenderer from "./ButtonBlockRenderer";
21
+ import { StockPhotoCredit } from "./StockPhotoCredit";
21
22
 
22
23
  const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || "";
23
24
  const BACKGROUND_COMPOSITING_CLASSES =
@@ -38,6 +39,57 @@ function loadEcommerceBlockRenderer(blockType: string) {
38
39
  );
39
40
  }
40
41
 
42
+ type SectionBackgroundImage = NonNullable<
43
+ NonNullable<SectionBlockContent["background"]>["image"]
44
+ >;
45
+
46
+ // Renders a section's background image. External https URLs (e.g. AI-inserted
47
+ // stock photos) use a plain <img> so any allowlisted host works without Next
48
+ // image remotePatterns config; stored R2 media keeps the optimized next/image
49
+ // path. Returns null when neither a URL nor an object key is present.
50
+ function renderBackgroundImageElement(image: SectionBackgroundImage, priority: boolean) {
51
+ const externalUrl =
52
+ typeof image.external_url === "string" && /^https?:\/\//i.test(image.external_url.trim())
53
+ ? image.external_url.trim()
54
+ : null;
55
+ const objectFit: "cover" | "contain" = image.size === "contain" ? "contain" : "cover";
56
+ const objectPosition = image.position || "center";
57
+
58
+ if (externalUrl) {
59
+ return (
60
+ // eslint-disable-next-line @next/next/no-img-element
61
+ <img
62
+ src={externalUrl}
63
+ alt={image.alt_text || ""}
64
+ loading={priority ? "eager" : "lazy"}
65
+ fetchPriority={priority ? "high" : "auto"}
66
+ decoding="async"
67
+ className="absolute inset-0 h-full w-full"
68
+ style={{ objectFit, objectPosition }}
69
+ />
70
+ );
71
+ }
72
+
73
+ if (!image.object_key) {
74
+ return null;
75
+ }
76
+
77
+ return (
78
+ <Image
79
+ src={`${R2_BASE_URL}/${image.object_key}`}
80
+ alt={image.alt_text || ""}
81
+ fill
82
+ priority={priority}
83
+ fetchPriority={priority ? "high" : "auto"}
84
+ placeholder={image.blur_data_url ? "blur" : "empty"}
85
+ blurDataURL={image.blur_data_url || undefined}
86
+ quality={image.quality || 80}
87
+ sizes="100vw"
88
+ style={{ objectFit, objectPosition }}
89
+ />
90
+ );
91
+ }
92
+
41
93
  interface SectionBlockRendererProps {
42
94
  content: SectionBlockContent;
43
95
  languageId: number;
@@ -410,21 +462,7 @@ export default async function SectionBlockRenderer({
410
462
  {/* Background image Layer for slide */}
411
463
  {slideBackground.type === 'image' && slideBackground.image && (
412
464
  <div className={ABSOLUTE_BACKGROUND_CLASSES}>
413
- <Image
414
- src={`${R2_BASE_URL}/${slideBackground.image.object_key}`}
415
- alt={slideBackground.image.alt_text || ""}
416
- fill
417
- priority={slidePriority}
418
- fetchPriority={slidePriority ? "high" : "auto"}
419
- placeholder={slideBackground.image.blur_data_url ? "blur" : "empty"}
420
- blurDataURL={slideBackground.image.blur_data_url || undefined}
421
- quality={slideBackground.image.quality || 80}
422
- sizes="100vw"
423
- style={{
424
- objectFit: slideBackground.image.size === 'contain' ? 'contain' : 'cover',
425
- objectPosition: slideBackground.image.position || 'center',
426
- }}
427
- />
465
+ {renderBackgroundImageElement(slideBackground.image, slidePriority)}
428
466
  {slideBackground.image.overlay && slideBackground.image.overlay.gradient && (
429
467
  <div
430
468
  className="absolute inset-0 transform-gpu [backface-visibility:hidden]"
@@ -525,23 +563,15 @@ export default async function SectionBlockRenderer({
525
563
  {...visualEditAttributes}
526
564
  >
527
565
  {/* Background image Layer */}
566
+ {content.background?.type === 'image' && content.background.image?.attribution && (
567
+ <StockPhotoCredit
568
+ attribution={content.background.image.attribution}
569
+ className="pointer-events-auto absolute bottom-1 right-2 z-10 rounded bg-black/40 px-1.5 py-0.5 text-[10px] text-white/90 backdrop-blur-sm [&_a]:text-white"
570
+ />
571
+ )}
528
572
  {content.background?.type === 'image' && content.background.image && (
529
573
  <div className={ABSOLUTE_BACKGROUND_CLASSES}>
530
- <Image
531
- src={`${R2_BASE_URL}/${content.background.image.object_key}`}
532
- alt={content.background.image.alt_text || ""}
533
- fill
534
- priority={isHero}
535
- fetchPriority={isHero ? "high" : "auto"}
536
- placeholder={content.background.image.blur_data_url ? "blur" : "empty"}
537
- blurDataURL={content.background.image.blur_data_url || undefined}
538
- quality={content.background.image.quality || 80}
539
- sizes="100vw"
540
- style={{
541
- objectFit: content.background.image.size === 'contain' ? 'contain' : 'cover',
542
- objectPosition: content.background.image.position || 'center',
543
- }}
544
- />
574
+ {renderBackgroundImageElement(content.background.image, isHero)}
545
575
  {content.background.image.overlay && content.background.image.overlay.gradient && (
546
576
  <div
547
577
  className="absolute inset-0 transform-gpu [backface-visibility:hidden]"
@@ -0,0 +1,167 @@
1
+ import React from "react";
2
+
3
+ // Stock-photo attribution. Field names match the search_stock_photos result +
4
+ // the ImageAttribution schema; snake_case is tolerated defensively.
5
+ export type StockPhotoAttribution = {
6
+ provider?: string | null;
7
+ photographer?: string | null;
8
+ photographerUrl?: string | null;
9
+ sourceUrl?: string | null;
10
+ downloadLocation?: string | null;
11
+ // The operator's registered Unsplash app name, resolved from CMS settings at
12
+ // search time and carried on the attribution — never hardcoded.
13
+ utmSource?: string | null;
14
+ photographer_url?: string | null;
15
+ source_url?: string | null;
16
+ };
17
+
18
+ function isUnsplash(attribution: StockPhotoAttribution) {
19
+ return (
20
+ attribution.provider === "unsplash" ||
21
+ /unsplash\.com/i.test(attribution.sourceUrl || attribution.source_url || "")
22
+ );
23
+ }
24
+
25
+ // Unsplash requires attribution links to carry utm params identifying your app.
26
+ // Without the operator's registered app name (set in /cms/settings/cortex-ai) we
27
+ // omit utm rather than tag a wrong app — the links still work.
28
+ function buildUnsplashUtm(attribution: StockPhotoAttribution) {
29
+ const source = attribution.utmSource?.trim();
30
+ if (!source) {
31
+ return null;
32
+ }
33
+ return `utm_source=${encodeURIComponent(source)}&utm_medium=referral`;
34
+ }
35
+
36
+ function withUtm(url: string, attribution: StockPhotoAttribution) {
37
+ if (!url || !isUnsplash(attribution)) {
38
+ return url;
39
+ }
40
+ const utm = buildUnsplashUtm(attribution);
41
+ if (!utm) {
42
+ return url;
43
+ }
44
+ return url.includes("?") ? `${url}&${utm}` : `${url}?${utm}`;
45
+ }
46
+
47
+ function resolveProvider(attribution: StockPhotoAttribution) {
48
+ const source = attribution.sourceUrl || attribution.source_url || "";
49
+ if (attribution.provider === "unsplash" || /unsplash\.com/i.test(source)) {
50
+ return { home: "https://unsplash.com", name: "Unsplash" };
51
+ }
52
+ if (attribution.provider === "pexels" || /pexels\.com/i.test(source)) {
53
+ return { home: "https://www.pexels.com", name: "Pexels" };
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * The plain-text form of the credit that {@link StockPhotoCredit} renders
60
+ * ("Photo by {photographer} on {Provider}"). Used to detect — and suppress — a
61
+ * caption that merely duplicates the attribution credit.
62
+ */
63
+ export function stockPhotoCreditText(
64
+ attribution: StockPhotoAttribution | null | undefined
65
+ ): string | null {
66
+ if (!attribution) {
67
+ return null;
68
+ }
69
+
70
+ const provider = resolveProvider(attribution);
71
+ const photographer = attribution.photographer;
72
+
73
+ if (!photographer && !provider) {
74
+ return null;
75
+ }
76
+
77
+ const who = photographer || "a photographer";
78
+ return provider ? `Photo by ${who} on ${provider.name}` : `Photo by ${who}`;
79
+ }
80
+
81
+ /**
82
+ * True when `caption` is just an attribution credit for `attribution` (so it
83
+ * would duplicate the rendered {@link StockPhotoCredit}). Covers the normal
84
+ * "Photo by {name} on {Provider}" form and the no-photographer fallback that
85
+ * search_stock_photos historically stored, "Photo on {Provider}".
86
+ */
87
+ export function isStockPhotoCreditCaption(
88
+ caption: string | null | undefined,
89
+ attribution: StockPhotoAttribution | null | undefined
90
+ ): boolean {
91
+ const text = (caption || "").trim().toLowerCase();
92
+ if (!text) {
93
+ return false;
94
+ }
95
+
96
+ const credit = stockPhotoCreditText(attribution);
97
+ if (credit && text === credit.toLowerCase()) {
98
+ return true;
99
+ }
100
+
101
+ const provider = resolveProvider(attribution || {});
102
+ return Boolean(provider && text === `photo on ${provider.name.toLowerCase()}`);
103
+ }
104
+
105
+ /**
106
+ * Renders "Photo by {photographer} on {Provider}" with the photographer's profile
107
+ * and the provider properly linked — satisfying the Unsplash API attribution
108
+ * requirement (also shown for Pexels as a courtesy). Renders nothing without a
109
+ * usable attribution.
110
+ */
111
+ export function StockPhotoCredit({
112
+ attribution,
113
+ className,
114
+ }: {
115
+ attribution: StockPhotoAttribution | null | undefined;
116
+ className?: string;
117
+ }) {
118
+ if (!attribution) {
119
+ return null;
120
+ }
121
+
122
+ const photographer = attribution.photographer;
123
+ const photographerUrl = attribution.photographerUrl || attribution.photographer_url;
124
+ const sourceUrl = attribution.sourceUrl || attribution.source_url;
125
+ const provider = resolveProvider(attribution);
126
+
127
+ if (!photographer && !provider) {
128
+ return null;
129
+ }
130
+
131
+ const linkClass = "underline underline-offset-2 hover:opacity-80";
132
+
133
+ return (
134
+ <span className={className}>
135
+ Photo by{" "}
136
+ {photographer ? (
137
+ photographerUrl ? (
138
+ <a
139
+ href={withUtm(photographerUrl, attribution)}
140
+ target="_blank"
141
+ rel="noopener noreferrer nofollow"
142
+ className={linkClass}
143
+ >
144
+ {photographer}
145
+ </a>
146
+ ) : (
147
+ photographer
148
+ )
149
+ ) : (
150
+ "a photographer"
151
+ )}
152
+ {provider ? (
153
+ <>
154
+ {" on "}
155
+ <a
156
+ href={withUtm(sourceUrl || provider.home, attribution)}
157
+ target="_blank"
158
+ rel="noopener noreferrer nofollow"
159
+ className={linkClass}
160
+ >
161
+ {provider.name}
162
+ </a>
163
+ </>
164
+ ) : null}
165
+ </span>
166
+ );
167
+ }