@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.
- package/components/admin/theme/AdminThemeFamilyCard.vue +277 -0
- package/components/admin/theme/AdminThemeOverridesPanel.vue +222 -0
- package/components/admin/theme/AdminThemePreviewPane.vue +218 -0
- package/components/admin/theme/AdminThemeSceneAdmin.vue +189 -0
- package/components/admin/theme/AdminThemeSceneGallery.vue +353 -0
- package/components/admin/theme/AdminThemeSceneProse.vue +140 -0
- package/components/admin/theme/AdminThemeTokenGroup.vue +98 -0
- package/components/admin/theme/AdminThemeTokenInput.vue +278 -0
- package/composables/useTheme.ts +24 -14
- package/composables/useThemeAdmin.ts +167 -0
- package/package.json +7 -7
- package/pages/admin/theme/edit/[id].vue +595 -0
- package/pages/admin/theme/index.vue +449 -0
- package/plugins/theme.ts +25 -7
- package/server/api/admin/themes/[id].delete.ts +40 -0
- package/server/api/admin/themes/[id].get.ts +20 -0
- package/server/api/admin/themes/[id].put.ts +45 -0
- package/server/api/admin/themes/discover.get.ts +22 -0
- package/server/api/admin/themes/index.get.ts +40 -0
- package/server/api/admin/themes/index.post.ts +46 -0
- package/server/api/profile/theme.put.ts +2 -1
- package/server/middleware/theme.ts +23 -9
- package/server/utils/instanceTheme.ts +145 -25
- package/types/theme.ts +54 -0
- package/utils/themeDiscovery.ts +67 -0
- package/utils/themeIO.ts +79 -0
- package/utils/themeIds.ts +25 -0
- package/pages/admin/theme.vue +0 -502
|
@@ -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
|
-
//
|
|
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
|
-
|
|
29
|
-
|
|
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
|
+
});
|