@commonpub/layer 0.24.0 → 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 (82) 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/composables/autoFormSchema.ts +319 -0
  23. package/composables/useAdminSidebar.ts +116 -0
  24. package/composables/useEditorChrome.ts +56 -0
  25. package/composables/useLayout.ts +34 -41
  26. package/composables/useLayoutAnnouncer.ts +332 -0
  27. package/composables/useLayoutAutoSave.ts +117 -0
  28. package/composables/useLayoutDrag.ts +290 -0
  29. package/composables/useLayoutEditor.ts +593 -0
  30. package/composables/useLayoutHistory.ts +583 -0
  31. package/composables/useLayoutHotkeys.ts +366 -0
  32. package/composables/useLayoutResize.ts +783 -0
  33. package/layouts/admin.vue +137 -24
  34. package/middleware/admin-layouts.ts +29 -0
  35. package/package.json +10 -7
  36. package/pages/[...customPath].vue +154 -0
  37. package/pages/admin/homepage.vue +46 -0
  38. package/pages/admin/index.vue +16 -0
  39. package/pages/admin/layouts/[id].vue +1110 -0
  40. package/pages/admin/layouts/index.vue +356 -0
  41. package/pages/explore.vue +16 -6
  42. package/sections/builtin/content-feed.ts +18 -29
  43. package/sections/builtin/contests.ts +11 -19
  44. package/sections/builtin/cta.ts +46 -0
  45. package/sections/builtin/custom-html.ts +16 -30
  46. package/sections/builtin/divider.ts +15 -17
  47. package/sections/builtin/editorial.ts +11 -21
  48. package/sections/builtin/embed.ts +31 -0
  49. package/sections/builtin/gallery.ts +29 -0
  50. package/sections/builtin/heading.ts +14 -19
  51. package/sections/builtin/hero.ts +16 -51
  52. package/sections/builtin/hubs.ts +11 -26
  53. package/sections/builtin/image.ts +12 -49
  54. package/sections/builtin/learning.ts +5 -13
  55. package/sections/builtin/markdown.ts +29 -0
  56. package/sections/builtin/paragraph.ts +14 -17
  57. package/sections/builtin/stats.ts +17 -18
  58. package/sections/builtin/video.ts +30 -0
  59. package/sections/registry.ts +11 -0
  60. package/server/api/admin/homepage/sections.put.ts +52 -1
  61. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  62. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  63. package/server/api/admin/layouts/[id].delete.ts +33 -1
  64. package/server/api/admin/layouts/[id].put.ts +78 -0
  65. package/server/api/admin/layouts/index.post.ts +60 -4
  66. package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
  67. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  68. package/server/api/layouts/by-route.get.ts +64 -12
  69. package/server/utils/layoutCache.ts +37 -1
  70. package/server/utils/validateSectionConfigs.ts +123 -0
  71. package/theme/base.css +1 -0
  72. package/components/sections/SectionContentFeed.vue +0 -160
  73. package/components/sections/SectionContests.vue +0 -193
  74. package/components/sections/SectionCustomHtml.vue +0 -70
  75. package/components/sections/SectionDivider.vue +0 -55
  76. package/components/sections/SectionEditorial.vue +0 -138
  77. package/components/sections/SectionHeading.vue +0 -78
  78. package/components/sections/SectionHero.vue +0 -164
  79. package/components/sections/SectionHubs.vue +0 -247
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
  82. package/components/sections/SectionStats.vue +0 -151
@@ -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,
@@ -1,36 +1,28 @@
1
1
  /**
2
2
  * Built-in section definition: contests.
3
3
  *
4
- * Phase 1c addition (session 159) active-contests list. Server-fetches
5
- * `/api/contests?limit=N` and renders a sidebar-style card with title,
6
- * entry count, "Nd left" deadline, and per-row Enter CTA.
4
+ * Stage E.4 reuses the existing ContestsSection (Active Contests
5
+ * sidebar card with `Nd left` countdowns).
7
6
  *
8
- * Feature-gated on `features.contests`. If the flag is off the API
9
- * returns 404; the section renders nothing (empty branch). Sidebar
10
- * defaults (colSpan 4) match the legacy `ContestsSection.vue` placement.
7
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
11
8
  */
12
- import { z } from 'zod';
13
9
  import type { SectionDefinition } from '@commonpub/ui';
14
- import SectionContests from '../../components/sections/SectionContests.vue';
10
+ import { contestsConfigSchema, type ContestsConfig } from '@commonpub/schema';
11
+ import ContestsSection from '../../components/homepage/ContestsSection.vue';
15
12
 
