@commonpub/layer 0.3.37 → 0.4.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,500 @@
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, 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
+ <!-- Theme Families -->
160
+ <section class="admin-theme-families">
161
+ <div v-for="family in families" :key="family.id" class="admin-family-card" :class="{ active: activeFamily === family.id }" >
162
+ <button
163
+ class="admin-family-select"
164
+ :disabled="saving"
165
+ @click="selectFamily(family)"
166
+ >
167
+ <div class="admin-family-previews">
168
+ <!-- Light preview -->
169
+ <div
170
+ v-if="family.light"
171
+ class="admin-family-preview"
172
+ :style="{ backgroundColor: family.preview.light.bg, borderColor: family.preview.light.border }"
173
+ >
174
+ <div
175
+ class="admin-preview-card"
176
+ :style="{
177
+ backgroundColor: family.preview.light.surface,
178
+ borderColor: family.preview.light.border,
179
+ boxShadow: `3px 3px 0 ${family.preview.light.border}`,
180
+ }"
181
+ >
182
+ <div class="admin-preview-heading" :style="{ backgroundColor: family.preview.light.text, opacity: 0.8 }"></div>
183
+ <div class="admin-preview-text" :style="{ backgroundColor: family.preview.light.text, opacity: 0.3 }"></div>
184
+ <div class="admin-preview-accent" :style="{ backgroundColor: family.preview.light.accent }"></div>
185
+ </div>
186
+ </div>
187
+ <!-- Dark preview -->
188
+ <div
189
+ v-if="family.dark"
190
+ class="admin-family-preview"
191
+ :style="{ backgroundColor: family.preview.dark.bg, borderColor: family.preview.dark.border }"
192
+ >
193
+ <div
194
+ class="admin-preview-card"
195
+ :style="{
196
+ backgroundColor: family.preview.dark.surface,
197
+ borderColor: family.preview.dark.border,
198
+ boxShadow: `3px 3px 0 ${family.preview.dark.border}`,
199
+ }"
200
+ >
201
+ <div class="admin-preview-heading" :style="{ backgroundColor: family.preview.dark.text, opacity: 0.8 }"></div>
202
+ <div class="admin-preview-text" :style="{ backgroundColor: family.preview.dark.text, opacity: 0.3 }"></div>
203
+ <div class="admin-preview-accent" :style="{ backgroundColor: family.preview.dark.accent }"></div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+
208
+ <div class="admin-family-meta">
209
+ <span class="admin-family-name">{{ family.name }}</span>
210
+ <span class="admin-family-desc">{{ family.description }}</span>
211
+ <span v-if="activeFamily === family.id" class="admin-family-active">
212
+ <i class="fa-solid fa-check"></i> Active
213
+ </span>
214
+ </div>
215
+ </button>
216
+ </div>
217
+ </section>
218
+
219
+ <!-- Token Overrides -->
220
+ <section class="admin-theme-overrides">
221
+ <h2 class="admin-section-title">Token Overrides</h2>
222
+ <p class="admin-section-desc">
223
+ Override individual CSS tokens instance-wide. These apply on top of the selected theme.
224
+ Use CSS values (colors, font families, sizes).
225
+ </p>
226
+
227
+ <div class="admin-overrides-list" v-if="Object.keys(tokenOverrides).length > 0">
228
+ <div v-for="(value, key) in tokenOverrides" :key="key" class="admin-override-row">
229
+ <code class="admin-override-key">--{{ key }}</code>
230
+ <span class="admin-override-value">
231
+ <span
232
+ v-if="String(value).startsWith('#') || String(value).startsWith('rgb')"
233
+ class="admin-override-swatch"
234
+ :style="{ backgroundColor: String(value) }"
235
+ ></span>
236
+ {{ value }}
237
+ </span>
238
+ <button
239
+ class="cpub-btn cpub-btn-sm admin-override-remove"
240
+ aria-label="Remove override"
241
+ @click="removeTokenOverride(key as string)"
242
+ >
243
+ <i class="fa-solid fa-xmark"></i>
244
+ </button>
245
+ </div>
246
+ </div>
247
+
248
+ <div class="admin-override-add">
249
+ <input
250
+ v-model="newTokenKey"
251
+ class="admin-override-input"
252
+ placeholder="Token name (e.g. accent)"
253
+ @keyup.enter="addTokenOverride"
254
+ />
255
+ <input
256
+ v-model="newTokenValue"
257
+ class="admin-override-input"
258
+ placeholder="Value (e.g. #ff6600)"
259
+ @keyup.enter="addTokenOverride"
260
+ />
261
+ <button class="cpub-btn cpub-btn-sm" :disabled="!newTokenKey.trim() || !newTokenValue.trim()" @click="addTokenOverride">
262
+ Add
263
+ </button>
264
+ </div>
265
+
266
+ <div class="admin-override-actions">
267
+ <button class="cpub-btn cpub-btn-primary" :disabled="saving" @click="saveTokenOverrides">
268
+ <i class="fa-solid fa-floppy-disk"></i> Save Overrides
269
+ </button>
270
+ </div>
271
+ </section>
272
+ </div>
273
+ </template>
274
+
275
+ <style scoped>
276
+ .admin-theme { max-width: 900px; }
277
+
278
+ .admin-theme-header { margin-bottom: var(--space-6); }
279
+ .admin-page-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); margin-bottom: var(--space-2); }
280
+ .admin-page-desc { font-size: var(--text-sm); color: var(--text-dim); }
281
+
282
+ .admin-theme-toast {
283
+ position: fixed;
284
+ top: calc(var(--nav-height) + var(--space-4));
285
+ right: var(--space-4);
286
+ padding: var(--space-2) var(--space-4);
287
+ background: var(--green);
288
+ color: #fff;
289
+ font-size: var(--text-sm);
290
+ font-weight: var(--font-weight-semibold);
291
+ z-index: var(--z-toast);
292
+ border: var(--border-width-default) solid var(--border);
293
+ box-shadow: var(--shadow-md);
294
+ display: flex;
295
+ align-items: center;
296
+ gap: var(--space-2);
297
+ }
298
+
299
+ .admin-section-title {
300
+ font-size: var(--text-lg);
301
+ font-weight: var(--font-weight-bold);
302
+ margin-bottom: var(--space-2);
303
+ }
304
+
305
+ .admin-section-desc {
306
+ font-size: var(--text-sm);
307
+ color: var(--text-dim);
308
+ margin-bottom: var(--space-4);
309
+ }
310
+
311
+ /* Theme families */
312
+ .admin-theme-families {
313
+ display: flex;
314
+ flex-direction: column;
315
+ gap: var(--space-4);
316
+ margin-bottom: var(--space-8);
317
+ }
318
+
319
+ .admin-family-card {
320
+ border: var(--border-width-default) solid var(--border2);
321
+ background: var(--surface);
322
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
323
+ }
324
+
325
+ .admin-family-card.active {
326
+ border-color: var(--accent);
327
+ box-shadow: var(--shadow-accent);
328
+ }
329
+
330
+ .admin-family-card:hover {
331
+ border-color: var(--border);
332
+ }
333
+
334
+ .admin-family-select {
335
+ display: flex;
336
+ width: 100%;
337
+ text-align: left;
338
+ cursor: pointer;
339
+ background: none;
340
+ border: none;
341
+ padding: 0;
342
+ color: inherit;
343
+ }
344
+
345
+ .admin-family-select:disabled {
346
+ opacity: 0.6;
347
+ cursor: wait;
348
+ }
349
+
350
+ .admin-family-previews {
351
+ display: flex;
352
+ flex-shrink: 0;
353
+ }
354
+
355
+ .admin-family-preview {
356
+ width: 140px;
357
+ height: 100px;
358
+ padding: var(--space-3);
359
+ display: flex;
360
+ align-items: center;
361
+ justify-content: center;
362
+ border-right: var(--border-width-default) solid var(--border2);
363
+ }
364
+
365
+ .admin-preview-card {
366
+ width: 80%;
367
+ padding: var(--space-2) var(--space-3);
368
+ border-width: 2px;
369
+ border-style: solid;
370
+ display: flex;
371
+ flex-direction: column;
372
+ gap: 4px;
373
+ }
374
+
375
+ .admin-preview-heading { height: 5px; width: 55%; }
376
+ .admin-preview-text { height: 3px; width: 85%; }
377
+ .admin-preview-accent { height: 12px; width: 40%; margin-top: 4px; }
378
+
379
+ .admin-family-meta {
380
+ padding: var(--space-4);
381
+ display: flex;
382
+ flex-direction: column;
383
+ justify-content: center;
384
+ gap: 2px;
385
+ min-width: 0;
386
+ }
387
+
388
+ .admin-family-name {
389
+ font-size: var(--text-md);
390
+ font-weight: var(--font-weight-bold);
391
+ }
392
+
393
+ .admin-family-desc {
394
+ font-size: var(--text-sm);
395
+ color: var(--text-dim);
396
+ line-height: var(--leading-snug);
397
+ }
398
+
399
+ .admin-family-active {
400
+ display: inline-flex;
401
+ align-items: center;
402
+ gap: 4px;
403
+ font-size: var(--text-xs);
404
+ font-family: var(--font-mono);
405
+ font-weight: var(--font-weight-semibold);
406
+ text-transform: uppercase;
407
+ letter-spacing: var(--tracking-wide);
408
+ color: var(--accent);
409
+ margin-top: var(--space-1);
410
+ }
411
+
412
+ /* Token overrides */
413
+ .admin-theme-overrides {
414
+ border-top: var(--border-width-default) solid var(--border);
415
+ padding-top: var(--space-6);
416
+ }
417
+
418
+ .admin-overrides-list {
419
+ border: var(--border-width-default) solid var(--border);
420
+ background: var(--surface);
421
+ margin-bottom: var(--space-4);
422
+ }
423
+
424
+ .admin-override-row {
425
+ display: flex;
426
+ align-items: center;
427
+ padding: var(--space-2) var(--space-3);
428
+ border-bottom: var(--border-width-default) solid var(--border2);
429
+ gap: var(--space-3);
430
+ }
431
+
432
+ .admin-override-row:last-child { border-bottom: none; }
433
+
434
+ .admin-override-key {
435
+ font-family: var(--font-mono);
436
+ font-size: var(--text-sm);
437
+ color: var(--accent);
438
+ flex-shrink: 0;
439
+ }
440
+
441
+ .admin-override-value {
442
+ font-family: var(--font-mono);
443
+ font-size: var(--text-sm);
444
+ color: var(--text-dim);
445
+ display: flex;
446
+ align-items: center;
447
+ gap: var(--space-2);
448
+ flex: 1;
449
+ min-width: 0;
450
+ overflow: hidden;
451
+ text-overflow: ellipsis;
452
+ white-space: nowrap;
453
+ }
454
+
455
+ .admin-override-swatch {
456
+ display: inline-block;
457
+ width: 14px;
458
+ height: 14px;
459
+ border: 1px solid var(--border2);
460
+ flex-shrink: 0;
461
+ }
462
+
463
+ .admin-override-remove {
464
+ flex-shrink: 0;
465
+ padding: var(--space-1);
466
+ color: var(--text-faint);
467
+ }
468
+
469
+ .admin-override-remove:hover { color: var(--red); }
470
+
471
+ .admin-override-add {
472
+ display: flex;
473
+ gap: var(--space-2);
474
+ padding: var(--space-3);
475
+ border: 2px dashed var(--border2);
476
+ background: var(--surface);
477
+ }
478
+
479
+ .admin-override-input {
480
+ font-size: var(--text-sm);
481
+ padding: var(--space-1) var(--space-2);
482
+ border: var(--border-width-default) solid var(--border);
483
+ background: var(--surface2);
484
+ color: var(--text);
485
+ font-family: var(--font-mono);
486
+ flex: 1;
487
+ min-width: 0;
488
+ }
489
+
490
+ .admin-override-actions {
491
+ margin-top: var(--space-4);
492
+ }
493
+
494
+ @media (max-width: 640px) {
495
+ .admin-family-select { flex-direction: column; }
496
+ .admin-family-previews { width: 100%; }
497
+ .admin-family-preview { flex: 1; border-right: none; border-bottom: var(--border-width-default) solid var(--border2); }
498
+ .admin-override-add { flex-direction: column; }
499
+ }
500
+ </style>
@@ -101,6 +101,13 @@ async function handleSubmit(): Promise<void> {
101
101
  />
102
102
  </div>
103
103
 
104
+ <p class="register-legal">
105
+ By creating an account, you agree to our
106
+ <NuxtLink to="/terms">Terms of Service</NuxtLink>
107
+ and acknowledge our
108
+ <NuxtLink to="/privacy">Privacy Policy</NuxtLink>.
109
+ </p>
110
+
104
111
  <button type="submit" class="submit-btn" :disabled="loading">
105
112
  {{ loading ? 'Creating...' : 'Create account' }}
106
113
  </button>
@@ -206,6 +213,21 @@ async function handleSubmit(): Promise<void> {
206
213
  cursor: not-allowed;
207
214
  }
208
215
 
216
+ .register-legal {
217
+ font-size: 11px;
218
+ color: var(--text-faint);
219
+ line-height: 1.5;
220
+ }
221
+
222
+ .register-legal a {
223
+ color: var(--accent);
224
+ text-decoration: none;
225
+ }
226
+
227
+ .register-legal a:hover {
228
+ text-decoration: underline;
229
+ }
230
+
209
231
  .register-footer {
210
232
  text-align: center;
211
233
  font-size: 12px;