@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,117 @@
1
+ /**
2
+ * useLayoutAutoSave — debounced auto-save for the layout editor.
3
+ *
4
+ * Phase 3a.6 + session-160 audit polish. Two complementary triggers:
5
+ * 1. Debounce: watch a dirty flag; on first dirt, wait `debounceMs`
6
+ * (default 1500 per docs/plans/layout-and-pages.md §7.13) then
7
+ * save. Further edits within the window reset the timer.
8
+ * 2. Visibility-change flush: when the tab becomes hidden (Cmd+Tab,
9
+ * tab close intent, minimize) and the draft is dirty, fire an
10
+ * immediate save. This is the safety net for users who edit
11
+ * then close the tab during the debounce window.
12
+ *
13
+ * Caller (the editor page) owns:
14
+ * - the dirty ref (from useLayoutEditor)
15
+ * - the save fn (from useLayoutEditor)
16
+ * - error/conflict handling — save() throws on 409 and the page
17
+ * catches it; auto-save itself just swallows + lets the
18
+ * editor.status reflect the result
19
+ *
20
+ * Per UX research synthesis (session 160 audit): debounce alone loses
21
+ * data when the user Cmd-W's during the window; blur alone misses
22
+ * idle-keyboard edits; both together gives a "nothing was lost"
23
+ * mental model.
24
+ *
25
+ * The composable returns a `cancel()` for tests + manual pause; the
26
+ * timer is automatically cleared on component unmount.
27
+ */
28
+ import { onBeforeUnmount, onMounted, watch, type ComputedRef, type Ref } from 'vue';
29
+
30
+ export interface UseLayoutAutoSaveOptions {
31
+ /** Reactive dirty flag — when true, schedule a save. */
32
+ dirty: ComputedRef<boolean> | Ref<boolean>;
33
+ /** Save function — called on debounce-fire. */
34
+ save: () => Promise<void>;
35
+ /** Debounce window in ms. Default 1500. */
36
+ debounceMs?: number;
37
+ /** When true, skip scheduling entirely (e.g. user toggled auto-save off). */
38
+ paused?: Ref<boolean>;
39
+ }
40
+
41
+ export interface UseLayoutAutoSaveResult {
42
+ /** Stop the pending timer + ignore subsequent dirt until explicitly resumed. */
43
+ cancel: () => void;
44
+ }
45
+
46
+ export function useLayoutAutoSave(opts: UseLayoutAutoSaveOptions): UseLayoutAutoSaveResult {
47
+ const debounceMs = opts.debounceMs ?? 1500;
48
+ let timer: ReturnType<typeof setTimeout> | null = null;
49
+
50
+ function cancel(): void {
51
+ if (timer !== null) {
52
+ clearTimeout(timer);
53
+ timer = null;
54
+ }
55
+ }
56
+
57
+ watch(
58
+ opts.dirty,
59
+ (isDirty) => {
60
+ cancel();
61
+ if (!isDirty) return;
62
+ if (opts.paused?.value) return;
63
+ timer = setTimeout(() => {
64
+ timer = null;
65
+ // Errors are surfaced via the save() side-effects (editor.status,
66
+ // toasts). Swallow here so the watcher doesn't reject.
67
+ void opts.save().catch(() => {
68
+ /* handled by save()'s status setter */
69
+ });
70
+ }, debounceMs);
71
+ },
72
+ { flush: 'post' },
73
+ );
74
+
75
+ /**
76
+ * Visibility-change flush: when the tab is being hidden AND the draft
77
+ * is dirty, cancel the pending debounce and save IMMEDIATELY. This
78
+ * protects against data loss when the user Cmd+Tab's or closes the
79
+ * tab during the debounce window.
80
+ *
81
+ * `document.visibilityState === 'hidden'` fires reliably across modern
82
+ * browsers (per CanIUse: 100% support). The save() call is async and
83
+ * returns a promise that we don't await — the browser may not give
84
+ * us time to finish, but firing the request is better than not.
85
+ *
86
+ * The "REAL safety" path (request that survives page teardown) is
87
+ * now wired separately: session 162 P2.3 added `editor.flushBeacon()`
88
+ * (fetch with `keepalive:true`) which the editor page calls from a
89
+ * `pagehide` listener. visibilitychange is the fast path; pagehide-
90
+ * +-beacon is the safety net for the actual teardown.
91
+ */
92
+ function onVisibilityChange(): void {
93
+ if (typeof document === 'undefined') return;
94
+ if (document.visibilityState !== 'hidden') return;
95
+ if (opts.paused?.value) return;
96
+ // Only flush if there's a pending dirty save
97
+ const isDirty = (opts.dirty as { value: boolean }).value;
98
+ if (!isDirty) return;
99
+ cancel();
100
+ void opts.save().catch(() => { /* handled */ });
101
+ }
102
+
103
+ onMounted(() => {
104
+ if (typeof document !== 'undefined') {
105
+ document.addEventListener('visibilitychange', onVisibilityChange);
106
+ }
107
+ });
108
+
109
+ onBeforeUnmount(() => {
110
+ cancel();
111
+ if (typeof document !== 'undefined') {
112
+ document.removeEventListener('visibilitychange', onVisibilityChange);
113
+ }
114
+ });
115
+
116
+ return { cancel };
117
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * useLayoutDrag — pure drag-drop dispatcher logic.
3
+ *
4
+ * Phase 3b/A. Owns the "what to do when X drops on Y" semantics so the
5
+ * component wiring (`<LayoutRow>` + palette tile in upcoming commits)
6
+ * stays a thin shell over these pure functions. Two motivations:
7
+ *
8
+ * 1. **Testability** — dispatcher behavior is unit-tested with plain
9
+ * objects + no dnd-kit infrastructure. The component layer only
10
+ * proves `makeDroppable` is called with the right options.
11
+ * 2. **One source of truth** — when 3b/B adds cross-zone drag + when
12
+ * 3f adds drag-from-inspector, the same dispatcher routes them.
13
+ * No "every component reinvents the drop semantics" drift.
14
+ *
15
+ * Drag payload shape: every drag carries `{ kind, ...details }` so the
16
+ * row's onDrop handler can branch on `kind` without sniffing arbitrary
17
+ * data. Palette tiles carry the section's registry def; section
18
+ * instances carry the section + source row id.
19
+ *
20
+ * Mutations are applied DIRECTLY to the row.sections array. The editor's
21
+ * deep watcher on `editor.draft.value` picks them up + bumps dirty +
22
+ * the existing auto-save composable schedules a save within 1.5s. No
23
+ * parallel save path — Phase 3b/A kickoff rule.
24
+ */
25
+ import type { LayoutSection, LayoutRow } from './useLayout';
26
+ import type { SectionDefinition } from '@commonpub/ui';
27
+ import type { IDragEvent } from '@vue-dnd-kit/core';
28
+
29
+ /* ------------------------------------------------------------------ */
30
+ /* Drag payload taxonomy */
31
+ /* ------------------------------------------------------------------ */
32
+
33
+ /**
34
+ * Discriminator on every drag payload. Matched by a fast switch in
35
+ * `dispatchSectionDrop` — adding a new kind is one literal + one case.
36
+ */
37
+ export type DragPayloadKind = 'palette-section-spec' | 'section-instance';
38
+
39
+ /**
40
+ * Payload carried by a palette tile drag. The tile knows the section's
41
+ * registry def; the drop handler asks the registry to mint a new
42
+ * `LayoutSection` from the def's `defaultConfig` + `defaultColSpan`.
43
+ *
44
+ * We pass the FULL def (not just `type`) because the registry isn't
45
+ * trivially accessible from every drop handler — the row may not have
46
+ * the registry plumbed in. Passing the def avoids an extra dependency.
47
+ */
48
+ export interface PaletteSectionDragPayload {
49
+ kind: 'palette-section-spec';
50
+ /** Section type slug — matches the def's `type`. */
51
+ sectionType: string;
52
+ /** Default config + colSpan to mint the new section from. */
53
+ defaultConfig: Record<string, unknown>;
54
+ defaultColSpan: number;
55
+ schemaVersion: number;
56
+ }
57
+
58
+ /**
59
+ * Payload carried by a section-instance drag (drag a section from one
60
+ * row to reorder or move to another row). 3b/A scope is within-row
61
+ * reorder; 3b/B adds cross-row + cross-zone.
62
+ *
63
+ * **The dispatcher MUST look up by `section.id`, not by `section`
64
+ * reference identity.** The envelope holds a reference at drag-start;
65
+ * if a concurrent `editor.refresh()` happens mid-drag (another admin's
66
+ * save + the user clicks "Reload their version" in the conflict modal),
67
+ * the referenced `section` becomes a phantom. Id-based lookup against
68
+ * the LIVE `row.sections` array tolerates the swap. Tests cover the
69
+ * 'section-not-found' noop branch.
70
+ */
71
+ export interface SectionInstanceDragPayload {
72
+ kind: 'section-instance';
73
+ /** The section being dragged. Held by REFERENCE — the dispatcher
74
+ * uses `.id` to look up the live position in `row.sections`,
75
+ * never the reference directly. See class-comment above. */
76
+ section: LayoutSection;
77
+ /** The row this section currently lives in — needed for cross-row
78
+ * moves so the dispatcher can remove it from its source. */
79
+ fromRowId: string;
80
+ }
81
+
82
+ export type DragPayload = PaletteSectionDragPayload | SectionInstanceDragPayload;
83
+
84
+ /* ------------------------------------------------------------------ */
85
+ /* Section factory */
86
+ /* ------------------------------------------------------------------ */
87
+
88
+ /**
89
+ * Mint a new `LayoutSection` from a registry definition. Uses the
90
+ * project's `crypto.randomUUID()` idiom (matches packages/server,
91
+ * layers/base/pages/learn/.../edit.vue). `enabled: true` so a fresh
92
+ * drop is immediately visible; `responsive: null` defers per-breakpoint
93
+ * tuning to Phase 3f's inspector.
94
+ *
95
+ * Pure — takes a def + returns a section. No mutation of inputs.
96
+ */
97
+ export function createSectionFromSpec(def: PaletteSectionDragPayload): LayoutSection {
98
+ return {
99
+ id: crypto.randomUUID(),
100
+ order: 0, // server-side write handler renumbers; client value isn't authoritative
101
+ type: def.sectionType,
102
+ config: { ...def.defaultConfig },
103
+ colSpan: def.defaultColSpan,
104
+ responsive: null,
105
+ enabled: true,
106
+ visibility: null,
107
+ schemaVersion: def.schemaVersion,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Build a palette drag payload from a registry section def. Called by
113
+ * `<AdminLayoutsPalette>` when wiring `makeDraggable` on each tile.
114
+ * Lives here so the drop handler's expectation + the drag source's
115
+ * production stay in lockstep.
116
+ */
117
+ export function paletteDragPayload(def: SectionDefinition): PaletteSectionDragPayload {
118
+ return {
119
+ kind: 'palette-section-spec',
120
+ sectionType: def.type,
121
+ defaultConfig: { ...def.defaultConfig },
122
+ defaultColSpan: def.defaultColSpan,
123
+ schemaVersion: def.schemaVersion,
124
+ };
125
+ }
126
+
127
+ /* ------------------------------------------------------------------ */
128
+ /* Insert-index computation */
129
+ /* ------------------------------------------------------------------ */
130
+
131
+ /**
132
+ * Where in `row.sections` to insert the dropped item. Read from
133
+ * dnd-kit's `event.hoveredDraggable` (the section currently under the
134
+ * cursor inside the row):
135
+ *
136
+ * - No hovered draggable → append to the end (drop on empty area).
137
+ * - Hovered draggable with placement.left/top → insert BEFORE it.
138
+ * - Hovered draggable with placement.right/bottom → insert AFTER it.
139
+ *
140
+ * Rows are horizontal; `left`/`right` are the primary signals.
141
+ * `top`/`bottom` are checked as fallbacks for vertical-list mode (which
142
+ * we'll use for cross-row reordering in 3b/B's stacked-zones layout —
143
+ * the computation is reused there).
144
+ *
145
+ * Pure. Takes the event + the fallback length. Returns an integer in
146
+ * `[0, fallbackLen]` (the half-open range that `Array.prototype.splice`
147
+ * accepts as an insert position).
148
+ */
149
+ export function computeInsertIndex(event: IDragEvent, fallbackLen: number): number {
150
+ const hovered = event.hoveredDraggable;
151
+ if (!hovered) return fallbackLen; // append
152
+ const insertBefore = hovered.placement.left || hovered.placement.top;
153
+ return insertBefore ? hovered.index : hovered.index + 1;
154
+ }
155
+
156
+ /* ------------------------------------------------------------------ */
157
+ /* Drop dispatcher */
158
+ /* ------------------------------------------------------------------ */
159
+
160
+ /**
161
+ * Outcome of a dispatched drop — useful for callers that want to
162
+ * narrate the result (ARIA live region, audit log, telemetry) without
163
+ * sniffing the row's array.
164
+ *
165
+ * Phase 3b/B adds `'moved'` for cross-row + cross-zone. The caller
166
+ * uses `fromRowId` + `toRowId` to build a moveSectionCommand for the
167
+ * undo stack + to look up zone slugs for narration.
168
+ */
169
+ export type DropOutcome =
170
+ | { kind: 'inserted'; section: LayoutSection; at: number }
171
+ | { kind: 'reordered'; section: LayoutSection; from: number; to: number }
172
+ | {
173
+ kind: 'moved';
174
+ section: LayoutSection;
175
+ fromRowId: string;
176
+ fromIdx: number;
177
+ fromTotal: number;
178
+ toRowId: string;
179
+ toIdx: number;
180
+ toTotal: number;
181
+ }
182
+ | { kind: 'noop'; reason: string };
183
+
184
+ /**
185
+ * Context the dispatcher needs for CROSS-ROW operations. Optional so
186
+ * within-row + palette callers (which don't need to look up other
187
+ * rows) can still call the dispatcher with a 2-arg signature.
188
+ *
189
+ * When `findRow` is omitted OR returns null for the source row, a
190
+ * cross-row drop falls back to noop (with reason `'no-find-row'` or
191
+ * `'source-row-not-found'`). This makes the cross-zone wiring an
192
+ * opt-in: callers in old contexts (single-row tests) still work.
193
+ */
194
+ export interface DispatchContext {
195
+ /** Look up a row anywhere in the draft by id. Returns null if the
196
+ * row doesn't exist (or the caller chose not to support cross-row). */
197
+ findRow?: (rowId: string) => LayoutRow | null;
198
+ }
199
+
200
+ /**
201
+ * Apply a drop to a row. The row's `sections` array is mutated in
202
+ * place — the editor's deep watcher picks it up + auto-save fires.
203
+ *
204
+ * Phase 3b/B scope:
205
+ * - palette-section-spec → splice in a fresh section at the computed
206
+ * insert index.
207
+ * - section-instance dragged FROM THIS SAME ROW → reorder in place
208
+ * via splice-remove + splice-insert (sameList per dnd-kit's model).
209
+ * - section-instance dragged FROM A DIFFERENT ROW + ctx.findRow
210
+ * provides the source → remove from source, insert into destination
211
+ * row. Cross-row + cross-zone share this branch (a "different
212
+ * row" may be in another zone).
213
+ *
214
+ * Returns a DropOutcome describing what happened. `noop` outcomes have
215
+ * a `reason` for diagnostics — surfaced in tests + (optionally) audit
216
+ * logs.
217
+ */
218
+ export function dispatchSectionDrop(
219
+ event: IDragEvent,
220
+ row: LayoutRow,
221
+ ctx: DispatchContext = {},
222
+ ): DropOutcome {
223
+ const item = event.draggedItems[0]?.item as DragPayload | undefined;
224
+ if (!item) return { kind: 'noop', reason: 'no-dragged-item' };
225
+
226
+ if (item.kind === 'palette-section-spec') {
227
+ const newSection = createSectionFromSpec(item);
228
+ const insertAt = computeInsertIndex(event, row.sections.length);
229
+ row.sections.splice(insertAt, 0, newSection);
230
+ return { kind: 'inserted', section: newSection, at: insertAt };
231
+ }
232
+
233
+ if (item.kind === 'section-instance') {
234
+ if (item.fromRowId !== row.id) {
235
+ // Cross-row drag. Needs the source row to splice out of.
236
+ // Without ctx.findRow, we can't reach it — fall back to noop.
237
+ if (!ctx.findRow) {
238
+ return { kind: 'noop', reason: 'no-find-row' };
239
+ }
240
+ const sourceRow = ctx.findRow(item.fromRowId);
241
+ if (!sourceRow) {
242
+ return { kind: 'noop', reason: 'source-row-not-found' };
243
+ }
244
+ const fromIdx = sourceRow.sections.findIndex((s) => s.id === item.section.id);
245
+ if (fromIdx === -1) {
246
+ // The dragged section isn't in the source row — defensive
247
+ // against a stale payload or concurrent edit.
248
+ return { kind: 'noop', reason: 'section-not-found' };
249
+ }
250
+ const fromTotal = sourceRow.sections.length;
251
+ // Target index in the DESTINATION row (which is `row`, separate
252
+ // from source). No adjustment needed — the splice on source
253
+ // doesn't shift indices in the destination.
254
+ const targetIdx = computeInsertIndex(event, row.sections.length);
255
+ const [moved] = sourceRow.sections.splice(fromIdx, 1);
256
+ if (!moved) return { kind: 'noop', reason: 'section-vanished-mid-splice' };
257
+ row.sections.splice(targetIdx, 0, moved);
258
+ return {
259
+ kind: 'moved',
260
+ section: moved,
261
+ fromRowId: sourceRow.id,
262
+ fromIdx,
263
+ fromTotal,
264
+ toRowId: row.id,
265
+ toIdx: targetIdx,
266
+ toTotal: row.sections.length,
267
+ };
268
+ }
269
+ const fromIdx = row.sections.findIndex((s) => s.id === item.section.id);
270
+ if (fromIdx === -1) {
271
+ // The dragged section isn't in this row's array — defensive
272
+ // against a stale payload or a concurrent edit that removed it.
273
+ return { kind: 'noop', reason: 'section-not-found' };
274
+ }
275
+ // Compute the target index using the row's CURRENT sections (the
276
+ // dragged section is still there). After splice-remove the indices
277
+ // shift down by 1 for positions after fromIdx — adjust on insert.
278
+ const targetIdx = computeInsertIndex(event, row.sections.length);
279
+ const [moved] = row.sections.splice(fromIdx, 1);
280
+ // Adjust if removing the source shifted the target.
281
+ const adjustedTarget = targetIdx > fromIdx ? targetIdx - 1 : targetIdx;
282
+ if (!moved) return { kind: 'noop', reason: 'section-vanished-mid-splice' };
283
+ row.sections.splice(adjustedTarget, 0, moved);
284
+ return { kind: 'reordered', section: moved, from: fromIdx, to: adjustedTarget };
285
+ }
286
+
287
+ // Exhaustive switch: TS narrows `item` to `never` here. Any new kind
288
+ // will surface as a compile error → forces an explicit case.
289
+ return { kind: 'noop', reason: 'unknown-drag-kind' };
290
+ }