@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,346 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Help overlay — keyboard shortcut reference modal. Phase 3d.3 +
|
|
4
|
+
* session 165 deep audit R3-B (focus trap).
|
|
5
|
+
*
|
|
6
|
+
* Opens on `?` (Shift+/) per the convention Linear / GitHub / Notion
|
|
7
|
+
* / Figma all share. Read-only: lists every keyboard shortcut grouped
|
|
8
|
+
* by category. Close via Esc, backdrop click, or the Close button.
|
|
9
|
+
*
|
|
10
|
+
* Focus trap: when focus leaves the dialog while it's open, snap it
|
|
11
|
+
* back to the Close button. Implemented via a `focusin` listener on
|
|
12
|
+
* document. The check `dialog.contains(target)` allows focus to move
|
|
13
|
+
* freely WITHIN the dialog (future-proof: works even if more
|
|
14
|
+
* focusables are added later) but rejects focus outside. Closes WCAG
|
|
15
|
+
* ARIA Dialog pattern (focus shouldn't escape an open modal).
|
|
16
|
+
*
|
|
17
|
+
* Cross-platform key rendering: shows ⌘ (Cmd) for the META modifier in
|
|
18
|
+
* each chord. Mac users see "⌘ Z"; the visible glyph is the same
|
|
19
|
+
* keyboard pictograph their OS uses everywhere. Windows/Linux users
|
|
20
|
+
* read "⌘ Z" and pattern-match to Ctrl — every cross-platform editor
|
|
21
|
+
* (Notion / VS Code / Linear) uses this convention rather than
|
|
22
|
+
* runtime-sniffing the platform (which is brittle on iPad with a
|
|
23
|
+
* Magic Keyboard, on Linux desktops claiming to be a Mac, etc).
|
|
24
|
+
*/
|
|
25
|
+
import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
|
26
|
+
|
|
27
|
+
const props = defineProps<{
|
|
28
|
+
/** Show/hide the modal. The parent (the editor page) owns this
|
|
29
|
+
* ref + flips it on `?` keypress. */
|
|
30
|
+
open: boolean;
|
|
31
|
+
}>();
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{
|
|
34
|
+
(e: 'close'): void;
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
/* ------------------------------------------------------------------ */
|
|
38
|
+
/* Hotkey table — single source of truth for the rendered chords. */
|
|
39
|
+
/* If a new binding is added to useLayoutHotkeys, ADD A ROW HERE too. */
|
|
40
|
+
/* ------------------------------------------------------------------ */
|
|
41
|
+
interface Hotkey {
|
|
42
|
+
/** The chord, rendered as one or more `<kbd>` elements joined with
|
|
43
|
+
* a `+` separator at view-time. */
|
|
44
|
+
chord: string[];
|
|
45
|
+
/** Short prose description. Plain English, no jargon. */
|
|
46
|
+
description: string;
|
|
47
|
+
}
|
|
48
|
+
interface HotkeyGroup {
|
|
49
|
+
title: string;
|
|
50
|
+
rows: Hotkey[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const groups: HotkeyGroup[] = [
|
|
54
|
+
{
|
|
55
|
+
title: 'Edit',
|
|
56
|
+
rows: [
|
|
57
|
+
{ chord: ['Backspace'], description: 'Remove the selected section. Press Command+Z to restore.' },
|
|
58
|
+
{ chord: ['Delete'], description: 'Remove the selected section (alias for Backspace).' },
|
|
59
|
+
{ chord: ['⌘', 'D'], description: 'Duplicate the selected section. Selection moves to the new copy.' },
|
|
60
|
+
{ chord: ['Shift', '←'], description: 'Shrink the selected section by 1 column. Right neighbour absorbs the gap; stops at the section’s minimum.' },
|
|
61
|
+
{ chord: ['Shift', '→'], description: 'Grow the selected section by 1 column. Right neighbour shrinks; stops at its minimum.' },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
title: 'History',
|
|
66
|
+
rows: [
|
|
67
|
+
{ chord: ['⌘', 'Z'], description: 'Undo the last change. Stack holds the most recent 50 operations.' },
|
|
68
|
+
{ chord: ['⌘', 'Shift', 'Z'], description: 'Redo. Cancelled by any new action — Notion/Linear convention.' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
// (Move group deliberately omitted — session 165 deep audit R1-A.
|
|
72
|
+
// Move Up / Move Down / Move-to-zone are visible buttons in the
|
|
73
|
+
// section's top-right cluster, discoverable via Tab. They're not
|
|
74
|
+
// hidden keyboard shortcuts; listing them here as "Tab + Enter"
|
|
75
|
+
// chord rows was misleading — the chord didn't disambiguate the
|
|
76
|
+
// three different button targets.)
|
|
77
|
+
{
|
|
78
|
+
title: 'View',
|
|
79
|
+
rows: [
|
|
80
|
+
{ chord: ['?'], description: 'Show this overlay.' },
|
|
81
|
+
{ chord: ['Esc'], description: 'Close this overlay; close popovers; clear selection.' },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/* ------------------------------------------------------------------ */
|
|
87
|
+
/* Focus on close button at open-time (matches conflict modal). */
|
|
88
|
+
/* ------------------------------------------------------------------ */
|
|
89
|
+
const closeBtn = ref<HTMLButtonElement | null>(null);
|
|
90
|
+
const dialogEl = ref<HTMLElement | null>(null);
|
|
91
|
+
watch(
|
|
92
|
+
() => props.open,
|
|
93
|
+
async (isOpen) => {
|
|
94
|
+
if (isOpen) {
|
|
95
|
+
await nextTick();
|
|
96
|
+
closeBtn.value?.focus();
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
{ immediate: true },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
/* Esc closes — global keydown so backdrop focus (or accidental loss
|
|
103
|
+
* of focus to <body>) still dismisses. The :open guard makes this a
|
|
104
|
+
* no-op when closed. */
|
|
105
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
106
|
+
if (props.open && e.key === 'Escape') {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
emit('close');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* Focus trap (session 165 deep audit R3-B). When the modal is open and
|
|
113
|
+
* focus moves to an element OUTSIDE the dialog (Tab past the close
|
|
114
|
+
* button, programmatic focus from underlying page, etc), snap focus
|
|
115
|
+
* back to the close button. The `dialog.contains(target)` check
|
|
116
|
+
* future-proofs the trap: any new focusable inside the dialog is
|
|
117
|
+
* naturally allowed.
|
|
118
|
+
*
|
|
119
|
+
* Why focusin (vs keydown Tab): catches BOTH Tab and programmatic
|
|
120
|
+
* focus changes, including ones the editor page might trigger via
|
|
121
|
+
* other composables. The check fires AFTER focus has moved, so the
|
|
122
|
+
* snap-back is observable (a tiny focus blip on the outside element),
|
|
123
|
+
* but it's the most robust approach in jsdom + browsers.
|
|
124
|
+
*
|
|
125
|
+
* Topmost-only guard (session 165 round 5): if a later-mounted dialog
|
|
126
|
+
* is on top of us, let THAT dialog's trap own focus. Without this guard
|
|
127
|
+
* two simultaneously-open modals' traps would ping-pong focus. Parent
|
|
128
|
+
* coordination in [id].vue closes us when ConflictModal opens, so this
|
|
129
|
+
* guard is belt-and-suspenders — necessary only for the brief window
|
|
130
|
+
* where both are mounted before the watcher fires. */
|
|
131
|
+
function isTopmostDialog(): boolean {
|
|
132
|
+
if (typeof document === 'undefined') return false;
|
|
133
|
+
if (!dialogEl.value) return false;
|
|
134
|
+
const all = document.querySelectorAll('[role="dialog"], [role="alertdialog"]');
|
|
135
|
+
return all[all.length - 1] === dialogEl.value;
|
|
136
|
+
}
|
|
137
|
+
function onFocusIn(e: FocusEvent): void {
|
|
138
|
+
if (!props.open) return;
|
|
139
|
+
if (!isTopmostDialog()) return;
|
|
140
|
+
const target = e.target as Node | null;
|
|
141
|
+
if (!target) return;
|
|
142
|
+
const dlg = dialogEl.value;
|
|
143
|
+
if (!dlg) return;
|
|
144
|
+
if (dlg.contains(target)) return;
|
|
145
|
+
closeBtn.value?.focus();
|
|
146
|
+
}
|
|
147
|
+
onMounted(() => {
|
|
148
|
+
if (typeof window !== 'undefined') {
|
|
149
|
+
window.addEventListener('keydown', onKeydown);
|
|
150
|
+
document.addEventListener('focusin', onFocusIn);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
onBeforeUnmount(() => {
|
|
154
|
+
if (typeof window !== 'undefined') {
|
|
155
|
+
window.removeEventListener('keydown', onKeydown);
|
|
156
|
+
document.removeEventListener('focusin', onFocusIn);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
</script>
|
|
160
|
+
|
|
161
|
+
<template>
|
|
162
|
+
<Teleport to="body">
|
|
163
|
+
<div
|
|
164
|
+
v-if="open"
|
|
165
|
+
class="cpub-admin-layouts-help-backdrop"
|
|
166
|
+
role="presentation"
|
|
167
|
+
@click.self="emit('close')"
|
|
168
|
+
>
|
|
169
|
+
<div
|
|
170
|
+
ref="dialogEl"
|
|
171
|
+
class="cpub-admin-layouts-help-modal"
|
|
172
|
+
role="dialog"
|
|
173
|
+
aria-modal="true"
|
|
174
|
+
aria-labelledby="cpub-admin-layouts-help-title"
|
|
175
|
+
>
|
|
176
|
+
<header class="cpub-admin-layouts-help-header">
|
|
177
|
+
<h2 id="cpub-admin-layouts-help-title" class="cpub-admin-layouts-help-title">
|
|
178
|
+
Keyboard shortcuts
|
|
179
|
+
</h2>
|
|
180
|
+
<button
|
|
181
|
+
ref="closeBtn"
|
|
182
|
+
type="button"
|
|
183
|
+
class="cpub-admin-layouts-help-close"
|
|
184
|
+
aria-label="Close keyboard shortcuts"
|
|
185
|
+
@click="emit('close')"
|
|
186
|
+
>
|
|
187
|
+
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
|
188
|
+
</button>
|
|
189
|
+
</header>
|
|
190
|
+
<div class="cpub-admin-layouts-help-body">
|
|
191
|
+
<section
|
|
192
|
+
v-for="group in groups"
|
|
193
|
+
:key="group.title"
|
|
194
|
+
class="cpub-admin-layouts-help-group"
|
|
195
|
+
>
|
|
196
|
+
<h3 class="cpub-admin-layouts-help-group-title">{{ group.title }}</h3>
|
|
197
|
+
<dl class="cpub-admin-layouts-help-list">
|
|
198
|
+
<template v-for="row in group.rows" :key="row.description">
|
|
199
|
+
<dt class="cpub-admin-layouts-help-chord">
|
|
200
|
+
<template v-for="(part, i) in row.chord" :key="i">
|
|
201
|
+
<kbd>{{ part }}</kbd>
|
|
202
|
+
<span v-if="i < row.chord.length - 1" class="cpub-admin-layouts-help-plus" aria-hidden="true">+</span>
|
|
203
|
+
</template>
|
|
204
|
+
</dt>
|
|
205
|
+
<dd class="cpub-admin-layouts-help-desc">{{ row.description }}</dd>
|
|
206
|
+
</template>
|
|
207
|
+
</dl>
|
|
208
|
+
</section>
|
|
209
|
+
</div>
|
|
210
|
+
<footer class="cpub-admin-layouts-help-footer">
|
|
211
|
+
<p class="cpub-admin-layouts-help-hint">
|
|
212
|
+
⌘ stands for the Command key on macOS and the Ctrl key on Windows/Linux.
|
|
213
|
+
</p>
|
|
214
|
+
</footer>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</Teleport>
|
|
218
|
+
</template>
|
|
219
|
+
|
|
220
|
+
<style scoped>
|
|
221
|
+
.cpub-admin-layouts-help-backdrop {
|
|
222
|
+
position: fixed;
|
|
223
|
+
inset: 0;
|
|
224
|
+
background: var(--color-surface-overlay, rgba(0, 0, 0, 0.5));
|
|
225
|
+
display: flex;
|
|
226
|
+
align-items: center;
|
|
227
|
+
justify-content: center;
|
|
228
|
+
z-index: 1000;
|
|
229
|
+
padding: var(--space-4);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.cpub-admin-layouts-help-modal {
|
|
233
|
+
background: var(--surface);
|
|
234
|
+
border: var(--border-width-default) solid var(--border);
|
|
235
|
+
box-shadow: var(--shadow-lg);
|
|
236
|
+
max-width: 560px;
|
|
237
|
+
width: 100%;
|
|
238
|
+
max-height: 90vh;
|
|
239
|
+
display: flex;
|
|
240
|
+
flex-direction: column;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.cpub-admin-layouts-help-header {
|
|
244
|
+
display: flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
justify-content: space-between;
|
|
247
|
+
gap: var(--space-3);
|
|
248
|
+
padding: var(--space-4);
|
|
249
|
+
border-bottom: 1px solid var(--border2);
|
|
250
|
+
}
|
|
251
|
+
.cpub-admin-layouts-help-title {
|
|
252
|
+
font-size: var(--text-lg);
|
|
253
|
+
font-weight: var(--font-weight-bold);
|
|
254
|
+
margin: 0;
|
|
255
|
+
}
|
|
256
|
+
.cpub-admin-layouts-help-close {
|
|
257
|
+
/* 28×28 touch target — matches the section-move buttons + edge-tab
|
|
258
|
+
buttons elsewhere in the editor (WCAG 2.5.8 AA = 24×24 floor). */
|
|
259
|
+
width: 28px;
|
|
260
|
+
height: 28px;
|
|
261
|
+
display: inline-flex;
|
|
262
|
+
align-items: center;
|
|
263
|
+
justify-content: center;
|
|
264
|
+
background: var(--surface);
|
|
265
|
+
border: 1px solid var(--border2);
|
|
266
|
+
color: var(--text-dim);
|
|
267
|
+
cursor: pointer;
|
|
268
|
+
font-size: var(--text-sm);
|
|
269
|
+
}
|
|
270
|
+
.cpub-admin-layouts-help-close:hover { background: var(--surface2); color: var(--text); }
|
|
271
|
+
.cpub-admin-layouts-help-close:focus-visible {
|
|
272
|
+
outline: 2px solid var(--accent);
|
|
273
|
+
outline-offset: 2px;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.cpub-admin-layouts-help-body {
|
|
277
|
+
padding: var(--space-4);
|
|
278
|
+
overflow-y: auto;
|
|
279
|
+
display: flex;
|
|
280
|
+
flex-direction: column;
|
|
281
|
+
gap: var(--space-4);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.cpub-admin-layouts-help-group-title {
|
|
285
|
+
font-family: var(--font-mono);
|
|
286
|
+
font-size: var(--text-xs);
|
|
287
|
+
text-transform: uppercase;
|
|
288
|
+
letter-spacing: var(--tracking-wide);
|
|
289
|
+
color: var(--text-dim);
|
|
290
|
+
margin: 0 0 var(--space-2) 0;
|
|
291
|
+
border-bottom: 1px solid var(--border2);
|
|
292
|
+
padding-bottom: var(--space-1);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.cpub-admin-layouts-help-list {
|
|
296
|
+
display: grid;
|
|
297
|
+
grid-template-columns: minmax(120px, max-content) 1fr;
|
|
298
|
+
column-gap: var(--space-4);
|
|
299
|
+
row-gap: var(--space-2);
|
|
300
|
+
margin: 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.cpub-admin-layouts-help-chord {
|
|
304
|
+
display: inline-flex;
|
|
305
|
+
align-items: center;
|
|
306
|
+
gap: var(--space-1);
|
|
307
|
+
margin: 0;
|
|
308
|
+
}
|
|
309
|
+
.cpub-admin-layouts-help-chord kbd {
|
|
310
|
+
display: inline-flex;
|
|
311
|
+
align-items: center;
|
|
312
|
+
justify-content: center;
|
|
313
|
+
min-width: 1.6em;
|
|
314
|
+
padding: 0 var(--space-1);
|
|
315
|
+
height: 1.6em;
|
|
316
|
+
background: var(--surface2);
|
|
317
|
+
border: 1px solid var(--border);
|
|
318
|
+
border-bottom-width: 2px;
|
|
319
|
+
color: var(--text);
|
|
320
|
+
font-family: var(--font-mono);
|
|
321
|
+
font-size: var(--text-xs);
|
|
322
|
+
text-transform: none;
|
|
323
|
+
letter-spacing: 0;
|
|
324
|
+
}
|
|
325
|
+
.cpub-admin-layouts-help-plus {
|
|
326
|
+
color: var(--text-faint);
|
|
327
|
+
font-size: var(--text-xs);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.cpub-admin-layouts-help-desc {
|
|
331
|
+
margin: 0;
|
|
332
|
+
color: var(--text);
|
|
333
|
+
font-size: var(--text-sm);
|
|
334
|
+
line-height: 1.5;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.cpub-admin-layouts-help-footer {
|
|
338
|
+
padding: var(--space-3) var(--space-4);
|
|
339
|
+
border-top: 1px solid var(--border2);
|
|
340
|
+
}
|
|
341
|
+
.cpub-admin-layouts-help-hint {
|
|
342
|
+
font-size: var(--text-xs);
|
|
343
|
+
color: var(--text-dim);
|
|
344
|
+
margin: 0;
|
|
345
|
+
}
|
|
346
|
+
</style>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Inspector — right column of the editor shell. 3-way dispatcher on the
|
|
4
|
+
* editor's current selection (Phase 3e):
|
|
5
|
+
* - nothing selected → page-meta form (<AdminLayoutsInspectorPage>)
|
|
6
|
+
* - section selected → section config auto-form (<AdminLayoutsInspectorSection>)
|
|
7
|
+
* - row selected → row config auto-form (<AdminLayoutsInspectorRow>)
|
|
8
|
+
*
|
|
9
|
+
* The selected section/row are resolved from `draft` by id so the
|
|
10
|
+
* inspector stays a pure function of (draft, selection) — no duplicated
|
|
11
|
+
* selection state. A stale selection (id no longer in draft, e.g. another
|
|
12
|
+
* admin deleted it) falls back to the page-meta form.
|
|
13
|
+
*
|
|
14
|
+
* Config edits bubble as `update:section-config` / `update:row-config`
|
|
15
|
+
* with `{ id, config }`; the editor page mutates the draft (auto-save
|
|
16
|
+
* picks up the dirty flag). Page-meta + name edits keep their existing
|
|
17
|
+
* emits unchanged.
|
|
18
|
+
*/
|
|
19
|
+
import type { LayoutRecord, LayoutSectionResolved, LayoutRowResolved } from '@commonpub/server';
|
|
20
|
+
import type { EditorSelection } from '../../../composables/useLayoutEditor';
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
draft: LayoutRecord | null;
|
|
24
|
+
selection: EditorSelection;
|
|
25
|
+
}>();
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits<{
|
|
28
|
+
(e: 'update:page-meta', value: LayoutRecord['pageMeta']): void;
|
|
29
|
+
(e: 'update:name', value: string): void;
|
|
30
|
+
(e: 'update:section-config', value: { id: string; config: Record<string, unknown> }): void;
|
|
31
|
+
(e: 'update:row-config', value: { id: string; config: Record<string, unknown> }): void;
|
|
32
|
+
}>();
|
|
33
|
+
|
|
34
|
+
/** Locate the selected section in the draft (or null if absent/stale). */
|
|
35
|
+
const selectedSection = computed<LayoutSectionResolved | null>(() => {
|
|
36
|
+
const sel = props.selection;
|
|
37
|
+
if (!sel || sel.kind !== 'section' || !props.draft) return null;
|
|
38
|
+
for (const zone of props.draft.zones) {
|
|
39
|
+
for (const row of zone.rows) {
|
|
40
|
+
const found = row.sections.find((s) => s.id === sel.id);
|
|
41
|
+
if (found) return found;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Locate the selected row in the draft (or null if absent/stale). */
|
|
48
|
+
const selectedRow = computed<LayoutRowResolved | null>(() => {
|
|
49
|
+
const sel = props.selection;
|
|
50
|
+
if (!sel || sel.kind !== 'row' || !props.draft) return null;
|
|
51
|
+
for (const zone of props.draft.zones) {
|
|
52
|
+
const found = zone.rows.find((r) => r.id === sel.id);
|
|
53
|
+
if (found) return found;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** Which branch the dispatcher renders — also drives the header hint. */
|
|
59
|
+
const mode = computed<'loading' | 'page' | 'section' | 'row'>(() => {
|
|
60
|
+
if (!props.draft) return 'loading';
|
|
61
|
+
if (selectedSection.value) return 'section';
|
|
62
|
+
if (selectedRow.value) return 'row';
|
|
63
|
+
return 'page';
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const hint = computed<string>(() => {
|
|
67
|
+
switch (mode.value) {
|
|
68
|
+
case 'section': return 'Editing the selected section. Click empty canvas to edit page meta.';
|
|
69
|
+
case 'row': return 'Editing the selected row. Click empty canvas to edit page meta.';
|
|
70
|
+
default: return 'Page meta. Select a section or row to edit its content.';
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function onPageMetaUpdate(value: LayoutRecord['pageMeta']): void {
|
|
75
|
+
emit('update:page-meta', value);
|
|
76
|
+
}
|
|
77
|
+
function onNameUpdate(value: string): void {
|
|
78
|
+
emit('update:name', value);
|
|
79
|
+
}
|
|
80
|
+
function onSectionConfigUpdate(config: Record<string, unknown>): void {
|
|
81
|
+
const s = selectedSection.value;
|
|
82
|
+
if (s) emit('update:section-config', { id: s.id, config });
|
|
83
|
+
}
|
|
84
|
+
function onRowConfigUpdate(config: Record<string, unknown>): void {
|
|
85
|
+
const r = selectedRow.value;
|
|
86
|
+
if (r) emit('update:row-config', { id: r.id, config });
|
|
87
|
+
}
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<template>
|
|
91
|
+
<aside class="cpub-admin-layouts-inspector" aria-label="Inspector">
|
|
92
|
+
<header class="cpub-admin-layouts-inspector-header">
|
|
93
|
+
<h2 class="cpub-admin-layouts-inspector-title">Inspector</h2>
|
|
94
|
+
<p class="cpub-admin-layouts-inspector-hint">{{ hint }}</p>
|
|
95
|
+
</header>
|
|
96
|
+
|
|
97
|
+
<div v-if="mode === 'loading'" class="cpub-admin-layouts-inspector-loading">
|
|
98
|
+
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
|
99
|
+
<span>Loading…</span>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<AdminLayoutsInspectorSection
|
|
103
|
+
v-else-if="mode === 'section' && selectedSection"
|
|
104
|
+
:section="selectedSection"
|
|
105
|
+
@update:config="onSectionConfigUpdate"
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
<AdminLayoutsInspectorRow
|
|
109
|
+
v-else-if="mode === 'row' && selectedRow"
|
|
110
|
+
:row="selectedRow"
|
|
111
|
+
@update:config="onRowConfigUpdate"
|
|
112
|
+
/>
|
|
113
|
+
|
|
114
|
+
<AdminLayoutsInspectorPage
|
|
115
|
+
v-else-if="draft"
|
|
116
|
+
:draft="draft"
|
|
117
|
+
@update:page-meta="onPageMetaUpdate"
|
|
118
|
+
@update:name="onNameUpdate"
|
|
119
|
+
/>
|
|
120
|
+
</aside>
|
|
121
|
+
</template>
|
|
122
|
+
|
|
123
|
+
<style scoped>
|
|
124
|
+
.cpub-admin-layouts-inspector {
|
|
125
|
+
display: flex;
|
|
126
|
+
flex-direction: column;
|
|
127
|
+
gap: var(--space-4);
|
|
128
|
+
padding: var(--space-4);
|
|
129
|
+
background: var(--surface);
|
|
130
|
+
border-left: var(--border-width-default) solid var(--border);
|
|
131
|
+
overflow-y: auto;
|
|
132
|
+
height: 100%;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.cpub-admin-layouts-inspector-header { border-bottom: 1px solid var(--border2); padding-bottom: var(--space-3); }
|
|
136
|
+
.cpub-admin-layouts-inspector-title {
|
|
137
|
+
font-family: var(--font-mono);
|
|
138
|
+
font-size: var(--text-xs);
|
|
139
|
+
text-transform: uppercase;
|
|
140
|
+
letter-spacing: var(--tracking-widest);
|
|
141
|
+
color: var(--text-dim);
|
|
142
|
+
margin: 0 0 var(--space-1) 0;
|
|
143
|
+
font-weight: var(--font-weight-semibold);
|
|
144
|
+
}
|
|
145
|
+
.cpub-admin-layouts-inspector-hint {
|
|
146
|
+
font-size: var(--text-xs);
|
|
147
|
+
color: var(--text-faint);
|
|
148
|
+
margin: 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.cpub-admin-layouts-inspector-loading {
|
|
152
|
+
display: flex; align-items: center; gap: var(--space-2);
|
|
153
|
+
padding: var(--space-4);
|
|
154
|
+
color: var(--text-faint);
|
|
155
|
+
font-size: var(--text-sm);
|
|
156
|
+
}
|
|
157
|
+
</style>
|