@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
@@ -0,0 +1,356 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * /admin/layouts — list page.
4
+ *
5
+ * Phase 3a.2. Lists all layouts via GET /api/admin/layouts (gated on
6
+ * admin + layoutEngine). Rows: scope label, name, state pill, last
7
+ * updated, actions (edit, delete). Click a row → /admin/layouts/[id].
8
+ *
9
+ * Creation flow lives on the editor page; this page is read + delete
10
+ * only for v1. New layouts come from:
11
+ * - the homepage migration (POST /api/admin/layouts/migrate-homepage)
12
+ * - the custom-page wizard (Phase 2 catch-all — POST /api/admin/layouts)
13
+ * Both already exist server-side; the editor pages 3a.3+ will surface them.
14
+ */
15
+ import type { LayoutRecord } from '@commonpub/server';
16
+
17
+ definePageMeta({
18
+ layout: 'admin',
19
+ middleware: ['auth', 'admin-layouts'],
20
+ });
21
+ useSeoMeta({ title: `Layouts — Admin — ${useSiteName()}` });
22
+
23
+ const toast = useToast();
24
+ const { data: layouts, refresh, pending } = await useFetch<LayoutRecord[]>(
25
+ '/api/admin/layouts',
26
+ );
27
+
28
+ function scopeLabel(scope: LayoutRecord['scope']): string {
29
+ if (scope.type === 'route') return scope.path;
30
+ if (scope.type === 'custom-page') return scope.path;
31
+ return `virtual:${scope.key}`;
32
+ }
33
+
34
+ function scopeKind(scope: LayoutRecord['scope']): string {
35
+ if (scope.type === 'route') return 'Route';
36
+ if (scope.type === 'custom-page') return 'Custom page';
37
+ return 'Virtual';
38
+ }
39
+
40
+ function formatDate(iso: string): string {
41
+ const d = new Date(iso);
42
+ if (Number.isNaN(d.getTime())) return iso;
43
+ return d.toLocaleString();
44
+ }
45
+
46
+ // Round-3 audit fix: real migration trigger (was a misleading link to
47
+ // the legacy editor). Wires POST /api/admin/layouts/migrate-homepage.
48
+ const migrating = ref<boolean>(false);
49
+
50
+ async function migrateHomepage(): Promise<void> {
51
+ if (migrating.value) return;
52
+ migrating.value = true;
53
+ try {
54
+ const result = await $fetch<{ migrated: boolean; reason?: string; layoutId?: string }>(
55
+ '/api/admin/layouts/migrate-homepage',
56
+ { method: 'POST', body: {} },
57
+ );
58
+ if (result.migrated) {
59
+ toast.success('Homepage migrated to layout engine');
60
+ await refresh();
61
+ } else if (result.reason === 'layout-already-exists') {
62
+ toast.show('A homepage layout already exists — opening it');
63
+ await refresh();
64
+ } else {
65
+ toast.show(result.reason ?? 'Migration finished');
66
+ await refresh();
67
+ }
68
+ } catch (err) {
69
+ const e = err as { statusMessage?: string; message?: string };
70
+ toast.error(e.statusMessage ?? e.message ?? 'Migration failed');
71
+ } finally {
72
+ migrating.value = false;
73
+ }
74
+ }
75
+
76
+ async function deleteLayout(layout: LayoutRecord): Promise<void> {
77
+ if (
78
+ !confirm(
79
+ `Delete layout "${layout.name}" (${scopeLabel(layout.scope)})?\n\nThis cannot be undone. If a route, the page falls back to its legacy renderer.`,
80
+ )
81
+ ) {
82
+ return;
83
+ }
84
+ // R4 audit P1 fix: homepage scope gets an extra confirm + the special
85
+ // X-Cpub-Confirm-Homepage-Delete header (the API refuses without it).
86
+ // Two prompts is intentional — deleting the homepage layout nukes
87
+ // its entire publish history.
88
+ const isHomepage =
89
+ layout.scope.type === 'route' && layout.scope.path === '/';
90
+ const headers: Record<string, string> = {};
91
+ if (isHomepage) {
92
+ if (
93
+ !confirm(
94
+ 'You are deleting the HOMEPAGE layout. This destroys all publish history. The homepage will fall back to the legacy renderer until re-migrated. Continue?',
95
+ )
96
+ ) {
97
+ return;
98
+ }
99
+ headers['X-Cpub-Confirm-Homepage-Delete'] = '1';
100
+ }
101
+ try {
102
+ await $fetch(`/api/admin/layouts/${layout.id}`, { method: 'DELETE', headers });
103
+ toast.success('Layout deleted');
104
+ await refresh();
105
+ } catch (err) {
106
+ const e = err as { statusMessage?: string; message?: string };
107
+ toast.error(e.statusMessage ?? e.message ?? 'Failed to delete layout');
108
+ }
109
+ }
110
+
111
+ // Sort: routes first (by path), then custom-pages, then virtuals.
112
+ const sortedLayouts = computed<LayoutRecord[]>(() => {
113
+ if (!layouts.value) return [];
114
+ const buckets: Record<'route' | 'custom-page' | 'virtual', LayoutRecord[]> = {
115
+ 'route': [],
116
+ 'custom-page': [],
117
+ 'virtual': [],
118
+ };
119
+ for (const l of layouts.value) buckets[l.scope.type].push(l);
120
+ for (const arr of Object.values(buckets)) {
121
+ arr.sort((a, b) => scopeLabel(a.scope).localeCompare(scopeLabel(b.scope)));
122
+ }
123
+ return [...buckets.route, ...buckets['custom-page'], ...buckets.virtual];
124
+ });
125
+ </script>
126
+
127
+ <template>
128
+ <div class="cpub-admin-layouts">
129
+ <header class="cpub-admin-layouts-header">
130
+ <div>
131
+ <h1 class="cpub-admin-layouts-title">Layouts</h1>
132
+ <p class="cpub-admin-layouts-subtitle">
133
+ Visual editor for every route, custom page, and virtual zone on this instance.
134
+ </p>
135
+ </div>
136
+ </header>
137
+
138
+ <div v-if="pending" class="cpub-admin-layouts-loading">
139
+ <i class="fa-solid fa-circle-notch fa-spin"></i> Loading layouts…
140
+ </div>
141
+
142
+ <template v-else-if="sortedLayouts.length === 0">
143
+ <!--
144
+ Empty state — single icon + headline + one-line description +
145
+ single primary action, per Carbon + Mobbin SaaS empty-state
146
+ synthesis. Skipping illustration on purpose: the sharp-corner +
147
+ mono UI label aesthetic reads as intentional with just text.
148
+
149
+ Round-3 audit fix: the primary CTA now actually fires the
150
+ migration endpoint (POST /api/admin/layouts/migrate-homepage)
151
+ instead of misleading-linking to the legacy /admin/homepage editor.
152
+ -->
153
+ <div class="cpub-admin-layouts-empty">
154
+ <i class="fa-regular fa-folder-open cpub-admin-layouts-empty-icon" aria-hidden="true"></i>
155
+ <h2 class="cpub-admin-layouts-empty-text">No layouts yet</h2>
156
+ <p class="cpub-admin-layouts-empty-hint">
157
+ Layouts arrange sections — hero, feed, blocks — into reusable page templates.
158
+ Start by migrating your existing homepage from the legacy editor.
159
+ </p>
160
+ <button
161
+ type="button"
162
+ class="cpub-admin-layouts-empty-cta"
163
+ :disabled="migrating"
164
+ @click="migrateHomepage"
165
+ >
166
+ <i
167
+ :class="migrating
168
+ ? 'fa-solid fa-circle-notch fa-spin'
169
+ : 'fa-solid fa-house-circle-check'"
170
+ aria-hidden="true"
171
+ ></i>
172
+ <span>{{ migrating ? 'Migrating…' : 'Migrate homepage to layout' }}</span>
173
+ </button>
174
+ <p class="cpub-admin-layouts-empty-secondary">
175
+ Or
176
+ <NuxtLink to="/admin/homepage" class="cpub-admin-layouts-empty-link">edit the legacy homepage</NuxtLink>
177
+ first.
178
+ </p>
179
+ </div>
180
+ </template>
181
+
182
+ <template v-else>
183
+ <table class="cpub-admin-layouts-table">
184
+ <thead>
185
+ <tr>
186
+ <th>Scope</th>
187
+ <th>Name</th>
188
+ <th>State</th>
189
+ <th>Last updated</th>
190
+ <th class="cpub-admin-layouts-actions-col">Actions</th>
191
+ </tr>
192
+ </thead>
193
+ <tbody>
194
+ <tr v-for="layout in sortedLayouts" :key="layout.id">
195
+ <td>
196
+ <span class="cpub-admin-layouts-scope-kind">{{ scopeKind(layout.scope) }}</span>
197
+ <code class="cpub-admin-layouts-scope-label">{{ scopeLabel(layout.scope) }}</code>
198
+ </td>
199
+ <td>{{ layout.name }}</td>
200
+ <td>
201
+ <span
202
+ class="cpub-admin-layouts-state"
203
+ :data-state="layout.state"
204
+ >{{ layout.state }}</span>
205
+ </td>
206
+ <td>{{ formatDate(layout.updatedAt) }}</td>
207
+ <td>
208
+ <div class="cpub-admin-layouts-actions">
209
+ <NuxtLink
210
+ :to="`/admin/layouts/${layout.id}`"
211
+ class="cpub-admin-layouts-btn"
212
+ :aria-label="`Edit layout ${layout.name}`"
213
+ >
214
+ <i class="fa-solid fa-pen"></i>
215
+ <span>Edit</span>
216
+ </NuxtLink>
217
+ <button
218
+ type="button"
219
+ class="cpub-admin-layouts-btn cpub-admin-layouts-btn--danger"
220
+ :aria-label="`Delete layout ${layout.name}`"
221
+ @click="deleteLayout(layout)"
222
+ >
223
+ <i class="fa-solid fa-trash"></i>
224
+ <span>Delete</span>
225
+ </button>
226
+ </div>
227
+ </td>
228
+ </tr>
229
+ </tbody>
230
+ </table>
231
+ </template>
232
+ </div>
233
+ </template>
234
+
235
+ <style scoped>
236
+ .cpub-admin-layouts { display: flex; flex-direction: column; gap: var(--space-6); }
237
+ .cpub-admin-layouts-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
238
+ .cpub-admin-layouts-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); margin: 0 0 var(--space-2) 0; }
239
+ .cpub-admin-layouts-subtitle { color: var(--text-dim); margin: 0; }
240
+
241
+ .cpub-admin-layouts-loading {
242
+ display: flex; align-items: center; gap: var(--space-2);
243
+ padding: var(--space-8); color: var(--text-faint);
244
+ }
245
+
246
+ .cpub-admin-layouts-empty {
247
+ display: flex; flex-direction: column; align-items: center;
248
+ gap: var(--space-3); padding: var(--space-10) var(--space-6);
249
+ background: var(--surface2); border: var(--border-width-default) solid var(--border2);
250
+ max-width: 560px;
251
+ margin: 0 auto;
252
+ text-align: center;
253
+ }
254
+ .cpub-admin-layouts-empty-icon { font-size: var(--text-3xl); color: var(--text-faint); }
255
+ .cpub-admin-layouts-empty-text {
256
+ font-size: var(--text-lg);
257
+ font-weight: var(--font-weight-semibold);
258
+ color: var(--text);
259
+ margin: 0;
260
+ }
261
+ .cpub-admin-layouts-empty-hint { color: var(--text-dim); margin: 0 0 var(--space-2) 0; max-width: 44ch; }
262
+ .cpub-admin-layouts-empty-cta {
263
+ display: inline-flex; align-items: center; gap: var(--space-2);
264
+ padding: var(--space-2) var(--space-4);
265
+ background: var(--accent);
266
+ border: var(--border-width-default) solid var(--accent);
267
+ color: var(--surface);
268
+ font-family: var(--font-mono);
269
+ font-size: var(--text-xs);
270
+ text-transform: uppercase;
271
+ letter-spacing: var(--tracking-wide);
272
+ text-decoration: none;
273
+ margin-top: var(--space-2);
274
+ }
275
+ .cpub-admin-layouts-empty-cta:hover:not(:disabled) { filter: brightness(1.1); }
276
+ .cpub-admin-layouts-empty-cta:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
277
+ .cpub-admin-layouts-empty-cta:disabled { opacity: 0.6; cursor: not-allowed; }
278
+
279
+ .cpub-admin-layouts-empty-secondary {
280
+ margin: var(--space-2) 0 0 0;
281
+ font-size: var(--text-sm);
282
+ color: var(--text-faint);
283
+ }
284
+ .cpub-admin-layouts-empty-link { color: var(--accent); text-decoration: underline; }
285
+
286
+ .cpub-admin-layouts-table {
287
+ width: 100%; border-collapse: collapse;
288
+ background: var(--surface);
289
+ border: var(--border-width-default) solid var(--border);
290
+ }
291
+ .cpub-admin-layouts-table th,
292
+ .cpub-admin-layouts-table td {
293
+ padding: var(--space-3) var(--space-4);
294
+ text-align: left;
295
+ border-bottom: 1px solid var(--border2);
296
+ vertical-align: middle;
297
+ }
298
+ .cpub-admin-layouts-table th {
299
+ background: var(--surface2);
300
+ font-family: var(--font-mono);
301
+ font-size: var(--text-xs);
302
+ text-transform: uppercase;
303
+ letter-spacing: var(--tracking-wide);
304
+ color: var(--text-dim);
305
+ font-weight: var(--font-weight-semibold);
306
+ }
307
+ .cpub-admin-layouts-table tbody tr:hover { background: var(--surface2); }
308
+ .cpub-admin-layouts-table tbody tr:last-child td { border-bottom: 0; }
309
+
310
+ .cpub-admin-layouts-scope-kind {
311
+ display: inline-block;
312
+ font-family: var(--font-mono);
313
+ font-size: 10px;
314
+ text-transform: uppercase;
315
+ letter-spacing: var(--tracking-wide);
316
+ color: var(--text-faint);
317
+ margin-right: var(--space-2);
318
+ }
319
+ .cpub-admin-layouts-scope-label {
320
+ font-family: var(--font-mono);
321
+ font-size: var(--text-sm);
322
+ color: var(--text);
323
+ }
324
+
325
+ .cpub-admin-layouts-state {
326
+ display: inline-block;
327
+ padding: 2px var(--space-2);
328
+ font-family: var(--font-mono);
329
+ font-size: 10px;
330
+ text-transform: uppercase;
331
+ letter-spacing: var(--tracking-wide);
332
+ border: 1px solid var(--border2);
333
+ }
334
+ .cpub-admin-layouts-state[data-state='published'] { color: var(--accent); border-color: var(--accent); }
335
+ .cpub-admin-layouts-state[data-state='draft'] { color: var(--text-dim); }
336
+
337
+ .cpub-admin-layouts-actions-col { width: 220px; }
338
+ .cpub-admin-layouts-actions { display: flex; gap: var(--space-2); }
339
+
340
+ .cpub-admin-layouts-btn {
341
+ display: inline-flex; align-items: center; gap: var(--space-1);
342
+ padding: var(--space-1) var(--space-3);
343
+ background: var(--surface);
344
+ border: var(--border-width-default) solid var(--border);
345
+ color: var(--text);
346
+ font-family: var(--font-mono);
347
+ font-size: var(--text-xs);
348
+ text-transform: uppercase;
349
+ letter-spacing: var(--tracking-wide);
350
+ text-decoration: none;
351
+ cursor: pointer;
352
+ }
353
+ .cpub-admin-layouts-btn:hover { background: var(--surface2); }
354
+ .cpub-admin-layouts-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
355
+ .cpub-admin-layouts-btn--danger:hover { color: var(--red); border-color: var(--red); }
356
+ </style>
package/pages/explore.vue CHANGED
@@ -455,17 +455,20 @@ const sortOptions = [
455
455
  outline: none;
456
456
  }
