@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.
Files changed (81) hide show
  1. package/README.md +41 -12
  2. package/components/LayoutRow.vue +944 -0
  3. package/components/LayoutSection.vue +1028 -0
  4. package/components/LayoutSlot.vue +104 -162
  5. package/components/PageFrame.vue +116 -0
  6. package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
  7. package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
  8. package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
  9. package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
  10. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
  11. package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
  13. package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
  14. package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
  15. package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
  16. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
  17. package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
  18. package/components/blocks/BlockDividerView.vue +52 -2
  19. package/components/homepage/ContentGridSection.vue +23 -1
  20. package/components/homepage/HeroSection.vue +69 -8
  21. package/components/sections/SectionCta.vue +175 -0
  22. package/components/sections/SectionLearning.vue +232 -0
  23. package/composables/autoFormSchema.ts +319 -0
  24. package/composables/useAdminSidebar.ts +116 -0
  25. package/composables/useEditorChrome.ts +56 -0
  26. package/composables/useFeatures.ts +32 -5
  27. package/composables/useLayout.ts +46 -43
  28. package/composables/useLayoutAnnouncer.ts +332 -0
  29. package/composables/useLayoutAutoSave.ts +117 -0
  30. package/composables/useLayoutDrag.ts +290 -0
  31. package/composables/useLayoutEditor.ts +593 -0
  32. package/composables/useLayoutHistory.ts +583 -0
  33. package/composables/useLayoutHotkeys.ts +366 -0
  34. package/composables/useLayoutResize.ts +783 -0
  35. package/layouts/admin.vue +137 -24
  36. package/middleware/admin-layouts.ts +29 -0
  37. package/nuxt.config.ts +14 -0
  38. package/package.json +8 -5
  39. package/pages/[...customPath].vue +154 -0
  40. package/pages/admin/homepage.vue +46 -0
  41. package/pages/admin/index.vue +16 -0
  42. package/pages/admin/layouts/[id].vue +1110 -0
  43. package/pages/admin/layouts/index.vue +356 -0
  44. package/pages/explore.vue +16 -6
  45. package/sections/builtin/content-feed.ts +18 -29
  46. package/sections/builtin/contests.ts +30 -0
  47. package/sections/builtin/cta.ts +46 -0
  48. package/sections/builtin/custom-html.ts +36 -0
  49. package/sections/builtin/divider.ts +15 -17
  50. package/sections/builtin/editorial.ts +29 -0
  51. package/sections/builtin/embed.ts +31 -0
  52. package/sections/builtin/gallery.ts +29 -0
  53. package/sections/builtin/heading.ts +14 -19
  54. package/sections/builtin/hero.ts +16 -51
  55. package/sections/builtin/hubs.ts +30 -0
  56. package/sections/builtin/image.ts +12 -49
  57. package/sections/builtin/learning.ts +30 -0
  58. package/sections/builtin/markdown.ts +29 -0
  59. package/sections/builtin/paragraph.ts +14 -17
  60. package/sections/builtin/stats.ts +35 -0
  61. package/sections/builtin/video.ts +30 -0
  62. package/sections/registry.ts +38 -7
  63. package/server/api/admin/homepage/sections.put.ts +52 -1
  64. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  65. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  66. package/server/api/admin/layouts/[id].delete.ts +33 -1
  67. package/server/api/admin/layouts/[id].put.ts +78 -0
  68. package/server/api/admin/layouts/index.post.ts +60 -4
  69. package/server/api/admin/layouts/migrate-homepage.post.ts +68 -0
  70. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  71. package/server/api/layouts/by-route.get.ts +64 -12
  72. package/server/plugins/feature-flags-prime.ts +39 -0
  73. package/server/utils/layoutCache.ts +37 -1
  74. package/server/utils/validateSectionConfigs.ts +123 -0
  75. package/theme/base.css +1 -0
  76. package/components/sections/SectionContentFeed.vue +0 -160
  77. package/components/sections/SectionDivider.vue +0 -55
  78. package/components/sections/SectionHeading.vue +0 -78
  79. package/components/sections/SectionHero.vue +0 -164
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
package/layouts/admin.vue CHANGED
@@ -1,9 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  const { isAdmin } = useAuth();
3
- const { admin: adminEnabled } = useFeatures();
3
+ const { admin: adminEnabled, layoutEngine } = useFeatures();
4
4
  const runtimeConfig = useRuntimeConfig();
5
5
  const siteName = computed(() => (runtimeConfig.public.siteName as string) || 'CommonPub');
