@commonpub/layer 0.21.22 → 0.22.0

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,424 @@
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
+ // --- Token overrides (legacy / quick tweaks) ---
248
+ // State + UI live in <AdminThemeOverridesPanel>; this page only persists
249
+ // what the panel emits.
250
+
251
+ const initialOverrides = computed<Record<string, string>>(() => {
252
+ const raw = settings.value?.['theme.token_overrides'];
253
+ return raw && typeof raw === 'object' && !Array.isArray(raw)
254
+ ? { ...(raw as Record<string, string>) }
255
+ : {};
256
+ });
257
+
258
+ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
259
+ saving.value = true;
260
+ try {
261
+ await $fetch('/api/admin/settings', {
262
+ method: 'PUT',
263
+ body: { key: 'theme.token_overrides', value: overrides },
264
+ });
265
+ await refreshSettings();
266
+ notify('Overrides saved', 'success');
267
+ } catch (err) {
268
+ notify(err instanceof Error ? err.message : 'Failed to save', 'error');
269
+ } finally {
270
+ saving.value = false;
271
+ }
272
+ }
273
+ </script>
274
+
275
+ <template>
276
+ <div class="admin-theme-page">
277
+ <header class="admin-theme-header">
278
+ <div>
279
+ <h1 class="admin-page-title">Theme</h1>
280
+ <p class="admin-page-desc">
281
+ Pick a theme, edit your own, or capture the look your layer app already ships
282
+ with. Changes apply instance-wide; individual users still control light/dark.
283
+ </p>
284
+ </div>
285
+ <div class="admin-theme-actions">
286
+ <button class="cpub-btn" :disabled="saving" @click="openImportDialog">
287
+ <i class="fa-solid fa-file-import" aria-hidden="true" /> Import…
288
+ </button>
289
+ <input
290
+ ref="importFileInput"
291
+ type="file"
292
+ accept="application/json,.json,.cpub-theme.json"
293
+ hidden
294
+ @change="onImportFile"
295
+ />
296
+ <button class="cpub-btn cpub-btn-primary" :disabled="saving" @click="createBlank">
297
+ <i class="fa-solid fa-plus" aria-hidden="true" /> New custom theme
298
+ </button>
299
+ </div>
300
+ </header>
301
+
302
+ <div v-if="toast" class="admin-theme-toast" :class="`tone-${toast.tone}`">
303
+ <i :class="['fa-solid', toast.tone === 'success' ? 'fa-check' : 'fa-triangle-exclamation']" aria-hidden="true" />
304
+ {{ toast.msg }}
305
+ </div>
306
+
307
+ <!-- Discovery banner -->
308
+ <section
309
+ v-if="discovery.count > 0"
310
+ class="admin-theme-discovery"
311
+ role="region"
312
+ aria-label="Discovered theme tokens"
313
+ >
314
+ <div class="admin-theme-discovery-icon"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true" /></div>
315
+ <div class="admin-theme-discovery-body">
316
+ <h2 class="admin-theme-discovery-title">Your site has a custom theme</h2>
317
+ <p class="admin-theme-discovery-desc">
318
+ We detected <strong>{{ discovery.count }}</strong> CSS token{{ discovery.count === 1 ? '' : 's' }}
319
+ on <code>:root</code> that differ from the built-in defaults — probably from
320
+ a CSS file your layer app loads. Capture it into an editable custom theme so
321
+ you can tweak it from this admin panel.
322
+ </p>
323
+ </div>
324
+ <button class="cpub-btn cpub-btn-primary" :disabled="saving" @click="captureCurrent">
325
+ <i class="fa-solid fa-camera" aria-hidden="true" /> Capture
326
+ </button>
327
+ </section>
328
+
329
+ <p v-if="themesApi.loading.value && !themesApi.data.value" class="admin-empty">
330
+ <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" /> Loading themes…
331
+ </p>
332
+
333
+ <section v-else class="admin-theme-families">
334
+ <AdminThemeFamilyCard
335
+ v-for="family in themesApi.families.value"
336
+ :key="family.id"
337
+ :family="family"
338
+ :active="activeFamily === family.id"
339
+ :saving="saving"
340
+ @select="setActiveTheme"
341
+ @edit="editTheme"
342
+ @duplicate="duplicateTheme"
343
+ @export-theme="exportTheme"
344
+ @remove="removeTheme"
345
+ />
346
+ </section>
347
+
348
+ <!-- Token overrides (legacy / quick tweaks) -->
349
+ <AdminThemeOverridesPanel
350
+ :initial="initialOverrides"
351
+ :saving="saving"
352
+ @save="saveOverrides"
353
+ />
354
+ </div>
355
+ </template>
356
+
357
+ <style scoped>
358
+ .admin-theme-page { max-width: 1080px; }
359
+
360
+ .admin-theme-header {
361
+ display: flex;
362
+ align-items: flex-end;
363
+ gap: var(--space-4);
364
+ margin-bottom: var(--space-6);
365
+ flex-wrap: wrap;
366
+ }
367
+ .admin-page-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); margin: 0 0 var(--space-2); }
368
+ .admin-page-desc { font-size: var(--text-sm); color: var(--text-dim); margin: 0; max-width: 560px; line-height: var(--leading-snug); }
369
+ .admin-theme-actions { display: flex; gap: var(--space-2); margin-left: auto; }
370
+
371
+ .admin-theme-toast {
372
+ position: fixed;
373
+ top: calc(var(--nav-height) + var(--space-4));
374
+ right: var(--space-4);
375
+ padding: var(--space-2) var(--space-4);
376
+ font-size: var(--text-sm);
377
+ font-weight: var(--font-weight-semibold);
378
+ z-index: var(--z-toast);
379
+ border: var(--border-width-default) solid var(--border);
380
+ box-shadow: var(--shadow-md);
381
+ display: flex;
382
+ align-items: center;
383
+ gap: var(--space-2);
384
+ color: var(--color-text-inverse);
385
+ }
386
+ .admin-theme-toast.tone-success { background: var(--green); }
387
+ .admin-theme-toast.tone-error { background: var(--red); }
388
+
389
+ .admin-theme-discovery {
390
+ display: flex;
391
+ align-items: center;
392
+ gap: var(--space-4);
393
+ padding: var(--space-4) var(--space-5);
394
+ background: var(--accent-bg);
395
+ border: var(--border-width-default) solid var(--accent-border);
396
+ margin-bottom: var(--space-5);
397
+ }
398
+ .admin-theme-discovery-icon {
399
+ width: 40px;
400
+ height: 40px;
401
+ background: var(--accent);
402
+ color: var(--color-on-accent);
403
+ display: inline-flex;
404
+ align-items: center;
405
+ justify-content: center;
406
+ font-size: 18px;
407
+ flex-shrink: 0;
408
+ }
409
+ .admin-theme-discovery-body { flex: 1; }
410
+ .admin-theme-discovery-title { font-size: var(--text-md); font-weight: var(--font-weight-bold); margin: 0 0 4px; }
411
+ .admin-theme-discovery-desc { font-size: var(--text-sm); color: var(--text-dim); margin: 0; line-height: var(--leading-snug); }
412
+ .admin-theme-discovery-desc code { font-family: var(--font-mono); font-size: 0.95em; color: var(--accent); padding: 0 4px; background: var(--accent-bg); }
413
+
414
+ .admin-theme-families { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-8); }
415
+
416
+ /* Overrides-panel styles moved to AdminThemeOverridesPanel.vue */
417
+
418
+ @media (max-width: 640px) {
419
+ .admin-theme-header { align-items: flex-start; }
420
+ .admin-theme-actions { margin-left: 0; width: 100%; }
421
+ .admin-theme-actions .cpub-btn { flex: 1; }
422
+ .admin-theme-discovery { flex-direction: column; align-items: flex-start; }
423
+ }
424
+ </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
+ });