@commonpub/layer 0.23.3 → 0.24.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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Built-in section definition: hubs.
3
+ *
4
+ * Phase 1c addition (session 159) — trending hubs list. Server-fetches
5
+ * `/api/hubs?limit=N` and renders a sidebar-style card with icon, name,
6
+ * member count, and a Join CTA per hub.
7
+ *
8
+ * Renderer also dispatches join requests via `POST /api/hubs/:slug/join`
9
+ * for authenticated visitors (mirrors legacy `HubsSection.vue`); guests
10
+ * are redirected to `/auth/login?redirect=/`.
11
+ *
12
+ * Defaults to colSpan 4 (sidebar). Set colSpan 12 for a horizontal
13
+ * sweep on a community-discovery page.
14
+ */
15
+ import { z } from 'zod';
16
+ import type { SectionDefinition } from '@commonpub/ui';
17
+ import SectionHubs from '../../components/sections/SectionHubs.vue';
18
+
19
+ const configSchema = z.object({
20
+ heading: z.string().max(120).default('Trending Hubs'),
21
+ limit: z.number().int().min(1).max(20).default(4),
22
+ });
23
+
24
+ export const hubsSection: SectionDefinition<z.infer<typeof configSchema>> = {
25
+ type: 'hubs',
26
+ name: 'Hubs',
27
+ description: 'Trending hubs list with join action (feature-gated)',
28
+ icon: 'fa-users',
29
+ category: 'data',
30
+ status: 'stable',
31
+ // Palette gate — admins on a hubs-disabled instance shouldn't see this
32
+ // type in the section-picker. Runtime gating is separate: each placed
33
+ // instance's `visibility.features` array controls render visibility
34
+ // (LayoutSlot honors it). Setting both here AND in the migration script
35
+ // is the belt-and-braces.
36
+ featureGate: 'hubs',
37
+ configSchema,
38
+ defaultConfig: { heading: 'Trending Hubs', limit: 4 },
39
+ schemaVersion: 1,
40
+ component: SectionHubs,
41
+ minColSpan: 3,
42
+ maxColSpan: 12,
43
+ defaultColSpan: 4,
44
+ resizable: true,
45
+ };
@@ -0,0 +1,38 @@
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 (title,
6
+ * description, difficulty + duration, enrollment count).
7
+ *
8
+ * Feature-gated on `features.learning`. Behaves like editorial /
9
+ * content-feed structurally — a discoverable feed of an entity type.
10
+ */
11
+ import { z } from 'zod';
12
+ import type { SectionDefinition } from '@commonpub/ui';
13
+ import SectionLearning from '../../components/sections/SectionLearning.vue';
14
+
15
+ const configSchema = z.object({
16
+ heading: z.string().max(120).default('Learning Paths'),
17
+ limit: z.number().int().min(1).max(12).default(6),
18
+ columns: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(3),
19
+ });
20
+
21
+ export const learningSection: SectionDefinition<z.infer<typeof configSchema>> = {
22
+ type: 'learning',
23
+ name: 'Learning paths',
24
+ description: 'Grid of learning paths with enrollment + difficulty (feature-gated)',
25
+ icon: 'fa-graduation-cap',
26
+ category: 'data',
27
+ status: 'stable',
28
+ // Palette gate — see hubs.ts for the rationale.
29
+ featureGate: 'learning',
30
+ configSchema,
31
+ defaultConfig: { heading: 'Learning Paths', limit: 6, columns: 3 },
32
+ schemaVersion: 1,
33
+ component: SectionLearning,
34
+ minColSpan: 6,
35
+ maxColSpan: 12,
36
+ defaultColSpan: 12,
37
+ resizable: true,
38
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Built-in section definition: stats.
3
+ *
4
+ * Phase 1c addition (session 159) — platform stats grid. Server-fetches
5
+ * `/api/stats` (PlatformStats) and renders a small numeric grid:
6
+ * Projects, Posts (blog + legacy article), Members, Hubs.
7
+ *
8
+ * Hubs metric is gated on `features.hubs` — when the flag is off, the
9
+ * cell hides + the grid collapses to 1×3. Sidebar-friendly by default
10
+ * (colSpan 4), but works as a narrow row at 12.
11
+ */
12
+ import { z } from 'zod';
13
+ import type { SectionDefinition } from '@commonpub/ui';
14
+ import SectionStats from '../../components/sections/SectionStats.vue';
15
+
16
+ const configSchema = z.object({
17
+ heading: z.string().max(120).default('Platform Stats'),
18
+ });
19
+
20
+ export const statsSection: SectionDefinition<z.infer<typeof configSchema>> = {
21
+ type: 'stats',
22
+ name: 'Platform stats',
23
+ description: 'Numeric grid of platform totals (projects, posts, members, hubs)',
24
+ icon: 'fa-chart-simple',
25
+ category: 'data',
26
+ status: 'stable',
27
+ configSchema,
28
+ defaultConfig: { heading: 'Platform Stats' },
29
+ schemaVersion: 1,
30
+ component: SectionStats,
31
+ // Stat blocks tile down to 2×2 in a quarter-width column comfortably
32
+ minColSpan: 3,
33
+ maxColSpan: 12,
34
+ defaultColSpan: 4,
35
+ resizable: true,
36
+ };
@@ -29,26 +29,46 @@ 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';
32
38
 
33
39
  // Singleton — registered once at module load. Vue/Nuxt's setup() runs
34
40
  // per-component, but module load is once per app process. Safe.
35
41
  const registry = new SectionRegistry();
36
42
 
37
43
  // --- 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).
44
+ // Phase 1c full catalog (session 159): divider (proof-of-life) + 11
45
+ // sections covering the four leading categories — layout (hero,
46
+ // divider), content (heading, paragraph, image, custom-html), and data
47
+ // (content-feed, editorial, stats, hubs, contests, learning).
41
48
  //
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.
49
+ // With the addition of editorial / stats / hubs / contests / learning /
50
+ // custom-html this session, every section type the legacy
51
+ // `homepage.sections` JSON dispatches to (HomepageSectionRenderer.vue)
52
+ // is now representable as a registered section — unblocking the real
53
+ // legacy-homepage migration (Phase 1c step 3 in the session-158 handoff).
54
+ //
55
+ // Phase 6b will add the remaining 18 types (gallery, video, embed,
56
+ // spacer, cta, featured-content, content-card, contest-list, hub-list,
57
+ // event-list, member-list, stats-grid, contact-form, newsletter,
58
+ // announcement, markdown, iframe, content-grid alias). See
59
+ // docs/plans/layout-and-pages.md §3.4.
46
60
  registry.register(dividerSection);
47
61
  registry.register(heroSection);
48
62
  registry.register(headingSection);
49
63
  registry.register(paragraphSection);
50
64
  registry.register(imageSection);
51
65
  registry.register(contentFeedSection);
66
+ registry.register(editorialSection);
67
+ registry.register(statsSection);
68
+ registry.register(hubsSection);
69
+ registry.register(contestsSection);
70
+ registry.register(learningSection);
71
+ registry.register(customHtmlSection);
52
72
 
53
73
  /**
54
74
  * Read-only accessor — the layer's standard pattern for shared state.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * POST /api/admin/layouts/migrate-homepage
3
+ *
4
+ * Converts the operator's legacy `instance_settings.homepage.sections`
5
+ * JSON into a real `layouts` row at scope ('route', '/').
6
+ *
7
+ * Body (all optional):
8
+ * { force?: boolean }
9
+ *
10
+ * Default: skip when a layout already exists at the route (returns
11
+ * `{migrated:false, reason:'layout-already-exists', layoutId}`). With
12
+ * `force: true`, the existing layout + its rows / sections / versions
13
+ * are deleted via FK cascade and replaced.
14
+ *
15
+ * Intended canary flow (replaces the older seed-homepage path for
16
+ * instances that have customised their homepage):
17
+ * 1. Operator runs migration 0005 (if not already applied)
18
+ * 2. Operator POSTs here → layouts row matching the live homepage
19
+ * is created + published
20
+ * 3. Operator flips `features.layoutEngine` ON
21
+ * 4. Homepage SSR renders via LayoutSlot — visually identical, but
22
+ * now sourced from the layouts table instead of homepage.sections
23
+ * 5. Once stable, the legacy `homepage.sections` setting can be
24
+ * removed (separate operator action — this endpoint doesn't
25
+ * delete legacy data)
26
+ *
27
+ * Admin + features.admin + features.layoutEngine. Invalidates the
28
+ * layouts-by-route cache on a successful migration (or force-replace).
29
+ */
30
+ import { z } from 'zod';
31
+ import { migrateHomepageSectionsToLayout } from '@commonpub/server';
32
+ import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
33
+
34
+ const bodySchema = z.object({
35
+ force: z.boolean().optional().default(false),
36
+ });
37
+
38
+ export default defineEventHandler(async (event) => {
39
+ requireFeature('admin');
40
+ requireFeature('layoutEngine');
41
+ const admin = requireAdmin(event);
42
+
43
+ const body = await readBody(event).catch(() => ({}));
44
+ const { force } = bodySchema.parse(body ?? {});
45
+
46
+ const db = useDB();
47
+ const result = await migrateHomepageSectionsToLayout(db, {
48
+ adminId: admin.id,
49
+ force,
50
+ });
51
+
52
+ if (result.migrated) {
53
+ invalidateLayoutsByRouteCache();
54
+ }
55
+ return result;
56
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Nitro plugin — prime each SSR request with DB-merged feature flags.
3
+ *
4
+ * The Vue `useFeatures()` composable (layers/base/composables/useFeatures.ts)
5
+ * initialises its `useState('feature-flags')` from `useRuntimeConfig().public.features`,
6
+ * which is the BUILD-TIME config baked into the bundle. Admin-UI flag
7
+ * overrides land in `instance_settings.features.overrides` and are
8
+ * picked up by `useConfig()` (server-side, DB-merged with 60s cache),
9
+ * but the layer composable never queries that at SSR time — only AFTER
10
+ * client mount, via `$fetch('/api/features')`.
11
+ *
12
+ * Effect of the gap: SSR renders with stale (build-time) flag values.
13
+ * An admin flipping `layoutEngine: true` from off-by-default through the
14
+ * admin UI doesn't take effect for SSR; the first paint shows the
15
+ * v-else-if branch, then hydration replaces it with the v-if branch.
16
+ * Bad UX and breaks curl-based canary verification.
17
+ *
18
+ * Fix: read the merged config at request-start and attach it to
19
+ * `event.context.cpubFeatureFlags`. The Vue composable's `getInitialFlags`
20
+ * (modified alongside this plugin) reads from `useRequestEvent()` context
21
+ * on the server, falling back to the runtime config when context is
22
+ * absent (e.g. island components, error pages).
23
+ *
24
+ * Cost: one call to `useConfig()` per request. The merged config is
25
+ * itself cached (60s TTL on DB overrides), so this is a Map lookup,
26
+ * not a DB hit on the hot path.
27
+ */
28
+ export default defineNitroPlugin((nitroApp) => {
29
+ nitroApp.hooks.hook('request', (event) => {
30
+ try {
31
+ const config = useConfig();
32
+ event.context.cpubFeatureFlags = config.features;
33
+ } catch {
34
+ // useConfig() throws at startup when the DB isn't ready yet.
35
+ // Leave context.cpubFeatureFlags unset → composable falls back to
36
+ // build-time runtime config (same behavior as before this plugin).
37
+ }
38
+ });
39
+ });