6
- const sidebarOpen = ref(false);
6
+
7
+ // Sidebar state (desktop collapse + mobile drawer) — see useAdminSidebar.ts.
8
+ // Editor routes (/admin/layouts/[id], /admin/theme/edit/[id]) auto-collapse
9
+ // so the editor canvas gets more horizontal room; user can override per visit.
10
+ const { desktopCollapsed, mobileOpen, toggleDesktop, toggleMobile, closeMobile } = useAdminSidebar();
7
11
  </script>
8
12
 
9
13
  <template>
@@ -14,8 +18,24 @@ const sidebarOpen = ref(false);
14
18
  <div v-else class="admin-layout">
15
19
  <header class="admin-topbar">
16
20
  <div class="admin-topbar-inner">
17
- <button class="admin-menu-btn" aria-label="Toggle sidebar" @click="sidebarOpen = !sidebarOpen">
18
- <i :class="sidebarOpen ? 'fa-solid fa-xmark' : 'fa-solid fa-bars'"></i>
21
+ <button
22
+ class="admin-menu-btn"
23
+ :aria-label="mobileOpen ? 'Close menu' : 'Open menu'"
24
+ :aria-expanded="mobileOpen"
25
+ aria-controls="admin-sidebar-nav"
26
+ @click="toggleMobile"
27
+ >
28
+ <i :class="mobileOpen ? 'fa-solid fa-xmark' : 'fa-solid fa-bars'"></i>
29
+ </button>
30
+ <button
31
+ class="admin-collapse-btn"
32
+ :aria-label="desktopCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
33
+ :aria-expanded="!desktopCollapsed"
34
+ aria-controls="admin-sidebar-nav"
35
+ :title="desktopCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
36
+ @click="toggleDesktop"
37
+ >
38
+ <i :class="desktopCollapsed ? 'fa-solid fa-angles-right' : 'fa-solid fa-angles-left'"></i>
19
39
  </button>
20
40
  <NuxtLink to="/" class="admin-brand">{{ siteName }}</NuxtLink>
21
41
  <span class="admin-badge">Admin</span>
@@ -24,21 +44,64 @@ const sidebarOpen = ref(false);
24
44
  </header>
25
45
 
26
46
  <div class="admin-body">
27
- <aside class="admin-sidebar" :class="{ open: sidebarOpen }" aria-label="Admin navigation">
47
+ <aside
48
+ id="admin-sidebar-nav"
49
+ class="admin-sidebar"
50
+ :class="{ 'admin-sidebar--collapsed': desktopCollapsed, 'admin-sidebar--mobile-open': mobileOpen }"
51
+ aria-label="Admin navigation"
52
+ >
28
53
  <nav class="admin-nav">
