@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
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
|
-
|
|
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
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<NuxtLink to="/admin
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<NuxtLink to="/admin/
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<NuxtLink to="/admin/
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
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>
|
package/pages/admin/homepage.vue
CHANGED
|
@@ -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>
|
package/pages/admin/index.vue
CHANGED
|
@@ -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>
|