@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,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autoFormSchema — pure Zod → form-field descriptor engine (Phase 3e).
|
|
3
|
+
*
|
|
4
|
+
* Converts a section's `configSchema` (a `z.ZodType`) into a normalized
|
|
5
|
+
* `AutoFormField[]` that `<AdminLayoutsInspectorSection>` /
|
|
6
|
+
* `<AdminLayoutsInspectorRow>` render as native inputs reusing the
|
|
7
|
+
* `cpub-inspector-page-*` design language.
|
|
8
|
+
*
|
|
9
|
+
* ## Why hand-rolled, not FormKit (session 167 decision)
|
|
10
|
+
*
|
|
11
|
+
* The Phase 3 plan + `feedback-phase-3-hybrid-libraries` prescribed
|
|
12
|
+
* `@formkit/zod` for this. Verified against source at session 167:
|
|
13
|
+
* 1. `@formkit/zod`'s `createZodPlugin` adds VALIDATION ONLY to a form
|
|
14
|
+
* whose inputs you hand-author — it does NOT generate fields from a
|
|
15
|
+
* schema (formkit.com/plugins/zod). It delivers none of Phase 3e's
|
|
16
|
+
* auto-gen goal.
|
|
17
|
+
* 2. `@formkit/zod@2.0.0` peer-deps `zod@^3`; the monorepo is on
|
|
18
|
+
* `zod@4.3.6` everywhere. FormKit has not shipped Zod 4 support.
|
|
19
|
+
* The plan's risk register pre-authorized this exact fallback ("fall back
|
|
20
|
+
* to a hand-rolled <AutoForm> — Phase 3e decision"). See
|
|
21
|
+
* `project-session-167-formkit-pivot` memory.
|
|
22
|
+
*
|
|
23
|
+
* ## How it works
|
|
24
|
+
*
|
|
25
|
+
* Zod 4 ships a native `z.toJSONSchema()` that emits a complete, stable
|
|
26
|
+
* JSON Schema (type/enum/minLength/maxLength/pattern/minimum/maximum/
|
|
27
|
+
* minItems/maxItems/default/nested items+properties/required). We walk
|
|
28
|
+
* THAT (a well-specified format) rather than poking Zod internals, so the
|
|
29
|
+
* engine is resilient to Zod's internal churn. The §7.10 input-mapping
|
|
30
|
+
* table maps 1:1 onto JSON Schema node kinds.
|
|
31
|
+
*
|
|
32
|
+
* The engine is intentionally framework-free (no Vue imports) so it is
|
|
33
|
+
* unit-testable in isolation — per `feedback-css-cascade-unit-test-blind-spot`,
|
|
34
|
+
* keeping the LOGIC pure means the only place a CSS-cascade bug can hide
|
|
35
|
+
* is the `.vue` view, which gets the fresh-eyes pass.
|
|
36
|
+
*/
|
|
37
|
+
import { z, type ZodType } from 'zod';
|
|
38
|
+
|
|
39
|
+
/** The native input control a field maps to. */
|
|
40
|
+
export type AutoFormControl =
|
|
41
|
+
| 'text'
|
|
42
|
+
| 'textarea'
|
|
43
|
+
| 'number'
|
|
44
|
+
| 'select'
|
|
45
|
+
| 'toggle'
|
|
46
|
+
| 'array'
|
|
47
|
+
| 'group'
|
|
48
|
+
| 'unsupported';
|
|
49
|
+
|
|
50
|
+
export interface AutoFormOption {
|
|
51
|
+
value: string | number;
|
|
52
|
+
label: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AutoFormField {
|
|
56
|
+
/** Object key in the config blob (also the `name` for refs / labels). */
|
|
57
|
+
key: string;
|
|
58
|
+
/** Humanized label derived from `key` (e.g. `customTitle` → "Custom title"). */
|
|
59
|
+
label: string;
|
|
60
|
+
/** The control to render. */
|
|
61
|
+
control: AutoFormControl;
|
|
62
|
+
/**
|
|
63
|
+
* Whether the field is required IN FORM TERMS — i.e. present in the
|
|
64
|
+
* schema's `required` array AND has no `default`. A field with a default
|
|
65
|
+
* is pre-filled, so it never blocks the user; we don't asterisk it.
|
|
66
|
+
*/
|
|
67
|
+
required: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* True when the field can legitimately be ABSENT — not in `required` AND
|
|
70
|
+
* no `default` (e.g. the optional row-config enums). Selects use this to
|
|
71
|
+
* offer a leading "default/unset" option so a `undefined` value doesn't
|
|
72
|
+
* masquerade as the first real choice being selected.
|
|
73
|
+
*/
|
|
74
|
+
optional: boolean;
|
|
75
|
+
/** JSON Schema `description` — reserved for the §7.10 `keyword:arg`
|
|
76
|
+
* rich-field extension (rich/content-picker/image/color). Unused in v1. */
|
|
77
|
+
description?: string;
|
|
78
|
+
/** Schema default, if any. */
|
|
79
|
+
defaultValue?: unknown;
|
|
80
|
+
// --- string constraints ---
|
|
81
|
+
minLength?: number;
|
|
82
|
+
maxLength?: number;
|
|
83
|
+
/** Regex source string (e.g. URL guards). Carried for inline validation. */
|
|
84
|
+
pattern?: string;
|
|
85
|
+
// --- number constraints ---
|
|
86
|
+
min?: number;
|
|
87
|
+
max?: number;
|
|
88
|
+
/** Step granularity — 1 for integers, undefined (any) for floats. */
|
|
89
|
+
step?: number;
|
|
90
|
+
// --- select (enum + union-of-const) ---
|
|
91
|
+
options?: AutoFormOption[];
|
|
92
|
+
// --- array (repeater) ---
|
|
93
|
+
/** For `array<object>`: the per-item sub-fields. */
|
|
94
|
+
itemFields?: AutoFormField[];
|
|
95
|
+
/** A blank item to append when the user clicks "+ Add". */
|
|
96
|
+
itemDefault?: unknown;
|
|
97
|
+
minItems?: number;
|
|
98
|
+
maxItems?: number;
|
|
99
|
+
// --- group (nested object) ---
|
|
100
|
+
fields?: AutoFormField[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface AutoFormModel {
|
|
104
|
+
fields: AutoFormField[];
|
|
105
|
+
/** True when the schema exposes no editable properties (e.g. `stats`). */
|
|
106
|
+
isEmpty: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* maxLength at/above which a string renders as a multi-line textarea
|
|
111
|
+
* instead of a single-line input. Tuned against the real section schemas:
|
|
112
|
+
* caption(480)/subtitle(500)/body(800)/markdown(100k)/html(8k,50k) become
|
|
113
|
+
* textareas; title(240)/heading(240)/name(255)/alt(240) stay single-line.
|
|
114
|
+
* URL-pattern strings bypass this (see the `string` branch) — they're
|
|
115
|
+
* single-line regardless of their 2048 cap.
|
|
116
|
+
*/
|
|
117
|
+
const LONG_TEXT_THRESHOLD = 480;
|
|
118
|
+
|
|
119
|
+
type JsonNode = Record<string, unknown>;
|
|
120
|
+
|
|
121
|
+
/** `#/$defs/Foo` → root.$defs.Foo. Zod inlines single-use subschemas but
|
|
122
|
+
* emits $ref/$defs when one is reused; resolve defensively either way. */
|
|
123
|
+
function resolveRef(node: JsonNode | undefined, root: JsonNode): JsonNode {
|
|
124
|
+
if (!node) return {};
|
|
125
|
+
const ref = node['$ref'];
|
|
126
|
+
if (typeof ref !== 'string') return node;
|
|
127
|
+
// Only the local "#/$defs/Name" form is produced by z.toJSONSchema.
|
|
128
|
+
const m = /^#\/\$defs\/(.+)$/.exec(ref);
|
|
129
|
+
if (!m) return node;
|
|
130
|
+
const defs = root['$defs'] as Record<string, JsonNode> | undefined;
|
|
131
|
+
const target = defs?.[m[1]!];
|
|
132
|
+
// Merge so sibling keywords on the $ref node (rare) aren't lost.
|
|
133
|
+
return target ? { ...target, ...stripRef(node) } : node;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function stripRef(node: JsonNode): JsonNode {
|
|
137
|
+
const { ['$ref']: _ref, ...rest } = node;
|
|
138
|
+
return rest;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `customTitle` → "Custom title"; `paddingY` → "Padding y"; `categorySlug` → "Category slug". */
|
|
142
|
+
export function humanizeKey(key: string): string {
|
|
143
|
+
const spaced = key
|
|
144
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
145
|
+
.replace(/[_-]+/g, ' ')
|
|
146
|
+
.trim()
|
|
147
|
+
.toLowerCase();
|
|
148
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Enum value → display label. Keeps short slugs as-is (they're already
|
|
152
|
+
* terse design tokens like "sm"/"primary"); just trims. Numbers stringify. */
|
|
153
|
+
function optionLabel(value: string | number): string {
|
|
154
|
+
return String(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isConstUnion(node: JsonNode): node is JsonNode & { anyOf: JsonNode[] } {
|
|
158
|
+
const anyOf = node['anyOf'];
|
|
159
|
+
return Array.isArray(anyOf) && anyOf.length > 0 && anyOf.every((o) => o && typeof o === 'object' && 'const' in o);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Walk one object node's `properties` into fields. */
|
|
163
|
+
function objectToFields(node: JsonNode, root: JsonNode): AutoFormField[] {
|
|
164
|
+
const properties = (node['properties'] as Record<string, JsonNode> | undefined) ?? {};
|
|
165
|
+
const required = Array.isArray(node['required']) ? (node['required'] as string[]) : [];
|
|
166
|
+
return Object.entries(properties).map(([key, raw]) => nodeToField(key, raw, root, required.includes(key)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function nodeToField(
|
|
170
|
+
key: string,
|
|
171
|
+
rawNode: JsonNode,
|
|
172
|
+
root: JsonNode,
|
|
173
|
+
inRequiredArray: boolean,
|
|
174
|
+
): AutoFormField {
|
|
175
|
+
const node = resolveRef(rawNode, root);
|
|
176
|
+
const defaultValue = node['default'];
|
|
177
|
+
// Form-required: in the required list AND no default to pre-fill it.
|
|
178
|
+
const required = inRequiredArray && defaultValue === undefined;
|
|
179
|
+
// Optional: absent-allowed (not required) AND no default to fall back to.
|
|
180
|
+
const optional = !inRequiredArray && defaultValue === undefined;
|
|
181
|
+
const base: AutoFormField = {
|
|
182
|
+
key,
|
|
183
|
+
label: humanizeKey(key),
|
|
184
|
+
control: 'unsupported',
|
|
185
|
+
required,
|
|
186
|
+
optional,
|
|
187
|
+
defaultValue,
|
|
188
|
+
};
|
|
189
|
+
const description = node['description'];
|
|
190
|
+
if (typeof description === 'string') base.description = description;
|
|
191
|
+
|
|
192
|
+
// 1. Union of literal consts (heading.level, content-feed.columns,
|
|
193
|
+
// learning.columns) — z.union([z.literal(1), …]) → anyOf:[{const}].
|
|
194
|
+
if (isConstUnion(node)) {
|
|
195
|
+
base.control = 'select';
|
|
196
|
+
base.options = node.anyOf.map((o) => {
|
|
197
|
+
const v = o['const'] as string | number;
|
|
198
|
+
return { value: v, label: optionLabel(v) };
|
|
199
|
+
});
|
|
200
|
+
return base;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2. String enum → select.
|
|
204
|
+
if (Array.isArray(node['enum'])) {
|
|
205
|
+
base.control = 'select';
|
|
206
|
+
base.options = (node['enum'] as Array<string | number>).map((v) => ({ value: v, label: optionLabel(v) }));
|
|
207
|
+
return base;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const type = node['type'];
|
|
211
|
+
switch (type) {
|
|
212
|
+
case 'boolean':
|
|
213
|
+
base.control = 'toggle';
|
|
214
|
+
return base;
|
|
215
|
+
|
|
216
|
+
case 'integer':
|
|
217
|
+
case 'number':
|
|
218
|
+
base.control = 'number';
|
|
219
|
+
if (typeof node['minimum'] === 'number') base.min = node['minimum'] as number;
|
|
220
|
+
if (typeof node['maximum'] === 'number') base.max = node['maximum'] as number;
|
|
221
|
+
base.step = type === 'integer' ? 1 : undefined;
|
|
222
|
+
return base;
|
|
223
|
+
|
|
224
|
+
case 'string': {
|
|
225
|
+
if (typeof node['maxLength'] === 'number') base.maxLength = node['maxLength'] as number;
|
|
226
|
+
if (typeof node['minLength'] === 'number') base.minLength = node['minLength'] as number;
|
|
227
|
+
// Pattern strings are URLs/paths in our schemas — single-line text,
|
|
228
|
+
// NOT <input type=url> (which would reject valid root-relative paths
|
|
229
|
+
// like `/about`). Carry the pattern for our own inline validation.
|
|
230
|
+
if (typeof node['pattern'] === 'string') {
|
|
231
|
+
base.control = 'text';
|
|
232
|
+
base.pattern = node['pattern'] as string;
|
|
233
|
+
return base;
|
|
234
|
+
}
|
|
235
|
+
base.control = base.maxLength !== undefined && base.maxLength >= LONG_TEXT_THRESHOLD ? 'textarea' : 'text';
|
|
236
|
+
return base;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
case 'array': {
|
|
240
|
+
base.control = 'array';
|
|
241
|
+
if (typeof node['minItems'] === 'number') base.minItems = node['minItems'] as number;
|
|
242
|
+
if (typeof node['maxItems'] === 'number') base.maxItems = node['maxItems'] as number;
|
|
243
|
+
const items = resolveRef(node['items'] as JsonNode | undefined, root);
|
|
244
|
+
if (items['type'] === 'object' || items['properties']) {
|
|
245
|
+
base.itemFields = objectToFields(items, root);
|
|
246
|
+
base.itemDefault = buildDefaults(base.itemFields);
|
|
247
|
+
} else {
|
|
248
|
+
// Scalar array (none in v1 builtins, but handle generically).
|
|
249
|
+
const itemField = nodeToField('item', items, root, false);
|
|
250
|
+
base.itemFields = [itemField];
|
|
251
|
+
base.itemDefault = blankFor(itemField);
|
|
252
|
+
}
|
|
253
|
+
return base;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case 'object': {
|
|
257
|
+
base.control = 'group';
|
|
258
|
+
base.fields = objectToFields(node, root);
|
|
259
|
+
return base;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
default:
|
|
263
|
+
// Unknown / unrepresentable — render read-only so the admin sees the
|
|
264
|
+
// field exists but can't corrupt it. R3 ops: forward-compat for new
|
|
265
|
+
// Zod kinds a future schema introduces.
|
|
266
|
+
base.control = 'unsupported';
|
|
267
|
+
return base;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A sensible blank value for a freshly-added array item / group. */
|
|
272
|
+
function blankFor(field: AutoFormField): unknown {
|
|
273
|
+
if (field.defaultValue !== undefined) return field.defaultValue;
|
|
274
|
+
switch (field.control) {
|
|
275
|
+
case 'text':
|
|
276
|
+
case 'textarea':
|
|
277
|
+
return '';
|
|
278
|
+
case 'number':
|
|
279
|
+
return field.min ?? 0;
|
|
280
|
+
case 'toggle':
|
|
281
|
+
return false;
|
|
282
|
+
case 'select':
|
|
283
|
+
return field.options?.[0]?.value ?? '';
|
|
284
|
+
case 'array':
|
|
285
|
+
return [];
|
|
286
|
+
case 'group':
|
|
287
|
+
return field.fields ? buildDefaults(field.fields) : {};
|
|
288
|
+
default:
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Build a default object from a field list (for new array items / groups). */
|
|
294
|
+
export function buildDefaults(fields: AutoFormField[]): Record<string, unknown> {
|
|
295
|
+
const out: Record<string, unknown> = {};
|
|
296
|
+
for (const f of fields) out[f.key] = blankFor(f);
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Convert a Zod schema to a normalized form model. Returns `{ fields: [],
|
|
302
|
+
* isEmpty: true }` if the schema is not an object or can't be represented
|
|
303
|
+
* (degrades gracefully — never throws into the render path).
|
|
304
|
+
*/
|
|
305
|
+
export function buildAutoForm(schema: ZodType): AutoFormModel {
|
|
306
|
+
let json: JsonNode;
|
|
307
|
+
try {
|
|
308
|
+
json = z.toJSONSchema(schema) as JsonNode;
|
|
309
|
+
} catch {
|
|
310
|
+
// Unrepresentable schema (z.toJSONSchema throws by default). Degrade to
|
|
311
|
+
// an empty model; the view shows the "no options" state.
|
|
312
|
+
return { fields: [], isEmpty: true };
|
|
313
|
+
}
|
|
314
|
+
if (json['type'] !== 'object' && !json['properties']) {
|
|
315
|
+
return { fields: [], isEmpty: true };
|
|
316
|
+
}
|
|
317
|
+
const fields = objectToFields(json, json);
|
|
318
|
+
return { fields, isEmpty: fields.length === 0 };
|
|
319
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAdminSidebar — state machine for the admin chrome's left sidebar.
|
|
3
|
+
*
|
|
4
|
+
* Two independent surfaces:
|
|
5
|
+
* 1. Desktop (≥768px): width can collapse from 200px to ~56px (icons-only).
|
|
6
|
+
* The user's preference is persisted to a cookie. Editor routes
|
|
7
|
+
* (`/admin/layouts/[id]`, `/admin/theme/edit/[id]`) auto-collapse to
|
|
8
|
+
* give the canvas more room; the user can override that for the
|
|
9
|
+
* current page visit only.
|
|
10
|
+
* 2. Mobile (<768px): drawer that slides in from the left, independent
|
|
11
|
+
* of the desktop collapse state. Closes when a nav link is clicked.
|
|
12
|
+
*
|
|
13
|
+
* Design (mirrors Linear/Figma/Notion editor-mode patterns):
|
|
14
|
+
* - userPref — persisted boolean (cookie); the user's "default"
|
|
15
|
+
* collapsed state. Cookie chosen over localStorage
|
|
16
|
+
* so SSR renders correctly first time — no
|
|
17
|
+
* hydration flash where the sidebar starts expanded
|
|
18
|
+
* and then snaps to collapsed once the client tick
|
|
19
|
+
* reads storage. Matches `useTheme`'s pattern.
|
|
20
|
+
* - sessionOverride — null | boolean; non-null only when the user manually
|
|
21
|
+
* toggled while on an editor route. Resets on route
|
|
22
|
+
* change so leaving the editor returns to userPref.
|
|
23
|
+
* - desktopCollapsed (computed):
|
|
24
|
+
* sessionOverride ?? (isEditorRoute ? true : userPref)
|
|
25
|
+
*
|
|
26
|
+
* Tests live in `__tests__/useAdminSidebar.test.ts` — cover SSR-safe
|
|
27
|
+
* hydration via mocked cookie, route-aware override, toggle persistence,
|
|
28
|
+
* and the mobile/desktop split.
|
|
29
|
+
*
|
|
30
|
+
* Wired into `layers/base/layouts/admin.vue` only.
|
|
31
|
+
*
|
|
32
|
+
* Sub-route caveat: `EDITOR_ROUTE_PATTERNS` use `$` end-anchors so
|
|
33
|
+
* `/admin/layouts/abc/preview` would NOT auto-collapse. Today no editor
|
|
34
|
+
* has sub-routes; if Phase 3b+ adds them, extend the regexes or use
|
|
35
|
+
* `startsWith` semantics.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const COOKIE_KEY = 'cpub-admin-sidebar-collapsed';
|
|
39
|
+
|
|
40
|
+
const EDITOR_ROUTE_PATTERNS: RegExp[] = [
|
|
41
|
+
/^\/admin\/layouts\/[^/]+$/, // /admin/layouts/[id] — Phase 3a layout editor
|
|
42
|
+
/^\/admin\/theme\/edit\/[^/]+$/, // /admin/theme/edit/[id] — session 154+156 theme editor
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export interface AdminSidebarApi {
|
|
46
|
+
/** Final computed: is the desktop sidebar collapsed right now? */
|
|
47
|
+
desktopCollapsed: ComputedRef<boolean>;
|
|
48
|
+
/** Mobile drawer open state. Independent of desktop. */
|
|
49
|
+
mobileOpen: Ref<boolean>;
|
|
50
|
+
/** Whether the current route is one we auto-collapse for. Exposed for callers that want to label things. */
|
|
51
|
+
isEditorRoute: ComputedRef<boolean>;
|
|
52
|
+
/** Toggle desktop collapse. On editor routes: session-only. Off editor routes: persists to localStorage. */
|
|
53
|
+
toggleDesktop: () => void;
|
|
54
|
+
/** Toggle mobile drawer. */
|
|
55
|
+
toggleMobile: () => void;
|
|
56
|
+
/** Close mobile drawer (used by nav link click handlers). */
|
|
57
|
+
closeMobile: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useAdminSidebar(): AdminSidebarApi {
|
|
61
|
+
const route = useRoute();
|
|
62
|
+
|
|
63
|
+
// Persistent user pref: cookie (server-readable → no hydration flash).
|
|
64
|
+
// `useCookie` returns a Ref bound bidirectionally to the cookie; setting
|
|
65
|
+
// `.value` writes Set-Cookie on the next SSR response or updates
|
|
66
|
+
// document.cookie on the client. Default = false (expanded). The cookie
|
|
67
|
+
// is only created when the user actually toggles (Nuxt useCookie doesn't
|
|
68
|
+
// emit Set-Cookie for unchanged default values).
|
|
69
|
+
const userPref = useCookie<boolean>(COOKIE_KEY, {
|
|
70
|
+
default: () => false,
|
|
71
|
+
maxAge: 60 * 60 * 24 * 365, // 1 year — sidebar pref is "forever"
|
|
72
|
+
path: '/',
|
|
73
|
+
sameSite: 'lax',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Transient: useState so it survives in-layout navigation but doesn't
|
|
77
|
+
// persist across page reloads (it's a per-visit override).
|
|
78
|
+
const sessionOverride = useState<boolean | null>('cpub-admin-sidebar-override', () => null);
|
|
79
|
+
const mobileOpen = useState<boolean>('cpub-admin-sidebar-mobile-open', () => false);
|
|
80
|
+
|
|
81
|
+
const isEditorRoute = computed<boolean>(() =>
|
|
82
|
+
EDITOR_ROUTE_PATTERNS.some((p) => p.test(route.path))
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const desktopCollapsed = computed<boolean>(() => {
|
|
86
|
+
if (sessionOverride.value !== null) return sessionOverride.value;
|
|
87
|
+
return isEditorRoute.value ? true : userPref.value;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Route change clears the session override so leaving the editor route
|
|
91
|
+
// returns the sidebar to the user's persistent preference.
|
|
92
|
+
watch(() => route.path, () => {
|
|
93
|
+
sessionOverride.value = null;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function toggleDesktop(): void {
|
|
97
|
+
const next = !desktopCollapsed.value;
|
|
98
|
+
if (isEditorRoute.value) {
|
|
99
|
+
sessionOverride.value = next;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
userPref.value = next;
|
|
103
|
+
sessionOverride.value = null;
|
|
104
|
+
// No explicit storage write — `useCookie` syncs automatically.
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toggleMobile(): void {
|
|
108
|
+
mobileOpen.value = !mobileOpen.value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function closeMobile(): void {
|
|
112
|
+
mobileOpen.value = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { desktopCollapsed, mobileOpen, isEditorRoute, toggleDesktop, toggleMobile, closeMobile };
|
|
116
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useEditorChrome — palette + inspector visibility for the layout editor.
|
|
3
|
+
*
|
|
4
|
+
* User-reported: the editor's 3-column shell (palette ~280px / canvas /
|
|
5
|
+
* inspector ~320px) squishes the canvas to ~tablet width even on a wide
|
|
6
|
+
* desktop. Content cards in the preview clipped mid-word ("VIDEO/AYBACK
|
|
7
|
+
* SYSTE"). Fix is to let the admin hide either pane independently.
|
|
8
|
+
*
|
|
9
|
+
* Each visibility flag persists via cookie (matches `useAdminSidebar` +
|
|
10
|
+
* `useTheme` — no SSR/CSR hydration flash; Nuxt's `useCookie` reads the
|
|
11
|
+
* cookie on the server). Cookie is only emitted when the user toggles —
|
|
12
|
+
* Nuxt skips Set-Cookie for unchanged defaults.
|
|
13
|
+
*
|
|
14
|
+
* Wired into `pages/admin/layouts/[id].vue` (the editor shell) + the
|
|
15
|
+
* toggle buttons in `components/admin/layouts/AdminLayoutsToolbar.vue`.
|
|
16
|
+
*
|
|
17
|
+
* Pattern: v-show on the panels themselves (preserves component state +
|
|
18
|
+
* scroll/focus across toggles) + a `cpub-admin-layouts-editor-body--*`
|
|
19
|
+
* class on the parent grid (re-flows `grid-template-columns`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const PALETTE_COOKIE = 'cpub-editor-palette-hidden';
|
|
23
|
+
const INSPECTOR_COOKIE = 'cpub-editor-inspector-hidden';
|
|
24
|
+
|
|
25
|
+
export interface EditorChromeApi {
|
|
26
|
+
paletteHidden: Ref<boolean>;
|
|
27
|
+
inspectorHidden: Ref<boolean>;
|
|
28
|
+
togglePalette: () => void;
|
|
29
|
+
toggleInspector: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useEditorChrome(): EditorChromeApi {
|
|
33
|
+
const paletteHidden = useCookie<boolean>(PALETTE_COOKIE, {
|
|
34
|
+
default: () => false,
|
|
35
|
+
maxAge: 60 * 60 * 24 * 365, // 1 year
|
|
36
|
+
path: '/',
|
|
37
|
+
sameSite: 'lax',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const inspectorHidden = useCookie<boolean>(INSPECTOR_COOKIE, {
|
|
41
|
+
default: () => false,
|
|
42
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
43
|
+
path: '/',
|
|
44
|
+
sameSite: 'lax',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function togglePalette(): void {
|
|
48
|
+
paletteHidden.value = !paletteHidden.value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toggleInspector(): void {
|
|
52
|
+
inspectorHidden.value = !inspectorHidden.value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { paletteHidden, inspectorHidden, togglePalette, toggleInspector };
|
|
56
|
+
}
|
|
@@ -64,14 +64,41 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
|
|
64
64
|
},
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Build the initial flags. Two sources, in priority order:
|
|
69
|
+
*
|
|
70
|
+
* 1. **Server-side, request-scoped**: `event.context.cpubFeatureFlags` —
|
|
71
|
+
* DB-merged values set by the Nitro `feature-flags-prime` plugin
|
|
72
|
+
* (apps/reference/server/plugins/feature-flags-prime.ts). This is
|
|
73
|
+
* how admin-UI flag overrides take effect at SSR time. Without it,
|
|
74
|
+
* SSR would render with stale build-time defaults until client
|
|
75
|
+
* hydration replaced them — visible flash + broken curl-based canary.
|
|
76
|
+
*
|
|
77
|
+
* 2. **Build-time runtime config**: `useRuntimeConfig().public.features` —
|
|
78
|
+
* the legacy initialisation. Fallback for client-side, for early
|
|
79
|
+
* startup before the plugin's hook can fire, and for thin apps
|
|
80
|
+
* that haven't installed the prime plugin yet.
|
|
81
|
+
*
|
|
82
|
+
* Either way, defaults fill any unmentioned flags + identity is deep-
|
|
83
|
+
* merged so a partial override (e.g. `{ identity: { actingAs: true } }`)
|
|
84
|
+
* lands on top of the defaulted sub-flags rather than replacing the
|
|
85
|
+
* whole nested object.
|
|
86
|
+
*/
|
|
68
87
|
export function getInitialFlags(): FeatureFlags {
|
|
88
|
+
if (import.meta.server) {
|
|
89
|
+
// useRequestEvent is auto-imported on Nuxt server-side
|
|
90
|
+
const event = (typeof useRequestEvent === 'function') ? useRequestEvent() : null;
|
|
91
|
+
const ctxFlags = event?.context?.cpubFeatureFlags as Partial<FeatureFlags> | undefined;
|
|
92
|
+
if (ctxFlags && typeof ctxFlags === 'object') {
|
|
93
|
+
return {
|
|
94
|
+
...DEFAULT_FLAGS,
|
|
95
|
+
...ctxFlags,
|
|
96
|
+
identity: { ...DEFAULT_FLAGS.identity, ...(ctxFlags.identity ?? {}) },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
69
100
|
const config = useRuntimeConfig();
|
|
70
101
|
const buildFlags = (config.public.features as unknown as Partial<FeatureFlags> | undefined) ?? {};
|
|
71
|
-
// Merge top-level booleans, but deep-merge `identity` so a partial
|
|
72
|
-
// runtime override (e.g., `{ identity: { actingAs: true } }`) lands on
|
|
73
|
-
// top of the defaulted sub-flags rather than replacing the whole
|
|
74
|
-
// nested object.
|
|
75
102
|
return {
|
|
76
103
|
...DEFAULT_FLAGS,
|
|
77
104
|
...buildFlags,
|
package/composables/useLayout.ts
CHANGED
|
@@ -17,50 +17,44 @@
|
|
|
17
17
|
* The `<LayoutSlot>` component is the only intended caller in v1; other
|
|
18
18
|
* consumers should use that instead.
|
|
19
19
|
*/
|
|
20
|
+
import { computed } from 'vue';
|
|
20
21
|
import type { Ref } from 'vue';
|
|
22
|
+
import type {
|
|
23
|
+
LayoutRecord,
|
|
24
|
+
LayoutSectionResolved,
|
|
25
|
+
LayoutRowResolved,
|
|
26
|
+
LayoutZone,
|
|
27
|
+
} from '@commonpub/server';
|
|
21
28
|
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
schemaVersion: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface LayoutRow {
|
|
35
|
-
id: string;
|
|
36
|
-
order: number;
|
|
37
|
-
config: {
|
|
38
|
-
gap?: 'none' | 'sm' | 'md' | 'lg';
|
|
39
|
-
align?: 'start' | 'center' | 'stretch';
|
|
40
|
-
background?: string;
|
|
41
|
-
paddingY?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
|
42
|
-
} | null;
|
|
43
|
-
sections: LayoutSection[];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface LayoutZoneClient {
|
|
47
|
-
zone: string;
|
|
48
|
-
rows: LayoutRow[];
|
|
49
|
-
}
|
|
29
|
+
// Re-export the server-side resolved types under the existing client-
|
|
30
|
+
// facing names. Until session 162 these were locally-declared duplicates
|
|
31
|
+
// (manually kept in sync) — the R2 audit caught that AdminLayoutsCanvas
|
|
32
|
+
// hand-mapped LayoutRecord → LayoutPayload field-by-field, silently
|
|
33
|
+
// dropping any newly-added section field (e.g. a future `pinned` flag).
|
|
34
|
+
// Same-named single source of truth fixes that bug class.
|
|
35
|
+
export type LayoutSection = LayoutSectionResolved;
|
|
36
|
+
export type LayoutRow = LayoutRowResolved;
|
|
37
|
+
export type LayoutZoneClient = LayoutZone;
|
|
50
38
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The leaner client-facing view of a layout — only the fields the
|
|
41
|
+
* renderer needs (state for draft-gate, pageMeta for SEO, zones for
|
|
42
|
+
* structure). Structurally a `Pick` of the full server LayoutRecord
|
|
43
|
+
* so any LayoutRecord is assignable to LayoutPayload without
|
|
44
|
+
* transformation (session 162 P2.4 R2 audit fix).
|
|
45
|
+
*
|
|
46
|
+
* IMPORTANT CAVEAT — because the row/section types are re-exports of
|
|
47
|
+
* the server's resolved types (above), any new field added to
|
|
48
|
+
* LayoutSectionResolved or LayoutRowResolved on the server will
|
|
49
|
+
* automatically flow through to the public render path here. That's
|
|
50
|
+
* usually what you want for genuinely public fields (e.g. a future
|
|
51
|
+
* `pinned: boolean` should be visible). But fields that should be
|
|
52
|
+
* admin-only (e.g. internal moderation tags) MUST be added to a
|
|
53
|
+
* separate server-side type, never to LayoutSectionResolved /
|
|
54
|
+
* LayoutRowResolved — otherwise they leak via /api/layouts/by-route.
|
|
55
|
+
* Session 162 audit note.
|
|
56
|
+
*/
|
|
57
|
+
export type LayoutPayload = Pick<LayoutRecord, 'state' | 'pageMeta' | 'zones'>;
|
|
64
58
|
|
|
65
59
|
export interface UseLayoutResult {
|
|
66
60
|
/** The layout payload, or null if none exists / feature off. */
|
|
@@ -96,6 +90,16 @@ export function useLayout(path: string | Ref<string> | (() => string)): UseLayou
|
|
|
96
90
|
: path.value
|
|
97
91
|
);
|
|
98
92
|
|
|
93
|
+
// Resolve query as a reactive computed — passing `{ path: pathGetter }`
|
|
94
|
+
// (a raw function value) made useFetch serialise the function reference,
|
|
95
|
+
// turning the request URL into ?path=undefined → 400 → null layout. The
|
|
96
|
+
// bug was load-bearing for the canary on commonpub.io (session 159): SSR
|
|
97
|
+
// saw `homepageLayout.value === null` despite a published layout existing,
|
|
98
|
+
// so layoutEngineActive resolved to false and pages/index.vue fell
|
|
99
|
+
// through to the legacy renderer. Fix: always pass a Ref/computed so
|
|
100
|
+
// useFetch reads the resolved value AND re-evaluates on dep change.
|
|
101
|
+
const queryRef = computed(() => ({ path: pathGetter() }));
|
|
102
|
+
|
|
99
103
|
const { data, pending, error, refresh } = useFetch<LayoutPayload | null>(
|
|
100
104
|
'/api/layouts/by-route',
|
|
101
105
|
{
|
|
@@ -103,8 +107,7 @@ export function useLayout(path: string | Ref<string> | (() => string)): UseLayou
|
|
|
103
107
|
// can dedupe across components on the same nav). For the reactive
|
|
104
108
|
// case, omit key so useFetch derives one from the query Ref.
|
|
105
109
|
key: typeof path === 'string' ? `layout:${path}` : undefined,
|
|
106
|
-
|
|
107
|
-
query: { path: pathGetter },
|
|
110
|
+
query: queryRef,
|
|
108
111
|
// Watch the path getter so reactive callers refetch on change.
|
|
109
112
|
// For string callers this is empty array → no extra reactivity.
|
|
110
113
|
watch: typeof path === 'string' ? [] : [pathGetter],
|