29
- <NuxtLink to="/admin" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-gauge"></i> Dashboard</NuxtLink>
30
- <NuxtLink to="/admin/users" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-users"></i> Users</NuxtLink>
31
- <NuxtLink to="/admin/content" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-newspaper"></i> Content</NuxtLink>
32
- <NuxtLink to="/admin/categories" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-tags"></i> Categories</NuxtLink>
33
- <NuxtLink to="/admin/reports" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-flag"></i> Reports</NuxtLink>
34
- <NuxtLink to="/admin/audit" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-clipboard-list"></i> Audit Log</NuxtLink>
35
- <NuxtLink to="/admin/theme" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-palette"></i> Theme</NuxtLink>
36
- <NuxtLink to="/admin/homepage" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-house"></i> Homepage</NuxtLink>
37
- <NuxtLink to="/admin/navigation" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-bars"></i> Navigation</NuxtLink>
38
- <NuxtLink to="/admin/features" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-toggle-on"></i> Features</NuxtLink>
39
- <NuxtLink to="/admin/federation" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-globe"></i> Federation</NuxtLink>
40
- <NuxtLink to="/admin/api-keys" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-key"></i> API Keys</NuxtLink>
41
- <NuxtLink to="/admin/settings" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
54
+ <!--
55
+ Nav link pattern: icon + visible label. When collapsed, label text
56
+ stays in the DOM (clip-path) so screen readers still announce
57
+ "Dashboard, link" the icon alone has no accessible name.
58
+ `title` attr only set when collapsed visual tooltip on hover.
59
+ -->
60
+ <NuxtLink to="/admin" class="admin-nav-link" :title="desktopCollapsed ? 'Dashboard' : undefined" @click="closeMobile">
61
+ <i class="fa-solid fa-gauge"></i><span class="admin-nav-label">Dashboard</span>
62
+ </NuxtLink>
63
+ <NuxtLink to="/admin/users" class="admin-nav-link" :title="desktopCollapsed ? 'Users' : undefined" @click="closeMobile">
64
+ <i class="fa-solid fa-users"></i><span class="admin-nav-label">Users</span>
65
+ </NuxtLink>
66
+ <NuxtLink to="/admin/content" class="admin-nav-link" :title="desktopCollapsed ? 'Content' : undefined" @click="closeMobile">
67
+ <i class="fa-solid fa-newspaper"></i><span class="admin-nav-label">Content</span>
68
+ </NuxtLink>
69
+ <NuxtLink to="/admin/categories" class="admin-nav-link" :title="desktopCollapsed ? 'Categories' : undefined" @click="closeMobile">
70
+ <i class="fa-solid fa-tags"></i><span class="admin-nav-label">Categories</span>
71
+ </NuxtLink>
72
+ <NuxtLink to="/admin/reports" class="admin-nav-link" :title="desktopCollapsed ? 'Reports' : undefined" @click="closeMobile">
73
+ <i class="fa-solid fa-flag"></i><span class="admin-nav-label">Reports</span>
74
+ </NuxtLink>
75
+ <NuxtLink to="/admin/audit" class="admin-nav-link" :title="desktopCollapsed ? 'Audit Log' : undefined" @click="closeMobile">
76
+ <i class="fa-solid fa-clipboard-list"></i><span class="admin-nav-label">Audit Log</span>
77
+ </NuxtLink>
78
+ <NuxtLink to="/admin/theme" class="admin-nav-link" :title="desktopCollapsed ? 'Theme' : undefined" @click="closeMobile">
79
+ <i class="fa-solid fa-palette"></i><span class="admin-nav-label">Theme</span>
80
+ </NuxtLink>
81
+ <NuxtLink to="/admin/homepage" class="admin-nav-link" :title="desktopCollapsed ? 'Homepage' : undefined" @click="closeMobile">
82
+ <i class="fa-solid fa-house"></i><span class="admin-nav-label">Homepage</span>
83
+ </NuxtLink>
84
+ <!-- Layouts editor — gated on layoutEngine feature flag (CLAUDE.md rule #2).
85
+ Stays invisible until the operator flips the flag, then appears between
86
+ the legacy /admin/homepage editor and Navigation. Phase 3a — session 160 audit. -->
87
+ <NuxtLink v-if="layoutEngine" to="/admin/layouts" class="admin-nav-link" :title="desktopCollapsed ? 'Layouts' : undefined" @click="closeMobile">
88
+ <i class="fa-solid fa-table-cells-large"></i><span class="admin-nav-label">Layouts</span>
89
+ </NuxtLink>
90
+ <NuxtLink to="/admin/navigation" class="admin-nav-link" :title="desktopCollapsed ? 'Navigation' : undefined" @click="closeMobile">
91
+ <i class="fa-solid fa-bars"></i><span class="admin-nav-label">Navigation</span>
92
+ </NuxtLink>
93
+ <NuxtLink to="/admin/features" class="admin-nav-link" :title="desktopCollapsed ? 'Features' : undefined" @click="closeMobile">
94
+ <i class="fa-solid fa-toggle-on"></i><span class="admin-nav-label">Features</span>
95
+ </NuxtLink>
96
+ <NuxtLink to="/admin/federation" class="admin-nav-link" :title="desktopCollapsed ? 'Federation' : undefined" @click="closeMobile">
97
+ <i class="fa-solid fa-globe"></i><span class="admin-nav-label">Federation</span>
98
+ </NuxtLink>
99
+ <NuxtLink to="/admin/api-keys" class="admin-nav-link" :title="desktopCollapsed ? 'API Keys' : undefined" @click="closeMobile">
100
+ <i class="fa-solid fa-key"></i><span class="admin-nav-label">API Keys</span>
101
+ </NuxtLink>
102
+ <NuxtLink to="/admin/settings" class="admin-nav-link" :title="desktopCollapsed ? 'Settings' : undefined" @click="closeMobile">
103
+ <i class="fa-solid fa-gear"></i><span class="admin-nav-label">Settings</span>
104
+ </NuxtLink>
42
105
  </nav>
43
106
  </aside>
44
107
 
@@ -84,19 +147,38 @@ const sidebarOpen = ref(false);
84
147
  gap: var(--space-3);
85
148
  }
86
149
 