457
457
 
458
- /* Grid */
458
+ /* Grid — responsive auto-fill so cards stay a reasonable, consistent
459
+ width and fill the row (matches feed.vue's proven pattern). The old
460
+ fixed repeat(3,1fr)/repeat(2,1fr) produced oversized cards (esp. the
461
+ 2-col hub/people grid ≈ 470px each) that didn't adapt to the container. */
459
462
  .cpub-explore-grid {
460
463
  display: grid;
461
- grid-template-columns: repeat(3, 1fr);
464
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
462
465
  gap: 16px;
463
466
  }
464
467
 
465
- /* Hub cards */
468
+ /* Hub cards (horizontal layout — slightly wider min) */
466
469
  .cpub-explore-hub-grid {
467
470
  display: grid;
468
- grid-template-columns: repeat(2, 1fr);
471
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
469
472
  gap: 12px;
470
473
  }
471
474
 
@@ -542,8 +545,15 @@ const sortOptions = [
542
545
  }
543
546
 
544
547
  @media (max-width: 768px) {
545
- .cpub-explore-grid { grid-template-columns: 1fr; }
546
- .cpub-explore-hub-grid { grid-template-columns: 1fr; }
548
+ /* Tighter min on tablet keeps 2 columns where they fit (matches feed.vue)
549
+ rather than collapsing straight to one. */
550
+ .cpub-explore-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
551
+ .cpub-explore-hub-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
547
552
  .cpub-explore-filters { flex-wrap: wrap; }
548
553
  }
