@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.
@@ -1,502 +0,0 @@
1
- <script setup lang="ts">
2
- import { BUILT_IN_THEMES } from '@commonpub/ui';
3
-
4
- definePageMeta({ layout: 'admin', middleware: 'auth' });
5
- useSeoMeta({ title: `Theme — Admin — ${useSiteName()}` });
6
-
7
- const { data: settings, pending, refresh } = await useFetch<Record<string, unknown>>('/api/admin/settings');
8
-
9
- const saving = ref(false);
10
- const saveSuccess = ref(false);
11
-
12
- const instanceDefault = computed(() => {
13
- const val = settings.value?.['theme.default'];
14
- return typeof val === 'string' ? val : 'base';
15
- });
16
-
17
- // Build families from BUILT_IN_THEMES
18
- interface ThemeFamily {
19
- id: string;
20
- name: string;
21
- description: string;
22
- light: { id: string; name: string } | null;
23
- dark: { id: string; name: string } | null;
24
- preview: {
25
- light: { bg: string; surface: string; accent: string; text: string; border: string };
26
- dark: { bg: string; surface: string; accent: string; text: string; border: string };
27
- };
28
- }
29
-
30
- const PREVIEW_COLORS: Record<string, { bg: string; surface: string; accent: string; text: string; border: string }> = {
31
- base: { bg: '#fafaf9', surface: '#ffffff', accent: '#5b9cf6', text: '#1a1a1a', border: '#1a1a1a' },
32
- dark: { bg: '#111111', surface: '#1a1a1a', accent: '#5b9cf6', text: '#e5e5e3', border: '#444440' },
33
- generics: { bg: '#0c0c0b', surface: '#141413', accent: '#5b9cf6', text: '#d8d5cf', border: '#272725' },
34
- agora: { bg: '#f7f4ed', surface: '#faf8f3', accent: '#3d8b5e', text: '#1a1a1a', border: '#1a1a1a' },
35
- 'agora-dark': { bg: '#0d1a12', surface: '#141f17', accent: '#4aa06e', text: '#e8e8e2', border: '#3a4f40' },
36
- };
37
-
38
- const FAMILY_META: Record<string, { name: string; description: string }> = {
39
- classic: { name: 'Classic', description: 'Sharp corners, offset shadows, blue accent — the original CommonPub look' },
40
- agora: { name: 'Agora', description: 'Warm parchment tones, green accent, Fraunces serif — institutional warmth' },
41
- generics: { name: 'Generics', description: 'Minimal dark aesthetic with soft glow shadows' },
42
- };
43
-
44
- const families = computed<ThemeFamily[]>(() => {
45
- const map = new Map<string, ThemeFamily>();
46
-
47
- for (const theme of BUILT_IN_THEMES) {
48
- if (!map.has(theme.family)) {
49
- const meta = FAMILY_META[theme.family] ?? { name: theme.family, description: '' };
50
- map.set(theme.family, {
51
- id: theme.family,
52
- name: meta.name,
53
- description: meta.description,
54
- light: null,
55
- dark: null,
56
- preview: {
57
- light: PREVIEW_COLORS.base!,
58
- dark: PREVIEW_COLORS.dark!,
59
- },
60
- });
61
- }
62
- const fam = map.get(theme.family)!;
63
- if (theme.isDark) {
64
- fam.dark = { id: theme.id, name: theme.name };
65
- fam.preview.dark = PREVIEW_COLORS[theme.id] ?? PREVIEW_COLORS.dark!;
66
- } else {
67
- fam.light = { id: theme.id, name: theme.name };
68
- fam.preview.light = PREVIEW_COLORS[theme.id] ?? PREVIEW_COLORS.base!;
69
- }
70
- }
71
-
72
- return [...map.values()];
73
- });
74
-
75
- /** Which family is currently active? */
76
- const THEME_TO_FAMILY: Record<string, string> = {
77
- base: 'classic', dark: 'classic', generics: 'generics',
78
- agora: 'agora', 'agora-dark': 'agora',
79
- };
80
- const activeFamily = computed(() => THEME_TO_FAMILY[instanceDefault.value] ?? 'classic');
81
-
82
- async function selectFamily(family: ThemeFamily): Promise<void> {
83
- // Set the light variant as default (users toggle dark mode themselves)
84
- const themeId = family.light?.id ?? family.dark?.id ?? 'base';
85
- saving.value = true;
86
- saveSuccess.value = false;
87
- try {
88
- await $fetch('/api/admin/settings', {
89
- method: 'PUT',
90
- body: { key: 'theme.default', value: themeId },
91
- });
92
- await refresh();
93
- saveSuccess.value = true;
94
- setTimeout(() => { saveSuccess.value = false; }, 2000);
95
- } finally {
96
- saving.value = false;
97
- }
98
- }
99
-
100
- // Token overrides
101
- const tokenOverrides = ref<Record<string, string>>({});
102
- const newTokenKey = ref('');
103
- const newTokenValue = ref('');
104
-
105
- watchEffect(() => {
106
- const raw = settings.value?.['theme.token_overrides'];
107
- if (raw && typeof raw === 'object' && raw !== null) {
108
- tokenOverrides.value = { ...(raw as Record<string, string>) };
109
- } else {
110
- tokenOverrides.value = {};
111
- }
112
- });
113
-
114
- async function saveTokenOverrides(): Promise<void> {
115
- saving.value = true;
116
- saveSuccess.value = false;
117
- try {
118
- await $fetch('/api/admin/settings', {
119
- method: 'PUT',
120
- body: { key: 'theme.token_overrides', value: tokenOverrides.value },
121
- });
122
- await refresh();
123
- saveSuccess.value = true;
124
- setTimeout(() => { saveSuccess.value = false; }, 2000);
125
- } finally {
126
- saving.value = false;
127
- }
128
- }
129
-
130
- function addTokenOverride(): void {
131
- const key = newTokenKey.value.trim();
132
- const value = newTokenValue.value.trim();
133
- if (!key || !value) return;
134
- tokenOverrides.value[key] = value;
135
- newTokenKey.value = '';
136
- newTokenValue.value = '';
137
- }
138
-
139
- function removeTokenOverride(key: string): void {
140
- const next = { ...tokenOverrides.value };
141
- delete next[key];
142
- tokenOverrides.value = next;
143
- }
144
- </script>
145
-
146
- <template>
147
- <div class="admin-theme">
148
- <div class="admin-theme-header">
149
- <h1 class="admin-page-title">Theme</h1>
150
- <p class="admin-page-desc">
151
- Set the instance theme. This applies to all users. Individual users can toggle between light and dark mode.
152
- </p>
153
- </div>
154
-
155
- <div v-if="saveSuccess" class="admin-theme-toast">
156
- <i class="fa-solid fa-check"></i> Saved
157
- </div>
158
-
159
- <p v-if="pending" class="admin-empty"><i class="fa-solid fa-circle-notch fa-spin"></i> Loading theme settings...</p>
160
-
161
- <!-- Theme Families -->
162
- <section v-else class="admin-theme-families">
163
- <div v-for="family in families" :key="family.id" class="admin-family-card" :class="{ active: activeFamily === family.id }" >
164
- <button
165
- class="admin-family-select"
166
- :disabled="saving"
167
- @click="selectFamily(family)"
168
- >
169
- <div class="admin-family-previews">
170
- <!-- Light preview -->
171
- <div
172
- v-if="family.light"
173
- class="admin-family-preview"
174
- :style="{ backgroundColor: family.preview.light.bg, borderColor: family.preview.light.border }"
175
- >
176
- <div
177
- class="admin-preview-card"
178
- :style="{
179
- backgroundColor: family.preview.light.surface,
180
- borderColor: family.preview.light.border,
181
- boxShadow: `3px 3px 0 ${family.preview.light.border}`,
182
- }"
183
- >
184
- <div class="admin-preview-heading" :style="{ backgroundColor: family.preview.light.text, opacity: 0.8 }"></div>
185
- <div class="admin-preview-text" :style="{ backgroundColor: family.preview.light.text, opacity: 0.3 }"></div>
186
- <div class="admin-preview-accent" :style="{ backgroundColor: family.preview.light.accent }"></div>
187
- </div>
188
- </div>
189
- <!-- Dark preview -->
190
- <div
191
- v-if="family.dark"
192
- class="admin-family-preview"
193
- :style="{ backgroundColor: family.preview.dark.bg, borderColor: family.preview.dark.border }"
194
- >
195
- <div
196
- class="admin-preview-card"
197
- :style="{
198
- backgroundColor: family.preview.dark.surface,
199
- borderColor: family.preview.dark.border,
200
- boxShadow: `3px 3px 0 ${family.preview.dark.border}`,
201
- }"
202
- >
203
- <div class="admin-preview-heading" :style="{ backgroundColor: family.preview.dark.text, opacity: 0.8 }"></div>
204
- <div class="admin-preview-text" :style="{ backgroundColor: family.preview.dark.text, opacity: 0.3 }"></div>
205
- <div class="admin-preview-accent" :style="{ backgroundColor: family.preview.dark.accent }"></div>
206
- </div>
207
- </div>
208
- </div>
209
-
210
- <div class="admin-family-meta">
211
- <span class="admin-family-name">{{ family.name }}</span>
212
- <span class="admin-family-desc">{{ family.description }}</span>
213
- <span v-if="activeFamily === family.id" class="admin-family-active">
214
- <i class="fa-solid fa-check"></i> Active
215
- </span>
216
- </div>
217
- </button>
218
- </div>
219
- </section>
220
-
221
- <!-- Token Overrides -->
222
- <section class="admin-theme-overrides">
223
- <h2 class="admin-section-title">Token Overrides</h2>
224
- <p class="admin-section-desc">
225
- Override individual CSS tokens instance-wide. These apply on top of the selected theme.
226
- Use CSS values (colors, font families, sizes).
227
- </p>
228
-
229
- <div class="admin-overrides-list" v-if="Object.keys(tokenOverrides).length > 0">
230
- <div v-for="(value, key) in tokenOverrides" :key="key" class="admin-override-row">
231
- <code class="admin-override-key">--{{ key }}</code>
232
- <span class="admin-override-value">
233
- <span
234
- v-if="String(value).startsWith('#') || String(value).startsWith('rgb')"
235
- class="admin-override-swatch"
236
- :style="{ backgroundColor: String(value) }"
237
- ></span>
238
- {{ value }}
239
- </span>
240
- <button
241
- class="cpub-btn cpub-btn-sm admin-override-remove"
242
- aria-label="Remove override"
243
- @click="removeTokenOverride(key as string)"
244
- >
245
- <i class="fa-solid fa-xmark"></i>
246
- </button>
247
- </div>
248
- </div>
249
-
250
- <div class="admin-override-add">
251
- <input
252
- v-model="newTokenKey"
253
- class="admin-override-input"
254
- placeholder="Token name (e.g. accent)"
255
- @keyup.enter="addTokenOverride"
256
- />
257
- <input
258
- v-model="newTokenValue"
259
- class="admin-override-input"
260
- placeholder="Value (e.g. #ff6600)"
261
- @keyup.enter="addTokenOverride"
262
- />
263
- <button class="cpub-btn cpub-btn-sm" :disabled="!newTokenKey.trim() || !newTokenValue.trim()" @click="addTokenOverride">
264
- Add
265
- </button>
266
- </div>
267
-
268
- <div class="admin-override-actions">
269
- <button class="cpub-btn cpub-btn-primary" :disabled="saving" @click="saveTokenOverrides">
270
- <i class="fa-solid fa-floppy-disk"></i> Save Overrides
271
- </button>
272
- </div>
273
- </section>
274
- </div>
275
- </template>
276
-
277
- <style scoped>
278
- .admin-theme { max-width: 900px; }
279
-
280
- .admin-theme-header { margin-bottom: var(--space-6); }
281
- .admin-page-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); margin-bottom: var(--space-2); }
282
- .admin-page-desc { font-size: var(--text-sm); color: var(--text-dim); }
283
-
284
- .admin-theme-toast {
285
- position: fixed;
286
- top: calc(var(--nav-height) + var(--space-4));
287
- right: var(--space-4);
288
- padding: var(--space-2) var(--space-4);
289
- background: var(--green);
290
- color: var(--color-text-inverse);
291
- font-size: var(--text-sm);
292
- font-weight: var(--font-weight-semibold);
293
- z-index: var(--z-toast);
294
- border: var(--border-width-default) solid var(--border);
295
- box-shadow: var(--shadow-md);
296
- display: flex;
297
- align-items: center;
298
- gap: var(--space-2);
299
- }
300
-
301
- .admin-section-title {
302
- font-size: var(--text-lg);
303
- font-weight: var(--font-weight-bold);
304
- margin-bottom: var(--space-2);
305
- }
306
-
307
- .admin-section-desc {
308
- font-size: var(--text-sm);
309
- color: var(--text-dim);
310
- margin-bottom: var(--space-4);
311
- }
312
-
313
- /* Theme families */
314
- .admin-theme-families {
315
- display: flex;
316
- flex-direction: column;
317
- gap: var(--space-4);
318
- margin-bottom: var(--space-8);
319
- }
320
-
321
- .admin-family-card {
322
- border: var(--border-width-default) solid var(--border2);
323
- background: var(--surface);
324
- transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
325
- }
326
-
327
- .admin-family-card.active {
328
- border-color: var(--accent);
329
- box-shadow: var(--shadow-accent);
330
- }
331
-
332
- .admin-family-card:hover {
333
- border-color: var(--border);
334
- }
335
-
336
- .admin-family-select {
337
- display: flex;
338
- width: 100%;
339
- text-align: left;
340
- cursor: pointer;
341
- background: none;
342
- border: none;
343
- padding: 0;
344
- color: inherit;
345
- }
346
-
347
- .admin-family-select:disabled {
348
- opacity: 0.6;
349
- cursor: wait;
350
- }
351
-
352
- .admin-family-previews {
353
- display: flex;
354
- flex-shrink: 0;
355
- }
356
-
357
- .admin-family-preview {
358
- width: 140px;
359
- height: 100px;
360
- padding: var(--space-3);
361
- display: flex;
362
- align-items: center;
363
- justify-content: center;
364
- border-right: var(--border-width-default) solid var(--border2);
365
- }
366
-
367
- .admin-preview-card {
368
- width: 80%;
369
- padding: var(--space-2) var(--space-3);
370
- border-width: 2px;
371
- border-style: solid;
372
- display: flex;
373
- flex-direction: column;
374
- gap: 4px;
375
- }
376
-
377
- .admin-preview-heading { height: 5px; width: 55%; }
378
- .admin-preview-text { height: 3px; width: 85%; }
379
- .admin-preview-accent { height: 12px; width: 40%; margin-top: 4px; }
380
-
381
- .admin-family-meta {
382
- padding: var(--space-4);
383
- display: flex;
384
- flex-direction: column;
385
- justify-content: center;
386
- gap: 2px;
387
- min-width: 0;
388
- }
389
-
390
- .admin-family-name {
391
- font-size: var(--text-md);
392
- font-weight: var(--font-weight-bold);
393
- }
394
-
395
- .admin-family-desc {
396
- font-size: var(--text-sm);
397
- color: var(--text-dim);
398
- line-height: var(--leading-snug);
399
- }
400
-
401
- .admin-family-active {
402
- display: inline-flex;
403
- align-items: center;
404
- gap: 4px;
405
- font-size: var(--text-xs);
406
- font-family: var(--font-mono);
407
- font-weight: var(--font-weight-semibold);
408
- text-transform: uppercase;
409
- letter-spacing: var(--tracking-wide);
410
- color: var(--accent);
411
- margin-top: var(--space-1);
412
- }
413
-
414
- /* Token overrides */
415
- .admin-theme-overrides {
416
- border-top: var(--border-width-default) solid var(--border);
417
- padding-top: var(--space-6);
418
- }
419
-
420
- .admin-overrides-list {
421
- border: var(--border-width-default) solid var(--border);
422
- background: var(--surface);
423
- margin-bottom: var(--space-4);
424
- }
425
-
426
- .admin-override-row {
427
- display: flex;
428
- align-items: center;
429
- padding: var(--space-2) var(--space-3);
430
- border-bottom: var(--border-width-default) solid var(--border2);
431
- gap: var(--space-3);
432
- }
433
-
434
- .admin-override-row:last-child { border-bottom: none; }
435
-
436
- .admin-override-key {
437
- font-family: var(--font-mono);
438
- font-size: var(--text-sm);
439
- color: var(--accent);
440
- flex-shrink: 0;
441
- }
442
-
443
- .admin-override-value {
444
- font-family: var(--font-mono);
445
- font-size: var(--text-sm);
446
- color: var(--text-dim);
447
- display: flex;
448
- align-items: center;
449
- gap: var(--space-2);
450
- flex: 1;
451
- min-width: 0;
452
- overflow: hidden;
453
- text-overflow: ellipsis;
454
- white-space: nowrap;
455
- }
456
-
457
- .admin-override-swatch {
458
- display: inline-block;
459
- width: 14px;
460
- height: 14px;
461
- border: var(--border-width-default) solid var(--border2);
462
- flex-shrink: 0;
463
- }
464
-
465
- .admin-override-remove {
466
- flex-shrink: 0;
467
- padding: var(--space-1);
468
- color: var(--text-faint);
469
- }
470
-
471
- .admin-override-remove:hover { color: var(--red); }
472
-
473
- .admin-override-add {
474
- display: flex;
475
- gap: var(--space-2);
476
- padding: var(--space-3);
477
- border: 2px dashed var(--border2);
478
- background: var(--surface);
479
- }
480
-
481
- .admin-override-input {
482
- font-size: var(--text-sm);
483
- padding: var(--space-1) var(--space-2);
484
- border: var(--border-width-default) solid var(--border);
485
- background: var(--surface2);
486
- color: var(--text);
487
- font-family: var(--font-mono);
488
- flex: 1;
489
- min-width: 0;
490
- }
491
-
492
- .admin-override-actions {
493
- margin-top: var(--space-4);
494
- }
495
-
496
- @media (max-width: 640px) {
497
- .admin-family-select { flex-direction: column; }
498
- .admin-family-previews { width: 100%; }
499
- .admin-family-preview { flex: 1; border-right: none; border-bottom: var(--border-width-default) solid var(--border2); }
500
- .admin-override-add { flex-direction: column; }
501
- }
502
- </style>