create-nextblock 0.14.2 → 0.14.5
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/[slug]/page.tsx +7 -2
- package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
- package/templates/nextblock-template/app/actions/postActions.ts +3 -0
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
- package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
- package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +736 -117
- package/templates/nextblock-template/app/api/view/route.ts +114 -0
- package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
- package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
- package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
- package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
- package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
- package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
- package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
- package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
- package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
- package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
- package/templates/nextblock-template/app/layout.tsx +49 -0
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
- package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
- package/templates/nextblock-template/app/page.tsx +6 -3
- package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
- package/templates/nextblock-template/app/providers.tsx +16 -4
- package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
- package/templates/nextblock-template/components/theme-icon.tsx +78 -0
- package/templates/nextblock-template/components/theme-switcher.tsx +85 -90
- package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
- package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
- package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
- package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
- package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
- package/templates/nextblock-template/lib/search/server.ts +3 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +102 -87
- package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
- package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
- package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
- package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
- package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
- package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
- package/templates/nextblock-template/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -1,24 +1,40 @@
|
|
|
1
|
-
// app/cms/settings/global-css/page.tsx
|
|
2
|
-
import { getGlobalCss } from './actions';
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
1
|
+
// app/cms/settings/global-css/page.tsx
|
|
2
|
+
import { getGlobalCss } from './actions';
|
|
3
|
+
import { getSiteThemes } from './theme-actions';
|
|
4
|
+
import GlobalCssForm from './components/GlobalCssForm';
|
|
5
|
+
import ThemeManager from './components/ThemeManager';
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
|
|
7
|
+
|
|
8
|
+
export default async function GlobalCssSettingsPage() {
|
|
9
|
+
const [css, themes] = await Promise.all([getGlobalCss(), getSiteThemes()]);
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="mx-auto max-w-6xl space-y-6">
|
|
13
|
+
<Card>
|
|
14
|
+
<CardHeader>
|
|
15
|
+
<CardTitle>Themes</CardTitle>
|
|
16
|
+
<CardDescription>
|
|
17
|
+
Recolour the themes visitors can pick from the switcher, or add your own. Changes apply
|
|
18
|
+
site-wide without a redeploy.
|
|
19
|
+
</CardDescription>
|
|
20
|
+
</CardHeader>
|
|
21
|
+
<CardContent>
|
|
22
|
+
<ThemeManager initialThemes={themes} />
|
|
23
|
+
</CardContent>
|
|
24
|
+
</Card>
|
|
25
|
+
|
|
26
|
+
<Card>
|
|
27
|
+
<CardHeader>
|
|
28
|
+
<CardTitle>Global CSS</CardTitle>
|
|
29
|
+
<CardDescription>
|
|
30
|
+
Inject custom CSS rules dynamically across the entire application front-end. Loaded after
|
|
31
|
+
the themes above, so it can override any of them.
|
|
32
|
+
</CardDescription>
|
|
33
|
+
</CardHeader>
|
|
34
|
+
<CardContent>
|
|
35
|
+
<GlobalCssForm initialCss={css} />
|
|
36
|
+
</CardContent>
|
|
37
|
+
</Card>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// app/cms/settings/global-css/theme-actions.ts
|
|
2
|
+
'use server';
|
|
3
|
+
|
|
4
|
+
import { createClient } from '@nextblock-cms/db/server';
|
|
5
|
+
import { revalidatePath, revalidateTag } from 'next/cache';
|
|
6
|
+
import type { SettingsActionResult } from '../../../../lib/cms/action-result';
|
|
7
|
+
import { isValidThemeSlug, type SiteTheme } from '../../../../lib/themes/buildThemeCss';
|
|
8
|
+
import { isThemeTokenKey, isValidTokenValue, THEME_TOKEN_KEYS } from '../../../../lib/themes/tokens';
|
|
9
|
+
|
|
10
|
+
const THEME_COLUMNS =
|
|
11
|
+
'id, slug, name, description, icon, color_scheme, tokens, extra_css, is_system, is_default, is_active, sort_order';
|
|
12
|
+
|
|
13
|
+
/** Theme edits are ADMIN-only — RLS enforces it too, this is the friendly error. */
|
|
14
|
+
async function requireAdmin() {
|
|
15
|
+
const supabase = createClient();
|
|
16
|
+
const {
|
|
17
|
+
data: { user },
|
|
18
|
+
} = await supabase.auth.getUser();
|
|
19
|
+
if (!user) {
|
|
20
|
+
return { supabase, error: 'You must be logged in to manage themes.' as const };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { data: profile, error: profileError } = await supabase
|
|
24
|
+
.from('profiles')
|
|
25
|
+
.select('role')
|
|
26
|
+
.eq('id', user.id)
|
|
27
|
+
.single();
|
|
28
|
+
|
|
29
|
+
if (profileError || !profile || profile.role !== 'ADMIN') {
|
|
30
|
+
return { supabase, error: 'Only administrators can manage themes.' as const };
|
|
31
|
+
}
|
|
32
|
+
return { supabase, error: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function revalidateThemes() {
|
|
36
|
+
revalidateTag('public-layout-site-themes', 'max');
|
|
37
|
+
revalidatePath('/', 'layout');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getSiteThemes(): Promise<SiteTheme[]> {
|
|
41
|
+
const supabase = createClient();
|
|
42
|
+
const { data, error } = await supabase.from('site_themes').select(THEME_COLUMNS).order('sort_order');
|
|
43
|
+
if (error || !data) return [];
|
|
44
|
+
return data as unknown as SiteTheme[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Keep only known tokens with well-formed values. Anything else is dropped
|
|
49
|
+
* rather than rejected, so a partially-filled form still saves what it can.
|
|
50
|
+
*/
|
|
51
|
+
function sanitizeTokens(input: unknown): Record<string, string> {
|
|
52
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return {};
|
|
53
|
+
const out: Record<string, string> = {};
|
|
54
|
+
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
|
55
|
+
if (typeof value !== 'string') continue;
|
|
56
|
+
const trimmed = value.trim();
|
|
57
|
+
if (isThemeTokenKey(key) && isValidTokenValue(key, trimmed)) {
|
|
58
|
+
out[key] = trimmed;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ThemeInput {
|
|
65
|
+
name: string;
|
|
66
|
+
slug?: string;
|
|
67
|
+
description?: string | null;
|
|
68
|
+
icon?: string;
|
|
69
|
+
color_scheme?: 'light' | 'dark';
|
|
70
|
+
tokens?: Record<string, string>;
|
|
71
|
+
extra_css?: string | null;
|
|
72
|
+
is_active?: boolean;
|
|
73
|
+
is_default?: boolean;
|
|
74
|
+
sort_order?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function createTheme(input: ThemeInput): Promise<SettingsActionResult & { slug?: string }> {
|
|
78
|
+
const { supabase, error: authError } = await requireAdmin();
|
|
79
|
+
if (authError) return { ok: false, error: authError };
|
|
80
|
+
|
|
81
|
+
const slug = (input.slug ?? input.name ?? '')
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.trim()
|
|
84
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
85
|
+
.replace(/^-+|-+$/g, '');
|
|
86
|
+
|
|
87
|
+
if (!isValidThemeSlug(slug)) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: 'Theme id must be 2-40 characters, lowercase letters, numbers and dashes, starting with a letter.',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (!input.name?.trim()) {
|
|
94
|
+
return { ok: false, error: 'Theme name is required.' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { error } = await supabase.from('site_themes').insert({
|
|
98
|
+
slug,
|
|
99
|
+
name: input.name.trim(),
|
|
100
|
+
description: input.description?.trim() || null,
|
|
101
|
+
icon: input.icon || 'Palette',
|
|
102
|
+
color_scheme: input.color_scheme === 'dark' ? 'dark' : 'light',
|
|
103
|
+
tokens: sanitizeTokens(input.tokens),
|
|
104
|
+
extra_css: input.extra_css?.trim() || null,
|
|
105
|
+
is_active: input.is_active ?? true,
|
|
106
|
+
is_default: false,
|
|
107
|
+
is_system: false,
|
|
108
|
+
sort_order: input.sort_order ?? 100,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (error) {
|
|
112
|
+
if (error.code === '23505') {
|
|
113
|
+
return { ok: false, error: `A theme with the id "${slug}" already exists.` };
|
|
114
|
+
}
|
|
115
|
+
console.error('Error creating theme:', error);
|
|
116
|
+
return { ok: false, error: 'Failed to create theme.' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
revalidateThemes();
|
|
120
|
+
return { ok: true, message: `Theme "${input.name.trim()}" created.`, slug };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function updateTheme(id: string, input: ThemeInput): Promise<SettingsActionResult> {
|
|
124
|
+
const { supabase, error: authError } = await requireAdmin();
|
|
125
|
+
if (authError) return { ok: false, error: authError };
|
|
126
|
+
|
|
127
|
+
if (!input.name?.trim()) {
|
|
128
|
+
return { ok: false, error: 'Theme name is required.' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// `slug` and `is_system` are intentionally not updatable: the slug is the CSS
|
|
132
|
+
// class and the value persisted in each visitor's localStorage by next-themes,
|
|
133
|
+
// so renaming it would silently reset everyone's chosen theme.
|
|
134
|
+
const patch: Record<string, unknown> = {
|
|
135
|
+
name: input.name.trim(),
|
|
136
|
+
description: input.description?.trim() || null,
|
|
137
|
+
icon: input.icon || 'Palette',
|
|
138
|
+
color_scheme: input.color_scheme === 'dark' ? 'dark' : 'light',
|
|
139
|
+
extra_css: input.extra_css?.trim() || null,
|
|
140
|
+
};
|
|
141
|
+
if (input.tokens !== undefined) patch.tokens = sanitizeTokens(input.tokens);
|
|
142
|
+
if (input.is_active !== undefined) patch.is_active = input.is_active;
|
|
143
|
+
if (input.sort_order !== undefined) patch.sort_order = input.sort_order;
|
|
144
|
+
|
|
145
|
+
const { error } = await supabase.from('site_themes').update(patch).eq('id', id);
|
|
146
|
+
|
|
147
|
+
if (error) {
|
|
148
|
+
console.error('Error updating theme:', error);
|
|
149
|
+
return { ok: false, error: 'Failed to update theme.' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
revalidateThemes();
|
|
153
|
+
return { ok: true, message: 'Theme saved.' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function setDefaultTheme(id: string): Promise<SettingsActionResult> {
|
|
157
|
+
const { supabase, error: authError } = await requireAdmin();
|
|
158
|
+
if (authError) return { ok: false, error: authError };
|
|
159
|
+
|
|
160
|
+
// A hidden theme cannot be the site default.
|
|
161
|
+
const { error } = await supabase
|
|
162
|
+
.from('site_themes')
|
|
163
|
+
.update({ is_default: true, is_active: true })
|
|
164
|
+
.eq('id', id);
|
|
165
|
+
|
|
166
|
+
if (error) {
|
|
167
|
+
console.error('Error setting default theme:', error);
|
|
168
|
+
return { ok: false, error: 'Failed to set the default theme.' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
revalidateThemes();
|
|
172
|
+
return { ok: true, message: 'Default theme updated.' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function deleteTheme(id: string): Promise<SettingsActionResult> {
|
|
176
|
+
const { supabase, error: authError } = await requireAdmin();
|
|
177
|
+
if (authError) return { ok: false, error: authError };
|
|
178
|
+
|
|
179
|
+
const { data: theme, error: readError } = await supabase
|
|
180
|
+
.from('site_themes')
|
|
181
|
+
.select('slug, name, is_system, is_default')
|
|
182
|
+
.eq('id', id)
|
|
183
|
+
.single();
|
|
184
|
+
|
|
185
|
+
if (readError || !theme) {
|
|
186
|
+
return { ok: false, error: 'Theme not found.' };
|
|
187
|
+
}
|
|
188
|
+
if (theme.is_system) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
error: `"${theme.name}" is a system theme. Light and Dark are what "System" resolves to, so they cannot be deleted — but you can recolour them freely.`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (theme.is_default) {
|
|
195
|
+
return { ok: false, error: 'Make another theme the default before deleting this one.' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const { error } = await supabase.from('site_themes').delete().eq('id', id);
|
|
199
|
+
if (error) {
|
|
200
|
+
console.error('Error deleting theme:', error);
|
|
201
|
+
return { ok: false, error: 'Failed to delete theme.' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
revalidateThemes();
|
|
205
|
+
return { ok: true, message: `Theme "${theme.name}" deleted. Visitors using it fall back to the default.` };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function duplicateTheme(id: string): Promise<SettingsActionResult & { slug?: string }> {
|
|
209
|
+
const { supabase, error: authError } = await requireAdmin();
|
|
210
|
+
if (authError) return { ok: false, error: authError };
|
|
211
|
+
|
|
212
|
+
const { data: source, error: readError } = await supabase
|
|
213
|
+
.from('site_themes')
|
|
214
|
+
.select(THEME_COLUMNS)
|
|
215
|
+
.eq('id', id)
|
|
216
|
+
.single();
|
|
217
|
+
|
|
218
|
+
if (readError || !source) {
|
|
219
|
+
return { ok: false, error: 'Theme not found.' };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const theme = source as unknown as SiteTheme;
|
|
223
|
+
// Find a free slug: my-theme-copy, my-theme-copy-2, ...
|
|
224
|
+
const { data: existing } = await supabase.from('site_themes').select('slug');
|
|
225
|
+
const taken = new Set((existing ?? []).map((row) => row.slug));
|
|
226
|
+
let slug = `${theme.slug}-copy`.slice(0, 40);
|
|
227
|
+
let n = 2;
|
|
228
|
+
while (taken.has(slug)) {
|
|
229
|
+
slug = `${theme.slug}-copy-${n}`.slice(0, 40);
|
|
230
|
+
n += 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { error } = await supabase.from('site_themes').insert({
|
|
234
|
+
slug,
|
|
235
|
+
name: `${theme.name} copy`,
|
|
236
|
+
description: theme.description,
|
|
237
|
+
icon: theme.icon,
|
|
238
|
+
color_scheme: theme.color_scheme,
|
|
239
|
+
tokens: sanitizeTokens(theme.tokens),
|
|
240
|
+
extra_css: theme.extra_css,
|
|
241
|
+
is_active: true,
|
|
242
|
+
is_default: false,
|
|
243
|
+
is_system: false,
|
|
244
|
+
sort_order: (theme.sort_order ?? 0) + 1,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (error) {
|
|
248
|
+
console.error('Error duplicating theme:', error);
|
|
249
|
+
return { ok: false, error: 'Failed to duplicate theme.' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
revalidateThemes();
|
|
253
|
+
return { ok: true, message: `Created "${theme.name} copy".`, slug };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Exposed so the client form can render inputs for exactly what the server accepts. */
|
|
257
|
+
export async function getThemeTokenKeys(): Promise<string[]> {
|
|
258
|
+
return THEME_TOKEN_KEYS;
|
|
259
|
+
}
|
|
@@ -13,6 +13,12 @@ import { ConsentGatedAnalytics } from '../components/privacy/ConsentGatedAnalyti
|
|
|
13
13
|
import { ConsentBanner } from '../components/privacy/ConsentBanner';
|
|
14
14
|
import { getPrivacySettings } from '../lib/privacy/settings';
|
|
15
15
|
import { DEFAULT_PRIVACY_SETTINGS } from '../lib/privacy/types';
|
|
16
|
+
import {
|
|
17
|
+
activeThemeSlugs,
|
|
18
|
+
buildThemeCss,
|
|
19
|
+
defaultThemeSlug,
|
|
20
|
+
type SiteTheme,
|
|
21
|
+
} from '../lib/themes/buildThemeCss';
|
|
16
22
|
import { DeferredSpeedInsights } from '../components/DeferredSpeedInsights';
|
|
17
23
|
import { DeferredVisualEditing } from '../components/visual-editing/DeferredVisualEditing';
|
|
18
24
|
import {
|
|
@@ -169,6 +175,25 @@ const getCachedGlobalCss = unstable_cache(
|
|
|
169
175
|
{ revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS }
|
|
170
176
|
);
|
|
171
177
|
|
|
178
|
+
const getCachedSiteThemes = unstable_cache(
|
|
179
|
+
async (): Promise<SiteTheme[]> => {
|
|
180
|
+
const supabase = createStaticSupabaseClient();
|
|
181
|
+
const { data, error } = await supabase
|
|
182
|
+
.from('site_themes')
|
|
183
|
+
.select('id, slug, name, description, icon, color_scheme, tokens, extra_css, is_system, is_default, is_active, sort_order')
|
|
184
|
+
.order('sort_order');
|
|
185
|
+
|
|
186
|
+
if (error || !data) {
|
|
187
|
+
// A missing table (pre-migration install) must not take the site down —
|
|
188
|
+
// libs/ui/src/styles/theme.css still ships a working fallback palette.
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
return data as unknown as SiteTheme[];
|
|
192
|
+
},
|
|
193
|
+
['public-layout-site-themes'],
|
|
194
|
+
{ revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS, tags: ['public-layout-site-themes'] }
|
|
195
|
+
);
|
|
196
|
+
|
|
172
197
|
const getCachedTranslations = unstable_cache(
|
|
173
198
|
async () => {
|
|
174
199
|
const supabase = createStaticSupabaseClient();
|
|
@@ -298,6 +323,7 @@ async function loadLayoutData() {
|
|
|
298
323
|
siteTitle: 'NextBlock',
|
|
299
324
|
isEcommerceActive: false,
|
|
300
325
|
globalCss: '',
|
|
326
|
+
siteThemes: [] as SiteTheme[],
|
|
301
327
|
privacySettings: DEFAULT_PRIVACY_SETTINGS,
|
|
302
328
|
footerAttributionEnabled: true,
|
|
303
329
|
rememberVisitorChoice: DEFAULT_LANGUAGE_DETECTION_SETTINGS.rememberVisitorChoice,
|
|
@@ -324,6 +350,7 @@ async function loadLayoutData() {
|
|
|
324
350
|
currenciesResult,
|
|
325
351
|
copyrightSettingsResult,
|
|
326
352
|
globalCssResult,
|
|
353
|
+
siteThemesResult,
|
|
327
354
|
translationsResult,
|
|
328
355
|
isEcommerceActive,
|
|
329
356
|
privacySettings,
|
|
@@ -336,6 +363,7 @@ async function loadLayoutData() {
|
|
|
336
363
|
en: '(c) {year} Nextblock CMS. All rights reserved.',
|
|
337
364
|
})),
|
|
338
365
|
getCachedGlobalCss().catch(() => ''),
|
|
366
|
+
getCachedSiteThemes().catch(() => [] as SiteTheme[]),
|
|
339
367
|
getCachedTranslations().catch(() => []),
|
|
340
368
|
verifyPackageOnline('ecommerce').catch(() => false),
|
|
341
369
|
getPrivacySettings().catch(() => DEFAULT_PRIVACY_SETTINGS),
|
|
@@ -369,6 +397,7 @@ async function loadLayoutData() {
|
|
|
369
397
|
const copyrightText = templateForLocale.replace('{year}', new Date().getFullYear().toString());
|
|
370
398
|
|
|
371
399
|
const globalCss = typeof globalCssResult === 'string' ? globalCssResult : '';
|
|
400
|
+
const siteThemes = Array.isArray(siteThemesResult) ? siteThemesResult : [];
|
|
372
401
|
const translations = Array.isArray(translationsResult) ? translationsResult : [];
|
|
373
402
|
|
|
374
403
|
const hasSupabaseEnv = isSupabaseConfigured();
|
|
@@ -404,6 +433,7 @@ async function loadLayoutData() {
|
|
|
404
433
|
siteTitle,
|
|
405
434
|
isEcommerceActive,
|
|
406
435
|
globalCss,
|
|
436
|
+
siteThemes,
|
|
407
437
|
privacySettings,
|
|
408
438
|
footerAttributionEnabled,
|
|
409
439
|
rememberVisitorChoice: languageDetectionSettings.rememberVisitorChoice,
|
|
@@ -486,10 +516,25 @@ export default async function RootLayout({
|
|
|
486
516
|
siteTitle,
|
|
487
517
|
isEcommerceActive,
|
|
488
518
|
globalCss,
|
|
519
|
+
siteThemes,
|
|
489
520
|
privacySettings,
|
|
490
521
|
footerAttributionEnabled,
|
|
491
522
|
rememberVisitorChoice,
|
|
492
523
|
} = await loadLayoutData();
|
|
524
|
+
// Themes are rendered server-side into <head> so the palette is correct on the
|
|
525
|
+
// very first paint, before next-themes' blocking script adds the html class.
|
|
526
|
+
const themeCss = buildThemeCss(siteThemes);
|
|
527
|
+
const themeSlugs = activeThemeSlugs(siteThemes);
|
|
528
|
+
const initialTheme = defaultThemeSlug(siteThemes);
|
|
529
|
+
const themeCatalog = siteThemes
|
|
530
|
+
.filter((theme) => theme.is_active)
|
|
531
|
+
.sort((a, b) => a.sort_order - b.sort_order)
|
|
532
|
+
.map((theme) => ({
|
|
533
|
+
slug: theme.slug,
|
|
534
|
+
name: theme.name,
|
|
535
|
+
icon: theme.icon,
|
|
536
|
+
colorScheme: theme.color_scheme,
|
|
537
|
+
}));
|
|
493
538
|
const draft = await draftMode();
|
|
494
539
|
// GTM container id comes solely from the privacy settings row (site_settings).
|
|
495
540
|
// There is intentionally no NEXT_PUBLIC_GTM_ID env fallback — analytics is
|
|
@@ -517,6 +562,7 @@ export default async function RootLayout({
|
|
|
517
562
|
<html lang={serverDeterminedLocale} suppressHydrationWarning>
|
|
518
563
|
<head>
|
|
519
564
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
565
|
+
{themeCss && <style id="nb-theme-tokens" dangerouslySetInnerHTML={{ __html: themeCss }} />}
|
|
520
566
|
{globalCss && <style dangerouslySetInnerHTML={{ __html: globalCss }} />}
|
|
521
567
|
</head>
|
|
522
568
|
<body className="min-h-screen">
|
|
@@ -545,6 +591,9 @@ export default async function RootLayout({
|
|
|
545
591
|
rememberVisitorChoice={rememberVisitorChoice}
|
|
546
592
|
translations={translations}
|
|
547
593
|
nonce={nonce}
|
|
594
|
+
themeSlugs={themeSlugs}
|
|
595
|
+
initialTheme={initialTheme}
|
|
596
|
+
themeCatalog={themeCatalog}
|
|
548
597
|
>
|
|
549
598
|
<ToasterProvider />
|
|
550
599
|
<AppShell
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getSsgSupabaseClient } from '@nextblock-cms/db/server';
|
|
2
|
+
import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
|
|
2
3
|
import { getHomepageTranslationGroupId } from './homepage';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -164,7 +165,8 @@ export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
|
|
|
164
165
|
supabase
|
|
165
166
|
.from('pages')
|
|
166
167
|
.select('slug, updated_at, language_id, translation_group_id')
|
|
167
|
-
.eq('status', 'published')
|
|
168
|
+
.eq('status', 'published')
|
|
169
|
+
.or(buildPublishedAtOrFilter()),
|
|
168
170
|
fetchLanguageMap(supabase),
|
|
169
171
|
getHomepageTranslationGroupId(supabase),
|
|
170
172
|
]);
|
|
@@ -200,13 +202,12 @@ export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
|
|
|
200
202
|
export async function fetchAllPublishedPosts(): Promise<SitemapEntry[]> {
|
|
201
203
|
const supabase = getSsgSupabaseClient();
|
|
202
204
|
try {
|
|
203
|
-
const nowIso = new Date().toISOString();
|
|
204
205
|
const [{ data: posts, error }, languageMap] = await Promise.all([
|
|
205
206
|
supabase
|
|
206
207
|
.from('posts')
|
|
207
208
|
.select('slug, updated_at, language_id, translation_group_id')
|
|
208
209
|
.eq('status', 'published')
|
|
209
|
-
.or(
|
|
210
|
+
.or(buildPublishedAtOrFilter()),
|
|
210
211
|
fetchLanguageMap(supabase),
|
|
211
212
|
]);
|
|
212
213
|
|
|
@@ -238,7 +239,8 @@ export async function fetchAllActiveProducts(): Promise<SitemapEntry[]> {
|
|
|
238
239
|
supabase
|
|
239
240
|
.from('products')
|
|
240
241
|
.select('slug, updated_at, created_at, language_id, translation_group_id')
|
|
241
|
-
.eq('status', 'active')
|
|
242
|
+
.eq('status', 'active')
|
|
243
|
+
.or(buildPublishedAtOrFilter()),
|
|
242
244
|
fetchLanguageMap(supabase),
|
|
243
245
|
]);
|
|
244
246
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
2
|
|
|
3
3
|
import { getServiceRoleSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
4
|
+
import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
|
|
4
5
|
import {
|
|
5
6
|
getDefaultCurrency,
|
|
6
7
|
inferCurrencyCodeFromLocale,
|
|
@@ -1099,6 +1100,7 @@ export async function searchCatalogProducts(body: unknown, request: Request) {
|
|
|
1099
1100
|
.from('products')
|
|
1100
1101
|
.select(PRODUCT_SELECT, { count: 'exact' })
|
|
1101
1102
|
.eq('status', 'active')
|
|
1103
|
+
.or(buildPublishedAtOrFilter())
|
|
1102
1104
|
.order('created_at', { ascending: false })
|
|
1103
1105
|
.range(pagination.offset, pagination.offset + pagination.limit - 1);
|
|
1104
1106
|
|
|
@@ -1192,7 +1194,7 @@ async function selectRowsByField(
|
|
|
1192
1194
|
|
|
1193
1195
|
let query = client.from(table).select(select).in(field, values);
|
|
1194
1196
|
if (table === 'products') {
|
|
1195
|
-
query = query.eq('status', 'active');
|
|
1197
|
+
query = query.eq('status', 'active').or(buildPublishedAtOrFilter());
|
|
1196
1198
|
}
|
|
1197
1199
|
|
|
1198
1200
|
const { data } = await query;
|
|
@@ -1251,6 +1253,7 @@ async function resolveProductRowsByIdentifiers(ids: string[]): Promise<{
|
|
|
1251
1253
|
.from('products')
|
|
1252
1254
|
.select(PRODUCT_SELECT)
|
|
1253
1255
|
.eq('status', 'active')
|
|
1256
|
+
.or(buildPublishedAtOrFilter())
|
|
1254
1257
|
.in('id', productIds);
|
|
1255
1258
|
|
|
1256
1259
|
if (error) {
|
|
@@ -3,6 +3,7 @@ import { cookies, draftMode, headers } from 'next/headers';
|
|
|
3
3
|
import { notFound } from 'next/navigation';
|
|
4
4
|
import type { Metadata } from 'next';
|
|
5
5
|
import { createClient, getSsgSupabaseClient } from '@nextblock-cms/db/server';
|
|
6
|
+
import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
|
|
6
7
|
import PageClientContent from './[slug]/PageClientContent';
|
|
7
8
|
import { getPageDataBySlug } from './[slug]/page.utils';
|
|
8
9
|
import BlockRenderer from '../components/BlockRenderer';
|
|
@@ -56,7 +57,7 @@ async function resolveHomepageData(preferredLocale: string) {
|
|
|
56
57
|
.limit(1);
|
|
57
58
|
|
|
58
59
|
if (!draft.isEnabled) {
|
|
59
|
-
siblingQuery = siblingQuery.eq('status', 'published');
|
|
60
|
+
siblingQuery = siblingQuery.eq('status', 'published').or(buildPublishedAtOrFilter());
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
const { data: sibling } = await siblingQuery.maybeSingle();
|
|
@@ -131,7 +132,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
131
132
|
.from('pages')
|
|
132
133
|
.select('language_id, slug')
|
|
133
134
|
.eq('translation_group_id', pageData.translation_group_id)
|
|
134
|
-
.eq('status', 'published')
|
|
135
|
+
.eq('status', 'published')
|
|
136
|
+
.or(buildPublishedAtOrFilter()),
|
|
135
137
|
]);
|
|
136
138
|
|
|
137
139
|
const { data: languages } = languagesResult;
|
|
@@ -188,7 +190,8 @@ export default async function RootPage() {
|
|
|
188
190
|
.from('pages')
|
|
189
191
|
.select('slug, languages!inner(code)')
|
|
190
192
|
.eq('translation_group_id', pageData.translation_group_id)
|
|
191
|
-
.eq('status', 'published')
|
|
193
|
+
.eq('status', 'published')
|
|
194
|
+
.or(buildPublishedAtOrFilter());
|
|
192
195
|
|
|
193
196
|
if (translations) {
|
|
194
197
|
translations.forEach((translation: PageTranslation) => {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
resolveTranslatedText,
|
|
9
9
|
} from '@nextblock-cms/ecommerce';
|
|
10
10
|
import { getSsgSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
11
|
+
import { LIVE_STATUS, buildPublishedAtOrFilter, isPubliclyVisible } from '@nextblock-cms/utils';
|
|
11
12
|
import { notFound } from 'next/navigation';
|
|
12
13
|
import { Metadata } from 'next';
|
|
13
14
|
import { draftMode, cookies, headers } from 'next/headers';
|
|
@@ -124,7 +125,16 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|
|
124
125
|
const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
|
|
125
126
|
const productRecord = product as any;
|
|
126
127
|
|
|
127
|
-
if (
|
|
128
|
+
if (
|
|
129
|
+
!productRecord ||
|
|
130
|
+
!isPubliclyVisible({
|
|
131
|
+
status: productRecord.status,
|
|
132
|
+
publishedAt: productRecord.published_at,
|
|
133
|
+
liveStatus: LIVE_STATUS.product,
|
|
134
|
+
})
|
|
135
|
+
) {
|
|
136
|
+
return { title: 'Product Not Found' };
|
|
137
|
+
}
|
|
128
138
|
|
|
129
139
|
// Resolve image URL for OG Image
|
|
130
140
|
let imageUrl = undefined;
|
|
@@ -147,6 +157,8 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|
|
147
157
|
.select('language_id, slug')
|
|
148
158
|
.eq('translation_group_id', productRecord.translation_group_id)
|
|
149
159
|
.eq('status', 'active')
|
|
160
|
+
// Never advertise a scheduled translation via hreflang.
|
|
161
|
+
.or(buildPublishedAtOrFilter())
|
|
150
162
|
]);
|
|
151
163
|
|
|
152
164
|
const { data: languages } = languagesResult;
|
|
@@ -225,11 +237,23 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|
|
225
237
|
const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
|
|
226
238
|
let productRecord = product as any;
|
|
227
239
|
|
|
228
|
-
|
|
240
|
+
const draft = await draftMode();
|
|
241
|
+
|
|
242
|
+
// Draft mode is how the CMS previews a product before it is public, so it must
|
|
243
|
+
// reach draft and scheduled rows — everyone else only sees active products whose
|
|
244
|
+
// go-live moment has passed.
|
|
245
|
+
if (
|
|
246
|
+
!productRecord ||
|
|
247
|
+
(!draft.isEnabled &&
|
|
248
|
+
!isPubliclyVisible({
|
|
249
|
+
status: productRecord.status,
|
|
250
|
+
publishedAt: productRecord.published_at,
|
|
251
|
+
liveStatus: LIVE_STATUS.product,
|
|
252
|
+
}))
|
|
253
|
+
) {
|
|
229
254
|
notFound();
|
|
230
255
|
}
|
|
231
256
|
|
|
232
|
-
const draft = await draftMode();
|
|
233
257
|
const visualEditingEnabled =
|
|
234
258
|
draft.isEnabled || process.env.NEXTBLOCK_VISUAL_EDITING_ENABLED === 'true';
|
|
235
259
|
|
|
@@ -15,6 +15,7 @@ if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
|
15
15
|
import { AuthProvider } from '../context/AuthContext';
|
|
16
16
|
import { LanguageProvider, useLanguage } from '../context/LanguageContext';
|
|
17
17
|
import { CurrentContentProvider } from '../context/CurrentContentContext';
|
|
18
|
+
import { ThemeCatalogProvider } from '../context/ThemeCatalogContext';
|
|
18
19
|
import { DeferredCartTranslator } from '../components/DeferredCartTranslator';
|
|
19
20
|
import { CurrencyProvider } from '@nextblock-cms/ecommerce/CurrencyProvider';
|
|
20
21
|
import { TranslationsProvider } from '@nextblock-cms/utils';
|
|
@@ -46,9 +47,20 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
|
|
|
46
47
|
initialDefaultLanguage,
|
|
47
48
|
rememberVisitorChoice,
|
|
48
49
|
translations,
|
|
49
|
-
nonce
|
|
50
|
+
nonce,
|
|
51
|
+
themeSlugs,
|
|
52
|
+
initialTheme,
|
|
53
|
+
themeCatalog,
|
|
50
54
|
} = props;
|
|
51
55
|
|
|
56
|
+
// Themes come from the site_themes table. Fall back to the three that ship in
|
|
57
|
+
// libs/ui/src/styles/theme.css if the table is empty or unreachable, so the
|
|
58
|
+
// switcher never renders an empty list.
|
|
59
|
+
const resolvedThemes: string[] =
|
|
60
|
+
Array.isArray(themeSlugs) && themeSlugs.length > 0 ? themeSlugs : ['light', 'dark', 'vibrant'];
|
|
61
|
+
const resolvedDefault: string =
|
|
62
|
+
typeof initialTheme === 'string' && resolvedThemes.includes(initialTheme) ? initialTheme : 'light';
|
|
63
|
+
|
|
52
64
|
return (
|
|
53
65
|
<AuthProvider serverUser={serverUser} serverProfile={serverProfile}>
|
|
54
66
|
<LanguageProvider
|
|
@@ -67,13 +79,13 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
|
|
|
67
79
|
<TranslationBridge translations={translations}>
|
|
68
80
|
<ThemeProvider
|
|
69
81
|
attribute="class"
|
|
70
|
-
defaultTheme=
|
|
82
|
+
defaultTheme={resolvedDefault}
|
|
71
83
|
enableSystem
|
|
72
84
|
disableTransitionOnChange
|
|
73
85
|
nonce={nonce}
|
|
74
|
-
themes={
|
|
86
|
+
themes={resolvedThemes}
|
|
75
87
|
>
|
|
76
|
-
{children}
|
|
88
|
+
<ThemeCatalogProvider themes={themeCatalog}>{children}</ThemeCatalogProvider>
|
|
77
89
|
</ThemeProvider>
|
|
78
90
|
</TranslationBridge>
|
|
79
91
|
</CurrentContentProvider>
|