@commonpub/layer 0.22.0 → 0.23.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.
- package/components/LayoutSlot.vue +266 -0
- package/components/admin/theme/AdminThemePreviewPane.vue +32 -1
- package/components/sections/SectionContentFeed.vue +160 -0
- package/components/sections/SectionDivider.vue +55 -0
- package/components/sections/SectionHeading.vue +78 -0
- package/components/sections/SectionHero.vue +164 -0
- package/components/sections/SectionImage.vue +104 -0
- package/components/sections/SectionParagraph.vue +55 -0
- package/composables/useFeatures.ts +10 -0
- package/composables/useLayout.ts +132 -0
- package/package.json +7 -7
- package/pages/admin/theme/edit/[id].vue +68 -20
- package/pages/admin/theme/index.vue +48 -2
- package/pages/index.vue +23 -2
- package/server/api/admin/layouts/[id]/publish.post.ts +33 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +43 -0
- package/server/api/admin/layouts/[id]/versions/index.get.ts +29 -0
- package/server/api/admin/layouts/[id].delete.ts +34 -0
- package/server/api/admin/layouts/[id].get.ts +27 -0
- package/server/api/admin/layouts/[id].put.ts +64 -0
- package/server/api/admin/layouts/index.get.ts +25 -0
- package/server/api/admin/layouts/index.post.ts +48 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +35 -0
- package/server/api/admin/themes/discover.get.ts +14 -5
- package/server/api/layouts/by-route.get.ts +87 -0
- package/server/utils/layoutCache.ts +53 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Built-in section: hero — banner with title, optional eyebrow + subtitle,
|
|
4
|
+
* up to two CTAs.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1c starter. Variants: `default` (left-aligned, grid backdrop),
|
|
7
|
+
* `compact` (smaller padding, no backdrop), `centered` (centered text
|
|
8
|
+
* + CTAs, grid backdrop).
|
|
9
|
+
*
|
|
10
|
+
* Intentionally does NOT replicate the existing `HomepageHeroSection`'s
|
|
11
|
+
* contest-dispatch logic — that becomes its own data-aware section in
|
|
12
|
+
* Phase 6b (`contest-feature` or similar). This hero is pure
|
|
13
|
+
* config-driven so the editor preview is deterministic.
|
|
14
|
+
*
|
|
15
|
+
* `var(--*)` only.
|
|
16
|
+
*/
|
|
17
|
+
import type { SectionRenderProps } from '@commonpub/ui';
|
|
18
|
+
|
|
19
|
+
interface HeroCta {
|
|
20
|
+
label: string;
|
|
21
|
+
href: string;
|
|
22
|
+
variant: 'primary' | 'secondary';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface HeroConfig extends Record<string, unknown> {
|
|
26
|
+
variant: 'default' | 'compact' | 'centered';
|
|
27
|
+
eyebrow: string;
|
|
28
|
+
title: string;
|
|
29
|
+
subtitle: string;
|
|
30
|
+
ctas: HeroCta[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
defineProps<SectionRenderProps<HeroConfig>>();
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<section
|
|
38
|
+
class="cpub-section-hero"
|
|
39
|
+
:data-variant="config.variant"
|
|
40
|
+
:aria-labelledby="`section-hero-${meta.sectionId}`"
|
|
41
|
+
>
|
|
42
|
+
<div v-if="config.variant !== 'compact'" class="cpub-section-hero-grid-bg" aria-hidden="true" />
|
|
43
|
+
<div class="cpub-section-hero-inner">
|
|
44
|
+
<div class="cpub-section-hero-content">
|
|
45
|
+
<p v-if="config.eyebrow" class="cpub-section-hero-eyebrow">{{ config.eyebrow }}</p>
|
|
46
|
+
<h1
|
|
47
|
+
:id="`section-hero-${meta.sectionId}`"
|
|
48
|
+
class="cpub-section-hero-title"
|
|
49
|
+
>
|
|
50
|
+
{{ config.title }}
|
|
51
|
+
</h1>
|
|
52
|
+
<p v-if="config.subtitle" class="cpub-section-hero-subtitle">{{ config.subtitle }}</p>
|
|
53
|
+
<div v-if="config.ctas.length > 0" class="cpub-section-hero-actions">
|
|
54
|
+
<NuxtLink
|
|
55
|
+
v-for="(cta, i) in config.ctas"
|
|
56
|
+
:key="i"
|
|
57
|
+
:to="cta.href"
|
|
58
|
+
class="cpub-btn"
|
|
59
|
+
:class="cta.variant === 'primary' ? 'cpub-btn-primary' : ''"
|
|
60
|
+
>
|
|
61
|
+
{{ cta.label }}
|
|
62
|
+
</NuxtLink>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<style scoped>
|
|
70
|
+
.cpub-section-hero {
|
|
71
|
+
position: relative;
|
|
72
|
+
background: var(--surface);
|
|
73
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
74
|
+
overflow: hidden;
|
|
75
|
+
min-height: 180px;
|
|
76
|
+
display: flex;
|
|
77
|
+
align-items: stretch;
|
|
78
|
+
}
|
|
79
|
+
.cpub-section-hero[data-variant='compact'] {
|
|
80
|
+
min-height: 120px;
|
|
81
|
+
border-bottom: none;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.cpub-section-hero-grid-bg {
|
|
85
|
+
position: absolute;
|
|
86
|
+
inset: 0;
|
|
87
|
+
background-image:
|
|
88
|
+
linear-gradient(var(--border2) 1px, transparent 1px),
|
|
89
|
+
linear-gradient(90deg, var(--border2) 1px, transparent 1px);
|
|
90
|
+
background-size: 32px 32px;
|
|
91
|
+
opacity: 0.25;
|
|
92
|
+
pointer-events: none;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.cpub-section-hero-inner {
|
|
96
|
+
position: relative;
|
|
97
|
+
z-index: 1;
|
|
98
|
+
max-width: 1280px;
|
|
99
|
+
margin: 0 auto;
|
|
100
|
+
padding: var(--space-6) var(--space-6);
|
|
101
|
+
width: 100%;
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
}
|
|
105
|
+
.cpub-section-hero[data-variant='compact'] .cpub-section-hero-inner {
|
|
106
|
+
padding: var(--space-4) var(--space-6);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.cpub-section-hero-content { flex: 1; min-width: 0; }
|
|
110
|
+
|
|
111
|
+
.cpub-section-hero[data-variant='centered'] .cpub-section-hero-inner {
|
|
112
|
+
justify-content: center;
|
|
113
|
+
}
|
|
114
|
+
.cpub-section-hero[data-variant='centered'] .cpub-section-hero-content {
|
|
115
|
+
text-align: center;
|
|
116
|
+
max-width: 720px;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.cpub-section-hero-eyebrow {
|
|
120
|
+
font-family: var(--font-mono);
|
|
121
|
+
font-size: var(--text-xs);
|
|
122
|
+
text-transform: uppercase;
|
|
123
|
+
letter-spacing: 0.1em;
|
|
124
|
+
color: var(--text-faint);
|
|
125
|
+
margin: 0 0 var(--space-2);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.cpub-section-hero-title {
|
|
129
|
+
font-size: var(--text-3xl);
|
|
130
|
+
font-weight: 700;
|
|
131
|
+
line-height: 1.2;
|
|
132
|
+
margin: 0 0 var(--space-2);
|
|
133
|
+
color: var(--text);
|
|
134
|
+
}
|
|
135
|
+
.cpub-section-hero[data-variant='compact'] .cpub-section-hero-title {
|
|
136
|
+
font-size: var(--text-2xl);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.cpub-section-hero-subtitle {
|
|
140
|
+
font-size: var(--text-md);
|
|
141
|
+
color: var(--text-dim);
|
|
142
|
+
line-height: 1.6;
|
|
143
|
+
margin: 0 0 var(--space-4);
|
|
144
|
+
max-width: 560px;
|
|
145
|
+
}
|
|
146
|
+
.cpub-section-hero[data-variant='centered'] .cpub-section-hero-subtitle {
|
|
147
|
+
margin-inline: auto;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.cpub-section-hero-actions {
|
|
151
|
+
display: flex;
|
|
152
|
+
flex-wrap: wrap;
|
|
153
|
+
gap: var(--space-2);
|
|
154
|
+
}
|
|
155
|
+
.cpub-section-hero[data-variant='centered'] .cpub-section-hero-actions {
|
|
156
|
+
justify-content: center;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@media (max-width: 640px) {
|
|
160
|
+
.cpub-section-hero-inner { padding: var(--space-4); }
|
|
161
|
+
.cpub-section-hero-title { font-size: var(--text-2xl); }
|
|
162
|
+
.cpub-section-hero-actions { width: 100%; }
|
|
163
|
+
}
|
|
164
|
+
</style>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Built-in section: image — single image with optional caption + link.
|
|
4
|
+
*
|
|
5
|
+
* Phase 1c starter. Author-provided `src` is rendered as-is; aspect-ratio
|
|
6
|
+
* keeps cards uniform. Lazy-loaded by default for below-fold sections.
|
|
7
|
+
*
|
|
8
|
+
* Phase 3e inspector will swap the `src` text input for an ImageUpload
|
|
9
|
+
* picker via `.describe('image')` Zod metadata.
|
|
10
|
+
*
|
|
11
|
+
* `var(--*)` only.
|
|
12
|
+
*/
|
|
13
|
+
import type { SectionRenderProps } from '@commonpub/ui';
|
|
14
|
+
|
|
15
|
+
interface ImageConfig extends Record<string, unknown> {
|
|
16
|
+
src: string;
|
|
17
|
+
alt: string;
|
|
18
|
+
caption: string;
|
|
19
|
+
href: string;
|
|
20
|
+
fit: 'contain' | 'cover';
|
|
21
|
+
aspectRatio: '16/9' | '4/3' | '1/1' | 'auto';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
defineProps<SectionRenderProps<ImageConfig>>();
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<figure
|
|
29
|
+
class="cpub-section-image"
|
|
30
|
+
:data-aspect="config.aspectRatio"
|
|
31
|
+
:data-fit="config.fit"
|
|
32
|
+
>
|
|
33
|
+
<!-- The `href` field is optional — link only when truthy so screen
|
|
34
|
+
readers don't announce a non-interactive image as a link. -->
|
|
35
|
+
<component
|
|
36
|
+
:is="config.href ? 'a' : 'div'"
|
|
37
|
+
:href="config.href || undefined"
|
|
38
|
+
class="cpub-section-image-frame"
|
|
39
|
+
>
|
|
40
|
+
<img
|
|
41
|
+
v-if="config.src"
|
|
42
|
+
:src="config.src"
|
|
43
|
+
:alt="config.alt"
|
|
44
|
+
loading="lazy"
|
|
45
|
+
decoding="async"
|
|
46
|
+
class="cpub-section-image-img"
|
|
47
|
+
/>
|
|
48
|
+
<div v-else class="cpub-section-image-placeholder" aria-hidden="true">
|
|
49
|
+
<i class="fa-regular fa-image" />
|
|
50
|
+
</div>
|
|
51
|
+
</component>
|
|
52
|
+
<figcaption v-if="config.caption" class="cpub-section-image-caption">
|
|
53
|
+
{{ config.caption }}
|
|
54
|
+
</figcaption>
|
|
55
|
+
</figure>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<style scoped>
|
|
59
|
+
.cpub-section-image {
|
|
60
|
+
margin: 0;
|
|
61
|
+
display: flex;
|
|
62
|
+
flex-direction: column;
|
|
63
|
+
gap: var(--space-2);
|
|
64
|
+
}
|
|
65
|
+
.cpub-section-image-frame {
|
|
66
|
+
display: block;
|
|
67
|
+
width: 100%;
|
|
68
|
+
background: var(--surface2);
|
|
69
|
+
border: var(--border-width-default) solid var(--border);
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
text-decoration: none;
|
|
72
|
+
color: inherit;
|
|
73
|
+
}
|
|
74
|
+
.cpub-section-image[data-aspect='16/9'] .cpub-section-image-frame { aspect-ratio: 16 / 9; }
|
|
75
|
+
.cpub-section-image[data-aspect='4/3'] .cpub-section-image-frame { aspect-ratio: 4 / 3; }
|
|
76
|
+
.cpub-section-image[data-aspect='1/1'] .cpub-section-image-frame { aspect-ratio: 1 / 1; }
|
|
77
|
+
|
|
78
|
+
.cpub-section-image-img {
|
|
79
|
+
width: 100%;
|
|
80
|
+
height: 100%;
|
|
81
|
+
display: block;
|
|
82
|
+
}
|
|
83
|
+
.cpub-section-image[data-fit='cover'] .cpub-section-image-img { object-fit: cover; }
|
|
84
|
+
.cpub-section-image[data-fit='contain'] .cpub-section-image-img { object-fit: contain; }
|
|
85
|
+
/* Aspect=auto + no explicit ratio: let the image dictate height */
|
|
86
|
+
.cpub-section-image[data-aspect='auto'] .cpub-section-image-img {
|
|
87
|
+
height: auto;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.cpub-section-image-placeholder {
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: center;
|
|
94
|
+
height: 100%;
|
|
95
|
+
min-height: 120px;
|
|
96
|
+
color: var(--text-faint);
|
|
97
|
+
font-size: var(--text-xl);
|
|
98
|
+
}
|
|
99
|
+
.cpub-section-image-caption {
|
|
100
|
+
font-size: var(--text-sm);
|
|
101
|
+
color: var(--text-dim);
|
|
102
|
+
line-height: 1.5;
|
|
103
|
+
}
|
|
104
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Built-in section: paragraph — plain prose body.
|
|
4
|
+
*
|
|
5
|
+
* Phase 1c starter. Stores `text: string` and splits on blank lines into
|
|
6
|
+
* paragraphs at render time. The auto-form inspector (Phase 3e) will
|
|
7
|
+
* upgrade this to a TipTap subset via the `.describe('rich')` Zod
|
|
8
|
+
* metadata — at which point `schemaVersion` bumps to 2 with a migration
|
|
9
|
+
* that converts plain text → block tuples.
|
|
10
|
+
*
|
|
11
|
+
* `var(--*)` only.
|
|
12
|
+
*/
|
|
13
|
+
import { computed } from 'vue';
|
|
14
|
+
import type { SectionRenderProps } from '@commonpub/ui';
|
|
15
|
+
|
|
16
|
+
interface ParagraphConfig extends Record<string, unknown> {
|
|
17
|
+
text: string;
|
|
18
|
+
align: 'left' | 'center';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const props = defineProps<SectionRenderProps<ParagraphConfig>>();
|
|
22
|
+
|
|
23
|
+
// Blank-line split — preserves authored paragraph breaks without needing
|
|
24
|
+
// a rich-text editor. Empty paragraphs are dropped (defensive).
|
|
25
|
+
const paragraphs = computed<string[]>(() =>
|
|
26
|
+
(props.config.text ?? '')
|
|
27
|
+
.split(/\n{2,}/)
|
|
28
|
+
.map((p) => p.trim())
|
|
29
|
+
.filter((p) => p.length > 0),
|
|
30
|
+
);
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div class="cpub-section-paragraph" :data-align="config.align">
|
|
35
|
+
<p v-for="(p, i) in paragraphs" :key="i">{{ p }}</p>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<style scoped>
|
|
40
|
+
.cpub-section-paragraph {
|
|
41
|
+
margin-block: var(--space-3);
|
|
42
|
+
color: var(--text);
|
|
43
|
+
font-size: var(--text-md);
|
|
44
|
+
line-height: 1.7;
|
|
45
|
+
}
|
|
46
|
+
.cpub-section-paragraph[data-align='center'] {
|
|
47
|
+
text-align: center;
|
|
48
|
+
}
|
|
49
|
+
.cpub-section-paragraph p {
|
|
50
|
+
margin: 0 0 var(--space-3);
|
|
51
|
+
}
|
|
52
|
+
.cpub-section-paragraph p:last-child {
|
|
53
|
+
margin-bottom: 0;
|
|
54
|
+
}
|
|
55
|
+
</style>
|
|
@@ -26,6 +26,14 @@ export interface FeatureFlags {
|
|
|
26
26
|
emailNotifications: boolean;
|
|
27
27
|
publicApi: boolean;
|
|
28
28
|
contentImport: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* DB-backed page layout engine. Default OFF — when ON, the homepage
|
|
31
|
+
* (and future layout-bearing pages) render via `<LayoutSlot>` zones
|
|
32
|
+
* resolved from the `layouts` table. Operator MUST run
|
|
33
|
+
* POST /api/admin/layouts/seed-homepage before flipping this on so
|
|
34
|
+
* a default layout exists at scope ('route', '/'). Added session 158.
|
|
35
|
+
*/
|
|
36
|
+
layoutEngine: boolean;
|
|
29
37
|
/**
|
|
30
38
|
* Cross-instance delegated authorization. All sub-flags default false.
|
|
31
39
|
* Mirrors `@commonpub/config`'s `IdentityFeatures`. Phase 1b+ — see
|
|
@@ -46,6 +54,7 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
|
|
46
54
|
contests: false, events: false, learning: true, explainers: true,
|
|
47
55
|
editorial: true, federation: false, admin: false, emailNotifications: false,
|
|
48
56
|
publicApi: false, contentImport: true,
|
|
57
|
+
layoutEngine: false,
|
|
49
58
|
identity: {
|
|
50
59
|
linkRemoteAccounts: false,
|
|
51
60
|
signInWithRemote: false,
|
|
@@ -120,6 +129,7 @@ export function useFeatures() {
|
|
|
120
129
|
emailNotifications: computed(() => flags.value.emailNotifications),
|
|
121
130
|
publicApi: computed(() => flags.value.publicApi),
|
|
122
131
|
contentImport: computed(() => flags.value.contentImport),
|
|
132
|
+
layoutEngine: computed(() => flags.value.layoutEngine),
|
|
123
133
|
identity: computed(() => flags.value.identity),
|
|
124
134
|
};
|
|
125
135
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side composable for resolving a route's active layout.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `useFetch('/api/layouts/by-route?path=…')` with SSR-safe caching:
|
|
5
|
+
* - Server-side: fetched during SSR, payload is included in the
|
|
6
|
+
* hydration snapshot (Nuxt's `useFetch` default) — same data is
|
|
7
|
+
* re-used on client mount with zero extra requests
|
|
8
|
+
* - Client-side: subsequent navigations to the same path return the
|
|
9
|
+
* cached value (Nuxt's request cache, keyed by `key`)
|
|
10
|
+
*
|
|
11
|
+
* Returns `null` when:
|
|
12
|
+
* - The layout-engine feature is OFF (`/api/layouts/by-route` 404s,
|
|
13
|
+
* which we surface as null so consumers fall back to legacy renderers
|
|
14
|
+
* gracefully)
|
|
15
|
+
* - No layout exists for the route
|
|
16
|
+
*
|
|
17
|
+
* The `<LayoutSlot>` component is the only intended caller in v1; other
|
|
18
|
+
* consumers should use that instead.
|
|
19
|
+
*/
|
|
20
|
+
import type { Ref } from 'vue';
|
|
21
|
+
|
|
22
|
+
export interface LayoutSection {
|
|
23
|
+
id: string;
|
|
24
|
+
order: number;
|
|
25
|
+
type: string;
|
|
26
|
+
config: Record<string, unknown>;
|
|
27
|
+
colSpan: number;
|
|
28
|
+
responsive: { sm?: number; md?: number; lg?: number } | null;
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
visibility: { roles?: string[]; features?: string[]; hideAt?: ('sm' | 'md' | 'lg')[] } | null;
|
|
31
|
+
schemaVersion: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LayoutRow {
|
|
35
|
+
id: string;
|
|
36
|
+
order: number;
|
|
37
|
+
config: {
|
|
38
|
+
gap?: 'none' | 'sm' | 'md' | 'lg';
|
|
39
|
+
align?: 'start' | 'center' | 'stretch';
|
|
40
|
+
background?: string;
|
|
41
|
+
paddingY?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
|
42
|
+
} | null;
|
|
43
|
+
sections: LayoutSection[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LayoutZoneClient {
|
|
47
|
+
zone: string;
|
|
48
|
+
rows: LayoutRow[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LayoutPayload {
|
|
52
|
+
zones: LayoutZoneClient[];
|
|
53
|
+
pageMeta: {
|
|
54
|
+
title: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
ogImage?: string;
|
|
57
|
+
noindex?: boolean;
|
|
58
|
+
ogType?: 'website' | 'article' | 'profile';
|
|
59
|
+
access?: 'public' | 'members' | 'admin';
|
|
60
|
+
frame?: 'narrow' | 'wide' | 'two-column' | 'three-column' | 'sidebar-left' | 'sidebar-right';
|
|
61
|
+
} | null;
|
|
62
|
+
state: 'draft' | 'published';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface UseLayoutResult {
|
|
66
|
+
/** The layout payload, or null if none exists / feature off. */
|
|
67
|
+
layout: Ref<LayoutPayload | null>;
|
|
68
|
+
/** True while the initial fetch is in flight. */
|
|
69
|
+
pending: Ref<boolean>;
|
|
70
|
+
/** Truthy if the fetch errored (incl. 404 for feature off). */
|
|
71
|
+
error: Ref<unknown>;
|
|
72
|
+
/** Re-fetch the layout (after a save / publish). */
|
|
73
|
+
refresh: () => Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the layout for a given route path. SSR-safe; caches per path
|
|
78
|
+
* for the request lifetime + survives hydration.
|
|
79
|
+
*
|
|
80
|
+
* Returns a layout=null Ref when the feature is off so consumers can
|
|
81
|
+
* `v-if="layout"` and fall through to a legacy renderer.
|
|
82
|
+
*
|
|
83
|
+
* **Reactivity**: pass a string for the typical case (literal route on
|
|
84
|
+
* a page) — useFetch fires once at setup. For the rare case where the
|
|
85
|
+
* route changes without remount (e.g. a parent component swapping the
|
|
86
|
+
* prop dynamically), pass a Ref or getter — useFetch will refire on
|
|
87
|
+
* change. Without this, a path-prop change on `<LayoutSlot>` would
|
|
88
|
+
* silently leave the stale fetch result in place.
|
|
89
|
+
*/
|
|
90
|
+
export function useLayout(path: string | Ref<string> | (() => string)): UseLayoutResult {
|
|
91
|
+
const pathGetter = (): string => (
|
|
92
|
+
typeof path === 'string'
|
|
93
|
+
? path
|
|
94
|
+
: typeof path === 'function'
|
|
95
|
+
? path()
|
|
96
|
+
: path.value
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const { data, pending, error, refresh } = useFetch<LayoutPayload | null>(
|
|
100
|
+
'/api/layouts/by-route',
|
|
101
|
+
{
|
|
102
|
+
// Static key for the literal-string case (so Nuxt's request cache
|
|
103
|
+
// can dedupe across components on the same nav). For the reactive
|
|
104
|
+
// case, omit key so useFetch derives one from the query Ref.
|
|
105
|
+
key: typeof path === 'string' ? `layout:${path}` : undefined,
|
|
106
|
+
// Functional query — useFetch re-evaluates on watched deps change.
|
|
107
|
+
query: { path: pathGetter },
|
|
108
|
+
// Watch the path getter so reactive callers refetch on change.
|
|
109
|
+
// For string callers this is empty array → no extra reactivity.
|
|
110
|
+
watch: typeof path === 'string' ? [] : [pathGetter],
|
|
111
|
+
// 404 from the API (feature off OR route has no layout) is NOT an
|
|
112
|
+
// exceptional case — surface as null. Don't treat as error.
|
|
113
|
+
onResponseError({ response }) {
|
|
114
|
+
if (response.status === 404) {
|
|
115
|
+
// Don't throw; data stays null
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
// Falsy data on 404 maps to null
|
|
120
|
+
transform: (input: LayoutPayload | null | undefined) => input ?? null,
|
|
121
|
+
server: true,
|
|
122
|
+
lazy: false,
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
layout: data as Ref<LayoutPayload | null>,
|
|
128
|
+
pending,
|
|
129
|
+
error,
|
|
130
|
+
refresh: async () => { await refresh(); },
|
|
131
|
+
};
|
|
132
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
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.15.0",
|
|
54
|
+
"@commonpub/explainer": "0.7.15",
|
|
53
55
|
"@commonpub/editor": "0.7.11",
|
|
54
|
-
"@commonpub/docs": "0.6.3",
|
|
55
|
-
"@commonpub/learning": "0.5.2",
|
|
56
|
-
"@commonpub/config": "0.14.0",
|
|
57
|
-
"@commonpub/server": "2.56.0",
|
|
58
56
|
"@commonpub/auth": "0.6.0",
|
|
57
|
+
"@commonpub/learning": "0.5.2",
|
|
58
|
+
"@commonpub/ui": "0.9.0",
|
|
59
59
|
"@commonpub/schema": "0.17.0",
|
|
60
|
+
"@commonpub/docs": "0.6.3",
|
|
60
61
|
"@commonpub/protocol": "0.12.0",
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/explainer": "0.7.15"
|
|
62
|
+
"@commonpub/server": "2.57.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -149,7 +149,13 @@ const pairCandidates = computed(() =>
|
|
|
149
149
|
|
|
150
150
|
// --- Save / cancel / export -----------------------------------------
|
|
151
151
|
|
|
152
|
-
|
|
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> {
|
|
153
159
|
saving.value = true;
|
|
154
160
|
error.value = null;
|
|
155
161
|
try {
|
|
@@ -163,15 +169,14 @@ async function save(): Promise<void> {
|
|
|
163
169
|
parentTheme: draft.value.parentTheme,
|
|
164
170
|
tokens: draft.value.tokens,
|
|
165
171
|
};
|
|
172
|
+
|
|
173
|
+
let savedId: string;
|
|
166
174
|
if (isCreating) {
|
|
167
175
|
const created = await $fetch('/api/admin/themes', {
|
|
168
176
|
method: 'POST',
|
|
169
177
|
body: payload,
|
|
170
178
|
});
|
|
171
|
-
|
|
172
|
-
dirty.value = false;
|
|
173
|
-
await themesApi.refresh();
|
|
174
|
-
router.replace(`/admin/theme/edit/${(created as { id: string }).id}`);
|
|
179
|
+
savedId = (created as { id: string }).id;
|
|
175
180
|
} else {
|
|
176
181
|
// Cast: Nuxt's typed-route inference for dynamic URLs picks the
|
|
177
182
|
// narrowest method overload (GET) — same workaround used in
|
|
@@ -180,9 +185,25 @@ async function save(): Promise<void> {
|
|
|
180
185
|
`/api/admin/themes/${draft.value.id}`,
|
|
181
186
|
{ method: 'PUT', body: payload },
|
|
182
187
|
);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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}`);
|
|
186
207
|
}
|
|
187
208
|
} catch (err) {
|
|
188
209
|
const msg = err instanceof Error ? err.message : 'Save failed';
|
|
@@ -194,13 +215,7 @@ async function save(): Promise<void> {
|
|
|
194
215
|
}
|
|
195
216
|
|
|
196
217
|
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');
|
|
218
|
+
await save({ apply: true });
|
|
204
219
|
}
|
|
205
220
|
|
|
206
221
|
function exportTheme(): void {
|
|
@@ -248,9 +263,14 @@ onBeforeUnmount(() => {
|
|
|
248
263
|
<template>
|
|
249
264
|
<div class="theme-editor">
|
|
250
265
|
<header class="theme-editor-toolbar">
|
|
251
|
-
<button
|
|
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
|
+
>
|
|
252
271
|
<i class="fa-solid fa-arrow-left" aria-hidden="true" />
|
|
253
272
|
<span>Themes</span>
|
|
273
|
+
<span v-if="dirty" class="theme-editor-dirty-dot" aria-label="unsaved changes"></span>
|
|
254
274
|
</button>
|
|
255
275
|
|
|
256
276
|
<div class="theme-editor-meta">
|
|
@@ -329,11 +349,13 @@ onBeforeUnmount(() => {
|
|
|
329
349
|
<button class="cpub-btn cpub-btn-sm" @click="exportTheme" title="Download .cpub-theme.json">
|
|
330
350
|
<i class="fa-solid fa-file-export" aria-hidden="true" /> Export
|
|
331
351
|
</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" />
|
|
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' }}
|
|
334
355
|
</button>
|
|
335
356
|
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="saving" @click="applyAndSave">
|
|
336
|
-
<i class="fa-solid fa-rocket" aria-hidden="true" />
|
|
357
|
+
<i :class="['fa-solid', saving ? 'fa-circle-notch fa-spin' : 'fa-rocket']" aria-hidden="true" />
|
|
358
|
+
{{ saving ? 'Applying…' : 'Save & apply' }}
|
|
337
359
|
</button>
|
|
338
360
|
</div>
|
|
339
361
|
</header>
|
|
@@ -406,7 +428,33 @@ onBeforeUnmount(() => {
|
|
|
406
428
|
flex-wrap: wrap;
|
|
407
429
|
}
|
|
408
430
|
|
|
409
|
-
.theme-editor-back {
|
|
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
|
+
}
|
|
410
458
|
|
|
411
459
|
.theme-editor-meta {
|
|
412
460
|
display: flex;
|