@commonpub/layer 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +41 -12
  2. package/components/LayoutRow.vue +944 -0
  3. package/components/LayoutSection.vue +1028 -0
  4. package/components/LayoutSlot.vue +104 -162
  5. package/components/PageFrame.vue +116 -0
  6. package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
  7. package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
  8. package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
  9. package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
  10. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
  11. package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
  13. package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
  14. package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
  15. package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
  16. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
  17. package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
  18. package/components/blocks/BlockDividerView.vue +52 -2
  19. package/components/homepage/ContentGridSection.vue +23 -1
  20. package/components/homepage/HeroSection.vue +69 -8
  21. package/components/sections/SectionCta.vue +175 -0
  22. package/composables/autoFormSchema.ts +319 -0
  23. package/composables/useAdminSidebar.ts +116 -0
  24. package/composables/useEditorChrome.ts +56 -0
  25. package/composables/useLayout.ts +34 -41
  26. package/composables/useLayoutAnnouncer.ts +332 -0
  27. package/composables/useLayoutAutoSave.ts +117 -0
  28. package/composables/useLayoutDrag.ts +290 -0
  29. package/composables/useLayoutEditor.ts +593 -0
  30. package/composables/useLayoutHistory.ts +583 -0
  31. package/composables/useLayoutHotkeys.ts +366 -0
  32. package/composables/useLayoutResize.ts +783 -0
  33. package/layouts/admin.vue +137 -24
  34. package/middleware/admin-layouts.ts +29 -0
  35. package/package.json +10 -7
  36. package/pages/[...customPath].vue +154 -0
  37. package/pages/admin/homepage.vue +46 -0
  38. package/pages/admin/index.vue +16 -0
  39. package/pages/admin/layouts/[id].vue +1110 -0
  40. package/pages/admin/layouts/index.vue +356 -0
  41. package/pages/explore.vue +16 -6
  42. package/sections/builtin/content-feed.ts +18 -29
  43. package/sections/builtin/contests.ts +11 -19
  44. package/sections/builtin/cta.ts +46 -0
  45. package/sections/builtin/custom-html.ts +16 -30
  46. package/sections/builtin/divider.ts +15 -17
  47. package/sections/builtin/editorial.ts +11 -21
  48. package/sections/builtin/embed.ts +31 -0
  49. package/sections/builtin/gallery.ts +29 -0
  50. package/sections/builtin/heading.ts +14 -19
  51. package/sections/builtin/hero.ts +16 -51
  52. package/sections/builtin/hubs.ts +11 -26
  53. package/sections/builtin/image.ts +12 -49
  54. package/sections/builtin/learning.ts +5 -13
  55. package/sections/builtin/markdown.ts +29 -0
  56. package/sections/builtin/paragraph.ts +14 -17
  57. package/sections/builtin/stats.ts +17 -18
  58. package/sections/builtin/video.ts +30 -0
  59. package/sections/registry.ts +11 -0
  60. package/server/api/admin/homepage/sections.put.ts +52 -1
  61. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  62. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  63. package/server/api/admin/layouts/[id].delete.ts +33 -1
  64. package/server/api/admin/layouts/[id].put.ts +78 -0
  65. package/server/api/admin/layouts/index.post.ts +60 -4
  66. package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
  67. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  68. package/server/api/layouts/by-route.get.ts +64 -12
  69. package/server/utils/layoutCache.ts +37 -1
  70. package/server/utils/validateSectionConfigs.ts +123 -0
  71. package/theme/base.css +1 -0
  72. package/components/sections/SectionContentFeed.vue +0 -160
  73. package/components/sections/SectionContests.vue +0 -193
  74. package/components/sections/SectionCustomHtml.vue +0 -70
  75. package/components/sections/SectionDivider.vue +0 -55
  76. package/components/sections/SectionEditorial.vue +0 -138
  77. package/components/sections/SectionHeading.vue +0 -78
  78. package/components/sections/SectionHero.vue +0 -164
  79. package/components/sections/SectionHubs.vue +0 -247
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
  82. package/components/sections/SectionStats.vue +0 -151
@@ -0,0 +1,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>