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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/actions/interactions.ts +27 -4
- 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/custom-blocks/components/BlockComposer.tsx +40 -2
- package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -0
- package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +138 -71
- 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/cms/users/[id]/edit/page.tsx +20 -1
- package/templates/nextblock-template/app/cms/users/actions.ts +69 -0
- package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -0
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +4 -1
- package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -0
- package/templates/nextblock-template/app/cms/users/page.tsx +12 -3
- 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/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/next-env.d.ts +1 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +5 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|