@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,547 @@
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
+ async function save(): Promise<void> {
153
+ saving.value = true;
154
+ error.value = null;
155
+ try {
156
+ const payload = {
157
+ id: draft.value.id,
158
+ name: draft.value.name,
159
+ description: draft.value.description,
160
+ family: draft.value.family,
161
+ isDark: draft.value.isDark,
162
+ pairId: draft.value.pairId,
163
+ parentTheme: draft.value.parentTheme,
164
+ tokens: draft.value.tokens,
165
+ };
166
+ if (isCreating) {
167
+ const created = await $fetch('/api/admin/themes', {
168
+ method: 'POST',
169
+ body: payload,
170
+ });
171
+ notify('Theme created', 'success');
172
+ dirty.value = false;
173
+ await themesApi.refresh();
174
+ router.replace(`/admin/theme/edit/${(created as { id: string }).id}`);
175
+ } else {
176
+ // Cast: Nuxt's typed-route inference for dynamic URLs picks the
177
+ // narrowest method overload (GET) — same workaround used in
178
+ // learn/[slug]/edit.vue.
179
+ await ($fetch as (url: string, opts: Record<string, unknown>) => Promise<unknown>)(
180
+ `/api/admin/themes/${draft.value.id}`,
181
+ { method: 'PUT', body: payload },
182
+ );
183
+ notify('Saved', 'success');
184
+ dirty.value = false;
185
+ await themesApi.refresh();
186
+ }
187
+ } catch (err) {
188
+ const msg = err instanceof Error ? err.message : 'Save failed';
189
+ error.value = msg;
190
+ notify(msg, 'error');
191
+ } finally {
192
+ saving.value = false;
193
+ }
194
+ }
195
+
196
+ async function applyAndSave(): Promise<void> {
197
+ await save();
198
+ if (error.value) return;
199
+ await $fetch('/api/admin/settings', {
200
+ method: 'PUT',
201
+ body: { key: 'theme.default', value: `cpub-custom-${draft.value.id}` },
202
+ });
203
+ notify('Saved and applied instance-wide', 'success');
204
+ }
205
+
206
+ function exportTheme(): void {
207
+ // Snapshot the in-progress draft (unsaved tokens included) so the
208
+ // admin can export-while-editing without committing first.
209
+ downloadThemeFile({
210
+ id: draft.value.id,
211
+ name: draft.value.name,
212
+ description: draft.value.description,
213
+ family: draft.value.family,
214
+ isDark: draft.value.isDark,
215
+ pairId: draft.value.pairId,
216
+ parentTheme: draft.value.parentTheme,
217
+ tokens: draft.value.tokens,
218
+ createdAt: draft.value.createdAt ?? new Date().toISOString(),
219
+ updatedAt: new Date().toISOString(),
220
+ });
221
+ }
222
+
223
+ function cancel(): void {
224
+ if (dirty.value && !confirm('Discard unsaved changes?')) return;
225
+ router.push('/admin/theme');
226
+ }
227
+
228
+ function notify(msg: string, tone: 'success' | 'error'): void {
229
+ toast.value = { msg, tone };
230
+ setTimeout(() => { toast.value = null; }, 2400);
231
+ }
232
+
233
+ // Browser leave guard — warn before refresh/close when there are unsaved changes
234
+ function beforeUnloadGuard(e: BeforeUnloadEvent): void {
235
+ if (dirty.value) {
236
+ e.preventDefault();
237
+ e.returnValue = '';
238
+ }
239
+ }
240
+ onMounted(() => {
241
+ if (typeof window !== 'undefined') window.addEventListener('beforeunload', beforeUnloadGuard);
242
+ });
243
+ onBeforeUnmount(() => {
244
+ if (typeof window !== 'undefined') window.removeEventListener('beforeunload', beforeUnloadGuard);
245
+ });
246
+ </script>
247
+
248
+ <template>
249
+ <div class="theme-editor">
250
+ <header class="theme-editor-toolbar">
251
+ <button class="cpub-btn cpub-btn-sm theme-editor-back" @click="cancel">
252
+ <i class="fa-solid fa-arrow-left" aria-hidden="true" />
253
+ <span>Themes</span>
254
+ </button>
255
+
256
+ <div class="theme-editor-meta">
257
+ <label class="theme-editor-field theme-editor-field-name">
258
+ <span class="theme-editor-field-label">Name</span>
259
+ <input
260
+ v-model="draft.name"
261
+ class="theme-editor-input theme-editor-input-name"
262
+ type="text"
263
+ placeholder="My theme"
264
+ @input="onMetaChange"
265
+ />
266
+ </label>
267
+
268
+ <label class="theme-editor-field">
269
+ <span class="theme-editor-field-label">ID</span>
270
+ <input
271
+ v-model="draft.id"
272
+ class="theme-editor-input theme-editor-input-id"
273
+ type="text"
274
+ placeholder="my-theme"
275
+ :disabled="!isCreating"
276
+ @input="onMetaChange"
277
+ />
278
+ </label>
279
+
280
+ <label class="theme-editor-field">
281
+ <span class="theme-editor-field-label">Family</span>
282
+ <input
283
+ v-model="draft.family"
284
+ class="theme-editor-input theme-editor-input-family"
285
+ type="text"
286
+ placeholder="custom"
287
+ @input="onMetaChange"
288
+ />
289
+ </label>
290
+
291
+ <label class="theme-editor-field">
292
+ <span class="theme-editor-field-label">Inherits from</span>
293
+ <select v-model="draft.parentTheme" class="theme-editor-input" @change="onMetaChange">
294
+ <option v-for="p in parentOptions" :key="p.id" :value="p.id">{{ p.name }}</option>
295
+ </select>
296
+ </label>
297
+
298
+ <label class="theme-editor-field theme-editor-field-toggle">
299
+ <span class="theme-editor-field-label">Mode</span>
300
+ <div class="theme-editor-mode-pill" role="group">
301
+ <button
302
+ type="button"
303
+ class="theme-editor-mode-btn"
304
+ :class="{ active: !draft.isDark }"
305
+ @click="(() => { draft.isDark = false; onMetaChange(); })()"
306
+ >Light</button>
307
+ <button
308
+ type="button"
309
+ class="theme-editor-mode-btn"
310
+ :class="{ active: draft.isDark }"
311
+ @click="(() => { draft.isDark = true; onMetaChange(); })()"
312
+ >Dark</button>
313
+ </div>
314
+ </label>
315
+
316
+ <label v-if="pairCandidates.length" class="theme-editor-field">
317
+ <span class="theme-editor-field-label">Pair with</span>
318
+ <select v-model="draft.pairId" class="theme-editor-input" @change="onMetaChange">
319
+ <option :value="undefined">— none —</option>
320
+ <option v-for="p in pairCandidates" :key="p.id" :value="p.id">{{ p.name }}</option>
321
+ </select>
322
+ </label>
323
+ </div>
324
+
325
+ <div class="theme-editor-actions">
326
+ <span v-if="modifiedTotal > 0" class="theme-editor-modified">
327
+ {{ modifiedTotal }} token{{ modifiedTotal === 1 ? '' : 's' }} customized
328
+ </span>
329
+ <button class="cpub-btn cpub-btn-sm" @click="exportTheme" title="Download .cpub-theme.json">
330
+ <i class="fa-solid fa-file-export" aria-hidden="true" /> Export
331
+ </button>
332
+ <button class="cpub-btn cpub-btn-sm" :disabled="saving || !dirty" @click="save">
333
+ <i class="fa-solid fa-floppy-disk" aria-hidden="true" /> Save
334
+ </button>
335
+ <button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="saving" @click="applyAndSave">
336
+ <i class="fa-solid fa-rocket" aria-hidden="true" /> Save &amp; apply
337
+ </button>
338
+ </div>
339
+ </header>
340
+
341
+ <p v-if="error" class="theme-editor-error">
342
+ <i class="fa-solid fa-triangle-exclamation" aria-hidden="true" /> {{ error }}
343
+ </p>
344
+
345
+ <textarea
346
+ v-model="draft.description"
347
+ class="theme-editor-description"
348
+ placeholder="Description — shown on the theme list (optional)"
349
+ rows="2"
350
+ @input="onMetaChange"
351
+ />
352
+
353
+ <p v-if="loading" class="admin-empty">
354
+ <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" /> Loading editor…
355
+ </p>
356
+
357
+ <div v-else class="theme-editor-body">
358
+ <section class="theme-editor-tokens" aria-label="Token editor">
359
+ <AdminThemeTokenGroup
360
+ v-for="group in TOKEN_GROUP_ORDER"
361
+ :key="group"
362
+ :group="group"
363
+ :label="TOKEN_GROUP_LABELS[group].label"
364
+ :icon="TOKEN_GROUP_LABELS[group].icon"
365
+ :description="TOKEN_GROUP_LABELS[group].description"
366
+ :specs="groups[group]"
367
+ :tokens="draft.tokens"
368
+ :open="group === 'surfaces' || group === 'accent'"
369
+ @update="updateToken"
370
+ @reset="resetToken"
371
+ />
372
+ </section>
373
+
374
+ <AdminThemePreviewPane
375
+ class="theme-editor-preview"
376
+ :tokens="draft.tokens"
377
+ :parent-theme="draft.parentTheme"
378
+ :is-dark="draft.isDark"
379
+ />
380
+ </div>
381
+
382
+ <div v-if="toast" class="admin-theme-toast" :class="`tone-${toast.tone}`">
383
+ <i :class="['fa-solid', toast.tone === 'success' ? 'fa-check' : 'fa-triangle-exclamation']" aria-hidden="true" />
384
+ {{ toast.msg }}
385
+ </div>
386
+ </div>
387
+ </template>
388
+
389
+ <style scoped>
390
+ .theme-editor {
391
+ display: flex;
392
+ flex-direction: column;
393
+ margin: calc(-1 * var(--space-6));
394
+ min-height: calc(100vh - var(--nav-height));
395
+ background: var(--bg);
396
+ }
397
+
398
+ /* Toolbar */
399
+ .theme-editor-toolbar {
400
+ display: flex;
401
+ align-items: center;
402
+ gap: var(--space-3);
403
+ padding: var(--space-3) var(--space-4);
404
+ background: var(--surface);
405
+ border-bottom: var(--border-width-default) solid var(--border);
406
+ flex-wrap: wrap;
407
+ }
408
+
409
+ .theme-editor-back { flex-shrink: 0; }
410
+
411
+ .theme-editor-meta {
412
+ display: flex;
413
+ align-items: flex-end;
414
+ gap: var(--space-3);
415
+ flex-wrap: wrap;
416
+ flex: 1;
417
+ min-width: 0;
418
+ }
419
+
420
+ .theme-editor-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
421
+ .theme-editor-field-label {
422
+ font-family: var(--font-mono);
423
+ font-size: 10px;
424
+ letter-spacing: var(--tracking-wide);
425
+ text-transform: uppercase;
426
+ color: var(--text-faint);
427
+ }
428
+
429
+ .theme-editor-input {
430
+ background: var(--surface2);
431
+ color: var(--text);
432
+ border: var(--border-width-thin) solid var(--border2);
433
+ padding: 6px 10px;
434
+ font-size: var(--text-sm);
435
+ font-family: var(--font-body);
436
+ min-width: 0;
437
+ }
438
+ .theme-editor-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
439
+ .theme-editor-input:disabled { color: var(--text-faint); background: var(--surface); }
440
+
441
+ .theme-editor-input-name { min-width: 160px; }
442
+ .theme-editor-input-id { font-family: var(--font-mono); min-width: 120px; max-width: 180px; }
443
+ .theme-editor-input-family { font-family: var(--font-mono); min-width: 100px; max-width: 140px; }
444
+
445
+ .theme-editor-mode-pill {
446
+ display: inline-flex;
447
+ background: var(--surface2);
448
+ border: var(--border-width-thin) solid var(--border2);
449
+ padding: 2px;
450
+ }
451
+ .theme-editor-mode-btn {
452
+ background: none;
453
+ border: 0;
454
+ padding: 4px 10px;
455
+ font-family: var(--font-mono);
456
+ font-size: 11px;
457
+ letter-spacing: var(--tracking-wide);
458
+ text-transform: uppercase;
459
+ color: var(--text-dim);
460
+ cursor: pointer;
461
+ }
462
+ .theme-editor-mode-btn.active { background: var(--surface); color: var(--accent); }
463
+
464
+ .theme-editor-actions {
465
+ display: flex;
466
+ align-items: center;
467
+ gap: var(--space-2);
468
+ margin-left: auto;
469
+ flex-wrap: wrap;
470
+ }
471
+ .theme-editor-modified {
472
+ font-family: var(--font-mono);
473
+ font-size: var(--text-label);
474
+ letter-spacing: var(--tracking-wide);
475
+ text-transform: uppercase;
476
+ color: var(--accent);
477
+ }
478
+
479
+ .theme-editor-error {
480
+ margin: 0;
481
+ padding: var(--space-3) var(--space-4);
482
+ background: var(--red-bg);
483
+ border-bottom: var(--border-width-default) solid var(--red);
484
+ color: var(--red);
485
+ font-size: var(--text-sm);
486
+ display: flex;
487
+ align-items: center;
488
+ gap: var(--space-2);
489
+ }
490
+
491
+ .theme-editor-description {
492
+ background: var(--surface);
493
+ border: 0;
494
+ border-bottom: var(--border-width-default) solid var(--border);
495
+ padding: var(--space-3) var(--space-4);
496
+ font-size: var(--text-sm);
497
+ font-family: var(--font-body);
498
+ color: var(--text);
499
+ resize: vertical;
500
+ width: 100%;
501
+ }
502
+ .theme-editor-description:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
503
+
504
+ .theme-editor-body {
505
+ display: grid;
506
+ grid-template-columns: minmax(320px, 380px) 1fr;
507
+ flex: 1;
508
+ min-height: 0;
509
+ overflow: hidden;
510
+ }
511
+
512
+ .theme-editor-tokens {
513
+ background: var(--surface);
514
+ border-right: var(--border-width-default) solid var(--border);
515
+ overflow: auto;
516
+ min-height: 0;
517
+ }
518
+
519
+ .theme-editor-preview {
520
+ min-height: 0;
521
+ overflow: hidden;
522
+ }
523
+
524
+ /* Toast reuses the list page's style */
525
+ .admin-theme-toast {
526
+ position: fixed;
527
+ top: calc(var(--nav-height) + var(--space-4));
528
+ right: var(--space-4);
529
+ padding: var(--space-2) var(--space-4);
530
+ font-size: var(--text-sm);
531
+ font-weight: var(--font-weight-semibold);
532
+ z-index: var(--z-toast);
533
+ border: var(--border-width-default) solid var(--border);
534
+ box-shadow: var(--shadow-md);
535
+ display: flex;
536
+ align-items: center;
537
+ gap: var(--space-2);
538
+ color: var(--color-text-inverse);
539
+ }
540
+ .admin-theme-toast.tone-success { background: var(--green); }
541
+ .admin-theme-toast.tone-error { background: var(--red); }
542
+
543
+ @media (max-width: 900px) {
544
+ .theme-editor-body { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
545
+ .theme-editor-tokens { border-right: 0; border-bottom: var(--border-width-default) solid var(--border); }
546
+ }
547
+ </style>