@commonpub/layer 0.23.3 → 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.
- package/README.md +41 -12
- package/components/LayoutRow.vue +944 -0
- package/components/LayoutSection.vue +1028 -0
- package/components/LayoutSlot.vue +104 -162
- package/components/PageFrame.vue +116 -0
- package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
- package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
- package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
- package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
- package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
- package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
- package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
- package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
- package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
- package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
- package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
- package/components/blocks/BlockDividerView.vue +52 -2
- package/components/homepage/ContentGridSection.vue +23 -1
- package/components/homepage/HeroSection.vue +69 -8
- package/components/sections/SectionCta.vue +175 -0
- package/components/sections/SectionLearning.vue +232 -0
- package/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useFeatures.ts +32 -5
- package/composables/useLayout.ts +46 -43
- package/composables/useLayoutAnnouncer.ts +332 -0
- package/composables/useLayoutAutoSave.ts +117 -0
- package/composables/useLayoutDrag.ts +290 -0
- package/composables/useLayoutEditor.ts +593 -0
- package/composables/useLayoutHistory.ts +583 -0
- package/composables/useLayoutHotkeys.ts +366 -0
- package/composables/useLayoutResize.ts +783 -0
- package/layouts/admin.vue +137 -24
- package/middleware/admin-layouts.ts +29 -0
- package/nuxt.config.ts +14 -0
- package/package.json +8 -5
- package/pages/[...customPath].vue +154 -0
- package/pages/admin/homepage.vue +46 -0
- package/pages/admin/index.vue +16 -0
- package/pages/admin/layouts/[id].vue +1110 -0
- package/pages/admin/layouts/index.vue +356 -0
- package/pages/explore.vue +16 -6
- package/sections/builtin/content-feed.ts +18 -29
- package/sections/builtin/contests.ts +30 -0
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +36 -0
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +29 -0
- package/sections/builtin/embed.ts +31 -0
- package/sections/builtin/gallery.ts +29 -0
- package/sections/builtin/heading.ts +14 -19
- package/sections/builtin/hero.ts +16 -51
- package/sections/builtin/hubs.ts +30 -0
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +30 -0
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +35 -0
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +38 -7
- package/server/api/admin/homepage/sections.put.ts +52 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
- package/server/api/admin/layouts/[id].delete.ts +33 -1
- package/server/api/admin/layouts/[id].put.ts +78 -0
- package/server/api/admin/layouts/index.post.ts +60 -4
- package/server/api/admin/layouts/migrate-homepage.post.ts +68 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- package/server/plugins/feature-flags-prime.ts +39 -0
- package/server/utils/layoutCache.ts +37 -1
- package/server/utils/validateSectionConfigs.ts +123 -0
- package/theme/base.css +1 -0
- package/components/sections/SectionContentFeed.vue +0 -160
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionImage.vue +0 -104
- package/components/sections/SectionParagraph.vue +0 -55
|
@@ -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
|
+
}
|