@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,595 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * /admin/theme/edit/[id] — full theme editor.
4
+ *
5
+ * Layout:
6
+ * ┌─────────────────────────────────────────────────────────────┐
7
+ * │ Toolbar: name • family • parent • dark? • save • export │
8
+ * ├──────────────────────┬──────────────────────────────────────┤
9
+ * │ Token editor pane │ Preview pane │
10
+ * │ (grouped, collapsible│ (scene picker, live tokens applied)│
11
+ * │ token rows) │ │
12
+ * └───────────────────────┴─────────────────────────────────────┘
13
+ *
14
+ * Special URL: /admin/theme/edit/__new
15
+ * The list page stashes a seed in sessionStorage and pushes the user here.
16
+ * On save, the seed is POSTed and the user is redirected to the real ID.
17
+ */
18
+ import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
19
+ import { TOKEN_GROUP_LABELS, TOKEN_GROUP_ORDER, tokensByGroup } from '@commonpub/ui';
20
+
21
+ definePageMeta({ layout: 'admin', middleware: 'auth' });
22
+
23
+ const route = useRoute();
24
+ const router = useRouter();
25
+ const themesApi = useThemeAdmin();
26
+
27
+ const rawId = String(route.params.id ?? '');
28
+ const isCreating = rawId === '__new';
29
+
30
+ const loading = ref(true);
31
+ const saving = ref(false);
32
+ const dirty = ref(false);
33
+ const error = ref<string | null>(null);
34
+ const toast = ref<{ msg: string; tone: 'success' | 'error' } | null>(null);
35
+
36
+ interface DraftTheme {
37
+ id: string;
38
+ name: string;
39
+ description: string;
40
+ family: string;
41
+ isDark: boolean;
42
+ pairId?: string;
43
+ parentTheme: string;
44
+ tokens: Record<string, string>;
45
+ createdAt?: string;
46
+ }
47
+
48
+ const draft = ref<DraftTheme>({
49
+ id: '',
50
+ name: '',
51
+ description: '',
52
+ family: 'custom',
53
+ isDark: false,
54
+ parentTheme: 'base',
55
+ tokens: {},
56
+ });
57
+
58
+ // --- Load -------------------------------------------------------------
59
+
60
+ onMounted(async () => {
61
+ if (isCreating) {
62
+ const raw = sessionStorage.getItem('cpub-theme-editor-seed');
63
+ if (raw) {
64
+ try {
65
+ const seed = JSON.parse(raw);
66
+ draft.value = {
67
+ id: seed.id ?? '',
68
+ name: seed.name ?? 'My theme',
69
+ description: seed.description ?? '',
70
+ family: seed.family ?? 'custom',
71
+ isDark: Boolean(seed.isDark),
72
+ pairId: seed.pairId,
73
+ parentTheme: seed.parentTheme ?? 'base',
74
+ tokens: seed.tokens ?? {},
75
+ };
76
+ } catch {
77
+ // Bad seed — start blank
78
+ draft.value.id = 'my-theme';
79
+ draft.value.name = 'My theme';
80
+ }
81
+ sessionStorage.removeItem('cpub-theme-editor-seed');
82
+ } else {
83
+ // Direct navigation to /__new with no seed — give a blank draft
84
+ draft.value.id = 'my-theme';
85
+ draft.value.name = 'My theme';
86
+ }
87
+ } else {
88
+ try {
89
+ const theme = await $fetch<DraftTheme>(`/api/admin/themes/${rawId}`);
90
+ draft.value = {
91
+ id: theme.id,
92
+ name: theme.name,
93
+ description: theme.description ?? '',
94
+ family: theme.family,
95
+ isDark: theme.isDark,
96
+ pairId: theme.pairId,
97
+ parentTheme: theme.parentTheme,
98
+ tokens: { ...theme.tokens },
99
+ createdAt: theme.createdAt,
100
+ };
101
+ } catch (err) {
102
+ error.value = err instanceof Error ? err.message : 'Failed to load theme';
103
+ }
104
+ }
105
+
106
+ // Load themes for the parent/pair pickers
107
+ if (!themesApi.data.value) await themesApi.refresh();
108
+ loading.value = false;
109
+ });
110
+
111
+ // --- Token editing ----------------------------------------------------
112
+
113
+ const groups = computed(() => tokensByGroup());
114
+
115
+ function updateToken(key: string, value: string): void {
116
+ if (value === '') {
117
+ // Empty input — treat as "use parent's value" (delete the override)
118
+ const next = { ...draft.value.tokens };
119
+ delete next[key];
120
+ draft.value.tokens = next;
121
+ } else {
122
+ draft.value.tokens = { ...draft.value.tokens, [key]: value };
123
+ }
124
+ dirty.value = true;
125
+ }
126
+
127
+ function resetToken(key: string): void {
128
+ const next = { ...draft.value.tokens };
129
+ delete next[key];
130
+ draft.value.tokens = next;
131
+ dirty.value = true;
132
+ }
133
+
134
+ const modifiedTotal = computed(() => Object.keys(draft.value.tokens).length);
135
+
136
+ // --- Metadata edits ---------------------------------------------------
137
+
138
+ function onMetaChange(): void { dirty.value = true; }
139
+
140
+ // Available parent themes — built-in only (custom-as-parent gets complex)
141
+ const parentOptions = computed(() => themesApi.data.value?.builtIn.map((t) => ({ id: t.id, name: t.name })) ?? []);
142
+
143
+ // Available pair candidates — same family, opposite mode, custom themes only
144
+ const pairCandidates = computed(() =>
145
+ (themesApi.data.value?.custom ?? []).filter(
146
+ (t) => t.family === draft.value.family && t.isDark !== draft.value.isDark && t.id !== draft.value.id,
147
+ ),
148
+ );
149
+
150
+ // --- Save / cancel / export -----------------------------------------
151
+
152
+ /**
153
+ * Save the draft. If `apply` is true, ALSO set this theme as the
154
+ * instance default in the same await chain — must happen BEFORE the
155
+ * create-mode router.replace, otherwise the navigation could unmount
156
+ * the component mid-PUT and lose the apply.
157
+ */
158
+ async function save({ apply = false }: { apply?: boolean } = {}): Promise<void> {
159
+ saving.value = true;
160
+ error.value = null;
161
+ try {
162
+ const payload = {
163
+ id: draft.value.id,
164
+ name: draft.value.name,
165
+ description: draft.value.description,
166
+ family: draft.value.family,
167
+ isDark: draft.value.isDark,
168
+ pairId: draft.value.pairId,
169
+ parentTheme: draft.value.parentTheme,
170
+ tokens: draft.value.tokens,
171
+ };
172
+
173
+ let savedId: string;
174
+ if (isCreating) {
175
+ const created = await $fetch('/api/admin/themes', {
176
+ method: 'POST',
177
+ body: payload,
178
+ });
179
+ savedId = (created as { id: string }).id;
180
+ } else {
181
+ // Cast: Nuxt's typed-route inference for dynamic URLs picks the
182
+ // narrowest method overload (GET) — same workaround used in
183
+ // learn/[slug]/edit.vue.
184
+ await ($fetch as (url: string, opts: Record<string, unknown>) => Promise<unknown>)(
185
+ `/api/admin/themes/${draft.value.id}`,
186
+ { method: 'PUT', body: payload },
187
+ );
188
+ savedId = draft.value.id;
189
+ }
190
+
191
+ // Apply BEFORE refresh/navigation so the navigate doesn't unmount us
192
+ // mid-PUT (would lose the apply + the success toast).
193
+ if (apply) {
194
+ await $fetch('/api/admin/settings', {
195
+ method: 'PUT',
196
+ body: { key: 'theme.default', value: `cpub-custom-${savedId}` },
197
+ });
198
+ }
199
+
200
+ notify(apply ? 'Saved & applied' : (isCreating ? 'Theme created' : 'Saved'), 'success');
201
+ dirty.value = false;
202
+ await themesApi.refresh();
203
+
204
+ // Navigate LAST so all the awaits above have observable effects.
205
+ if (isCreating) {
206
+ router.replace(`/admin/theme/edit/${savedId}`);
207
+ }
208
+ } catch (err) {
209
+ const msg = err instanceof Error ? err.message : 'Save failed';
210
+ error.value = msg;
211
+ notify(msg, 'error');
212
+ } finally {
213
+ saving.value = false;
214
+ }
215
+ }
216
+
217
+ async function applyAndSave(): Promise<void> {
218
+ await save({ apply: true });
219
+ }
220
+
221
+ function exportTheme(): void {
222
+ // Snapshot the in-progress draft (unsaved tokens included) so the
223
+ // admin can export-while-editing without committing first.
224
+ downloadThemeFile({
225
+ id: draft.value.id,
226
+ name: draft.value.name,
227
+ description: draft.value.description,
228
+ family: draft.value.family,
229
+ isDark: draft.value.isDark,
230
+ pairId: draft.value.pairId,
231
+ parentTheme: draft.value.parentTheme,
232
+ tokens: draft.value.tokens,
233
+ createdAt: draft.value.createdAt ?? new Date().toISOString(),
234
+ updatedAt: new Date().toISOString(),
235
+ });
236
+ }
237
+
238
+ function cancel(): void {
239
+ if (dirty.value && !confirm('Discard unsaved changes?')) return;
240
+ router.push('/admin/theme');
241
+ }
242
+
243
+ function notify(msg: string, tone: 'success' | 'error'): void {
244
+ toast.value = { msg, tone };
245
+ setTimeout(() => { toast.value = null; }, 2400);
246
+ }
247
+
248
+ // Browser leave guard — warn before refresh/close when there are unsaved changes
249
+ function beforeUnloadGuard(e: BeforeUnloadEvent): void {
250
+ if (dirty.value) {
251
+ e.preventDefault();
252
+ e.returnValue = '';
253
+ }
254
+ }
255
+ onMounted(() => {
256
+ if (typeof window !== 'undefined') window.addEventListener('beforeunload', beforeUnloadGuard);
257
+ });
258
+ onBeforeUnmount(() => {
259
+ if (typeof window !== 'undefined') window.removeEventListener('beforeunload', beforeUnloadGuard);
260
+ });
261
+ </script>
262
+
263
+ <template>
264
+ <div class="theme-editor">
265
+ <header class="theme-editor-toolbar">
266
+ <button
267
+ class="cpub-btn cpub-btn-sm theme-editor-back"
268
+ :title="dirty ? 'You have unsaved changes' : 'Back to themes list'"
269
+ @click="cancel"
270
+ >
271
+ <i class="fa-solid fa-arrow-left" aria-hidden="true" />
272
+ <span>Themes</span>
273
+ <span v-if="dirty" class="theme-editor-dirty-dot" aria-label="unsaved changes"></span>
274
+ </button>
275
+
276
+ <div class="theme-editor-meta">
277
+ <label class="theme-editor-field theme-editor-field-name">
278
+ <span class="theme-editor-field-label">Name</span>
279
+ <input
280
+ v-model="draft.name"
281
+ class="theme-editor-input theme-editor-input-name"
282
+ type="text"
283
+ placeholder="My theme"
284
+ @input="onMetaChange"
285
+ />
286
+ </label>
287
+
288
+ <label class="theme-editor-field">
289
+ <span class="theme-editor-field-label">ID</span>
290
+ <input
291
+ v-model="draft.id"
292
+ class="theme-editor-input theme-editor-input-id"
293
+ type="text"
294
+ placeholder="my-theme"
295
+ :disabled="!isCreating"
296
+ @input="onMetaChange"
297
+ />
298
+ </label>
299
+
300
+ <label class="theme-editor-field">
301
+ <span class="theme-editor-field-label">Family</span>
302
+ <input
303
+ v-model="draft.family"
304
+ class="theme-editor-input theme-editor-input-family"
305
+ type="text"
306
+ placeholder="custom"
307
+ @input="onMetaChange"
308
+ />
309
+ </label>
310
+
311
+ <label class="theme-editor-field">
312
+ <span class="theme-editor-field-label">Inherits from</span>
313
+ <select v-model="draft.parentTheme" class="theme-editor-input" @change="onMetaChange">
314
+ <option v-for="p in parentOptions" :key="p.id" :value="p.id">{{ p.name }}</option>
315
+ </select>
316
+ </label>
317
+
318
+ <label class="theme-editor-field theme-editor-field-toggle">
319
+ <span class="theme-editor-field-label">Mode</span>
320
+ <div class="theme-editor-mode-pill" role="group">
321
+ <button
322
+ type="button"
323
+ class="theme-editor-mode-btn"
324
+ :class="{ active: !draft.isDark }"
325
+ @click="(() => { draft.isDark = false; onMetaChange(); })()"
326
+ >Light</button>
327
+ <button
328
+ type="button"
329
+ class="theme-editor-mode-btn"
330
+ :class="{ active: draft.isDark }"
331
+ @click="(() => { draft.isDark = true; onMetaChange(); })()"
332
+ >Dark</button>
333
+ </div>
334
+ </label>
335
+
336
+ <label v-if="pairCandidates.length" class="theme-editor-field">
337
+ <span class="theme-editor-field-label">Pair with</span>
338
+ <select v-model="draft.pairId" class="theme-editor-input" @change="onMetaChange">
339
+ <option :value="undefined">— none —</option>
340
+ <option v-for="p in pairCandidates" :key="p.id" :value="p.id">{{ p.name }}</option>
341
+ </select>
342
+ </label>
343
+ </div>
344
+
345
+ <div class="theme-editor-actions">
346
+ <span v-if="modifiedTotal > 0" class="theme-editor-modified">
347
+ {{ modifiedTotal }} token{{ modifiedTotal === 1 ? '' : 's' }} customized
348
+ </span>
349
+ <button class="cpub-btn cpub-btn-sm" @click="exportTheme" title="Download .cpub-theme.json">
350
+ <i class="fa-solid fa-file-export" aria-hidden="true" /> Export
351
+ </button>
352
+ <button class="cpub-btn cpub-btn-sm" :disabled="saving || !dirty" @click="() => save()">
353
+ <i :class="['fa-solid', saving ? 'fa-circle-notch fa-spin' : 'fa-floppy-disk']" aria-hidden="true" />
354
+ {{ saving ? 'Saving…' : 'Save' }}
355
+ </button>
356
+ <button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="saving" @click="applyAndSave">
357
+ <i :class="['fa-solid', saving ? 'fa-circle-notch fa-spin' : 'fa-rocket']" aria-hidden="true" />
358
+ {{ saving ? 'Applying…' : 'Save & apply' }}
359
+ </button>
360
+ </div>
361
+ </header>
362
+
363
+ <p v-if="error" class="theme-editor-error">
364
+ <i class="fa-solid fa-triangle-exclamation" aria-hidden="true" /> {{ error }}
365
+ </p>
366
+
367
+ <textarea
368
+ v-model="draft.description"
369
+ class="theme-editor-description"
370
+ placeholder="Description — shown on the theme list (optional)"
371
+ rows="2"
372
+ @input="onMetaChange"
373
+ />
374
+
375
+ <p v-if="loading" class="admin-empty">
376
+ <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" /> Loading editor…
377
+ </p>
378
+
379
+ <div v-else class="theme-editor-body">
380
+ <section class="theme-editor-tokens" aria-label="Token editor">
381
+ <AdminThemeTokenGroup
382
+ v-for="group in TOKEN_GROUP_ORDER"
383
+ :key="group"
384
+ :group="group"
385
+ :label="TOKEN_GROUP_LABELS[group].label"
386
+ :icon="TOKEN_GROUP_LABELS[group].icon"
387
+ :description="TOKEN_GROUP_LABELS[group].description"
388
+ :specs="groups[group]"
389
+ :tokens="draft.tokens"
390
+ :open="group === 'surfaces' || group === 'accent'"
391
+ @update="updateToken"
392
+ @reset="resetToken"
393
+ />
394
+ </section>
395
+
396
+ <AdminThemePreviewPane
397
+ class="theme-editor-preview"
398
+ :tokens="draft.tokens"
399
+ :parent-theme="draft.parentTheme"
400
+ :is-dark="draft.isDark"
401
+ />
402
+ </div>
403
+
404
+ <div v-if="toast" class="admin-theme-toast" :class="`tone-${toast.tone}`">
405
+ <i :class="['fa-solid', toast.tone === 'success' ? 'fa-check' : 'fa-triangle-exclamation']" aria-hidden="true" />
406
+ {{ toast.msg }}
407
+ </div>
408
+ </div>
409
+ </template>
410
+
411
+ <style scoped>
412
+ .theme-editor {
413
+ display: flex;
414
+ flex-direction: column;
415
+ margin: calc(-1 * var(--space-6));
416
+ min-height: calc(100vh - var(--nav-height));
417
+ background: var(--bg);
418
+ }
419
+
420
+ /* Toolbar */
421
+ .theme-editor-toolbar {
422
+ display: flex;
423
+ align-items: center;
424
+ gap: var(--space-3);
425
+ padding: var(--space-3) var(--space-4);
426
+ background: var(--surface);
427
+ border-bottom: var(--border-width-default) solid var(--border);
428
+ flex-wrap: wrap;
429
+ }
430
+
431
+ .theme-editor-back {
432
+ flex-shrink: 0;
433
+ position: relative;
434
+ }
435
+
436
+ .theme-editor-dirty-dot {
437
+ display: inline-block;
438
+ width: 6px;
439
+ height: 6px;
440
+ background: var(--accent);
441
+ border-radius: var(--radius-full);
442
+ margin-left: 4px;
443
+ /* Subtle pulse so it draws the eye without being noisy */
444
+ animation: theme-editor-dirty-pulse 2s ease-in-out infinite;
445
+ }
446
+
447
+ @keyframes theme-editor-dirty-pulse {
448
+ 0%, 100% { opacity: 1; }
449
+ 50% { opacity: 0.4; }
450
+ }
451
+ @media (prefers-reduced-motion: reduce) {
452
+ .theme-editor-dirty-dot { animation: none; }
453
+ }
454
+
455
+ .theme-editor-input-name {
456
+ font-weight: var(--font-weight-semibold);
457
+ }
458
+
459
+ .theme-editor-meta {
460
+ display: flex;
461
+ align-items: flex-end;
462
+ gap: var(--space-3);
463
+ flex-wrap: wrap;
464
+ flex: 1;
465
+ min-width: 0;
466
+ }
467
+
468
+ .theme-editor-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
469
+ .theme-editor-field-label {
470
+ font-family: var(--font-mono);
471
+ font-size: 10px;
472
+ letter-spacing: var(--tracking-wide);
473
+ text-transform: uppercase;
474
+ color: var(--text-faint);
475
+ }
476
+
477
+ .theme-editor-input {
478
+ background: var(--surface2);
479
+ color: var(--text);
480
+ border: var(--border-width-thin) solid var(--border2);
481
+ padding: 6px 10px;
482
+ font-size: var(--text-sm);
483
+ font-family: var(--font-body);
484
+ min-width: 0;
485
+ }
486
+ .theme-editor-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
487
+ .theme-editor-input:disabled { color: var(--text-faint); background: var(--surface); }
488
+
489
+ .theme-editor-input-name { min-width: 160px; }
490
+ .theme-editor-input-id { font-family: var(--font-mono); min-width: 120px; max-width: 180px; }
491
+ .theme-editor-input-family { font-family: var(--font-mono); min-width: 100px; max-width: 140px; }
492
+
493
+ .theme-editor-mode-pill {
494
+ display: inline-flex;
495
+ background: var(--surface2);
496
+ border: var(--border-width-thin) solid var(--border2);
497
+ padding: 2px;
498
+ }
499
+ .theme-editor-mode-btn {
500
+ background: none;
501
+ border: 0;
502
+ padding: 4px 10px;
503
+ font-family: var(--font-mono);
504
+ font-size: 11px;
505
+ letter-spacing: var(--tracking-wide);
506
+ text-transform: uppercase;
507
+ color: var(--text-dim);
508
+ cursor: pointer;
509
+ }
510
+ .theme-editor-mode-btn.active { background: var(--surface); color: var(--accent); }
511
+
512
+ .theme-editor-actions {
513
+ display: flex;
514
+ align-items: center;
515
+ gap: var(--space-2);
516
+ margin-left: auto;
517
+ flex-wrap: wrap;
518
+ }
519
+ .theme-editor-modified {
520
+ font-family: var(--font-mono);
521
+ font-size: var(--text-label);
522
+ letter-spacing: var(--tracking-wide);
523
+ text-transform: uppercase;
524
+ color: var(--accent);
525
+ }
526
+
527
+ .theme-editor-error {
528
+ margin: 0;
529
+ padding: var(--space-3) var(--space-4);
530
+ background: var(--red-bg);
531
+ border-bottom: var(--border-width-default) solid var(--red);
532
+ color: var(--red);
533
+ font-size: var(--text-sm);
534
+ display: flex;
535
+ align-items: center;
536
+ gap: var(--space-2);
537
+ }
538
+
539
+ .theme-editor-description {
540
+ background: var(--surface);
541
+ border: 0;
542
+ border-bottom: var(--border-width-default) solid var(--border);
543
+ padding: var(--space-3) var(--space-4);
544
+ font-size: var(--text-sm);
545
+ font-family: var(--font-body);
546
+ color: var(--text);
547
+ resize: vertical;
548
+ width: 100%;
549
+ }
550
+ .theme-editor-description:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
551
+
552
+ .theme-editor-body {
553
+ display: grid;
554
+ grid-template-columns: minmax(320px, 380px) 1fr;
555
+ flex: 1;
556
+ min-height: 0;
557
+ overflow: hidden;
558
+ }
559
+
560
+ .theme-editor-tokens {
561
+ background: var(--surface);
562
+ border-right: var(--border-width-default) solid var(--border);
563
+ overflow: auto;
564
+ min-height: 0;
565
+ }
566
+
567
+ .theme-editor-preview {
568
+ min-height: 0;
569
+ overflow: hidden;
570
+ }
571
+
572
+ /* Toast reuses the list page's style */
573
+ .admin-theme-toast {
574
+ position: fixed;
575
+ top: calc(var(--nav-height) + var(--space-4));
576
+ right: var(--space-4);
577
+ padding: var(--space-2) var(--space-4);
578
+ font-size: var(--text-sm);
579
+ font-weight: var(--font-weight-semibold);
580
+ z-index: var(--z-toast);
581
+ border: var(--border-width-default) solid var(--border);
582
+ box-shadow: var(--shadow-md);
583
+ display: flex;
584
+ align-items: center;
585
+ gap: var(--space-2);
586
+ color: var(--color-text-inverse);
587
+ }
588
+ .admin-theme-toast.tone-success { background: var(--green); }
589
+ .admin-theme-toast.tone-error { background: var(--red); }
590
+
591
+ @media (max-width: 900px) {
592
+ .theme-editor-body { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
593
+ .theme-editor-tokens { border-right: 0; border-bottom: var(--border-width-default) solid var(--border); }
594
+ }
595
+ </style>