@commonpub/layer 0.22.1 → 0.23.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/LayoutSlot.vue +266 -0
- 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 -6
- package/pages/admin/theme/index.vue +22 -1
- package/pages/index.vue +23 -2
- package/sections/builtin/content-feed.ts +52 -0
- package/sections/builtin/divider.ts +37 -0
- package/sections/builtin/heading.ts +36 -0
- package/sections/builtin/hero.ts +67 -0
- package/sections/builtin/image.ts +67 -0
- package/sections/builtin/paragraph.ts +34 -0
- package/sections/registry.ts +61 -0
- 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.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"middleware",
|
|
14
14
|
"pages",
|
|
15
15
|
"plugins",
|
|
16
|
+
"sections",
|
|
16
17
|
"server",
|
|
17
18
|
"theme",
|
|
18
19
|
"types",
|
|
@@ -51,15 +52,15 @@
|
|
|
51
52
|
"vue-router": "^4.3.0",
|
|
52
53
|
"zod": "^4.3.6",
|
|
53
54
|
"@commonpub/auth": "0.6.0",
|
|
54
|
-
"@commonpub/
|
|
55
|
-
"@commonpub/config": "0.14.0",
|
|
55
|
+
"@commonpub/config": "0.15.0",
|
|
56
56
|
"@commonpub/editor": "0.7.11",
|
|
57
57
|
"@commonpub/explainer": "0.7.15",
|
|
58
|
-
"@commonpub/learning": "0.5.2",
|
|
59
|
-
"@commonpub/schema": "0.17.0",
|
|
60
58
|
"@commonpub/protocol": "0.12.0",
|
|
59
|
+
"@commonpub/server": "2.57.0",
|
|
60
|
+
"@commonpub/schema": "0.17.0",
|
|
61
|
+
"@commonpub/learning": "0.5.2",
|
|
61
62
|
"@commonpub/ui": "0.9.0",
|
|
62
|
-
"@commonpub/
|
|
63
|
+
"@commonpub/docs": "0.6.3"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
65
66
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -259,9 +259,30 @@ function recheckDiscovery(): void {
|
|
|
259
259
|
* If admins want to re-capture from a fresh :root state, they can revert
|
|
260
260
|
* to the base theme, clear overrides, then the banner will reappear.
|
|
261
261
|
*/
|
|
262
|
+
/**
|
|
263
|
+
* Show the "your site has a custom theme" banner ONLY when the detected
|
|
264
|
+
* overrides on :root are likely from a CSS file the thin layer app
|
|
265
|
+
* loaded (the deveco.io case) — NOT from the built-in theme itself or
|
|
266
|
+
* from a custom theme the admin already saved.
|
|
267
|
+
*
|
|
268
|
+
* Refined gate (5 conditions, all must pass):
|
|
269
|
+
* - count > 0 (something to capture)
|
|
270
|
+
* - active theme is NOT a custom theme (already captured)
|
|
271
|
+
* - active theme IS 'base' (any non-base built-in's tokens would
|
|
272
|
+
* dominate the diff, making "capture" produce a clone of that
|
|
273
|
+
* theme — pointless. e.g. commonpub.io's active='agora' triggers
|
|
274
|
+
* count > 0 from agora.css; banner would confuse the admin)
|
|
275
|
+
* - no instance-wide token overrides set (those explain the diff)
|
|
276
|
+
*
|
|
277
|
+
* Caveat: a thin app that registers a `themes:` entry AND sets
|
|
278
|
+
* instanceDefault to the registered slug (e.g. 'deveco') would have
|
|
279
|
+
* the banner HIDDEN even though their CSS file overrides ARE the use
|
|
280
|
+
* case the banner targets. For that case the admin can use the Fork
|
|
281
|
+
* button on the registered theme's card instead. Document.
|
|
282
|
+
*/
|
|
262
283
|
const showDiscoveryBanner = computed<boolean>(() => {
|
|
263
284
|
if (discovery.value.count === 0) return false;
|
|
264
|
-
if (instanceDefault.value
|
|
285
|
+
if (instanceDefault.value !== 'base') return false; // hides built-in non-base + registered + custom
|
|
265
286
|
if (Object.keys(initialOverrides.value).length > 0) return false;
|
|
266
287
|
return true;
|
|
267
288
|
});
|
package/pages/index.vue
CHANGED
|
@@ -14,7 +14,7 @@ const sortedSections = computed(() =>
|
|
|
14
14
|
);
|
|
15
15
|
|
|
16
16
|
const { user: authUser } = useAuth();
|
|
17
|
-
const { hubs: hubsEnabled, contests: contestsEnabled, learning: learningEnabled, video: videoEnabled, docs: docsEnabled, editorial: editorialEnabled } = useFeatures();
|
|
17
|
+
const { hubs: hubsEnabled, contests: contestsEnabled, learning: learningEnabled, video: videoEnabled, docs: docsEnabled, editorial: editorialEnabled, layoutEngine: layoutEngineEnabled } = useFeatures();
|
|
18
18
|
const { enabledTypeMeta } = useContentTypes();
|
|
19
19
|
|
|
20
20
|
const activeTab = ref(authUser.value ? 'foryou' : 'latest');
|
|
@@ -143,8 +143,29 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
143
143
|
|
|
144
144
|
<template>
|
|
145
145
|
<div>
|
|
146
|
+
<!-- ═══ LAYOUT ENGINE (Phase 1c — feature-flagged) ═══
|
|
147
|
+
When `features.layoutEngine` is ON, render the homepage via
|
|
148
|
+
<LayoutSlot> zones backed by the layouts table. Operators flip
|
|
149
|
+
this on AFTER running POST /api/admin/layouts/seed-homepage so
|
|
150
|
+
a default layout exists at scope ('route', '/'). If the flag is
|
|
151
|
+
on but no layout exists, LayoutSlot renders nothing and the
|
|
152
|
+
user sees an empty page — documented at
|
|
153
|
+
docs/reference/guides/layout-engine.md. Falls through to the
|
|
154
|
+
configurable section renderer when the flag is OFF (default). -->
|
|
155
|
+
<template v-if="layoutEngineEnabled">
|
|
156
|
+
<LayoutSlot route="/" zone="full-width" />
|
|
157
|
+
<div class="cpub-main-layout">
|
|
158
|
+
<main class="cpub-feed-col">
|
|
159
|
+
<LayoutSlot route="/" zone="main" />
|
|
160
|
+
</main>
|
|
161
|
+
<aside class="cpub-sidebar">
|
|
162
|
+
<LayoutSlot route="/" zone="sidebar" />
|
|
163
|
+
</aside>
|
|
164
|
+
</div>
|
|
165
|
+
</template>
|
|
166
|
+
|
|
146
167
|
<!-- ═══ CONFIGURABLE HOMEPAGE (section renderer) ═══ -->
|
|
147
|
-
<template v-if="hasCustomSections">
|
|
168
|
+
<template v-else-if="hasCustomSections">
|
|
148
169
|
<!-- Full-width sections (hero) -->
|
|
149
170
|
<HomepageSectionRenderer :sections="sortedSections" zone="full-width" />
|
|
150
171
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: content-feed.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1c starter and the first DATA section. Fetches `/api/content`
|
|
5
|
+
* with config-driven filters and renders a responsive grid of
|
|
6
|
+
* `<ContentCard>`s.
|
|
7
|
+
*
|
|
8
|
+
* Config fields split into server-filter (forwarded to `/api/content`)
|
|
9
|
+
* and render-only (`heading`, `columns`). Keeping the contract explicit
|
|
10
|
+
* here matches the auto-form mapping in Phase 3e and stops accidental
|
|
11
|
+
* pass-through of admin-only filter values.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
15
|
+
import SectionContentFeed from '../../components/sections/SectionContentFeed.vue';
|
|
16
|
+
|
|
17
|
+
const configSchema = z.object({
|
|
18
|
+
heading: z.string().max(120).default(''),
|
|
19
|
+
contentType: z.string().max(64).default(''),
|
|
20
|
+
sort: z.enum(['recent', 'popular', 'featured', 'editorial']).default('recent'),
|
|
21
|
+
limit: z.number().int().min(1).max(24).default(6),
|
|
22
|
+
columns: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(3),
|
|
23
|
+
tag: z.string().max(64).default(''),
|
|
24
|
+
featured: z.boolean().default(false),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const contentFeedSection: SectionDefinition<z.infer<typeof configSchema>> = {
|
|
28
|
+
type: 'content-feed',
|
|
29
|
+
name: 'Content feed',
|
|
30
|
+
description: 'Grid of content cards filtered by type / tag / sort',
|
|
31
|
+
icon: 'fa-stream',
|
|
32
|
+
category: 'data',
|
|
33
|
+
status: 'stable',
|
|
34
|
+
configSchema,
|
|
35
|
+
defaultConfig: {
|
|
36
|
+
heading: '',
|
|
37
|
+
contentType: '',
|
|
38
|
+
sort: 'recent',
|
|
39
|
+
limit: 6,
|
|
40
|
+
columns: 3,
|
|
41
|
+
tag: '',
|
|
42
|
+
featured: false,
|
|
43
|
+
},
|
|
44
|
+
schemaVersion: 1,
|
|
45
|
+
component: SectionContentFeed,
|
|
46
|
+
// Multi-column grid collapses to less than half-width — readability + the
|
|
47
|
+
// card aspect ratio break down below 6
|
|
48
|
+
minColSpan: 6,
|
|
49
|
+
maxColSpan: 12,
|
|
50
|
+
defaultColSpan: 12,
|
|
51
|
+
resizable: true,
|
|
52
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: divider.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 proof-of-life — the simplest possible registered section.
|
|
5
|
+
* Validates the registry → LayoutSlot → renderer chain without any
|
|
6
|
+
* Zod complexity, content fetches, or admin-only config.
|
|
7
|
+
*
|
|
8
|
+
* Phase 1c adds: hero, heading, paragraph, image, content-feed —
|
|
9
|
+
* each in its own `builtin/{type}.ts` file, registered in
|
|
10
|
+
* `../registry.ts` alongside this one.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
14
|
+
import SectionDivider from '../../components/sections/SectionDivider.vue';
|
|
15
|
+
|
|
16
|
+
const configSchema = z.object({
|
|
17
|
+
variant: z.enum(['solid', 'dashed', 'dotted', 'accent']).default('solid'),
|
|
18
|
+
spacingY: z.enum(['sm', 'md', 'lg', 'xl']).default('md'),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const dividerSection: SectionDefinition<z.infer<typeof configSchema>> = {
|
|
22
|
+
type: 'divider',
|
|
23
|
+
name: 'Divider',
|
|
24
|
+
description: 'Horizontal rule with style + spacing options',
|
|
25
|
+
icon: 'fa-minus',
|
|
26
|
+
category: 'layout',
|
|
27
|
+
status: 'stable',
|
|
28
|
+
configSchema,
|
|
29
|
+
defaultConfig: { variant: 'solid', spacingY: 'md' },
|
|
30
|
+
schemaVersion: 1,
|
|
31
|
+
component: SectionDivider,
|
|
32
|
+
// Dividers are always full-width; resize is meaningless for a 1px line
|
|
33
|
+
minColSpan: 12,
|
|
34
|
+
maxColSpan: 12,
|
|
35
|
+
defaultColSpan: 12,
|
|
36
|
+
resizable: false,
|
|
37
|
+
};
|