554
+
555
+ @media (max-width: 480px) {
556
+ .cpub-explore-grid,
557
+ .cpub-explore-hub-grid { grid-template-columns: 1fr; }
558
+ }
549
559
  </style>
@@ -1,50 +1,39 @@
1
1
  /**
2
2
  * Built-in section definition: content-feed.
3
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.
4
+ * Stage E.4 reuses the existing ContentGridSection which has tabs
5
+ * (For You / Latest / Following / per-type) + the content grid +
6
+ * pagination via Load More.
7
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.
8
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
12
9
  */
13
- import { z } from 'zod';
14
10
  import type { SectionDefinition } from '@commonpub/ui';
15
- import SectionContentFeed from '../../components/sections/SectionContentFeed.vue';
11
+ import { contentFeedConfigSchema, type ContentFeedConfig } from '@commonpub/schema';
12
+ import ContentGridSection from '../../components/homepage/ContentGridSection.vue';
16
13
 
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>> = {
14
+ export const contentFeedSection: SectionDefinition<ContentFeedConfig> = {
28
15
  type: 'content-feed',
29
16
  name: 'Content feed',
30
- description: 'Grid of content cards filtered by type / tag / sort',
17
+ description: 'Filterable content grid with tabs + load more (uses ContentGridSection)',
31
18
  icon: 'fa-stream',
32
19
  category: 'data',
33
20
  status: 'stable',
34
- configSchema,
21
+ configSchema: contentFeedConfigSchema,
35
22
  defaultConfig: {
36
23
  heading: '',
37
24
  contentType: '',
38
25
  sort: 'recent',
39
- limit: 6,
40
- columns: 3,
41
- tag: '',
42
- featured: false,
26
+ limit: 12,
27
+ columns: 2,
28
+ categorySlug: '',
43
29
  },
44
30
  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
31
+ component: ContentGridSection,
32
+ // ContentGridSection takes { config: HomepageSectionConfig; title?: string }
33
+ propMap: ({ config }) => ({
34
+ config,
35
+ title: (config.heading as string | undefined) ?? '',
36
+ }),
48
37
  minColSpan: 6,
49
38
  maxColSpan: 12,
50
39
  defaultColSpan: 12,
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Built-in section definition: contests.
3
+ *
4
+ * Stage E.4 — reuses the existing ContestsSection (Active Contests
5
+ * sidebar card with `Nd left` countdowns).
6
+ *
7
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
8
+ */
9
+ import type { SectionDefinition } from '@commonpub/ui';
10
+ import { contestsConfigSchema, type ContestsConfig } from '@commonpub/schema';
11
+ import ContestsSection from '../../components/homepage/ContestsSection.vue';
12
+
13
+ export const contestsSection: SectionDefinition<ContestsConfig> = {
14
+ type: 'contests',
15
+ name: 'Contests',
16
+ description: 'Active contests with deadlines (uses ContestsSection)',
17
+ icon: 'fa-trophy',
18
+ category: 'data',
19
+ status: 'stable',
20
+ featureGate: 'contests',
21
+ configSchema: contestsConfigSchema,
22
+ defaultConfig: { limit: 3 },
23
+ schemaVersion: 1,
24
+ component: ContestsSection,
25
+ propMap: ({ config }) => ({ config }),
26
+ minColSpan: 3,
27
+ maxColSpan: 12,
28
+ defaultColSpan: 4,
29
+ resizable: true,
30
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Built-in section definition: cta — call-to-action panel.
3
+ *
4
+ * Phase 6b addition (session 159). Smaller than hero — typically used
5
+ * mid-page to break up a long content area with a focused next-action.
6
+ *
7
+ * Config: heading + body + up to 3 buttons. Buttons each have
8
+ * label/href/variant (primary/secondary/ghost). URL guard rejects
9
+ * javascript:, data:, vbscript:, file:.
10
+ *
11
+ * **Stage E.3 audit (session 159)**: kept as a section-specific
12
+ * SectionCta.vue renderer because the closest existing component
13
+ * (BlockCalloutView, an info/tip/warning notice with icon + label)
14
+ * doesn't support action buttons. Extending BlockCalloutView would
15
+ * force two unrelated UI patterns through one renderer.
16
+ *
17
+ * Schema (incl. button URL guard) lives in `@commonpub/schema/sectionConfigs`
18
+ * (session 161 move).
19
+ */
20
+ import type { SectionDefinition } from '@commonpub/ui';
21
+ import { ctaConfigSchema, type CtaConfig } from '@commonpub/schema';
22
+ import SectionCta from '../../components/sections/SectionCta.vue';
23
+
24
+ export const ctaSection: SectionDefinition<CtaConfig> = {
25
+ type: 'cta',
26
+ name: 'Call to Action',
27
+ description: 'Heading + body + up to 3 buttons. Mid-page action panel.',
28
+ icon: 'fa-arrow-right',
29
+ category: 'content',
30
+ status: 'stable',
31
+ configSchema: ctaConfigSchema,
32
+ defaultConfig: {
33
+ variant: 'default',
34
+ heading: 'Take the next step',
35
+ body: '',
36
+ buttons: [],
37
+ align: 'left',
38
+ },
39
+ schemaVersion: 1,
40
+ component: SectionCta,
41
+ // CTA panels read fine at half-width; below that the heading wraps awkwardly
42
+ minColSpan: 4,
43
+ maxColSpan: 12,
44
+ defaultColSpan: 12,
45
+ resizable: true,
46
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Built-in section definition: custom-html.
3
+ *
4
+ * Stage E.4 — reuses the existing CustomHtmlSection (renders
5
+ * `config.html` via `v-html` with an optional title).
6
+ *
7
+ * **SECURITY POSTURE**: identical to legacy CustomHtmlSection — `v-html`
8
+ * with no runtime sanitisation. Admin-only via the layout API gate +
9
+ * the existing homepage editor's section schema. Phase 6b plans server-
10
+ * side sanitisation at admin-write time (see prior SectionCustomHtml.vue
11
+ * doc + `docs/plans/layout-and-pages.md §6.5`).
12
+ *
13
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
14
+ */
15
+ import type { SectionDefinition } from '@commonpub/ui';
16
+ import { customHtmlConfigSchema, type CustomHtmlConfig } from '@commonpub/schema';
17
+ import CustomHtmlSection from '../../components/homepage/CustomHtmlSection.vue';
18
+
19
+ export const customHtmlSection: SectionDefinition<CustomHtmlConfig> = {
20
+ type: 'custom-html',
21
+ name: 'Custom HTML',
22
+ description: 'Raw HTML escape hatch (uses CustomHtmlSection)',
23
+ icon: 'fa-code',
24
+ category: 'content',
25
+ status: 'beta',
26
+ configSchema: customHtmlConfigSchema,
27
+ defaultConfig: { heading: '', html: '' },
28
+ schemaVersion: 1,
29
+ component: CustomHtmlSection,
30
+ // CustomHtmlSection takes { config: HomepageSectionConfig; title?: string }
31
+ propMap: ({ config }) => ({ config, title: (config.heading as string | undefined) ?? '' }),
32
+ minColSpan: 3,
33
+ maxColSpan: 12,
34
+ defaultColSpan: 12,
35
+ resizable: true,
36
+ };
@@ -1,34 +1,32 @@
1
1
  /**
2
2
  * Built-in section definition: divider.
3
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.
4
+ * Stage E.1 — reuses BlockDividerView (extended to accept optional
5
+ * variant + spacingY on its `content` prop). Existing block callers
6
+ * (BlockContentRenderer) pass nothing and get the legacy 36px solid line.
7
+ * Layout-engine callers pass `{variant, spacingY}` via the propMap below
8
+ * to customise.
7
9
  *
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.
10
+ * Schema moved to `@commonpub/schema/sectionConfigs` in session 161 so
11
+ * the server can validate per-section configs without dragging Vue into
12
+ * the Nitro bundle. See `validateSectionConfigs.ts`.
11
13
  */
12
- import { z } from 'zod';
13
14
  import type { SectionDefinition } from '@commonpub/ui';
14
- import SectionDivider from '../../components/sections/SectionDivider.vue';
15
+ import { dividerConfigSchema, type DividerConfig } from '@commonpub/schema';
16
+ import BlockDividerView from '../../components/blocks/BlockDividerView.vue';
15
17
 
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>> = {
18
+ export const dividerSection: SectionDefinition<DividerConfig> = {
22
19
  type: 'divider',
23
20
  name: 'Divider',
24
- description: 'Horizontal rule with style + spacing options',
21
+ description: 'Horizontal rule with style + spacing (uses BlockDividerView)',
25
22
  icon: 'fa-minus',
26
23
  category: 'layout',
27
24
  status: 'stable',
28
- configSchema,
25
+ configSchema: dividerConfigSchema,
29
26
  defaultConfig: { variant: 'solid', spacingY: 'md' },
30
27
  schemaVersion: 1,
31
- component: SectionDivider,
28
+ component: BlockDividerView,
29
+ propMap: ({ config }) => ({ content: config }),
32
30
  // Dividers are always full-width; resize is meaningless for a 1px line
33
31
  minColSpan: 12,
34
32
  maxColSpan: 12,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Built-in section definition: editorial.
3
+ *
4
+ * Stage E.4 — reuses the existing EditorialSection which fetches
5
+ * `/api/content?editorial=true` and renders Staff Picks grid.
6
+ *
7
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
8
+ */
9
+ import type { SectionDefinition } from '@commonpub/ui';
10
+ import { editorialConfigSchema, type EditorialConfig } from '@commonpub/schema';
11
+ import EditorialSection from '../../components/homepage/EditorialSection.vue';
12
+
13
+ export const editorialSection: SectionDefinition<EditorialConfig> = {
14
+ type: 'editorial',
15
+ name: 'Editorial',
16
+ description: 'Staff-picked content grid (uses EditorialSection)',
17
+ icon: 'fa-pen-fancy',
18
+ category: 'data',
19
+ status: 'stable',
20
+ configSchema: editorialConfigSchema,
21
+ defaultConfig: { limit: 3 },
22
+ schemaVersion: 1,
23
+ component: EditorialSection,
24
+ propMap: ({ config }) => ({ config }),
25
+ minColSpan: 6,
26
+ maxColSpan: 12,
27
+ defaultColSpan: 12,
28
+ resizable: true,
29
+ };