@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,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLayoutDrag — pure drag-drop dispatcher logic.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3b/A. Owns the "what to do when X drops on Y" semantics so the
|
|
5
|
+
* component wiring (`<LayoutRow>` + palette tile in upcoming commits)
|
|
6
|
+
* stays a thin shell over these pure functions. Two motivations:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Testability** — dispatcher behavior is unit-tested with plain
|
|
9
|
+
* objects + no dnd-kit infrastructure. The component layer only
|
|
10
|
+
* proves `makeDroppable` is called with the right options.
|
|
11
|
+
* 2. **One source of truth** — when 3b/B adds cross-zone drag + when
|
|
12
|
+
* 3f adds drag-from-inspector, the same dispatcher routes them.
|
|
13
|
+
* No "every component reinvents the drop semantics" drift.
|
|
14
|
+
*
|
|
15
|
+
* Drag payload shape: every drag carries `{ kind, ...details }` so the
|
|
16
|
+
* row's onDrop handler can branch on `kind` without sniffing arbitrary
|
|
17
|
+
* data. Palette tiles carry the section's registry def; section
|
|
18
|
+
* instances carry the section + source row id.
|
|
19
|
+
*
|
|
20
|
+
* Mutations are applied DIRECTLY to the row.sections array. The editor's
|
|
21
|
+
* deep watcher on `editor.draft.value` picks them up + bumps dirty +
|
|
22
|
+
* the existing auto-save composable schedules a save within 1.5s. No
|
|
23
|
+
* parallel save path — Phase 3b/A kickoff rule.
|
|
24
|
+
*/
|
|
25
|
+
import type { LayoutSection, LayoutRow } from './useLayout';
|
|
26
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
27
|
+
import type { IDragEvent } from '@vue-dnd-kit/core';
|
|
28
|
+
|
|
29
|
+
/* ------------------------------------------------------------------ */
|
|
30
|
+
/* Drag payload taxonomy */
|
|
31
|
+
/* ------------------------------------------------------------------ */
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Discriminator on every drag payload. Matched by a fast switch in
|
|
35
|
+
* `dispatchSectionDrop` — adding a new kind is one literal + one case.
|
|
36
|
+
*/
|
|
37
|
+
export type DragPayloadKind = 'palette-section-spec' | 'section-instance';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Payload carried by a palette tile drag. The tile knows the section's
|
|
41
|
+
* registry def; the drop handler asks the registry to mint a new
|
|
42
|
+
* `LayoutSection` from the def's `defaultConfig` + `defaultColSpan`.
|
|
43
|
+
*
|
|
44
|
+
* We pass the FULL def (not just `type`) because the registry isn't
|
|
45
|
+
* trivially accessible from every drop handler — the row may not have
|
|
46
|
+
* the registry plumbed in. Passing the def avoids an extra dependency.
|
|
47
|
+
*/
|
|
48
|
+
export interface PaletteSectionDragPayload {
|
|
49
|
+
kind: 'palette-section-spec';
|
|
50
|
+
/** Section type slug — matches the def's `type`. */
|
|
51
|
+
sectionType: string;
|
|
52
|
+
/** Default config + colSpan to mint the new section from. */
|
|
53
|
+
defaultConfig: Record<string, unknown>;
|
|
54
|
+
defaultColSpan: number;
|
|
55
|
+
schemaVersion: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Payload carried by a section-instance drag (drag a section from one
|
|
60
|
+
* row to reorder or move to another row). 3b/A scope is within-row
|
|
61
|
+
* reorder; 3b/B adds cross-row + cross-zone.
|
|
62
|
+
*
|
|
63
|
+
* **The dispatcher MUST look up by `section.id`, not by `section`
|
|
64
|
+
* reference identity.** The envelope holds a reference at drag-start;
|
|
65
|
+
* if a concurrent `editor.refresh()` happens mid-drag (another admin's
|
|
66
|
+
* save + the user clicks "Reload their version" in the conflict modal),
|
|
67
|
+
* the referenced `section` becomes a phantom. Id-based lookup against
|
|
68
|
+
* the LIVE `row.sections` array tolerates the swap. Tests cover the
|
|
69
|
+
* 'section-not-found' noop branch.
|
|
70
|
+
*/
|
|
71
|
+
export interface SectionInstanceDragPayload {
|
|
72
|
+
kind: 'section-instance';
|
|
73
|
+
/** The section being dragged. Held by REFERENCE — the dispatcher
|
|
74
|
+
* uses `.id` to look up the live position in `row.sections`,
|
|
75
|
+
* never the reference directly. See class-comment above. */
|
|
76
|
+
section: LayoutSection;
|
|
77
|
+
/** The row this section currently lives in — needed for cross-row
|
|
78
|
+
* moves so the dispatcher can remove it from its source. */
|
|
79
|
+
fromRowId: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type DragPayload = PaletteSectionDragPayload | SectionInstanceDragPayload;
|
|
83
|
+
|
|
84
|
+
/* ------------------------------------------------------------------ */
|
|
85
|
+
/* Section factory */
|
|
86
|
+
/* ------------------------------------------------------------------ */
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Mint a new `LayoutSection` from a registry definition. Uses the
|
|
90
|
+
* project's `crypto.randomUUID()` idiom (matches packages/server,
|
|
91
|
+
* layers/base/pages/learn/.../edit.vue). `enabled: true` so a fresh
|
|
92
|
+
* drop is immediately visible; `responsive: null` defers per-breakpoint
|
|
93
|
+
* tuning to Phase 3f's inspector.
|
|
94
|
+
*
|
|
95
|
+
* Pure — takes a def + returns a section. No mutation of inputs.
|
|
96
|
+
*/
|
|
97
|
+
export function createSectionFromSpec(def: PaletteSectionDragPayload): LayoutSection {
|
|
98
|
+
return {
|
|
99
|
+
id: crypto.randomUUID(),
|
|
100
|
+
order: 0, // server-side write handler renumbers; client value isn't authoritative
|
|
101
|
+
type: def.sectionType,
|
|
102
|
+
config: { ...def.defaultConfig },
|
|
103
|
+
colSpan: def.defaultColSpan,
|
|
104
|
+
responsive: null,
|
|
105
|
+
enabled: true,
|
|
106
|
+
visibility: null,
|
|
107
|
+
schemaVersion: def.schemaVersion,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build a palette drag payload from a registry section def. Called by
|
|
113
|
+
* `<AdminLayoutsPalette>` when wiring `makeDraggable` on each tile.
|
|
114
|
+
* Lives here so the drop handler's expectation + the drag source's
|
|
115
|
+
* production stay in lockstep.
|
|
116
|
+
*/
|
|
117
|
+
export function paletteDragPayload(def: SectionDefinition): PaletteSectionDragPayload {
|
|
118
|
+
return {
|
|
119
|
+
kind: 'palette-section-spec',
|
|
120
|
+
sectionType: def.type,
|
|
121
|
+
defaultConfig: { ...def.defaultConfig },
|
|
122
|
+
defaultColSpan: def.defaultColSpan,
|
|
123
|
+
schemaVersion: def.schemaVersion,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ------------------------------------------------------------------ */
|
|
128
|
+
/* Insert-index computation */
|
|
129
|
+
/* ------------------------------------------------------------------ */
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Where in `row.sections` to insert the dropped item. Read from
|
|
133
|
+
* dnd-kit's `event.hoveredDraggable` (the section currently under the
|
|
134
|
+
* cursor inside the row):
|
|
135
|
+
*
|
|
136
|
+
* - No hovered draggable → append to the end (drop on empty area).
|
|
137
|
+
* - Hovered draggable with placement.left/top → insert BEFORE it.
|
|
138
|
+
* - Hovered draggable with placement.right/bottom → insert AFTER it.
|
|
139
|
+
*
|
|
140
|
+
* Rows are horizontal; `left`/`right` are the primary signals.
|
|
141
|
+
* `top`/`bottom` are checked as fallbacks for vertical-list mode (which
|
|
142
|
+
* we'll use for cross-row reordering in 3b/B's stacked-zones layout —
|
|
143
|
+
* the computation is reused there).
|
|
144
|
+
*
|
|
145
|
+
* Pure. Takes the event + the fallback length. Returns an integer in
|
|
146
|
+
* `[0, fallbackLen]` (the half-open range that `Array.prototype.splice`
|
|
147
|
+
* accepts as an insert position).
|
|
148
|
+
*/
|
|
149
|
+
export function computeInsertIndex(event: IDragEvent, fallbackLen: number): number {
|
|
150
|
+
const hovered = event.hoveredDraggable;
|
|
151
|
+
if (!hovered) return fallbackLen; // append
|
|
152
|
+
const insertBefore = hovered.placement.left || hovered.placement.top;
|
|
153
|
+
return insertBefore ? hovered.index : hovered.index + 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------------------------ */
|
|
157
|
+
/* Drop dispatcher */
|
|
158
|
+
/* ------------------------------------------------------------------ */
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Outcome of a dispatched drop — useful for callers that want to
|
|
162
|
+
* narrate the result (ARIA live region, audit log, telemetry) without
|
|
163
|
+
* sniffing the row's array.
|
|
164
|
+
*
|
|
165
|
+
* Phase 3b/B adds `'moved'` for cross-row + cross-zone. The caller
|
|
166
|
+
* uses `fromRowId` + `toRowId` to build a moveSectionCommand for the
|
|
167
|
+
* undo stack + to look up zone slugs for narration.
|
|
168
|
+
*/
|
|
169
|
+
export type DropOutcome =
|
|
170
|
+
| { kind: 'inserted'; section: LayoutSection; at: number }
|
|
171
|
+
| { kind: 'reordered'; section: LayoutSection; from: number; to: number }
|
|
172
|
+
| {
|
|
173
|
+
kind: 'moved';
|
|
174
|
+
section: LayoutSection;
|
|
175
|
+
fromRowId: string;
|
|
176
|
+
fromIdx: number;
|
|
177
|
+
fromTotal: number;
|
|
178
|
+
toRowId: string;
|
|
179
|
+
toIdx: number;
|
|
180
|
+
toTotal: number;
|
|
181
|
+
}
|
|
182
|
+
| { kind: 'noop'; reason: string };
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Context the dispatcher needs for CROSS-ROW operations. Optional so
|
|
186
|
+
* within-row + palette callers (which don't need to look up other
|
|
187
|
+
* rows) can still call the dispatcher with a 2-arg signature.
|
|
188
|
+
*
|
|
189
|
+
* When `findRow` is omitted OR returns null for the source row, a
|
|
190
|
+
* cross-row drop falls back to noop (with reason `'no-find-row'` or
|
|
191
|
+
* `'source-row-not-found'`). This makes the cross-zone wiring an
|
|
192
|
+
* opt-in: callers in old contexts (single-row tests) still work.
|
|
193
|
+
*/
|
|
194
|
+
export interface DispatchContext {
|
|
195
|
+
/** Look up a row anywhere in the draft by id. Returns null if the
|
|
196
|
+
* row doesn't exist (or the caller chose not to support cross-row). */
|
|
197
|
+
findRow?: (rowId: string) => LayoutRow | null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Apply a drop to a row. The row's `sections` array is mutated in
|
|
202
|
+
* place — the editor's deep watcher picks it up + auto-save fires.
|
|
203
|
+
*
|
|
204
|
+
* Phase 3b/B scope:
|
|
205
|
+
* - palette-section-spec → splice in a fresh section at the computed
|
|
206
|
+
* insert index.
|
|
207
|
+
* - section-instance dragged FROM THIS SAME ROW → reorder in place
|
|
208
|
+
* via splice-remove + splice-insert (sameList per dnd-kit's model).
|
|
209
|
+
* - section-instance dragged FROM A DIFFERENT ROW + ctx.findRow
|
|
210
|
+
* provides the source → remove from source, insert into destination
|
|
211
|
+
* row. Cross-row + cross-zone share this branch (a "different
|
|
212
|
+
* row" may be in another zone).
|
|
213
|
+
*
|
|
214
|
+
* Returns a DropOutcome describing what happened. `noop` outcomes have
|
|
215
|
+
* a `reason` for diagnostics — surfaced in tests + (optionally) audit
|
|
216
|
+
* logs.
|
|
217
|
+
*/
|
|
218
|
+
export function dispatchSectionDrop(
|
|
219
|
+
event: IDragEvent,
|
|
220
|
+
row: LayoutRow,
|
|
221
|
+
ctx: DispatchContext = {},
|
|
222
|
+
): DropOutcome {
|
|
223
|
+
const item = event.draggedItems[0]?.item as DragPayload | undefined;
|
|
224
|
+
if (!item) return { kind: 'noop', reason: 'no-dragged-item' };
|
|
225
|
+
|
|
226
|
+
if (item.kind === 'palette-section-spec') {
|
|
227
|
+
const newSection = createSectionFromSpec(item);
|
|
228
|
+
const insertAt = computeInsertIndex(event, row.sections.length);
|
|
229
|
+
row.sections.splice(insertAt, 0, newSection);
|
|
230
|
+
return { kind: 'inserted', section: newSection, at: insertAt };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (item.kind === 'section-instance') {
|
|
234
|
+
if (item.fromRowId !== row.id) {
|
|
235
|
+
// Cross-row drag. Needs the source row to splice out of.
|
|
236
|
+
// Without ctx.findRow, we can't reach it — fall back to noop.
|
|
237
|
+
if (!ctx.findRow) {
|
|
238
|
+
return { kind: 'noop', reason: 'no-find-row' };
|
|
239
|
+
}
|
|
240
|
+
const sourceRow = ctx.findRow(item.fromRowId);
|
|
241
|
+
if (!sourceRow) {
|
|
242
|
+
return { kind: 'noop', reason: 'source-row-not-found' };
|
|
243
|
+
}
|
|
244
|
+
const fromIdx = sourceRow.sections.findIndex((s) => s.id === item.section.id);
|
|
245
|
+
if (fromIdx === -1) {
|
|
246
|
+
// The dragged section isn't in the source row — defensive
|
|
247
|
+
// against a stale payload or concurrent edit.
|
|
248
|
+
return { kind: 'noop', reason: 'section-not-found' };
|
|
249
|
+
}
|
|
250
|
+
const fromTotal = sourceRow.sections.length;
|
|
251
|
+
// Target index in the DESTINATION row (which is `row`, separate
|
|
252
|
+
// from source). No adjustment needed — the splice on source
|
|
253
|
+
// doesn't shift indices in the destination.
|
|
254
|
+
const targetIdx = computeInsertIndex(event, row.sections.length);
|
|
255
|
+
const [moved] = sourceRow.sections.splice(fromIdx, 1);
|
|
256
|
+
if (!moved) return { kind: 'noop', reason: 'section-vanished-mid-splice' };
|
|
257
|
+
row.sections.splice(targetIdx, 0, moved);
|
|
258
|
+
return {
|
|
259
|
+
kind: 'moved',
|
|
260
|
+
section: moved,
|
|
261
|
+
fromRowId: sourceRow.id,
|
|
262
|
+
fromIdx,
|
|
263
|
+
fromTotal,
|
|
264
|
+
toRowId: row.id,
|
|
265
|
+
toIdx: targetIdx,
|
|
266
|
+
toTotal: row.sections.length,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const fromIdx = row.sections.findIndex((s) => s.id === item.section.id);
|
|
270
|
+
if (fromIdx === -1) {
|
|
271
|
+
// The dragged section isn't in this row's array — defensive
|
|
272
|
+
// against a stale payload or a concurrent edit that removed it.
|
|
273
|
+
return { kind: 'noop', reason: 'section-not-found' };
|
|
274
|
+
}
|
|
275
|
+
// Compute the target index using the row's CURRENT sections (the
|
|
276
|
+
// dragged section is still there). After splice-remove the indices
|
|
277
|
+
// shift down by 1 for positions after fromIdx — adjust on insert.
|
|
278
|
+
const targetIdx = computeInsertIndex(event, row.sections.length);
|
|
279
|
+
const [moved] = row.sections.splice(fromIdx, 1);
|
|
280
|
+
// Adjust if removing the source shifted the target.
|
|
281
|
+
const adjustedTarget = targetIdx > fromIdx ? targetIdx - 1 : targetIdx;
|
|
282
|
+
if (!moved) return { kind: 'noop', reason: 'section-vanished-mid-splice' };
|
|
283
|
+
row.sections.splice(adjustedTarget, 0, moved);
|
|
284
|
+
return { kind: 'reordered', section: moved, from: fromIdx, to: adjustedTarget };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Exhaustive switch: TS narrows `item` to `never` here. Any new kind
|
|
288
|
+
// will surface as a compile error → forces an explicit case.
|
|
289
|
+
return { kind: 'noop', reason: 'unknown-drag-kind' };
|
|
290
|
+
}
|