create-nextblock 0.14.2 → 0.14.4
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/cron/reset-sandbox/sandboxResetSql.ts +375 -117
- 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/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/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/context/ThemeCatalogContext.tsx +44 -0
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
- 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/setup/migrations-bundle.ts +92 -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/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
|
@@ -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
|
|
@@ -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>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { HeadingBlockContent } from '../../../lib/blocks/blockRegistry';
|
|
3
|
+
import { resolveTextAlign, resolveTextColor } from '../../../lib/blocks/blockColors';
|
|
3
4
|
import type { VisualEditAttributes } from "../../../lib/visual-editing/types";
|
|
4
5
|
|
|
5
6
|
interface HeadingBlockRendererProps {
|
|
@@ -23,19 +24,15 @@ const HeadingBlockRenderer: React.FC<HeadingBlockRendererProps> = ({
|
|
|
23
24
|
: 2;
|
|
24
25
|
const Tag: React.ElementType = `h${level}`;
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
alignmentClass = `text-${content.textAlign}`;
|
|
29
|
-
}
|
|
27
|
+
const alignmentClass = resolveTextAlign(content.textAlign);
|
|
28
|
+
const { className: colorClass, style: colorStyle } = resolveTextColor(content.textColor);
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
30
|
+
const combinedClasses = ["my-6 font-bold container mx-auto", alignmentClass, colorClass]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(" ");
|
|
35
33
|
|
|
36
|
-
const combinedClasses = `my-6 font-bold container mx-auto ${alignmentClass} ${colorClass}`.trim();
|
|
37
34
|
return (
|
|
38
|
-
<Tag className={combinedClasses} {...visualEditAttributes}>
|
|
35
|
+
<Tag className={combinedClasses} style={colorStyle} {...visualEditAttributes}>
|
|
39
36
|
{content.text_content}
|
|
40
37
|
</Tag>
|
|
41
38
|
);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Aperture,
|
|
5
|
+
Atom,
|
|
6
|
+
Blend,
|
|
7
|
+
Brush,
|
|
8
|
+
Cloud,
|
|
9
|
+
Contrast,
|
|
10
|
+
Droplet,
|
|
11
|
+
Feather,
|
|
12
|
+
Flame,
|
|
13
|
+
Gem,
|
|
14
|
+
Ghost,
|
|
15
|
+
Leaf,
|
|
16
|
+
Moon,
|
|
17
|
+
Mountain,
|
|
18
|
+
Palette,
|
|
19
|
+
Rocket,
|
|
20
|
+
Snowflake,
|
|
21
|
+
Sparkles,
|
|
22
|
+
Star,
|
|
23
|
+
Sun,
|
|
24
|
+
Sunrise,
|
|
25
|
+
Sunset,
|
|
26
|
+
Waves,
|
|
27
|
+
Zap,
|
|
28
|
+
type LucideIcon,
|
|
29
|
+
} from "lucide-react";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Curated icon set offered to themes.
|
|
33
|
+
*
|
|
34
|
+
* A fixed map rather than a dynamic lucide lookup: the icon name comes from the
|
|
35
|
+
* database, and importing lucide dynamically by string would either pull the
|
|
36
|
+
* whole icon set into the bundle or break tree-shaking.
|
|
37
|
+
*/
|
|
38
|
+
export const THEME_ICONS: Record<string, LucideIcon> = {
|
|
39
|
+
Sun,
|
|
40
|
+
Moon,
|
|
41
|
+
Zap,
|
|
42
|
+
Sparkles,
|
|
43
|
+
Palette,
|
|
44
|
+
Flame,
|
|
45
|
+
Droplet,
|
|
46
|
+
Leaf,
|
|
47
|
+
Star,
|
|
48
|
+
Cloud,
|
|
49
|
+
Snowflake,
|
|
50
|
+
Waves,
|
|
51
|
+
Mountain,
|
|
52
|
+
Sunrise,
|
|
53
|
+
Sunset,
|
|
54
|
+
Contrast,
|
|
55
|
+
Blend,
|
|
56
|
+
Brush,
|
|
57
|
+
Aperture,
|
|
58
|
+
Atom,
|
|
59
|
+
Feather,
|
|
60
|
+
Gem,
|
|
61
|
+
Ghost,
|
|
62
|
+
Rocket,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const THEME_ICON_NAMES = Object.keys(THEME_ICONS);
|
|
66
|
+
|
|
67
|
+
export function ThemeIcon({
|
|
68
|
+
name,
|
|
69
|
+
size = 16,
|
|
70
|
+
className,
|
|
71
|
+
}: {
|
|
72
|
+
name: string;
|
|
73
|
+
size?: number;
|
|
74
|
+
className?: string;
|
|
75
|
+
}) {
|
|
76
|
+
const Icon = THEME_ICONS[name] ?? Palette;
|
|
77
|
+
return <Icon size={size} className={className} />;
|
|
78
|
+
}
|
|
@@ -1,90 +1,85 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { Button } from "@nextblock-cms/ui";
|
|
4
|
-
import {
|
|
5
|
-
DropdownMenu,
|
|
6
|
-
DropdownMenuContent,
|
|
7
|
-
DropdownMenuRadioGroup,
|
|
8
|
-
DropdownMenuRadioItem,
|
|
9
|
-
DropdownMenuTrigger,
|
|
10
|
-
} from "@nextblock-cms/ui";
|
|
11
|
-
import { Laptop
|
|
12
|
-
import { useTheme } from "next-themes";
|
|
13
|
-
import { useEffect, useState } from "react";
|
|
14
|
-
import { useTranslations } from "@nextblock-cms/utils";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
</DropdownMenu>
|
|
87
|
-
);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
export { ThemeSwitcher };
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@nextblock-cms/ui";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuRadioGroup,
|
|
8
|
+
DropdownMenuRadioItem,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from "@nextblock-cms/ui";
|
|
11
|
+
import { Laptop } from "lucide-react";
|
|
12
|
+
import { useTheme } from "next-themes";
|
|
13
|
+
import { useEffect, useState } from "react";
|
|
14
|
+
import { useTranslations } from "@nextblock-cms/utils";
|
|
15
|
+
import { useThemeCatalog } from "../context/ThemeCatalogContext";
|
|
16
|
+
import { ThemeIcon } from "./theme-icon";
|
|
17
|
+
|
|
18
|
+
const ICON_SIZE = 16;
|
|
19
|
+
|
|
20
|
+
const ThemeSwitcher = () => {
|
|
21
|
+
const [mounted, setMounted] = useState(false);
|
|
22
|
+
const { theme, setTheme } = useTheme();
|
|
23
|
+
const { t } = useTranslations();
|
|
24
|
+
const themes = useThemeCatalog();
|
|
25
|
+
|
|
26
|
+
// useEffect only runs on the client, so now we can safely show the UI
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setMounted(true);
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
if (!mounted) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const active = themes.find((entry) => entry.slug === theme);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Built-in slugs keep their existing translation keys so no copy is lost;
|
|
39
|
+
* admin-created themes fall back to the name stored on the row.
|
|
40
|
+
*/
|
|
41
|
+
const labelFor = (slug: string, name: string) => {
|
|
42
|
+
const builtin: Record<string, string> = {
|
|
43
|
+
light: "theme_light",
|
|
44
|
+
dark: "theme_dark",
|
|
45
|
+
vibrant: "theme_vibrant",
|
|
46
|
+
};
|
|
47
|
+
const key = builtin[slug];
|
|
48
|
+
if (!key) return name;
|
|
49
|
+
const translated = t(key);
|
|
50
|
+
return translated === key ? name : translated;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<DropdownMenu>
|
|
55
|
+
<DropdownMenuTrigger asChild>
|
|
56
|
+
<Button variant="ghost" size={"sm"} aria-label={t('theme_switcher')}>
|
|
57
|
+
{active ? (
|
|
58
|
+
<ThemeIcon name={active.icon} size={ICON_SIZE} className="text-muted-foreground" />
|
|
59
|
+
) : (
|
|
60
|
+
<Laptop size={ICON_SIZE} className="text-muted-foreground" />
|
|
61
|
+
)}
|
|
62
|
+
</Button>
|
|
63
|
+
</DropdownMenuTrigger>
|
|
64
|
+
<DropdownMenuContent className="w-content" align="start">
|
|
65
|
+
<DropdownMenuRadioGroup
|
|
66
|
+
value={theme}
|
|
67
|
+
onValueChange={(e) => setTheme(e)}
|
|
68
|
+
>
|
|
69
|
+
{themes.map((entry) => (
|
|
70
|
+
<DropdownMenuRadioItem key={entry.slug} className="flex gap-2" value={entry.slug}>
|
|
71
|
+
<ThemeIcon name={entry.icon} size={ICON_SIZE} className="text-muted-foreground" />{" "}
|
|
72
|
+
<span>{labelFor(entry.slug, entry.name)}</span>
|
|
73
|
+
</DropdownMenuRadioItem>
|
|
74
|
+
))}
|
|
75
|
+
<DropdownMenuRadioItem className="flex gap-2" value="system">
|
|
76
|
+
<Laptop size={ICON_SIZE} className="text-muted-foreground" />{" "}
|
|
77
|
+
<span>{t('theme_system')}</span>
|
|
78
|
+
</DropdownMenuRadioItem>
|
|
79
|
+
</DropdownMenuRadioGroup>
|
|
80
|
+
</DropdownMenuContent>
|
|
81
|
+
</DropdownMenu>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export { ThemeSwitcher };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
/** The switcher-facing shape of a row in `site_themes`. */
|
|
6
|
+
export interface ThemeCatalogEntry {
|
|
7
|
+
slug: string;
|
|
8
|
+
name: string;
|
|
9
|
+
/** lucide-react icon name. */
|
|
10
|
+
icon: string;
|
|
11
|
+
colorScheme: "light" | "dark";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Themes are read once on the server in app/layout.tsx and handed to the client
|
|
16
|
+
* through this context, so the switcher never fetches and never hardcodes a
|
|
17
|
+
* list. Falls back to the palette shipped in libs/ui/src/styles/theme.css when
|
|
18
|
+
* the table is empty or unreachable.
|
|
19
|
+
*/
|
|
20
|
+
export const FALLBACK_THEME_CATALOG: ThemeCatalogEntry[] = [
|
|
21
|
+
{ slug: "light", name: "Light", icon: "Sun", colorScheme: "light" },
|
|
22
|
+
{ slug: "dark", name: "Dark", icon: "Moon", colorScheme: "dark" },
|
|
23
|
+
{ slug: "vibrant", name: "Vibrant", icon: "Zap", colorScheme: "dark" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const ThemeCatalogContext = createContext<ThemeCatalogEntry[]>(FALLBACK_THEME_CATALOG);
|
|
27
|
+
|
|
28
|
+
export function ThemeCatalogProvider({
|
|
29
|
+
themes,
|
|
30
|
+
children,
|
|
31
|
+
}: {
|
|
32
|
+
themes?: ThemeCatalogEntry[];
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
}) {
|
|
35
|
+
const value = useMemo(
|
|
36
|
+
() => (Array.isArray(themes) && themes.length > 0 ? themes : FALLBACK_THEME_CATALOG),
|
|
37
|
+
[themes],
|
|
38
|
+
);
|
|
39
|
+
return <ThemeCatalogContext.Provider value={value}>{children}</ThemeCatalogContext.Provider>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useThemeCatalog(): ThemeCatalogEntry[] {
|
|
43
|
+
return useContext(ThemeCatalogContext);
|
|
44
|
+
}
|
|
@@ -200,3 +200,80 @@ contract lives in `libs/sdk` and is documented in
|
|
|
200
200
|
|
|
201
201
|
If you are changing how blocks work inside the CMS, start here. If you are
|
|
202
202
|
designing a reusable third-party block contract, start with the SDK doc.
|
|
203
|
+
|
|
204
|
+
## Themes and Colour
|
|
205
|
+
|
|
206
|
+
### Editable site themes
|
|
207
|
+
|
|
208
|
+
Themes are rows in `site_themes`, edited at **/cms/settings/global-css**. An
|
|
209
|
+
ADMIN can recolour any theme, create new ones, duplicate, reorder, hide, set the
|
|
210
|
+
site default, and delete non-system themes.
|
|
211
|
+
|
|
212
|
+
The pipeline is:
|
|
213
|
+
|
|
214
|
+
1. `app/layout.tsx` reads the table through a cached `getCachedSiteThemes()`
|
|
215
|
+
(tag `public-layout-site-themes`).
|
|
216
|
+
2. `lib/themes/buildThemeCss.ts` renders each row to a `:root.<slug> { ... }`
|
|
217
|
+
rule, injected as a `<style id="nb-theme-tokens">` in `<head>` — before the
|
|
218
|
+
Global CSS box, so custom CSS can still override a theme.
|
|
219
|
+
3. `app/providers.tsx` feeds the slug list and default to `next-themes`, and
|
|
220
|
+
`context/ThemeCatalogContext.tsx` carries names + icons to the switcher.
|
|
221
|
+
|
|
222
|
+
`libs/ui/src/styles/theme.css` remains as the fallback palette for standalone
|
|
223
|
+
consumers of the published `@nextblock-cms/ui` package. Generated rules use
|
|
224
|
+
`:root.<slug>` (specificity 0,2,0) so they always beat that file's `.dark` /
|
|
225
|
+
`.vibrant` rules (0,1,0) regardless of stylesheet order.
|
|
226
|
+
|
|
227
|
+
### Token storage
|
|
228
|
+
|
|
229
|
+
Colour tokens are stored as **bare HSL triplets** (`"222 47% 11%"`), not hex,
|
|
230
|
+
because `libs/ui/tailwind.config.js` composes them as `hsl(var(--primary))` and
|
|
231
|
+
appends alpha as `hsl(var(--primary) / 0.5)` — only a bare triplet supports
|
|
232
|
+
that. `lib/themes/tokenColor.ts` converts to and from the hex the colour picker
|
|
233
|
+
speaks. The allowed token list lives in `lib/themes/tokens.ts`; anything not on
|
|
234
|
+
it is dropped on save, and values are shape-checked, because the result is
|
|
235
|
+
interpolated into a `<style>` tag.
|
|
236
|
+
|
|
237
|
+
Per-theme `extra_css` is emitted **nested inside** the theme rule, so authors
|
|
238
|
+
write `& h1 { ... }` and scoping is automatic. `sanitizeExtraCss()` strips `<`
|
|
239
|
+
and unbalanced `}` so it cannot escape the rule or close the style element.
|
|
240
|
+
|
|
241
|
+
### Known limitation: `dark:` utilities under custom themes
|
|
242
|
+
|
|
243
|
+
Tailwind's dark variant compiles to `.dark`, and `darkMode: ['class']` is fixed
|
|
244
|
+
at build time. A custom theme with `color_scheme: 'dark'` recolours every token
|
|
245
|
+
and sets CSS `color-scheme`, but does **not** activate `dark:` utilities — the
|
|
246
|
+
same behaviour the shipped `.vibrant` theme has always had.
|
|
247
|
+
|
|
248
|
+
It cannot be fixed by mapping the theme to two classes: `next-themes` applies
|
|
249
|
+
its value with a single `classList.add(value)`, and `DOMTokenList.add` throws
|
|
250
|
+
`InvalidCharacterError` on a string containing a space. Wiring `dark:` to a data
|
|
251
|
+
attribute would require a pre-hydration script mirroring next-themes' own.
|
|
252
|
+
|
|
253
|
+
Verify the database → CSS path without booting the app:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
npm run verify:site-themes
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Block text colour
|
|
260
|
+
|
|
261
|
+
`apps/nextblock/lib/blocks/blockColors.ts` is the single source of truth for
|
|
262
|
+
block text colour, shared by `HeadingBlockRenderer`, `EditableBlock` and
|
|
263
|
+
`ColumnEditor` so the CMS preview cannot drift from the live page.
|
|
264
|
+
|
|
265
|
+
A stored value is either a **theme token** keyword (`"primary"` — follows the
|
|
266
|
+
theme) or a **literal CSS colour** (`"#FF8800"` — fixed). Tokens render as a
|
|
267
|
+
static Tailwind class; literals render as an inline `style`. `resolveTextColor()`
|
|
268
|
+
returns `{ className?, style? }` and falls back to `{}` for anything it does not
|
|
269
|
+
recognise, so a hand-edited value degrades to the inherited colour instead of
|
|
270
|
+
emitting a bogus class.
|
|
271
|
+
|
|
272
|
+
Class names here are written as **complete literals**. Tailwind v4 ignores the
|
|
273
|
+
`safelist` key in the legacy JS config — `libs/ui/tailwind.config.js` still
|
|
274
|
+
carries one, but v4 never reads it, so a class exists only if the scanner finds
|
|
275
|
+
the whole string in a source file.
|
|
276
|
+
|
|
277
|
+
The editor control is `ColorField` from `libs/ui`, which offers the theme tokens
|
|
278
|
+
and a custom picker (hex, alpha, screen eyedropper, recent colours) with a live
|
|
279
|
+
WCAG contrast badge measured against the section background.
|