@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,593 @@
1
+ /**
2
+ * useLayoutEditor — draft state for the /admin/layouts/[id] editor.
3
+ *
4
+ * Phase 3a.3. Owns:
5
+ * - `draft`: editable copy of the layout (mutate freely; the editor
6
+ * UI binds to this)
7
+ * - `original`: last-saved snapshot; used to compute dirty state +
8
+ * drives the If-Match header on save (3a.6)
9
+ * - `dirty`: shallow-compared marker; true when draft !== original
10
+ * - `save()` / `publish()` / `refresh()`: server interactions
11
+ *
12
+ * Auto-save (3a.6) plugs in by watching `dirty` + calling `save()`
13
+ * after a debounce. The composable itself is sync — auto-save is a
14
+ * separate composable to keep concerns clean.
15
+ *
16
+ * The composable expects to be created in an admin page context
17
+ * (`/admin/layouts/[id]`) — it doesn't ship feature-flag guards
18
+ * because the route + middleware already filter callers.
19
+ */
20
+ import type { LayoutRecord } from '@commonpub/server';
21
+ import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
22
+
23
+ export interface LayoutEditorState {
24
+ /** The editable layout — mutate freely. */
25
+ draft: Ref<LayoutRecord | null>;
26
+ /** Last-saved snapshot, kept in sync with the server. */
27
+ original: Ref<LayoutRecord | null>;
28
+ /** True when draft has diverged from original. */
29
+ dirty: ComputedRef<boolean>;
30
+ /** True when a save is in flight. */
31
+ saving: Ref<boolean>;
32
+ /** Last save status — drives the toolbar indicator. */
33
+ status: Ref<'idle' | 'saving' | 'saved' | 'error' | 'conflict'>;
34
+ /** Last save error message (when status==='error'). */
35
+ errorMessage: Ref<string | null>;
36
+ /** Persist the draft. Throws on 409 (conflict — caller handles).
37
+ * Pass `{ force: true }` to omit the If-Match header (overwrites
38
+ * whatever's on the server). */
39
+ save: (opts?: { force?: boolean }) => Promise<void>;
40
+ /** Publish the current saved version → live. */
41
+ publish: () => Promise<void>;
42
+ /** Pull latest from server, replacing draft + original. */
43
+ refresh: () => Promise<void>;
44
+ /** Discard local changes (revert draft to original). */
45
+ discard: () => void;
46
+ /** Abort any in-flight save fetch — call from `onBeforeUnmount` to
47
+ * prevent orphan PUTs from landing after the editor unmounts (which
48
+ * could cause stale 409s when the user opens the editor again).
49
+ * R4 P2 fix (session 161). */
50
+ abort: () => void;
51
+ /** Fire a best-effort PUT via `fetch({keepalive:true})` — survives
52
+ * page teardown so unsaved edits don't vanish when the tab is
53
+ * closed inside the 1.5s auto-save debounce window. Returns true
54
+ * if a request was queued, false if the no-op preconditions held
55
+ * (no draft/original, not dirty, or fetch unavailable). Fire-and-
56
+ * forget; does NOT await. Bypasses the abort controller (the whole
57
+ * point is for this request to outlive it).
58
+ *
59
+ * Call from a `pagehide` listener (the only event that fires
60
+ * reliably on mobile tab-close + bfcache eviction; `beforeunload`
61
+ * doesn't run on iOS Safari at all). R2 P2 deferred (session 162). */
62
+ flushBeacon: () => boolean;
63
+ /** True when the recent conflict rate has exceeded the threshold
64
+ * (3 conflicts within 60s). Wire this into useLayoutAutoSave's
65
+ * `paused` prop so auto-save stops banging the server while the
66
+ * user reconciles with the other editor. Cleared by
67
+ * `clearConflictHistory()`. R2 P2 deferred (session 162). */
68
+ conflictThrashing: ComputedRef<boolean>;
69
+ /** Reset the conflict-window counter back to zero. Call after the
70
+ * user has explicitly refreshed/force-saved + wants auto-save to
71
+ * resume. */
72
+ clearConflictHistory: () => void;
73
+ /**
74
+ * Current selection — drives the right-hand inspector dispatcher.
75
+ *
76
+ * `null` → render the page-meta form (default).
77
+ * `{kind:'section', id:X}` → render the section-config form (Phase 3f placeholder this session).
78
+ * `{kind:'row', id:Y}` → render the row-config form (Phase 3f placeholder this session).
79
+ *
80
+ * Session 3b/A landing: click / Enter on a section sets selection;
81
+ * click outside the canvas (or Esc) clears. Selection is intentionally
82
+ * NOT persisted across refresh/discard — those operations replace the
83
+ * draft wholesale, so the previously-selected id may no longer exist.
84
+ *
85
+ * See `feedback-visual-editor-ux-patterns` (inspector dispatch pattern)
86
+ * + docs/plans/layout-and-pages.md §7.9.
87
+ */
88
+ selectedId: Ref<EditorSelection>;
89
+ /** Set selection; pass `null` to clear. */
90
+ select: (selection: EditorSelection) => void;
91
+ /** Alias for `select(null)` — semantic sugar for click-outside / Esc. */
92
+ clearSelection: () => void;
93
+ }
94
+
95
+ /**
96
+ * Discriminated selection target. Encoded as an object (not a tuple) so
97
+ * inspector callers can branch on `kind` cleanly + so future kinds
98
+ * (e.g. `'zone'`) extend the union without breaking call sites.
99
+ *
100
+ * `null` = no selection (default; inspector renders page-meta form).
101
+ */
102
+ export type EditorSelection =
103
+ | { kind: 'section'; id: string }
104
+ | { kind: 'row'; id: string }
105
+ | null;
106
+
107
+ /**
108
+ * Discriminated failure for the multi-step publish() flow.
109
+ *
110
+ * publish() chains three independent server calls: save (only when
111
+ * dirty), POST /publish, then refresh. The R4 audit found that a
112
+ * generic "Publish failed" toast hides which step actually failed —
113
+ * the most important case being a publish-step failure AFTER a
114
+ * successful save, where the user's changes ARE durably saved as a
115
+ * draft but they don't know it.
116
+ *
117
+ * The consumer (editor page) catches `PublishStepError`, branches on
118
+ * `step`, and renders a step-specific toast. Session 162 P2.7.
119
+ */
120
+ export type PublishStep = 'save' | 'publish' | 'refresh';
121
+
122
+ export class PublishStepError extends Error {
123
+ readonly step: PublishStep;
124
+ override readonly cause: unknown;
125
+ constructor(step: PublishStep, cause: unknown) {
126
+ super(`Publish step "${step}" failed`);
127
+ this.name = 'PublishStepError';
128
+ this.step = step;
129
+ this.cause = cause;
130
+ }
131
+ }
132
+
133
+ /** Deep-clone via JSON. The LayoutRecord shape is JSON-safe (no Date/Map/Set). */
134
+ function clone<T>(value: T): T {
135
+ return JSON.parse(JSON.stringify(value)) as T;
136
+ }
137
+
138
+ /**
139
+ * Stable-JSON stringify — key-order insensitive. Used only at SEED
140
+ * time (the first non-null assignment to draft) to decide whether to
141
+ * auto-mark pristine. The R2-audit perf concern was that this used to
142
+ * run per-keystroke via the old `dirty` computed; here it runs once
143
+ * per editor instance lifetime, so the O(N) cost is amortised.
144
+ */
145
+ function stableString(value: unknown): string {
146
+ return JSON.stringify(value, (_key, val) => {
147
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
148
+ const sorted: Record<string, unknown> = {};
149
+ for (const k of Object.keys(val as Record<string, unknown>).sort()) {
150
+ sorted[k] = (val as Record<string, unknown>)[k];
151
+ }
152
+ return sorted;
153
+ }
154
+ return val;
155
+ });
156
+ }
157
+
158
+ export function useLayoutEditor(id: string): LayoutEditorState {
159
+ const draft = ref<LayoutRecord | null>(null);
160
+ const original = ref<LayoutRecord | null>(null);
161
+ const saving = ref(false);
162
+ const status = ref<'idle' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
163
+ const errorMessage = ref<string | null>(null);
164
+
165
+ /**
166
+ * Dirty tracking — session 162 P2.6 (R2 audit).
167
+ *
168
+ * Was: `dirty = computed(stableString(draft) !== stableString(original))`.
169
+ * Walked the entire layout JSON on every keystroke. At N=50 sections
170
+ * the audit measured 5-10ms per keystroke; at N=200 it ate a frame
171
+ * (>16ms). Today's homepage at N=5 hid it.
172
+ *
173
+ * Now: O(1) version counters. `dirtyVersion` increments on every
174
+ * draft mutation via a deep watcher; `savedVersion` snapshots at
175
+ * save-success (or seed/refresh/discard); `dirty` is a single
176
+ * `dirtyVersion !== savedVersion` compare.
177
+ *
178
+ * The deep watcher subscribes to nested properties once at setup
179
+ * (using Vue's reactive Proxy infrastructure); subsequent mutations
180
+ * notify it in O(1) without re-walking. Outer ref reassignment
181
+ * (`draft.value = ...`) re-walks the new value — rare path (initial
182
+ * seed, refresh, discard).
183
+ *
184
+ * `flush: 'sync'` so dirty reflects the mutation in the SAME tick
185
+ * (lets test code read `dirty.value` immediately after a mutation
186
+ * without `await nextTick()`). Safe re-entrancy: the watcher only
187
+ * mutates dirtyVersion/savedVersion, never draft itself.
188
+ *
189
+ * Auto-sync on initial seed: the canonical consumer pattern is
190
+ * `editor.original.value = X; editor.draft.value = clone(X);` (the
191
+ * editor page does exactly this after useFetch lands). On that first
192
+ * draft assignment the watcher fires with oldDraft===null. We do a
193
+ * ONE-TIME stable-string compare against original.value — if equal,
194
+ * sync savedVersion so dirty starts false. If different, leave
195
+ * savedVersion alone so dirty starts true (covers tests that seed
196
+ * a divergent draft to assert dirty behavior).
197
+ *
198
+ * This single O(N) walk per editor lifetime replaces the previous
199
+ * O(N) walk per keystroke — the audit's actual cost concern.
200
+ */
201
+ const dirtyVersion = ref(0);
202
+ const savedVersion = ref(0);
203
+
204
+ watch(
205
+ draft,
206
+ (newDraft, oldDraft) => {
207
+ dirtyVersion.value++;
208
+ if (
209
+ oldDraft === null &&
210
+ newDraft !== null &&
211
+ original.value !== null &&
212
+ stableString(newDraft) === stableString(original.value)
213
+ ) {
214
+ savedVersion.value = dirtyVersion.value;
215
+ }
216
+ },
217
+ { deep: true, flush: 'sync' },
218
+ );
219
+
220
+ const dirty = computed<boolean>(() => {
221
+ if (!draft.value || !original.value) return false;
222
+ return dirtyVersion.value !== savedVersion.value;
223
+ });
224
+
225
+ /**
226
+ * Conflict-window tracking — session 162 P2.5 (R2 audit).
227
+ *
228
+ * Scenario: admin clicks "Reload" in the conflict modal; while their
229
+ * refresh is in flight a third admin saves; their next edit 409s
230
+ * immediately. Each conflict is a different third-party save (not a
231
+ * loop), but the UX thrashes — modal in/out/in. Worse, the auto-save
232
+ * watcher keeps re-triggering the save-then-409 cycle.
233
+ *
234
+ * Mitigation: after 3 conflicts within a 60s rolling window, flip
235
+ * `conflictThrashing` true. The editor page passes this to
236
+ * useLayoutAutoSave's `paused` prop, halting the auto-save cycle.
237
+ * A banner prompts the user to refresh / force-save / coordinate;
238
+ * `clearConflictHistory()` (UI: "Resume auto-save") clears the
239
+ * window and restarts. The 3-in-60s threshold matches what mature
240
+ * collab editors (Notion, Linear retros) settled on after tuning.
241
+ */
242
+ const CONFLICT_WINDOW_MS = 60_000;
243
+ const CONFLICT_THRESHOLD = 3;
244
+ const conflictHistory = ref<number[]>([]);
245
+ function recordConflict(): void {
246
+ const now = Date.now();
247
+ conflictHistory.value = [
248
+ ...conflictHistory.value.filter((t) => now - t < CONFLICT_WINDOW_MS),
249
+ now,
250
+ ];
251
+ }
252
+ function clearConflictHistory(): void {
253
+ conflictHistory.value = [];
254
+ }
255
+ const conflictThrashing = computed<boolean>(() => {
256
+ // The computed only re-evaluates when `conflictHistory.value`
257
+ // changes — Vue caches the last result otherwise. That means a
258
+ // rolling window doesn't naturally "expire" via wall-clock time;
259
+ // once `thrashing===true` is set, it stays true until either
260
+ // (a) a new conflict event mutates conflictHistory (rare while
261
+ // auto-save is paused — see the feedback loop below), or
262
+ // (b) the user explicitly calls `clearConflictHistory()`.
263
+ //
264
+ // Feedback loop: thrashing=true → useLayoutAutoSave.paused=true →
265
+ // no new save attempts → no new conflicts → conflictHistory frozen
266
+ // → thrashing stays true. That matches the intended UX: the admin
267
+ // must explicitly acknowledge (Refresh / Force save / Resume)
268
+ // before auto-save resumes. A wall-clock timer would silently
269
+ // resume in the background, defeating the throttle's purpose.
270
+ const now = Date.now();
271
+ return conflictHistory.value.filter((t) => now - t < CONFLICT_WINDOW_MS).length
272
+ >= CONFLICT_THRESHOLD;
273
+ });
274
+
275
+ /**
276
+ * Selection state — drives the inspector dispatcher (Phase 3b/A).
277
+ *
278
+ * Held as a single ref containing a discriminated union. Cleared on
279
+ * refresh/discard because those operations replace draft wholesale +
280
+ * the selected id may no longer exist in the new draft. NOT cleared
281
+ * on save() — the server returns the same ids in the snapshot.
282
+ */
283
+ const selectedId = ref<EditorSelection>(null);
284
+ function select(selection: EditorSelection): void {
285
+ selectedId.value = selection;
286
+ }
287
+ function clearSelection(): void {
288
+ selectedId.value = null;
289
+ }
290
+
291
+ async function refresh(): Promise<void> {
292
+ const fresh = await $fetch<LayoutRecord>(`/api/admin/layouts/${id}`);
293
+ original.value = fresh;
294
+ // Replaces a non-null draft → the auto-sync-on-null path doesn't
295
+ // fire. Bump-and-sync explicitly so dirty starts false post-refresh.
296
+ draft.value = clone(fresh);
297
+ savedVersion.value = dirtyVersion.value;
298
+ status.value = 'idle';
299
+ errorMessage.value = null;
300
+ // The id that was selected may not exist in the fresh snapshot
301
+ // (another admin deleted it; we can't keep a phantom selection).
302
+ // Clear; the inspector falls back to the page-meta form.
303
+ selectedId.value = null;
304
+ }
305
+
306
+ /**
307
+ * In-flight save promise (single-flight guard — session 160 audit P1).
308
+ * Two distinct triggers can fire save() in parallel: manual Save click
309
+ * + auto-save timer, OR auto-save + visibility-flush. Without this
310
+ * guard, both calls pass the dirty check, both set saving=true, both
311
+ * send PUTs with the SAME (stale) If-Match. Server accepts both
312
+ * (last-write-wins). Client's `original.updatedAt` ends up referring
313
+ * to whichever response landed LAST in the await queue, which is not
314
+ * guaranteed to be the same as the actual DB row's updatedAt under
315
+ * network jitter — leading to spurious 409s on the next save.
316
+ *
317
+ * Pattern: store the in-flight promise; concurrent callers return
318
+ * the SAME promise (await it). Cleared in finally so the next save
319
+ * starts fresh.
320
+ */
321
+ let inFlightSave: Promise<void> | null = null;
322
+
323
+ /**
324
+ * AbortController for the editor's lifetime — aborted by abort() on
325
+ * unmount (called from the page's onBeforeUnmount). The single-flight
326
+ * guard above prevents parallel saves, so one controller suffices for
327
+ * the whole composable instance: after abort, no further saves should
328
+ * fire (the editor is unmounting). A new mount creates a new composable
329
+ * instance → new controller.
330
+ *
331
+ * The `typeof` guard makes this resilient to environments without
332
+ * the AbortController global (older Node test harnesses, etc).
333
+ * R4 P2 fix (session 161).
334
+ */
335
+ const abortController = typeof AbortController !== 'undefined'
336
+ ? new AbortController()
337
+ : null;
338
+
339
+ function abort(): void {
340
+ abortController?.abort();
341
+ }
342
+
343
+ /**
344
+ * Beacon save — session 162 P2.3.
345
+ *
346
+ * The auto-save composable's `visibilitychange` flush handles the
347
+ * common Cmd+Tab / minimize case via regular `$fetch`. That works
348
+ * because the page is still alive when the request goes out and the
349
+ * browser drains the connection normally. But for tab close (and
350
+ * especially iOS Safari, which doesn't fire `beforeunload` at all),
351
+ * the page is torn down before the request finishes — the abort
352
+ * controller cancels it, or the browser kills the network stack.
353
+ *
354
+ * `pagehide` fires reliably on every tab-close + bfcache event on
355
+ * every browser; combined with `fetch(..., { keepalive: true })` the
356
+ * browser commits to delivering the request even after the page is
357
+ * gone (subject to the 64KB body cap, which our LayoutPayload sits
358
+ * well under).
359
+ *
360
+ * sendBeacon would work too but is POST-only; we want PUT to share
361
+ * the existing endpoint + If-Match contract. The keepalive flag is
362
+ * the modern way to get the same lifecycle guarantee with arbitrary
363
+ * methods. Native `fetch` is used directly (not `$fetch`/ofetch)
364
+ * because we don't want response parsing, retries, or error wrapping
365
+ * — the page is gone, nobody can read the result.
366
+ */
367
+ function flushBeacon(): boolean {
368
+ if (typeof fetch === 'undefined') return false;
369
+ if (!draft.value || !original.value) return false;
370
+ if (!dirty.value) return false;
371
+
372
+ const body = JSON.stringify({
373
+ scope: draft.value.scope,
374
+ name: draft.value.name,
375
+ pageMeta: draft.value.pageMeta ?? undefined,
376
+ zones: draft.value.zones,
377
+ state: draft.value.state,
378
+ });
379
+ // Session 163 deep audit: the `keepalive: true` PUT has a 64KB body
380
+ // cap enforced by every browser. A complex layout (many sections
381
+ // with rich text + image URLs + visibility metadata) can exceed
382
+ // this. The browser silently drops the request → fire-and-forget
383
+ // becomes fire-and-vanish + the user's edits are lost. Surface
384
+ // false here so the caller (or the beforeunload prompt) is aware
385
+ // the beacon path can't carry this payload. Beforeunload still
386
+ // catches the user's intent to leave; the auto-save's pre-hide
387
+ // visibility-flush handles smaller payloads (no keepalive cap).
388
+ const BEACON_BODY_MAX_BYTES = 60 * 1024; // 60KB — 4KB headroom under the 64KB browser cap
389
+ if (body.length > BEACON_BODY_MAX_BYTES) {
390
+ return false;
391
+ }
392
+ const headers: Record<string, string> = {
393
+ 'Content-Type': 'application/json',
394
+ 'If-Match': original.value.updatedAt,
395
+ // Lets the server audit-log distinguish beacon saves from regular
396
+ // auto-saves (helpful when tracing "did my last edit land?").
397
+ 'X-Cpub-Save-Source': 'beacon',
398
+ };
399
+ try {
400
+ // Fire-and-forget. No signal so the unmount abort() cannot cancel
401
+ // it. `keepalive:true` lets the browser deliver the request after
402
+ // page teardown — without it, the connection dies with the page.
403
+ void fetch(`/api/admin/layouts/${id}`, {
404
+ method: 'PUT',
405
+ headers,
406
+ body,
407
+ keepalive: true,
408
+ });
409
+ return true;
410
+ } catch {
411
+ // Swallow — the page is going away; the user isn't here to see
412
+ // an error, and the fetch is best-effort anyway.
413
+ return false;
414
+ }
415
+ }
416
+
417
+ async function save(opts: { force?: boolean } = {}): Promise<void> {
418
+ if (!draft.value || !original.value) return;
419
+ if (!dirty.value) return;
420
+ // Single-flight: if a save is already in flight, coalesce — return
421
+ // the in-flight promise instead of starting a parallel request.
422
+ // Subsequent saves are picked up by the auto-save watcher when the
423
+ // first one completes (dirty stays true if the user kept editing).
424
+ if (inFlightSave) return inFlightSave;
425
+
426
+ saving.value = true;
427
+ status.value = 'saving';
428
+ errorMessage.value = null;
429
+ // Capture the original.updatedAt BEFORE the request — used as the
430
+ // If-Match value. The server's response will give us a fresh
431
+ // updatedAt to use for the next save's optimistic-concurrency check.
432
+ const ifMatch = opts.force ? undefined : original.value.updatedAt;
433
+ // Capture dirtyVersion AT SAVE START so any edits the user makes
434
+ // during the await leave `dirty` true post-save (their unsaved
435
+ // newer edits survive). Session 162 P2.6.
436
+ const savingVersion = dirtyVersion.value;
437
+
438
+ inFlightSave = (async () => {
439
+ try {
440
+ const headers: Record<string, string> = {};
441
+ if (ifMatch) headers['If-Match'] = ifMatch;
442
+ // Signal a deliberate force-save so the server can audit-log
443
+ // it distinctly from first-creation (both share no-If-Match).
444
+ if (opts.force) headers['X-Cpub-Force-Save'] = '1';
445
+ const updated = await $fetch<LayoutRecord>(`/api/admin/layouts/${id}`, {
446
+ method: 'PUT',
447
+ headers,
448
+ body: {
449
+ scope: draft.value!.scope,
450
+ name: draft.value!.name,
451
+ pageMeta: draft.value!.pageMeta ?? undefined,
452
+ zones: draft.value!.zones,
453
+ state: draft.value!.state,
454
+ },
455
+ // Cancel the fetch if the editor unmounts mid-save. Catch block
456
+ // below recognises the AbortError and short-circuits without
457
+ // surfacing it as a user-visible error.
458
+ signal: abortController?.signal,
459
+ });
460
+ // Update `original` only — DON'T overwrite `draft`. The user may
461
+ // have made further edits while the save was in flight; those
462
+ // edits stay in draft + the dirty comparison correctly flips
463
+ // true again so the auto-save composable schedules a follow-up.
464
+ // The server returns the saved snapshot which becomes the new
465
+ // baseline for If-Match.
466
+ original.value = updated;
467
+ // Mark the version we sent as durable. If the user made edits
468
+ // during the await, dirtyVersion > savingVersion → dirty stays
469
+ // true → auto-save schedules a follow-up. If no edits during
470
+ // await, dirtyVersion === savingVersion → dirty becomes false.
471
+ //
472
+ // Session 164 audit-of-audit P3 fix — savedVersion is monotonic:
473
+ // discard()/refresh() also write to savedVersion (syncing it to
474
+ // the post-replacement dirtyVersion), which can leave it AHEAD
475
+ // of the savingVersion captured at save start. Without this
476
+ // guard, save's completion would move savedVersion BACKWARD,
477
+ // spuriously flipping `dirty` true post-discard. Concrete trace:
478
+ //
479
+ // t=0 edit (dirtyVersion=1, savedVersion=0)
480
+ // t=1500 save fires (savingVersion=1, in flight)
481
+ // t=1800 discard (draft=original; dirtyVersion=2; discard sets
482
+ // savedVersion=dirtyVersion=2 → dirty=false)
483
+ // t=2000 save completes
484
+ // old: savedVersion = savingVersion = 1 → dirty = (2!=1)=true (WRONG)
485
+ // new: savingVersion=1 NOT > savedVersion=2; skip → dirty=false ✓
486
+ //
487
+ // Server-state divergence (server saved pre-discard edit while
488
+ // client discarded) still exists; abort-on-discard would close
489
+ // it but requires more surface area. Defer; the dirty-flag UX
490
+ // fix here is the high-leverage piece.
491
+ if (savingVersion > savedVersion.value) {
492
+ savedVersion.value = savingVersion;
493
+ }
494
+ status.value = 'saved';
495
+ } catch (err) {
496
+ const e = err as { statusCode?: number; statusMessage?: string; message?: string; name?: string };
497
+ // AbortError: the editor unmounted mid-save (the user navigated
498
+ // away). The component is gone — surfacing "Save failed" is
499
+ // wrong (the user isn't here to see it, and the fetch was
500
+ // CANCELLED, not failed). Reset to idle + re-throw so the
501
+ // outer caller knows the promise didn't complete normally,
502
+ // but skip the user-visible status/error mutations.
503
+ if (e.name === 'AbortError') {
504
+ status.value = 'idle';
505
+ errorMessage.value = null;
506
+ throw err;
507
+ }
508
+ if (e.statusCode === 409) {
509
+ status.value = 'conflict';
510
+ errorMessage.value = 'Another admin edited this layout while you were working.';
511
+ // Session 162 P2.5: feed the rolling-window throttle. A single
512
+ // 409 is normal collab; 3 within 60s is thrashing → pause
513
+ // auto-save so the user reconciles before the next round trip.
514
+ recordConflict();
515
+ } else {
516
+ status.value = 'error';
517
+ errorMessage.value = e.statusMessage ?? e.message ?? 'Save failed';
518
+ }
519
+ throw err;
520
+ } finally {
521
+ saving.value = false;
522
+ inFlightSave = null;
523
+ }
524
+ })();
525
+
526
+ return inFlightSave;
527
+ }
528
+
529
+ async function publish(): Promise<void> {
530
+ if (!draft.value) return;
531
+
532
+ // Save first if dirty — publish snapshots the LAST SAVED state.
533
+ // Session 162 P2.7: wrap each step in its own try/catch so the
534
+ // caller can render a step-specific failure toast. The most
535
+ // important case is a 'publish' step failure AFTER a successful
536
+ // 'save' — the user's changes are durably saved as a draft but
537
+ // the generic "Publish failed" toast hid that.
538
+ if (dirty.value) {
539
+ try {
540
+ await save();
541
+ } catch (err) {
542
+ throw new PublishStepError('save', err);
543
+ }
544
+ }
545
+
546
+ try {
547
+ await $fetch(`/api/admin/layouts/${id}/publish`, { method: 'POST' });
548
+ } catch (err) {
549
+ throw new PublishStepError('publish', err);
550
+ }
551
+
552
+ try {
553
+ await refresh();
554
+ } catch (err) {
555
+ // Server data IS published. Only the editor's local view is
556
+ // stale. The page handler can recommend a manual reload.
557
+ throw new PublishStepError('refresh', err);
558
+ }
559
+ }
560
+
561
+ function discard(): void {
562
+ if (!original.value) return;
563
+ draft.value = clone(original.value);
564
+ // Same as refresh — non-null replacement. Sync explicitly.
565
+ savedVersion.value = dirtyVersion.value;
566
+ status.value = 'idle';
567
+ errorMessage.value = null;
568
+ // Same rationale as refresh — the discarded draft's selected id may
569
+ // not exist in the original snapshot if the user added a section
570
+ // then discarded.
571
+ selectedId.value = null;
572
+ }
573
+
574
+ return {
575
+ draft,
576
+ original,
577
+ dirty,
578
+ saving,
579
+ status,
580
+ errorMessage,
581
+ save,
582
+ publish,
583
+ refresh,
584
+ discard,
585
+ abort,
586
+ flushBeacon,
587
+ conflictThrashing,
588
+ clearConflictHistory,
589
+ selectedId,
590
+ select,
591
+ clearSelection,
592
+ };
593
+ }