@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,278 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One token row in the editor. Picks the input control based on the token
|
|
4
|
+
* spec's `kind` and emits updates upward. Shows:
|
|
5
|
+
* • token name (mono)
|
|
6
|
+
* • description (faint, on its own line)
|
|
7
|
+
* • the appropriate input
|
|
8
|
+
* • a "reset to default" button when the value differs
|
|
9
|
+
*
|
|
10
|
+
* No prop drilling, no internal state — this is a pure controlled component.
|
|
11
|
+
*/
|
|
12
|
+
import type { TokenSpec } from '@commonpub/ui';
|
|
13
|
+
import { computed } from 'vue';
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
spec: TokenSpec;
|
|
17
|
+
value: string;
|
|
18
|
+
/** Resolved value (after CSS resolution) for color preview when `value` is a var()
|
|
19
|
+
* or rgba expression. Optional — falls back to `value`. */
|
|
20
|
+
resolvedValue?: string;
|
|
21
|
+
}>();
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits<{
|
|
24
|
+
update: [value: string];
|
|
25
|
+
reset: [];
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
const isModified = computed(() => props.value !== props.spec.default && props.value !== '');
|
|
29
|
+
|
|
30
|
+
/** Returns a hex/rgb color that <input type="color"> understands; null if it can't. */
|
|
31
|
+
const colorPickerValue = computed<string | null>(() => {
|
|
32
|
+
const v = (props.value || props.resolvedValue || props.spec.default).trim();
|
|
33
|
+
if (/^#[0-9a-f]{3}$/i.test(v)) {
|
|
34
|
+
// Expand 3-digit hex to 6-digit
|
|
35
|
+
return '#' + v.slice(1).split('').map((c) => c + c).join('');
|
|
36
|
+
}
|
|
37
|
+
if (/^#[0-9a-f]{6}$/i.test(v)) return v.toLowerCase();
|
|
38
|
+
// rgb/rgba: extract first three numbers
|
|
39
|
+
const m = v.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
|
40
|
+
if (m) {
|
|
41
|
+
const hex = '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('');
|
|
42
|
+
return hex;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function onColorPick(e: Event): void {
|
|
48
|
+
const next = (e.target as HTMLInputElement).value;
|
|
49
|
+
emit('update', next);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onTextChange(e: Event): void {
|
|
53
|
+
emit('update', (e.target as HTMLInputElement | HTMLSelectElement).value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Number tokens (lengths) split into magnitude + unit for nicer editing
|
|
57
|
+
const NUMBER_UNITS = ['rem', 'px', 'em', '%', 'vh', 'vw', 'ch'] as const;
|
|
58
|
+
type LengthUnit = typeof NUMBER_UNITS[number] | '';
|
|
59
|
+
const lengthParts = computed<{ num: string; unit: LengthUnit }>(() => {
|
|
60
|
+
const v = (props.value || props.spec.default).trim();
|
|
61
|
+
const m = v.match(/^(-?\d*\.?\d+)\s*(rem|px|em|%|vh|vw|ch)?$/);
|
|
62
|
+
if (m) return { num: m[1] ?? '', unit: (m[2] ?? '') as LengthUnit };
|
|
63
|
+
return { num: '', unit: '' };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function commitLengthParts(num: string, unit: LengthUnit): void {
|
|
67
|
+
if (num === '') return;
|
|
68
|
+
emit('update', unit === '' ? num : `${num}${unit}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Shadow tokens — exposed as raw string editing (composer is future work)
|
|
72
|
+
// Font weights — restricted dropdown
|
|
73
|
+
const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900'];
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<div class="token-row" :class="{ 'is-modified': isModified }">
|
|
78
|
+
<div class="token-row-head">
|
|
79
|
+
<code class="token-name">--{{ spec.key }}</code>
|
|
80
|
+
<button
|
|
81
|
+
v-if="isModified"
|
|
82
|
+
type="button"
|
|
83
|
+
class="token-reset"
|
|
84
|
+
:title="`Reset to ${spec.default}`"
|
|
85
|
+
@click="emit('reset')"
|
|
86
|
+
>
|
|
87
|
+
<i class="fa-solid fa-rotate-left" aria-hidden="true" />
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<p v-if="spec.description" class="token-desc">{{ spec.description }}</p>
|
|
92
|
+
|
|
93
|
+
<!-- COLOR -->
|
|
94
|
+
<div v-if="spec.kind === 'color'" class="token-input-color">
|
|
95
|
+
<input
|
|
96
|
+
v-if="colorPickerValue"
|
|
97
|
+
type="color"
|
|
98
|
+
class="token-color-swatch"
|
|
99
|
+
:value="colorPickerValue"
|
|
100
|
+
:aria-label="`${spec.key} color`"
|
|
101
|
+
@input="onColorPick"
|
|
102
|
+
/>
|
|
103
|
+
<div
|
|
104
|
+
v-else
|
|
105
|
+
class="token-color-swatch-fallback"
|
|
106
|
+
:style="{ background: value || spec.default }"
|
|
107
|
+
:title="value || spec.default"
|
|
108
|
+
aria-hidden="true"
|
|
109
|
+
/>
|
|
110
|
+
<input
|
|
111
|
+
class="token-input"
|
|
112
|
+
type="text"
|
|
113
|
+
:value="value"
|
|
114
|
+
:placeholder="spec.default"
|
|
115
|
+
@input="onTextChange"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<!-- LENGTH -->
|
|
120
|
+
<div v-else-if="spec.kind === 'length'" class="token-input-length">
|
|
121
|
+
<input
|
|
122
|
+
class="token-input token-input-num"
|
|
123
|
+
type="text"
|
|
124
|
+
inputmode="decimal"
|
|
125
|
+
:value="lengthParts.num"
|
|
126
|
+
:placeholder="spec.default"
|
|
127
|
+
@change="(e) => commitLengthParts((e.target as HTMLInputElement).value, lengthParts.unit)"
|
|
128
|
+
/>
|
|
129
|
+
<select
|
|
130
|
+
class="token-input token-input-unit"
|
|
131
|
+
:value="lengthParts.unit"
|
|
132
|
+
@change="(e) => commitLengthParts(lengthParts.num, (e.target as HTMLSelectElement).value as never)"
|
|
133
|
+
>
|
|
134
|
+
<option v-for="u in NUMBER_UNITS" :key="u" :value="u">{{ u }}</option>
|
|
135
|
+
<option value="">—</option>
|
|
136
|
+
</select>
|
|
137
|
+
<input
|
|
138
|
+
class="token-input token-input-raw"
|
|
139
|
+
type="text"
|
|
140
|
+
:value="value"
|
|
141
|
+
:placeholder="spec.default"
|
|
142
|
+
title="Or enter raw CSS"
|
|
143
|
+
@change="onTextChange"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<!-- NUMBER (unitless: weight, z-index, leading) -->
|
|
148
|
+
<input
|
|
149
|
+
v-else-if="spec.kind === 'number'"
|
|
150
|
+
class="token-input"
|
|
151
|
+
type="text"
|
|
152
|
+
inputmode="decimal"
|
|
153
|
+
:value="value"
|
|
154
|
+
:placeholder="spec.default"
|
|
155
|
+
@input="onTextChange"
|
|
156
|
+
/>
|
|
157
|
+
|
|
158
|
+
<!-- FONT WEIGHT -->
|
|
159
|
+
<select
|
|
160
|
+
v-else-if="spec.kind === 'font-weight'"
|
|
161
|
+
class="token-input"
|
|
162
|
+
:value="value || spec.default"
|
|
163
|
+
@change="onTextChange"
|
|
164
|
+
>
|
|
165
|
+
<option v-for="w in WEIGHTS" :key="w" :value="w">{{ w }}</option>
|
|
166
|
+
</select>
|
|
167
|
+
|
|
168
|
+
<!-- FONT FAMILY -->
|
|
169
|
+
<input
|
|
170
|
+
v-else-if="spec.kind === 'font-family'"
|
|
171
|
+
class="token-input token-input-font"
|
|
172
|
+
type="text"
|
|
173
|
+
:value="value"
|
|
174
|
+
:placeholder="spec.default"
|
|
175
|
+
:style="{ fontFamily: value || spec.default }"
|
|
176
|
+
@input="onTextChange"
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
<!-- SHADOW / TRANSITION / STRING — raw text -->
|
|
180
|
+
<input
|
|
181
|
+
v-else
|
|
182
|
+
class="token-input token-input-mono"
|
|
183
|
+
type="text"
|
|
184
|
+
:value="value"
|
|
185
|
+
:placeholder="spec.default"
|
|
186
|
+
@input="onTextChange"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
</template>
|
|
190
|
+
|
|
191
|
+
<style scoped>
|
|
192
|
+
.token-row {
|
|
193
|
+
display: flex;
|
|
194
|
+
flex-direction: column;
|
|
195
|
+
gap: 4px;
|
|
196
|
+
padding: var(--space-2) var(--space-3);
|
|
197
|
+
border-bottom: var(--border-width-thin) solid var(--border2);
|
|
198
|
+
}
|
|
199
|
+
.token-row:last-child { border-bottom: 0; }
|
|
200
|
+
.token-row.is-modified { background: var(--accent-bg); }
|
|
201
|
+
|
|
202
|
+
.token-row-head {
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
gap: var(--space-2);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.token-name {
|
|
209
|
+
flex: 1;
|
|
210
|
+
font-family: var(--font-mono);
|
|
211
|
+
font-size: var(--text-sm);
|
|
212
|
+
color: var(--text);
|
|
213
|
+
font-weight: var(--font-weight-medium);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.token-reset {
|
|
217
|
+
width: 22px;
|
|
218
|
+
height: 22px;
|
|
219
|
+
background: none;
|
|
220
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
221
|
+
color: var(--text-dim);
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
display: inline-flex;
|
|
224
|
+
align-items: center;
|
|
225
|
+
justify-content: center;
|
|
226
|
+
font-size: 10px;
|
|
227
|
+
border-radius: 0;
|
|
228
|
+
}
|
|
229
|
+
.token-reset:hover { color: var(--accent); border-color: var(--accent); }
|
|
230
|
+
|
|
231
|
+
.token-desc {
|
|
232
|
+
font-size: var(--text-xs);
|
|
233
|
+
color: var(--text-faint);
|
|
234
|
+
margin: 0;
|
|
235
|
+
line-height: var(--leading-snug);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.token-input {
|
|
239
|
+
background: var(--surface2);
|
|
240
|
+
color: var(--text);
|
|
241
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
242
|
+
padding: 6px 8px;
|
|
243
|
+
font-size: var(--text-sm);
|
|
244
|
+
font-family: var(--font-mono);
|
|
245
|
+
width: 100%;
|
|
246
|
+
}
|
|
247
|
+
.token-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
|
|
248
|
+
|
|
249
|
+
.token-input-color { display: flex; align-items: center; gap: var(--space-2); }
|
|
250
|
+
.token-color-swatch {
|
|
251
|
+
width: 36px;
|
|
252
|
+
height: 30px;
|
|
253
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
254
|
+
padding: 0;
|
|
255
|
+
background: transparent;
|
|
256
|
+
cursor: pointer;
|
|
257
|
+
flex-shrink: 0;
|
|
258
|
+
}
|
|
259
|
+
.token-color-swatch-fallback {
|
|
260
|
+
width: 36px;
|
|
261
|
+
height: 30px;
|
|
262
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
263
|
+
flex-shrink: 0;
|
|
264
|
+
background-image:
|
|
265
|
+
linear-gradient(45deg, var(--border2) 25%, transparent 25%),
|
|
266
|
+
linear-gradient(-45deg, var(--border2) 25%, transparent 25%),
|
|
267
|
+
linear-gradient(45deg, transparent 75%, var(--border2) 75%),
|
|
268
|
+
linear-gradient(-45deg, transparent 75%, var(--border2) 75%);
|
|
269
|
+
background-size: 8px 8px;
|
|
270
|
+
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.token-input-length { display: grid; grid-template-columns: 1fr 70px; gap: 4px; }
|
|
274
|
+
.token-input-length .token-input-raw { grid-column: 1 / -1; font-size: var(--text-xs); }
|
|
275
|
+
|
|
276
|
+
.token-input-font { font-size: var(--text-sm); }
|
|
277
|
+
.token-input-mono { font-family: var(--font-mono); font-size: var(--text-xs); }
|
|
278
|
+
</style>
|
package/composables/useTheme.ts
CHANGED
|
@@ -10,6 +10,11 @@ import { THEME_TO_FAMILY, FAMILY_VARIANTS } from '../utils/themeConfig';
|
|
|
10
10
|
*
|
|
11
11
|
* The dark mode preference cookie (`cpub-color-scheme`) is only persisted
|
|
12
12
|
* when the user has accepted functional cookies via the consent banner.
|
|
13
|
+
*
|
|
14
|
+
* Custom themes (`cpub-custom-*`) and code-registered themes pass through —
|
|
15
|
+
* the user's cookie toggle is recorded but the server picks the actual variant
|
|
16
|
+
* using the custom theme's `pairId` (if declared). For built-in family pairs,
|
|
17
|
+
* the variant flip happens client-side immediately for snappy UX.
|
|
13
18
|
*/
|
|
14
19
|
export function useTheme(): {
|
|
15
20
|
/** Current active theme ID (resolved from instance default + dark mode) */
|
|
@@ -39,23 +44,28 @@ export function useTheme(): {
|
|
|
39
44
|
schemeCookie.value = dark ? 'dark' : 'light';
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
applyThemeToElement(document.documentElement, newTheme);
|
|
47
|
+
// Built-in family flip is purely client-side for snappy UX.
|
|
48
|
+
// Custom/registered themes need a server round-trip on next nav
|
|
49
|
+
// (the server reads the new cookie and picks the right pair).
|
|
50
|
+
if (THEME_TO_FAMILY[instanceDefault.value]) {
|
|
51
|
+
const family = THEME_TO_FAMILY[instanceDefault.value]!;
|
|
52
|
+
const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
|
|
53
|
+
const newTheme = dark ? variants.dark : variants.light;
|
|
54
|
+
themeId.value = newTheme;
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
if (import.meta.client) {
|
|
57
|
+
applyThemeToElement(document.documentElement, newTheme);
|
|
58
|
+
$fetch('/api/profile/theme', {
|
|
59
|
+
method: 'PUT',
|
|
60
|
+
body: { themeId: newTheme },
|
|
61
|
+
}).catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
} else if (import.meta.client) {
|
|
64
|
+
// Custom theme: just persist preference; server will pick the variant on next request
|
|
53
65
|
$fetch('/api/profile/theme', {
|
|
54
66
|
method: 'PUT',
|
|
55
|
-
body: { themeId:
|
|
56
|
-
}).catch(() => {
|
|
57
|
-
// Not logged in or network error — cookie preference is sufficient
|
|
58
|
-
});
|
|
67
|
+
body: { themeId: instanceDefault.value },
|
|
68
|
+
}).catch(() => {});
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared client-side state for the admin theme system.
|
|
3
|
+
*
|
|
4
|
+
* Singleton — both the list page (`/admin/theme`) and the editor
|
|
5
|
+
* (`/admin/theme/edit/[id]`) read the same `data`/`families` refs so
|
|
6
|
+
* a save in one place propagates to the other without refetching.
|
|
7
|
+
*
|
|
8
|
+
* Discovery helpers live in `utils/themeDiscovery.ts`; import/export
|
|
9
|
+
* in `utils/themeIO.ts`; id helpers in `utils/themeIds.ts`; types in
|
|
10
|
+
* `types/theme.ts`. This file orchestrates them into one composable.
|
|
11
|
+
*/
|
|
12
|
+
import { computed, ref } from 'vue';
|
|
13
|
+
import { BUILT_IN_THEMES, previewFromTokens } from '@commonpub/ui';
|
|
14
|
+
import type { CustomThemeRecord, ThemesPayload, ThemeFamilyView } from '../types/theme';
|
|
15
|
+
|
|
16
|
+
// ---- Family display metadata for built-in themes ------------------------
|
|
17
|
+
|
|
18
|
+
const BUILT_IN_FAMILY_META: Record<string, { name: string; description: string }> = {
|
|
19
|
+
classic: { name: 'Classic', description: 'Sharp corners, offset shadows, blue accent — the original CommonPub look' },
|
|
20
|
+
agora: { name: 'Agora', description: 'Warm parchment tones, green accent, serif display font — institutional warmth' },
|
|
21
|
+
generics: { name: 'Generics', description: 'Minimal dark aesthetic with soft glow shadows' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const BUILT_IN_PREVIEWS: Record<string, { bg: string; surface: string; accent: string; text: string; border: string }> = {
|
|
25
|
+
base: { bg: '#fafaf9', surface: '#ffffff', accent: '#5b9cf6', text: '#1a1a1a', border: '#1a1a1a' },
|
|
26
|
+
dark: { bg: '#111111', surface: '#1a1a1a', accent: '#5b9cf6', text: '#e5e5e3', border: '#444440' },
|
|
27
|
+
generics: { bg: '#0c0c0b', surface: '#141413', accent: '#5b9cf6', text: '#d8d5cf', border: '#272725' },
|
|
28
|
+
agora: { bg: '#f7f4ed', surface: '#faf8f3', accent: '#3d8b5e', text: '#1a1a1a', border: '#1a1a1a' },
|
|
29
|
+
'agora-dark': { bg: '#0d1a12', surface: '#141f17', accent: '#4aa06e', text: '#e8e8e2', border: '#3a4f40' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ---- Singleton state ----------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const data = ref<ThemesPayload | null>(null);
|
|
35
|
+
const loading = ref(false);
|
|
36
|
+
const error = ref<string | null>(null);
|
|
37
|
+
|
|
38
|
+
async function refresh(): Promise<void> {
|
|
39
|
+
loading.value = true;
|
|
40
|
+
error.value = null;
|
|
41
|
+
try {
|
|
42
|
+
data.value = await $fetch<ThemesPayload>('/api/admin/themes');
|
|
43
|
+
} catch (err) {
|
|
44
|
+
error.value = err instanceof Error ? err.message : 'Failed to load themes';
|
|
45
|
+
} finally {
|
|
46
|
+
loading.value = false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- Family view-model builder ------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Merge built-in + registered + custom themes into one family-grouped
|
|
54
|
+
* list. The same family slug across sources collapses into a single
|
|
55
|
+
* entry; later sources overwrite earlier display metadata (custom >
|
|
56
|
+
* registered > built-in).
|
|
57
|
+
*/
|
|
58
|
+
function buildFamilies(payload: ThemesPayload): ThemeFamilyView[] {
|
|
59
|
+
const map = new Map<string, ThemeFamilyView>();
|
|
60
|
+
|
|
61
|
+
// Built-in first
|
|
62
|
+
for (const t of payload.builtIn) {
|
|
63
|
+
const meta = BUILT_IN_FAMILY_META[t.family] ?? { name: t.family, description: '' };
|
|
64
|
+
const fam = ensureFamily(map, t.family, {
|
|
65
|
+
name: meta.name,
|
|
66
|
+
description: meta.description,
|
|
67
|
+
source: 'builtin',
|
|
68
|
+
});
|
|
69
|
+
const preview = BUILT_IN_PREVIEWS[t.id] ?? (t.isDark ? BUILT_IN_PREVIEWS.dark! : BUILT_IN_PREVIEWS.base!);
|
|
70
|
+
placeVariant(fam, t.id, t.name, t.isDark, preview);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Registered themes — promote source if family was previously built-in
|
|
74
|
+
for (const t of payload.registered) {
|
|
75
|
+
const fallback = t.isDark ? BUILT_IN_PREVIEWS.dark! : BUILT_IN_PREVIEWS.base!;
|
|
76
|
+
const preview = {
|
|
77
|
+
bg: t.preview?.bg ?? fallback.bg,
|
|
78
|
+
surface: t.preview?.surface ?? fallback.surface,
|
|
79
|
+
accent: t.preview?.accent ?? fallback.accent,
|
|
80
|
+
text: t.preview?.text ?? fallback.text,
|
|
81
|
+
border: t.preview?.border ?? fallback.border,
|
|
82
|
+
};
|
|
83
|
+
const fam = ensureFamily(map, t.family, {
|
|
84
|
+
name: t.name,
|
|
85
|
+
description: t.description ?? `Code-registered theme from this app's commonpub.config.ts`,
|
|
86
|
+
source: 'registered',
|
|
87
|
+
});
|
|
88
|
+
if (fam.source === 'builtin') fam.source = 'registered';
|
|
89
|
+
placeVariant(fam, t.id, t.name, t.isDark, preview);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Custom (DB-stored) — these win the meta tug-of-war
|
|
93
|
+
for (const t of payload.custom) {
|
|
94
|
+
const dataAttr = `cpub-custom-${t.id}`;
|
|
95
|
+
const preview = previewFromTokens(t.tokens, t.isDark);
|
|
96
|
+
const fam = ensureFamily(map, t.family, {
|
|
97
|
+
name: t.name,
|
|
98
|
+
description: t.description || 'Custom theme',
|
|
99
|
+
source: 'custom',
|
|
100
|
+
});
|
|
101
|
+
fam.source = 'custom';
|
|
102
|
+
fam.name = t.name;
|
|
103
|
+
if (t.description) fam.description = t.description;
|
|
104
|
+
placeVariant(fam, dataAttr, t.name, t.isDark, preview);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return [...map.values()];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ensureFamily(
|
|
111
|
+
map: Map<string, ThemeFamilyView>,
|
|
112
|
+
id: string,
|
|
113
|
+
init: { name: string; description: string; source: ThemeFamilyView['source'] },
|
|
114
|
+
): ThemeFamilyView {
|
|
115
|
+
let fam = map.get(id);
|
|
116
|
+
if (!fam) {
|
|
117
|
+
fam = {
|
|
118
|
+
id,
|
|
119
|
+
name: init.name,
|
|
120
|
+
description: init.description,
|
|
121
|
+
source: init.source,
|
|
122
|
+
light: null,
|
|
123
|
+
dark: null,
|
|
124
|
+
preview: { light: BUILT_IN_PREVIEWS.base!, dark: BUILT_IN_PREVIEWS.dark! },
|
|
125
|
+
};
|
|
126
|
+
map.set(id, fam);
|
|
127
|
+
}
|
|
128
|
+
return fam;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function placeVariant(
|
|
132
|
+
fam: ThemeFamilyView,
|
|
133
|
+
themeId: string,
|
|
134
|
+
themeName: string,
|
|
135
|
+
isDark: boolean,
|
|
136
|
+
preview: { bg: string; surface: string; accent: string; text: string; border: string },
|
|
137
|
+
): void {
|
|
138
|
+
if (isDark) {
|
|
139
|
+
fam.dark = { id: themeId, name: themeName };
|
|
140
|
+
fam.preview.dark = preview;
|
|
141
|
+
} else {
|
|
142
|
+
fam.light = { id: themeId, name: themeName };
|
|
143
|
+
fam.preview.light = preview;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- Composable surface -------------------------------------------------
|
|
148
|
+
|
|
149
|
+
export function useThemeAdmin(): {
|
|
150
|
+
data: typeof data;
|
|
151
|
+
loading: typeof loading;
|
|
152
|
+
error: typeof error;
|
|
153
|
+
families: import('vue').ComputedRef<ThemeFamilyView[]>;
|
|
154
|
+
refresh: typeof refresh;
|
|
155
|
+
/** Find a custom theme by id in the current payload. Returns null if absent. */
|
|
156
|
+
findCustom: (id: string) => CustomThemeRecord | null;
|
|
157
|
+
} {
|
|
158
|
+
const families = computed<ThemeFamilyView[]>(() => (data.value ? buildFamilies(data.value) : []));
|
|
159
|
+
return {
|
|
160
|
+
data,
|
|
161
|
+
loading,
|
|
162
|
+
error,
|
|
163
|
+
families,
|
|
164
|
+
refresh,
|
|
165
|
+
findCustom: (id: string) => data.value?.custom.find((t) => t.id === id) ?? null,
|
|
166
|
+
};
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -50,16 +50,16 @@
|
|
|
50
50
|
"vue": "^3.4.0",
|
|
51
51
|
"vue-router": "^4.3.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
-
"@commonpub/config": "0.13.0",
|
|
54
53
|
"@commonpub/auth": "0.6.0",
|
|
55
|
-
"@commonpub/explainer": "0.7.15",
|
|
56
|
-
"@commonpub/editor": "0.7.11",
|
|
57
54
|
"@commonpub/docs": "0.6.3",
|
|
55
|
+
"@commonpub/config": "0.14.0",
|
|
56
|
+
"@commonpub/editor": "0.7.11",
|
|
57
|
+
"@commonpub/explainer": "0.7.15",
|
|
58
58
|
"@commonpub/learning": "0.5.2",
|
|
59
|
-
"@commonpub/
|
|
60
|
-
"@commonpub/server": "2.55.0",
|
|
59
|
+
"@commonpub/schema": "0.17.0",
|
|
61
60
|
"@commonpub/protocol": "0.12.0",
|
|
62
|
-
"@commonpub/
|
|
61
|
+
"@commonpub/ui": "0.9.0",
|
|
62
|
+
"@commonpub/server": "2.56.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|