@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.
- 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/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useLayout.ts +34 -41
- 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/package.json +10 -7
- 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 +11 -19
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +16 -30
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +11 -21
- 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 +11 -26
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +5 -13
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +17 -18
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +11 -0
- 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 +12 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- 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/SectionContests.vue +0 -193
- package/components/sections/SectionCustomHtml.vue +0 -70
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionEditorial.vue +0 -138
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionHubs.vue +0 -247
- package/components/sections/SectionImage.vue +0 -104
- package/components/sections/SectionParagraph.vue +0 -55
- package/components/sections/SectionStats.vue +0 -151
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Page-meta inspector form (Phase 3a.4).
|
|
4
|
+
*
|
|
5
|
+
* Fields: name (layout-table column) + title + description + ogImage +
|
|
6
|
+
* ogType + access + frame + noindex. Hardcoded for v1 — Phase 3e
|
|
7
|
+
* replaces this with a Zod-driven auto-form (FormKit). Until then,
|
|
8
|
+
* the explicit form gives admins immediate control over the meta
|
|
9
|
+
* tags + frame without depending on the form-generator work.
|
|
10
|
+
*
|
|
11
|
+
* Emits `update:page-meta` + `update:name` so the editor page can
|
|
12
|
+
* mutate the draft (auto-save 3a.6 watches for dirty + persists).
|
|
13
|
+
* No internal state — fully controlled component.
|
|
14
|
+
*/
|
|
15
|
+
import type { LayoutRecord } from '@commonpub/server';
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{ draft: LayoutRecord }>();
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits<{
|
|
20
|
+
(e: 'update:page-meta', value: LayoutRecord['pageMeta']): void;
|
|
21
|
+
(e: 'update:name', value: string): void;
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
// Local controlled values mirror the draft. Edits emit upward; the
|
|
25
|
+
// editor page mutates the draft ref; reactivity flows back here on
|
|
26
|
+
// next tick. No local state to keep in sync.
|
|
27
|
+
const name = computed<string>({
|
|
28
|
+
get: () => props.draft.name,
|
|
29
|
+
set: (v) => emit('update:name', v),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Helper: build a patched pageMeta object preserving other fields.
|
|
33
|
+
// Title is required by the schema — never delete it; empty-string instead.
|
|
34
|
+
function patch<K extends keyof NonNullable<LayoutRecord['pageMeta']>>(
|
|
35
|
+
key: K,
|
|
36
|
+
value: NonNullable<LayoutRecord['pageMeta']>[K] | undefined,
|
|
37
|
+
): void {
|
|
38
|
+
type PM = NonNullable<LayoutRecord['pageMeta']>;
|
|
39
|
+
const current: PM = props.draft.pageMeta ?? { title: '' };
|
|
40
|
+
const next: PM = { ...current };
|
|
41
|
+
if (value === undefined || value === '') {
|
|
42
|
+
if (key === 'title') {
|
|
43
|
+
next.title = '';
|
|
44
|
+
} else {
|
|
45
|
+
delete next[key];
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
next[key] = value;
|
|
49
|
+
}
|
|
50
|
+
emit('update:page-meta', next);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const title = computed<string>({
|
|
54
|
+
get: () => props.draft.pageMeta?.title ?? '',
|
|
55
|
+
set: (v) => patch('title', v),
|
|
56
|
+
});
|
|
57
|
+
const description = computed<string>({
|
|
58
|
+
get: () => props.draft.pageMeta?.description ?? '',
|
|
59
|
+
set: (v) => patch('description', v || undefined),
|
|
60
|
+
});
|
|
61
|
+
const ogImage = computed<string>({
|
|
62
|
+
get: () => props.draft.pageMeta?.ogImage ?? '',
|
|
63
|
+
set: (v) => patch('ogImage', v || undefined),
|
|
64
|
+
});
|
|
65
|
+
const ogType = computed<'website' | 'article' | 'profile'>({
|
|
66
|
+
get: () => (props.draft.pageMeta?.ogType as 'website' | 'article' | 'profile') ?? 'website',
|
|
67
|
+
set: (v) => patch('ogType', v),
|
|
68
|
+
});
|
|
69
|
+
const access = computed<'public' | 'members' | 'admin'>({
|
|
70
|
+
get: () => (props.draft.pageMeta?.access as 'public' | 'members' | 'admin') ?? 'public',
|
|
71
|
+
set: (v) => patch('access', v),
|
|
72
|
+
});
|
|
73
|
+
const frame = computed<'narrow' | 'wide' | 'two-column' | 'three-column' | 'sidebar-left' | 'sidebar-right'>({
|
|
74
|
+
get: () => (props.draft.pageMeta?.frame as 'narrow' | 'wide' | 'two-column' | 'three-column' | 'sidebar-left' | 'sidebar-right') ?? 'wide',
|
|
75
|
+
set: (v) => patch('frame', v),
|
|
76
|
+
});
|
|
77
|
+
const noindex = computed<boolean>({
|
|
78
|
+
get: () => props.draft.pageMeta?.noindex ?? false,
|
|
79
|
+
set: (v) => patch('noindex', v || undefined),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const id = (suffix: string): string => `cpub-inspector-page-${suffix}`;
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<template>
|
|
86
|
+
<form
|
|
87
|
+
class="cpub-inspector-page-form"
|
|
88
|
+
@submit.prevent
|
|
89
|
+
aria-label="Page meta editor"
|
|
90
|
+
>
|
|
91
|
+
<div class="cpub-inspector-page-field">
|
|
92
|
+
<label :for="id('name')">Layout name</label>
|
|
93
|
+
<input
|
|
94
|
+
:id="id('name')"
|
|
95
|
+
v-model="name"
|
|
96
|
+
type="text"
|
|
97
|
+
autocomplete="off"
|
|
98
|
+
:aria-describedby="id('name-hint')"
|
|
99
|
+
/>
|
|
100
|
+
<p :id="id('name-hint')" class="cpub-inspector-page-hint">
|
|
101
|
+
Internal label shown in the layouts list. Not user-facing.
|
|
102
|
+
</p>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div class="cpub-inspector-page-field">
|
|
106
|
+
<label :for="id('title')">Page title</label>
|
|
107
|
+
<input
|
|
108
|
+
:id="id('title')"
|
|
109
|
+
v-model="title"
|
|
110
|
+
type="text"
|
|
111
|
+
autocomplete="off"
|
|
112
|
+
:aria-describedby="id('title-hint')"
|
|
113
|
+
/>
|
|
114
|
+
<p :id="id('title-hint')" class="cpub-inspector-page-hint">
|
|
115
|
+
Browser tab + Open Graph title. Required.
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div class="cpub-inspector-page-field">
|
|
120
|
+
<label :for="id('description')">Description</label>
|
|
121
|
+
<textarea
|
|
122
|
+
:id="id('description')"
|
|
123
|
+
v-model="description"
|
|
124
|
+
rows="3"
|
|
125
|
+
></textarea>
|
|
126
|
+
<p class="cpub-inspector-page-hint">
|
|
127
|
+
Meta description + Open Graph description.
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div class="cpub-inspector-page-field">
|
|
132
|
+
<label :for="id('ogImage')">OG image URL</label>
|
|
133
|
+
<input
|
|
134
|
+
:id="id('ogImage')"
|
|
135
|
+
v-model="ogImage"
|
|
136
|
+
type="url"
|
|
137
|
+
autocomplete="off"
|
|
138
|
+
placeholder="https://…"
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div class="cpub-inspector-page-field-grid">
|
|
143
|
+
<div class="cpub-inspector-page-field">
|
|
144
|
+
<label :for="id('ogType')">OG type</label>
|
|
145
|
+
<select :id="id('ogType')" v-model="ogType">
|
|
146
|
+
<option value="website">website</option>
|
|
147
|
+
<option value="article">article</option>
|
|
148
|
+
<option value="profile">profile</option>
|
|
149
|
+
</select>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div class="cpub-inspector-page-field">
|
|
153
|
+
<label :for="id('access')">Access</label>
|
|
154
|
+
<select :id="id('access')" v-model="access">
|
|
155
|
+
<option value="public">public</option>
|
|
156
|
+
<option value="members">members</option>
|
|
157
|
+
<option value="admin">admin</option>
|
|
158
|
+
</select>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="cpub-inspector-page-field cpub-inspector-page-field--disabled">
|
|
163
|
+
<label :for="id('frame')">Frame <span class="cpub-inspector-page-soon">Phase 4</span></label>
|
|
164
|
+
<select :id="id('frame')" v-model="frame" disabled aria-describedby="frame-soon-hint">
|
|
165
|
+
<option value="narrow">narrow</option>
|
|
166
|
+
<option value="wide">wide</option>
|
|
167
|
+
<option value="two-column">two-column</option>
|
|
168
|
+
<option value="three-column">three-column</option>
|
|
169
|
+
<option value="sidebar-left">sidebar-left</option>
|
|
170
|
+
<option value="sidebar-right">sidebar-right</option>
|
|
171
|
+
</select>
|
|
172
|
+
<p id="frame-soon-hint" class="cpub-inspector-page-hint">
|
|
173
|
+
Page chrome shape — reserved for Phase 4. Currently has no effect; the renderer
|
|
174
|
+
always exposes the same three zones (full-width, main, sidebar).
|
|
175
|
+
</p>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div class="cpub-inspector-page-checkbox">
|
|
179
|
+
<input
|
|
180
|
+
:id="id('noindex')"
|
|
181
|
+
v-model="noindex"
|
|
182
|
+
type="checkbox"
|
|
183
|
+
/>
|
|
184
|
+
<label :for="id('noindex')">noindex (hide from search engines)</label>
|
|
185
|
+
</div>
|
|
186
|
+
</form>
|
|
187
|
+
</template>
|
|
188
|
+
|
|
189
|
+
<style scoped>
|
|
190
|
+
.cpub-inspector-page-form {
|
|
191
|
+
display: flex;
|
|
192
|
+
flex-direction: column;
|
|
193
|
+
gap: var(--space-4);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.cpub-inspector-page-field { display: flex; flex-direction: column; gap: var(--space-1); }
|
|
197
|
+
.cpub-inspector-page-field-grid {
|
|
198
|
+
display: grid;
|
|
199
|
+
grid-template-columns: 1fr 1fr;
|
|
200
|
+
gap: var(--space-3);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.cpub-inspector-page-field label {
|
|
204
|
+
font-family: var(--font-mono);
|
|
205
|
+
font-size: 10px;
|
|
206
|
+
text-transform: uppercase;
|
|
207
|
+
letter-spacing: var(--tracking-wide);
|
|
208
|
+
color: var(--text-dim);
|
|
209
|
+
font-weight: var(--font-weight-semibold);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.cpub-inspector-page-field input,
|
|
213
|
+
.cpub-inspector-page-field textarea,
|
|
214
|
+
.cpub-inspector-page-field select {
|
|
215
|
+
padding: var(--space-2) var(--space-3);
|
|
216
|
+
background: var(--surface);
|
|
217
|
+
border: var(--border-width-default) solid var(--border);
|
|
218
|
+
color: var(--text);
|
|
219
|
+
font-family: var(--font-body);
|
|
220
|
+
font-size: var(--text-sm);
|
|
221
|
+
}
|
|
222
|
+
.cpub-inspector-page-field input:focus-visible,
|
|
223
|
+
.cpub-inspector-page-field textarea:focus-visible,
|
|
224
|
+
.cpub-inspector-page-field select:focus-visible {
|
|
225
|
+
outline: 2px solid var(--accent);
|
|
226
|
+
outline-offset: 1px;
|
|
227
|
+
border-color: var(--accent);
|
|
228
|
+
}
|
|
229
|
+
.cpub-inspector-page-field textarea {
|
|
230
|
+
resize: vertical;
|
|
231
|
+
font-family: var(--font-body);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.cpub-inspector-page-hint {
|
|
235
|
+
font-size: var(--text-xs);
|
|
236
|
+
color: var(--text-faint);
|
|
237
|
+
margin: 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* Disabled-field pattern for "reserved for future phase" controls.
|
|
241
|
+
The field is visible (signals the surface exists) but greyed out to
|
|
242
|
+
prevent the "set it, see nothing change" trust break per audit round 3. */
|
|
243
|
+
.cpub-inspector-page-field--disabled { opacity: 0.65; }
|
|
244
|
+
.cpub-inspector-page-field--disabled select { cursor: not-allowed; background: var(--surface2); }
|
|
245
|
+
.cpub-inspector-page-soon {
|
|
246
|
+
font-family: var(--font-mono);
|
|
247
|
+
font-size: 9px;
|
|
248
|
+
text-transform: uppercase;
|
|
249
|
+
letter-spacing: var(--tracking-wide);
|
|
250
|
+
color: var(--text-faint);
|
|
251
|
+
border: 1px solid var(--border2);
|
|
252
|
+
padding: 0 4px;
|
|
253
|
+
margin-left: var(--space-1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.cpub-inspector-page-checkbox {
|
|
257
|
+
display: flex;
|
|
258
|
+
align-items: center;
|
|
259
|
+
gap: var(--space-2);
|
|
260
|
+
}
|
|
261
|
+
.cpub-inspector-page-checkbox label {
|
|
262
|
+
font-size: var(--text-sm);
|
|
263
|
+
color: var(--text);
|
|
264
|
+
}
|
|
265
|
+
.cpub-inspector-page-checkbox input { cursor: pointer; }
|
|
266
|
+
</style>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Row-config inspector (Phase 3e). Edits a row's styling config — gap,
|
|
4
|
+
* align, background, vertical padding. Dogfoods the SAME auto-form engine
|
|
5
|
+
* as the section inspector, fed the canonical `layoutRowConfigSchema` from
|
|
6
|
+
* `@commonpub/schema` (the exact schema the server validates row config
|
|
7
|
+
* against — no drift).
|
|
8
|
+
*
|
|
9
|
+
* Controlled: emits `update:config` with a fresh config object. The page
|
|
10
|
+
* replaces `row.config` → draft watcher → dirty → auto-save. Row config is
|
|
11
|
+
* nullable; we present an empty object to the form so a fresh row starts
|
|
12
|
+
* blank, and emit only the keys the admin actually sets.
|
|
13
|
+
*/
|
|
14
|
+
import type { LayoutRowResolved } from '@commonpub/server';
|
|
15
|
+
import { layoutRowConfigSchema } from '@commonpub/schema';
|
|
16
|
+
import { buildAutoForm } from '../../../composables/autoFormSchema';
|
|
17
|
+
|
|
18
|
+
const props = defineProps<{ row: LayoutRowResolved }>();
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
(e: 'update:config', value: Record<string, unknown>): void;
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
const model = buildAutoForm(layoutRowConfigSchema);
|
|
25
|
+
|
|
26
|
+
const config = computed<Record<string, unknown>>(() => (props.row.config ?? {}) as Record<string, unknown>);
|
|
27
|
+
|
|
28
|
+
const errors = computed<Record<string, string>>(() => {
|
|
29
|
+
const result = layoutRowConfigSchema.safeParse(config.value);
|
|
30
|
+
if (result.success) return {};
|
|
31
|
+
const map: Record<string, string> = {};
|
|
32
|
+
for (const issue of result.error.issues) {
|
|
33
|
+
const key = issue.path.map((p) => String(p)).join('.');
|
|
34
|
+
if (!(key in map)) map[key] = issue.message;
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function onUpdate(value: Record<string, unknown>): void {
|
|
40
|
+
emit('update:config', value);
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<div class="cpub-inspector-row">
|
|
46
|
+
<header class="cpub-inspector-row-head">
|
|
47
|
+
<i class="fa-solid fa-grip-lines" aria-hidden="true"></i>
|
|
48
|
+
<span class="cpub-inspector-row-name">Row</span>
|
|
49
|
+
</header>
|
|
50
|
+
|
|
51
|
+
<AdminLayoutsAutoForm
|
|
52
|
+
:fields="model.fields"
|
|
53
|
+
:model-value="config"
|
|
54
|
+
:errors="errors"
|
|
55
|
+
:id-seed="`row-${row.id}`"
|
|
56
|
+
@update:model-value="onUpdate"
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<style scoped>
|
|
62
|
+
.cpub-inspector-row {
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
gap: var(--space-4);
|
|
66
|
+
}
|
|
67
|
+
.cpub-inspector-row-head {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: var(--space-2);
|
|
71
|
+
padding-bottom: var(--space-2);
|
|
72
|
+
border-bottom: 1px solid var(--border2);
|
|
73
|
+
}
|
|
74
|
+
.cpub-inspector-row-head > i { color: var(--accent); font-size: var(--text-base); }
|
|
75
|
+
.cpub-inspector-row-name {
|
|
76
|
+
font-size: var(--text-sm);
|
|
77
|
+
font-weight: var(--font-weight-semibold);
|
|
78
|
+
color: var(--text);
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Section-config inspector (Phase 3e). Renders the auto-generated form
|
|
4
|
+
* for the selected section's `config` blob, driven by the section's
|
|
5
|
+
* `configSchema` (Zod) via the `buildAutoForm` engine + the recursive
|
|
6
|
+
* `<AdminLayoutsAutoForm>` renderer.
|
|
7
|
+
*
|
|
8
|
+
* Controlled component: emits `update:config` with a fresh config object
|
|
9
|
+
* on every edit. The editor page replaces `section.config` with it →
|
|
10
|
+
* draft deep-watcher fires → dirty → existing 1.5s auto-save debounce.
|
|
11
|
+
* Single-flight save is untouched (we only mutate `draft`).
|
|
12
|
+
*
|
|
13
|
+
* Validation: the full config is `safeParse`d against the section's Zod
|
|
14
|
+
* schema on every change; issues surface inline per field (keyed by
|
|
15
|
+
* dot-joined path). This is the SOFT guide; the server's
|
|
16
|
+
* `validateSectionConfigs` is the hard gate.
|
|
17
|
+
*
|
|
18
|
+
* Edge states handled (plan §7.15):
|
|
19
|
+
* - Unregistered section type (layer upgrade removed it) → error card.
|
|
20
|
+
* - Empty schema (e.g. `stats`) → "no options" note.
|
|
21
|
+
* - Schema-version drift → advisory banner.
|
|
22
|
+
*/
|
|
23
|
+
import type { LayoutSectionResolved } from '@commonpub/server';
|
|
24
|
+
import { buildAutoForm } from '../../../composables/autoFormSchema';
|
|
25
|
+
import { useSectionRegistry } from '../../../sections/registry';
|
|
26
|
+
|
|
27
|
+
const props = defineProps<{ section: LayoutSectionResolved }>();
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{
|
|
30
|
+
(e: 'update:config', value: Record<string, unknown>): void;
|
|
31
|
+
}>();
|
|
32
|
+
|
|
33
|
+
const registry = useSectionRegistry();
|
|
34
|
+
|
|
35
|
+
const def = computed(() => registry.get(props.section.type));
|
|
36
|
+
|
|
37
|
+
const model = computed(() => {
|
|
38
|
+
const d = def.value;
|
|
39
|
+
if (!d) return null;
|
|
40
|
+
return buildAutoForm(d.configSchema);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Advisory only — the renderer migrates on load; this flags un-saved drift. */
|
|
44
|
+
const versionDrift = computed<boolean>(() => {
|
|
45
|
+
const d = def.value;
|
|
46
|
+
return !!d && typeof props.section.schemaVersion === 'number' && props.section.schemaVersion !== d.schemaVersion;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Inline validation — safeParse the live config, flatten Zod issues into
|
|
51
|
+
* a dot-joined-path → message map the AutoForm looks fields up in.
|
|
52
|
+
*/
|
|
53
|
+
const errors = computed<Record<string, string>>(() => {
|
|
54
|
+
const d = def.value;
|
|
55
|
+
if (!d) return {};
|
|
56
|
+
const result = d.configSchema.safeParse(props.section.config);
|
|
57
|
+
if (result.success) return {};
|
|
58
|
+
const map: Record<string, string> = {};
|
|
59
|
+
for (const issue of result.error.issues) {
|
|
60
|
+
const key = issue.path.map((p) => String(p)).join('.');
|
|
61
|
+
// First issue per path wins (matches FormKit/native single-message UX).
|
|
62
|
+
if (!(key in map)) map[key] = issue.message;
|
|
63
|
+
}
|
|
64
|
+
return map;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function onUpdate(value: Record<string, unknown>): void {
|
|
68
|
+
emit('update:config', value);
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<div class="cpub-inspector-section">
|
|
74
|
+
<header class="cpub-inspector-section-head">
|
|
75
|
+
<i :class="['fa-solid', def?.icon ?? 'fa-puzzle-piece']" aria-hidden="true"></i>
|
|
76
|
+
<div class="cpub-inspector-section-head-text">
|
|
77
|
+
<span class="cpub-inspector-section-name">{{ def?.name ?? section.type }}</span>
|
|
78
|
+
<span class="cpub-inspector-section-type">{{ section.type }}</span>
|
|
79
|
+
</div>
|
|
80
|
+
</header>
|
|
81
|
+
|
|
82
|
+
<!-- Unregistered type — the layer dropped this section since save. -->
|
|
83
|
+
<div v-if="!def" class="cpub-inspector-section-unknown" role="alert">
|
|
84
|
+
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
|
|
85
|
+
<p>
|
|
86
|
+
Unknown section type <code>{{ section.type }}</code>. It may have been
|
|
87
|
+
removed in a layer upgrade. Its config can’t be edited here; remove the
|
|
88
|
+
section or restore the section type.
|
|
89
|
+
</p>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<template v-else>
|
|
93
|
+
<div v-if="versionDrift" class="cpub-inspector-section-drift" role="status">
|
|
94
|
+
<i class="fa-solid fa-circle-info" aria-hidden="true"></i>
|
|
95
|
+
<span>
|
|
96
|
+
This section was authored on schema v{{ section.schemaVersion }};
|
|
97
|
+
the current version is v{{ def!.schemaVersion }}. Save to persist any upgrade.
|
|
98
|
+
</span>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<p v-if="model?.isEmpty" class="cpub-inspector-section-empty">
|
|
102
|
+
This section has no configurable options.
|
|
103
|
+
</p>
|
|
104
|
+
|
|
105
|
+
<AdminLayoutsAutoForm
|
|
106
|
+
v-else-if="model"
|
|
107
|
+
:fields="model.fields"
|
|
108
|
+
:model-value="section.config"
|
|
109
|
+
:errors="errors"
|
|
110
|
+
:id-seed="`section-${section.id}`"
|
|
111
|
+
@update:model-value="onUpdate"
|
|
112
|
+
/>
|
|
113
|
+
</template>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<style scoped>
|
|
118
|
+
.cpub-inspector-section {
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
gap: var(--space-4);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.cpub-inspector-section-head {
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: var(--space-2);
|
|
128
|
+
padding-bottom: var(--space-2);
|
|
129
|
+
border-bottom: 1px solid var(--border2);
|
|
130
|
+
}
|
|
131
|
+
.cpub-inspector-section-head > i { color: var(--accent); font-size: var(--text-base); }
|
|
132
|
+
.cpub-inspector-section-head-text { display: flex; flex-direction: column; gap: 1px; }
|
|
133
|
+
.cpub-inspector-section-name {
|
|
134
|
+
font-size: var(--text-sm);
|
|
135
|
+
font-weight: var(--font-weight-semibold);
|
|
136
|
+
color: var(--text);
|
|
137
|
+
}
|
|
138
|
+
.cpub-inspector-section-type {
|
|
139
|
+
font-family: var(--font-mono);
|
|
140
|
+
font-size: 10px;
|
|
141
|
+
text-transform: uppercase;
|
|
142
|
+
letter-spacing: var(--tracking-wide);
|
|
143
|
+
color: var(--text-faint);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.cpub-inspector-section-empty,
|
|
147
|
+
.cpub-inspector-section-unknown,
|
|
148
|
+
.cpub-inspector-section-drift {
|
|
149
|
+
font-size: var(--text-sm);
|
|
150
|
+
color: var(--text-dim);
|
|
151
|
+
margin: 0;
|
|
152
|
+
}
|
|
153
|
+
.cpub-inspector-section-empty { color: var(--text-faint); }
|
|
154
|
+
|
|
155
|
+
.cpub-inspector-section-unknown,
|
|
156
|
+
.cpub-inspector-section-drift {
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: flex-start;
|
|
159
|
+
gap: var(--space-2);
|
|
160
|
+
padding: var(--space-3);
|
|
161
|
+
border: var(--border-width-default) solid var(--border2);
|
|
162
|
+
}
|
|
163
|
+
.cpub-inspector-section-unknown {
|
|
164
|
+
background: var(--yellow-bg);
|
|
165
|
+
border-color: var(--yellow-border);
|
|
166
|
+
}
|
|
167
|
+
.cpub-inspector-section-unknown i { color: var(--yellow); margin-top: 2px; }
|
|
168
|
+
.cpub-inspector-section-unknown code {
|
|
169
|
+
font-family: var(--font-mono);
|
|
170
|
+
background: var(--surface);
|
|
171
|
+
padding: 0 4px;
|
|
172
|
+
}
|
|
173
|
+
.cpub-inspector-section-drift i { color: var(--accent); margin-top: 2px; }
|
|
174
|
+
.cpub-inspector-section-drift span { font-size: var(--text-xs); }
|
|
175
|
+
</style>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Section palette — left column of the editor shell.
|
|
4
|
+
*
|
|
5
|
+
* Phase 3a.3 shipped READ-ONLY tiles (no drag). Phase 3b/A turns each
|
|
6
|
+
* tile into a drag source via <AdminLayoutsPaletteTile>'s makeDraggable.
|
|
7
|
+
* The drop side lives in <LayoutRow>'s makeDroppable; the shared
|
|
8
|
+
* `useLayoutDrag` module connects them.
|
|
9
|
+
*
|
|
10
|
+
* The categories order matches the palette grouping in
|
|
11
|
+
* docs/plans/layout-and-pages.md §7.2 — layout, content, data,
|
|
12
|
+
* editorial, interactive, embed, custom.
|
|
13
|
+
*/
|
|
14
|
+
import { computed } from 'vue';
|
|
15
|
+
import { useSectionRegistry } from '../../../sections/registry';
|
|
16
|
+
import type { SectionCategory } from '@commonpub/ui';
|
|
17
|
+
import AdminLayoutsPaletteTile from './AdminLayoutsPaletteTile.vue';
|
|
18
|
+
|
|
19
|
+
const CATEGORY_LABELS: Record<SectionCategory, string> = {
|
|
20
|
+
layout: 'Layout',
|
|
21
|
+
content: 'Content',
|
|
22
|
+
data: 'Data',
|
|
23
|
+
editorial: 'Editorial',
|
|
24
|
+
interactive: 'Interactive',
|
|
25
|
+
embed: 'Embed',
|
|
26
|
+
custom: 'Custom',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Order for visual grouping in the palette.
|
|
30
|
+
const CATEGORY_ORDER: SectionCategory[] = [
|
|
31
|
+
'layout', 'content', 'data', 'editorial', 'interactive', 'embed', 'custom',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const registry = useSectionRegistry();
|
|
35
|
+
|
|
36
|
+
const grouped = computed(() => {
|
|
37
|
+
const byCat = registry.byCategory();
|
|
38
|
+
return CATEGORY_ORDER
|
|
39
|
+
.map((category) => ({
|
|
40
|
+
category,
|
|
41
|
+
label: CATEGORY_LABELS[category],
|
|
42
|
+
sections: byCat[category],
|
|
43
|
+
}))
|
|
44
|
+
.filter((g) => g.sections.length > 0);
|
|
45
|
+
});
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<aside class="cpub-admin-layouts-palette" aria-label="Section palette">
|
|
50
|
+
<header class="cpub-admin-layouts-palette-header">
|
|
51
|
+
<h2 class="cpub-admin-layouts-palette-title">Sections available</h2>
|
|
52
|
+
<p class="cpub-admin-layouts-palette-hint">
|
|
53
|
+
Drag a tile onto a row to add a section.
|
|
54
|
+
</p>
|
|
55
|
+
</header>
|
|
56
|
+
|
|
57
|
+
<div
|
|
58
|
+
v-for="group in grouped"
|
|
59
|
+
:key="group.category"
|
|
60
|
+
class="cpub-admin-layouts-palette-group"
|
|
61
|
+
>
|
|
62
|
+
<h3 class="cpub-admin-layouts-palette-group-title">{{ group.label }}</h3>
|
|
63
|
+
<ul class="cpub-admin-layouts-palette-list">
|
|
64
|
+
<AdminLayoutsPaletteTile
|
|
65
|
+
v-for="section in group.sections"
|
|
66
|
+
:key="section.type"
|
|
67
|
+
:section="section"
|
|
68
|
+
/>
|
|
69
|
+
</ul>
|
|
70
|
+
</div>
|
|
71
|
+
</aside>
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<style scoped>
|
|
75
|
+
.cpub-admin-layouts-palette {
|
|
76
|
+
display: flex; flex-direction: column;
|
|
77
|
+
gap: var(--space-4);
|
|
78
|
+
padding: var(--space-4);
|
|
79
|
+
background: var(--surface);
|
|
80
|
+
border-right: var(--border-width-default) solid var(--border);
|
|
81
|
+
overflow-y: auto;
|
|
82
|
+
height: 100%;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.cpub-admin-layouts-palette-header { border-bottom: 1px solid var(--border2); padding-bottom: var(--space-3); }
|
|
86
|
+
.cpub-admin-layouts-palette-title {
|
|
87
|
+
font-family: var(--font-mono);
|
|
88
|
+
font-size: var(--text-xs);
|
|
89
|
+
text-transform: uppercase;
|
|
90
|
+
letter-spacing: var(--tracking-widest);
|
|
91
|
+
color: var(--text-dim);
|
|
92
|
+
margin: 0 0 var(--space-1) 0;
|
|
93
|
+
font-weight: var(--font-weight-semibold);
|
|
94
|
+
}
|
|
95
|
+
.cpub-admin-layouts-palette-hint {
|
|
96
|
+
font-size: var(--text-xs);
|
|
97
|
+
color: var(--text-faint);
|
|
98
|
+
margin: 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.cpub-admin-layouts-palette-group { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
102
|
+
.cpub-admin-layouts-palette-group-title {
|
|
103
|
+
font-family: var(--font-mono);
|
|
104
|
+
font-size: 10px;
|
|
105
|
+
text-transform: uppercase;
|
|
106
|
+
letter-spacing: var(--tracking-wide);
|
|
107
|
+
color: var(--text-faint);
|
|
108
|
+
margin: 0;
|
|
109
|
+
font-weight: var(--font-weight-semibold);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.cpub-admin-layouts-palette-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-1); }
|
|
113
|
+
|
|
114
|
+
/* Tile-body styling moved to AdminLayoutsPaletteTile.vue's scoped
|
|
115
|
+
styles (Vue scoped styles are component-instance hashed). Palette
|
|
116
|
+
owns only the surrounding scroll container + group titles. */
|
|
117
|
+
</style>
|