@commonpub/layer 0.21.22 → 0.22.1

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.
@@ -0,0 +1,449 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * /admin/theme — top-level theme manager.
4
+ *
5
+ * Lists every theme available to the picker (built-in, code-registered,
6
+ * DB-stored custom) grouped by family. The currently active family is
7
+ * highlighted. From here the admin can:
8
+ *
9
+ * • select any theme (sets `theme.default` instance-wide)
10
+ * • edit a custom theme (opens /admin/theme/edit/[id])
11
+ * • duplicate / fork any theme into a new editable custom theme
12
+ * • delete a custom theme
13
+ * • create a new custom theme from scratch
14
+ * • capture the currently-applied :root tokens (when a thin layer app
15
+ * ships its own CSS overrides) into a new editable custom theme
16
+ * • import a theme from a .cpub-theme.json file
17
+ * • adjust the legacy "token overrides" — ad-hoc inline tweaks that
18
+ * apply on top of whichever theme is active
19
+ *
20
+ * Heavy lifting lives in `useThemeAdmin`. This page is the orchestration
21
+ * surface.
22
+ */
23
+ import { onMounted, ref, computed, watch } from 'vue';
24
+ // Auto-imported by Nuxt:
25
+ // useThemeAdmin ← composables/useThemeAdmin.ts
26
+ // parseCustomThemeId ← utils/themeIds.ts
27
+ // buildExportFile, ← utils/themeIO.ts
28
+ // parseExportFile,
29
+ // downloadThemeFile
30
+ // detectAppliedOverrides ← utils/themeDiscovery.ts
31
+
32
+ definePageMeta({ layout: 'admin', middleware: 'auth' });
33
+ useSeoMeta({ title: `Theme — Admin — ${useSiteName()}` });
34
+
35
+ const themesApi = useThemeAdmin();
36
+ const router = useRouter();
37
+
38
+ const { data: settings, refresh: refreshSettings } = await useFetch<Record<string, unknown>>('/api/admin/settings');
39
+
40
+ const saving = ref(false);
41
+ const toast = ref<{ msg: string; tone: 'success' | 'error' } | null>(null);
42
+
43
+ const instanceDefault = computed<string>(() => {
44
+ const val = settings.value?.['theme.default'];
45
+ return typeof val === 'string' ? val : 'base';
46
+ });
47
+
48
+ /** Which family is currently active? Computed by checking the picked
49
+ * themeId against each family's light/dark variant. */
50
+ const activeFamily = computed<string | null>(() => {
51
+ const id = instanceDefault.value;
52
+ for (const f of themesApi.families.value) {
53
+ if (f.light?.id === id || f.dark?.id === id) return f.id;
54
+ }
55
+ return null;
56
+ });
57
+
58
+ onMounted(async () => {
59
+ await themesApi.refresh();
60
+ recheckDiscovery();
61
+ });
62
+
63
+ // --- Selection ---
64
+
65
+ async function setActiveTheme(themeId: string): Promise<void> {
66
+ saving.value = true;
67
+ try {
68
+ await $fetch('/api/admin/settings', {
69
+ method: 'PUT',
70
+ body: { key: 'theme.default', value: themeId },
71
+ });
72
+ await refreshSettings();
73
+ notify('Theme applied', 'success');
74
+ } catch (err) {
75
+ notify(err instanceof Error ? err.message : 'Failed to save', 'error');
76
+ } finally {
77
+ saving.value = false;
78
+ }
79
+ }
80
+
81
+ // --- Edit / Duplicate / Delete ---
82
+
83
+ function editTheme(themeId: string): void {
84
+ const customId = parseCustomThemeId(themeId);
85
+ if (!customId) return;
86
+ router.push(`/admin/theme/edit/${customId}`);
87
+ }
88
+
89
+ async function duplicateTheme(themeId: string): Promise<void> {
90
+ // Build a seed in client-side, then push to the editor in "create" mode.
91
+ // The editor reads the seed from a sessionStorage key (avoids a server
92
+ // round-trip just to create-then-edit).
93
+ const customId = parseCustomThemeId(themeId);
94
+ let seed: {
95
+ id: string;
96
+ name: string;
97
+ description: string;
98
+ family: string;
99
+ isDark: boolean;
100
+ parentTheme: string;
101
+ tokens: Record<string, string>;
102
+ };
103
+
104
+ if (customId && themesApi.data.value) {
105
+ const src = themesApi.data.value.custom.find((t) => t.id === customId);
106
+ if (!src) return;
107
+ seed = {
108
+ id: nextAvailableId(`${src.id}-copy`),
109
+ name: `${src.name} (copy)`,
110
+ description: src.description ?? '',
111
+ family: src.family,
112
+ isDark: src.isDark,
113
+ parentTheme: src.parentTheme,
114
+ tokens: { ...src.tokens },
115
+ };
116
+ } else {
117
+ // Forking a built-in or registered theme — seed tokens from computed
118
+ // styles by switching <html> data-theme momentarily. Cleaner: pull
119
+ // defaults from TOKEN_SPECS. We use a hybrid: defaults for known
120
+ // tokens with the active-theme overrides for the few that aren't.
121
+ const detected = detectAppliedOverrides();
122
+ seed = {
123
+ id: nextAvailableId(themeId.replace(/^cpub-custom-/, '') + '-fork'),
124
+ name: `Custom — based on ${themeId}`,
125
+ description: '',
126
+ family: `custom-${themeId.replace(/^cpub-custom-/, '')}`,
127
+ isDark: detected.isDark,
128
+ parentTheme: themeId,
129
+ tokens: detected.tokens,
130
+ };
131
+ }
132
+
133
+ sessionStorage.setItem('cpub-theme-editor-seed', JSON.stringify(seed));
134
+ router.push('/admin/theme/edit/__new');
135
+ }
136
+
137
+ async function removeTheme(themeId: string): Promise<void> {
138
+ const customId = parseCustomThemeId(themeId);
139
+ if (!customId) return;
140
+ if (!confirm(`Delete custom theme "${customId}"? This cannot be undone.`)) return;
141
+ saving.value = true;
142
+ try {
143
+ const res = await $fetch<{ ok: true; resetDefault: boolean }>(`/api/admin/themes/${customId}`, {
144
+ method: 'DELETE',
145
+ });
146
+ await Promise.all([themesApi.refresh(), refreshSettings()]);
147
+ notify(res.resetDefault ? 'Theme deleted — default reset to Classic' : 'Theme deleted', 'success');
148
+ } catch (err) {
149
+ notify(err instanceof Error ? err.message : 'Failed to delete', 'error');
150
+ } finally {
151
+ saving.value = false;
152
+ }
153
+ }
154
+
155
+ // --- Create / Capture / Import ---
156
+
157
+ function createBlank(): void {
158
+ const seed = {
159
+ id: nextAvailableId('my-theme'),
160
+ name: 'My theme',
161
+ description: '',
162
+ family: 'custom',
163
+ isDark: false,
164
+ parentTheme: 'base',
165
+ tokens: {},
166
+ };
167
+ sessionStorage.setItem('cpub-theme-editor-seed', JSON.stringify(seed));
168
+ router.push('/admin/theme/edit/__new');
169
+ }
170
+
171
+ function captureCurrent(): void {
172
+ const detected = detectAppliedOverrides();
173
+ if (detected.count === 0) {
174
+ notify('No custom tokens detected at :root', 'error');
175
+ return;
176
+ }
177
+ const seed = {
178
+ id: nextAvailableId(`captured-${new Date().toISOString().slice(0, 10)}`),
179
+ name: 'Captured current site theme',
180
+ description: `Auto-captured from the live :root on ${new Date().toLocaleDateString()} — ${detected.count} tokens.`,
181
+ family: 'captured',
182
+ isDark: detected.isDark,
183
+ parentTheme: detected.isDark ? 'dark' : 'base',
184
+ tokens: detected.tokens,
185
+ };
186
+ sessionStorage.setItem('cpub-theme-editor-seed', JSON.stringify(seed));
187
+ router.push('/admin/theme/edit/__new');
188
+ }
189
+
190
+ const importFileInput = ref<HTMLInputElement | null>(null);
191
+ function openImportDialog(): void {
192
+ importFileInput.value?.click();
193
+ }
194
+ async function onImportFile(e: Event): Promise<void> {
195
+ const file = (e.target as HTMLInputElement).files?.[0];
196
+ if (!file) return;
197
+ const text = await file.text();
198
+ try {
199
+ const theme = parseExportFile(text);
200
+ theme.id = nextAvailableId(theme.id);
201
+ sessionStorage.setItem('cpub-theme-editor-seed', JSON.stringify(theme));
202
+ router.push('/admin/theme/edit/__new');
203
+ } catch (err) {
204
+ notify(`Import failed: ${err instanceof Error ? err.message : 'unknown error'}`, 'error');
205
+ }
206
+ (e.target as HTMLInputElement).value = '';
207
+ }
208
+
209
+ function exportTheme(themeId: string): void {
210
+ const customId = parseCustomThemeId(themeId);
211
+ if (!customId) return;
212
+ const src = themesApi.findCustom(customId);
213
+ if (!src) return;
214
+ downloadThemeFile(src);
215
+ notify(`Exported ${src.id}.cpub-theme.json`, 'success');
216
+ }
217
+
218
+ // --- Helpers ---
219
+
220
+ function nextAvailableId(base: string): string {
221
+ const slug = base.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
222
+ const used = new Set((themesApi.data.value?.custom ?? []).map((t) => t.id));
223
+ if (!used.has(slug)) return slug;
224
+ let i = 2;
225
+ while (used.has(`${slug}-${i}`)) i++;
226
+ return `${slug}-${i}`;
227
+ }
228
+
229
+ function notify(msg: string, tone: 'success' | 'error'): void {
230
+ toast.value = { msg, tone };
231
+ setTimeout(() => { toast.value = null; }, 2400);
232
+ }
233
+
234
+ // --- Discovery (client-only) ---
235
+
236
+ const discovery = ref<{ count: number; tokens: Record<string, string>; isDark: boolean }>({
237
+ count: 0,
238
+ tokens: {},
239
+ isDark: false,
240
+ });
241
+
242
+ function recheckDiscovery(): void {
243
+ if (typeof window === 'undefined') return;
244
+ discovery.value = detectAppliedOverrides();
245
+ }
246
+
247
+ /**
248
+ * Only show the "your site has a custom theme" banner when the detected
249
+ * overrides are LIKELY from a CSS file shipped by the layer app — NOT from
250
+ * a custom theme the admin has already saved (which would also appear as
251
+ * :root token overrides because the SSR middleware injects them there).
252
+ *
253
+ * Gating rules:
254
+ * - hide when the active default is already a cpub-custom-* theme
255
+ * - hide when instance-wide token overrides are set (those tokens explain
256
+ * the diff; the banner would confuse the admin into re-capturing them)
257
+ * - hide when no overrides were detected
258
+ *
259
+ * If admins want to re-capture from a fresh :root state, they can revert
260
+ * to the base theme, clear overrides, then the banner will reappear.
261
+ */
262
+ const showDiscoveryBanner = computed<boolean>(() => {
263
+ if (discovery.value.count === 0) return false;
264
+ if (instanceDefault.value.startsWith('cpub-custom-')) return false;
265
+ if (Object.keys(initialOverrides.value).length > 0) return false;
266
+ return true;
267
+ });
268
+
269
+ // --- Token overrides (legacy / quick tweaks) ---
270
+ // State + UI live in <AdminThemeOverridesPanel>; this page only persists
271
+ // what the panel emits.
272
+
273
+ const initialOverrides = computed<Record<string, string>>(() => {
274
+ const raw = settings.value?.['theme.token_overrides'];
275
+ return raw && typeof raw === 'object' && !Array.isArray(raw)
276
+ ? { ...(raw as Record<string, string>) }
277
+ : {};
278
+ });
279
+
280
+ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
281
+ saving.value = true;
282
+ try {
283
+ await $fetch('/api/admin/settings', {
284
+ method: 'PUT',
285
+ body: { key: 'theme.token_overrides', value: overrides },
286
+ });
287
+ await refreshSettings();
288
+ notify('Overrides saved', 'success');
289
+ } catch (err) {
290
+ notify(err instanceof Error ? err.message : 'Failed to save', 'error');
291
+ } finally {
292
+ saving.value = false;
293
+ }
294
+ }
295
+ </script>
296
+
297
+ <template>
298
+ <div class="admin-theme-page">
299
+ <header class="admin-theme-header">
300
+ <div>
301
+ <h1 class="admin-page-title">Theme</h1>
302
+ <p class="admin-page-desc">
303
+ Pick a theme, edit your own, or capture the look your layer app already ships
304
+ with. Changes apply instance-wide; individual users still control light/dark.
305
+ </p>
306
+ </div>
307
+ <div class="admin-theme-actions">
308
+ <button class="cpub-btn" :disabled="saving" @click="openImportDialog">
309
+ <i class="fa-solid fa-file-import" aria-hidden="true" /> Import…
310
+ </button>
311
+ <input
312
+ ref="importFileInput"
313
+ type="file"
314
+ accept="application/json,.json,.cpub-theme.json"
315
+ hidden
316
+ @change="onImportFile"
317
+ />
318
+ <button class="cpub-btn cpub-btn-primary" :disabled="saving" @click="createBlank">
319
+ <i class="fa-solid fa-plus" aria-hidden="true" /> New custom theme
320
+ </button>
321
+ </div>
322
+ </header>
323
+
324
+ <div v-if="toast" class="admin-theme-toast" :class="`tone-${toast.tone}`">
325
+ <i :class="['fa-solid', toast.tone === 'success' ? 'fa-check' : 'fa-triangle-exclamation']" aria-hidden="true" />
326
+ {{ toast.msg }}
327
+ </div>
328
+
329
+ <!-- Discovery banner — only when the overrides are from CSS (not from
330
+ a custom theme this admin already saved or instance-wide overrides).
331
+ Without this gate, the banner would re-appear after capture since
332
+ the custom theme it created now appears as a token override on :root. -->
333
+ <section
334
+ v-if="showDiscoveryBanner"
335
+ class="admin-theme-discovery"
336
+ role="region"
337
+ aria-label="Discovered theme tokens"
338
+ >
339
+ <div class="admin-theme-discovery-icon"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true" /></div>
340
+ <div class="admin-theme-discovery-body">
341
+ <h2 class="admin-theme-discovery-title">Your site has a custom theme</h2>
342
+ <p class="admin-theme-discovery-desc">
343
+ We detected <strong>{{ discovery.count }}</strong> CSS token{{ discovery.count === 1 ? '' : 's' }}
344
+ on <code>:root</code> that differ from the built-in defaults — probably from
345
+ a CSS file your layer app loads. Capture it into an editable custom theme so
346
+ you can tweak it from this admin panel.
347
+ </p>
348
+ </div>
349
+ <button class="cpub-btn cpub-btn-primary" :disabled="saving" @click="captureCurrent">
350
+ <i class="fa-solid fa-camera" aria-hidden="true" /> Capture
351
+ </button>
352
+ </section>
353
+
354
+ <p v-if="themesApi.loading.value && !themesApi.data.value" class="admin-empty">
355
+ <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" /> Loading themes…
356
+ </p>
357
+
358
+ <section v-else class="admin-theme-families">
359
+ <AdminThemeFamilyCard
360
+ v-for="family in themesApi.families.value"
361
+ :key="family.id"
362
+ :family="family"
363
+ :active="activeFamily === family.id"
364
+ :saving="saving"
365
+ @select="setActiveTheme"
366
+ @edit="editTheme"
367
+ @duplicate="duplicateTheme"
368
+ @export-theme="exportTheme"
369
+ @remove="removeTheme"
370
+ />
371
+ </section>
372
+
373
+ <!-- Token overrides (legacy / quick tweaks) -->
374
+ <AdminThemeOverridesPanel
375
+ :initial="initialOverrides"
376
+ :saving="saving"
377
+ @save="saveOverrides"
378
+ />
379
+ </div>
380
+ </template>
381
+
382
+ <style scoped>
383
+ .admin-theme-page { max-width: 1080px; }
384
+
385
+ .admin-theme-header {
386
+ display: flex;
387
+ align-items: flex-end;
388
+ gap: var(--space-4);
389
+ margin-bottom: var(--space-6);
390
+ flex-wrap: wrap;
391
+ }
392
+ .admin-page-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); margin: 0 0 var(--space-2); }
393
+ .admin-page-desc { font-size: var(--text-sm); color: var(--text-dim); margin: 0; max-width: 560px; line-height: var(--leading-snug); }
394
+ .admin-theme-actions { display: flex; gap: var(--space-2); margin-left: auto; }
395
+
396
+ .admin-theme-toast {
397
+ position: fixed;
398
+ top: calc(var(--nav-height) + var(--space-4));
399
+ right: var(--space-4);
400
+ padding: var(--space-2) var(--space-4);
401
+ font-size: var(--text-sm);
402
+ font-weight: var(--font-weight-semibold);
403
+ z-index: var(--z-toast);
404
+ border: var(--border-width-default) solid var(--border);
405
+ box-shadow: var(--shadow-md);
406
+ display: flex;
407
+ align-items: center;
408
+ gap: var(--space-2);
409
+ color: var(--color-text-inverse);
410
+ }
411
+ .admin-theme-toast.tone-success { background: var(--green); }
412
+ .admin-theme-toast.tone-error { background: var(--red); }
413
+
414
+ .admin-theme-discovery {
415
+ display: flex;
416
+ align-items: center;
417
+ gap: var(--space-4);
418
+ padding: var(--space-4) var(--space-5);
419
+ background: var(--accent-bg);
420
+ border: var(--border-width-default) solid var(--accent-border);
421
+ margin-bottom: var(--space-5);
422
+ }
423
+ .admin-theme-discovery-icon {
424
+ width: 40px;
425
+ height: 40px;
426
+ background: var(--accent);
427
+ color: var(--color-on-accent);
428
+ display: inline-flex;
429
+ align-items: center;
430
+ justify-content: center;
431
+ font-size: 18px;
432
+ flex-shrink: 0;
433
+ }
434
+ .admin-theme-discovery-body { flex: 1; }
435
+ .admin-theme-discovery-title { font-size: var(--text-md); font-weight: var(--font-weight-bold); margin: 0 0 4px; }
436
+ .admin-theme-discovery-desc { font-size: var(--text-sm); color: var(--text-dim); margin: 0; line-height: var(--leading-snug); }
437
+ .admin-theme-discovery-desc code { font-family: var(--font-mono); font-size: 0.95em; color: var(--accent); padding: 0 4px; background: var(--accent-bg); }
438
+
439
+ .admin-theme-families { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-8); }
440
+
441
+ /* Overrides-panel styles moved to AdminThemeOverridesPanel.vue */
442
+
443
+ @media (max-width: 640px) {
444
+ .admin-theme-header { align-items: flex-start; }
445
+ .admin-theme-actions { margin-left: 0; width: 100%; }
446
+ .admin-theme-actions .cpub-btn { flex: 1; }
447
+ .admin-theme-discovery { flex-direction: column; align-items: flex-start; }
448
+ }
449
+ </style>
package/plugins/theme.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  // Theme plugin — resolves theme on server (zero flash) and hydrates on client.
2
2
  //
