@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,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: embed.
|
|
3
|
+
*
|
|
4
|
+
* Stage E.2 — reuses BlockEmbedView (generic-iframe renderer). Host
|
|
5
|
+
* allowlist + sandbox policy live in BlockEmbedView.
|
|
6
|
+
*
|
|
7
|
+
* Schema (incl. URL_HTTPS_OR_EMPTY guard — strict http(s) only, no
|
|
8
|
+
* relative-path branch unlike image/video/gallery) lives in
|
|
9
|
+
* `@commonpub/schema/sectionConfigs` (session 161 move).
|
|
10
|
+
*/
|
|
11
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
12
|
+
import { embedConfigSchema, type EmbedConfig } from '@commonpub/schema';
|
|
13
|
+
import BlockEmbedView from '../../components/blocks/BlockEmbedView.vue';
|
|
14
|
+
|
|
15
|
+
export const embedSection: SectionDefinition<EmbedConfig> = {
|
|
16
|
+
type: 'embed',
|
|
17
|
+
name: 'Embed',
|
|
18
|
+
description: 'Tweets, gists, CodePen, Loom, etc (uses BlockEmbedView)',
|
|
19
|
+
icon: 'fa-square-arrow-up-right',
|
|
20
|
+
category: 'content',
|
|
21
|
+
status: 'stable',
|
|
22
|
+
configSchema: embedConfigSchema,
|
|
23
|
+
defaultConfig: { url: '' },
|
|
24
|
+
schemaVersion: 1,
|
|
25
|
+
component: BlockEmbedView,
|
|
26
|
+
propMap: ({ config }) => ({ content: config }),
|
|
27
|
+
minColSpan: 6,
|
|
28
|
+
maxColSpan: 12,
|
|
29
|
+
defaultColSpan: 12,
|
|
30
|
+
resizable: true,
|
|
31
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: gallery.
|
|
3
|
+
*
|
|
4
|
+
* Stage E.2 — reuses BlockGalleryView (image grid + lightbox).
|
|
5
|
+
*
|
|
6
|
+
* Schema (incl. per-image URL_MEDIA_OR_EMPTY guard + max-20 array bound)
|
|
7
|
+
* lives in `@commonpub/schema/sectionConfigs` (session 161 move).
|
|
8
|
+
*/
|
|
9
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
10
|
+
import { galleryConfigSchema, type GalleryConfig } from '@commonpub/schema';
|
|
11
|
+
import BlockGalleryView from '../../components/blocks/BlockGalleryView.vue';
|
|
12
|
+
|
|
13
|
+
export const gallerySection: SectionDefinition<GalleryConfig> = {
|
|
14
|
+
type: 'gallery',
|
|
15
|
+
name: 'Gallery',
|
|
16
|
+
description: 'Image grid (uses BlockGalleryView)',
|
|
17
|
+
icon: 'fa-images',
|
|
18
|
+
category: 'content',
|
|
19
|
+
status: 'stable',
|
|
20
|
+
configSchema: galleryConfigSchema,
|
|
21
|
+
defaultConfig: { images: [] },
|
|
22
|
+
schemaVersion: 1,
|
|
23
|
+
component: BlockGalleryView,
|
|
24
|
+
propMap: ({ config }) => ({ content: config }),
|
|
25
|
+
minColSpan: 6,
|
|
26
|
+
maxColSpan: 12,
|
|
27
|
+
defaultColSpan: 12,
|
|
28
|
+
resizable: true,
|
|
29
|
+
};
|
|
@@ -1,34 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Built-in section definition: heading.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Stage E.1 (session 159) — reuses the existing `BlockHeadingView`
|
|
5
|
+
* renderer (`layers/base/components/blocks/BlockHeadingView.vue`)
|
|
6
|
+
* instead of a parallel `SectionHeading.vue`.
|
|
7
|
+
*
|
|
8
|
+
* Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
|
|
9
|
+
* See `feedback-reuse-existing-components` memory + Stage E plan.
|
|
7
10
|
*/
|
|
8
|
-
import { z } from 'zod';
|
|
9
11
|
import type { SectionDefinition } from '@commonpub/ui';
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
const configSchema = z.object({
|
|
13
|
-
text: z.string().min(1).max(240).default('Section heading'),
|
|
14
|
-
level: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(2),
|
|
15
|
-
align: z.enum(['left', 'center']).default('left'),
|
|
16
|
-
eyebrow: z.string().max(120).default(''),
|
|
17
|
-
subline: z.string().max(480).default(''),
|
|
18
|
-
});
|
|
12
|
+
import { headingConfigSchema, type HeadingConfig } from '@commonpub/schema';
|
|
13
|
+
import BlockHeadingView from '../../components/blocks/BlockHeadingView.vue';
|
|
19
14
|
|
|
20
|
-
export const headingSection: SectionDefinition<
|
|
15
|
+
export const headingSection: SectionDefinition<HeadingConfig> = {
|
|
21
16
|
type: 'heading',
|
|
22
17
|
name: 'Heading',
|
|
23
|
-
description: 'Single h1–
|
|
18
|
+
description: 'Single h1–h6 heading (uses BlockHeadingView)',
|
|
24
19
|
icon: 'fa-heading',
|
|
25
20
|
category: 'content',
|
|
26
21
|
status: 'stable',
|
|
27
|
-
configSchema,
|
|
28
|
-
defaultConfig: {
|
|
22
|
+
configSchema: headingConfigSchema,
|
|
23
|
+
defaultConfig: { level: 2, text: 'Section heading' },
|
|
29
24
|
schemaVersion: 1,
|
|
30
|
-
component:
|
|
31
|
-
|
|
25
|
+
component: BlockHeadingView,
|
|
26
|
+
propMap: ({ config }) => ({ content: config }),
|
|
32
27
|
minColSpan: 3,
|
|
33
28
|
maxColSpan: 12,
|
|
34
29
|
defaultColSpan: 12,
|
package/sections/builtin/hero.ts
CHANGED
|
@@ -1,67 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Built-in section definition: hero.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Stage E.4 — reuses the existing HeroSection which handles
|
|
5
|
+
* contest-aware swap + dismiss button + hardcoded fallback copy
|
|
6
|
+
* ("Build. Document. Share.").
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* future `contest-feature` data section in Phase 6b).
|
|
8
|
+
* Schema (incl. URL_LINK_STRICT guard on CTA hrefs) lives in
|
|
9
|
+
* `@commonpub/schema/sectionConfigs` (session 161 move).
|
|
11
10
|
*/
|
|
12
|
-
import { z } from 'zod';
|
|
13
11
|
import type { SectionDefinition } from '@commonpub/ui';
|
|
14
|
-
import
|
|
12
|
+
import { heroConfigSchema, type HeroConfig } from '@commonpub/schema';
|
|
13
|
+
import HeroSection from '../../components/homepage/HeroSection.vue';
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
* URL guard — accepts http(s), site-relative paths, hash links, mailto/tel.
|
|
18
|
-
* Rejects javascript:, data:, vbscript:, file:, etc. — admin-set fields
|
|
19
|
-
* render to ALL visitors, so a malicious admin (or DB corruption) could
|
|
20
|
-
* inject a clickable XSS via `<a href="javascript:...">` without this.
|
|
21
|
-
*
|
|
22
|
-
* Defense at the write boundary; the renderer doesn't re-validate
|
|
23
|
-
* (Vue's :href binding doesn't sanitize, so this IS the guard).
|
|
24
|
-
*/
|
|
25
|
-
const SAFE_LINK_URL = /^(https?:\/\/|\/|#|mailto:|tel:)/i;
|
|
26
|
-
|
|
27
|
-
const ctaSchema = z.object({
|
|
28
|
-
label: z.string().min(1).max(80),
|
|
29
|
-
href: z.string().min(1).max(2048).regex(SAFE_LINK_URL, {
|
|
30
|
-
message: 'href must be http(s), relative (/), hash (#), mailto:, or tel:',
|
|
31
|
-
}),
|
|
32
|
-
variant: z.enum(['primary', 'secondary']).default('primary'),
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const configSchema = z.object({
|
|
36
|
-
variant: z.enum(['default', 'compact', 'centered']).default('default'),
|
|
37
|
-
eyebrow: z.string().max(120).default(''),
|
|
38
|
-
title: z.string().min(1).max(240).default('Welcome'),
|
|
39
|
-
subtitle: z.string().max(800).default(''),
|
|
40
|
-
// Capped at 2 — visual + a11y guidance: more than two competing CTAs
|
|
41
|
-
// dilute the call-to-action and read as a button bar instead.
|
|
42
|
-
ctas: z.array(ctaSchema).max(2).default([]),
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
export const heroSection: SectionDefinition<z.infer<typeof configSchema>> = {
|
|
15
|
+
export const heroSection: SectionDefinition<HeroConfig> = {
|
|
46
16
|
type: 'hero',
|
|
47
17
|
name: 'Hero',
|
|
48
|
-
description: '
|
|
18
|
+
description: 'Top-of-page banner (uses HeroSection — contest-aware)',
|
|
49
19
|
icon: 'fa-bullhorn',
|
|
50
20
|
category: 'layout',
|
|
51
21
|
status: 'stable',
|
|
52
|
-
configSchema,
|
|
53
|
-
defaultConfig: {
|
|
54
|
-
variant: 'default',
|
|
55
|
-
eyebrow: '',
|
|
56
|
-
title: 'Welcome',
|
|
57
|
-
subtitle: '',
|
|
58
|
-
ctas: [],
|
|
59
|
-
},
|
|
22
|
+
configSchema: heroConfigSchema,
|
|
23
|
+
defaultConfig: { variant: 'default', customTitle: '', customSubtitle: '', ctas: [] },
|
|
60
24
|
schemaVersion: 1,
|
|
61
|
-
component:
|
|
62
|
-
//
|
|
63
|
-
|
|
25
|
+
component: HeroSection,
|
|
26
|
+
// HeroSection takes { config: HomepageSectionConfig }
|
|
27
|
+
propMap: ({ config }) => ({ config }),
|
|
28
|
+
minColSpan: 12,
|
|
64
29
|
maxColSpan: 12,
|
|
65
30
|
defaultColSpan: 12,
|
|
66
|
-
resizable:
|
|
31
|
+
resizable: false,
|
|
67
32
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: hubs.
|
|
3
|
+
*
|
|
4
|
+
* Stage E.4 — reuses the existing HubsSection (Trending Hubs sidebar
|
|
5
|
+
* card with join CTAs).
|
|
6
|
+
*
|
|
7
|
+
* Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
|
|
8
|
+
*/
|
|
9
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
10
|
+
import { hubsConfigSchema, type HubsConfig } from '@commonpub/schema';
|
|
11
|
+
import HubsSection from '../../components/homepage/HubsSection.vue';
|
|
12
|
+
|
|
13
|
+
export const hubsSection: SectionDefinition<HubsConfig> = {
|
|
14
|
+
type: 'hubs',
|
|
15
|
+
name: 'Hubs',
|
|
16
|
+
description: 'Trending hubs with join action (uses HubsSection)',
|
|
17
|
+
icon: 'fa-users',
|
|
18
|
+
category: 'data',
|
|
19
|
+
status: 'stable',
|
|
20
|
+
featureGate: 'hubs',
|
|
21
|
+
configSchema: hubsConfigSchema,
|
|
22
|
+
defaultConfig: { limit: 4 },
|
|
23
|
+
schemaVersion: 1,
|
|
24
|
+
component: HubsSection,
|
|
25
|
+
propMap: ({ config }) => ({ config }),
|
|
26
|
+
minColSpan: 3,
|
|
27
|
+
maxColSpan: 12,
|
|
28
|
+
defaultColSpan: 4,
|
|
29
|
+
resizable: true,
|
|
30
|
+
};
|
|
@@ -1,65 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Built-in section definition: image.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* via `.describe('image')` — current Zod is a plain URL string so the
|
|
7
|
-
* form generator still produces a usable text input today.
|
|
4
|
+
* Stage E.1 — reuses BlockImageView (`<figure>` with size preset +
|
|
5
|
+
* caption + lazy loading).
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* context). Custom-HTML / iframe sections are the XSS surface and gate
|
|
12
|
-
* differently (admin-only addRoles in Phase 6b).
|
|
7
|
+
* Schema (incl. URL_MEDIA_OR_EMPTY guard on `src`) lives in
|
|
8
|
+
* `@commonpub/schema/sectionConfigs` (session 161 move).
|
|
13
9
|
*/
|
|
14
|
-
import { z } from 'zod';
|
|
15
10
|
import type { SectionDefinition } from '@commonpub/ui';
|
|
16
|
-
import
|
|
11
|
+
import { imageConfigSchema, type ImageConfig } from '@commonpub/schema';
|
|
12
|
+
import BlockImageView from '../../components/blocks/BlockImageView.vue';
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
* URL guards — separate for `src` (image fetch) vs `href` (click target).
|
|
20
|
-
*
|
|
21
|
-
* - `src`: http(s) or site-relative. data: rejected (large + tracking
|
|
22
|
-
* surface; ImageUpload should be the path for inline data). javascript:
|
|
23
|
-
* in <img src> doesn't execute in modern browsers, but disallow anyway
|
|
24
|
-
* for consistency.
|
|
25
|
-
* - `href`: http(s), site-relative, hash, mailto:, tel:. javascript:
|
|
26
|
-
* would execute on click — admin-set fields render to ALL visitors so
|
|
27
|
-
* this is a stored XSS surface without the regex.
|
|
28
|
-
*
|
|
29
|
-
* Both allow EMPTY string (the section's "no image yet" / "no link"
|
|
30
|
-
* state). The `^(?:$|…)` shape matches empty-string only when the
|
|
31
|
-
* `$` end-of-string anchor immediately follows `^` — pinned by tests
|
|
32
|
-
* because the obvious `^(?:|…)` would have an empty alternation
|
|
33
|
-
* branch that matches ANY input (the empty match always succeeds at
|
|
34
|
-
* position 0).
|
|
35
|
-
*/
|
|
36
|
-
const SAFE_IMAGE_URL = /^(?:$|https?:\/\/|\/)/i;
|
|
37
|
-
const SAFE_LINK_URL = /^(?:$|https?:\/\/|\/|#|mailto:|tel:)/i;
|
|
38
|
-
|
|
39
|
-
const configSchema = z.object({
|
|
40
|
-
src: z.string().max(2048).regex(SAFE_IMAGE_URL, {
|
|
41
|
-
message: 'src must be http(s) or relative (/)',
|
|
42
|
-
}).default(''),
|
|
43
|
-
alt: z.string().max(240).default(''),
|
|
44
|
-
caption: z.string().max(480).default(''),
|
|
45
|
-
href: z.string().max(2048).regex(SAFE_LINK_URL, {
|
|
46
|
-
message: 'href must be http(s), relative (/), hash (#), mailto:, or tel:',
|
|
47
|
-
}).default(''),
|
|
48
|
-
fit: z.enum(['contain', 'cover']).default('contain'),
|
|
49
|
-
aspectRatio: z.enum(['16/9', '4/3', '1/1', 'auto']).default('auto'),
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
export const imageSection: SectionDefinition<z.infer<typeof configSchema>> = {
|
|
14
|
+
export const imageSection: SectionDefinition<ImageConfig> = {
|
|
53
15
|
type: 'image',
|
|
54
16
|
name: 'Image',
|
|
55
|
-
description: 'Single image with
|
|
17
|
+
description: 'Single image with caption (uses BlockImageView)',
|
|
56
18
|
icon: 'fa-image',
|
|
57
19
|
category: 'content',
|
|
58
20
|
status: 'stable',
|
|
59
|
-
configSchema,
|
|
60
|
-
defaultConfig: { src: '', alt: '', caption: '',
|
|
21
|
+
configSchema: imageConfigSchema,
|
|
22
|
+
defaultConfig: { src: '', alt: '', caption: '', size: 'l' },
|
|
61
23
|
schemaVersion: 1,
|
|
62
|
-
component:
|
|
24
|
+
component: BlockImageView,
|
|
25
|
+
propMap: ({ config }) => ({ content: config }),
|
|
63
26
|
minColSpan: 3,
|
|
64
27
|
maxColSpan: 12,
|
|
65
28
|
defaultColSpan: 12,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: learning.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1c addition (session 159) — learning paths grid. Server-fetches
|
|
5
|
+
* `/api/learn?limit=N` and renders a responsive grid of cards.
|
|
6
|
+
*
|
|
7
|
+
* Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
|
|
8
|
+
*/
|
|
9
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
10
|
+
import { learningConfigSchema, type LearningConfig } from '@commonpub/schema';
|
|
11
|
+
import SectionLearning from '../../components/sections/SectionLearning.vue';
|
|
12
|
+
|
|
13
|
+
export const learningSection: SectionDefinition<LearningConfig> = {
|
|
14
|
+
type: 'learning',
|
|
15
|
+
name: 'Learning paths',
|
|
16
|
+
description: 'Grid of learning paths with enrollment + difficulty (feature-gated)',
|
|
17
|
+
icon: 'fa-graduation-cap',
|
|
18
|
+
category: 'data',
|
|
19
|
+
status: 'stable',
|
|
20
|
+
// Palette gate — see hubs.ts for the rationale.
|
|
21
|
+
featureGate: 'learning',
|
|
22
|
+
configSchema: learningConfigSchema,
|
|
23
|
+
defaultConfig: { heading: 'Learning Paths', limit: 6, columns: 3 },
|
|
24
|
+
schemaVersion: 1,
|
|
25
|
+
component: SectionLearning,
|
|
26
|
+
minColSpan: 6,
|
|
27
|
+
maxColSpan: 12,
|
|
28
|
+
defaultColSpan: 12,
|
|
29
|
+
resizable: true,
|
|
30
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: markdown.
|
|
3
|
+
*
|
|
4
|
+
* Stage E.2 — reuses BlockMarkdownView. Pipes source markdown through
|
|
5
|
+
* markdownToBlockTuples + sanitizeBlockHtml — safer than custom-html.
|
|
6
|
+
*
|
|
7
|
+
* Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
|
|
8
|
+
*/
|
|
9
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
10
|
+
import { markdownConfigSchema, type MarkdownConfig } from '@commonpub/schema';
|
|
11
|
+
import BlockMarkdownView from '../../components/blocks/BlockMarkdownView.vue';
|
|
12
|
+
|
|
13
|
+
export const markdownSection: SectionDefinition<MarkdownConfig> = {
|
|
14
|
+
type: 'markdown',
|
|
15
|
+
name: 'Markdown',
|
|
16
|
+
description: 'Markdown body — safer than custom-html (uses BlockMarkdownView)',
|
|
17
|
+
icon: 'fa-markdown',
|
|
18
|
+
category: 'content',
|
|
19
|
+
status: 'stable',
|
|
20
|
+
configSchema: markdownConfigSchema,
|
|
21
|
+
defaultConfig: { source: '' },
|
|
22
|
+
schemaVersion: 1,
|
|
23
|
+
component: BlockMarkdownView,
|
|
24
|
+
propMap: ({ config }) => ({ content: config }),
|
|
25
|
+
minColSpan: 3,
|
|
26
|
+
maxColSpan: 12,
|
|
27
|
+
defaultColSpan: 12,
|
|
28
|
+
resizable: true,
|
|
29
|
+
};
|
|
@@ -1,32 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Built-in section definition: paragraph.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Stage E.1 — reuses BlockTextView (sanitised HTML body via
|
|
5
|
+
* `sanitizeBlockHtml`). Admin-set HTML works because BlockTextView pipes
|
|
6
|
+
* through sanitizeBlockHtml — same XSS posture as the rest of the Block
|
|
7
|
+
* system.
|
|
8
|
+
*
|
|
9
|
+
* Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
|
|
8
10
|
*/
|
|
9
|
-
import { z } from 'zod';
|
|
10
11
|
import type { SectionDefinition } from '@commonpub/ui';
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
const configSchema = z.object({
|
|
14
|
-
text: z.string().max(8000).default(''),
|
|
15
|
-
align: z.enum(['left', 'center']).default('left'),
|
|
16
|
-
});
|
|
12
|
+
import { paragraphConfigSchema, type ParagraphConfig } from '@commonpub/schema';
|
|
13
|
+
import BlockTextView from '../../components/blocks/BlockTextView.vue';
|
|
17
14
|
|
|
18
|
-
export const paragraphSection: SectionDefinition<
|
|
15
|
+
export const paragraphSection: SectionDefinition<ParagraphConfig> = {
|
|
19
16
|
type: 'paragraph',
|
|
20
17
|
name: 'Paragraph',
|
|
21
|
-
description: '
|
|
18
|
+
description: 'Sanitised HTML body (uses BlockTextView)',
|
|
22
19
|
icon: 'fa-align-left',
|
|
23
20
|
category: 'content',
|
|
24
21
|
status: 'stable',
|
|
25
|
-
configSchema,
|
|
26
|
-
defaultConfig: {
|
|
22
|
+
configSchema: paragraphConfigSchema,
|
|
23
|
+
defaultConfig: { html: '<p>Paragraph body.</p>' },
|
|
27
24
|
schemaVersion: 1,
|
|
28
|
-
component:
|
|
29
|
-
|
|
25
|
+
component: BlockTextView,
|
|
26
|
+
propMap: ({ config }) => ({ content: config }),
|
|
30
27
|
minColSpan: 3,
|
|
31
28
|
maxColSpan: 12,
|
|
32
29
|
defaultColSpan: 6,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: stats.
|
|
3
|
+
*
|
|
4
|
+
* Stage E.4 — reuses the existing StatsSection (`layers/base/
|
|
5
|
+
* components/homepage/StatsSection.vue`) which fetches `/api/stats`
|
|
6
|
+
* + renders the Projects/Posts/Members/Hubs sidebar card. StatsSection
|
|
7
|
+
* takes no props (reads useFeatures internally for the hubs gate).
|
|
8
|
+
*
|
|
9
|
+
* Schema lives in `@commonpub/schema/sectionConfigs` (session 161 move).
|
|
10
|
+
* Schema is intentionally `z.object({})` so admin tooling / migrations
|
|
11
|
+
* can pass extra fields (e.g., legacy `heading: 'Platform Stats'`)
|
|
12
|
+
* without rejection; StatsSection ignores them since it takes no props.
|
|
13
|
+
*/
|
|
14
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
15
|
+
import { statsConfigSchema, type StatsConfig } from '@commonpub/schema';
|
|
16
|
+
import StatsSection from '../../components/homepage/StatsSection.vue';
|
|
17
|
+
|
|
18
|
+
export const statsSection: SectionDefinition<StatsConfig> = {
|
|
19
|
+
type: 'stats',
|
|
20
|
+
name: 'Platform stats',
|
|
21
|
+
description: 'Projects/Posts/Members/Hubs sidebar card (uses StatsSection)',
|
|
22
|
+
icon: 'fa-chart-simple',
|
|
23
|
+
category: 'data',
|
|
24
|
+
status: 'stable',
|
|
25
|
+
configSchema: statsConfigSchema,
|
|
26
|
+
defaultConfig: {},
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
component: StatsSection,
|
|
29
|
+
// StatsSection takes no props — pass empty object
|
|
30
|
+
propMap: () => ({}),
|
|
31
|
+
minColSpan: 3,
|
|
32
|
+
maxColSpan: 12,
|
|
33
|
+
defaultColSpan: 4,
|
|
34
|
+
resizable: true,
|
|
35
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: video.
|
|
3
|
+
*
|
|
4
|
+
* Stage E.2 — reuses BlockVideoView (YouTube/Vimeo embed routing +
|
|
5
|
+
* local file fallback).
|
|
6
|
+
*
|
|
7
|
+
* Schema (incl. URL_MEDIA_OR_EMPTY guard) lives in
|
|
8
|
+
* `@commonpub/schema/sectionConfigs` (session 161 move).
|
|
9
|
+
*/
|
|
10
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
11
|
+
import { videoConfigSchema, type VideoConfig } from '@commonpub/schema';
|
|
12
|
+
import BlockVideoView from '../../components/blocks/BlockVideoView.vue';
|
|
13
|
+
|
|
14
|
+
export const videoSection: SectionDefinition<VideoConfig> = {
|
|
15
|
+
type: 'video',
|
|
16
|
+
name: 'Video',
|
|
17
|
+
description: 'YouTube / Vimeo / local file (uses BlockVideoView)',
|
|
18
|
+
icon: 'fa-film',
|
|
19
|
+
category: 'content',
|
|
20
|
+
status: 'stable',
|
|
21
|
+
configSchema: videoConfigSchema,
|
|
22
|
+
defaultConfig: { url: '' },
|
|
23
|
+
schemaVersion: 1,
|
|
24
|
+
component: BlockVideoView,
|
|
25
|
+
propMap: ({ config }) => ({ content: config }),
|
|
26
|
+
minColSpan: 6,
|
|
27
|
+
maxColSpan: 12,
|
|
28
|
+
defaultColSpan: 12,
|
|
29
|
+
resizable: true,
|
|
30
|
+
};
|
package/sections/registry.ts
CHANGED
|
@@ -29,26 +29,57 @@ import { paragraphSection } from './builtin/paragraph';
|
|
|
29
29
|
import { imageSection } from './builtin/image';
|
|
30
30
|
import { heroSection } from './builtin/hero';
|
|
31
31
|
import { contentFeedSection } from './builtin/content-feed';
|
|
32
|
+
import { editorialSection } from './builtin/editorial';
|
|
33
|
+
import { statsSection } from './builtin/stats';
|
|
34
|
+
import { hubsSection } from './builtin/hubs';
|
|
35
|
+
import { contestsSection } from './builtin/contests';
|
|
36
|
+
import { learningSection } from './builtin/learning';
|
|
37
|
+
import { customHtmlSection } from './builtin/custom-html';
|
|
38
|
+
import { ctaSection } from './builtin/cta';
|
|
39
|
+
import { markdownSection } from './builtin/markdown';
|
|
40
|
+
import { gallerySection } from './builtin/gallery';
|
|
41
|
+
import { videoSection } from './builtin/video';
|
|
42
|
+
import { embedSection } from './builtin/embed';
|
|
32
43
|
|
|
33
44
|
// Singleton — registered once at module load. Vue/Nuxt's setup() runs
|
|
34
45
|
// per-component, but module load is once per app process. Safe.
|
|
35
46
|
const registry = new SectionRegistry();
|
|
36
47
|
|
|
37
48
|
// --- Built-in registrations -----------------------------------------------
|
|
38
|
-
// Phase 1c
|
|
39
|
-
// the four leading categories — layout (hero,
|
|
40
|
-
// paragraph, image), and data
|
|
49
|
+
// Phase 1c full catalog (session 159): divider (proof-of-life) + 11
|
|
50
|
+
// sections covering the four leading categories — layout (hero,
|
|
51
|
+
// divider), content (heading, paragraph, image, custom-html), and data
|
|
52
|
+
// (content-feed, editorial, stats, hubs, contests, learning).
|
|
41
53
|
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
54
|
+
// With the addition of editorial / stats / hubs / contests / learning /
|
|
55
|
+
// custom-html this session, every section type the legacy
|
|
56
|
+
// `homepage.sections` JSON dispatches to (HomepageSectionRenderer.vue)
|
|
57
|
+
// is now representable as a registered section — unblocking the real
|
|
58
|
+
// legacy-homepage migration (Phase 1c step 3 in the session-158 handoff).
|
|
59
|
+
//
|
|
60
|
+
// Phase 6b will add the remaining 18 types (gallery, video, embed,
|
|
61
|
+
// spacer, cta, featured-content, content-card, contest-list, hub-list,
|
|
62
|
+
// event-list, member-list, stats-grid, contact-form, newsletter,
|
|
63
|
+
// announcement, markdown, iframe, content-grid alias). See
|
|
64
|
+
// docs/plans/layout-and-pages.md §3.4.
|
|
46
65
|
registry.register(dividerSection);
|
|
47
66
|
registry.register(heroSection);
|
|
48
67
|
registry.register(headingSection);
|
|
49
68
|
registry.register(paragraphSection);
|
|
50
69
|
registry.register(imageSection);
|
|
51
70
|
registry.register(contentFeedSection);
|
|
71
|
+
registry.register(editorialSection);
|
|
72
|
+
registry.register(statsSection);
|
|
73
|
+
registry.register(hubsSection);
|
|
74
|
+
registry.register(contestsSection);
|
|
75
|
+
registry.register(learningSection);
|
|
76
|
+
registry.register(customHtmlSection);
|
|
77
|
+
// Phase 6b additions (session 159)
|
|
78
|
+
registry.register(ctaSection);
|
|
79
|
+
registry.register(markdownSection);
|
|
80
|
+
registry.register(gallerySection);
|
|
81
|
+
registry.register(videoSection);
|
|
82
|
+
registry.register(embedSection);
|
|
52
83
|
|
|
53
84
|
/**
|
|
54
85
|
* Read-only accessor — the layer's standard pattern for shared state.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { setHomepageSections } from '@commonpub/server';
|
|
1
|
+
import { setHomepageSections, migrateHomepageSectionsToLayout } from '@commonpub/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
|
|
3
4
|
|
|
4
5
|
const sectionConfigSchema = z.object({
|
|
5
6
|
contentType: z.string().max(64).optional(),
|
|
@@ -48,5 +49,55 @@ export default defineEventHandler(async (event) => {
|
|
|
48
49
|
|
|
49
50
|
await setHomepageSections(db, body.sections, user.id, getRequestIP(event) ?? undefined);
|
|
50
51
|
|
|
52
|
+
// When the layout engine is on, the legacy `homepage.sections` JSON
|
|
53
|
+
// is no longer what /pages/index.vue renders — the page reads from
|
|
54
|
+
// the `layouts` table. Without this sync, admin edits made via the
|
|
55
|
+
// existing homepage editor (which still writes homepage.sections,
|
|
56
|
+
// because the Phase 3 layout editor isn't built yet) would have
|
|
57
|
+
// ZERO visible effect on the live page.
|
|
58
|
+
//
|
|
59
|
+
// Fix: after the legacy save succeeds, if layoutEngine is on,
|
|
60
|
+
// **R4 audit P0 data-loss fix (session 160)**: previously this called
|
|
61
|
+
// migrateHomepageSectionsToLayout with force:true on every legacy save,
|
|
62
|
+
// which DELETES the existing layout (cascade → rows, sections,
|
|
63
|
+
// VERSIONS). If an admin had bespoke-edited the homepage via /admin/
|
|
64
|
+
// layouts, those edits + the entire publish history were silently
|
|
65
|
+
// destroyed the next time anyone touched /admin/homepage. Same data-
|
|
66
|
+
// loss path also hit the audit log if two admins were editing in
|
|
67
|
+
// parallel.
|
|
68
|
+
//
|
|
69
|
+
// New semantics: NON-DESTRUCTIVE auto-sync. If a layout doesn't yet
|
|
70
|
+
// exist for ('route','/'), create it from the legacy data so the
|
|
71
|
+
// first-time operator's legacy edits keep working. If a layout DOES
|
|
72
|
+
// exist, leave it alone — the new editor is the source of truth from
|
|
73
|
+
// that point forward. /admin/homepage now shows a deprecation banner
|
|
74
|
+
// (see the page itself) directing operators to /admin/layouts.
|
|
75
|
+
//
|
|
76
|
+
// History: original auto-sync added session 159 (per the comment
|
|
77
|
+
// above) to handle "I removed a section in /admin/homepage but it
|
|
78
|
+
// still renders" — that scenario still works on first-time admins
|
|
79
|
+
// since the layout gets created on first save. After the operator
|
|
80
|
+
// adopts the layout editor, /admin/homepage becomes effectively
|
|
81
|
+
// read-only with respect to the live homepage; the legacy data still
|
|
82
|
+
// saves into instance_settings for backward-compat but is no longer
|
|
83
|
+
// promoted.
|
|
84
|
+
const config = useConfig();
|
|
85
|
+
if (config.features.layoutEngine) {
|
|
86
|
+
try {
|
|
87
|
+
const result = await migrateHomepageSectionsToLayout(db, {
|
|
88
|
+
adminId: user.id,
|
|
89
|
+
force: false, // changed from true — see comment above
|
|
90
|
+
});
|
|
91
|
+
if (result.migrated) {
|
|
92
|
+
invalidateLayoutsByRouteCache();
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// Don't fail the user's save just because the layout sync hit a
|
|
96
|
+
// hiccup. Log + return — they can re-trigger the migration via
|
|
97
|
+
// the dedicated /api/admin/layouts/migrate-homepage endpoint.
|
|
98
|
+
console.error('[admin:homepage.sections] post-save layout sync failed:', err);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
51
102
|
return { sections: body.sections, message: 'Homepage updated' };
|
|
52
103
|
});
|
|
@@ -28,6 +28,18 @@ export default defineEventHandler(async (event) => {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const version = await publishLayout(db, id, { publishedBy: admin.id });
|
|
31
|
+
|
|
32
|
+
// Audit log (round 3): publish changes what the public sees — highest-
|
|
33
|
+
// leverage action. layout_versions also stores publishedBy (durable trail);
|
|
34
|
+
// stdout adds incident-response greppability.
|
|
35
|
+
console.info('cpub.audit.layout.publish', JSON.stringify({
|
|
36
|
+
at: new Date().toISOString(),
|
|
37
|
+
adminId: admin.id,
|
|
38
|
+
layoutId: id,
|
|
39
|
+
scope: existing.scope,
|
|
40
|
+
versionId: (version as { id?: string } | null | undefined)?.id ?? null,
|
|
41
|
+
}));
|
|
42
|
+
|
|
31
43
|
invalidateLayoutsByRouteCache();
|
|
32
44
|
return version;
|
|
33
45
|
});
|
|
@@ -31,6 +31,17 @@ export default defineEventHandler(async (event) => {
|
|
|
31
31
|
|
|
32
32
|
try {
|
|
33
33
|
const reverted = await revertToVersion(db, id, versionId, { userId: admin.id });
|
|
34
|
+
|
|
35
|
+
// Audit log (round 3): revert overwrites current state with a prior
|
|
36
|
+
// snapshot — destructive transformation, deserves forensic trail.
|
|
37
|
+
console.info('cpub.audit.layout.revert', JSON.stringify({
|
|
38
|
+
at: new Date().toISOString(),
|
|
39
|
+
adminId: admin.id,
|
|
40
|
+
layoutId: id,
|
|
41
|
+
scope: existing.scope,
|
|
42
|
+
versionId,
|
|
43
|
+
}));
|
|
44
|
+
|
|
34
45
|
invalidateLayoutsByRouteCache();
|
|
35
46
|
return reverted;
|
|
36
47
|
} catch (e) {
|