@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,1110 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* /admin/layouts/[id] — editor shell (Phase 3a.3 + 3a.5).
|
|
4
|
+
*
|
|
5
|
+
* Three-column orchestrator with a sticky top toolbar:
|
|
6
|
+
* - Toolbar (3a.5): back-link + name + state + viewport segmented
|
|
7
|
+
* control + save indicator + Save/Publish buttons
|
|
8
|
+
* - Palette (left) — every registered section, grouped by category
|
|
9
|
+
* - Canvas (center) — <LayoutSlot :editable previewOverride=draft>
|
|
10
|
+
* - Inspector (right) — page-meta form (3a.4); section/row forms
|
|
11
|
+
* arrive alongside drag-drop in 3b/3f
|
|
12
|
+
*
|
|
13
|
+
* State lives in `useLayoutEditor(id)` — draft + original + dirty +
|
|
14
|
+
* save/publish/refresh/discard. Auto-save (3a.6) is wired through
|
|
15
|
+
* `useLayoutAutoSave` watching `editor.dirty`.
|
|
16
|
+
*/
|
|
17
|
+
import type { LayoutRecord } from '@commonpub/server';
|
|
18
|
+
import { PublishStepError } from '../../../composables/useLayoutEditor';
|
|
19
|
+
import { useLayoutAnnouncer, narrateUndo, narrateRedo, narrateUndoEmpty, narrateRedoEmpty, narrateRowAdded, narrateRowRemoved } from '../../../composables/useLayoutAnnouncer';
|
|
20
|
+
import { useLayoutHistory, addRowCommand, removeRowCommand } from '../../../composables/useLayoutHistory';
|
|
21
|
+
import { useLayoutHotkeys } from '../../../composables/useLayoutHotkeys';
|
|
22
|
+
import { DnDProvider } from '@vue-dnd-kit/core';
|
|
23
|
+
import { useSectionRegistry } from '../../../sections/registry';
|
|
24
|
+
import { useLayoutResize } from '../../../composables/useLayoutResize';
|
|
25
|
+
|
|
26
|
+
definePageMeta({
|
|
27
|
+
layout: 'admin',
|
|
28
|
+
middleware: ['auth', 'admin-layouts'],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const route = useRoute();
|
|
32
|
+
const toast = useToast();
|
|
33
|
+
const id = computed<string>(() => String(route.params.id));
|
|
34
|
+
|
|
35
|
+
const editor = useLayoutEditor(id.value);
|
|
36
|
+
const history = useLayoutHistory();
|
|
37
|
+
|
|
38
|
+
// Phase 3b/B + 3d: window-level keyboard shortcuts. The composable
|
|
39
|
+
// attaches on mount + detaches on unmount; input/textarea/contenteditable
|
|
40
|
+
// focus skips so the browser's native text editing wins. Phase 3d adds:
|
|
41
|
+
// - Backspace / Delete = remove the selected section
|
|
42
|
+
// - Cmd/Ctrl+D = duplicate the selected section
|
|
43
|
+
// - ? = open the keyboard shortcuts help overlay
|
|
44
|
+
const helpOpen = ref<boolean>(false);
|
|
45
|
+
const sectionRegistry = useSectionRegistry();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Phase 3c — bounds lookup for the Shift+Arrow keyboard resize. Walks
|
|
49
|
+
* the live draft to find the section's host row + its right neighbour,
|
|
50
|
+
* then reads the registry for min/max colSpan. Returning null silences
|
|
51
|
+
* the binding for unresizable / unregistered sections.
|
|
52
|
+
*
|
|
53
|
+
* Lives at the editor page level (vs inside useLayoutHotkeys directly)
|
|
54
|
+
* because the closure needs the LIVE editor.draft + the registry —
|
|
55
|
+
* passing both as closures from setup mirrors the existing
|
|
56
|
+
* `getDraft` / `getSelection` pattern in `useLayoutHotkeys` and
|
|
57
|
+
* keeps the same semantic shape the LayoutRow's
|
|
58
|
+
* `resizeHandlerForSection` already uses.
|
|
59
|
+
*/
|
|
60
|
+
function lookupResizeBounds(sectionId: string) {
|
|
61
|
+
const draft = editor.draft.value;
|
|
62
|
+
if (!draft) return null;
|
|
63
|
+
// Walk to find host row + index. Inline rather than importing
|
|
64
|
+
// findSectionLocation to keep the search self-contained at the editor
|
|
65
|
+
// page level + return the precise row id.
|
|
66
|
+
for (const zone of draft.zones) {
|
|
67
|
+
for (const row of zone.rows) {
|
|
68
|
+
const idx = row.sections.findIndex((s) => s.id === sectionId);
|
|
69
|
+
if (idx === -1) continue;
|
|
70
|
+
const section = row.sections[idx];
|
|
71
|
+
if (!section) continue;
|
|
72
|
+
const def = sectionRegistry.get(section.type);
|
|
73
|
+
if (!def || !def.resizable) return null;
|
|
74
|
+
const neighbourSection = idx < row.sections.length - 1
|
|
75
|
+
? row.sections[idx + 1]
|
|
76
|
+
: null;
|
|
77
|
+
const neighbourDef = neighbourSection
|
|
78
|
+
? sectionRegistry.get(neighbourSection.type)
|
|
79
|
+
: null;
|
|
80
|
+
// Session 166 round-2 audit P1: mirror LayoutRow's
|
|
81
|
+
// resizable:false-neighbour rule. If the neighbour explicitly
|
|
82
|
+
// opted out of resize, treat it as fixed-width (effective min =
|
|
83
|
+
// current colSpan) so the keyboard path can't shrink it either.
|
|
84
|
+
const neighbourFixed = !!neighbourSection && neighbourDef?.resizable === false;
|
|
85
|
+
const effectiveNeighbourMin = neighbourFixed
|
|
86
|
+
? (neighbourSection?.colSpan ?? 1)
|
|
87
|
+
: (neighbourDef?.minColSpan ?? 1);
|
|
88
|
+
return {
|
|
89
|
+
sectionType: section.type,
|
|
90
|
+
rowId: row.id,
|
|
91
|
+
sectionMin: def.minColSpan,
|
|
92
|
+
sectionMax: def.maxColSpan,
|
|
93
|
+
neighbour: neighbourSection
|
|
94
|
+
? {
|
|
95
|
+
sectionId: neighbourSection.id,
|
|
96
|
+
min: effectiveNeighbourMin,
|
|
97
|
+
max: neighbourDef?.maxColSpan ?? 12,
|
|
98
|
+
}
|
|
99
|
+
: null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
useLayoutHotkeys({
|
|
107
|
+
getDraft: () => editor.draft.value,
|
|
108
|
+
getSelection: () => editor.selectedId.value,
|
|
109
|
+
setSelection: (sel) => editor.select(sel),
|
|
110
|
+
onShowHelp: () => { helpOpen.value = true; },
|
|
111
|
+
lookupResizeBounds,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Toolbar undo / redo emit handlers — wire to the same history singleton
|
|
115
|
+
// the hotkey uses + the same announcer narration. Tooltip text comes
|
|
116
|
+
// from `history.lastLabel` / `nextLabel` so the user can see WHICH
|
|
117
|
+
// command they're about to undo without taking action.
|
|
118
|
+
function onToolbarUndo(): void {
|
|
119
|
+
const draft = editor.draft.value;
|
|
120
|
+
if (!draft) return;
|
|
121
|
+
const ann = useLayoutAnnouncer();
|
|
122
|
+
const cmd = history.undo(draft);
|
|
123
|
+
ann.announcePolite(cmd ? narrateUndo(cmd.label) : narrateUndoEmpty());
|
|
124
|
+
}
|
|
125
|
+
function onToolbarRedo(): void {
|
|
126
|
+
const draft = editor.draft.value;
|
|
127
|
+
if (!draft) return;
|
|
128
|
+
const ann = useLayoutAnnouncer();
|
|
129
|
+
const cmd = history.redo(draft);
|
|
130
|
+
ann.announcePolite(cmd ? narrateRedo(cmd.label) : narrateRedoEmpty());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Session 164 polish — "+ Add row" handler. Closes the v1 blocker
|
|
135
|
+
* where a fresh layout (or one with an empty zone) had no drop target.
|
|
136
|
+
*
|
|
137
|
+
* Mutates draft directly so the existing dirty watcher fires +
|
|
138
|
+
* auto-save schedules. Records to history so Cmd+Z removes the row
|
|
139
|
+
* (the addRowCommand's invert handles this). Narrates via assertive
|
|
140
|
+
* channel — "Row added" is a state change like drag/drop, not
|
|
141
|
+
* informational like undo.
|
|
142
|
+
*/
|
|
143
|
+
function onAddRow(zoneSlug: string): void {
|
|
144
|
+
const draft = editor.draft.value;
|
|
145
|
+
if (!draft) return;
|
|
146
|
+
const zone = draft.zones.find((z) => z.zone === zoneSlug);
|
|
147
|
+
if (!zone) return;
|
|
148
|
+
const newRow = {
|
|
149
|
+
id: crypto.randomUUID(),
|
|
150
|
+
order: zone.rows.length,
|
|
151
|
+
config: null,
|
|
152
|
+
sections: [],
|
|
153
|
+
};
|
|
154
|
+
const position = zone.rows.length;
|
|
155
|
+
zone.rows.push(newRow);
|
|
156
|
+
const ann = useLayoutAnnouncer();
|
|
157
|
+
ann.announce(narrateRowAdded(zoneSlug, position, zone.rows.length));
|
|
158
|
+
history.record(addRowCommand({
|
|
159
|
+
zoneSlug,
|
|
160
|
+
position,
|
|
161
|
+
row: newRow,
|
|
162
|
+
label: `add row to ${zoneSlug}`,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Session 164 polish — Remove row. Confirm before removing rows with
|
|
168
|
+
* sections (destructive intent: section data goes away, only restorable
|
|
169
|
+
* via Cmd+Z within the same editor session). Empty rows skip confirm —
|
|
170
|
+
* they were the Add Row button's leftover dashed placeholder; removing
|
|
171
|
+
* is a recovery action, not a destruction.
|
|
172
|
+
*/
|
|
173
|
+
function onRemoveRow(zoneSlug: string, rowId: string): void {
|
|
174
|
+
const draft = editor.draft.value;
|
|
175
|
+
if (!draft) return;
|
|
176
|
+
const zone = draft.zones.find((z) => z.zone === zoneSlug);
|
|
177
|
+
if (!zone) return;
|
|
178
|
+
const idx = zone.rows.findIndex((r) => r.id === rowId);
|
|
179
|
+
if (idx === -1) return;
|
|
180
|
+
const row = zone.rows[idx];
|
|
181
|
+
if (!row) return;
|
|
182
|
+
if (row.sections.length > 0) {
|
|
183
|
+
const sectionWord = row.sections.length === 1 ? 'section' : 'sections';
|
|
184
|
+
const ok = window.confirm(
|
|
185
|
+
`Remove this row and its ${row.sections.length} ${sectionWord}? `
|
|
186
|
+
+ `Cmd+Z restores it within this session.`,
|
|
187
|
+
);
|
|
188
|
+
if (!ok) return;
|
|
189
|
+
}
|
|
190
|
+
const position = idx;
|
|
191
|
+
// Capture the row's full state BEFORE splice so the command's invert
|
|
192
|
+
// can restore sections + config too.
|
|
193
|
+
const rowClone = JSON.parse(JSON.stringify(row));
|
|
194
|
+
zone.rows.splice(idx, 1);
|
|
195
|
+
const ann = useLayoutAnnouncer();
|
|
196
|
+
ann.announce(narrateRowRemoved(zoneSlug));
|
|
197
|
+
history.record(removeRowCommand({
|
|
198
|
+
zoneSlug,
|
|
199
|
+
position,
|
|
200
|
+
row: rowClone,
|
|
201
|
+
label: `remove row from ${zoneSlug}`,
|
|
202
|
+
}));
|
|
203
|
+
// Clear selection if the removed row contained the currently-selected
|
|
204
|
+
// section/row (it's no longer in draft).
|
|
205
|
+
const sel = editor.selectedId.value;
|
|
206
|
+
if (sel) {
|
|
207
|
+
if (sel.kind === 'row' && sel.id === rowId) editor.clearSelection();
|
|
208
|
+
else if (sel.kind === 'section' && rowClone.sections.some((s: { id: string }) => s.id === sel.id)) {
|
|
209
|
+
editor.clearSelection();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Palette + inspector visibility — persists per-admin via cookie so the
|
|
215
|
+
// admin's last layout (e.g. "I always work with inspector hidden, palette
|
|
216
|
+
// visible") sticks across sessions. Session 161 user-reported squish fix.
|
|
217
|
+
const chrome = useEditorChrome();
|
|
218
|
+
|
|
219
|
+
// SSR-prime: fetch the layout via useFetch (hydration-safe), then
|
|
220
|
+
// hand it to the editor composable. The composable also exposes
|
|
221
|
+
// refresh() for client-only re-fetches (after publish, etc).
|
|
222
|
+
const { data: initial, error } = await useFetch<LayoutRecord>(
|
|
223
|
+
`/api/admin/layouts/${id.value}`,
|
|
224
|
+
);
|
|
225
|
+
if (initial.value) {
|
|
226
|
+
editor.original.value = initial.value;
|
|
227
|
+
editor.draft.value = JSON.parse(JSON.stringify(initial.value));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Phase 3b/B: clear undo history at seed time. The history singleton is
|
|
231
|
+
// module-scoped, so opening a different layout could otherwise inherit
|
|
232
|
+
// the previous editor's stack — Cmd+Z would undo into the wrong draft.
|
|
233
|
+
// Same rule applies on refresh + save success (handled below via watch).
|
|
234
|
+
history.clear();
|
|
235
|
+
|
|
236
|
+
useSeoMeta({
|
|
237
|
+
title: () => `Edit: ${editor.draft.value?.name ?? 'Layout'} — Admin — ${useSiteName()}`,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Viewport preview state — purely UI; doesn't mutate the layout.
|
|
241
|
+
const viewport = ref<'mobile' | 'tablet' | 'desktop'>('desktop');
|
|
242
|
+
|
|
243
|
+
// Conflict modal visibility — flips true when save() returns 409.
|
|
244
|
+
const conflictOpen = ref<boolean>(false);
|
|
245
|
+
|
|
246
|
+
// R4 audit P1 fix: unsaved-edit guards. Without these, the user can
|
|
247
|
+
// navigate (back button, sidebar nav, typed URL) between the last edit
|
|
248
|
+
// and the 1500ms debounce firing → silent data loss. visibilitychange
|
|
249
|
+
// flush handles Cmd+Tab/minimize but NOT in-app navigation.
|
|
250
|
+
//
|
|
251
|
+
// Three guards layered for the unique failure modes each covers:
|
|
252
|
+
// 1. onBeforeRouteLeave — fires on Nuxt navigation (sidebar links,
|
|
253
|
+
// router.push, NuxtLink). Confirms with the user; if they cancel,
|
|
254
|
+
// navigation aborts and they stay on the editor.
|
|
255
|
+
// 2. beforeunload — fires on tab close, reload, or external nav.
|
|
256
|
+
// Modern browsers ignore the message string and show their generic
|
|
257
|
+
// prompt; setting preventDefault is enough to trigger it. Does
|
|
258
|
+
// NOT fire on iOS Safari.
|
|
259
|
+
// 3. pagehide → editor.flushBeacon() — session 162 P2.3. The only
|
|
260
|
+
// event that fires reliably on tab-close + bfcache eviction +
|
|
261
|
+
// iOS Safari. Sends the unsaved draft via fetch(keepalive:true)
|
|
262
|
+
// so it survives page teardown when the user closes the tab
|
|
263
|
+
// inside the auto-save debounce window.
|
|
264
|
+
function onBeforeUnload(e: BeforeUnloadEvent): void {
|
|
265
|
+
if (editor.dirty.value) {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
// Some browsers still read returnValue; set for compatibility.
|
|
268
|
+
e.returnValue = '';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function onPageHide(): void {
|
|
272
|
+
// Fire-and-forget — the page may be teardowning RIGHT NOW. The
|
|
273
|
+
// beacon's keepalive flag is what makes the request survive.
|
|
274
|
+
editor.flushBeacon();
|
|
275
|
+
}
|
|
276
|
+
onMounted(() => {
|
|
277
|
+
if (typeof window !== 'undefined') {
|
|
278
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
279
|
+
window.addEventListener('pagehide', onPageHide);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
onBeforeUnmount(() => {
|
|
283
|
+
if (typeof window !== 'undefined') {
|
|
284
|
+
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
285
|
+
window.removeEventListener('pagehide', onPageHide);
|
|
286
|
+
}
|
|
287
|
+
// R4 P2 (session 161): cancel any in-flight save. Without this, a save
|
|
288
|
+
// started before unmount lands afterward as an "orphan" PUT — which can
|
|
289
|
+
// cause stale 409s the next time the user opens the editor in another
|
|
290
|
+
// tab (server bumped updatedAt; client's cached If-Match is stale).
|
|
291
|
+
editor.abort();
|
|
292
|
+
// Session 163 deep audit: useLayoutAnnouncer is a module-scope
|
|
293
|
+
// singleton with a 1.2s auto-clear setTimeout. Without explicit
|
|
294
|
+
// clear() on unmount, the message + pending timer leak across editor
|
|
295
|
+
// mounts — closing layout A while a Move announcement is mid-cycle
|
|
296
|
+
// would show that stale message on the next editor open. Two agents
|
|
297
|
+
// independently caught this (Agent A + Agent B).
|
|
298
|
+
useLayoutAnnouncer().clear();
|
|
299
|
+
// Session 166 R3-6 audit: useLayoutResize is also a module-scope
|
|
300
|
+
// singleton. If the user navigates away mid-drag (e.g. unsaved-edit
|
|
301
|
+
// guard accepts), the resize state stays 'resizing' across editor
|
|
302
|
+
// mounts. The composable's defensive recovery in startResize would
|
|
303
|
+
// commit-and-replace, but until then the state is stale + the
|
|
304
|
+
// document handlers are still attached. Explicit cancel matches the
|
|
305
|
+
// announcer pattern + closes the leak fully.
|
|
306
|
+
useLayoutResize().cancelResize();
|
|
307
|
+
});
|
|
308
|
+
onBeforeRouteLeave((_to, _from, next) => {
|
|
309
|
+
if (!editor.dirty.value) return next();
|
|
310
|
+
const ok = window.confirm(
|
|
311
|
+
'You have unsaved changes that haven’t auto-saved yet. Leave anyway?',
|
|
312
|
+
);
|
|
313
|
+
return next(ok);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Auto-save: watches editor.dirty, debounces 1.5s, calls editor.save().
|
|
317
|
+
// Composable handles unmount cleanup. Session 162 P2.5: pause when the
|
|
318
|
+
// conflict rate exceeds the threshold (3 in 60s) so we stop banging
|
|
319
|
+
// the server while the user reconciles with the other editor; the
|
|
320
|
+
// banner below surfaces this state with a Resume button.
|
|
321
|
+
useLayoutAutoSave({
|
|
322
|
+
dirty: editor.dirty,
|
|
323
|
+
save: () => editor.save(),
|
|
324
|
+
debounceMs: 1500,
|
|
325
|
+
paused: computed(() => editor.conflictThrashing.value),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Surface conflicts from any save (manual or auto) as the modal —
|
|
329
|
+
// EXCEPT when we've already crossed into thrashing. At that point the
|
|
330
|
+
// banner is the single reconciliation surface; the modal on top would
|
|
331
|
+
// be redundant (same actions, more visual noise).
|
|
332
|
+
//
|
|
333
|
+
// Session 165 round 5 — dual-modal coordination: if the user has the
|
|
334
|
+
// keyboard-shortcuts help overlay open and a 409 fires (e.g. auto-save
|
|
335
|
+
// debounce landed on a conflict mid-read), close help so the conflict
|
|
336
|
+
// resolution dialog owns focus exclusively. Both modals have focus
|
|
337
|
+
// traps; without this, the briefly-overlapping mount window would
|
|
338
|
+
// ping-pong focus between them. The topmost-only guard inside each
|
|
339
|
+
// modal's trap covers the brief mount window; this closes the window
|
|
340
|
+
// fully by making the lower modal go away.
|
|
341
|
+
watch(editor.status, (status) => {
|
|
342
|
+
if (status === 'conflict' && !editor.conflictThrashing.value) {
|
|
343
|
+
conflictOpen.value = true;
|
|
344
|
+
helpOpen.value = false;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
/*
|
|
349
|
+
* Phase 3b/B + R2 audit P1 fix: history clears when the SERVER baseline
|
|
350
|
+
* changes (refresh, save success) AND the local draft matches that
|
|
351
|
+
* baseline (dirty=false). One watcher, one gate.
|
|
352
|
+
*
|
|
353
|
+
* Why dirty-gated: the original status-transition approach cleared on
|
|
354
|
+
* every saving→saved transition, but that's wrong when the user undid
|
|
355
|
+
* MID-SAVE. Concretely: save kicks off at t=1500ms; user undos at
|
|
356
|
+
* t=1800ms; save completes at t=2200ms (with the pre-undo snapshot
|
|
357
|
+
* persisted). After save: original = pre-undo state; draft = post-undo
|
|
358
|
+
* state; dirty = TRUE. Clearing history here would nuke the redo branch
|
|
359
|
+
* for an undo that hasn't actually been persisted yet. The dirty gate
|
|
360
|
+
* ensures we only clear when the LOCAL state ALSO matches the server.
|
|
361
|
+
*
|
|
362
|
+
* Why one watcher instead of two: save() reassigns original AND
|
|
363
|
+
* transitions status; refresh() reassigns original AND resets status to
|
|
364
|
+
* 'idle'. Both flow through the original-change. The previous dual-
|
|
365
|
+
* watcher setup fired clear() twice on save (idempotent but indicates
|
|
366
|
+
* architectural confusion).
|
|
367
|
+
*
|
|
368
|
+
* discard() doesn't change original (just draft); its explicit
|
|
369
|
+
* history.clear() call in onDiscard covers that path.
|
|
370
|
+
*/
|
|
371
|
+
watch(() => editor.original.value, (newOriginal, oldOriginal) => {
|
|
372
|
+
if (oldOriginal === null) return; // initial seed; handled at await time
|
|
373
|
+
if (newOriginal === oldOriginal) return;
|
|
374
|
+
// The server baseline changed. If the local draft has un-saved edits
|
|
375
|
+
// on top of it, keep history so the user can still undo them. If
|
|
376
|
+
// draft is in lockstep with original, clear (saved baseline is the
|
|
377
|
+
// new ground truth per plan §7.14).
|
|
378
|
+
if (!editor.dirty.value) {
|
|
379
|
+
history.clear();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// And: if thrashing trips while the modal is open (the 3rd conflict
|
|
384
|
+
// arrives mid-modal), close the modal so the banner is the only surface.
|
|
385
|
+
// Focus the banner's safe recommended action AFTER the modal unmounts +
|
|
386
|
+
// banner mounts — without this, the previously-focused modal button
|
|
387
|
+
// disappears and focus falls back to <body>, stranding keyboard users.
|
|
388
|
+
// Only steal focus when the modal WAS open; otherwise the banner's
|
|
389
|
+
// role="alert" announces it without disrupting wherever the user was.
|
|
390
|
+
const thrashPrimaryBtn = ref<HTMLButtonElement | null>(null);
|
|
391
|
+
watch(() => editor.conflictThrashing.value, async (thrashing) => {
|
|
392
|
+
if (!thrashing) return;
|
|
393
|
+
if (!conflictOpen.value) return;
|
|
394
|
+
conflictOpen.value = false;
|
|
395
|
+
await nextTick();
|
|
396
|
+
thrashPrimaryBtn.value?.focus();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
function onResumeAutoSave(): void {
|
|
400
|
+
editor.clearConflictHistory();
|
|
401
|
+
toast.success('Auto-save resumed');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function onPageMetaUpdate(value: LayoutRecord['pageMeta']): void {
|
|
405
|
+
if (!editor.draft.value) return;
|
|
406
|
+
editor.draft.value.pageMeta = value;
|
|
407
|
+
}
|
|
408
|
+
function onNameUpdate(value: string): void {
|
|
409
|
+
if (!editor.draft.value) return;
|
|
410
|
+
editor.draft.value.name = value;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Phase 3e — section/row config edits from the inspector. Locate the
|
|
415
|
+
* target by id in the live draft + replace its `config` object. The deep
|
|
416
|
+
* `draft` watcher fires → `dirty` flips → the existing 1.5s auto-save
|
|
417
|
+
* debounce persists (single-flight save untouched). Config edits are not
|
|
418
|
+
* yet recorded to the undo stack — plan §7.14's `edit-section-config` op
|
|
419
|
+
* is a follow-up (Phase 3f); page-meta edits have the same gap today.
|
|
420
|
+
*/
|
|
421
|
+
function onSectionConfigUpdate(payload: { id: string; config: Record<string, unknown> }): void {
|
|
422
|
+
const draft = editor.draft.value;
|
|
423
|
+
if (!draft) return;
|
|
424
|
+
for (const zone of draft.zones) {
|
|
425
|
+
for (const row of zone.rows) {
|
|
426
|
+
const section = row.sections.find((s) => s.id === payload.id);
|
|
427
|
+
if (section) {
|
|
428
|
+
section.config = payload.config;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function onRowConfigUpdate(payload: { id: string; config: Record<string, unknown> }): void {
|
|
435
|
+
const draft = editor.draft.value;
|
|
436
|
+
if (!draft) return;
|
|
437
|
+
for (const zone of draft.zones) {
|
|
438
|
+
const row = zone.rows.find((r) => r.id === payload.id);
|
|
439
|
+
if (row) {
|
|
440
|
+
row.config = payload.config;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function onSave(): Promise<void> {
|
|
447
|
+
try {
|
|
448
|
+
await editor.save();
|
|
449
|
+
toast.success('Layout saved');
|
|
450
|
+
} catch (err) {
|
|
451
|
+
const e = err as { statusCode?: number; statusMessage?: string };
|
|
452
|
+
if (e.statusCode === 409) {
|
|
453
|
+
// Modal is already open via the status watcher; no toast (modal is louder).
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
toast.error(e.statusMessage ?? 'Save failed');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function onDiscard(): void {
|
|
461
|
+
// R4 audit P2 fix: surfaces discard() to the UI. Confirms first since
|
|
462
|
+
// discard is destructive (loses unsaved edits).
|
|
463
|
+
if (!editor.dirty.value) return;
|
|
464
|
+
if (!confirm('Discard all unsaved changes? This cannot be undone.')) return;
|
|
465
|
+
editor.discard();
|
|
466
|
+
// Phase 3b/B: discard replaces `draft` with a clone of `original`. The
|
|
467
|
+
// commands in the past stack reference sections + positions that may
|
|
468
|
+
// not exist in the discarded-from state; an undo would re-apply
|
|
469
|
+
// operations that "discard" effectively rolled back. The confirm
|
|
470
|
+
// dialog already warned this is destructive — undo across discard
|
|
471
|
+
// would be more surprising, not less. So clear.
|
|
472
|
+
history.clear();
|
|
473
|
+
toast.success('Unsaved changes discarded');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function onPublish(): Promise<void> {
|
|
477
|
+
if (!confirm('Publish this layout? The current draft replaces the live version.')) return;
|
|
478
|
+
try {
|
|
479
|
+
await editor.publish();
|
|
480
|
+
toast.success('Layout published');
|
|
481
|
+
} catch (err) {
|
|
482
|
+
// Session 162 P2.7: surface WHICH step failed so the admin knows
|
|
483
|
+
// whether their changes are safely saved or lost. Generic
|
|
484
|
+
// "Publish failed" hid the save-succeeded-publish-failed case.
|
|
485
|
+
if (err instanceof PublishStepError) {
|
|
486
|
+
const causeMsg = (err.cause as { statusMessage?: string })?.statusMessage;
|
|
487
|
+
switch (err.step) {
|
|
488
|
+
case 'save':
|
|
489
|
+
toast.error(causeMsg
|
|
490
|
+
? `Could not save your edits (${causeMsg}). Nothing was published.`
|
|
491
|
+
: 'Could not save your edits. Nothing was published.');
|
|
492
|
+
return;
|
|
493
|
+
case 'publish':
|
|
494
|
+
toast.error(
|
|
495
|
+
'Your changes are saved as a draft, but publish failed. ' +
|
|
496
|
+
'Try Publish again — the saved draft is durable.',
|
|
497
|
+
);
|
|
498
|
+
return;
|
|
499
|
+
case 'refresh':
|
|
500
|
+
// The publish succeeded on the server; only the local view
|
|
501
|
+
// is stale. The next save / publish picks up correctly; a
|
|
502
|
+
// reload syncs immediately.
|
|
503
|
+
toast.show(
|
|
504
|
+
'Published — but the editor view is stale. Reload to sync.',
|
|
505
|
+
);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const e = err as { statusMessage?: string };
|
|
510
|
+
toast.error(e.statusMessage ?? 'Publish failed');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function onConflictRefresh(): Promise<void> {
|
|
515
|
+
conflictOpen.value = false;
|
|
516
|
+
try {
|
|
517
|
+
await editor.refresh();
|
|
518
|
+
// Refresh = explicit reconciliation (admin took the other version).
|
|
519
|
+
// Clear the throttle so auto-save resumes; if cascade really
|
|
520
|
+
// persists, the rolling-window will trip again on its own.
|
|
521
|
+
editor.clearConflictHistory();
|
|
522
|
+
toast.success('Refreshed — server state loaded');
|
|
523
|
+
} catch (err) {
|
|
524
|
+
const e = err as { statusMessage?: string };
|
|
525
|
+
toast.error(e.statusMessage ?? 'Refresh failed');
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function onConflictForceSave(): Promise<void> {
|
|
530
|
+
conflictOpen.value = false;
|
|
531
|
+
try {
|
|
532
|
+
await editor.save({ force: true });
|
|
533
|
+
// Force save = explicit reconciliation (admin overwrote with their
|
|
534
|
+
// version). Same rationale as Refresh — resume auto-save.
|
|
535
|
+
editor.clearConflictHistory();
|
|
536
|
+
toast.success('Layout force-saved');
|
|
537
|
+
} catch (err) {
|
|
538
|
+
const e = err as { statusMessage?: string };
|
|
539
|
+
toast.error(e.statusMessage ?? 'Force save failed');
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
</script>
|
|
543
|
+
|
|
544
|
+
<template>
|
|
545
|
+
<div class="cpub-admin-layouts-editor">
|
|
546
|
+
<div v-if="error" class="cpub-admin-layouts-editor-error">
|
|
547
|
+
<i class="fa-solid fa-circle-exclamation"></i>
|
|
548
|
+
<p>Failed to load layout. <NuxtLink to="/admin/layouts">Back to layouts</NuxtLink></p>
|
|
549
|
+
</div>
|
|
550
|
+
|
|
551
|
+
<template v-else>
|
|
552
|
+
<AdminLayoutsToolbar
|
|
553
|
+
:layout-name="editor.draft.value?.name ?? ''"
|
|
554
|
+
:state="editor.draft.value?.state ?? 'draft'"
|
|
555
|
+
:viewport="viewport"
|
|
556
|
+
:save-status="editor.status.value"
|
|
557
|
+
:dirty="editor.dirty.value"
|
|
558
|
+
:error-message="editor.errorMessage.value"
|
|
559
|
+
:last-saved-at="editor.original.value?.updatedAt ?? null"
|
|
560
|
+
:can-undo="history.canUndo.value"
|
|
561
|
+
:can-redo="history.canRedo.value"
|
|
562
|
+
:undo-label="history.lastLabel.value"
|
|
563
|
+
:redo-label="history.nextLabel.value"
|
|
564
|
+
@update:viewport="viewport = $event"
|
|
565
|
+
@save="onSave"
|
|
566
|
+
@publish="onPublish"
|
|
567
|
+
@discard="onDiscard"
|
|
568
|
+
@undo="onToolbarUndo"
|
|
569
|
+
@redo="onToolbarRedo"
|
|
570
|
+
/>
|
|
571
|
+
|
|
572
|
+
<!--
|
|
573
|
+
Session 162 P2.5: conflict-thrash banner. Shows when 3+ saves
|
|
574
|
+
have 409'd within the last 60s — auto-save is now paused so the
|
|
575
|
+
page stops banging the server while the admin reconciles. The
|
|
576
|
+
existing AdminLayoutsConflictModal handles the per-conflict UX;
|
|
577
|
+
this banner is the layer above, addressing the cascade pattern.
|
|
578
|
+
role="alert" + aria-live="assertive" so screen readers announce
|
|
579
|
+
the pause immediately (it changes the editor's autosave contract).
|
|
580
|
+
|
|
581
|
+
Audit fix: the three actions promised in the body copy
|
|
582
|
+
(Refresh / Force save / Resume) all render as inline buttons so
|
|
583
|
+
the admin can reconcile without first triggering a save to surface
|
|
584
|
+
the modal. Reuses the same handlers as the conflict modal.
|
|
585
|
+
-->
|
|
586
|
+
<div
|
|
587
|
+
v-if="editor.conflictThrashing.value"
|
|
588
|
+
class="cpub-admin-layouts-editor-thrash"
|
|
589
|
+
role="alert"
|
|
590
|
+
aria-live="assertive"
|
|
591
|
+
aria-atomic="true"
|
|
592
|
+
>
|
|
593
|
+
<i class="fa-solid fa-triangle-exclamation cpub-admin-layouts-editor-thrash-icon" aria-hidden="true"></i>
|
|
594
|
+
<div class="cpub-admin-layouts-editor-thrash-body">
|
|
595
|
+
<strong>Auto-save paused</strong>
|
|
596
|
+
<span>
|
|
597
|
+
Three of your recent saves collided with another admin's
|
|
598
|
+
edits. Reload their version (recommended) — your edits will
|
|
599
|
+
be lost. Overwriting their changes is destructive and final.
|
|
600
|
+
</span>
|
|
601
|
+
</div>
|
|
602
|
+
<!--
|
|
603
|
+
Button hierarchy matches AdminLayoutsConflictModal verbatim
|
|
604
|
+
(session 160 R1 audit established this discipline): primary
|
|
605
|
+
accent = SAFE recommended action, neutral default = middle
|
|
606
|
+
option, danger red = destructive action LAST in tab order so
|
|
607
|
+
keyboard users don't land on it.
|
|
608
|
+
Banner-specific: "Resume auto-save" replaces the modal's
|
|
609
|
+
"Keep editing here" — same neutral level, different semantic
|
|
610
|
+
(banner's middle option turns auto-save back on without
|
|
611
|
+
reconciliation; modal's middle option closes the modal).
|
|
612
|
+
-->
|
|
613
|
+
<div class="cpub-admin-layouts-editor-thrash-actions">
|
|
614
|
+
<button
|
|
615
|
+
ref="thrashPrimaryBtn"
|
|
616
|
+
type="button"
|
|
617
|
+
class="cpub-admin-layouts-editor-thrash-btn cpub-admin-layouts-editor-thrash-btn--primary"
|
|
618
|
+
@click="onConflictRefresh"
|
|
619
|
+
>
|
|
620
|
+
<i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i>
|
|
621
|
+
Reload their version
|
|
622
|
+
</button>
|
|
623
|
+
<button
|
|
624
|
+
type="button"
|
|
625
|
+
class="cpub-admin-layouts-editor-thrash-btn"
|
|
626
|
+
@click="onResumeAutoSave"
|
|
627
|
+
>
|
|
628
|
+
<i class="fa-solid fa-play" aria-hidden="true"></i>
|
|
629
|
+
Resume auto-save
|
|
630
|
+
</button>
|
|
631
|
+
<button
|
|
632
|
+
type="button"
|
|
633
|
+
class="cpub-admin-layouts-editor-thrash-btn cpub-admin-layouts-editor-thrash-btn--danger"
|
|
634
|
+
@click="onConflictForceSave"
|
|
635
|
+
>
|
|
636
|
+
<i class="fa-solid fa-arrow-up-from-bracket" aria-hidden="true"></i>
|
|
637
|
+
Overwrite their changes
|
|
638
|
+
</button>
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
|
|
642
|
+
<!--
|
|
643
|
+
Round-3 audit fix: phone (≤640px) sees a single banner instead
|
|
644
|
+
of the editor. Drag-drop on a 375px viewport is user-hostile
|
|
645
|
+
regardless of how well-designed — matches docs/plans/layout-and-pages.md §7.7.
|
|
646
|
+
Note: the @media rule uses `max-width: 640px` (inclusive), so
|
|
647
|
+
a viewport at exactly 640px sees the banner — comment matches.
|
|
648
|
+
-->
|
|
649
|
+
<div class="cpub-admin-layouts-editor-phone-only">
|
|
650
|
+
<i class="fa-solid fa-display cpub-admin-layouts-editor-phone-icon" aria-hidden="true"></i>
|
|
651
|
+
<h2>Use a larger screen</h2>
|
|
652
|
+
<p>The layout editor needs a tablet or desktop viewport (640px or wider).</p>
|
|
653
|
+
<NuxtLink to="/admin/layouts" class="cpub-admin-layouts-editor-phone-back">← Back to Layouts</NuxtLink>
|
|
654
|
+
</div>
|
|
655
|
+
|
|
656
|
+
<!--
|
|
657
|
+
Phase 3b/A: DnDProvider is the drag-drop root. ONE provider per
|
|
658
|
+
editor; ALL draggables (palette tiles) + droppables (rows / zones)
|
|
659
|
+
must be inside this subtree so dnd-kit's collision detection +
|
|
660
|
+
keyboard sensor can see them as a single namespace.
|
|
661
|
+
Wraps palette + canvas + inspector together so drag-from-palette
|
|
662
|
+
→ drop-on-canvas works without crossing a provider boundary.
|
|
663
|
+
Per the package's external API verified at session 162 close:
|
|
664
|
+
- keyboard sensor auto-attaches to document on mount (Space/Arrow/Esc)
|
|
665
|
+
- `previewTo='body'` teleports the drag preview to <body> so it
|
|
666
|
+
escapes any overflow:hidden ancestor + stays above the chrome
|
|
667
|
+
Click-outside the body clears selection (the inspector then
|
|
668
|
+
falls back to the page-meta form per §7.9 dispatch pattern).
|
|
669
|
+
-->
|
|
670
|
+
<!--
|
|
671
|
+
Phase 3b/A: SR narration channel — a singleton aria-live region
|
|
672
|
+
that <LayoutSection> + <LayoutRow> mirror drag/drop + Move
|
|
673
|
+
Up/Down events into. dnd-kit ships no announcer OOTB; this
|
|
674
|
+
closes the WCAG 2.1.1 gap. Mounted ONCE outside the
|
|
675
|
+
DnDProvider so it survives the inner reactivity churn.
|
|
676
|
+
-->
|
|
677
|
+
<AdminLayoutsAnnouncer />
|
|
678
|
+
|
|
679
|
+
<DnDProvider
|
|
680
|
+
preview-to="body"
|
|
681
|
+
class="cpub-admin-layouts-editor-dnd"
|
|
682
|
+
@click.self="editor.clearSelection"
|
|
683
|
+
>
|
|
684
|
+
<div
|
|
685
|
+
class="cpub-admin-layouts-editor-body"
|
|
686
|
+
:class="{
|
|
687
|
+
'cpub-admin-layouts-editor-body--palette-hidden': chrome.paletteHidden.value,
|
|
688
|
+
'cpub-admin-layouts-editor-body--inspector-hidden': chrome.inspectorHidden.value,
|
|
689
|
+
}"
|
|
690
|
+
>
|
|
691
|
+
<!-- Tablet/phone collapse: canvas FIRST so the surface admin came
|
|
692
|
+
for is immediately visible; palette + inspector stack below.
|
|
693
|
+
(Pre-audit ordering put palette first → admin had to scroll
|
|
694
|
+
past 17 tiles to reach the canvas.)
|
|
695
|
+
v-show on palette + inspector (not v-if) preserves component
|
|
696
|
+
state — scroll position, focused field — across hide/show. -->
|
|
697
|
+
<AdminLayoutsCanvas
|
|
698
|
+
:layout="editor.draft.value"
|
|
699
|
+
:viewport="viewport"
|
|
700
|
+
:on-select="editor.select"
|
|
701
|
+
:selected-id="editor.selectedId.value"
|
|
702
|
+
:on-add-row="onAddRow"
|
|
703
|
+
:on-remove-row="onRemoveRow"
|
|
704
|
+
/>
|
|
705
|
+
<AdminLayoutsPalette v-show="!chrome.paletteHidden.value" />
|
|
706
|
+
<AdminLayoutsInspector
|
|
707
|
+
v-show="!chrome.inspectorHidden.value"
|
|
708
|
+
:draft="editor.draft.value"
|
|
709
|
+
:selection="editor.selectedId.value"
|
|
710
|
+
@update:page-meta="onPageMetaUpdate"
|
|
711
|
+
@update:name="onNameUpdate"
|
|
712
|
+
@update:section-config="onSectionConfigUpdate"
|
|
713
|
+
@update:row-config="onRowConfigUpdate"
|
|
714
|
+
/>
|
|
715
|
+
|
|
716
|
+
<!--
|
|
717
|
+
Session 164 polish: edge tab toggles for palette + inspector.
|
|
718
|
+
Move-on-collapse pattern (Notion / Linear / Cursor): when the
|
|
719
|
+
panel is visible the tab sits at the panel's outer edge; when
|
|
720
|
+
the panel is collapsed the tab sits at the screen edge,
|
|
721
|
+
inviting expansion. The chevron icon tells the direction.
|
|
722
|
+
|
|
723
|
+
Placed INSIDE editor-body (which is position:relative) so
|
|
724
|
+
absolute positioning anchors to it. v-show on the panels
|
|
725
|
+
preserves their state across toggles; the tabs themselves
|
|
726
|
+
are always visible in editable mode.
|
|
727
|
+
|
|
728
|
+
Hidden on mobile/tablet (< 1024px) where the body falls
|
|
729
|
+
back to a single column DOM-order stack — the toggles
|
|
730
|
+
would float over content with no panel to collapse.
|
|
731
|
+
-->
|
|
732
|
+
<button
|
|
733
|
+
type="button"
|
|
734
|
+
class="cpub-admin-layouts-editor-edge-tab cpub-admin-layouts-editor-edge-tab--left"
|
|
735
|
+
:class="{ 'cpub-admin-layouts-editor-edge-tab--collapsed': chrome.paletteHidden.value }"
|
|
736
|
+
:aria-label="chrome.paletteHidden.value ? 'Show sections panel' : 'Hide sections panel'"
|
|
737
|
+
:aria-pressed="!chrome.paletteHidden.value"
|
|
738
|
+
:title="chrome.paletteHidden.value ? 'Show sections panel' : 'Hide sections panel'"
|
|
739
|
+
@click="chrome.togglePalette"
|
|
740
|
+
>
|
|
741
|
+
<i :class="chrome.paletteHidden.value ? 'fa-solid fa-angles-right' : 'fa-solid fa-angles-left'" aria-hidden="true"></i>
|
|
742
|
+
</button>
|
|
743
|
+
<button
|
|
744
|
+
type="button"
|
|
745
|
+
class="cpub-admin-layouts-editor-edge-tab cpub-admin-layouts-editor-edge-tab--right"
|
|
746
|
+
:class="{ 'cpub-admin-layouts-editor-edge-tab--collapsed': chrome.inspectorHidden.value }"
|
|
747
|
+
:aria-label="chrome.inspectorHidden.value ? 'Show inspector panel' : 'Hide inspector panel'"
|
|
748
|
+
:aria-pressed="!chrome.inspectorHidden.value"
|
|
749
|
+
:title="chrome.inspectorHidden.value ? 'Show inspector panel' : 'Hide inspector panel'"
|
|
750
|
+
@click="chrome.toggleInspector"
|
|
751
|
+
>
|
|
752
|
+
<i :class="chrome.inspectorHidden.value ? 'fa-solid fa-angles-left' : 'fa-solid fa-angles-right'" aria-hidden="true"></i>
|
|
753
|
+
</button>
|
|
754
|
+
</div>
|
|
755
|
+
</DnDProvider>
|
|
756
|
+
|
|
757
|
+
<AdminLayoutsConflictModal
|
|
758
|
+
:open="conflictOpen"
|
|
759
|
+
:message="editor.errorMessage.value"
|
|
760
|
+
@refresh="onConflictRefresh"
|
|
761
|
+
@force-save="onConflictForceSave"
|
|
762
|
+
@close="conflictOpen = false"
|
|
763
|
+
/>
|
|
764
|
+
<!-- Phase 3d.3 — keyboard shortcut help overlay. Opens on `?`
|
|
765
|
+
via useLayoutHotkeys.onShowHelp; Esc / backdrop click / Close
|
|
766
|
+
button dismiss. Read-only; no editor state mutation. -->
|
|
767
|
+
<AdminLayoutsHelpOverlay
|
|
768
|
+
:open="helpOpen"
|
|
769
|
+
@close="helpOpen = false"
|
|
770
|
+
/>
|
|
771
|
+
</template>
|
|
772
|
+
</div>
|
|
773
|
+
</template>
|
|
774
|
+
|
|
775
|
+
<style scoped>
|
|
776
|
+
.cpub-admin-layouts-editor {
|
|
777
|
+
display: flex;
|
|
778
|
+
flex-direction: column;
|
|
779
|
+
/* The admin layout (.admin-main) wraps us in `padding: var(--space-6)`,
|
|
780
|
+
which would inset the editor + cause its 100vh-based height to
|
|
781
|
+
overflow the viewport. Suck up to the parent padding edges so the
|
|
782
|
+
editor reads as full-bleed inside the admin chrome. */
|
|
783
|
+
margin: calc(var(--space-6) * -1);
|
|
784
|
+
height: calc(100vh - var(--admin-topbar-height, 56px));
|
|
785
|
+
min-height: 600px;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.cpub-admin-layouts-editor-error {
|
|
789
|
+
display: flex;
|
|
790
|
+
flex-direction: column;
|
|
791
|
+
align-items: center;
|
|
792
|
+
gap: var(--space-3);
|
|
793
|
+
padding: var(--space-8);
|
|
794
|
+
color: var(--text-dim);
|
|
795
|
+
}
|
|
796
|
+
.cpub-admin-layouts-editor-error a { color: var(--accent); text-decoration: underline; }
|
|
797
|
+
|
|
798
|
+
/* Session 162 P2.5 conflict-thrash banner. Audit fix: the original
|
|
799
|
+
--warning token didn't exist in the theme system → fell back to
|
|
800
|
+
surface2 which read as a neutral box, not alert. Now uses the
|
|
801
|
+
established --yellow-bg / --yellow-border tokens (defined on every
|
|
802
|
+
theme — base.css line 70-71 + all variants) that other "attention"
|
|
803
|
+
surfaces in the layer use. Sits between toolbar + body so it's
|
|
804
|
+
visible regardless of canvas scroll. */
|
|
805
|
+
.cpub-admin-layouts-editor-thrash {
|
|
806
|
+
display: flex;
|
|
807
|
+
align-items: center;
|
|
808
|
+
gap: var(--space-3);
|
|
809
|
+
padding: var(--space-3) var(--space-4);
|
|
810
|
+
background: var(--yellow-bg);
|
|
811
|
+
color: var(--text);
|
|
812
|
+
border-bottom: var(--border-width-default) solid var(--yellow-border);
|
|
813
|
+
flex-shrink: 0;
|
|
814
|
+
}
|
|
815
|
+
.cpub-admin-layouts-editor-thrash-icon {
|
|
816
|
+
color: var(--yellow);
|
|
817
|
+
font-size: var(--text-lg);
|
|
818
|
+
flex-shrink: 0;
|
|
819
|
+
}
|
|
820
|
+
.cpub-admin-layouts-editor-thrash-body {
|
|
821
|
+
display: flex;
|
|
822
|
+
flex-direction: column;
|
|
823
|
+
gap: 2px;
|
|
824
|
+
flex: 1;
|
|
825
|
+
min-width: 0;
|
|
826
|
+
}
|
|
827
|
+
.cpub-admin-layouts-editor-thrash-body strong {
|
|
828
|
+
font-family: var(--font-mono);
|
|
829
|
+
font-size: var(--text-xs);
|
|
830
|
+
text-transform: uppercase;
|
|
831
|
+
letter-spacing: var(--tracking-wide);
|
|
832
|
+
}
|
|
833
|
+
.cpub-admin-layouts-editor-thrash-body span {
|
|
834
|
+
font-size: var(--text-sm);
|
|
835
|
+
color: var(--text-dim);
|
|
836
|
+
}
|
|
837
|
+
.cpub-admin-layouts-editor-thrash-actions {
|
|
838
|
+
display: flex;
|
|
839
|
+
gap: var(--space-2);
|
|
840
|
+
flex-shrink: 0;
|
|
841
|
+
}
|
|
842
|
+
.cpub-admin-layouts-editor-thrash-btn {
|
|
843
|
+
padding: var(--space-1) var(--space-3);
|
|
844
|
+
background: var(--surface);
|
|
845
|
+
border: var(--border-width-default) solid var(--border);
|
|
846
|
+
color: var(--text);
|
|
847
|
+
font-family: var(--font-mono);
|
|
848
|
+
font-size: var(--text-xs);
|
|
849
|
+
text-transform: uppercase;
|
|
850
|
+
letter-spacing: var(--tracking-wide);
|
|
851
|
+
cursor: pointer;
|
|
852
|
+
}
|
|
853
|
+
.cpub-admin-layouts-editor-thrash-btn:hover { background: var(--surface2); }
|
|
854
|
+
.cpub-admin-layouts-editor-thrash-btn:focus-visible {
|
|
855
|
+
outline: 2px solid var(--accent);
|
|
856
|
+
outline-offset: 2px;
|
|
857
|
+
}
|
|
858
|
+
/* Hierarchy matches AdminLayoutsConflictModal's btn--primary +
|
|
859
|
+
btn--danger so the cognitive model for resolving a conflict is the
|
|
860
|
+
same whether the admin meets the modal first or the cascade banner
|
|
861
|
+
first (session 162 audit-on-audit fix). */
|
|
862
|
+
.cpub-admin-layouts-editor-thrash-btn--primary {
|
|
863
|
+
background: var(--accent);
|
|
864
|
+
border-color: var(--accent);
|
|
865
|
+
color: var(--surface);
|
|
866
|
+
}
|
|
867
|
+
.cpub-admin-layouts-editor-thrash-btn--primary:hover { filter: brightness(1.1); background: var(--accent); }
|
|
868
|
+
.cpub-admin-layouts-editor-thrash-btn--danger {
|
|
869
|
+
color: var(--red);
|
|
870
|
+
border-color: var(--red);
|
|
871
|
+
}
|
|
872
|
+
.cpub-admin-layouts-editor-thrash-btn--danger:hover {
|
|
873
|
+
background: var(--red);
|
|
874
|
+
color: var(--surface);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
@media (max-width: 1024px) {
|
|
878
|
+
/* Wrap the action buttons under the body on tablet/mobile so they
|
|
879
|
+
don't squish the message. */
|
|
880
|
+
.cpub-admin-layouts-editor-thrash {
|
|
881
|
+
flex-wrap: wrap;
|
|
882
|
+
align-items: flex-start;
|
|
883
|
+
}
|
|
884
|
+
.cpub-admin-layouts-editor-thrash-body { flex-basis: 100%; }
|
|
885
|
+
.cpub-admin-layouts-editor-thrash-actions { flex-basis: 100%; justify-content: flex-end; }
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/* Phase 3b/A — DnDProvider sits between the editor wrapper and the
|
|
889
|
+
body grid. Without explicit dimensions it would collapse and the
|
|
890
|
+
body grid loses its height. Mirrors the body's flex behavior so
|
|
891
|
+
the provider is layout-transparent. */
|
|
892
|
+
.cpub-admin-layouts-editor-dnd {
|
|
893
|
+
display: flex;
|
|
894
|
+
flex-direction: column;
|
|
895
|
+
flex: 1;
|
|
896
|
+
min-height: 0;
|
|
897
|
+
}
|
|
898
|
+
.cpub-admin-layouts-editor-dnd > .cpub-admin-layouts-editor-body {
|
|
899
|
+
flex: 1;
|
|
900
|
+
min-height: 0;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
.cpub-admin-layouts-editor-body {
|
|
904
|
+
display: grid;
|
|
905
|
+
/* DOM order: canvas, palette, inspector. CSS grid-template-areas
|
|
906
|
+
places them visually palette / canvas / inspector at >=1024px. */
|
|
907
|
+
grid-template-columns: 280px 1fr 320px;
|
|
908
|
+
grid-template-areas: 'palette canvas inspector';
|
|
909
|
+
flex: 1;
|
|
910
|
+
min-height: 0;
|
|
911
|
+
/* Session 164: positions the edge-tab toggles anchored to the body's
|
|
912
|
+
left/right boundaries. The tabs use absolute positioning relative
|
|
913
|
+
to this container. */
|
|
914
|
+
position: relative;
|
|
915
|
+
}
|
|
916
|
+
.cpub-admin-layouts-editor-body > :nth-child(1) { grid-area: canvas; } /* canvas (1st in DOM) */
|
|
917
|
+
.cpub-admin-layouts-editor-body > :nth-child(2) { grid-area: palette; } /* palette (2nd in DOM) */
|
|
918
|
+
.cpub-admin-layouts-editor-body > :nth-child(3) { grid-area: inspector; } /* inspector (3rd in DOM) */
|
|
919
|
+
|
|
920
|
+
/* Session 161: hide-palette / hide-inspector grid reflow. Removes the
|
|
921
|
+
panel column entirely (vs display:none on the child, which would
|
|
922
|
+
leave the grid column reserved as empty space). v-show on the panel
|
|
923
|
+
element keeps it in the DOM so component state (scroll, focus,
|
|
924
|
+
active field) survives toggling. */
|
|
925
|
+
.cpub-admin-layouts-editor-body--palette-hidden {
|
|
926
|
+
grid-template-columns: 1fr 320px;
|
|
927
|
+
grid-template-areas: 'canvas inspector';
|
|
928
|
+
}
|
|
929
|
+
.cpub-admin-layouts-editor-body--inspector-hidden {
|
|
930
|
+
grid-template-columns: 280px 1fr;
|
|
931
|
+
grid-template-areas: 'palette canvas';
|
|
932
|
+
}
|
|
933
|
+
.cpub-admin-layouts-editor-body--palette-hidden.cpub-admin-layouts-editor-body--inspector-hidden {
|
|
934
|
+
grid-template-columns: 1fr;
|
|
935
|
+
grid-template-areas: 'canvas';
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
@media (max-width: 1280px) {
|
|
939
|
+
.cpub-admin-layouts-editor-body { grid-template-columns: 240px 1fr 280px; }
|
|
940
|
+
.cpub-admin-layouts-editor-body--palette-hidden { grid-template-columns: 1fr 280px; }
|
|
941
|
+
.cpub-admin-layouts-editor-body--inspector-hidden { grid-template-columns: 240px 1fr; }
|
|
942
|
+
.cpub-admin-layouts-editor-body--palette-hidden.cpub-admin-layouts-editor-body--inspector-hidden { grid-template-columns: 1fr; }
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
@media (max-width: 1024px) {
|
|
946
|
+
/* On tablet, fall back to DOM-order single column (canvas first,
|
|
947
|
+
palette next, inspector last) — admin sees the editing surface
|
|
948
|
+
immediately without scrolling past the palette. v1 doesn't ship
|
|
949
|
+
bottom-sheet behavior (Phase 6a). */
|
|
950
|
+
.cpub-admin-layouts-editor-body {
|
|
951
|
+
grid-template-columns: 1fr;
|
|
952
|
+
grid-template-areas: none;
|
|
953
|
+
}
|
|
954
|
+
.cpub-admin-layouts-editor-body > * { grid-area: auto; }
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/* ------------------------------------------------------------------ */
|
|
958
|
+
/* Session 164 polish: panel edge-tab toggles. */
|
|
959
|
+
/* */
|
|
960
|
+
/* Replaces the toolbar palette/inspector buttons (user-reported as */
|
|
961
|
+
/* non-obvious). Tabs sit at the panel/canvas boundary when the panel */
|
|
962
|
+
/* is visible, and AT the screen edge when the panel is collapsed — */
|
|
963
|
+
/* the icon (« / ») tells the direction. */
|
|
964
|
+
/* */
|
|
965
|
+
/* 280px on the left aligns to palette's grid column width; 320px on */
|
|
966
|
+
/* the right aligns to inspector's. The --collapsed modifier moves the */
|
|
967
|
+
/* tab to the screen edge (left:0 or right:0). */
|
|
968
|
+
/* */
|
|
969
|
+
/* Hidden on <1024px viewport: at tablet/phone the body falls back to a */
|
|
970
|
+
/* DOM-order single column stack; floating edge tabs would overlay the */
|
|
971
|
+
/* stacked panels meaninglessly. */
|
|
972
|
+
/* ------------------------------------------------------------------ */
|
|
973
|
+
.cpub-admin-layouts-editor-edge-tab {
|
|
974
|
+
position: absolute;
|
|
975
|
+
top: 50%;
|
|
976
|
+
transform: translateY(-50%);
|
|
977
|
+
/* Session 164 audit R1-1: bumped from 18px → 28px to clear WCAG 2.5.8
|
|
978
|
+
AA minimum target size (24×24) with a small design buffer.
|
|
979
|
+
Matches the 28×28 button convention used for cpub-layout-section-move
|
|
980
|
+
per feedback-visual-editor-ux-patterns. Height stays at 56px (2× width)
|
|
981
|
+
so the tab still reads as a vertical edge handle, not a square button. */
|
|
982
|
+
width: 28px;
|
|
983
|
+
height: 56px;
|
|
984
|
+
display: inline-flex;
|
|
985
|
+
align-items: center;
|
|
986
|
+
justify-content: center;
|
|
987
|
+
background: var(--surface);
|
|
988
|
+
border: 1px solid var(--border);
|
|
989
|
+
color: var(--text-dim);
|
|
990
|
+
cursor: pointer;
|
|
991
|
+
/* Above the canvas + panel content but below modals + announcer */
|
|
992
|
+
z-index: 5;
|
|
993
|
+
transition: left 200ms ease-out, right 200ms ease-out, background var(--transition-default), color var(--transition-default);
|
|
994
|
+
/* Compact icon size matches the slim handle silhouette. The 28px
|
|
995
|
+
touch surface is what WCAG cares about — the chevron centers inside. */
|
|
996
|
+
font-size: 10px;
|
|
997
|
+
}
|
|
998
|
+
.cpub-admin-layouts-editor-edge-tab:hover {
|
|
999
|
+
background: var(--surface2);
|
|
1000
|
+
color: var(--accent);
|
|
1001
|
+
border-color: var(--accent);
|
|
1002
|
+
}
|
|
1003
|
+
.cpub-admin-layouts-editor-edge-tab:focus-visible {
|
|
1004
|
+
outline: 2px solid var(--accent);
|
|
1005
|
+
outline-offset: 2px;
|
|
1006
|
+
color: var(--accent);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
.cpub-admin-layouts-editor-edge-tab--left {
|
|
1010
|
+
/* Sit at the right edge of the palette (which is 280px wide). The
|
|
1011
|
+
-14px offset centers the 28px-wide tab ON the boundary so half is
|
|
1012
|
+
in the palette + half in the canvas — reads as "the boundary
|
|
1013
|
+
itself is the toggle". (Was -9px when the tab was 18px wide.) */
|
|
1014
|
+
left: calc(280px - 14px);
|
|
1015
|
+
}
|
|
1016
|
+
.cpub-admin-layouts-editor-edge-tab--right {
|
|
1017
|
+
right: calc(320px - 14px);
|
|
1018
|
+
}
|
|
1019
|
+
.cpub-admin-layouts-editor-edge-tab--left.cpub-admin-layouts-editor-edge-tab--collapsed {
|
|
1020
|
+
/* Collapsed: snap to the screen edge so the admin sees an obvious
|
|
1021
|
+
"click here to bring it back" affordance. */
|
|
1022
|
+
left: 0;
|
|
1023
|
+
}
|
|
1024
|
+
.cpub-admin-layouts-editor-edge-tab--right.cpub-admin-layouts-editor-edge-tab--collapsed {
|
|
1025
|
+
right: 0;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/* Mirror the breakpoint reduction at <=1280px so the tabs follow the
|
|
1029
|
+
narrower panel widths (240 / 280 from the body media query). The
|
|
1030
|
+
-14px offset is the 28px-wide tab's half-width, same logic as the
|
|
1031
|
+
1025px+ case above (was -9px when the tab was 18px wide). */
|
|
1032
|
+
@media (max-width: 1280px) {
|
|
1033
|
+
.cpub-admin-layouts-editor-edge-tab--left { left: calc(240px - 14px); }
|
|
1034
|
+
.cpub-admin-layouts-editor-edge-tab--right { right: calc(280px - 14px); }
|
|
1035
|
+
.cpub-admin-layouts-editor-edge-tab--left.cpub-admin-layouts-editor-edge-tab--collapsed { left: 0; }
|
|
1036
|
+
.cpub-admin-layouts-editor-edge-tab--right.cpub-admin-layouts-editor-edge-tab--collapsed { right: 0; }
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/* Tablet/phone: hide. The single-column DOM stack already gives admin
|
|
1040
|
+
direct access to each section without needing collapse affordances. */
|
|
1041
|
+
@media (max-width: 1024px) {
|
|
1042
|
+
.cpub-admin-layouts-editor-edge-tab { display: none; }
|
|
1043
|
+
/* Session 164 audit R3-3: force panels visible regardless of the
|
|
1044
|
+
cookie-persisted desktop-collapse state. At tablet/phone the body
|
|
1045
|
+
falls back to a DOM-order single-column stack — the desktop
|
|
1046
|
+
'collapsed' state has no useful meaning when there's no grid column
|
|
1047
|
+
to remove, but `chrome.paletteHidden` / `chrome.inspectorHidden`
|
|
1048
|
+
still drive v-show on the panel components, leaving an admin who
|
|
1049
|
+
collapsed on desktop with NO way to re-show on tablet (the edge
|
|
1050
|
+
tabs are hidden by the rule above; the toolbar toggles were
|
|
1051
|
+
removed in the 164 polish). Override v-show's inline display:none
|
|
1052
|
+
with `flex !important` (panels natively use display:flex column —
|
|
1053
|
+
'block' would break their internal layout). Scoped :deep() because
|
|
1054
|
+
the .cpub-admin-layouts-{palette,inspector} root classes live in
|
|
1055
|
+
child components. */
|
|
1056
|
+
:deep(.cpub-admin-layouts-palette),
|
|
1057
|
+
:deep(.cpub-admin-layouts-inspector) {
|
|
1058
|
+
display: flex !important;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/* prefers-reduced-motion: kill the slide transition so the tab snaps
|
|
1063
|
+
to its new position immediately. Plan §7.11 + WCAG 2.3.3. */
|
|
1064
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1065
|
+
.cpub-admin-layouts-editor-edge-tab { transition: none; }
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/* Phone (<640px) — show a "use a larger screen" banner and HIDE the
|
|
1069
|
+
editor body entirely. Drag-drop on 375px is user-hostile per the
|
|
1070
|
+
plan §7.7. */
|
|
1071
|
+
.cpub-admin-layouts-editor-phone-only {
|
|
1072
|
+
display: none;
|
|
1073
|
+
flex-direction: column;
|
|
1074
|
+
align-items: center;
|
|
1075
|
+
gap: var(--space-3);
|
|
1076
|
+
padding: var(--space-8) var(--space-4);
|
|
1077
|
+
text-align: center;
|
|
1078
|
+
}
|
|
1079
|
+
.cpub-admin-layouts-editor-phone-only h2 {
|
|
1080
|
+
font-size: var(--text-lg);
|
|
1081
|
+
margin: 0;
|
|
1082
|
+
color: var(--text);
|
|
1083
|
+
}
|
|
1084
|
+
.cpub-admin-layouts-editor-phone-only p {
|
|
1085
|
+
margin: 0;
|
|
1086
|
+
color: var(--text-dim);
|
|
1087
|
+
max-width: 32ch;
|
|
1088
|
+
}
|
|
1089
|
+
.cpub-admin-layouts-editor-phone-icon {
|
|
1090
|
+
font-size: var(--text-3xl);
|
|
1091
|
+
color: var(--text-faint);
|
|
1092
|
+
}
|
|
1093
|
+
.cpub-admin-layouts-editor-phone-back {
|
|
1094
|
+
display: inline-flex;
|
|
1095
|
+
align-items: center;
|
|
1096
|
+
gap: var(--space-1);
|
|
1097
|
+
margin-top: var(--space-2);
|
|
1098
|
+
color: var(--accent);
|
|
1099
|
+
text-decoration: underline;
|
|
1100
|
+
font-family: var(--font-mono);
|
|
1101
|
+
font-size: var(--text-xs);
|
|
1102
|
+
text-transform: uppercase;
|
|
1103
|
+
letter-spacing: var(--tracking-wide);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
@media (max-width: 640px) {
|
|
1107
|
+
.cpub-admin-layouts-editor-phone-only { display: flex; }
|
|
1108
|
+
.cpub-admin-layouts-editor-body { display: none; }
|
|
1109
|
+
}
|
|
1110
|
+
</style>
|