3
- // The admin picks the instance theme (family + default mode).
4
- // Users only toggle light/dark. Server middleware resolves the final theme ID.
5
- // This plugin reads that resolved value and sets data-theme on <html>.
3
+ // The admin picks the instance theme (family + default mode + custom theme tokens).
4
+ // Users only toggle light/dark. Server middleware resolves the final theme ID and
5
+ // the inline CSS to inject. This plugin sets data-theme on <html> and forwards
6
+ // the inline `<style>` so first paint has the correct tokens.
6
7
 
7
8
  export default defineNuxtPlugin(() => {
8
9
  const themeId = useState<string>('cpub-theme', () => 'base');
9
10
  const instanceTheme = useState<string>('cpub-instance-theme', () => 'base');
10
11
  const isDark = useState<boolean>('cpub-dark-mode', () => false);
12
+ const themeInlineCss = useState<string>('cpub-theme-inline-css', () => '');
11
13
 
12
14
  if (import.meta.server) {
13
15
  const event = useRequestEvent();
@@ -15,6 +17,7 @@ export default defineNuxtPlugin(() => {
15
17
  themeId.value = event.context.resolvedTheme ?? 'base';
16
18
  instanceTheme.value = event.context.instanceTheme ?? 'base';
17
19
  isDark.value = event.context.isDarkMode ?? false;
20
+ themeInlineCss.value = event.context.themeInlineCss ?? '';
18
21
  }
19
22
  }
20
23
 
@@ -23,10 +26,25 @@ export default defineNuxtPlugin(() => {
23
26
  localStorage.removeItem('cpub-theme');
24
27
  }
25
28
 
26
- // Set data-theme on <html> during SSR — first paint has the correct theme
29
+ // Set data-theme on <html> during SSR — first paint has the correct theme.
30
+ // The inline style is rendered just before </head> via useHead so it loads
31
+ // after the theme CSS files (cascade wins on equal specificity).
32
+ const head: Parameters<typeof useHead>[0] = {};
27
33
  if (themeId.value && themeId.value !== 'base') {
28
- useHead({
29
- htmlAttrs: { 'data-theme': themeId.value },
30
- });
34
+ head.htmlAttrs = { 'data-theme': themeId.value };
35
+ }
36
+ if (themeInlineCss.value) {
37
+ // `key` lets Nuxt dedupe + replace this exact tag on navigation if the
38
+ // theme changes mid-session (e.g. admin saves while editing).
39
+ head.style = [{
40
+ key: 'cpub-theme-inline',
41
+ innerHTML: themeInlineCss.value,
42
+ tagPosition: 'head',
43
+ // hid id helps the editor's preview replace it without dupes
44
+ id: 'cpub-theme-inline',
45
+ }];
46
+ }
47
+ if (Object.keys(head).length > 0) {
48
+ useHead(head);
31
49
  }
32
50
  });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * DELETE /api/admin/themes/[id]
3
+ *
4
+ * Delete a DB-stored custom theme. If the deleted theme is the current
5
+ * instance default, the default falls back to `base` on the next read.
6
+ */
7
+ import { eq } from 'drizzle-orm';
8
+ import { instanceSettings } from '@commonpub/schema';
9
+ import {
10
+ deleteCustomTheme,
11
+ customThemeDataAttr,
12
+ } from '@commonpub/server';
13
+
14
+ export default defineEventHandler(async (event): Promise<{ ok: true; resetDefault: boolean }> => {
15
+ requireFeature('admin');
16
+ const admin = requireAdmin(event);
17
+ const db = useDB();
18
+
19
+ const { id } = parseParams(event, { id: 'string' });
20
+
21
+ await deleteCustomTheme(db, id, admin.id);
22
+
23
+ // If this theme was the active default, reset to `base` so SSR doesn't 404
24
+ let resetDefault = false;
25
+ const dataAttr = customThemeDataAttr(id);
26
+ const [defaultRow] = await db
27
+ .select({ value: instanceSettings.value })
28
+ .from(instanceSettings)
29
+ .where(eq(instanceSettings.key, 'theme.default'));
30
+ if (defaultRow?.value === dataAttr || defaultRow?.value === id) {
31
+ await db
32
+ .update(instanceSettings)
33
+ .set({ value: 'base', updatedBy: admin.id, updatedAt: new Date() })
34
+ .where(eq(instanceSettings.key, 'theme.default'));
35
+ resetDefault = true;
36
+ }
37
+
38
+ invalidateThemeCache();
39
+ return { ok: true, resetDefault };
40
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * GET /api/admin/themes/[id]
3
+ *
4
+ * Returns a single custom theme by ID. 404 if not found.
5
+ */
6
+ import { getCustomTheme } from '@commonpub/server';
7
+
8
+ export default defineEventHandler(async (event) => {
9
+ requireFeature('admin');
10
+ requireAdmin(event);
11
+ const db = useDB();
12
+
13
+ const { id } = parseParams(event, { id: 'string' });
14
+
15
+ const theme = await getCustomTheme(db, id);
16
+ if (!theme) {
17
+ throw createError({ statusCode: 404, statusMessage: 'Theme not found' });
18
+ }
19
+ return theme;
20
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * PUT /api/admin/themes/[id]
3
+ *
4
+ * Update an existing custom theme. The ID is taken from the URL — any `id`
5
+ * in the body must match. 404 if the theme doesn't exist.
6
+ */
7
+ import { customThemeSchema } from '@commonpub/schema';
8
+ import { getCustomTheme, saveCustomTheme } from '@commonpub/server';
9
+
10
+ export default defineEventHandler(async (event) => {
11
+ requireFeature('admin');
12
+ const admin = requireAdmin(event);
13
+ const db = useDB();
14
+
15
+ const { id } = parseParams(event, { id: 'string' });
16
+ const input = await parseBody(event, customThemeSchema);
17
+
18
+ if (input.id !== id) {
19
+ throw createError({ statusCode: 400, statusMessage: 'URL id does not match body id' });
20
+ }
21
+
22
+ const existing = await getCustomTheme(db, id);
23
+ if (!existing) {
24
+ throw createError({ statusCode: 404, statusMessage: 'Theme not found' });
25
+ }
26
+
27
+ const saved = await saveCustomTheme(
28
+ db,
29
+ {
30
+ id: input.id,
31
+ name: input.name,
32
+ description: input.description ?? '',
33
+ family: input.family,
34
+ isDark: input.isDark,
35
+ pairId: input.pairId,
36
+ parentTheme: input.parentTheme,
37
+ tokens: input.tokens ?? {},
38
+ createdAt: existing.createdAt,
39
+ },
40
+ admin.id,
41
+ );
42
+
43
+ invalidateThemeCache();
44
+ return saved;
45
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * GET /api/admin/themes/discover
3
+ *
4
+ * Returns the canonical default values for every known token. The client
5
+ * uses this to diff against `getComputedStyle(:root)` and surface a
6
+ * "your site has a custom theme — capture it?" CTA when the runtime values
7
+ * differ from the built-in defaults.
8
+ *
9
+ * This is purely advisory; the client makes the decision based on the diff.
10
+ */
11
+ import { TOKEN_SPECS } from '@commonpub/ui';
12
+
13
+ export default defineEventHandler((event) => {
14
+ requireFeature('admin');
15
+ requireAdmin(event);
16
+
17
+ const defaults: Record<string, string> = {};
18
+ for (const spec of TOKEN_SPECS) {
19
+ defaults[spec.key] = spec.default;
20
+ }
21
+ return { defaults };
22
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * GET /api/admin/themes
3
+ *
4
+ * Returns the unified list of themes available to the admin theme picker:
5
+ *
6
+ * { builtIn: ThemeDefinition[], registered: RegisteredTheme[], custom: CustomThemeRecord[] }
7
+ *
8
+ * - `builtIn` is hard-coded in @commonpub/ui (BUILT_IN_THEMES)
9
+ * - `registered` comes from `commonpub.config.ts` themes[] (the thin layer
10
+ * app declares its own theme here)
11
+ * - `custom` is the DB-stored editable themes
12
+ *
13
+ * The client merges these three sources into the family-grouped picker.
14
+ */
15
+ import { BUILT_IN_THEMES } from '@commonpub/ui';
16
+ import { listCustomThemes } from '@commonpub/server';
17
+
18
+ export default defineEventHandler(async (event) => {
19
+ requireFeature('admin');
20
+ requireAdmin(event);
21
+ const db = useDB();
22
+ const config = useConfig();
23
+
24
+ const custom = await listCustomThemes(db);
25
+ const registered = ((config as unknown as { themes?: unknown }).themes ?? []) as Array<{
26
+ id: string;
27
+ name: string;
28
+ description?: string;
29
+ family: string;
30
+ isDark: boolean;
31
+ pairId?: string;
32
+ preview?: Record<string, string>;
33
+ }>;
34
+
35
+ return {
36
+ builtIn: BUILT_IN_THEMES,
37
+ registered,
38
+ custom,
39
+ };
40
+ });