@commonpub/layer 0.22.1 → 0.23.1
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/components/LayoutSlot.vue +266 -0
- package/components/sections/SectionContentFeed.vue +160 -0
- package/components/sections/SectionDivider.vue +55 -0
- package/components/sections/SectionHeading.vue +78 -0
- package/components/sections/SectionHero.vue +164 -0
- package/components/sections/SectionImage.vue +104 -0
- package/components/sections/SectionParagraph.vue +55 -0
- package/composables/useFeatures.ts +10 -0
- package/composables/useLayout.ts +132 -0
- package/package.json +7 -6
- package/pages/admin/theme/index.vue +22 -1
- package/pages/index.vue +23 -2
- package/sections/builtin/content-feed.ts +52 -0
- package/sections/builtin/divider.ts +37 -0
- package/sections/builtin/heading.ts +36 -0
- package/sections/builtin/hero.ts +67 -0
- package/sections/builtin/image.ts +67 -0
- package/sections/builtin/paragraph.ts +34 -0
- package/sections/registry.ts +61 -0
- package/server/api/admin/layouts/[id]/publish.post.ts +33 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +43 -0
- package/server/api/admin/layouts/[id]/versions/index.get.ts +29 -0
- package/server/api/admin/layouts/[id].delete.ts +34 -0
- package/server/api/admin/layouts/[id].get.ts +27 -0
- package/server/api/admin/layouts/[id].put.ts +64 -0
- package/server/api/admin/layouts/index.get.ts +25 -0
- package/server/api/admin/layouts/index.post.ts +48 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +35 -0
- package/server/api/admin/themes/discover.get.ts +14 -5
- package/server/api/layouts/by-route.get.ts +87 -0
- package/server/utils/layoutCache.ts +53 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: heading.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1c starter — single heading with optional eyebrow + subline.
|
|
5
|
+
* Drives the auto-form via the configSchema (Phase 3e maps Zod kinds to
|
|
6
|
+
* controls; level is a small enum → segmented control).
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
10
|
+
import SectionHeading from '../../components/sections/SectionHeading.vue';
|
|
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
|
+
});
|
|
19
|
+
|
|
20
|
+
export const headingSection: SectionDefinition<z.infer<typeof configSchema>> = {
|
|
21
|
+
type: 'heading',
|
|
22
|
+
name: 'Heading',
|
|
23
|
+
description: 'Single h1–h4 heading with optional eyebrow + subline',
|
|
24
|
+
icon: 'fa-heading',
|
|
25
|
+
category: 'content',
|
|
26
|
+
status: 'stable',
|
|
27
|
+
configSchema,
|
|
28
|
+
defaultConfig: { text: 'Section heading', level: 2, align: 'left', eyebrow: '', subline: '' },
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
component: SectionHeading,
|
|
31
|
+
// Heading reads fine narrow; allow as small as quarter-width
|
|
32
|
+
minColSpan: 3,
|
|
33
|
+
maxColSpan: 12,
|
|
34
|
+
defaultColSpan: 12,
|
|
35
|
+
resizable: true,
|
|
36
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: hero.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1c starter. Three variants — `default` (left-aligned with grid
|
|
5
|
+
* backdrop), `compact` (narrow with no backdrop), `centered` (centered
|
|
6
|
+
* content). Up to two CTAs each with their own variant.
|
|
7
|
+
*
|
|
8
|
+
* NOT contest-aware (the existing HomepageHeroSection has dispatch
|
|
9
|
+
* logic for the live-contest hero — that responsibility moves to a
|
|
10
|
+
* future `contest-feature` data section in Phase 6b).
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
14
|
+
import SectionHero from '../../components/sections/SectionHero.vue';
|
|
15
|
+
|
|
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>> = {
|
|
46
|
+
type: 'hero',
|
|
47
|
+
name: 'Hero',
|
|
48
|
+
description: 'Banner with title, subtitle, and up to two CTAs',
|
|
49
|
+
icon: 'fa-bullhorn',
|
|
50
|
+
category: 'layout',
|
|
51
|
+
status: 'stable',
|
|
52
|
+
configSchema,
|
|
53
|
+
defaultConfig: {
|
|
54
|
+
variant: 'default',
|
|
55
|
+
eyebrow: '',
|
|
56
|
+
title: 'Welcome',
|
|
57
|
+
subtitle: '',
|
|
58
|
+
ctas: [],
|
|
59
|
+
},
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
component: SectionHero,
|
|
62
|
+
// Hero breaks at <6 cols (CTA wrap looks awful); 12 is the canonical use
|
|
63
|
+
minColSpan: 6,
|
|
64
|
+
maxColSpan: 12,
|
|
65
|
+
defaultColSpan: 12,
|
|
66
|
+
resizable: true,
|
|
67
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: image.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1c starter. Stores raw URL + alt + optional caption + optional
|
|
5
|
+
* href. The Phase 3e auto-form will swap `src` for an ImageUpload picker
|
|
6
|
+
* via `.describe('image')` — current Zod is a plain URL string so the
|
|
7
|
+
* form generator still produces a usable text input today.
|
|
8
|
+
*
|
|
9
|
+
* Security note: `src` is rendered as-is via `<img>` — sanitisation is
|
|
10
|
+
* caller responsibility (the URL is never reflected into a script
|
|
11
|
+
* context). Custom-HTML / iframe sections are the XSS surface and gate
|
|
12
|
+
* differently (admin-only addRoles in Phase 6b).
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
16
|
+
import SectionImage from '../../components/sections/SectionImage.vue';
|
|
17
|
+
|
|
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>> = {
|
|
53
|
+
type: 'image',
|
|
54
|
+
name: 'Image',
|
|
55
|
+
description: 'Single image with optional caption + link',
|
|
56
|
+
icon: 'fa-image',
|
|
57
|
+
category: 'content',
|
|
58
|
+
status: 'stable',
|
|
59
|
+
configSchema,
|
|
60
|
+
defaultConfig: { src: '', alt: '', caption: '', href: '', fit: 'contain', aspectRatio: 'auto' },
|
|
61
|
+
schemaVersion: 1,
|
|
62
|
+
component: SectionImage,
|
|
63
|
+
minColSpan: 3,
|
|
64
|
+
maxColSpan: 12,
|
|
65
|
+
defaultColSpan: 12,
|
|
66
|
+
resizable: true,
|
|
67
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in section definition: paragraph.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1c starter — plain prose, blank-line split into `<p>` tags.
|
|
5
|
+
* Upgrade to TipTap-driven rich text in Phase 3e via `.describe('rich')`
|
|
6
|
+
* + a v2 migration; this v1 keeps the storage simple so the editor work
|
|
7
|
+
* doesn't block on TipTap integration.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
11
|
+
import SectionParagraph from '../../components/sections/SectionParagraph.vue';
|
|
12
|
+
|
|
13
|
+
const configSchema = z.object({
|
|
14
|
+
text: z.string().max(8000).default(''),
|
|
15
|
+
align: z.enum(['left', 'center']).default('left'),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const paragraphSection: SectionDefinition<z.infer<typeof configSchema>> = {
|
|
19
|
+
type: 'paragraph',
|
|
20
|
+
name: 'Paragraph',
|
|
21
|
+
description: 'Plain prose body with blank-line paragraph breaks',
|
|
22
|
+
icon: 'fa-align-left',
|
|
23
|
+
category: 'content',
|
|
24
|
+
status: 'stable',
|
|
25
|
+
configSchema,
|
|
26
|
+
defaultConfig: { text: '', align: 'left' },
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
component: SectionParagraph,
|
|
29
|
+
// Prose reads best at ~6/12; allow narrower for sidebars + full-width on landing pages
|
|
30
|
+
minColSpan: 3,
|
|
31
|
+
maxColSpan: 12,
|
|
32
|
+
defaultColSpan: 6,
|
|
33
|
+
resizable: true,
|
|
34
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer-level section registry — the Vue runtime catalog that
|
|
3
|
+
* `<LayoutSlot>` consults to render a section by its `type` slug.
|
|
4
|
+
*
|
|
5
|
+
* `SectionRegistry` (the class) lives in `@commonpub/ui` as Vue-aware
|
|
6
|
+
* types. This file:
|
|
7
|
+
* 1. Creates ONE shared registry instance per app process
|
|
8
|
+
* 2. Calls `register()` for every built-in section type the layer ships
|
|
9
|
+
* 3. Exports `useSectionRegistry()` so consumers (LayoutSlot + the
|
|
10
|
+
* admin editor's palette) can read it without circular imports
|
|
11
|
+
*
|
|
12
|
+
* **Same instance on server + client.** Vue plugins instantiate at
|
|
13
|
+
* setup time; section registration is synchronous + idempotent (the
|
|
14
|
+
* `register()` method throws on duplicate slugs — fail-fast).
|
|
15
|
+
*
|
|
16
|
+
* Adding a built-in section is THREE files (see any of `./builtin/*` for
|
|
17
|
+
* the template):
|
|
18
|
+
* 1. `./builtin/{type}.ts` — Zod schema + SectionDefinition export
|
|
19
|
+
* 2. `../components/sections/Section{PascalType}.vue` — renderer
|
|
20
|
+
* 3. One `registry.register(...)` line here
|
|
21
|
+
*
|
|
22
|
+
* To add a CUSTOM section from a thin layer app (Phase 9 — not yet
|
|
23
|
+
* shipped): same pattern, registered from your `commonpub.config.ts`.
|
|
24
|
+
*/
|
|
25
|
+
import { SectionRegistry } from '@commonpub/ui';
|
|
26
|
+
import { dividerSection } from './builtin/divider';
|
|
27
|
+
import { headingSection } from './builtin/heading';
|
|
28
|
+
import { paragraphSection } from './builtin/paragraph';
|
|
29
|
+
import { imageSection } from './builtin/image';
|
|
30
|
+
import { heroSection } from './builtin/hero';
|
|
31
|
+
import { contentFeedSection } from './builtin/content-feed';
|
|
32
|
+
|
|
33
|
+
// Singleton — registered once at module load. Vue/Nuxt's setup() runs
|
|
34
|
+
// per-component, but module load is once per app process. Safe.
|
|
35
|
+
const registry = new SectionRegistry();
|
|
36
|
+
|
|
37
|
+
// --- Built-in registrations -----------------------------------------------
|
|
38
|
+
// Phase 1c starter catalog: divider (proof-of-life) + 5 sections covering
|
|
39
|
+
// the four leading categories — layout (hero, divider), content (heading,
|
|
40
|
+
// paragraph, image), and data (content-feed).
|
|
41
|
+
//
|
|
42
|
+
// Phase 6b adds the remaining 20 types (gallery, video, embed, spacer,
|
|
43
|
+
// cta, featured-content, content-card, contest-list, hub-list, event-list,
|
|
44
|
+
// member-list, stats-grid, contact-form, newsletter, announcement,
|
|
45
|
+
// markdown, custom-html, iframe, editorial). See docs/plans/layout-and-pages.md §3.4.
|
|
46
|
+
registry.register(dividerSection);
|
|
47
|
+
registry.register(heroSection);
|
|
48
|
+
registry.register(headingSection);
|
|
49
|
+
registry.register(paragraphSection);
|
|
50
|
+
registry.register(imageSection);
|
|
51
|
+
registry.register(contentFeedSection);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read-only accessor — the layer's standard pattern for shared state.
|
|
55
|
+
* Use this everywhere instead of importing `registry` directly so we
|
|
56
|
+
* can swap to a Nuxt-provided instance in Phase 9 (when thin apps
|
|
57
|
+
* register their own sections via `commonpub.config.ts`).
|
|
58
|
+
*/
|
|
59
|
+
export function useSectionRegistry(): SectionRegistry {
|
|
60
|
+
return registry;
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/admin/layouts/[id]/publish
|
|
3
|
+
*
|
|
4
|
+
* Snapshot the current layout into `layout_versions`, set
|
|
5
|
+
* `published_version_id`, transition `state` to 'published'. Returns
|
|
6
|
+
* the version record.
|
|
7
|
+
*
|
|
8
|
+
* Admin + features.admin + features.layoutEngine.
|
|
9
|
+
* Invalidates the layouts-by-route cache on success.
|
|
10
|
+
*/
|
|
11
|
+
import { getLayoutById, publishLayout } from '@commonpub/server';
|
|
12
|
+
import { invalidateLayoutsByRouteCache } from '../../../../utils/layoutCache';
|
|
13
|
+
|
|
14
|
+
export default defineEventHandler(async (event) => {
|
|
15
|
+
requireFeature('admin');
|
|
16
|
+
requireFeature('layoutEngine');
|
|
17
|
+
const admin = requireAdmin(event);
|
|
18
|
+
const db = useDB();
|
|
19
|
+
|
|
20
|
+
const id = getRouterParam(event, 'id');
|
|
21
|
+
if (!id) {
|
|
22
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const existing = await getLayoutById(db, id);
|
|
26
|
+
if (!existing) {
|
|
27
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const version = await publishLayout(db, id, { publishedBy: admin.id });
|
|
31
|
+
invalidateLayoutsByRouteCache();
|
|
32
|
+
return version;
|
|
33
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/admin/layouts/[id]/versions/[versionId]/revert
|
|
3
|
+
*
|
|
4
|
+
* Restore a layout to a prior version snapshot. The original version
|
|
5
|
+
* row is NOT touched — snapshots are immutable. The layout's current
|
|
6
|
+
* rows + sections are rewritten from the snapshot, and the state
|
|
7
|
+
* transitions to 'draft' (admin can re-publish if desired).
|
|
8
|
+
*
|
|
9
|
+
* Admin + features.admin + features.layoutEngine.
|
|
10
|
+
* Invalidates the layouts-by-route cache on success.
|
|
11
|
+
*/
|
|
12
|
+
import { getLayoutById, revertToVersion } from '@commonpub/server';
|
|
13
|
+
import { invalidateLayoutsByRouteCache } from '../../../../../../utils/layoutCache';
|
|
14
|
+
|
|
15
|
+
export default defineEventHandler(async (event) => {
|
|
16
|
+
requireFeature('admin');
|
|
17
|
+
requireFeature('layoutEngine');
|
|
18
|
+
const admin = requireAdmin(event);
|
|
19
|
+
const db = useDB();
|
|
20
|
+
|
|
21
|
+
const id = getRouterParam(event, 'id');
|
|
22
|
+
const versionId = getRouterParam(event, 'versionId');
|
|
23
|
+
if (!id || !versionId) {
|
|
24
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id or versionId param' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const existing = await getLayoutById(db, id);
|
|
28
|
+
if (!existing) {
|
|
29
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const reverted = await revertToVersion(db, id, versionId, { userId: admin.id });
|
|
34
|
+
invalidateLayoutsByRouteCache();
|
|
35
|
+
return reverted;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
// revertToVersion throws on missing version — map to 404
|
|
38
|
+
if (e instanceof Error && e.message.includes('Version not found')) {
|
|
39
|
+
throw createError({ statusCode: 404, statusMessage: e.message });
|
|
40
|
+
}
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/admin/layouts/[id]/versions
|
|
3
|
+
*
|
|
4
|
+
* List all immutable version snapshots for a layout, newest first.
|
|
5
|
+
* Each entry has the FULL snapshot embedded — useful for the version
|
|
6
|
+
* history UI to show a preview of "what this version looked like".
|
|
7
|
+
*
|
|
8
|
+
* Admin + features.admin + features.layoutEngine.
|
|
9
|
+
*/
|
|
10
|
+
import { getLayoutById, listLayoutVersions } from '@commonpub/server';
|
|
11
|
+
|
|
12
|
+
export default defineEventHandler(async (event) => {
|
|
13
|
+
requireFeature('admin');
|
|
14
|
+
requireFeature('layoutEngine');
|
|
15
|
+
requireAdmin(event);
|
|
16
|
+
const db = useDB();
|
|
17
|
+
|
|
18
|
+
const id = getRouterParam(event, 'id');
|
|
19
|
+
if (!id) {
|
|
20
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const existing = await getLayoutById(db, id);
|
|
24
|
+
if (!existing) {
|
|
25
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return listLayoutVersions(db, id);
|
|
29
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DELETE /api/admin/layouts/[id]
|
|
3
|
+
*
|
|
4
|
+
* Permanently delete a layout. Cascades through rows + sections + versions.
|
|
5
|
+
*
|
|
6
|
+
* NOT recoverable. The editor's UI surfaces a confirm modal; this API
|
|
7
|
+
* trusts the caller.
|
|
8
|
+
*
|
|
9
|
+
* Admin + features.admin + features.layoutEngine.
|
|
10
|
+
* Invalidates the layouts-by-route cache on success.
|
|
11
|
+
*/
|
|
12
|
+
import { deleteLayout, getLayoutById } from '@commonpub/server';
|
|
13
|
+
import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
|
|
14
|
+
|
|
15
|
+
export default defineEventHandler(async (event): Promise<{ ok: true; id: string }> => {
|
|
16
|
+
requireFeature('admin');
|
|
17
|
+
requireFeature('layoutEngine');
|
|
18
|
+
requireAdmin(event);
|
|
19
|
+
const db = useDB();
|
|
20
|
+
|
|
21
|
+
const id = getRouterParam(event, 'id');
|
|
22
|
+
if (!id) {
|
|
23
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const existing = await getLayoutById(db, id);
|
|
27
|
+
if (!existing) {
|
|
28
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await deleteLayout(db, id);
|
|
32
|
+
invalidateLayoutsByRouteCache();
|
|
33
|
+
return { ok: true, id };
|
|
34
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/admin/layouts/[id]
|
|
3
|
+
*
|
|
4
|
+
* Fetch a layout by id. Returns the full record including draft state +
|
|
5
|
+
* version pointer. 404 if not found.
|
|
6
|
+
*
|
|
7
|
+
* Admin + features.admin + features.layoutEngine.
|
|
8
|
+
*/
|
|
9
|
+
import { getLayoutById } from '@commonpub/server';
|
|
10
|
+
|
|
11
|
+
export default defineEventHandler(async (event) => {
|
|
12
|
+
requireFeature('admin');
|
|
13
|
+
requireFeature('layoutEngine');
|
|
14
|
+
requireAdmin(event);
|
|
15
|
+
|
|
16
|
+
const id = getRouterParam(event, 'id');
|
|
17
|
+
if (!id) {
|
|
18
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const db = useDB();
|
|
22
|
+
const layout = await getLayoutById(db, id);
|
|
23
|
+
if (!layout) {
|
|
24
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
25
|
+
}
|
|
26
|
+
return layout;
|
|
27
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUT /api/admin/layouts/[id]
|
|
3
|
+
*
|
|
4
|
+
* Update a layout — auto-save target for the editor. Sends the WHOLE
|
|
5
|
+
* draft (zones → rows → sections); saveLayout diffs against current,
|
|
6
|
+
* renumbers positions, rewrites children in a transaction.
|
|
7
|
+
*
|
|
8
|
+
* Scope CANNOT be changed via PUT (it's immutable per layout). A scope
|
|
9
|
+
* change would mean a new layout — POST instead.
|
|
10
|
+
*
|
|
11
|
+
* Admin + features.admin + features.layoutEngine.
|
|
12
|
+
* Invalidates the layouts-by-route cache on success.
|
|
13
|
+
*/
|
|
14
|
+
import { layoutCreateSchema } from '@commonpub/schema';
|
|
15
|
+
import { getLayoutById, saveLayout } from '@commonpub/server';
|
|
16
|
+
import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
|
|
17
|
+
|
|
18
|
+
export default defineEventHandler(async (event) => {
|
|
19
|
+
requireFeature('admin');
|
|
20
|
+
requireFeature('layoutEngine');
|
|
21
|
+
const admin = requireAdmin(event);
|
|
22
|
+
const db = useDB();
|
|
23
|
+
|
|
24
|
+
const id = getRouterParam(event, 'id');
|
|
25
|
+
if (!id) {
|
|
26
|
+
throw createError({ statusCode: 400, statusMessage: 'Missing id param' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const existing = await getLayoutById(db, id);
|
|
30
|
+
if (!existing) {
|
|
31
|
+
throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const body = await parseBody(event, layoutCreateSchema);
|
|
35
|
+
|
|
36
|
+
// Scope is immutable — reject if the client tries to change it. This
|
|
37
|
+
// catches an "edit the wrong layout" bug at the API surface rather
|
|
38
|
+
// than silently moving sections to a new route.
|
|
39
|
+
if (
|
|
40
|
+
body.scope.type !== existing.scope.type ||
|
|
41
|
+
('path' in body.scope ? body.scope.path : body.scope.key) !==
|
|
42
|
+
('path' in existing.scope ? existing.scope.path : existing.scope.key)
|
|
43
|
+
) {
|
|
44
|
+
throw createError({
|
|
45
|
+
statusCode: 400,
|
|
46
|
+
statusMessage: 'Cannot change layout scope via PUT — POST a new layout instead',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const saved = await saveLayout(
|
|
51
|
+
db,
|
|
52
|
+
{
|
|
53
|
+
scope: body.scope,
|
|
54
|
+
name: body.name,
|
|
55
|
+
pageMeta: body.pageMeta,
|
|
56
|
+
zones: body.zones,
|
|
57
|
+
state: body.state,
|
|
58
|
+
},
|
|
59
|
+
{ id, userId: admin.id },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
invalidateLayoutsByRouteCache();
|
|
63
|
+
return saved;
|
|
64
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/admin/layouts
|
|
3
|
+
*
|
|
4
|
+
* List all layouts. Optional `?scope=route|virtual|custom-page` to
|
|
5
|
+
* filter by scope type. Returns the full LayoutRecord shape (nested
|
|
6
|
+
* zones → rows → sections + version + state).
|
|
7
|
+
*
|
|
8
|
+
* Admin + features.admin + features.layoutEngine.
|
|
9
|
+
*/
|
|
10
|
+
import { listLayouts } from '@commonpub/server';
|
|
11
|
+
|
|
12
|
+
export default defineEventHandler(async (event) => {
|
|
13
|
+
requireFeature('admin');
|
|
14
|
+
requireFeature('layoutEngine');
|
|
15
|
+
requireAdmin(event);
|
|
16
|
+
|
|
17
|
+
const db = useDB();
|
|
18
|
+
const query = getQuery(event) as { scope?: string };
|
|
19
|
+
const scopeFilter =
|
|
20
|
+
query.scope === 'route' || query.scope === 'virtual' || query.scope === 'custom-page'
|
|
21
|
+
? query.scope
|
|
22
|
+
: undefined;
|
|
23
|
+
|
|
24
|
+
return listLayouts(db, scopeFilter ? { scopeType: scopeFilter } : undefined);
|
|
25
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/admin/layouts
|
|
3
|
+
*
|
|
4
|
+
* Create a new layout for a scope. Body is the full layout payload
|
|
5
|
+
* (validated against `layoutCreateSchema`) with client-generated UUIDs
|
|
6
|
+
* for sections/rows. Returns the created LayoutRecord.
|
|
7
|
+
*
|
|
8
|
+
* Fails with 409 if a layout already exists at the given scope (the
|
|
9
|
+
* editor should PUT to the existing one instead).
|
|
10
|
+
*
|
|
11
|
+
* Admin + features.admin + features.layoutEngine.
|
|
12
|
+
* Invalidates the layouts-by-route cache on success.
|
|
13
|
+
*/
|
|
14
|
+
import { layoutCreateSchema } from '@commonpub/schema';
|
|
15
|
+
import { getLayoutByScope, saveLayout } from '@commonpub/server';
|
|
16
|
+
import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
|
|
17
|
+
|
|
18
|
+
export default defineEventHandler(async (event) => {
|
|
19
|
+
requireFeature('admin');
|
|
20
|
+
requireFeature('layoutEngine');
|
|
21
|
+
const admin = requireAdmin(event);
|
|
22
|
+
const db = useDB();
|
|
23
|
+
|
|
24
|
+
const body = await parseBody(event, layoutCreateSchema);
|
|
25
|
+
|
|
26
|
+
const existing = await getLayoutByScope(db, body.scope);
|
|
27
|
+
if (existing) {
|
|
28
|
+
throw createError({
|
|
29
|
+
statusCode: 409,
|
|
30
|
+
statusMessage: `A layout already exists for scope ${JSON.stringify(body.scope)}; PUT to /api/admin/layouts/${existing.id} to update it`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const saved = await saveLayout(
|
|
35
|
+
db,
|
|
36
|
+
{
|
|
37
|
+
scope: body.scope,
|
|
38
|
+
name: body.name,
|
|
39
|
+
pageMeta: body.pageMeta,
|
|
40
|
+
zones: body.zones,
|
|
41
|
+
state: body.state,
|
|
42
|
+
},
|
|
43
|
+
{ userId: admin.id },
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
invalidateLayoutsByRouteCache();
|
|
47
|
+
return saved;
|
|
48
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/admin/layouts/seed-homepage
|
|
3
|
+
*
|
|
4
|
+
* Operator's bootstrap call: seeds + publishes a default homepage
|
|
5
|
+
* layout (hero + content-feed) at scope ('route', '/'). Idempotent —
|
|
6
|
+
* safe to call repeatedly.
|
|
7
|
+
*
|
|
8
|
+
* Intended flow for enabling the layout engine on the homepage:
|
|
9
|
+
* 1. Operator runs migration 0005 (if not already applied)
|
|
10
|
+
* 2. Operator POSTs to this endpoint → layout exists, published v1
|
|
11
|
+
* 3. Operator flips `features.layoutEngine` ON
|
|
12
|
+
* 4. Homepage renders via `<LayoutSlot>` (v-if branch in pages/index.vue)
|
|
13
|
+
*
|
|
14
|
+
* NOT the full legacy `homepage.sections` migration — that needs the
|
|
15
|
+
* remaining sections from Phase 6b. Doc'd at
|
|
16
|
+
* `docs/reference/guides/layout-engine.md`.
|
|
17
|
+
*
|
|
18
|
+
* Admin + features.admin + features.layoutEngine.
|
|
19
|
+
* Invalidates the layouts-by-route cache on success.
|
|
20
|
+
*/
|
|
21
|
+
import { seedHomepageLayout } from '@commonpub/server';
|
|
22
|
+
import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
|
|
23
|
+
|
|
24
|
+
export default defineEventHandler(async (event) => {
|
|
25
|
+
requireFeature('admin');
|
|
26
|
+
requireFeature('layoutEngine');
|
|
27
|
+
const admin = requireAdmin(event);
|
|
28
|
+
const db = useDB();
|
|
29
|
+
|
|
30
|
+
const result = await seedHomepageLayout(db, { adminId: admin.id });
|
|
31
|
+
if (result.created) {
|
|
32
|
+
invalidateLayoutsByRouteCache();
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
});
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GET /api/admin/themes/discover
|
|
3
3
|
*
|
|
4
|
-
* Returns the canonical default values for every known token.
|
|
5
|
-
* uses this to diff against `getComputedStyle(:root)` and surface a
|
|
6
|
-
* "your site has a custom theme — capture it?" CTA when the runtime values
|
|
7
|
-
* differ from the built-in defaults.
|
|
4
|
+
* Returns the canonical default values for every known token.
|
|
8
5
|
*
|
|
9
|
-
*
|
|
6
|
+
* **CURRENTLY UNUSED** (as of session 157). The client-side
|
|
7
|
+
* `detectAppliedOverrides()` in `utils/themeDiscovery.ts` uses
|
|
8
|
+
* `TOKEN_SPECS` directly (same data source, no HTTP). The endpoint is
|
|
9
|
+
* kept because:
|
|
10
|
+
* 1. It's documented in `docs/reference/guides/theme-editor.md` as
|
|
11
|
+
* the source of truth for default values.
|
|
12
|
+
* 2. A future Phase (section registry adds new tokens at runtime, or
|
|
13
|
+
* the layer wants to source defaults from the server rather than
|
|
14
|
+
* the client bundle) may flip the client to use this endpoint.
|
|
15
|
+
* 3. Deleting would force a doc revision + breaking-change note for
|
|
16
|
+
* anyone who scripted against it externally.
|
|
17
|
+
*
|
|
18
|
+
* Cost is zero (no DB hit, just returns a static map). Safe to keep.
|
|
10
19
|
*/
|
|
11
20
|
import { TOKEN_SPECS } from '@commonpub/ui';
|
|
12
21
|
|