@commonpub/layer 0.24.0 → 0.25.1

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 +11 -8
  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,366 @@
1
+ /**
2
+ * useLayoutHotkeys — keyboard shortcuts for the layout editor.
3
+ *
4
+ * Phase 3b/B (3b.8): Cmd/Ctrl+Z = undo, Cmd/Ctrl+Shift+Z = redo.
5
+ * Phase 3d.1: Backspace / Delete = remove the selected section.
6
+ * Phase 3d.2: Cmd/Ctrl+D = duplicate the selected section.
7
+ * Phase 3d.3: ? (Shift+/) = show the help overlay.
8
+ *
9
+ * Deliberate non-binding: Cmd/Ctrl+Y. Notion, Linear, Figma, VS Code,
10
+ * Google Docs all settled on Shift+Z for redo on Mac (where Cmd+Y is
11
+ * "Show History"). Windows users may still expect Ctrl+Y, but matching
12
+ * the modern editor convention (Shift+Z BOTH places) is what the
13
+ * `feedback-match-established-pattern` memory points at.
14
+ *
15
+ * Input-field skip: when the focused element is `input`, `textarea`,
16
+ * or `[contenteditable]`, the browser's native undo handles per-field
17
+ * text undo + plain typing (so `?` in a search field stays a `?`).
18
+ * Stealing those keystrokes for layout commands would be user-hostile.
19
+ * We check `e.target` and short-circuit on every binding.
20
+ *
21
+ * SSR: the `typeof window` guard prevents the addEventListener call
22
+ * on server-render. Per `feedback-vitest-import-meta-client-undefined`
23
+ * — `import.meta.client` is a Nuxt build-time replacement that doesn't
24
+ * exist in vitest; `typeof window` is portable.
25
+ *
26
+ * Lifecycle: caller invokes inside `<script setup>` (the editor page);
27
+ * we attach on mount + detach on unmount via the lifecycle hooks. The
28
+ * editor unmount also clears history elsewhere — these are independent
29
+ * cleanups; both must happen.
30
+ */
31
+ import { onBeforeUnmount, onMounted } from 'vue';
32
+ import type { LayoutRecord } from '@commonpub/server';
33
+ import type { LayoutSection } from './useLayout';
34
+ import type { EditorSelection } from './useLayoutEditor';
35
+ import {
36
+ useLayoutHistory,
37
+ removeSectionCommand,
38
+ duplicateSectionCommand,
39
+ findSectionLocation,
40
+ } from './useLayoutHistory';
41
+ import {
42
+ useLayoutAnnouncer,
43
+ narrateUndo,
44
+ narrateRedo,
45
+ narrateUndoEmpty,
46
+ narrateRedoEmpty,
47
+ narrateSectionRemoved,
48
+ narrateSectionDuplicated,
49
+ } from './useLayoutAnnouncer';
50
+ import { useLayoutResize } from './useLayoutResize';
51
+
52
+ export interface UseLayoutHotkeysOptions {
53
+ /** Read the live draft at hotkey-time. Closure so the hotkey handler
54
+ * always sees the current draft even after refresh/discard reassigns
55
+ * the ref. Returns null when no draft is loaded; the hotkey is a
56
+ * silent noop then. */
57
+ getDraft: () => LayoutRecord | null;
58
+ /** Read the current selection at hotkey-time. Optional so the existing
59
+ * Cmd+Z/Cmd+Shift+Z bindings continue to work in tests + call sites
60
+ * that don't care about selection. Returns null when nothing selected;
61
+ * selection-gated hotkeys (Backspace, Cmd+D) become silent noops. */
62
+ getSelection?: () => EditorSelection;
63
+ /** Mutate selection. Backspace removes the section + clears selection
64
+ * (the section is gone). Cmd+D moves selection to the clone so the
65
+ * user can immediately Cmd+D again or arrow-move it. Optional so the
66
+ * composable degrades gracefully when not provided. */
67
+ setSelection?: (sel: EditorSelection) => void;
68
+ /** Toggled by `?` (Shift+/). The editor page mounts the help overlay
69
+ * modal + binds its `open` state to this callback. Optional — when
70
+ * absent, `?` is a silent noop. */
71
+ onShowHelp?: () => void;
72
+ /**
73
+ * Phase 3c — closure resolving per-section resize bounds from the
74
+ * section registry. Returns `null` when the section is not resizable
75
+ * (registry's `resizable: false`) OR not registered — in which case
76
+ * the Shift+Arrow binding is a silent noop. The closure also returns
77
+ * the right neighbour's id + bounds when one exists.
78
+ *
79
+ * Why a closure, not a registry import: useLayoutHotkeys runs in
80
+ * setup() but its handlers fire AT KEYSTROKE TIME, possibly long
81
+ * after setup. The registry instance is mounted as a Vue plugin —
82
+ * its `useSectionRegistry` calls inside a window handler would NOT
83
+ * inject correctly. Passing in a closure that the editor page builds
84
+ * (where the registry IS injected) keeps the import clean.
85
+ *
86
+ * When absent, Shift+Arrow is a no-op (degrade gracefully — same
87
+ * shape as the other selection-gated hotkeys).
88
+ */
89
+ lookupResizeBounds?: (sectionId: string) => {
90
+ sectionType: string;
91
+ rowId: string;
92
+ sectionMin: number;
93
+ sectionMax: number;
94
+ /** Null when the section is LAST in its row OR no neighbour
95
+ * registered. Same shape `applyKeyboardResize` expects. */
96
+ neighbour: { sectionId: string; min: number; max: number } | null;
97
+ } | null;
98
+ }
99
+
100
+ export interface UseLayoutHotkeysResult {
101
+ /** Detach the handler imperatively (tests, or manual pause). The
102
+ * default lifecycle hook also detaches on unmount. */
103
+ detach: () => void;
104
+ }
105
+
106
+ /** Is the event's target a text-input surface that owns its own undo
107
+ * + native typing semantics? */
108
+ function isTextInputTarget(target: EventTarget | null): boolean {
109
+ if (!target) return false;
110
+ const el = target as HTMLElement;
111
+ if (typeof el.matches !== 'function') return false;
112
+ // `[contenteditable]` matches both `contenteditable` and
113
+ // `contenteditable="true"`; `[contenteditable="false"]` would be a
114
+ // read-only block, so it's safe to skip via plain attr presence.
115
+ return el.matches('input, textarea, [contenteditable]:not([contenteditable="false"])');
116
+ }
117
+
118
+ /** Cmd on Mac, Ctrl elsewhere. We test BOTH so a user on Mac who
119
+ * remapped Ctrl→Cmd still gets the binding. */
120
+ function isUndoLike(e: KeyboardEvent): boolean {
121
+ return (e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z');
122
+ }
123
+
124
+ /** Backspace OR Delete with no modifiers. macOS sends 'Backspace' for the
125
+ * big delete key + 'Delete' for fn+Backspace; Windows sends 'Delete' for
126
+ * the standalone Del key. Both should fire the same intent — "remove
127
+ * the selected thing". Strictly modifier-free so:
128
+ * - Cmd+Backspace (URL-bar clear in Safari) stays user-controlled
129
+ * - Shift+Backspace (browser back-nav fallback in some browsers, or
130
+ * "delete word" in some editors) doesn't trigger a section remove
131
+ * under the user's elbow (session 165 deep audit R2-A) */
132
+ function isRemoveLike(e: KeyboardEvent): boolean {
133
+ if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return false;
134
+ return e.key === 'Backspace' || e.key === 'Delete';
135
+ }
136
+
137
+ /**
138
+ * Is any modal dialog currently open in the document? Used to suspend
139
+ * global section-mutating hotkeys (Backspace, Cmd+D, Cmd+Z) while a
140
+ * modal owns the user's focus. Without this, pressing Backspace while
141
+ * focused on the HelpOverlay's Close button or the ConflictModal's
142
+ * "Keep editing" button would silently remove the section behind the
143
+ * modal — the user can't even see the change happen.
144
+ *
145
+ * Detects any element with `role="dialog"` or `role="alertdialog"` in
146
+ * the DOM. Vue's `v-if` removes the element when closed, so presence
147
+ * = open. Doesn't require a wired-up callback — the modal's own ARIA
148
+ * attributes are the source of truth. This means new modals added later
149
+ * automatically participate without touching `useLayoutHotkeys`.
150
+ *
151
+ * Session 165 deep audit R3-A.
152
+ */
153
+ function isAnyDialogOpen(): boolean {
154
+ if (typeof document === 'undefined') return false;
155
+ return document.querySelector('[role="dialog"], [role="alertdialog"]') !== null;
156
+ }
157
+
158
+ /** Cmd/Ctrl+D — duplicate. Shift modifier explicitly excluded so
159
+ * Cmd+Shift+D (often "duplicate WITHOUT formatting" in editors,
160
+ * unbound here) doesn't accidentally fire. Browsers bind Cmd+D to
161
+ * "add bookmark" — we preventDefault when we handle, otherwise the
162
+ * user can still bookmark. */
163
+ function isDuplicateLike(e: KeyboardEvent): boolean {
164
+ if (e.shiftKey || e.altKey) return false;
165
+ if (!(e.metaKey || e.ctrlKey)) return false;
166
+ return e.key === 'd' || e.key === 'D';
167
+ }
168
+
169
+ /** `?` (Shift+/ on US keyboards). Cross-keyboard portable because we
170
+ * test the produced character, not the physical key. `key === '?'`
171
+ * fires for any layout that produces a literal question mark. */
172
+ function isHelpLike(e: KeyboardEvent): boolean {
173
+ if (e.metaKey || e.ctrlKey || e.altKey) return false;
174
+ return e.key === '?';
175
+ }
176
+
177
+ /** Shift+ArrowLeft / Shift+ArrowRight = keyboard resize (Phase 3c).
178
+ * Strict-Shift-only so unmodified arrows stay free for future drag-
179
+ * mode arrow navigation (plan §7.8). Cmd/Ctrl excluded so neither
180
+ * "switch tabs" (browser default) nor "move section" (intended for
181
+ * cross-row arrow nav per §7.8) collides with resize semantics. */
182
+ function isResizeLike(e: KeyboardEvent): 'shrink' | 'grow' | null {
183
+ if (e.metaKey || e.ctrlKey || e.altKey) return null;
184
+ if (!e.shiftKey) return null;
185
+ if (e.key === 'ArrowLeft') return 'shrink';
186
+ if (e.key === 'ArrowRight') return 'grow';
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * Should removing a section require a confirm? Heuristic: any
192
+ * authored config makes the section "rich" — the keystroke could
193
+ * destroy real authored content. An empty config (defaults only)
194
+ * is the just-dragged-in placeholder; removing it is recovery, not
195
+ * destruction. Mirrors `onRemoveRow`'s "confirm only when there's
196
+ * content" pattern in the editor page.
197
+ *
198
+ * Cmd+Z restores either way (within the session), so the confirm is
199
+ * a soft guard, not a contract. We still bypass when the section
200
+ * has no config — the keystroke is fast, the undo is fast, and the
201
+ * confirm dialog interrupts an empty-section sweep flow.
202
+ */
203
+ function isRichSection(section: LayoutSection): boolean {
204
+ const cfg = section.config;
205
+ if (!cfg || typeof cfg !== 'object') return false;
206
+ return Object.keys(cfg).length > 0;
207
+ }
208
+
209
+ export function useLayoutHotkeys(opts: UseLayoutHotkeysOptions): UseLayoutHotkeysResult {
210
+ const history = useLayoutHistory();
211
+ const announcer = useLayoutAnnouncer();
212
+ const resize = useLayoutResize();
213
+ let attached = false;
214
+
215
+ function onKeyDown(e: KeyboardEvent): void {
216
+ if (isTextInputTarget(e.target)) return; // text fields own the keystroke
217
+ // Modal-open suspends global hotkeys (session 165 deep audit R3-A).
218
+ // The modal owns the keystroke until dismissed; Esc + the modal's own
219
+ // dismiss-buttons still fire because they're handled in the modal's
220
+ // local listener, not this global one.
221
+ if (isAnyDialogOpen()) return;
222
+
223
+ // --- Undo / Redo (Phase 3b/B) ---
224
+ if (isUndoLike(e)) {
225
+ const draft = opts.getDraft();
226
+ if (!draft) return;
227
+ // Shift modifier reverses direction. Order matters: check Shift
228
+ // FIRST so Cmd+Shift+Z doesn't fall into the undo branch.
229
+ if (e.shiftKey) {
230
+ const cmd = history.redo(draft);
231
+ e.preventDefault();
232
+ announcer.announcePolite(cmd ? narrateRedo(cmd.label) : narrateRedoEmpty());
233
+ return;
234
+ }
235
+ const cmd = history.undo(draft);
236
+ e.preventDefault();
237
+ announcer.announcePolite(cmd ? narrateUndo(cmd.label) : narrateUndoEmpty());
238
+ return;
239
+ }
240
+
241
+ // --- Help overlay (Phase 3d.3) ---
242
+ if (isHelpLike(e)) {
243
+ if (!opts.onShowHelp) return;
244
+ e.preventDefault();
245
+ opts.onShowHelp();
246
+ return;
247
+ }
248
+
249
+ // The remaining bindings (Backspace, Cmd+D, Shift+Arrow) require a
250
+ // section selection. Read once + short-circuit if not applicable.
251
+ const sel = opts.getSelection?.();
252
+ if (!sel || sel.kind !== 'section') return;
253
+ const draft = opts.getDraft();
254
+ if (!draft) return;
255
+ const loc = findSectionLocation(draft, sel.id);
256
+ if (!loc) return; // stale selection — section vanished mid-keydown
257
+
258
+ // --- Keyboard resize (Phase 3c) ---
259
+ // Run BEFORE Backspace/Cmd+D so Shift+ArrowRight doesn't fall through.
260
+ // Modal-open guard already handled at top of handler.
261
+ const resizeDir = isResizeLike(e);
262
+ if (resizeDir !== null) {
263
+ e.preventDefault();
264
+ if (!opts.lookupResizeBounds) return;
265
+ const bounds = opts.lookupResizeBounds(sel.id);
266
+ if (!bounds) return; // not resizable / not registered
267
+ // applyKeyboardResize handles bounds checking + history record +
268
+ // narration; the return value is just for caller's follow-up.
269
+ resize.applyKeyboardResize({
270
+ rowId: bounds.rowId,
271
+ sectionId: sel.id,
272
+ direction: resizeDir,
273
+ getDraft: opts.getDraft,
274
+ sectionMin: bounds.sectionMin,
275
+ sectionMax: bounds.sectionMax,
276
+ sectionType: bounds.sectionType,
277
+ neighbour: bounds.neighbour,
278
+ });
279
+ return;
280
+ }
281
+
282
+ // --- Remove (Phase 3d.1) ---
283
+ if (isRemoveLike(e)) {
284
+ e.preventDefault();
285
+ // Soft confirm only when there's authored content. Empty sections
286
+ // (just-dropped placeholders) skip — the keystroke + Cmd+Z roundtrip
287
+ // is faster than a confirm dialog for the sweep case.
288
+ if (isRichSection(loc.section)) {
289
+ // typeof guard so SSR / jsdom-without-window doesn't blow up.
290
+ const confirmFn = typeof window !== 'undefined' ? window.confirm : () => true;
291
+ const ok = confirmFn(
292
+ `Remove this ${loc.section.type} section? Press Command+Z within this session to restore it.`,
293
+ );
294
+ if (!ok) return;
295
+ }
296
+ // Capture position BEFORE splice so the command's invert can
297
+ // restore at the original index (clamped by the factory).
298
+ const sectionClone = JSON.parse(JSON.stringify(loc.section)) as LayoutSection;
299
+ loc.row.sections.splice(loc.idx, 1);
300
+ announcer.announce(narrateSectionRemoved(loc.section.type, loc.zoneSlug));
301
+ history.record(removeSectionCommand({
302
+ rowId: loc.row.id,
303
+ position: loc.idx,
304
+ section: sectionClone,
305
+ label: `remove ${loc.section.type}`,
306
+ }));
307
+ // Selection points at a section that's no longer in the draft —
308
+ // clear it so the inspector falls back to page-meta + keyboard
309
+ // focus follows along when the page re-renders.
310
+ opts.setSelection?.(null);
311
+ return;
312
+ }
313
+
314
+ // --- Duplicate (Phase 3d.2) ---
315
+ if (isDuplicateLike(e)) {
316
+ e.preventDefault();
317
+ // Mint a fresh id BEFORE building the command so apply + invert
318
+ // both reference the same instance across undo/redo cycles. Using
319
+ // crypto.randomUUID() so the id is globally unique (collision
320
+ // resistance: 122 bits of entropy >> v1 layout sizes).
321
+ const cloneId = typeof crypto !== 'undefined' && crypto.randomUUID
322
+ ? crypto.randomUUID()
323
+ : `${loc.section.id}-copy-${Date.now()}`;
324
+ const clone: LayoutSection = {
325
+ ...JSON.parse(JSON.stringify(loc.section)),
326
+ id: cloneId,
327
+ };
328
+ // Land directly after the source so the visual + ordering both
329
+ // match "duplicate" semantics. (Notion / Figma / Linear all
330
+ // converge on insert-after-source.)
331
+ const at = loc.idx + 1;
332
+ loc.row.sections.splice(at, 0, JSON.parse(JSON.stringify(clone)));
333
+ announcer.announce(narrateSectionDuplicated(loc.section.type, at, loc.row.sections.length));
334
+ history.record(duplicateSectionCommand({
335
+ rowId: loc.row.id,
336
+ at,
337
+ clone,
338
+ label: `duplicate ${loc.section.type}`,
339
+ }));
340
+ // Move selection to the clone so a second Cmd+D duplicates the
341
+ // duplicate (not the original) — matches Figma + Notion sequence
342
+ // semantics. Arrow keys / Move Up now operate on the new copy.
343
+ opts.setSelection?.({ kind: 'section', id: cloneId });
344
+ return;
345
+ }
346
+ }
347
+
348
+ function detach(): void {
349
+ if (!attached) return;
350
+ if (typeof window === 'undefined') return;
351
+ window.removeEventListener('keydown', onKeyDown);
352
+ attached = false;
353
+ }
354
+
355
+ onMounted(() => {
356
+ if (typeof window === 'undefined') return;
357
+ window.addEventListener('keydown', onKeyDown);
358
+ attached = true;
359
+ });
360
+
361
+ onBeforeUnmount(() => {
362
+ detach();
363
+ });
364
+
365
+ return { detach };
366
+ }