16
- const configSchema = z.object({
17
- heading: z.string().max(120).default('Active Contests'),
18
- limit: z.number().int().min(1).max(10).default(3),
19
- });
20
-
21
- export const contestsSection: SectionDefinition<z.infer<typeof configSchema>> = {
13
+ export const contestsSection: SectionDefinition<ContestsConfig> = {
22
14
  type: 'contests',
23
15
  name: 'Contests',
24
- description: 'Active contests with deadlines (feature-gated)',
16
+ description: 'Active contests with deadlines (uses ContestsSection)',
25
17
  icon: 'fa-trophy',
26
18
  category: 'data',
27
19
  status: 'stable',
28
- // Palette gate — see hubs.ts for the rationale.
29
20
  featureGate: 'contests',
30
- configSchema,
31
- defaultConfig: { heading: 'Active Contests', limit: 3 },
21
+ configSchema: contestsConfigSchema,
22
+ defaultConfig: { limit: 3 },
32
23
  schemaVersion: 1,
33
- component: SectionContests,
24
+ component: ContestsSection,
25
+ propMap: ({ config }) => ({ config }),
34
26
  minColSpan: 3,
35
27
  maxColSpan: 12,
36
28
  defaultColSpan: 4,
@@ -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
+ };
@@ -1,48 +1,34 @@
1
1
  /**
2
2
  * Built-in section definition: custom-html.
3
3
  *
4
- * Phase 1c addition (session 159) admin-only raw HTML escape hatch.
4
+ * Stage E.4reuses the existing CustomHtmlSection (renders
5
+ * `config.html` via `v-html` with an optional title).
5
6
  *
6
- * **SECURITY POSTURE** important. This section renders config.html via
7
- * `v-html` with NO runtime sanitization, intentionally matching the
8
- * legacy `CustomHtmlSection.vue` behavior (already shipped in
9
- * production for the configurable homepage path). The threat model:
10
- * - Writes are gated on `requireAdmin(event)` in
11
- * `/api/admin/layouts/*` — only trusted admin users can set HTML.
12
- * - Even so, a compromised admin account → stored XSS on every
13
- * visitor's homepage. Phase 6b will add server-side DOMPurify
14
- * sanitization at admin-write time (matching the pattern in
15
- * `packages/server/src/content/content.ts:sanitizeBlockContent`)
16
- * and an `unsafeHtmlAllowed` instance setting that gates whether
17
- * this section type can be saved at all.
18
- * - For now: `status: 'beta'` flags the risk to admin-UI consumers.
19
- * The 50KB cap is a sanity bound, not a security control.
20
- * - Tracked in docs/plans/layout-and-pages.md §6.5 (custom-html
21
- * sanitization roadmap).
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`).
22
12
  *
23
- * Use this only when no other section type fits.
13
+ * Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
24
14
  */
25
- import { z } from 'zod';
26
15
  import type { SectionDefinition } from '@commonpub/ui';
27
- import SectionCustomHtml from '../../components/sections/SectionCustomHtml.vue';
16
+ import { customHtmlConfigSchema, type CustomHtmlConfig } from '@commonpub/schema';
17
+ import CustomHtmlSection from '../../components/homepage/CustomHtmlSection.vue';
28
18
 
29
- const configSchema = z.object({
30
- heading: z.string().max(120).default(''),
31
- html: z.string().max(50_000).default(''),
32
- });
33
-
34
- export const customHtmlSection: SectionDefinition<z.infer<typeof configSchema>> = {
19
+ export const customHtmlSection: SectionDefinition<CustomHtmlConfig> = {
35
20
  type: 'custom-html',
36
21
  name: 'Custom HTML',
37
- description: 'Raw HTML escape hatch admin-only; see security note in source',
22
+ description: 'Raw HTML escape hatch (uses CustomHtmlSection)',
38
23
  icon: 'fa-code',
39
24
  category: 'content',
40
- // Beta marks the unsanitised render path; admin UI shows a warning chip
41
25
  status: 'beta',
42
- configSchema,
26
+ configSchema: customHtmlConfigSchema,
43
27
  defaultConfig: { heading: '', html: '' },
44
28
  schemaVersion: 1,
45
- component: SectionCustomHtml,
29
+ component: CustomHtmlSection,
30
+ // CustomHtmlSection takes { config: HomepageSectionConfig; title?: string }
31
+ propMap: ({ config }) => ({ config, title: (config.heading as string | undefined) ?? '' }),
46
32
  minColSpan: 3,
47
33
  maxColSpan: 12,
48
34
  defaultColSpan: 12,