@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,266 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* <LayoutSlot> — renders one zone of a route's active layout.
|
|
4
|
+
*
|
|
5
|
+
* Page (e.g. `pages/index.vue`) is the FRAME; this component is the
|
|
6
|
+
* fillings for each zone the page exposes. Pages declare zones like:
|
|
7
|
+
*
|
|
8
|
+
* ```vue
|
|
9
|
+
* <template>
|
|
10
|
+
* <LayoutSlot route="/" zone="full-width" />
|
|
11
|
+
* <div class="cpub-content-grid">
|
|
12
|
+
* <LayoutSlot route="/" zone="main" />
|
|
13
|
+
* <aside><LayoutSlot route="/" zone="sidebar" /></aside>
|
|
14
|
+
* </div>
|
|
15
|
+
* </template>
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* For each row in the zone:
|
|
19
|
+
* - Renders a 12-column CSS Grid container with the row's gap setting
|
|
20
|
+
* - For each enabled + visible section: renders the registered section
|
|
21
|
+
* component, scoped to its resolved colSpan
|
|
22
|
+
*
|
|
23
|
+
* Visibility filters (run client-side on every render so role/feature
|
|
24
|
+
* changes propagate without a re-fetch):
|
|
25
|
+
* - section.enabled === false → skipped
|
|
26
|
+
* - visibility.features intersected with active feature flags must
|
|
27
|
+
* have ALL flags ON
|
|
28
|
+
* - visibility.roles must include the current user's role (or
|
|
29
|
+
* 'anonymous' for unauthenticated)
|
|
30
|
+
*
|
|
31
|
+
* When the layout-engine feature is OFF (the default for v1), `useLayout`
|
|
32
|
+
* resolves to null and this component renders nothing — pages that
|
|
33
|
+
* adopt <LayoutSlot> need a v-if fallback to the legacy renderer
|
|
34
|
+
* during the migration window.
|
|
35
|
+
*
|
|
36
|
+
* Section rendering: the registry component map is populated by a Nuxt
|
|
37
|
+
* plugin at startup (Phase 1c). Until then, unknown types render
|
|
38
|
+
* an admin-only error placeholder.
|
|
39
|
+
*/
|
|
40
|
+
import { computed } from 'vue';
|
|
41
|
+
import type { LayoutSection, LayoutPayload, LayoutZoneClient } from '../composables/useLayout';
|
|
42
|
+
import { useSectionRegistry } from '../sections/registry';
|
|
43
|
+
|
|
44
|
+
const props = defineProps<{
|
|
45
|
+
/** Route path this layout is for — e.g. '/', '/blog', '/hubs/foo'. */
|
|
46
|
+
route: string;
|
|
47
|
+
/** Zone slug — must match a zone declared in the layout's storage. */
|
|
48
|
+
zone: string;
|
|
49
|
+
/**
|
|
50
|
+
* Optional override — when set, this layout is rendered INSTEAD of
|
|
51
|
+
* fetching from /api/layouts/by-route. Used by the editor preview
|
|
52
|
+
* pane to render the in-progress draft without a save round-trip.
|
|
53
|
+
*/
|
|
54
|
+
previewOverride?: LayoutPayload | null;
|
|
55
|
+
}>();
|
|
56
|
+
|
|
57
|
+
const { layout, pending } = useLayout(props.route);
|
|
58
|
+
const features = useFeatures();
|
|
59
|
+
const { isAuthenticated, user } = useAuth();
|
|
60
|
+
const sectionRegistry = useSectionRegistry();
|
|
61
|
+
|
|
62
|
+
const activeLayout = computed(() => props.previewOverride ?? layout.value);
|
|
63
|
+
|
|
64
|
+
const zone = computed<LayoutZoneClient | null>(
|
|
65
|
+
() => activeLayout.value?.zones.find((z: LayoutZoneClient) => z.zone === props.zone) ?? null,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
function isFeatureOn(featureGate: string | undefined): boolean {
|
|
69
|
+
if (!featureGate) return true;
|
|
70
|
+
return (features.features.value as unknown as Record<string, boolean>)?.[featureGate] ?? false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function currentRole(): string {
|
|
74
|
+
if (!isAuthenticated.value) return 'anonymous';
|
|
75
|
+
return user.value?.role ?? 'member';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sectionVisible(s: LayoutSection): boolean {
|
|
79
|
+
if (!s.enabled) return false;
|
|
80
|
+
const v = s.visibility;
|
|
81
|
+
if (!v) return true;
|
|
82
|
+
if (v.features && v.features.some((f: string) => !isFeatureOn(f))) return false;
|
|
83
|
+
if (v.roles && v.roles.length > 0 && !v.roles.includes(currentRole())) return false;
|
|
84
|
+
// hideAt is a viewport-level filter — applied via CSS, not here
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve colSpan honouring the responsive fallback chain. Defers the
|
|
90
|
+
* viewport choice to CSS — we use `--cpub-section-cols-{sm|md|lg}`
|
|
91
|
+
* custom properties on each section and let media queries pick which
|
|
92
|
+
* one becomes the active `grid-column: span N` via `attr()`-style
|
|
93
|
+
* values. For now, just use the base colSpan (12 = full width on mobile
|
|
94
|
+
* happens via the row's flex-wrap).
|
|
95
|
+
*/
|
|
96
|
+
function resolveColSpan(s: LayoutSection, viewport: 'lg' | 'md' | 'sm'): number {
|
|
97
|
+
if (viewport === 'lg') return s.responsive?.lg ?? s.colSpan;
|
|
98
|
+
if (viewport === 'md') return s.responsive?.md ?? s.responsive?.lg ?? s.colSpan;
|
|
99
|
+
// Mobile default 12 unless explicitly overridden
|
|
100
|
+
return s.responsive?.sm ?? 12;
|
|
101
|
+
}
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<template>
|
|
105
|
+
<!--
|
|
106
|
+
Renders nothing when:
|
|
107
|
+
- layout-engine feature is off (useLayout returns null)
|
|
108
|
+
- no layout exists for this route
|
|
109
|
+
- no zone of that slug in the layout
|
|
110
|
+
- zone has zero rows
|
|
111
|
+
All four are valid "absence" cases — fall back to legacy rendering
|
|
112
|
+
via the page's v-if structure.
|
|
113
|
+
-->
|
|
114
|
+
<template v-if="zone && zone.rows.length > 0">
|
|
115
|
+
<div
|
|
116
|
+
v-for="row in zone.rows"
|
|
117
|
+
:key="row.id"
|
|
118
|
+
class="cpub-layout-row"
|
|
119
|
+
:data-row-id="row.id"
|
|
120
|
+
:data-gap="row.config?.gap ?? 'md'"
|
|
121
|
+
:data-align="row.config?.align ?? 'stretch'"
|
|
122
|
+
:data-padding-y="row.config?.paddingY ?? 'none'"
|
|
123
|
+
:style="row.config?.background ? { background: row.config.background } : {}"
|
|
124
|
+
>
|
|
125
|
+
<div
|
|
126
|
+
v-for="section in row.sections.filter(sectionVisible)"
|
|
127
|
+
:key="section.id"
|
|
128
|
+
class="cpub-layout-section"
|
|
129
|
+
:data-section-id="section.id"
|
|
130
|
+
:data-section-type="section.type"
|
|
131
|
+
:data-hide-sm="section.visibility?.hideAt?.includes('sm') ? 'true' : 'false'"
|
|
132
|
+
:data-hide-md="section.visibility?.hideAt?.includes('md') ? 'true' : 'false'"
|
|
133
|
+
:data-hide-lg="section.visibility?.hideAt?.includes('lg') ? 'true' : 'false'"
|
|
134
|
+
:style="{
|
|
135
|
+
'--cpub-section-cols-sm': resolveColSpan(section, 'sm'),
|
|
136
|
+
'--cpub-section-cols-md': resolveColSpan(section, 'md'),
|
|
137
|
+
'--cpub-section-cols-lg': resolveColSpan(section, 'lg'),
|
|
138
|
+
}"
|
|
139
|
+
>
|
|
140
|
+
<!--
|
|
141
|
+
Section render path:
|
|
142
|
+
1. Look up the section's `type` in the registry (one shared
|
|
143
|
+
instance per app process, populated at module-load time in
|
|
144
|
+
sections/registry.ts).
|
|
145
|
+
2. If registered, render via <component :is> with the section's
|
|
146
|
+
`config` + computed `meta`. Vue's component-resolver handles
|
|
147
|
+
both functional + SFC renderers.
|
|
148
|
+
3. If NOT registered, fall back to the admin-only placeholder
|
|
149
|
+
so admins can see "this section type isn't installed" while
|
|
150
|
+
end users see nothing for the section (an unknown section
|
|
151
|
+
shouldn't leak rendering debug info to the public).
|
|
152
|
+
-->
|
|
153
|
+
<component
|
|
154
|
+
v-if="sectionRegistry.has(section.type)"
|
|
155
|
+
:is="sectionRegistry.get(section.type)!.component"
|
|
156
|
+
:config="section.config"
|
|
157
|
+
:meta="{
|
|
158
|
+
route,
|
|
159
|
+
zone,
|
|
160
|
+
isPreview: !!previewOverride,
|
|
161
|
+
effectiveColSpan: resolveColSpan(section, 'lg'),
|
|
162
|
+
sectionId: section.id,
|
|
163
|
+
}"
|
|
164
|
+
/>
|
|
165
|
+
<div
|
|
166
|
+
v-else
|
|
167
|
+
class="cpub-layout-section-placeholder"
|
|
168
|
+
:aria-label="`Unregistered section type: ${section.type}`"
|
|
169
|
+
>
|
|
170
|
+
<code>{{ section.type }}</code>
|
|
171
|
+
<span class="cpub-layout-section-placeholder-hint">section type not registered</span>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</template>
|
|
176
|
+
|
|
177
|
+
<!-- Loading shimmer while initial fetch is in flight (no layout payload yet) -->
|
|
178
|
+
<div v-else-if="pending && !previewOverride" class="cpub-layout-skeleton" aria-hidden="true">
|
|
179
|
+
<div class="cpub-layout-skeleton-row" />
|
|
180
|
+
<div class="cpub-layout-skeleton-row" />
|
|
181
|
+
</div>
|
|
182
|
+
</template>
|
|
183
|
+
|
|
184
|
+
<style scoped>
|
|
185
|
+
.cpub-layout-row {
|
|
186
|
+
display: grid;
|
|
187
|
+
grid-template-columns: repeat(12, 1fr);
|
|
188
|
+
gap: var(--space-4);
|
|
189
|
+
width: 100%;
|
|
190
|
+
}
|
|
191
|
+
.cpub-layout-row[data-gap='none'] { gap: 0; }
|
|
192
|
+
.cpub-layout-row[data-gap='sm'] { gap: var(--space-2); }
|
|
193
|
+
.cpub-layout-row[data-gap='md'] { gap: var(--space-4); }
|
|
194
|
+
.cpub-layout-row[data-gap='lg'] { gap: var(--space-6); }
|
|
195
|
+
|
|
196
|
+
.cpub-layout-row[data-align='center'] { align-items: center; }
|
|
197
|
+
.cpub-layout-row[data-align='start'] { align-items: start; }
|
|
198
|
+
.cpub-layout-row[data-align='stretch'] { align-items: stretch; }
|
|
199
|
+
|
|
200
|
+
.cpub-layout-row[data-padding-y='sm'] { padding-block: var(--space-2); }
|
|
201
|
+
.cpub-layout-row[data-padding-y='md'] { padding-block: var(--space-4); }
|
|
202
|
+
.cpub-layout-row[data-padding-y='lg'] { padding-block: var(--space-6); }
|
|
203
|
+
.cpub-layout-row[data-padding-y='xl'] { padding-block: var(--space-8); }
|
|
204
|
+
|
|
205
|
+
/* Section span: default to lg (desktop). Media queries swap.
|
|
206
|
+
--cpub-section-cols-* are set per-section via inline style. */
|
|
207
|
+
.cpub-layout-section {
|
|
208
|
+
grid-column: span var(--cpub-section-cols-lg, 12);
|
|
209
|
+
min-width: 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@media (max-width: 1024px) {
|
|
213
|
+
.cpub-layout-section { grid-column: span var(--cpub-section-cols-md, var(--cpub-section-cols-lg, 12)); }
|
|
214
|
+
}
|
|
215
|
+
@media (max-width: 640px) {
|
|
216
|
+
.cpub-layout-section { grid-column: span var(--cpub-section-cols-sm, 12); }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* hideAt — orthogonal to colSpan. Use display:none so layout doesn't reserve space. */
|
|
220
|
+
.cpub-layout-section[data-hide-sm='true'] { @media (max-width: 640px) { display: none; } }
|
|
221
|
+
.cpub-layout-section[data-hide-md='true'] { @media (min-width: 641px) and (max-width: 1024px) { display: none; } }
|
|
222
|
+
.cpub-layout-section[data-hide-lg='true'] { @media (min-width: 1025px) { display: none; } }
|
|
223
|
+
|
|
224
|
+
/* Placeholder shown when no renderer is registered for the section type */
|
|
225
|
+
.cpub-layout-section-placeholder {
|
|
226
|
+
padding: var(--space-4);
|
|
227
|
+
background: var(--surface2);
|
|
228
|
+
border: 1px dashed var(--border2);
|
|
229
|
+
font-family: var(--font-mono);
|
|
230
|
+
font-size: var(--text-sm);
|
|
231
|
+
color: var(--text-dim);
|
|
232
|
+
text-align: center;
|
|
233
|
+
display: flex;
|
|
234
|
+
flex-direction: column;
|
|
235
|
+
gap: 4px;
|
|
236
|
+
align-items: center;
|
|
237
|
+
}
|
|
238
|
+
.cpub-layout-section-placeholder code {
|
|
239
|
+
color: var(--accent);
|
|
240
|
+
}
|
|
241
|
+
.cpub-layout-section-placeholder-hint {
|
|
242
|
+
font-size: var(--text-xs);
|
|
243
|
+
color: var(--text-faint);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* Skeleton loading state */
|
|
247
|
+
.cpub-layout-skeleton {
|
|
248
|
+
display: flex;
|
|
249
|
+
flex-direction: column;
|
|
250
|
+
gap: var(--space-4);
|
|
251
|
+
padding: var(--space-4);
|
|
252
|
+
}
|
|
253
|
+
.cpub-layout-skeleton-row {
|
|
254
|
+
height: 60px;
|
|
255
|
+
background: linear-gradient(90deg, var(--surface2) 25%, var(--surface3) 50%, var(--surface2) 75%);
|
|
256
|
+
background-size: 200% 100%;
|
|
257
|
+
animation: cpub-layout-skel 1.4s ease-in-out infinite;
|
|
258
|
+
}
|
|
259
|
+
@keyframes cpub-layout-skel {
|
|
260
|
+
0% { background-position: 200% 0; }
|
|
261
|
+
100% { background-position: -200% 0; }
|
|
262
|
+
}
|
|
263
|
+
@media (prefers-reduced-motion: reduce) {
|
|
264
|
+
.cpub-layout-skeleton-row { animation: none; }
|
|
265
|
+
}
|
|
266
|
+
</style>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Built-in section: content-feed — a data-driven grid of content cards.
|
|
4
|
+
*
|
|
5
|
+
* Phase 1c starter and the first DATA section. Fetches `/api/content`
|
|
6
|
+
* with config-driven filter parameters; renders the existing
|
|
7
|
+
* `<ContentCard>` so the visual identity matches feeds elsewhere on the
|
|
8
|
+
* site.
|
|
9
|
+
*
|
|
10
|
+
* Each instance fetches independently (no global feed cache) — Nuxt's
|
|
11
|
+
* useFetch dedupes by `key`, which we derive from the config so two
|
|
12
|
+
* identically-configured feeds share a single request while two
|
|
13
|
+
* differently-configured feeds (e.g. main + sidebar with different
|
|
14
|
+
* `sort`) hit the endpoint separately.
|
|
15
|
+
*
|
|
16
|
+
* SSR-safe: useFetch fetches at setup() and includes the payload in the
|
|
17
|
+
* hydration snapshot.
|
|
18
|
+
*
|
|
19
|
+
* `var(--*)` only.
|
|
20
|
+
*/
|
|
21
|
+
import { computed } from 'vue';
|
|
22
|
+
import type { PaginatedResponse, Serialized, ContentListItem } from '@commonpub/server';
|
|
23
|
+
import type { SectionRenderProps } from '@commonpub/ui';
|
|
24
|
+
|
|
25
|
+
interface ContentFeedConfig extends Record<string, unknown> {
|
|
26
|
+
heading: string;
|
|
27
|
+
contentType: string;
|
|
28
|
+
sort: 'recent' | 'popular' | 'featured' | 'editorial';
|
|
29
|
+
limit: number;
|
|
30
|
+
columns: 1 | 2 | 3 | 4;
|
|
31
|
+
tag: string;
|
|
32
|
+
featured: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const props = defineProps<SectionRenderProps<ContentFeedConfig>>();
|
|
36
|
+
|
|
37
|
+
// Build the API query — server expects `type` (single value or absent),
|
|
38
|
+
// `sort`, `limit`, `tag`, `featured`. Omit empty strings so the validator
|
|
39
|
+
// treats them as absent (vs the empty-string=match-empty trap).
|
|
40
|
+
const apiQuery = computed(() => {
|
|
41
|
+
const q: Record<string, unknown> = {
|
|
42
|
+
status: 'published',
|
|
43
|
+
sort: props.config.sort,
|
|
44
|
+
limit: Math.min(Math.max(props.config.limit, 1), 24),
|
|
45
|
+
};
|
|
46
|
+
if (props.config.contentType) q.type = props.config.contentType;
|
|
47
|
+
if (props.config.tag) q.tag = props.config.tag;
|
|
48
|
+
if (props.config.featured) q.featured = true;
|
|
49
|
+
return q;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Stable key so two identical content-feed sections on the same page
|
|
53
|
+
// share a single request, while different configurations don't collide.
|
|
54
|
+
const fetchKey = computed(
|
|
55
|
+
() => `section-content-feed:${JSON.stringify(apiQuery.value)}`,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// NO await — section is rendered inside <LayoutSlot> deep in the tree;
|
|
59
|
+
// awaiting top-level here would require Suspense on every parent, which
|
|
60
|
+
// neither the production page-render path nor the editor preview pane
|
|
61
|
+
// is set up for. The page (`/`, `/about`, etc.) already does its own
|
|
62
|
+
// `await useFetch` for content via the legacy renderer, so initial
|
|
63
|
+
// load is data-ready; this section's fetch is a fresh request per
|
|
64
|
+
// instance + config. Pending state surfaces in the template instead.
|
|
65
|
+
const { data: feed, pending } = useFetch<PaginatedResponse<Serialized<ContentListItem>>>(
|
|
66
|
+
'/api/content',
|
|
67
|
+
{
|
|
68
|
+
query: apiQuery,
|
|
69
|
+
key: fetchKey.value,
|
|
70
|
+
// Empty-result handler: surface a friendly empty state in the template
|
|
71
|
+
// rather than throwing. 404 wouldn't be a thing on /api/content anyway.
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const items = computed(() => feed.value?.items ?? []);
|
|
76
|
+
const isEmpty = computed(() => !pending.value && items.value.length === 0);
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<section
|
|
81
|
+
class="cpub-section-content-feed"
|
|
82
|
+
:aria-labelledby="config.heading ? `section-feed-${meta.sectionId}` : undefined"
|
|
83
|
+
>
|
|
84
|
+
<h2
|
|
85
|
+
v-if="config.heading"
|
|
86
|
+
:id="`section-feed-${meta.sectionId}`"
|
|
87
|
+
class="cpub-section-content-feed-heading"
|
|
88
|
+
>
|
|
89
|
+
{{ config.heading }}
|
|
90
|
+
</h2>
|
|
91
|
+
|
|
92
|
+
<div v-if="pending" class="cpub-section-content-feed-loading">
|
|
93
|
+
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
|
|
94
|
+
<span>Loading…</span>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div
|
|
98
|
+
v-else-if="!isEmpty"
|
|
99
|
+
class="cpub-section-content-feed-grid"
|
|
100
|
+
:data-columns="config.columns"
|
|
101
|
+
>
|
|
102
|
+
<ContentCard
|
|
103
|
+
v-for="item in items"
|
|
104
|
+
:key="item.id"
|
|
105
|
+
:item="item"
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<p v-else class="cpub-section-content-feed-empty">
|
|
110
|
+
No content yet.
|
|
111
|
+
</p>
|
|
112
|
+
</section>
|
|
113
|
+
</template>
|
|
114
|
+
|
|
115
|
+
<style scoped>
|
|
116
|
+
.cpub-section-content-feed {
|
|
117
|
+
display: flex;
|
|
118
|
+
flex-direction: column;
|
|
119
|
+
gap: var(--space-3);
|
|
120
|
+
}
|
|
121
|
+
.cpub-section-content-feed-heading {
|
|
122
|
+
font-family: var(--font-mono);
|
|
123
|
+
font-size: var(--text-xs);
|
|
124
|
+
font-weight: 700;
|
|
125
|
+
text-transform: uppercase;
|
|
126
|
+
letter-spacing: 0.08em;
|
|
127
|
+
color: var(--text-faint);
|
|
128
|
+
margin: 0;
|
|
129
|
+
padding-bottom: var(--space-2);
|
|
130
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
131
|
+
}
|
|
132
|
+
.cpub-section-content-feed-grid {
|
|
133
|
+
display: grid;
|
|
134
|
+
gap: var(--space-3);
|
|
135
|
+
}
|
|
136
|
+
.cpub-section-content-feed-grid[data-columns='1'] { grid-template-columns: 1fr; }
|
|
137
|
+
.cpub-section-content-feed-grid[data-columns='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
138
|
+
.cpub-section-content-feed-grid[data-columns='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
139
|
+
.cpub-section-content-feed-grid[data-columns='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
140
|
+
|
|
141
|
+
/* Responsive collapse — multi-col grids stack on tablet/mobile */
|
|
142
|
+
@media (max-width: 1024px) {
|
|
143
|
+
.cpub-section-content-feed-grid[data-columns='3'],
|
|
144
|
+
.cpub-section-content-feed-grid[data-columns='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
145
|
+
}
|
|
146
|
+
@media (max-width: 640px) {
|
|
147
|
+
.cpub-section-content-feed-grid { grid-template-columns: 1fr; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.cpub-section-content-feed-loading,
|
|
151
|
+
.cpub-section-content-feed-empty {
|
|
152
|
+
display: flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
justify-content: center;
|
|
155
|
+
gap: var(--space-2);
|
|
156
|
+
padding: var(--space-6);
|
|
157
|
+
color: var(--text-faint);
|
|
158
|
+
font-size: var(--text-sm);
|
|
159
|
+
}
|
|
160
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Built-in section: divider — a horizontal rule.
|
|
4
|
+
*
|
|
5
|
+
* Phase 1 proof-of-life — the simplest possible section, validating that:
|
|
6
|
+
* - `SectionRegistry.register()` accepts a Vue component
|
|
7
|
+
* - `<LayoutSlot>` resolves the section type slug to a renderer
|
|
8
|
+
* - The renderer receives `config` + `meta` and produces DOM
|
|
9
|
+
*
|
|
10
|
+
* Phase 1c adds the real catalogue (hero / heading / paragraph / image /
|
|
11
|
+
* content-feed). Until then, dropping a divider into a layout is the
|
|
12
|
+
* end-to-end smoke test of the layout engine.
|
|
13
|
+
*
|
|
14
|
+
* Uses `var(--*)` only (rule #3); inherits all colors / spacing from
|
|
15
|
+
* the active theme.
|
|
16
|
+
*/
|
|
17
|
+
import type { SectionRenderProps } from '@commonpub/ui';
|
|
18
|
+
|
|
19
|
+
interface DividerConfig extends Record<string, unknown> {
|
|
20
|
+
variant: 'solid' | 'dashed' | 'dotted' | 'accent';
|
|
21
|
+
spacingY: 'sm' | 'md' | 'lg' | 'xl';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
defineProps<SectionRenderProps<DividerConfig>>();
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<hr
|
|
29
|
+
class="cpub-section-divider"
|
|
30
|
+
:data-variant="config.variant"
|
|
31
|
+
:data-spacing-y="config.spacingY"
|
|
32
|
+
:aria-label="`section ${meta.sectionId}`"
|
|
33
|
+
/>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<style scoped>
|
|
37
|
+
.cpub-section-divider {
|
|
38
|
+
margin-block: var(--space-4);
|
|
39
|
+
border: 0;
|
|
40
|
+
border-top: var(--border-width-default) solid var(--border2);
|
|
41
|
+
width: 100%;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.cpub-section-divider[data-variant='dashed'] { border-top-style: dashed; }
|
|
45
|
+
.cpub-section-divider[data-variant='dotted'] { border-top-style: dotted; }
|
|
46
|
+
.cpub-section-divider[data-variant='accent'] {
|
|
47
|
+
border-top-color: var(--accent);
|
|
48
|
+
border-top-width: var(--border-width-thick);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.cpub-section-divider[data-spacing-y='sm'] { margin-block: var(--space-2); }
|
|
52
|
+
.cpub-section-divider[data-spacing-y='md'] { margin-block: var(--space-4); }
|
|
53
|
+
.cpub-section-divider[data-spacing-y='lg'] { margin-block: var(--space-6); }
|
|
54
|
+
.cpub-section-divider[data-spacing-y='xl'] { margin-block: var(--space-8); }
|
|
55
|
+
</style>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Built-in section: heading — a single configurable heading (h1–h4)
|
|
4
|
+
* with optional eyebrow + subline.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1c starter. Semantic level is admin-chosen — the auto-form
|
|
7
|
+
* inspector (Phase 3e) will warn on multiple h1s per layout.
|
|
8
|
+
*
|
|
9
|
+
* `var(--*)` only.
|
|
10
|
+
*/
|
|
11
|
+
import { computed } from 'vue';
|
|
12
|
+
import type { SectionRenderProps } from '@commonpub/ui';
|
|
13
|
+
|
|
14
|
+
interface HeadingConfig extends Record<string, unknown> {
|
|
15
|
+
text: string;
|
|
16
|
+
level: 1 | 2 | 3 | 4;
|
|
17
|
+
align: 'left' | 'center';
|
|
18
|
+
eyebrow: string;
|
|
19
|
+
subline: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const props = defineProps<SectionRenderProps<HeadingConfig>>();
|
|
23
|
+
|
|
24
|
+
// Vue's <component :is> with a tag string handles the level swap without
|
|
25
|
+
// needing a v-if chain. Defensive clamp: an out-of-range level (shouldn't
|
|
26
|
+
// happen — Zod gates it on write) falls back to h2.
|
|
27
|
+
const headingTag = computed(() => {
|
|
28
|
+
const n = props.config.level;
|
|
29
|
+
return n >= 1 && n <= 4 ? `h${n}` : 'h2';
|
|
30
|
+
});
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<section
|
|
35
|
+
class="cpub-section-heading"
|
|
36
|
+
:data-align="config.align"
|
|
37
|
+
:aria-labelledby="`section-heading-${meta.sectionId}`"
|
|
38
|
+
>
|
|
39
|
+
<p v-if="config.eyebrow" class="cpub-section-heading-eyebrow">{{ config.eyebrow }}</p>
|
|
40
|
+
<component
|
|
41
|
+
:is="headingTag"
|
|
42
|
+
:id="`section-heading-${meta.sectionId}`"
|
|
43
|
+
class="cpub-section-heading-text"
|
|
44
|
+
>
|
|
45
|
+
{{ config.text }}
|
|
46
|
+
</component>
|
|
47
|
+
<p v-if="config.subline" class="cpub-section-heading-subline">{{ config.subline }}</p>
|
|
48
|
+
</section>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<style scoped>
|
|
52
|
+
.cpub-section-heading {
|
|
53
|
+
margin-block: var(--space-4);
|
|
54
|
+
}
|
|
55
|
+
.cpub-section-heading[data-align='center'] {
|
|
56
|
+
text-align: center;
|
|
57
|
+
}
|
|
58
|
+
.cpub-section-heading-eyebrow {
|
|
59
|
+
font-family: var(--font-mono);
|
|
60
|
+
font-size: var(--text-xs);
|
|
61
|
+
text-transform: uppercase;
|
|
62
|
+
letter-spacing: 0.1em;
|
|
63
|
+
color: var(--text-faint);
|
|
64
|
+
margin: 0 0 var(--space-2);
|
|
65
|
+
}
|
|
66
|
+
.cpub-section-heading-text {
|
|
67
|
+
margin: 0;
|
|
68
|
+
font-weight: 700;
|
|
69
|
+
line-height: 1.25;
|
|
70
|
+
color: var(--text);
|
|
71
|
+
}
|
|
72
|
+
.cpub-section-heading-subline {
|
|
73
|
+
margin: var(--space-2) 0 0;
|
|
74
|
+
color: var(--text-dim);
|
|
75
|
+
font-size: var(--text-md);
|
|
76
|
+
line-height: 1.6;
|
|
77
|
+
}
|
|
78
|
+
</style>
|