create-nextblock 0.9.5 → 0.9.6
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/process-image/route.ts +13 -9
- package/templates/nextblock-template/app/cms/components/ContentLanguageSwitcher.tsx +19 -13
- package/templates/nextblock-template/app/layout.tsx +15 -23
- package/templates/nextblock-template/app/providers.tsx +1 -1
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +17 -15
- package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -0
- package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +9 -1
- package/templates/nextblock-template/lib/setup/schema-apply.ts +16 -3
- package/templates/nextblock-template/next.config.js +13 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// app/api/process-image/route.ts
|
|
2
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
-
import { getS3Client } from '@nextblock-cms/utils/server';
|
|
3
|
+
import { getS3Client } from '@nextblock-cms/utils/server';
|
|
4
4
|
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
5
5
|
import sharp from 'sharp';
|
|
6
6
|
import { Readable } from 'stream';
|
|
@@ -54,13 +54,13 @@ export async function POST(request: NextRequest) {
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
try {
|
|
57
|
-
const s3Client = await getS3Client();
|
|
58
|
-
if (!s3Client) {
|
|
59
|
-
console.error('R2 client is not configured. Check your R2 environment variables.');
|
|
60
|
-
return NextResponse.json({ error: 'Image processing is not configured on this server.' }, { status: 500 });
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const { objectKey: originalObjectKey, contentType: originalContentType } = await request.json();
|
|
57
|
+
const s3Client = await getS3Client();
|
|
58
|
+
if (!s3Client) {
|
|
59
|
+
console.error('R2 client is not configured. Check your R2 environment variables.');
|
|
60
|
+
return NextResponse.json({ error: 'Image processing is not configured on this server.' }, { status: 500 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { objectKey: originalObjectKey, contentType: originalContentType } = await request.json();
|
|
64
64
|
|
|
65
65
|
if (!originalObjectKey || !originalContentType) {
|
|
66
66
|
return NextResponse.json({ error: 'Missing objectKey or contentType in request body.' }, { status: 400 });
|
|
@@ -127,7 +127,11 @@ export async function POST(request: NextRequest) {
|
|
|
127
127
|
.toFormat(TARGET_FORMAT, { quality: 75 }) // Adjust quality as needed
|
|
128
128
|
.toBuffer();
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
// size.label keeps its '_avif' suffix as the stored variantLabel (used for
|
|
131
|
+
// variant selection), but it's redundant in the filename since the extension is
|
|
132
|
+
// already .avif — strip it so keys read `..._large.avif`, not `..._large_avif.avif`.
|
|
133
|
+
const fileSuffix = size.label.replace(/_avif$/, '');
|
|
134
|
+
const newObjectKey = `${baseName}_${fileSuffix}.${TARGET_FORMAT}`;
|
|
131
135
|
const newPublicUrl = `${R2_PUBLIC_URL_BASE}/${newObjectKey}`;
|
|
132
136
|
|
|
133
137
|
const putObjectParams = {
|
|
@@ -67,7 +67,13 @@ export default function ContentLanguageSwitcher({
|
|
|
67
67
|
.eq('translation_group_id', currentItem.translation_group_id);
|
|
68
68
|
|
|
69
69
|
if (error) {
|
|
70
|
-
|
|
70
|
+
// Surface a useful message: a network "Failed to fetch" (e.g. the browser
|
|
71
|
+
// Supabase client before the runtime public env is in place) serializes to {},
|
|
72
|
+
// so log the message/code explicitly.
|
|
73
|
+
console.error(
|
|
74
|
+
`Error fetching translations for ${itemType} group ${currentItem.translation_group_id}:`,
|
|
75
|
+
error.message || error.code || JSON.stringify(error),
|
|
76
|
+
);
|
|
71
77
|
setTranslations([]);
|
|
72
78
|
} else if (data) {
|
|
73
79
|
const mappedTranslations = data.map(item => {
|
|
@@ -116,17 +122,17 @@ export default function ContentLanguageSwitcher({
|
|
|
116
122
|
|
|
117
123
|
// Link to create new translation if it doesn't exist
|
|
118
124
|
// This requires a more complex "create translation" flow or pre-created placeholders
|
|
119
|
-
const tableMap = {
|
|
120
|
-
'page': 'pages',
|
|
121
|
-
'post': 'posts',
|
|
122
|
-
'product': 'products'
|
|
123
|
-
};
|
|
124
|
-
const baseUrl = `/cms/${tableMap[itemType]}`;
|
|
125
|
-
const editUrl = version
|
|
126
|
-
? `${baseUrl}/${version.id}/edit`
|
|
127
|
-
: itemType === 'product'
|
|
128
|
-
? `${baseUrl}/${currentItemAny.id}/edit?missing_lang_id=${lang.id}`
|
|
129
|
-
: `${baseUrl}/new?from_group=${currentItemAny.translation_group_id}&target_lang_id=${lang.id}&base_slug=${currentItemAny.slug}`; // Example URL for creating new translation
|
|
125
|
+
const tableMap = {
|
|
126
|
+
'page': 'pages',
|
|
127
|
+
'post': 'posts',
|
|
128
|
+
'product': 'products'
|
|
129
|
+
};
|
|
130
|
+
const baseUrl = `/cms/${tableMap[itemType]}`;
|
|
131
|
+
const editUrl = version
|
|
132
|
+
? `${baseUrl}/${version.id}/edit`
|
|
133
|
+
: itemType === 'product'
|
|
134
|
+
? `${baseUrl}/${currentItemAny.id}/edit?missing_lang_id=${lang.id}`
|
|
135
|
+
: `${baseUrl}/new?from_group=${currentItemAny.translation_group_id}&target_lang_id=${lang.id}&base_slug=${currentItemAny.slug}`; // Example URL for creating new translation
|
|
130
136
|
|
|
131
137
|
if (version) {
|
|
132
138
|
return (
|
|
@@ -160,4 +166,4 @@ export default function ContentLanguageSwitcher({
|
|
|
160
166
|
</DropdownMenuContent>
|
|
161
167
|
</DropdownMenu>
|
|
162
168
|
);
|
|
163
|
-
}
|
|
169
|
+
}
|
|
@@ -8,6 +8,7 @@ import { DeferredCartDrawer } from '../components/DeferredCartDrawer';
|
|
|
8
8
|
import { CURRENCY_COOKIE_NAME } from '@nextblock-cms/ecommerce/currency-constants';
|
|
9
9
|
import { ToasterProvider } from './ToasterProvider';
|
|
10
10
|
import { AppShell } from '../components/AppShell';
|
|
11
|
+
import { PublicEnvBootstrap } from '../components/PublicEnvBootstrap';
|
|
11
12
|
import { ConsentGatedAnalytics } from '../components/privacy/ConsentGatedAnalytics';
|
|
12
13
|
import { ConsentBanner } from '../components/privacy/ConsentBanner';
|
|
13
14
|
import { getPrivacySettings } from '../lib/privacy/settings';
|
|
@@ -447,21 +448,13 @@ export default async function RootLayout({
|
|
|
447
448
|
? (await import('@vercel/toolbar/next')).VercelToolbar
|
|
448
449
|
: null;
|
|
449
450
|
|
|
450
|
-
// Expose the PUBLIC Supabase values (url + anon key — both safe to ship to the
|
|
451
|
-
//
|
|
452
|
-
// NEXT_PUBLIC_* and
|
|
453
|
-
// writes those vars at runtime and the
|
|
454
|
-
// hold stale empties until a dev-server restart. Read from server process.env
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
|
|
458
|
-
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
|
459
|
-
if (!url || !anonKey) return '';
|
|
460
|
-
return `window.__NEXTBLOCK_PUBLIC_ENV__=${JSON.stringify({ url, anonKey }).replace(
|
|
461
|
-
/</g,
|
|
462
|
-
'\\u003c',
|
|
463
|
-
)};`;
|
|
464
|
-
})();
|
|
451
|
+
// Expose the PUBLIC Supabase values (url + anon key — both safe to ship to the browser)
|
|
452
|
+
// to the client at runtime via <PublicEnvBootstrap>. In production the client uses the
|
|
453
|
+
// build-time-inlined NEXT_PUBLIC_* and these just match; it only matters in local dev,
|
|
454
|
+
// where the wizard writes those vars at runtime and the loaded bundle would otherwise
|
|
455
|
+
// hold stale empties until a dev-server restart. Read from server process.env (fresh).
|
|
456
|
+
const publicSupabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
|
|
457
|
+
const publicSupabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
|
465
458
|
|
|
466
459
|
return (
|
|
467
460
|
<html lang={serverDeterminedLocale} suppressHydrationWarning>
|
|
@@ -470,14 +463,13 @@ export default async function RootLayout({
|
|
|
470
463
|
{globalCss && <style dangerouslySetInnerHTML={{ __html: globalCss }} />}
|
|
471
464
|
</head>
|
|
472
465
|
<body className="min-h-screen">
|
|
473
|
-
{
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
)}
|
|
466
|
+
{/* Sets window.__NEXTBLOCK_PUBLIC_ENV__ synchronously during render, before any
|
|
467
|
+
descendant calls the browser Supabase client — the local-dev runtime fallback. */}
|
|
468
|
+
<PublicEnvBootstrap
|
|
469
|
+
url={publicSupabaseUrl}
|
|
470
|
+
anonKey={publicSupabaseAnonKey}
|
|
471
|
+
r2Base={process.env.NEXT_PUBLIC_R2_BASE_URL || ''}
|
|
472
|
+
/>
|
|
481
473
|
{/* In development this loads after hydration to avoid browser-hidden nonce comparisons. */}
|
|
482
474
|
<Script
|
|
483
475
|
id="trusted-types-bootstrap"
|
|
@@ -65,7 +65,7 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
|
|
|
65
65
|
<TranslationBridge translations={translations}>
|
|
66
66
|
<ThemeProvider
|
|
67
67
|
attribute="class"
|
|
68
|
-
defaultTheme="
|
|
68
|
+
defaultTheme="light"
|
|
69
69
|
enableSystem
|
|
70
70
|
disableTransitionOnChange
|
|
71
71
|
nonce={nonce}
|
|
@@ -182,8 +182,12 @@ export default function SetupWizard({
|
|
|
182
182
|
put('R2_BUCKET_NAME', storage.bucket);
|
|
183
183
|
put('R2_ACCESS_KEY_ID', storage.accessKeyId);
|
|
184
184
|
put('R2_SECRET_ACCESS_KEY', storage.secretAccessKey);
|
|
185
|
+
// Both must hold the bucket's public read URL: image src is built from
|
|
186
|
+
// NEXT_PUBLIC_R2_BASE_URL, while next.config remotePatterns + CSP read
|
|
187
|
+
// NEXT_PUBLIC_R2_PUBLIC_URL. The wizard collects one URL and writes it to both
|
|
188
|
+
// (baseUrl only differs if a channel prefilled a separate custom domain).
|
|
185
189
|
put('NEXT_PUBLIC_R2_PUBLIC_URL', storage.publicUrl);
|
|
186
|
-
put('NEXT_PUBLIC_R2_BASE_URL', storage.baseUrl);
|
|
190
|
+
put('NEXT_PUBLIC_R2_BASE_URL', storage.baseUrl || storage.publicUrl);
|
|
187
191
|
put('SMTP_HOST', smtp.host);
|
|
188
192
|
put('SMTP_PORT', smtp.port);
|
|
189
193
|
put('SMTP_USER', smtp.user);
|
|
@@ -734,21 +738,19 @@ function StorageStep({
|
|
|
734
738
|
onChange={(e) => setStorage({ ...storage, secretAccessKey: e.target.value })}
|
|
735
739
|
/>
|
|
736
740
|
</Field>
|
|
737
|
-
<Field label="Public URL" htmlFor="r2Public">
|
|
738
|
-
<Input
|
|
739
|
-
id="r2Public"
|
|
740
|
-
value={storage.publicUrl}
|
|
741
|
-
onChange={(e) => setStorage({ ...storage, publicUrl: e.target.value })}
|
|
742
|
-
/>
|
|
743
|
-
</Field>
|
|
744
|
-
<Field label="Custom domain (optional)" htmlFor="r2Base">
|
|
745
|
-
<Input
|
|
746
|
-
id="r2Base"
|
|
747
|
-
value={storage.baseUrl}
|
|
748
|
-
onChange={(e) => setStorage({ ...storage, baseUrl: e.target.value })}
|
|
749
|
-
/>
|
|
750
|
-
</Field>
|
|
751
741
|
</div>
|
|
742
|
+
<Field label="Public bucket URL" htmlFor="r2Public">
|
|
743
|
+
<Input
|
|
744
|
+
id="r2Public"
|
|
745
|
+
placeholder="https://pub-xxxx.r2.dev"
|
|
746
|
+
value={storage.publicUrl}
|
|
747
|
+
onChange={(e) => setStorage({ ...storage, publicUrl: e.target.value })}
|
|
748
|
+
/>
|
|
749
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
750
|
+
Your bucket's public URL (Cloudflare R2 → your bucket → Public Development URL)
|
|
751
|
+
or a custom domain. Used to serve all media on your site.
|
|
752
|
+
</p>
|
|
753
|
+
</Field>
|
|
752
754
|
</div>
|
|
753
755
|
);
|
|
754
756
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Makes the PUBLIC Supabase values available to the browser at runtime via a global,
|
|
4
|
+
// set SYNCHRONOUSLY during render (before any descendant calls the browser createClient).
|
|
5
|
+
//
|
|
6
|
+
// Why: NEXT_PUBLIC_* are inlined into the client bundle at build time. On a fresh local
|
|
7
|
+
// dev setup the dev server starts with no env, so the loaded bundle holds empties; the
|
|
8
|
+
// /setup wizard then writes the env at runtime. Browser-side readers (the Supabase client,
|
|
9
|
+
// env checks, and resolveMediaUrl's R2 base) read `process.env.NEXT_PUBLIC_* ||
|
|
10
|
+
// window.__NEXTBLOCK_PUBLIC_ENV__`, so this fills the gap without a dev-server restart. In
|
|
11
|
+
// production the inlined values win and these props just match them (a harmless no-op). All
|
|
12
|
+
// of these (Supabase url + anon key, R2 public base) are public values (safe to ship).
|
|
13
|
+
//
|
|
14
|
+
// This runs at render time (not in an effect, and not via a nonce'd <script> that could
|
|
15
|
+
// be deferred/blocked in dev), so the global is set before sibling/descendant components
|
|
16
|
+
// render. It's mounted in the root layout, so the value persists across client navigations.
|
|
17
|
+
type PublicEnv = { url: string; anonKey: string; r2Base?: string };
|
|
18
|
+
|
|
19
|
+
declare global {
|
|
20
|
+
interface Window {
|
|
21
|
+
__NEXTBLOCK_PUBLIC_ENV__?: PublicEnv;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function PublicEnvBootstrap({ url, anonKey, r2Base }: PublicEnv) {
|
|
26
|
+
if (typeof window !== 'undefined' && url && anonKey) {
|
|
27
|
+
window.__NEXTBLOCK_PUBLIC_ENV__ = { url, anonKey, r2Base };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
@@ -20,7 +20,15 @@ const BUNDLED_PUBLIC_MEDIA_KEYS = new Set([
|
|
|
20
20
|
|
|
21
21
|
export function resolveMediaUrl(
|
|
22
22
|
objectKey?: string | null,
|
|
23
|
-
|
|
23
|
+
// On the client, NEXT_PUBLIC_R2_BASE_URL is inlined at build time, so a fresh-clone
|
|
24
|
+
// dev bundle built with no env holds an empty string; fall back to the runtime value
|
|
25
|
+
// injected by <PublicEnvBootstrap> (window.__NEXTBLOCK_PUBLIC_ENV__.r2Base). On the
|
|
26
|
+
// server, process.env is always current and the window branch is skipped.
|
|
27
|
+
baseUrl = process.env.NEXT_PUBLIC_R2_BASE_URL ||
|
|
28
|
+
(typeof window !== 'undefined'
|
|
29
|
+
? (window as { __NEXTBLOCK_PUBLIC_ENV__?: { r2Base?: string } })
|
|
30
|
+
.__NEXTBLOCK_PUBLIC_ENV__?.r2Base || ''
|
|
31
|
+
: '')
|
|
24
32
|
) {
|
|
25
33
|
if (!objectKey) return null;
|
|
26
34
|
|
|
@@ -141,6 +141,19 @@ const TRACKING_DDL =
|
|
|
141
141
|
'create table if not exists supabase_migrations.schema_migrations ' +
|
|
142
142
|
'(version text primary key, name text, statements text[]);';
|
|
143
143
|
|
|
144
|
+
// Re-grant the Supabase API roles on everything in `public` after all migrations run.
|
|
145
|
+
// Migration 06 grants ON ALL TABLES, but only covers tables that exist at that point;
|
|
146
|
+
// later migrations (e.g. content_drafts in 14) rely on the schema's DEFAULT PRIVILEGES,
|
|
147
|
+
// which a `DROP SCHEMA public CASCADE` reset wipes. This final, idempotent pass mirrors
|
|
148
|
+
// migration 06's grants over the FINAL table set, so no table is left ungranted
|
|
149
|
+
// ("permission denied"). RLS still governs row access on top of these base grants.
|
|
150
|
+
const GRANTS_SQL =
|
|
151
|
+
'grant usage on schema public to anon, authenticated, service_role;' +
|
|
152
|
+
'grant select on all tables in schema public to anon;' +
|
|
153
|
+
'grant all on all tables in schema public to authenticated, service_role;' +
|
|
154
|
+
'grant all on all sequences in schema public to anon, authenticated, service_role;' +
|
|
155
|
+
'grant execute on all functions in schema public to anon, authenticated, service_role;';
|
|
156
|
+
|
|
144
157
|
function recordSql(version: string, file: string): string {
|
|
145
158
|
return (
|
|
146
159
|
`insert into supabase_migrations.schema_migrations (version, name) ` +
|
|
@@ -300,6 +313,7 @@ async function applyViaManagementApi(
|
|
|
300
313
|
applied += 1;
|
|
301
314
|
}
|
|
302
315
|
|
|
316
|
+
await run(GRANTS_SQL);
|
|
303
317
|
await run("notify pgrst, 'reload schema';");
|
|
304
318
|
return { ok: true, applied };
|
|
305
319
|
} catch (caught) {
|
|
@@ -349,9 +363,8 @@ async function applyViaPostgres(
|
|
|
349
363
|
applied += 1;
|
|
350
364
|
}
|
|
351
365
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
366
|
+
await db.unsafe(GRANTS_SQL);
|
|
367
|
+
await db.unsafe("notify pgrst, 'reload schema';");
|
|
355
368
|
return { ok: true, applied };
|
|
356
369
|
} catch (caught) {
|
|
357
370
|
const message =
|
|
@@ -151,6 +151,19 @@ function getRemotePatterns() {
|
|
|
151
151
|
/** @type {RemotePattern[]} */
|
|
152
152
|
const patterns = [];
|
|
153
153
|
|
|
154
|
+
// Well-known media/storage providers, allowlisted by wildcard. next.config.js is
|
|
155
|
+
// evaluated once at server start and is NOT re-read when .env.local changes, so on a
|
|
156
|
+
// fresh install (the /setup wizard writes the R2/storage env at runtime) the exact
|
|
157
|
+
// env-derived hosts below would be missing until a dev-server restart — which made
|
|
158
|
+
// next/image hard-crash with "hostname ... is not configured". These static patterns
|
|
159
|
+
// cover the common buckets (Cloudflare R2 public + S3 endpoints, Supabase Storage)
|
|
160
|
+
// regardless of env timing. Custom domains are still picked up from env below.
|
|
161
|
+
patterns.push(
|
|
162
|
+
{ protocol: 'https', hostname: '**.r2.dev', pathname: '/**' },
|
|
163
|
+
{ protocol: 'https', hostname: '**.r2.cloudflarestorage.com', pathname: '/**' },
|
|
164
|
+
{ protocol: 'https', hostname: '**.supabase.co', pathname: '/**' },
|
|
165
|
+
);
|
|
166
|
+
|
|
154
167
|
// Add R2 Bucket URL if authenticated
|
|
155
168
|
if (process.env.NEXT_PUBLIC_R2_PUBLIC_URL) {
|
|
156
169
|
try {
|