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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
Input,
|
|
8
|
+
Label,
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
} from '@nextblock-cms/ui';
|
|
16
|
+
import { toast } from 'sonner';
|
|
17
|
+
import { Copy, Loader2, Plus, Star, Trash2 } from 'lucide-react';
|
|
18
|
+
import type { SiteTheme } from '../../../../../lib/themes/buildThemeCss';
|
|
19
|
+
import {
|
|
20
|
+
createTheme,
|
|
21
|
+
deleteTheme,
|
|
22
|
+
duplicateTheme,
|
|
23
|
+
setDefaultTheme,
|
|
24
|
+
updateTheme,
|
|
25
|
+
} from '../theme-actions';
|
|
26
|
+
import { ThemeEditor, ThemePreview, themeToDraft, type ThemeDraft } from './ThemeEditor';
|
|
27
|
+
import { ThemeIcon } from '../../../../../components/theme-icon';
|
|
28
|
+
|
|
29
|
+
export default function ThemeManager({ initialThemes }: { initialThemes: SiteTheme[] }) {
|
|
30
|
+
const router = useRouter();
|
|
31
|
+
const [themes, setThemes] = React.useState(initialThemes);
|
|
32
|
+
const [selectedId, setSelectedId] = React.useState(initialThemes[0]?.id ?? '');
|
|
33
|
+
const [draft, setDraft] = React.useState<ThemeDraft | null>(
|
|
34
|
+
initialThemes[0] ? themeToDraft(initialThemes[0]) : null,
|
|
35
|
+
);
|
|
36
|
+
const [pending, setPending] = React.useState<string | null>(null);
|
|
37
|
+
const [createOpen, setCreateOpen] = React.useState(false);
|
|
38
|
+
const [newName, setNewName] = React.useState('');
|
|
39
|
+
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
setThemes(initialThemes);
|
|
42
|
+
}, [initialThemes]);
|
|
43
|
+
|
|
44
|
+
const selected = themes.find((theme) => theme.id === selectedId) ?? null;
|
|
45
|
+
|
|
46
|
+
// Switching themes discards nothing silently: the draft is per-theme and only
|
|
47
|
+
// written back on Save, so we reseed whenever the selection changes.
|
|
48
|
+
const selectTheme = (theme: SiteTheme) => {
|
|
49
|
+
setSelectedId(theme.id);
|
|
50
|
+
setDraft(themeToDraft(theme));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const isDirty = React.useMemo(() => {
|
|
54
|
+
if (!selected || !draft) return false;
|
|
55
|
+
return JSON.stringify(draft) !== JSON.stringify(themeToDraft(selected));
|
|
56
|
+
}, [selected, draft]);
|
|
57
|
+
|
|
58
|
+
const run = async (key: string, fn: () => Promise<{ ok: boolean; message?: string; error?: string }>) => {
|
|
59
|
+
setPending(key);
|
|
60
|
+
try {
|
|
61
|
+
const result = await fn();
|
|
62
|
+
if (result.ok) {
|
|
63
|
+
toast.success(result.message ?? 'Done.');
|
|
64
|
+
router.refresh();
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
toast.error(result.error ?? 'Something went wrong.');
|
|
68
|
+
return false;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
toast.error(error instanceof Error ? error.message : 'Something went wrong.');
|
|
71
|
+
return false;
|
|
72
|
+
} finally {
|
|
73
|
+
setPending(null);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const handleSave = async () => {
|
|
78
|
+
if (!selected || !draft) return;
|
|
79
|
+
await run('save', () =>
|
|
80
|
+
updateTheme(selected.id, {
|
|
81
|
+
name: draft.name,
|
|
82
|
+
description: draft.description,
|
|
83
|
+
icon: draft.icon,
|
|
84
|
+
color_scheme: draft.color_scheme,
|
|
85
|
+
tokens: draft.tokens,
|
|
86
|
+
extra_css: draft.extra_css,
|
|
87
|
+
is_active: selected.is_default ? true : draft.is_active,
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleCreate = async () => {
|
|
93
|
+
const name = newName.trim();
|
|
94
|
+
if (!name) return;
|
|
95
|
+
const ok = await run('create', () =>
|
|
96
|
+
createTheme({
|
|
97
|
+
name,
|
|
98
|
+
// Start from the currently selected theme's palette so a new theme is a
|
|
99
|
+
// usable variation rather than an unreadable blank slate.
|
|
100
|
+
tokens: draft?.tokens ?? {},
|
|
101
|
+
color_scheme: draft?.color_scheme ?? 'light',
|
|
102
|
+
icon: 'Palette',
|
|
103
|
+
sort_order: (themes[themes.length - 1]?.sort_order ?? 0) + 10,
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
if (ok) {
|
|
107
|
+
setCreateOpen(false);
|
|
108
|
+
setNewName('');
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (!selected || !draft) {
|
|
113
|
+
return (
|
|
114
|
+
<div className="rounded-lg border border-dashed p-8 text-center">
|
|
115
|
+
<p className="text-sm text-muted-foreground">
|
|
116
|
+
No themes found. Run the pending database migration to seed Light, Dark and Vibrant.
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="space-y-4">
|
|
124
|
+
<div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
|
|
125
|
+
{/* Theme list */}
|
|
126
|
+
<aside className="space-y-2">
|
|
127
|
+
<div className="flex items-center justify-between">
|
|
128
|
+
<h2 className="text-sm font-semibold">Themes</h2>
|
|
129
|
+
<Button type="button" variant="ghost" size="sm" className="h-7 px-2" onClick={() => setCreateOpen(true)}>
|
|
130
|
+
<Plus className="h-3.5 w-3.5" />
|
|
131
|
+
</Button>
|
|
132
|
+
</div>
|
|
133
|
+
<ul className="space-y-1">
|
|
134
|
+
{themes.map((theme) => {
|
|
135
|
+
const active = theme.id === selectedId;
|
|
136
|
+
return (
|
|
137
|
+
<li key={theme.id}>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
onClick={() => selectTheme(theme)}
|
|
141
|
+
aria-current={active}
|
|
142
|
+
className={
|
|
143
|
+
'flex w-full items-center gap-2 rounded-md border px-2.5 py-2 text-left text-sm transition-colors ' +
|
|
144
|
+
(active ? 'border-ring bg-accent/50' : 'border-transparent hover:bg-accent/30')
|
|
145
|
+
}
|
|
146
|
+
>
|
|
147
|
+
<ThemeIcon name={theme.icon} size={15} className="shrink-0 text-muted-foreground" />
|
|
148
|
+
<span className="min-w-0 flex-1 truncate">{theme.name}</span>
|
|
149
|
+
{theme.is_default ? (
|
|
150
|
+
<Star className="h-3.5 w-3.5 shrink-0 fill-current text-amber-500" aria-label="Default theme" />
|
|
151
|
+
) : null}
|
|
152
|
+
{!theme.is_active ? (
|
|
153
|
+
<span className="shrink-0 text-[10px] text-muted-foreground">hidden</span>
|
|
154
|
+
) : null}
|
|
155
|
+
</button>
|
|
156
|
+
</li>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</ul>
|
|
160
|
+
</aside>
|
|
161
|
+
|
|
162
|
+
{/* Editor */}
|
|
163
|
+
<div className="min-w-0 space-y-4">
|
|
164
|
+
<div className="flex flex-wrap items-center gap-2 border-b pb-3">
|
|
165
|
+
<Button type="button" onClick={handleSave} disabled={!isDirty || pending !== null}>
|
|
166
|
+
{pending === 'save' ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
|
167
|
+
{isDirty ? 'Save changes' : 'Saved'}
|
|
168
|
+
</Button>
|
|
169
|
+
<Button
|
|
170
|
+
type="button"
|
|
171
|
+
variant="outline"
|
|
172
|
+
size="sm"
|
|
173
|
+
disabled={pending !== null}
|
|
174
|
+
onClick={() => run('duplicate', () => duplicateTheme(selected.id))}
|
|
175
|
+
>
|
|
176
|
+
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
|
177
|
+
Duplicate
|
|
178
|
+
</Button>
|
|
179
|
+
{!selected.is_default ? (
|
|
180
|
+
<Button
|
|
181
|
+
type="button"
|
|
182
|
+
variant="outline"
|
|
183
|
+
size="sm"
|
|
184
|
+
disabled={pending !== null}
|
|
185
|
+
onClick={() => run('default', () => setDefaultTheme(selected.id))}
|
|
186
|
+
>
|
|
187
|
+
<Star className="mr-1.5 h-3.5 w-3.5" />
|
|
188
|
+
Make default
|
|
189
|
+
</Button>
|
|
190
|
+
) : null}
|
|
191
|
+
{!selected.is_system && !selected.is_default ? (
|
|
192
|
+
<Button
|
|
193
|
+
type="button"
|
|
194
|
+
variant="outline"
|
|
195
|
+
size="sm"
|
|
196
|
+
disabled={pending !== null}
|
|
197
|
+
className="text-destructive hover:text-destructive"
|
|
198
|
+
onClick={() => {
|
|
199
|
+
if (
|
|
200
|
+
!window.confirm(
|
|
201
|
+
`Delete "${selected.name}"? Visitors currently using it will fall back to the default theme.`,
|
|
202
|
+
)
|
|
203
|
+
) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
void run('delete', () => deleteTheme(selected.id)).then((ok) => {
|
|
207
|
+
if (ok) {
|
|
208
|
+
const next = themes.find((theme) => theme.id !== selected.id);
|
|
209
|
+
if (next) selectTheme(next);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
|
215
|
+
Delete
|
|
216
|
+
</Button>
|
|
217
|
+
) : null}
|
|
218
|
+
{selected.is_system ? (
|
|
219
|
+
<span className="text-[11px] text-muted-foreground">
|
|
220
|
+
System theme — fully editable, but cannot be deleted because "System" resolves to it.
|
|
221
|
+
</span>
|
|
222
|
+
) : null}
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<ThemePreview draft={draft} />
|
|
226
|
+
|
|
227
|
+
<ThemeEditor theme={selected} draft={draft} onDraftChange={setDraft} />
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
232
|
+
<DialogContent>
|
|
233
|
+
<DialogHeader>
|
|
234
|
+
<DialogTitle>New theme</DialogTitle>
|
|
235
|
+
<DialogDescription>
|
|
236
|
+
Starts as a copy of "{selected.name}" so you can adjust from a working palette.
|
|
237
|
+
</DialogDescription>
|
|
238
|
+
</DialogHeader>
|
|
239
|
+
<div className="space-y-1.5">
|
|
240
|
+
<Label htmlFor="new-theme-name">Name</Label>
|
|
241
|
+
<Input
|
|
242
|
+
id="new-theme-name"
|
|
243
|
+
value={newName}
|
|
244
|
+
onChange={(event) => setNewName(event.target.value)}
|
|
245
|
+
placeholder="Midnight"
|
|
246
|
+
onKeyDown={(event) => {
|
|
247
|
+
if (event.key === 'Enter') {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
void handleCreate();
|
|
250
|
+
}
|
|
251
|
+
}}
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
<DialogFooter>
|
|
255
|
+
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
|
|
256
|
+
Cancel
|
|
257
|
+
</Button>
|
|
258
|
+
<Button type="button" onClick={handleCreate} disabled={!newName.trim() || pending !== null}>
|
|
259
|
+
{pending === 'create' ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
|
260
|
+
Create theme
|
|
261
|
+
</Button>
|
|
262
|
+
</DialogFooter>
|
|
263
|
+
</DialogContent>
|
|
264
|
+
</Dialog>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -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
|
+
}
|