@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.
Files changed (81) hide show
  1. package/README.md +41 -12
  2. package/components/LayoutRow.vue +944 -0
  3. package/components/LayoutSection.vue +1028 -0
  4. package/components/LayoutSlot.vue +104 -162
  5. package/components/PageFrame.vue +116 -0
  6. package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
  7. package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
  8. package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
  9. package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
  10. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
  11. package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
  13. package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
  14. package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
  15. package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
  16. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
  17. package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
  18. package/components/blocks/BlockDividerView.vue +52 -2
  19. package/components/homepage/ContentGridSection.vue +23 -1
  20. package/components/homepage/HeroSection.vue +69 -8
  21. package/components/sections/SectionCta.vue +175 -0
  22. package/components/sections/SectionLearning.vue +232 -0
  23. package/composables/autoFormSchema.ts +319 -0
  24. package/composables/useAdminSidebar.ts +116 -0
  25. package/composables/useEditorChrome.ts +56 -0
  26. package/composables/useFeatures.ts +32 -5
  27. package/composables/useLayout.ts +46 -43
  28. package/composables/useLayoutAnnouncer.ts +332 -0
  29. package/composables/useLayoutAutoSave.ts +117 -0
  30. package/composables/useLayoutDrag.ts +290 -0
  31. package/composables/useLayoutEditor.ts +593 -0
  32. package/composables/useLayoutHistory.ts +583 -0
  33. package/composables/useLayoutHotkeys.ts +366 -0
  34. package/composables/useLayoutResize.ts +783 -0
  35. package/layouts/admin.vue +137 -24
  36. package/middleware/admin-layouts.ts +29 -0
  37. package/nuxt.config.ts +14 -0
  38. package/package.json +8 -5
  39. package/pages/[...customPath].vue +154 -0
  40. package/pages/admin/homepage.vue +46 -0
  41. package/pages/admin/index.vue +16 -0
  42. package/pages/admin/layouts/[id].vue +1110 -0
  43. package/pages/admin/layouts/index.vue +356 -0
  44. package/pages/explore.vue +16 -6
  45. package/sections/builtin/content-feed.ts +18 -29
  46. package/sections/builtin/contests.ts +30 -0
  47. package/sections/builtin/cta.ts +46 -0
  48. package/sections/builtin/custom-html.ts +36 -0
  49. package/sections/builtin/divider.ts +15 -17
  50. package/sections/builtin/editorial.ts +29 -0
  51. package/sections/builtin/embed.ts +31 -0
  52. package/sections/builtin/gallery.ts +29 -0
  53. package/sections/builtin/heading.ts +14 -19
  54. package/sections/builtin/hero.ts +16 -51
  55. package/sections/builtin/hubs.ts +30 -0
  56. package/sections/builtin/image.ts +12 -49
  57. package/sections/builtin/learning.ts +30 -0
  58. package/sections/builtin/markdown.ts +29 -0
  59. package/sections/builtin/paragraph.ts +14 -17
  60. package/sections/builtin/stats.ts +35 -0
  61. package/sections/builtin/video.ts +30 -0
  62. package/sections/registry.ts +38 -7
  63. package/server/api/admin/homepage/sections.put.ts +52 -1
  64. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  65. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  66. package/server/api/admin/layouts/[id].delete.ts +33 -1
  67. package/server/api/admin/layouts/[id].put.ts +78 -0
  68. package/server/api/admin/layouts/index.post.ts +60 -4
  69. package/server/api/admin/layouts/migrate-homepage.post.ts +68 -0
  70. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  71. package/server/api/layouts/by-route.get.ts +64 -12
  72. package/server/plugins/feature-flags-prime.ts +39 -0
  73. package/server/utils/layoutCache.ts +37 -1
  74. package/server/utils/validateSectionConfigs.ts +123 -0
  75. package/theme/base.css +1 -0
  76. package/components/sections/SectionContentFeed.vue +0 -160
  77. package/components/sections/SectionDivider.vue +0 -55
  78. package/components/sections/SectionHeading.vue +0 -78
  79. package/components/sections/SectionHero.vue +0 -164
  80. package/components/sections/SectionImage.vue +0 -104
  81. 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>