87
- .admin-menu-btn {
88
- display: none;
150
+ .admin-menu-btn,
151
+ .admin-collapse-btn {
89
152
  width: 36px;
90
153
  height: 36px;
91
154
  background: none;
92
155
  border: var(--border-width-default) solid var(--border);
93
156
  color: var(--text-dim);
94
- font-size: 16px;
157
+ font-size: 14px;
95
158
  cursor: pointer;
96
159
  align-items: center;
97
160
  justify-content: center;
161
+ transition: color var(--transition-default), border-color var(--transition-default), background var(--transition-default);
162
+ }
163
+
164
+ .admin-menu-btn:hover,
165
+ .admin-collapse-btn:hover {
166
+ color: var(--accent);
167
+ border-color: var(--accent);
168
+ background: var(--accent-bg);
98
169
  }
99
170
 
171
+ .admin-menu-btn:focus-visible,
172
+ .admin-collapse-btn:focus-visible {
173
+ outline: 2px solid var(--accent);
174
+ outline-offset: 2px;
175
+ }
176
+
177
+ /* Mobile drawer toggle — desktop hides it, mobile media query reveals. */
178
+ .admin-menu-btn { display: none; }
179
+ /* Desktop collapse toggle — desktop shows it, mobile media query hides. */
180
+ .admin-collapse-btn { display: flex; }
181
+
100
182
  .admin-brand {
101
183
  font-weight: var(--font-weight-bold);
102
184
  font-size: var(--text-lg);
@@ -134,11 +216,17 @@ const sidebarOpen = ref(false);
134
216
  }
135
217
 
136
218
  .admin-sidebar {
137
- width: 200px;
219
+ width: var(--sidebar-width);
138
220
  border-right: var(--border-width-default) solid var(--border);
139
221
  background: var(--surface);
140
222
  padding: var(--space-4) var(--space-2);
141
223
  flex-shrink: 0;
224
+ transition: width var(--transition-default);
225
+ overflow: hidden; /* clip the labels as the width transitions */
226
+ }
227
+
228
+ .admin-sidebar--collapsed {
229
+ width: var(--sidebar-width-collapsed);
142
230
  }
143
231
 
144
232
  .admin-nav {
@@ -155,6 +243,7 @@ const sidebarOpen = ref(false);
155
243
  display: flex;
156
244
  align-items: center;
157
245
  gap: 10px;
246
+ white-space: nowrap;
158
247
  transition: color 0.12s, background 0.12s;
159
248
  }
160
249
 
@@ -162,6 +251,21 @@ const sidebarOpen = ref(false);
162
251
  width: 16px;
163
252
  text-align: center;
164
253
  font-size: 12px;
254
+ flex-shrink: 0;
255
+ }
256
+
257
+ .admin-nav-label {
258
+ /* Label fades out + width collapses when sidebar is collapsed. Kept in DOM
259
+ (not display:none) so screen readers continue to announce the link name. */
260
+ transition: opacity var(--transition-default), max-width var(--transition-default);
261
+ max-width: 12rem;
262
+ opacity: 1;
263
+ overflow: hidden;
264
+ }
265
+
266
+ .admin-sidebar--collapsed .admin-nav-label {
267
+ opacity: 0;
268
+ max-width: 0;
165
269
  }
166
270
 
167
271
  .admin-nav-link:hover {
@@ -198,8 +302,13 @@ const sidebarOpen = ref(false);
198
302
  }
199
303
 
