@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,419 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * AdminLayoutsAutoForm — the recursive native-input renderer for a
4
+ * normalized `AutoFormField[]` (Phase 3e). Controlled component: takes
5
+ * `modelValue` (the config object), renders one control per field, emits
6
+ * `update:modelValue` with a fresh object on every edit. Recurses into
7
+ * itself for `group` (nested object) + `array` (repeater) controls.
8
+ *
9
+ * Shared by `<AdminLayoutsInspectorSection>` (section config) +
10
+ * `<AdminLayoutsInspectorRow>` (row config) — one code path serves every
11
+ * registered section's auto-form. Reuses the `cpub-inspector-page-*`
12
+ * design language (mono uppercase labels, 2px borders, sharp corners) via
13
+ * its own `cpub-autoform-*` classes so it composes the same way without
14
+ * structurally coupling to the page-meta form (feedback-view-identity-classes).
15
+ *
16
+ * Errors: `errors` is a flat map keyed by DOT-joined config path
17
+ * (`ctas.0.href`). Each field looks itself up via the accumulated
18
+ * `pathPrefix`. Surfaced inline; the section validates against the Zod
19
+ * schema and passes the map down.
20
+ *
21
+ * Pure-presentation: zero hardcoded colors/fonts (CLAUDE.md #3). No
22
+ * pointer-events / visibility cascades on interactive children — the
23
+ * controls are plain native inputs (feedback-css-cascade-unit-test-blind-spot).
24
+ */
25
+ import type { AutoFormField } from '../../../composables/autoFormSchema';
26
+
27
+ defineOptions({ name: 'AdminLayoutsAutoForm' });
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ fields: AutoFormField[];
32
+ modelValue: Record<string, unknown>;
33
+ /** Flat error map keyed by dot-joined config path. */
34
+ errors?: Record<string, string>;
35
+ /** Accumulated path prefix for nested groups/arrays. Root = ''. */
36
+ pathPrefix?: string;
37
+ /** Stable id seed so generated control ids don't collide across forms. */
38
+ idSeed?: string;
39
+ }>(),
40
+ { errors: () => ({}), pathPrefix: '', idSeed: 'autoform' },
41
+ );
42
+
43
+ const emit = defineEmits<{
44
+ (e: 'update:modelValue', value: Record<string, unknown>): void;
45
+ }>();
46
+
47
+ function pathOf(key: string): string {
48
+ return props.pathPrefix ? `${props.pathPrefix}.${key}` : key;
49
+ }
50
+
51
+ function controlId(key: string): string {
52
+ return `cpub-${props.idSeed}-${pathOf(key).replace(/\./g, '-')}`;
53
+ }
54
+
55
+ function errorFor(key: string): string | undefined {
56
+ return props.errors[pathOf(key)];
57
+ }
58
+
59
+ /** Emit a new config object with one key replaced. */
60
+ function setKey(key: string, value: unknown): void {
61
+ emit('update:modelValue', { ...props.modelValue, [key]: value });
62
+ }
63
+
64
+ function valueOf(field: AutoFormField): unknown {
65
+ const v = props.modelValue[field.key];
66
+ return v === undefined ? field.defaultValue : v;
67
+ }
68
+
69
+ // --- scalar handlers (coerce native string/checkbox values per control) ---
70
+
71
+ function onText(field: AutoFormField, e: Event): void {
72
+ setKey(field.key, (e.target as HTMLInputElement | HTMLTextAreaElement).value);
73
+ }
74
+
75
+ function onNumber(field: AutoFormField, e: Event): void {
76
+ const raw = (e.target as HTMLInputElement).value;
77
+ // Empty input → clear back to the default (or undefined) rather than NaN.
78
+ setKey(field.key, raw === '' ? field.defaultValue : Number(raw));
79
+ }
80
+
81
+ function onToggle(field: AutoFormField, e: Event): void {
82
+ setKey(field.key, (e.target as HTMLInputElement).checked);
83
+ }
84
+
85
+ function onSelect(field: AutoFormField, e: Event): void {
86
+ const raw = (e.target as HTMLSelectElement).value;
87
+ // The leading "— Default —" option (optional fields) clears the key so
88
+ // the renderer falls back to its own default instead of a forced choice.
89
+ if (raw === '' && field.optional) {
90
+ setKey(field.key, undefined);
91
+ return;
92
+ }
93
+ // Numeric-const selects (heading.level, columns) must store numbers —
94
+ // <select> always yields strings. Coerce when every option is numeric.
95
+ const numeric = field.options?.every((o) => typeof o.value === 'number');
96
+ setKey(field.key, numeric ? Number(raw) : raw);
97
+ }
98
+
99
+ // --- array (repeater) handlers ---
100
+
101
+ function arrayValue(field: AutoFormField): Record<string, unknown>[] {
102
+ const v = props.modelValue[field.key];
103
+ return Array.isArray(v) ? (v as Record<string, unknown>[]) : [];
104
+ }
105
+
106
+ function addItem(field: AutoFormField): void {
107
+ const items = arrayValue(field);
108
+ if (field.maxItems !== undefined && items.length >= field.maxItems) return;
109
+ const blank = JSON.parse(JSON.stringify(field.itemDefault ?? {}));
110
+ setKey(field.key, [...items, blank]);
111
+ }
112
+
113
+ function removeItem(field: AutoFormField, index: number): void {
114
+ const items = arrayValue(field);
115
+ setKey(field.key, items.filter((_, i) => i !== index));
116
+ }
117
+
118
+ function updateItem(field: AutoFormField, index: number, value: Record<string, unknown>): void {
119
+ const items = arrayValue(field);
120
+ setKey(field.key, items.map((it, i) => (i === index ? value : it)));
121
+ }
122
+
123
+ function groupValue(field: AutoFormField): Record<string, unknown> {
124
+ const v = props.modelValue[field.key];
125
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
126
+ }
127
+ </script>
128
+
129
+ <template>
130
+ <div class="cpub-autoform">
131
+ <template v-for="f in fields" :key="f.key">
132
+ <!-- TOGGLE (boolean) -->
133
+ <div v-if="f.control === 'toggle'" class="cpub-autoform-checkbox">
134
+ <input
135
+ :id="controlId(f.key)"
136
+ type="checkbox"
137
+ :checked="!!valueOf(f)"
138
+ @change="onToggle(f, $event)"
139
+ />
140
+ <label :for="controlId(f.key)">{{ f.label }}</label>
141
+ </div>
142
+
143
+ <!-- ARRAY (repeater of object items) -->
144
+ <fieldset v-else-if="f.control === 'array'" class="cpub-autoform-array">
145
+ <legend class="cpub-autoform-label">
146
+ {{ f.label }}
147
+ <span v-if="f.maxItems !== undefined" class="cpub-autoform-count">
148
+ {{ arrayValue(f).length }}/{{ f.maxItems }}
149
+ </span>
150
+ </legend>
151
+
152
+ <p v-if="arrayValue(f).length === 0" class="cpub-autoform-empty">
153
+ No {{ f.label.toLowerCase() }} yet.
154
+ </p>
155
+
156
+ <div
157
+ v-for="(item, i) in arrayValue(f)"
158
+ :key="i"
159
+ class="cpub-autoform-array-item"
160
+ >
161
+ <div class="cpub-autoform-array-item-head">
162
+ <span class="cpub-autoform-array-item-index">{{ i + 1 }}</span>
163
+ <button
164
+ type="button"
165
+ class="cpub-autoform-array-remove"
166
+ :aria-label="`Remove ${f.label.toLowerCase()} ${i + 1}`"
167
+ @click="removeItem(f, i)"
168
+ >
169
+ <i class="fa-solid fa-trash-can" aria-hidden="true"></i>
170
+ </button>
171
+ </div>
172
+ <AdminLayoutsAutoForm
173
+ :fields="f.itemFields ?? []"
174
+ :model-value="item"
175
+ :errors="errors"
176
+ :path-prefix="`${pathOf(f.key)}.${i}`"
177
+ :id-seed="idSeed"
178
+ @update:model-value="updateItem(f, i, $event)"
179
+ />
180
+ </div>
181
+
182
+ <button
183
+ type="button"
184
+ class="cpub-autoform-array-add"
185
+ :disabled="f.maxItems !== undefined && arrayValue(f).length >= f.maxItems"
186
+ @click="addItem(f)"
187
+ >
188
+ <i class="fa-solid fa-plus" aria-hidden="true"></i>
189
+ Add {{ f.label.toLowerCase().replace(/s$/, '') }}
190
+ </button>
191
+ </fieldset>
192
+
193
+ <!-- GROUP (nested object) -->
194
+ <fieldset v-else-if="f.control === 'group'" class="cpub-autoform-group">
195
+ <legend class="cpub-autoform-label">{{ f.label }}</legend>
196
+ <AdminLayoutsAutoForm
197
+ :fields="f.fields ?? []"
198
+ :model-value="groupValue(f)"
199
+ :errors="errors"
200
+ :path-prefix="pathOf(f.key)"
201
+ :id-seed="idSeed"
202
+ @update:model-value="setKey(f.key, $event)"
203
+ />
204
+ </fieldset>
205
+
206
+ <!-- SCALAR FIELDS (text / textarea / number / select / unsupported) -->
207
+ <div v-else class="cpub-autoform-field">
208
+ <label :for="controlId(f.key)" class="cpub-autoform-label">
209
+ {{ f.label }}
210
+ <span v-if="f.required" class="cpub-autoform-required" aria-hidden="true">*</span>
211
+ </label>
212
+
213
+ <select
214
+ v-if="f.control === 'select'"
215
+ :id="controlId(f.key)"
216
+ :value="valueOf(f) ?? ''"
217
+ :aria-invalid="!!errorFor(f.key)"
218
+ :aria-describedby="errorFor(f.key) ? `${controlId(f.key)}-err` : undefined"
219
+ @change="onSelect(f, $event)"
220
+ >
221
+ <!-- Optional fields (no default) get a leading unset option so an
222
+ undefined value reads as "default", not the first real choice. -->
223
+ <option v-if="f.optional" value="">— Default —</option>
224
+ <option v-for="opt in f.options" :key="String(opt.value)" :value="opt.value">
225
+ {{ opt.label }}
226
+ </option>
227
+ </select>
228
+
229
+ <textarea
230
+ v-else-if="f.control === 'textarea'"
231
+ :id="controlId(f.key)"
232
+ :value="String(valueOf(f) ?? '')"
233
+ :maxlength="f.maxLength"
234
+ rows="4"
235
+ :aria-invalid="!!errorFor(f.key)"
236
+ :aria-describedby="errorFor(f.key) ? `${controlId(f.key)}-err` : undefined"
237
+ @input="onText(f, $event)"
238
+ ></textarea>
239
+
240
+ <input
241
+ v-else-if="f.control === 'number'"
242
+ :id="controlId(f.key)"
243
+ type="number"
244
+ :value="valueOf(f)"
245
+ :min="f.min"
246
+ :max="f.max"
247
+ :step="f.step"
248
+ :aria-invalid="!!errorFor(f.key)"
249
+ :aria-describedby="errorFor(f.key) ? `${controlId(f.key)}-err` : undefined"
250
+ @input="onNumber(f, $event)"
251
+ />
252
+
253
+ <input
254
+ v-else-if="f.control === 'text'"
255
+ :id="controlId(f.key)"
256
+ type="text"
257
+ :value="String(valueOf(f) ?? '')"
258
+ :maxlength="f.maxLength"
259
+ autocomplete="off"
260
+ :aria-invalid="!!errorFor(f.key)"
261
+ :aria-describedby="errorFor(f.key) ? `${controlId(f.key)}-err` : undefined"
262
+ @input="onText(f, $event)"
263
+ />
264
+
265
+ <!-- unsupported control (forward-compat for new Zod kinds) -->
266
+ <p v-else class="cpub-autoform-unsupported">
267
+ This field type isn’t editable here yet.
268
+ </p>
269
+
270
+ <p v-if="errorFor(f.key)" :id="`${controlId(f.key)}-err`" class="cpub-autoform-error" role="alert">
271
+ {{ errorFor(f.key) }}
272
+ </p>
273
+ </div>
274
+ </template>
275
+ </div>
276
+ </template>
277
+
278
+ <style scoped>
279
+ .cpub-autoform {
280
+ display: flex;
281
+ flex-direction: column;
282
+ gap: var(--space-4);
283
+ }
284
+
285
+ .cpub-autoform-field { display: flex; flex-direction: column; gap: var(--space-1); }
286
+
287
+ .cpub-autoform-label {
288
+ font-family: var(--font-mono);
289
+ font-size: 10px;
290
+ text-transform: uppercase;
291
+ letter-spacing: var(--tracking-wide);
292
+ color: var(--text-dim);
293
+ font-weight: var(--font-weight-semibold);
294
+ }
295
+ .cpub-autoform-required { color: var(--red); margin-left: 2px; }
296
+ .cpub-autoform-count {
297
+ color: var(--text-faint);
298
+ font-weight: var(--font-weight-normal);
299
+ margin-left: var(--space-1);
300
+ }
301
+
302
+ .cpub-autoform-field input,
303
+ .cpub-autoform-field textarea,
304
+ .cpub-autoform-field select {
305
+ padding: var(--space-2) var(--space-3);
306
+ background: var(--surface);
307
+ border: var(--border-width-default) solid var(--border);
308
+ color: var(--text);
309
+ font-family: var(--font-body);
310
+ font-size: var(--text-sm);
311
+ border-radius: 0;
312
+ }
313
+ .cpub-autoform-field textarea { resize: vertical; }
314
+ .cpub-autoform-field input:focus-visible,
315
+ .cpub-autoform-field textarea:focus-visible,
316
+ .cpub-autoform-field select:focus-visible {
317
+ outline: 2px solid var(--accent);
318
+ outline-offset: 1px;
319
+ border-color: var(--accent);
320
+ }
321
+ /* Invalid state — red border + the inline message below carries the why. */
322
+ .cpub-autoform-field input[aria-invalid='true'],
323
+ .cpub-autoform-field textarea[aria-invalid='true'],
324
+ .cpub-autoform-field select[aria-invalid='true'] {
325
+ border-color: var(--red);
326
+ }
327
+ .cpub-autoform-error {
328
+ font-size: var(--text-xs);
329
+ color: var(--red);
330
+ margin: 0;
331
+ }
332
+ .cpub-autoform-unsupported {
333
+ font-size: var(--text-xs);
334
+ color: var(--text-faint);
335
+ font-style: italic;
336
+ margin: 0;
337
+ }
338
+
339
+ .cpub-autoform-checkbox { display: flex; align-items: center; gap: var(--space-2); }
340
+ .cpub-autoform-checkbox label { font-size: var(--text-sm); color: var(--text); }
341
+ .cpub-autoform-checkbox input { cursor: pointer; }
342
+
343
+ /* Array repeater + nested group share a bordered card so the nesting is
344
+ visually obvious without indentation drift. */
345
+ .cpub-autoform-array,
346
+ .cpub-autoform-group {
347
+ display: flex;
348
+ flex-direction: column;
349
+ gap: var(--space-3);
350
+ margin: 0;
351
+ /* fieldset defaults to min-inline-size: min-content, which refuses to
352
+ shrink below its content + overflows the 320px inspector. Force it to
353
+ shrink with its container. */
354
+ min-width: 0;
355
+ padding: var(--space-3);
356
+ border: var(--border-width-default) solid var(--border2);
357
+ border-radius: 0;
358
+ }
359
+ .cpub-autoform-array legend,
360
+ .cpub-autoform-group legend { padding: 0 var(--space-1); }
361
+
362
+ .cpub-autoform-empty {
363
+ font-size: var(--text-xs);
364
+ color: var(--text-faint);
365
+ margin: 0;
366
+ }
367
+
368
+ .cpub-autoform-array-item {
369
+ display: flex;
370
+ flex-direction: column;
371
+ gap: var(--space-2);
372
+ padding: var(--space-3);
373
+ background: var(--surface2);
374
+ border: 1px solid var(--border2);
375
+ }
376
+ .cpub-autoform-array-item-head {
377
+ display: flex;
378
+ align-items: center;
379
+ justify-content: space-between;
380
+ }
381
+ .cpub-autoform-array-item-index {
382
+ font-family: var(--font-mono);
383
+ font-size: var(--text-xs);
384
+ color: var(--text-dim);
385
+ }
386
+ .cpub-autoform-array-remove {
387
+ width: 28px;
388
+ height: 28px;
389
+ display: inline-flex;
390
+ align-items: center;
391
+ justify-content: center;
392
+ background: transparent;
393
+ border: 1px solid var(--border2);
394
+ color: var(--text-dim);
395
+ cursor: pointer;
396
+ font-size: var(--text-xs);
397
+ }
398
+ .cpub-autoform-array-remove:hover { color: var(--red); border-color: var(--red); }
399
+ .cpub-autoform-array-remove:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
400
+
401
+ .cpub-autoform-array-add {
402
+ align-self: flex-start;
403
+ display: inline-flex;
404
+ align-items: center;
405
+ gap: var(--space-1);
406
+ padding: var(--space-1) var(--space-3);
407
+ background: var(--surface);
408
+ border: var(--border-width-default) solid var(--border);
409
+ color: var(--text);
410
+ font-family: var(--font-mono);
411
+ font-size: var(--text-xs);
412
+ text-transform: uppercase;
413
+ letter-spacing: var(--tracking-wide);
414
+ cursor: pointer;
415
+ }
416
+ .cpub-autoform-array-add:hover:not(:disabled) { background: var(--surface2); border-color: var(--accent); color: var(--accent); }
417
+ .cpub-autoform-array-add:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
418
+ .cpub-autoform-array-add:disabled { opacity: 0.5; cursor: not-allowed; }
419
+ </style>