@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,332 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Canvas — center column of the editor shell. Hosts the in-progress
|
|
4
|
+
* layout via <LayoutSlot :editable previewOverride>. The toolbar's
|
|
5
|
+
* viewport toggle sets `viewport`, which translates to a max-width
|
|
6
|
+
* cap on the inner canvas wrapper so admins can preview how the
|
|
7
|
+
* layout reflows at different breakpoints WITHOUT actually resizing
|
|
8
|
+
* the browser.
|
|
9
|
+
*
|
|
10
|
+
* Each zone the layout exposes gets a sub-pane. v1 ships the typical
|
|
11
|
+
* three-zone arrangement (full-width + main + sidebar); other shapes
|
|
12
|
+
* fall back to a single zone container.
|
|
13
|
+
*/
|
|
14
|
+
import { computed } from 'vue';
|
|
15
|
+
import type { LayoutRecord } from '@commonpub/server';
|
|
16
|
+
import type { LayoutPayload, LayoutRow as LayoutRowType } from '../../../composables/useLayout';
|
|
17
|
+
import type { EditorSelection } from '../../../composables/useLayoutEditor';
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(defineProps<{
|
|
20
|
+
layout: LayoutRecord | null;
|
|
21
|
+
/** Simulated viewport from the toolbar. */
|
|
22
|
+
viewport?: 'mobile' | 'tablet' | 'desktop';
|
|
23
|
+
/**
|
|
24
|
+
* Phase 3b/A — selection callback passed down to <LayoutSlot>.
|
|
25
|
+
* The editor page wires this to `useLayoutEditor.select` so the
|
|
26
|
+
* inspector dispatcher switches when the admin clicks a section.
|
|
27
|
+
* Optional — without it, clicks are silent (canvas still renders).
|
|
28
|
+
*/
|
|
29
|
+
onSelect?: (selection: EditorSelection) => void;
|
|
30
|
+
/** Currently-selected target — drives `--selected` chrome on sections/rows. */
|
|
31
|
+
selectedId?: EditorSelection | null;
|
|
32
|
+
/**
|
|
33
|
+
* Session 164 polish — "+ Add row" handler. Called with the zone slug
|
|
34
|
+
* the admin wants to add a row to. Editor page mutates draft +
|
|
35
|
+
* records to history + narrates. When absent, the Add row button is
|
|
36
|
+
* hidden (defensive: editable canvas without the callback means a
|
|
37
|
+
* parent forgot to wire it, NOT an intentional disabled state).
|
|
38
|
+
*/
|
|
39
|
+
onAddRow?: (zoneSlug: string) => void;
|
|
40
|
+
/** Session 164 polish — remove row handler. Same wiring as onAddRow;
|
|
41
|
+
* threaded through LayoutSlot to each LayoutRow's × button. */
|
|
42
|
+
onRemoveRow?: (zoneSlug: string, rowId: string) => void;
|
|
43
|
+
}>(), {
|
|
44
|
+
viewport: 'desktop',
|
|
45
|
+
onSelect: undefined,
|
|
46
|
+
selectedId: null,
|
|
47
|
+
onAddRow: undefined,
|
|
48
|
+
onRemoveRow: undefined,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Phase 3c — getDraft closure for the row's resize handler. Same
|
|
53
|
+
* `props.layout` reference the canvas already renders against — for
|
|
54
|
+
* the editor, `editor.draft.value` IS the layout prop (the editor page
|
|
55
|
+
* binds `:layout="editor.draft.value"`). The resize composable mutates
|
|
56
|
+
* the live tree directly; the editor's deep watcher picks it up + the
|
|
57
|
+
* existing dirty/auto-save path fires. No new state.
|
|
58
|
+
*/
|
|
59
|
+
function getDraft(): LayoutRecord | null {
|
|
60
|
+
return props.layout;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Session 162 P2.4 (R2 audit): LayoutPayload is now
|
|
64
|
+
// `Pick<LayoutRecord, 'state' | 'pageMeta' | 'zones'>`, so a LayoutRecord
|
|
65
|
+
// is structurally assignable as-is. The 25-line hand-built map this
|
|
66
|
+
// replaced silently dropped any newly-added section field — a future
|
|
67
|
+
// 'pinned' / 'theme' / etc would appear on the public render path but
|
|
68
|
+
// vanish from the editor preview. Direct assignment makes the preview
|
|
69
|
+
// shape track the live shape automatically.
|
|
70
|
+
const payload = computed<LayoutPayload | null>(() => props.layout);
|
|
71
|
+
|
|
72
|
+
const route = computed<string>(() => {
|
|
73
|
+
if (!props.layout) return '/';
|
|
74
|
+
const s = props.layout.scope;
|
|
75
|
+
if (s.type === 'route' || s.type === 'custom-page') return s.path;
|
|
76
|
+
return `/${s.key}`;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const zoneSlugs = computed<string[]>(() => {
|
|
80
|
+
if (!props.layout) return [];
|
|
81
|
+
return props.layout.zones.map((z) => z.zone);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The three zones <PageFrame> arranges (full-width above; main + sidebar
|
|
86
|
+
* grid below). The canvas maps each layout zone to the matching PageFrame
|
|
87
|
+
* slot via a dynamic slot name so the editor previews the REAL production
|
|
88
|
+
* arrangement instead of the old stacked equal-width boxes. Filtered to
|
|
89
|
+
* the known frame zones (v1 ships exactly these three).
|
|
90
|
+
*/
|
|
91
|
+
const FRAME_ZONES = ['full-width', 'main', 'sidebar'] as const;
|
|
92
|
+
const frameZones = computed<string[]>(() =>
|
|
93
|
+
zoneSlugs.value.filter((z) => (FRAME_ZONES as readonly string[]).includes(z)),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const viewportWidthPx: Record<'mobile' | 'tablet' | 'desktop', string> = {
|
|
97
|
+
mobile: '375px',
|
|
98
|
+
tablet: '768px',
|
|
99
|
+
desktop: '100%',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Phase 3b/B — cross-row lookups. The dispatcher needs to find the
|
|
104
|
+
* source row of a section-instance drag, which can be in any zone.
|
|
105
|
+
* The Canvas has access to the WHOLE layout (vs LayoutRow which only
|
|
106
|
+
* has its own row), so it synthesises these once + passes them down.
|
|
107
|
+
*
|
|
108
|
+
* Closures over `props.layout` — re-evaluated on every layout mutation
|
|
109
|
+
* via Vue's reactivity. The closures themselves are stable references
|
|
110
|
+
* (LayoutRow re-renders on prop change), but the data they read is
|
|
111
|
+
* always the live, post-mutation tree.
|
|
112
|
+
*/
|
|
113
|
+
function findRow(rowId: string): LayoutRowType | null {
|
|
114
|
+
const l = props.layout;
|
|
115
|
+
if (!l) return null;
|
|
116
|
+
for (const zone of l.zones) {
|
|
117
|
+
for (const row of zone.rows) {
|
|
118
|
+
if (row.id === rowId) return row as LayoutRowType;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function findZone(rowId: string): string | null {
|
|
125
|
+
const l = props.layout;
|
|
126
|
+
if (!l) return null;
|
|
127
|
+
for (const zone of l.zones) {
|
|
128
|
+
for (const row of zone.rows) {
|
|
129
|
+
if (row.id === rowId) return zone.zone;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Phase 3b/B — keyboard cross-zone landing target. The "Move to zone…"
|
|
137
|
+
* popover on each section lets a keyboard user move a section to
|
|
138
|
+
* another zone WITHOUT pointer drag (WCAG 2.1.1 a11y path; per the
|
|
139
|
+
* a11y memory: drag-drop alone isn't enough — every operation needs a
|
|
140
|
+
* non-drag alternative).
|
|
141
|
+
*
|
|
142
|
+
* Landing rule: end of the FIRST row in the target zone. Predictable
|
|
143
|
+
* ("go to sidebar" → land at top of sidebar), no ambiguous row-picker,
|
|
144
|
+
* works with v1 layouts that have 1–2 rows per zone. After landing,
|
|
145
|
+
* the user can Move Up/Down + drag to refine. Returns null for zones
|
|
146
|
+
* with zero rows (button gets disabled — creating a row from cross-zone
|
|
147
|
+
* move is deferred to the "Add row" arc).
|
|
148
|
+
*/
|
|
149
|
+
function findFirstRowInZone(zoneSlug: string): LayoutRowType | null {
|
|
150
|
+
const l = props.layout;
|
|
151
|
+
if (!l) return null;
|
|
152
|
+
const z = l.zones.find((zone) => zone.zone === zoneSlug);
|
|
153
|
+
if (!z || z.rows.length === 0) return null;
|
|
154
|
+
return (z.rows[0] ?? null) as LayoutRowType | null;
|
|
155
|
+
}
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<template>
|
|
159
|
+
<!--
|
|
160
|
+
Phase 3b/A: @click.self on the canvas stage clears editor selection
|
|
161
|
+
(audit R3-3). The DnDProvider above us is exactly canvas-sized, so
|
|
162
|
+
its @click.self rarely fires; the stage's padded chrome (visible
|
|
163
|
+
blank area between zones) is the natural click-clear surface.
|
|
164
|
+
Per docs/plans/layout-and-pages.md §7.9 dispatcher pattern:
|
|
165
|
+
nothing selected → page-meta form. Clicking the stage chrome IS
|
|
166
|
+
"deselect" in a visual editor.
|
|
167
|
+
-->
|
|
168
|
+
<section
|
|
169
|
+
class="cpub-admin-layouts-canvas"
|
|
170
|
+
:data-viewport="viewport"
|
|
171
|
+
aria-label="Layout canvas"
|
|
172
|
+
@click.self="props.onSelect?.(null)"
|
|
173
|
+
>
|
|
174
|
+
<div
|
|
175
|
+
class="cpub-admin-layouts-canvas-stage"
|
|
176
|
+
:style="{ maxWidth: viewportWidthPx[viewport] }"
|
|
177
|
+
@click.self="props.onSelect?.(null)"
|
|
178
|
+
>
|
|
179
|
+
<div v-if="!layout" class="cpub-admin-layouts-canvas-empty">
|
|
180
|
+
<i class="fa-regular fa-folder-open"></i>
|
|
181
|
+
<p>Loading layout…</p>
|
|
182
|
+
</div>
|
|
183
|
+
<!--
|
|
184
|
+
Consolidation Stage 2: the canvas previews the layout through the
|
|
185
|
+
shared <PageFrame> — the SAME frame production uses (full-width
|
|
186
|
+
above; main + sidebar side-by-side; one max-width/sidebar-width).
|
|
187
|
+
Previously zones were stacked as equal-width labeled boxes, which
|
|
188
|
+
did NOT match what visitors see (broken WYSIWYG). Each zone keeps
|
|
189
|
+
its editor chrome (label + add-row) via a dynamic PageFrame slot;
|
|
190
|
+
the section DOM inside <LayoutSlot> is UNCHANGED so drag/resize
|
|
191
|
+
bindings are unaffected.
|
|
192
|
+
-->
|
|
193
|
+
<PageFrame v-else editable>
|
|
194
|
+
<template v-for="zoneSlug in frameZones" :key="zoneSlug" #[zoneSlug]>
|
|
195
|
+
<div class="cpub-admin-layouts-canvas-zone" :data-zone="zoneSlug">
|
|
196
|
+
<header class="cpub-admin-layouts-canvas-zone-label">
|
|
197
|
+
<span class="cpub-admin-layouts-canvas-zone-label-text">{{ zoneSlug }}</span>
|
|
198
|
+
</header>
|
|
199
|
+
<div
|
|
200
|
+
class="cpub-admin-layouts-canvas-zone-body"
|
|
201
|
+
@click.self="props.onSelect?.(null)"
|
|
202
|
+
>
|
|
203
|
+
<LayoutSlot
|
|
204
|
+
:route="route"
|
|
205
|
+
:zone="zoneSlug"
|
|
206
|
+
:preview-override="payload"
|
|
207
|
+
:editable="true"
|
|
208
|
+
:on-select="props.onSelect"
|
|
209
|
+
:selected-id="props.selectedId"
|
|
210
|
+
:find-row="findRow"
|
|
211
|
+
:find-zone="findZone"
|
|
212
|
+
:zone-slugs="zoneSlugs"
|
|
213
|
+
:find-first-row-in-zone="findFirstRowInZone"
|
|
214
|
+
:on-remove-row="props.onRemoveRow"
|
|
215
|
+
:get-draft="getDraft"
|
|
216
|
+
/>
|
|
217
|
+
<!--
|
|
218
|
+
Session 164 polish (v1 blocker): "+ Add row". Without
|
|
219
|
+
this, a fresh layout (or a layout with an empty zone)
|
|
220
|
+
has no drop target — admin is stuck. Click → editor
|
|
221
|
+
page mutates draft.zones[i].rows + records to history
|
|
222
|
+
+ narrates. Plan §7.2.
|
|
223
|
+
Renders only when the parent provided onAddRow (so the
|
|
224
|
+
public path can't accidentally light up an action button).
|
|
225
|
+
-->
|
|
226
|
+
<button
|
|
227
|
+
v-if="props.onAddRow"
|
|
228
|
+
type="button"
|
|
229
|
+
class="cpub-admin-layouts-canvas-add-row"
|
|
230
|
+
:aria-label="`Add row to ${zoneSlug} zone`"
|
|
231
|
+
@click.stop="props.onAddRow!(zoneSlug)"
|
|
232
|
+
>
|
|
233
|
+
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
|
234
|
+
<span>Add row</span>
|
|
235
|
+
</button>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</template>
|
|
239
|
+
</PageFrame>
|
|
240
|
+
</div>
|
|
241
|
+
</section>
|
|
242
|
+
</template>
|
|
243
|
+
|
|
244
|
+
<style scoped>
|
|
245
|
+
.cpub-admin-layouts-canvas {
|
|
246
|
+
display: flex;
|
|
247
|
+
justify-content: center;
|
|
248
|
+
padding: var(--space-6);
|
|
249
|
+
background: var(--surface2);
|
|
250
|
+
overflow-y: auto;
|
|
251
|
+
height: 100%;
|
|
252
|
+
min-width: 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.cpub-admin-layouts-canvas-stage {
|
|
256
|
+
width: 100%;
|
|
257
|
+
display: flex;
|
|
258
|
+
flex-direction: column;
|
|
259
|
+
gap: var(--space-6);
|
|
260
|
+
/* max-width is set dynamically by the viewport toggle (3a.5 toolbar). */
|
|
261
|
+
transition: max-width 200ms ease-out;
|
|
262
|
+
}
|
|
263
|
+
@media (prefers-reduced-motion: reduce) {
|
|
264
|
+
.cpub-admin-layouts-canvas-stage { transition: none; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.cpub-admin-layouts-canvas-empty {
|
|
268
|
+
display: flex;
|
|
269
|
+
flex-direction: column;
|
|
270
|
+
align-items: center;
|
|
271
|
+
gap: var(--space-3);
|
|
272
|
+
padding: var(--space-8);
|
|
273
|
+
color: var(--text-faint);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.cpub-admin-layouts-canvas-zone {
|
|
277
|
+
background: var(--surface);
|
|
278
|
+
border: 1px solid var(--border2);
|
|
279
|
+
}
|
|
280
|
+
.cpub-admin-layouts-canvas-zone-label {
|
|
281
|
+
padding: var(--space-1) var(--space-3);
|
|
282
|
+
background: var(--surface2);
|
|
283
|
+
border-bottom: 1px solid var(--border2);
|
|
284
|
+
}
|
|
285
|
+
.cpub-admin-layouts-canvas-zone-label-text {
|
|
286
|
+
font-family: var(--font-mono);
|
|
287
|
+
font-size: 10px;
|
|
288
|
+
text-transform: uppercase;
|
|
289
|
+
letter-spacing: var(--tracking-widest);
|
|
290
|
+
color: var(--text-faint);
|
|
291
|
+
}
|
|
292
|
+
.cpub-admin-layouts-canvas-zone-body { padding: var(--space-4); }
|
|
293
|
+
|
|
294
|
+
/* ------------------------------------------------------------------ */
|
|
295
|
+
/* "+ Add row" button at the bottom of each zone. Dashed border picks */
|
|
296
|
+
/* up the existing editor-chrome convention (dashed hover outlines on */
|
|
297
|
+
/* rows / empty rows). Full-width within the zone so it reads as a */
|
|
298
|
+
/* drop target peer, not an icon button. */
|
|
299
|
+
/* ------------------------------------------------------------------ */
|
|
300
|
+
.cpub-admin-layouts-canvas-add-row {
|
|
301
|
+
display: flex;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: center;
|
|
304
|
+
gap: var(--space-2);
|
|
305
|
+
width: 100%;
|
|
306
|
+
padding: var(--space-3);
|
|
307
|
+
margin-top: var(--space-3);
|
|
308
|
+
background: transparent;
|
|
309
|
+
border: 1px dashed var(--border2);
|
|
310
|
+
color: var(--text-dim);
|
|
311
|
+
font-family: var(--font-mono);
|
|
312
|
+
font-size: var(--text-xs);
|
|
313
|
+
text-transform: uppercase;
|
|
314
|
+
letter-spacing: var(--tracking-wide);
|
|
315
|
+
cursor: pointer;
|
|
316
|
+
transition: color var(--transition-default), border-color var(--transition-default), background var(--transition-default);
|
|
317
|
+
}
|
|
318
|
+
.cpub-admin-layouts-canvas-add-row:hover {
|
|
319
|
+
color: var(--accent);
|
|
320
|
+
border-color: var(--accent);
|
|
321
|
+
border-style: solid;
|
|
322
|
+
background: var(--accent-bg);
|
|
323
|
+
}
|
|
324
|
+
.cpub-admin-layouts-canvas-add-row:focus-visible {
|
|
325
|
+
outline: 2px solid var(--accent);
|
|
326
|
+
outline-offset: 2px;
|
|
327
|
+
color: var(--accent);
|
|
328
|
+
}
|
|
329
|
+
@media (prefers-reduced-motion: reduce) {
|
|
330
|
+
.cpub-admin-layouts-canvas-add-row { transition: none; }
|
|
331
|
+
}
|
|
332
|
+
</style>
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Conflict resolution modal — appears when the server returns 409
|
|
4
|
+
* on a save (another admin edited the layout in the same window).
|
|
5
|
+
*
|
|
6
|
+
* Phase 3a.6 ships THREE options (per session-160 UX audit — the
|
|
7
|
+
* two-option pattern forces a misclick risk; the safe-middle option
|
|
8
|
+
* lets the user step back without committing):
|
|
9
|
+
* - "Reload their version" — re-fetch from server; LOCAL CHANGES LOST.
|
|
10
|
+
* This is the default-focused safe action.
|
|
11
|
+
* - "Keep editing here" — closes the modal; the user can copy text
|
|
12
|
+
* out or decide later. Sticky banner reminds them they're in conflict.
|
|
13
|
+
* - "Overwrite their changes" — re-send PUT without If-Match; their
|
|
14
|
+
* edits are lost. Styled destructive; not at button-peer level with
|
|
15
|
+
* the safe options (right side, red border).
|
|
16
|
+
*
|
|
17
|
+
* Per UX research synthesis (Notion/XWiki/Webflow patterns): "Force
|
|
18
|
+
* save" terminology is bureaucratic and doesn't name the consequence.
|
|
19
|
+
* "Overwrite their changes" names what actually happens. A real block-
|
|
20
|
+
* level diff is deferred to Phase 7 (versioning UI).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
|
24
|
+
|
|
25
|
+
const props = defineProps<{
|
|
26
|
+
/** Show/hide the modal. */
|
|
27
|
+
open: boolean;
|
|
28
|
+
/** Optional error message from the server. */
|
|
29
|
+
message?: string | null;
|
|
30
|
+
}>();
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
(e: 'refresh'): void;
|
|
34
|
+
(e: 'force-save'): void;
|
|
35
|
+
(e: 'close'): void;
|
|
36
|
+
}>();
|
|
37
|
+
|
|
38
|
+
// Focus the safe primary action when the modal opens. WCAG dialog
|
|
39
|
+
// pattern: initial focus on the recommended-action button so screen
|
|
40
|
+
// readers + keyboard users land on the safe choice, not the destructive
|
|
41
|
+
// one. (Tab can still walk to the destructive button after.)
|
|
42
|
+
//
|
|
43
|
+
// `immediate: true` so initial mounting with :open=true also focuses
|
|
44
|
+
// (catches the common-case where the parent toggles conflictOpen=true
|
|
45
|
+
// and Vue re-renders the modal subtree from scratch).
|
|
46
|
+
const primaryBtn = ref<HTMLButtonElement | null>(null);
|
|
47
|
+
const dialogEl = ref<HTMLElement | null>(null);
|
|
48
|
+
watch(
|
|
49
|
+
() => props.open,
|
|
50
|
+
async (isOpen) => {
|
|
51
|
+
if (isOpen) {
|
|
52
|
+
await nextTick();
|
|
53
|
+
primaryBtn.value?.focus();
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{ immediate: true },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Esc to dismiss (per dialog ARIA pattern). The :open guard makes this
|
|
60
|
+
// a no-op when the modal is closed; listener attached on client mount only.
|
|
61
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
62
|
+
if (props.open && e.key === 'Escape') {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
emit('close');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Focus trap (session 165 round 5 — mirrors HelpOverlay's pattern).
|
|
69
|
+
// When focus leaves the dialog while open, snap it back to the
|
|
70
|
+
// safe-action primary button. The `dialog.contains(target)` check
|
|
71
|
+
// allows free focus movement within the dialog (Tab walks Reload →
|
|
72
|
+
// Keep editing → Overwrite, then wraps back to Reload via snap-back).
|
|
73
|
+
// Forward-compatible — any future focusable added inside the dialog
|
|
74
|
+
// naturally participates via the contains check.
|
|
75
|
+
//
|
|
76
|
+
// Topmost-only guard: if a later-mounted dialog is on top (rare; the
|
|
77
|
+
// editor doesn't normally stack modals, and the parent coordinator
|
|
78
|
+
// in [id].vue closes HelpOverlay when this one opens), let that
|
|
79
|
+
// dialog's own trap own focus. Without the guard, two modals' traps
|
|
80
|
+
// would fight in a focus ping-pong.
|
|
81
|
+
function isTopmostDialog(): boolean {
|
|
82
|
+
if (typeof document === 'undefined') return false;
|
|
83
|
+
if (!dialogEl.value) return false;
|
|
84
|
+
const all = document.querySelectorAll('[role="dialog"], [role="alertdialog"]');
|
|
85
|
+
return all[all.length - 1] === dialogEl.value;
|
|
86
|
+
}
|
|
87
|
+
function onFocusIn(e: FocusEvent): void {
|
|
88
|
+
if (!props.open) return;
|
|
89
|
+
if (!isTopmostDialog()) return;
|
|
90
|
+
const target = e.target as Node | null;
|
|
91
|
+
if (!target) return;
|
|
92
|
+
const dlg = dialogEl.value;
|
|
93
|
+
if (!dlg) return;
|
|
94
|
+
if (dlg.contains(target)) return;
|
|
95
|
+
primaryBtn.value?.focus();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
onMounted(() => {
|
|
99
|
+
if (typeof window !== 'undefined') {
|
|
100
|
+
window.addEventListener('keydown', onKeydown);
|
|
101
|
+
document.addEventListener('focusin', onFocusIn);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
onBeforeUnmount(() => {
|
|
105
|
+
if (typeof window !== 'undefined') {
|
|
106
|
+
window.removeEventListener('keydown', onKeydown);
|
|
107
|
+
document.removeEventListener('focusin', onFocusIn);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<template>
|
|
113
|
+
<Teleport to="body">
|
|
114
|
+
<div
|
|
115
|
+
v-if="open"
|
|
116
|
+
class="cpub-admin-layouts-conflict-backdrop"
|
|
117
|
+
role="presentation"
|
|
118
|
+
@click.self="emit('close')"
|
|
119
|
+
>
|
|
120
|
+
<div
|
|
121
|
+
ref="dialogEl"
|
|
122
|
+
class="cpub-admin-layouts-conflict-modal"
|
|
123
|
+
role="alertdialog"
|
|
124
|
+
aria-modal="true"
|
|
125
|
+
aria-labelledby="cpub-admin-layouts-conflict-title"
|
|
126
|
+
aria-describedby="cpub-admin-layouts-conflict-body"
|
|
127
|
+
>
|
|
128
|
+
<header class="cpub-admin-layouts-conflict-header">
|
|
129
|
+
<i class="fa-solid fa-triangle-exclamation cpub-admin-layouts-conflict-icon"></i>
|
|
130
|
+
<h2 id="cpub-admin-layouts-conflict-title" class="cpub-admin-layouts-conflict-title">
|
|
131
|
+
Version conflict
|
|
132
|
+
</h2>
|
|
133
|
+
</header>
|
|
134
|
+
<div id="cpub-admin-layouts-conflict-body" class="cpub-admin-layouts-conflict-body">
|
|
135
|
+
<p>{{ message ?? 'Another admin saved this layout while you were editing.' }}</p>
|
|
136
|
+
<p class="cpub-admin-layouts-conflict-body-hint">
|
|
137
|
+
Reload their version (recommended) — or keep your edits visible so you can copy what
|
|
138
|
+
you need before deciding. Overwriting their changes is destructive and final.
|
|
139
|
+
</p>
|
|
140
|
+
</div>
|
|
141
|
+
<footer class="cpub-admin-layouts-conflict-footer">
|
|
142
|
+
<button
|
|
143
|
+
ref="primaryBtn"
|
|
144
|
+
type="button"
|
|
145
|
+
class="cpub-admin-layouts-conflict-btn cpub-admin-layouts-conflict-btn--primary"
|
|
146
|
+
@click="emit('refresh')"
|
|
147
|
+
>
|
|
148
|
+
<i class="fa-solid fa-arrows-rotate"></i>
|
|
149
|
+
<span>Reload their version</span>
|
|
150
|
+
</button>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
class="cpub-admin-layouts-conflict-btn"
|
|
154
|
+
@click="emit('close')"
|
|
155
|
+
>
|
|
156
|
+
<i class="fa-solid fa-pause"></i>
|
|
157
|
+
<span>Keep editing here</span>
|
|
158
|
+
</button>
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
class="cpub-admin-layouts-conflict-btn cpub-admin-layouts-conflict-btn--danger"
|
|
162
|
+
@click="emit('force-save')"
|
|
163
|
+
>
|
|
164
|
+
<i class="fa-solid fa-arrow-up-from-bracket"></i>
|
|
165
|
+
<span>Overwrite their changes</span>
|
|
166
|
+
</button>
|
|
167
|
+
</footer>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</Teleport>
|
|
171
|
+
</template>
|
|
172
|
+
|
|
173
|
+
<style scoped>
|
|
174
|
+
.cpub-admin-layouts-conflict-backdrop {
|
|
175
|
+
position: fixed;
|
|
176
|
+
inset: 0;
|
|
177
|
+
background: var(--color-surface-overlay, rgba(0, 0, 0, 0.5));
|
|
178
|
+
display: flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
justify-content: center;
|
|
181
|
+
z-index: 1000;
|
|
182
|
+
padding: var(--space-4);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.cpub-admin-layouts-conflict-modal {
|
|
186
|
+
background: var(--surface);
|
|
187
|
+
border: var(--border-width-default) solid var(--border);
|
|
188
|
+
box-shadow: var(--shadow-lg);
|
|
189
|
+
max-width: 480px;
|
|
190
|
+
width: 100%;
|
|
191
|
+
display: flex;
|
|
192
|
+
flex-direction: column;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.cpub-admin-layouts-conflict-header {
|
|
196
|
+
display: flex;
|
|
197
|
+
align-items: center;
|
|
198
|
+
gap: var(--space-3);
|
|
199
|
+
padding: var(--space-4);
|
|
200
|
+
border-bottom: 1px solid var(--border2);
|
|
201
|
+
}
|
|
202
|
+
.cpub-admin-layouts-conflict-icon {
|
|
203
|
+
font-size: var(--text-xl);
|
|
204
|
+
color: var(--red);
|
|
205
|
+
}
|
|
206
|
+
.cpub-admin-layouts-conflict-title {
|
|
207
|
+
font-size: var(--text-lg);
|
|
208
|
+
font-weight: var(--font-weight-bold);
|
|
209
|
+
margin: 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.cpub-admin-layouts-conflict-body {
|
|
213
|
+
padding: var(--space-4);
|
|
214
|
+
color: var(--text);
|
|
215
|
+
display: flex;
|
|
216
|
+
flex-direction: column;
|
|
217
|
+
gap: var(--space-3);
|
|
218
|
+
}
|
|
219
|
+
.cpub-admin-layouts-conflict-body-hint {
|
|
220
|
+
font-size: var(--text-sm);
|
|
221
|
+
color: var(--text-dim);
|
|
222
|
+
margin: 0;
|
|
223
|
+
}
|
|
224
|
+
.cpub-admin-layouts-conflict-body p { margin: 0; }
|
|
225
|
+
|
|
226
|
+
.cpub-admin-layouts-conflict-footer {
|
|
227
|
+
display: flex;
|
|
228
|
+
gap: var(--space-2);
|
|
229
|
+
padding: var(--space-4);
|
|
230
|
+
border-top: 1px solid var(--border2);
|
|
231
|
+
justify-content: flex-end;
|
|
232
|
+
flex-wrap: wrap;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.cpub-admin-layouts-conflict-btn {
|
|
236
|
+
display: inline-flex;
|
|
237
|
+
align-items: center;
|
|
238
|
+
gap: var(--space-1);
|
|
239
|
+
padding: var(--space-2) var(--space-3);
|
|
240
|
+
background: var(--surface);
|
|
241
|
+
border: var(--border-width-default) solid var(--border);
|
|
242
|
+
color: var(--text);
|
|
243
|
+
font-family: var(--font-mono);
|
|
244
|
+
font-size: var(--text-xs);
|
|
245
|
+
text-transform: uppercase;
|
|
246
|
+
letter-spacing: var(--tracking-wide);
|
|
247
|
+
cursor: pointer;
|
|
248
|
+
}
|
|
249
|
+
.cpub-admin-layouts-conflict-btn:hover { background: var(--surface2); }
|
|
250
|
+
.cpub-admin-layouts-conflict-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
251
|
+
.cpub-admin-layouts-conflict-btn--primary {
|
|
252
|
+
background: var(--accent);
|
|
253
|
+
color: var(--surface);
|
|
254
|
+
border-color: var(--accent);
|
|
255
|
+
}
|
|
256
|
+
.cpub-admin-layouts-conflict-btn--primary:hover {
|
|
257
|
+
background: var(--accent);
|
|
258
|
+
filter: brightness(1.1);
|
|
259
|
+
color: var(--surface);
|
|
260
|
+
}
|
|
261
|
+
.cpub-admin-layouts-conflict-btn--danger {
|
|
262
|
+
color: var(--red);
|
|
263
|
+
border-color: var(--red);
|
|
264
|
+
}
|
|
265
|
+
.cpub-admin-layouts-conflict-btn--danger:hover { background: var(--red); color: var(--surface); }
|
|
266
|
+
</style>
|