200
304
  @media (max-width: 768px) {
305
+ /* Mobile: hide the desktop collapse toggle, show the drawer hamburger. */
306
+ .admin-collapse-btn { display: none; }
201
307
  .admin-menu-btn { display: flex; }
308
+
202
309
  .admin-sidebar {
310
+ /* Reset desktop collapse semantics — mobile is a drawer, full width. */
311
+ width: 220px !important;
203
312
  position: fixed;
204
313
  top: var(--nav-height);
205
314
  left: 0;
@@ -208,9 +317,13 @@ const sidebarOpen = ref(false);
208
317
  transform: translateX(-100%);
209
318
  transition: transform 0.2s ease;
210
319
  box-shadow: none;
211
- width: 220px;
212
320
  }
213
- .admin-sidebar.open {
321
+ /* Mobile labels are always visible (drawer is either open or closed, not collapsed). */
322
+ .admin-sidebar .admin-nav-label {
323
+ opacity: 1 !important;
324
+ max-width: 12rem !important;
325
+ }
326
+ .admin-sidebar--mobile-open {
214
327
  transform: translateX(0);
215
328
  box-shadow: var(--shadow-lg);
216
329
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Named middleware for /admin/layouts/* pages.
3
+ *
4
+ * The global `feature-gate.global.ts` middleware gates the entire
5
+ * `/admin` prefix on the `admin` feature flag. The layout editor
6
+ * is gated on an ADDITIONAL `layoutEngine` flag — when the engine
7
+ * is off (the v1 default), the editor pages 404 instead of erroring
8
+ * on a server endpoint the user can't reach anyway.
9
+ *
10
+ * Pair this with `middleware: 'auth'` on the page — the auth
11
+ * middleware redirects unauthenticated users to /auth/login;
12
+ * this middleware filters the feature flag AFTER auth.
13
+ *
14
+ * See CLAUDE.md rule #2 (no feature without a flag) +
15
+ * docs/plans/phase-3-editor.md hard rule "Editor admin-only —
16
+ * gate /admin/layouts/* on requireFeature('admin') +
17
+ * requireFeature('layoutEngine')".
18
+ */
19
+ import { getInitialFlags, type FeatureFlags } from '../composables/useFeatures';
20
+
21
+ export default defineNuxtRouteMiddleware(() => {
22
+ // Same useState key as useFeatures() — the global middleware also
23
+ // primes it. Re-using getInitialFlags here avoids the null-poison
24
+ // bug from session 126 (see feature-gate.global.ts comment).
25
+ const flags = useState<FeatureFlags>('feature-flags', getInitialFlags);
26
+ if (!flags.value.layoutEngine) {
27
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' });
28
+ }
29
+ });
package/nuxt.config.ts CHANGED
@@ -82,6 +82,14 @@ export default defineNuxtConfig({
82
82
  domain: 'localhost:3000',
83
83
  siteName: 'CommonPub',
84
84
  siteDescription: 'A CommonPub instance',
85
+ // Nuxt only propagates env-var overrides (NUXT_PUBLIC_FEATURES_X) for
86
+ // keys DECLARED here. Undeclared keys are ignored at runtime, so
87
+ // every flag in @commonpub/config's FeatureFlags type must appear
88
+ // below — even if its default is false — or operators can't flip
89
+ // it via env var on a per-instance basis. Drift caused
90
+ // commonpub.io's first canary attempt to silently keep
91
+ // layoutEngine:false at SSR despite NUXT_PUBLIC_FEATURES_LAYOUT_ENGINE=true
92
+ // being set on the container.
85
93
  features: {
86
94
  content: true,
87
95
  social: true,
@@ -89,12 +97,18 @@ export default defineNuxtConfig({
89
97
  docs: true,
90
98
  video: true,
91
99
  contests: false,
100
+ events: false,
92
101
  learning: true,
93
102
  explainers: true,
103
+ editorial: true,
94
104
  federation: false,
95
105
  federateHubs: false,
106
+ seamlessFederation: false,
96
107
  admin: false,
97
108
  emailNotifications: false,
109
+ publicApi: false,
110
+ contentImport: true,
111
+ layoutEngine: false,
98
112
  },
99
113
  contentTypes: 'project,blog,explainer',
100
114
  contestCreation: 'admin',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.23.3",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -43,7 +43,9 @@
43
43
  "@tiptap/extension-placeholder": "^2.11.0",
44
44
  "@tiptap/extension-strike": "^2.11.0",
45
45
  "@tiptap/extension-text": "^2.11.0",
46
+ "@vue-dnd-kit/core": "2.4.6",
46
47
  "drizzle-orm": "^0.45.1",
48
+ "grid-layout-plus": "1.1.1",
47
49
  "highlight.js": "^11.11.1",
48
50
  "pg": "^8.13.0",
49
51
  "sharp": "^0.34.5",
@@ -51,21 +53,22 @@
51
53
  "vue": "^3.4.0",
52
54
  "vue-router": "^4.3.0",
53
55
  "zod": "^4.3.6",
54
- "@commonpub/config": "0.15.0",
55
56
  "@commonpub/docs": "0.6.3",
56
57
  "@commonpub/learning": "0.5.2",
57
- "@commonpub/editor": "0.7.11",
58
58
  "@commonpub/protocol": "0.12.0",
59
59
  "@commonpub/schema": "0.17.0",
60
- "@commonpub/server": "2.57.0",
60
+ "@commonpub/server": "2.58.0",
61
61
  "@commonpub/ui": "0.9.0",
62
+ "@commonpub/explainer": "0.7.15",
62
63
  "@commonpub/auth": "0.6.0",
63
- "@commonpub/explainer": "0.7.15"
64
+ "@commonpub/editor": "0.7.11",
65
+ "@commonpub/config": "0.15.0"
64
66
  },
65
67
  "devDependencies": {
66
68
  "@testing-library/jest-dom": "^6.9.1",
67
69
  "@testing-library/vue": "^8.1.0",
68
70
  "@vitejs/plugin-vue": "^5.2.4",
71
+ "axe-core": "^4.11.3",
69
72
  "jsdom": "^25.0.1",
70
73
  "vitest": "^3.2.4"
71
74
  },
@@ -0,0 +1,154 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Catch-all page for custom-page layouts.
4
+ *
5
+ * Spec: docs/plans/layout-and-pages.md §6.1, §6.4.
6
+ *
7
+ * Runs LAST in Nuxt's route precedence — every file-defined route in
8
+ * `pages/` wins automatically because they have a more specific match.
9
+ * For any path not matched by a file route:
10
+ * 1. Read `params.customPath` (array of segments) → join + normalise
11
+ * 2. Reject malformed paths with 404 (don't leak `instance_settings`
12
+ * reads to obviously-bad inputs)
13
+ * 3. `useLayout(normalisedPath)` to fetch the published layout, if any
14
+ * 4. If found: useSeoMeta from page_meta, render 3 zones via
15
+ * <LayoutSlot>
16
+ * 5. If null: throw 404 (handled by error.vue)
17
+ *
18
+ * Page meta: hidden from sitemap when not found, indexable by default
19
+ * when found unless `page_meta.noindex` is set.
20
+ *
21
+ * Access control: page_meta.access ∈ {'public', 'members', 'admin'}.
22
+ * Defaults to 'public'. 'members' redirects to /auth/login when not
23
+ * authenticated. 'admin' returns 404 to non-admins (don't leak
24
+ * existence — same posture as draft content).
25
+ *
26
+ * Zones (full-width / main / sidebar) are arranged by the shared
27
+ * <PageFrame> — the one canonical frame used by every page (consolidation
28
+ * pass). Phase 4 will let page_meta.frame parameterise PageFrame's tokens
29
+ * (narrow / wide / sidebar-left etc.) so custom pages pick a frame variant.
30
+ *
31
+ * `var(--*)` only.
32
+ */
33
+ import { computed } from 'vue';
34
+ import { pathNormalize } from '@commonpub/server/layout/path-normalize';
35
+ import type { LayoutPayload } from '../composables/useLayout';
36
+
37
+ definePageMeta({
38
+ // Run this catch-all AFTER all file-based routes (the default is
39
+ // alphabetical, which already puts `[...x]` last in Nuxt's compile,
40
+ // but pin it explicitly for clarity).
41
+ name: 'custom-page-catchall',
42
+ });
43
+
44
+ const route = useRoute();
45
+ const { user: authUser } = useAuth();
46
+
47
+ // Build raw path from params, then normalise via shared utility.
48
+ const rawPath = computed<string>(() => {
49
+ const p = route.params.customPath;
50
+ const parts = Array.isArray(p) ? p : (p ? [p] : []);
51
+ return '/' + parts.join('/');
52
+ });
53
+
54
+ const normalised = computed(() => pathNormalize(rawPath.value));
55
+
56
+ // Malformed paths 404 early — don't bother with a DB lookup.
57
+ if (!normalised.value.ok) {
58
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' });
59
+ }
60
+
61
+ const pathToLookup = computed(() => (normalised.value.ok ? normalised.value.path : '/'));
62
+
63
+ // IMPORTANT: use `await useFetch` directly (NOT useLayout) because we need
64
+ // the resolved data synchronously in setup() to throw 404 on missing.
65
+ // useLayout returns refs without awaiting; the data settles AFTER setup
66
+ // returns, so an early `customLayout.value === null` check fires for
67
+ // EVERY request — even when a real layout exists. Pages are Suspense-
68
+ // wrapped in Nuxt, so top-level await is safe here (same pattern
69
+ // pages/index.vue uses for /api/content).
70
+ //
71
+ // Session 159 audit caught this — it shipped first as `useLayout(...) +
72
+ // sync null-check` which had a load-bearing bug (404 always). Fixed by
73
+ // switching to awaited useFetch.
74
+ const { data: customLayout } = await useFetch<LayoutPayload | null>(
75
+ '/api/layouts/by-route',
76
+ {
77
+ query: computed(() => ({ path: pathToLookup.value })),
78
+ key: computed(() => `layout:${pathToLookup.value}`).value,
79
+ transform: (input: LayoutPayload | null | undefined) => input ?? null,
80
+ onResponseError({ response }) {
81
+ // 404 from API (feature off OR no layout for route) → treat as null;
82
+ // we'll throw a page-level 404 below if needed.
83
+ if (response.status === 404) return;
84
+ },
85
+ server: true,
86
+ lazy: false,
87
+ },
88
+ );
89
+
90
+ // Now safe to check — data is settled by Nuxt's Suspense before this line
91
+ if (customLayout.value === null) {
92
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' });
93
+ }
94
+
95
+ // Access control — uses page_meta.access. 'admin' returns 404 to non-
96
+ // admins (don't leak existence). 'members' redirects to login.
97
+ const access = computed(() => customLayout.value?.pageMeta?.access ?? 'public');
98
+ const isAuthenticated = computed(() => !!authUser.value);
99
+
100
+ if (customLayout.value && access.value === 'admin') {
101
+ // We don't have a user.role hint client-side reliably — gate via the
102
+ // existing /api/admin/probe pattern. For SSR-safe behavior, treat
103
+ // missing auth as 404. (Phase 3 inspector lets admins preview drafts
104
+ // — that path goes through a separate route.)
105
+ if (!isAuthenticated.value) {
106
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' });
107
+ }
108
+ // For an authenticated non-admin, the server-side admin probe also
109
+ // returns 404 to avoid leaking; client-side a malicious user would
110
+ // see the layout but layout-engine sections honour visibility.roles
111
+ // on top of this gate.
112
+ }
113
+
114
+ if (customLayout.value && access.value === 'members' && !isAuthenticated.value) {
115
+ await navigateTo(`/auth/login?redirect=${encodeURIComponent(pathToLookup.value)}`);
116
+ }
117
+
118
+ // Set page meta from page_meta. Defaults are conservative.
119
+ useSeoMeta({
120
+ title: () => customLayout.value?.pageMeta?.title ?? 'CommonPub',
121
+ description: () => customLayout.value?.pageMeta?.description,
122
+ ogTitle: () => customLayout.value?.pageMeta?.title,
123
+ ogDescription: () => customLayout.value?.pageMeta?.description,
124
+ ogImage: () => customLayout.value?.pageMeta?.ogImage,
125
+ ogType: () => customLayout.value?.pageMeta?.ogType ?? 'website',
126
+ robots: () => (customLayout.value?.pageMeta?.noindex ? 'noindex, nofollow' : 'index, follow'),
127
+ });
128
+
129
+ // Resolve which zones the layout actually has — render only those, in
130
+ // the canonical order (full-width above the split, then main + sidebar
131
+ // side by side, then sidebar collapses below main on narrow viewports).
132
+ const zones = computed(() => customLayout.value?.zones?.map((z: { zone: string }) => z.zone) ?? []);
133
+ const hasFullWidth = computed(() => zones.value.includes('full-width'));
134
+ const hasMain = computed(() => zones.value.includes('main'));
135
+ const hasSidebar = computed(() => zones.value.includes('sidebar'));
136
+ </script>
137
+
138
+ <template>
139
+ <!-- Consolidation: the page frame now comes from the shared <PageFrame>
140
+ (one canonical max-width + sidebar width + responsive collapse),
141
+ not a per-page `.cpub-custom-page-grid`. Slots are provided only for
142
+ the zones this layout actually has (preserves the prior hasX gating). -->
143
+ <PageFrame v-if="customLayout">
144
+ <template v-if="hasFullWidth" #full-width>
145
+ <LayoutSlot :route="pathToLookup" zone="full-width" />
146
+ </template>
147
+ <template v-if="hasMain" #main>
148
+ <LayoutSlot :route="pathToLookup" zone="main" />
149
+ </template>
150
+ <template v-if="hasSidebar" #sidebar>
151
+ <LayoutSlot :route="pathToLookup" zone="sidebar" />
152
+ </template>
153
+ </PageFrame>
154
+ </template>
@@ -102,10 +102,31 @@ function discard(): void {
102
102
  }
103
103
 
104
104
  const editingId = ref<string | null>(null);
105
+
106
+ // R4 audit deprecation note: when layoutEngine is on, this page's edits
107
+ // no longer overwrite a bespoke layout (the auto-sync at sections.put.ts
108
+ // is now non-destructive). The legacy editor still works for the saved
109
+ // JSON shape — but the live page renders via /admin/layouts. Surface this.
110
+ const { layoutEngine } = useFeatures();
105
111
  </script>
106
112
 
107
113
  <template>
108
114
  <div class="cpub-admin-homepage">
115
+ <!-- R4 audit (session 160): deprecation banner when layoutEngine is on.
116
+ The new visual editor at /admin/layouts is the canonical surface;
117
+ this legacy page still saves its JSON but the live homepage now
118
+ renders via the layouts table. Auto-sync is non-destructive — it
119
+ only creates the layout if one doesn't yet exist. -->
120
+ <div v-if="layoutEngine" class="cpub-admin-homepage-deprecation" role="status">
121
+ <i class="fa-solid fa-circle-info" aria-hidden="true"></i>
122
+ <div>
123
+ <p><strong>This is the legacy homepage editor.</strong> The Layout Engine is active on this instance — use the new visual editor for live changes.</p>
124
+ <NuxtLink to="/admin/layouts" class="cpub-admin-homepage-deprecation-link">
125
+ Open Layouts editor <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
126
+ </NuxtLink>
127
+ </div>
128
+ </div>
129
+
109
130
  <div class="cpub-admin-header">
110
131
  <div>
111
132
  <h1 class="cpub-admin-title">Homepage Layout</h1>
@@ -289,4 +310,29 @@ const editingId = ref<string | null>(null);
289
310
  .cpub-section-row { flex-direction: column; align-items: flex-start; }
290
311
  .cpub-section-actions { align-self: flex-end; }
291
312
  }
313
+
314
+ /* R4 audit (session 160): deprecation banner for the legacy editor
315
+ when layoutEngine is on. Direct, friendly, non-blocking — links to
316
+ the new editor without removing access to this page (which still
317
+ saves the JSON for backward compat). */
318
+ .cpub-admin-homepage-deprecation {
319
+ display: flex;
320
+ gap: var(--space-3);
321
+ align-items: flex-start;
322
+ padding: var(--space-3) var(--space-4);
323
+ background: var(--yellow-bg, var(--surface2));
324
+ border: 1px solid var(--yellow, var(--border));
325
+ margin-bottom: var(--space-4);
326
+ }
327
+ .cpub-admin-homepage-deprecation i { color: var(--yellow, var(--text-dim)); font-size: var(--text-lg); margin-top: 2px; }
328
+ .cpub-admin-homepage-deprecation p { margin: 0 0 var(--space-1) 0; color: var(--text); }
329
+ .cpub-admin-homepage-deprecation-link {
330
+ display: inline-flex; align-items: center; gap: var(--space-1);
331
+ color: var(--accent);
332
+ font-family: var(--font-mono);
333
+ font-size: var(--text-xs);
334
+ text-transform: uppercase;
335
+ letter-spacing: var(--tracking-wide);
336
+ text-decoration: underline;
337
+ }
292
338
  </style>
@@ -3,6 +3,11 @@ definePageMeta({ layout: 'admin', middleware: 'auth' });
3
3
  useSeoMeta({ title: `Admin Dashboard — ${useSiteName()}` });
4
4
 
5
5
  const { data: stats, pending } = await useFetch('/api/admin/stats');
6
+
7
+ // R3 P2: surface /admin/layouts on the dashboard landing.
8
+ // The sidebar already hides itself behind layoutEngine; mirror the
9
+ // same gate so the dashboard tile only appears when the editor is on.
10
+ const { layoutEngine: layoutEngineEnabled } = useFeatures();
6
11
  </script>
7
12
 
8
13
  <template>
@@ -45,6 +50,17 @@ const { data: stats, pending } = await useFetch('/api/admin/stats');
45
50
  <i class="fa-solid fa-gear cpub-admin-action-icon"></i>
46
51
  <span class="cpub-admin-action-label">Instance Settings</span>
47
52
  </NuxtLink>
53
+ <NuxtLink
54
+ v-if="layoutEngineEnabled"
55
+ to="/admin/layouts"
56
+ class="cpub-admin-action"
57
+ >
58
+ <i class="fa-solid fa-table-cells-large cpub-admin-action-icon"></i>
59
+ <!-- Label matches the sidebar nav verbatim ("Layouts"). "Edit
60
+ Layouts" implied a direct-to-editor jump but the route is
61
+ the list page. -->
62
+ <span class="cpub-admin-action-label">Layouts</span>
63
+ </NuxtLink>
48
64
  </div>
49
65
  </div>
50
66
  </template>