@commonpub/layer 0.23.3 → 0.25.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/README.md +41 -12
- package/components/LayoutRow.vue +944 -0
- package/components/LayoutSection.vue +1028 -0
- package/components/LayoutSlot.vue +104 -162
- package/components/PageFrame.vue +116 -0
- package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
- package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
- package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
- package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
- package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
- package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
- package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
- package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
- package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
- package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
- package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
- package/components/blocks/BlockDividerView.vue +52 -2
- package/components/homepage/ContentGridSection.vue +23 -1
- package/components/homepage/HeroSection.vue +69 -8
- package/components/sections/SectionCta.vue +175 -0
- package/components/sections/SectionLearning.vue +232 -0
- package/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useFeatures.ts +32 -5
- package/composables/useLayout.ts +46 -43
- package/composables/useLayoutAnnouncer.ts +332 -0
- package/composables/useLayoutAutoSave.ts +117 -0
- package/composables/useLayoutDrag.ts +290 -0
- package/composables/useLayoutEditor.ts +593 -0
- package/composables/useLayoutHistory.ts +583 -0
- package/composables/useLayoutHotkeys.ts +366 -0
- package/composables/useLayoutResize.ts +783 -0
- package/layouts/admin.vue +137 -24
- package/middleware/admin-layouts.ts +29 -0
- package/nuxt.config.ts +14 -0
- package/package.json +8 -5
- package/pages/[...customPath].vue +154 -0
- package/pages/admin/homepage.vue +46 -0
- package/pages/admin/index.vue +16 -0
- package/pages/admin/layouts/[id].vue +1110 -0
- package/pages/admin/layouts/index.vue +356 -0
- package/pages/explore.vue +16 -6
- package/sections/builtin/content-feed.ts +18 -29
- package/sections/builtin/contests.ts +30 -0
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +36 -0
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +29 -0
- package/sections/builtin/embed.ts +31 -0
- package/sections/builtin/gallery.ts +29 -0
- package/sections/builtin/heading.ts +14 -19
- package/sections/builtin/hero.ts +16 -51
- package/sections/builtin/hubs.ts +30 -0
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +30 -0
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +35 -0
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +38 -7
- package/server/api/admin/homepage/sections.put.ts +52 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
- package/server/api/admin/layouts/[id].delete.ts +33 -1
- package/server/api/admin/layouts/[id].put.ts +78 -0
- package/server/api/admin/layouts/index.post.ts +60 -4
- package/server/api/admin/layouts/migrate-homepage.post.ts +68 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- package/server/plugins/feature-flags-prime.ts +39 -0
- package/server/utils/layoutCache.ts +37 -1
- package/server/utils/validateSectionConfigs.ts +123 -0
- package/theme/base.css +1 -0
- package/components/sections/SectionContentFeed.vue +0 -160
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionImage.vue +0 -104
- package/components/sections/SectionParagraph.vue +0 -55
|
@@ -1,160 +0,0 @@
|
|
|
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>
|
|
@@ -1,55 +0,0 @@
|
|
|
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>
|
|
@@ -1,78 +0,0 @@
|
|
|
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>
|
|
@@ -1,164 +0,0 @@
|
|
|
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>
|
|
@@ -1,104 +0,0 @@
|
|
|
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>
|
|
@@ -1,55 +0,0 @@
|
|
|
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>
|