@commonpub/layer 0.24.0 → 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/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useLayout.ts +34 -41
- 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/package.json +10 -7
- 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 +11 -19
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +16 -30
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +11 -21
- 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 +11 -26
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +5 -13
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +17 -18
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +11 -0
- 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 +12 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- 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/SectionContests.vue +0 -193
- package/components/sections/SectionCustomHtml.vue +0 -70
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionEditorial.vue +0 -138
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionHubs.vue +0 -247
- package/components/sections/SectionImage.vue +0 -104
- package/components/sections/SectionParagraph.vue +0 -55
- package/components/sections/SectionStats.vue +0 -151
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-section config validation — runs every section's registered
|
|
3
|
+
* Zod schema against the user-submitted config blob.
|
|
4
|
+
*
|
|
5
|
+
* P1 security fix from session 160 audit, finally wired in session 161
|
|
6
|
+
* after the schemas moved to `@commonpub/schema/sectionConfigs`. The
|
|
7
|
+
* `layoutCreateSchema` top-level Zod only validates the SHAPE of
|
|
8
|
+
* `section.config` as `z.record(z.unknown())` — it doesn't enforce
|
|
9
|
+
* per-type rules (URL guards on hrefs, size caps on arrays, etc)
|
|
10
|
+
* declared in each section's `configSchema`. Without this check,
|
|
11
|
+
* an admin can bypass those guards by sending arbitrary config —
|
|
12
|
+
* limited blast radius (admin auth required) but every CMS treats
|
|
13
|
+
* admin-tier input as semi-trusted and validates anyway.
|
|
14
|
+
*
|
|
15
|
+
* Throws an h3-compatible 400 with a structured `data.sectionErrors`
|
|
16
|
+
* payload listing every invalid section + the Zod issue. Used by:
|
|
17
|
+
* - POST /api/admin/layouts (create)
|
|
18
|
+
* - PUT /api/admin/layouts/[id] (update)
|
|
19
|
+
*
|
|
20
|
+
* Unknown section types ALSO 400 — same handler — so a typo'd type
|
|
21
|
+
* surfaces as a validation error instead of silently rendering a
|
|
22
|
+
* placeholder on the public page.
|
|
23
|
+
*
|
|
24
|
+
* Server-safe because `@commonpub/schema` has zero Vue imports. The
|
|
25
|
+
* previous attempt to wire this (session 160 R2) imported the section
|
|
26
|
+
* registry, which transitively pulled `.vue` components into the Nitro
|
|
27
|
+
* bundle and broke the build. The proper fix — moving schemas to the
|
|
28
|
+
* schema package — was deferred then; this is that fix.
|
|
29
|
+
*/
|
|
30
|
+
import { SECTION_CONFIG_SCHEMAS } from '@commonpub/schema';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Throw an h3/Nuxt-compatible HTTP error WITHOUT depending on h3
|
|
34
|
+
* directly (h3 isn't a direct layer dep + isn't resolvable from
|
|
35
|
+
* vitest). Nitro's error handler treats any thrown Error with
|
|
36
|
+
* `statusCode` + `statusMessage` + `data` as the equivalent of
|
|
37
|
+
* createError() — same wire format.
|
|
38
|
+
*/
|
|
39
|
+
function httpError(opts: { statusCode: number; statusMessage: string; data?: unknown }): Error {
|
|
40
|
+
const err = new Error(opts.statusMessage) as Error & {
|
|
41
|
+
statusCode: number;
|
|
42
|
+
statusMessage: string;
|
|
43
|
+
data?: unknown;
|
|
44
|
+
};
|
|
45
|
+
err.statusCode = opts.statusCode;
|
|
46
|
+
err.statusMessage = opts.statusMessage;
|
|
47
|
+
err.data = opts.data;
|
|
48
|
+
return err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface InputZone {
|
|
52
|
+
zone: string;
|
|
53
|
+
rows: Array<{
|
|
54
|
+
config?: unknown;
|
|
55
|
+
sections: Array<{
|
|
56
|
+
type: string;
|
|
57
|
+
config: Record<string, unknown>;
|
|
58
|
+
}>;
|
|
59
|
+
}>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface SectionError {
|
|
63
|
+
zone: string;
|
|
64
|
+
rowIndex: number;
|
|
65
|
+
sectionIndex: number;
|
|
66
|
+
type: string;
|
|
67
|
+
// Zod 4's issue.path is PropertyKey[] (string | number | symbol);
|
|
68
|
+
// symbol paths in user-submitted JSON are not reachable but the type
|
|
69
|
+
// must accept them.
|
|
70
|
+
issues: Array<{ path: PropertyKey[]; message: string }>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validate every section in a layout's zones against its registered
|
|
75
|
+
* Zod configSchema. Throws an h3-compatible 400 on any failure.
|
|
76
|
+
*
|
|
77
|
+
* No `registry` parameter — schema lookup is done via
|
|
78
|
+
* `SECTION_CONFIG_SCHEMAS` from `@commonpub/schema`, which is the
|
|
79
|
+
* canonical type → schema map. Keep that map in sync when adding
|
|
80
|
+
* new section types (see `packages/schema/src/sectionConfigs.ts`).
|
|
81
|
+
*/
|
|
82
|
+
export function validateSectionConfigs(zones: InputZone[]): void {
|
|
83
|
+
const errors: SectionError[] = [];
|
|
84
|
+
|
|
85
|
+
for (const zone of zones) {
|
|
86
|
+
zone.rows.forEach((row, rowIndex) => {
|
|
87
|
+
row.sections.forEach((section, sectionIndex) => {
|
|
88
|
+
const schema = SECTION_CONFIG_SCHEMAS[section.type];
|
|
89
|
+
if (!schema) {
|
|
90
|
+
errors.push({
|
|
91
|
+
zone: zone.zone,
|
|
92
|
+
rowIndex,
|
|
93
|
+
sectionIndex,
|
|
94
|
+
type: section.type,
|
|
95
|
+
issues: [{ path: ['type'], message: `Unknown section type: ${section.type}` }],
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const result = schema.safeParse(section.config);
|
|
100
|
+
if (!result.success) {
|
|
101
|
+
errors.push({
|
|
102
|
+
zone: zone.zone,
|
|
103
|
+
rowIndex,
|
|
104
|
+
sectionIndex,
|
|
105
|
+
type: section.type,
|
|
106
|
+
issues: result.error.issues.map((i) => ({
|
|
107
|
+
path: [...i.path],
|
|
108
|
+
message: i.message,
|
|
109
|
+
})),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (errors.length > 0) {
|
|
117
|
+
throw httpError({
|
|
118
|
+
statusCode: 400,
|
|
119
|
+
statusMessage: `Section config validation failed (${errors.length} section${errors.length === 1 ? '' : 's'})`,
|
|
120
|
+
data: { code: 'SECTION_CONFIG_INVALID', sectionErrors: errors },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
package/theme/base.css
CHANGED
|
@@ -218,6 +218,7 @@
|
|
|
218
218
|
--nav-height: 3rem; /* 48px topbar */
|
|
219
219
|
--subnav-height: 2.75rem;
|
|
220
220
|
--sidebar-width: 12.5rem; /* 200px fixed sidebar */
|
|
221
|
+
--sidebar-width-collapsed: 3.5rem; /* 56px icons-only — admin chrome collapsed state */
|
|
221
222
|
--content-max-width: 60rem; /* 960px */
|
|
222
223
|
--content-wide-max-width: 75rem;
|
|
223
224
|
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Built-in section: content-feed — a data-driven grid of content cards.
|
|
4
|
-
*
|
|
5
|
-
* Phase 1c starter and the first DATA section. Fetches `/api/content`
|
|
6
|
-
* with config-driven filter parameters; renders the existing
|
|
7
|
-
* `<ContentCard>` so the visual identity matches feeds elsewhere on the
|
|
8
|
-
* site.
|
|
9
|
-
*
|
|
10
|
-
* Each instance fetches independently (no global feed cache) — Nuxt's
|
|
11
|
-
* useFetch dedupes by `key`, which we derive from the config so two
|
|
12
|
-
* identically-configured feeds share a single request while two
|
|
13
|
-
* differently-configured feeds (e.g. main + sidebar with different
|
|
14
|
-
* `sort`) hit the endpoint separately.
|
|
15
|
-
*
|
|
16
|
-
* SSR-safe: useFetch fetches at setup() and includes the payload in the
|
|
17
|
-
* hydration snapshot.
|
|
18
|
-
*
|
|
19
|
-
* `var(--*)` only.
|
|
20
|
-
*/
|
|
21
|
-
import { computed } from 'vue';
|
|
22
|
-
import type { PaginatedResponse, Serialized, ContentListItem } from '@commonpub/server';
|
|
23
|
-
import type { SectionRenderProps } from '@commonpub/ui';
|
|
24
|
-
|
|
25
|
-
interface ContentFeedConfig extends Record<string, unknown> {
|
|
26
|
-
heading: string;
|
|
27
|
-
contentType: string;
|
|
28
|
-
sort: 'recent' | 'popular' | 'featured' | 'editorial';
|
|
29
|
-
limit: number;
|
|
30
|
-
columns: 1 | 2 | 3 | 4;
|
|
31
|
-
tag: string;
|
|
32
|
-
featured: boolean;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const props = defineProps<SectionRenderProps<ContentFeedConfig>>();
|
|
36
|
-
|
|
37
|
-
// Build the API query — server expects `type` (single value or absent),
|
|
38
|
-
// `sort`, `limit`, `tag`, `featured`. Omit empty strings so the validator
|
|
39
|
-
// treats them as absent (vs the empty-string=match-empty trap).
|
|
40
|
-
const apiQuery = computed(() => {
|
|
41
|
-
const q: Record<string, unknown> = {
|
|
42
|
-
status: 'published',
|
|
43
|
-
sort: props.config.sort,
|
|
44
|
-
limit: Math.min(Math.max(props.config.limit, 1), 24),
|
|
45
|
-
};
|
|
46
|
-
if (props.config.contentType) q.type = props.config.contentType;
|
|
47
|
-
if (props.config.tag) q.tag = props.config.tag;
|
|
48
|
-
if (props.config.featured) q.featured = true;
|
|
49
|
-
return q;
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
// Stable key so two identical content-feed sections on the same page
|
|
53
|
-
// share a single request, while different configurations don't collide.
|
|
54
|
-
const fetchKey = computed(
|
|
55
|
-
() => `section-content-feed:${JSON.stringify(apiQuery.value)}`,
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
// NO await — section is rendered inside <LayoutSlot> deep in the tree;
|
|
59
|
-
// awaiting top-level here would require Suspense on every parent, which
|
|
60
|
-
// neither the production page-render path nor the editor preview pane
|
|
61
|
-
// is set up for. The page (`/`, `/about`, etc.) already does its own
|
|
62
|
-
// `await useFetch` for content via the legacy renderer, so initial
|
|
63
|
-
// load is data-ready; this section's fetch is a fresh request per
|
|
64
|
-
// instance + config. Pending state surfaces in the template instead.
|
|
65
|
-
const { data: feed, pending } = useFetch<PaginatedResponse<Serialized<ContentListItem>>>(
|
|
66
|
-
'/api/content',
|
|
67
|
-
{
|
|
68
|
-
query: apiQuery,
|
|
69
|
-
key: fetchKey.value,
|
|
70
|
-
// Empty-result handler: surface a friendly empty state in the template
|
|
71
|
-
// rather than throwing. 404 wouldn't be a thing on /api/content anyway.
|
|
72
|
-
},
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
const items = computed(() => feed.value?.items ?? []);
|
|
76
|
-
const isEmpty = computed(() => !pending.value && items.value.length === 0);
|
|
77
|
-
</script>
|
|
78
|
-
|
|
79
|
-
<template>
|
|
80
|
-
<section
|
|
81
|
-
class="cpub-section-content-feed"
|
|
82
|
-
:aria-labelledby="config.heading ? `section-feed-${meta.sectionId}` : undefined"
|
|
83
|
-
>
|
|
84
|
-
<h2
|
|
85
|
-
v-if="config.heading"
|
|
86
|
-
:id="`section-feed-${meta.sectionId}`"
|
|
87
|
-
class="cpub-section-content-feed-heading"
|
|
88
|
-
>
|
|
89
|
-
{{ config.heading }}
|
|
90
|
-
</h2>
|
|
91
|
-
|
|
92
|
-
<div v-if="pending" class="cpub-section-content-feed-loading">
|
|
93
|
-
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
|
|
94
|
-
<span>Loading…</span>
|
|
95
|
-
</div>
|
|
96
|
-
|
|
97
|
-
<div
|
|
98
|
-
v-else-if="!isEmpty"
|
|
99
|
-
class="cpub-section-content-feed-grid"
|
|
100
|
-
:data-columns="config.columns"
|
|
101
|
-
>
|
|
102
|
-
<ContentCard
|
|
103
|
-
v-for="item in items"
|
|
104
|
-
:key="item.id"
|
|
105
|
-
:item="item"
|
|
106
|
-
/>
|
|
107
|
-
</div>
|
|
108
|
-
|
|
109
|
-
<p v-else class="cpub-section-content-feed-empty">
|
|
110
|
-
No content yet.
|
|
111
|
-
</p>
|
|
112
|
-
</section>
|
|
113
|
-
</template>
|
|
114
|
-
|
|
115
|
-
<style scoped>
|
|
116
|
-
.cpub-section-content-feed {
|
|
117
|
-
display: flex;
|
|
118
|
-
flex-direction: column;
|
|
119
|
-
gap: var(--space-3);
|
|
120
|
-
}
|
|
121
|
-
.cpub-section-content-feed-heading {
|
|
122
|
-
font-family: var(--font-mono);
|
|
123
|
-
font-size: var(--text-xs);
|
|
124
|
-
font-weight: 700;
|
|
125
|
-
text-transform: uppercase;
|
|
126
|
-
letter-spacing: 0.08em;
|
|
127
|
-
color: var(--text-faint);
|
|
128
|
-
margin: 0;
|
|
129
|
-
padding-bottom: var(--space-2);
|
|
130
|
-
border-bottom: var(--border-width-default) solid var(--border);
|
|
131
|
-
}
|
|
132
|
-
.cpub-section-content-feed-grid {
|
|
133
|
-
display: grid;
|
|
134
|
-
gap: var(--space-3);
|
|
135
|
-
}
|
|
136
|
-
.cpub-section-content-feed-grid[data-columns='1'] { grid-template-columns: 1fr; }
|
|
137
|
-
.cpub-section-content-feed-grid[data-columns='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
138
|
-
.cpub-section-content-feed-grid[data-columns='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
139
|
-
.cpub-section-content-feed-grid[data-columns='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
140
|
-
|
|
141
|
-
/* Responsive collapse — multi-col grids stack on tablet/mobile */
|
|
142
|
-
@media (max-width: 1024px) {
|
|
143
|
-
.cpub-section-content-feed-grid[data-columns='3'],
|
|
144
|
-
.cpub-section-content-feed-grid[data-columns='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
145
|
-
}
|
|
146
|
-
@media (max-width: 640px) {
|
|
147
|
-
.cpub-section-content-feed-grid { grid-template-columns: 1fr; }
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
.cpub-section-content-feed-loading,
|
|
151
|
-
.cpub-section-content-feed-empty {
|
|
152
|
-
display: flex;
|
|
153
|
-
align-items: center;
|
|
154
|
-
justify-content: center;
|
|
155
|
-
gap: var(--space-2);
|
|
156
|
-
padding: var(--space-6);
|
|
157
|
-
color: var(--text-faint);
|
|
158
|
-
font-size: var(--text-sm);
|
|
159
|
-
}
|
|
160
|
-
</style>
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Built-in section: contests — active-contests list with countdown.
|
|
4
|
-
*
|
|
5
|
-
* Fetches `/api/contests?limit=N`, renders sidebar-style card. Each
|
|
6
|
-
* row: title (link), entry count, "Nd left" deadline, Enter CTA.
|
|
7
|
-
* Mirrors legacy `ContestsSection.vue`.
|
|
8
|
-
*
|
|
9
|
-
* Feature-gated upstream — when `features.contests` is off the API
|
|
10
|
-
* returns 404 and the section renders the empty branch.
|
|
11
|
-
*
|
|
12
|
-
* Non-await useFetch per session-158 pattern. `var(--*)` only.
|
|
13
|
-
*/
|
|
14
|
-
import { computed } from 'vue';
|
|
15
|
-
import type { SectionRenderProps } from '@commonpub/ui';
|
|
16
|
-
|
|
17
|
-
interface ContestItem {
|
|
18
|
-
id: string;
|
|
19
|
-
slug: string;
|
|
20
|
-
title: string;
|
|
21
|
-
entryCount?: number | null;
|
|
22
|
-
endDate?: string | Date | null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface ContestsResponse {
|
|
26
|
-
items?: ContestItem[];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface ContestsConfig extends Record<string, unknown> {
|
|
30
|
-
heading: string;
|
|
31
|
-
limit: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const props = defineProps<SectionRenderProps<ContestsConfig>>();
|
|
35
|
-
|
|
36
|
-
const apiQuery = computed(() => ({
|
|
37
|
-
limit: Math.min(Math.max(props.config.limit, 1), 10),
|
|
38
|
-
}));
|
|
39
|
-
|
|
40
|
-
const { data: contests, pending } = useFetch<ContestsResponse>(
|
|
41
|
-
'/api/contests',
|
|
42
|
-
{
|
|
43
|
-
query: apiQuery,
|
|
44
|
-
key: `section-contests:${JSON.stringify(apiQuery.value)}`,
|
|
45
|
-
// Sidebar widget — lazy so initial SSR doesn't block on the contests
|
|
46
|
-
// query (which is ~5x slower than /api/hubs in practice). Matches the
|
|
47
|
-
// legacy ContestsSection.vue pattern.
|
|
48
|
-
lazy: true,
|
|
49
|
-
},
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
const items = computed(() => contests.value?.items ?? []);
|
|
53
|
-
const isEmpty = computed(() => !pending.value && items.value.length === 0);
|
|
54
|
-
|
|
55
|
-
function daysLeft(endDate: string | Date | null | undefined): number | null {
|
|
56
|
-
if (!endDate) return null;
|
|
57
|
-
const ms = new Date(endDate).getTime() - Date.now();
|
|
58
|
-
if (Number.isNaN(ms)) return null;
|
|
59
|
-
return Math.max(0, Math.ceil(ms / 86_400_000));
|
|
60
|
-
}
|
|
61
|
-
</script>
|
|
62
|
-
|
|
63
|
-
<template>
|
|
64
|
-
<section
|
|
65
|
-
class="cpub-section-contests"
|
|
66
|
-
:aria-labelledby="config.heading ? `section-contests-${meta.sectionId}` : undefined"
|
|
67
|
-
>
|
|
68
|
-
<header
|
|
69
|
-
v-if="config.heading"
|
|
70
|
-
class="cpub-section-contests-header"
|
|
71
|
-
>
|
|
72
|
-
<h2
|
|
73
|
-
:id="`section-contests-${meta.sectionId}`"
|
|
74
|
-
class="cpub-section-contests-heading"
|
|
75
|
-
>
|
|
76
|
-
{{ config.heading }}
|
|
77
|
-
</h2>
|
|
78
|
-
<NuxtLink to="/contests" class="cpub-section-contests-all">View all</NuxtLink>
|
|
79
|
-
</header>
|
|
80
|
-
|
|
81
|
-
<div v-if="pending" class="cpub-section-contests-loading">
|
|
82
|
-
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
|
|
83
|
-
</div>
|
|
84
|
-
|
|
85
|
-
<ul v-else-if="!isEmpty" class="cpub-section-contests-list">
|
|
86
|
-
<li v-for="c in items" :key="c.id" class="cpub-section-contests-item">
|
|
87
|
-
<NuxtLink :to="`/contests/${c.slug}`" class="cpub-section-contests-name">
|
|
88
|
-
{{ c.title }}
|
|
89
|
-
</NuxtLink>
|
|
90
|
-
<div class="cpub-section-contests-meta">
|
|
91
|
-
<span class="cpub-section-contests-entries">
|
|
92
|
-
{{ c.entryCount ?? 0 }} entries
|
|
93
|
-
</span>
|
|
94
|
-
<span v-if="daysLeft(c.endDate) !== null" class="cpub-section-contests-deadline">
|
|
95
|
-
<i class="fa-regular fa-clock" aria-hidden="true" />
|
|
96
|
-
{{ daysLeft(c.endDate) }}d left
|
|
97
|
-
</span>
|
|
98
|
-
</div>
|
|
99
|
-
<NuxtLink :to="`/contests/${c.slug}`" class="cpub-section-contests-cta">
|
|
100
|
-
Enter Contest
|
|
101
|
-
</NuxtLink>
|
|
102
|
-
</li>
|
|
103
|
-
</ul>
|
|
104
|
-
|
|
105
|
-
<p v-else class="cpub-section-contests-empty">No active contests.</p>
|
|
106
|
-
</section>
|
|
107
|
-
</template>
|
|
108
|
-
|
|
109
|
-
<style scoped>
|
|
110
|
-
.cpub-section-contests {
|
|
111
|
-
background: var(--surface);
|
|
112
|
-
border: var(--border-width-default) solid var(--border);
|
|
113
|
-
padding: var(--space-4);
|
|
114
|
-
}
|
|
115
|
-
.cpub-section-contests-header {
|
|
116
|
-
display: flex;
|
|
117
|
-
align-items: center;
|
|
118
|
-
justify-content: space-between;
|
|
119
|
-
padding-bottom: var(--space-2);
|
|
120
|
-
border-bottom: var(--border-width-default) solid var(--border-soft);
|
|
121
|
-
margin-bottom: var(--space-3);
|
|
122
|
-
}
|
|
123
|
-
.cpub-section-contests-heading {
|
|
124
|
-
font-family: var(--font-mono);
|
|
125
|
-
font-size: var(--text-xxs);
|
|
126
|
-
font-weight: 700;
|
|
127
|
-
text-transform: uppercase;
|
|
128
|
-
letter-spacing: 0.08em;
|
|
129
|
-
color: var(--text-faint);
|
|
130
|
-
margin: 0;
|
|
131
|
-
}
|
|
132
|
-
.cpub-section-contests-all {
|
|
133
|
-
font-family: var(--font-mono);
|
|
134
|
-
font-size: var(--text-xxs);
|
|
135
|
-
color: var(--accent);
|
|
136
|
-
text-decoration: none;
|
|
137
|
-
}
|
|
138
|
-
.cpub-section-contests-list {
|
|
139
|
-
list-style: none;
|
|
140
|
-
margin: 0;
|
|
141
|
-
padding: 0;
|
|
142
|
-
}
|
|
143
|
-
.cpub-section-contests-item {
|
|
144
|
-
padding: var(--space-2) 0;
|
|
145
|
-
border-bottom: var(--border-width-default) solid var(--border-soft);
|
|
146
|
-
}
|
|
147
|
-
.cpub-section-contests-item:last-child { border-bottom: none; }
|
|
148
|
-
.cpub-section-contests-name {
|
|
149
|
-
font-size: var(--text-sm);
|
|
150
|
-
font-weight: 600;
|
|
151
|
-
color: var(--text);
|
|
152
|
-
text-decoration: none;
|
|
153
|
-
display: block;
|
|
154
|
-
margin-bottom: var(--space-1);
|
|
155
|
-
}
|
|
156
|
-
.cpub-section-contests-name:hover { color: var(--accent); }
|
|
157
|
-
.cpub-section-contests-meta {
|
|
158
|
-
display: flex;
|
|
159
|
-
align-items: center;
|
|
160
|
-
gap: var(--space-3);
|
|
161
|
-
margin-bottom: var(--space-2);
|
|
162
|
-
}
|
|
163
|
-
.cpub-section-contests-entries,
|
|
164
|
-
.cpub-section-contests-deadline {
|
|
165
|
-
font-family: var(--font-mono);
|
|
166
|
-
font-size: var(--text-xxs);
|
|
167
|
-
color: var(--text-faint);
|
|
168
|
-
display: inline-flex;
|
|
169
|
-
align-items: center;
|
|
170
|
-
gap: var(--space-1);
|
|
171
|
-
}
|
|
172
|
-
.cpub-section-contests-cta {
|
|
173
|
-
font-family: var(--font-mono);
|
|
174
|
-
font-size: var(--text-xxs);
|
|
175
|
-
text-transform: uppercase;
|
|
176
|
-
letter-spacing: 0.06em;
|
|
177
|
-
padding: var(--space-1) var(--space-2);
|
|
178
|
-
border: var(--border-width-default) solid var(--accent);
|
|
179
|
-
color: var(--accent);
|
|
180
|
-
text-decoration: none;
|
|
181
|
-
display: inline-block;
|
|
182
|
-
}
|
|
183
|
-
.cpub-section-contests-cta:hover { background: var(--accent-bg); }
|
|
184
|
-
.cpub-section-contests-loading,
|
|
185
|
-
.cpub-section-contests-empty {
|
|
186
|
-
display: flex;
|
|
187
|
-
align-items: center;
|
|
188
|
-
justify-content: center;
|
|
189
|
-
padding: var(--space-4);
|
|
190
|
-
color: var(--text-faint);
|
|
191
|
-
font-size: var(--text-sm);
|
|
192
|
-
}
|
|
193
|
-
</style>
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Built-in section: custom-html — admin-only raw HTML escape hatch.
|
|
4
|
-
*
|
|
5
|
-
* **SECURITY**: renders `config.html` via `v-html` with no runtime
|
|
6
|
-
* sanitization. Intentional Phase-1c posture — matches the legacy
|
|
7
|
-
* `CustomHtmlSection.vue` security baseline that already ships in
|
|
8
|
-
* production. Threat-model + Phase 6b sanitization plan live in the
|
|
9
|
-
* section definition file (`builtin/custom-html.ts`) and
|
|
10
|
-
* `docs/plans/layout-and-pages.md §6.5`.
|
|
11
|
-
*
|
|
12
|
-
* Only trusted admin users can write to this section via
|
|
13
|
-
* `/api/admin/layouts/*` (gated on `requireAdmin(event)`). A compromised
|
|
14
|
-
* admin account → stored XSS — that's the gap we're documenting +
|
|
15
|
-
* tracking, not the gap we're closing this session.
|
|
16
|
-
*
|
|
17
|
-
* `var(--*)` only.
|
|
18
|
-
*/
|
|
19
|
-
import type { SectionRenderProps } from '@commonpub/ui';
|
|
20
|
-
|
|
21
|
-
interface CustomHtmlConfig extends Record<string, unknown> {
|
|
22
|
-
heading: string;
|
|
23
|
-
html: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const props = defineProps<SectionRenderProps<CustomHtmlConfig>>();
|
|
27
|
-
void props; // template uses config + meta directly via `<script setup>`
|
|
28
|
-
</script>
|
|
29
|
-
|
|
30
|
-
<template>
|
|
31
|
-
<section
|
|
32
|
-
v-if="config.html"
|
|
33
|
-
class="cpub-section-custom-html"
|
|
34
|
-
:aria-labelledby="config.heading ? `section-custom-${meta.sectionId}` : undefined"
|
|
35
|
-
>
|
|
36
|
-
<h2
|
|
37
|
-
v-if="config.heading"
|
|
38
|
-
:id="`section-custom-${meta.sectionId}`"
|
|
39
|
-
class="cpub-section-custom-html-heading"
|
|
40
|
-
>
|
|
41
|
-
{{ config.heading }}
|
|
42
|
-
</h2>
|
|
43
|
-
<!-- v-html: see security note in source. Trusted admin input only. -->
|
|
44
|
-
<div class="cpub-section-custom-html-body" v-html="config.html" />
|
|
45
|
-
</section>
|
|
46
|
-
</template>
|
|
47
|
-
|
|
48
|
-
<style scoped>
|
|
49
|
-
.cpub-section-custom-html {
|
|
50
|
-
display: flex;
|
|
51
|
-
flex-direction: column;
|
|
52
|
-
gap: var(--space-3);
|
|
53
|
-
}
|
|
54
|
-
.cpub-section-custom-html-heading {
|
|
55
|
-
font-family: var(--font-mono);
|
|
56
|
-
font-size: var(--text-xs);
|
|
57
|
-
font-weight: 700;
|
|
58
|
-
text-transform: uppercase;
|
|
59
|
-
letter-spacing: 0.08em;
|
|
60
|
-
color: var(--text-faint);
|
|
61
|
-
margin: 0;
|
|
62
|
-
padding-bottom: var(--space-2);
|
|
63
|
-
border-bottom: var(--border-width-default) solid var(--border);
|
|
64
|
-
}
|
|
65
|
-
.cpub-section-custom-html-body {
|
|
66
|
-
font-size: var(--text-base);
|
|
67
|
-
line-height: 1.7;
|
|
68
|
-
color: var(--text);
|
|
69
|
-
}
|
|
70
|
-
</style>
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Built-in section: divider — a horizontal rule.
|
|
4
|
-
*
|
|
5
|
-
* Phase 1 proof-of-life — the simplest possible section, validating that:
|
|
6
|
-
* - `SectionRegistry.register()` accepts a Vue component
|
|
7
|
-
* - `<LayoutSlot>` resolves the section type slug to a renderer
|
|
8
|
-
* - The renderer receives `config` + `meta` and produces DOM
|
|
9
|
-
*
|
|
10
|
-
* Phase 1c adds the real catalogue (hero / heading / paragraph / image /
|
|
11
|
-
* content-feed). Until then, dropping a divider into a layout is the
|
|
12
|
-
* end-to-end smoke test of the layout engine.
|
|
13
|
-
*
|
|
14
|
-
* Uses `var(--*)` only (rule #3); inherits all colors / spacing from
|
|
15
|
-
* the active theme.
|
|
16
|
-
*/
|
|
17
|
-
import type { SectionRenderProps } from '@commonpub/ui';
|
|
18
|
-
|
|
19
|
-
interface DividerConfig extends Record<string, unknown> {
|
|
20
|
-
variant: 'solid' | 'dashed' | 'dotted' | 'accent';
|
|
21
|
-
spacingY: 'sm' | 'md' | 'lg' | 'xl';
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
defineProps<SectionRenderProps<DividerConfig>>();
|
|
25
|
-
</script>
|
|
26
|
-
|
|
27
|
-
<template>
|
|
28
|
-
<hr
|
|
29
|
-
class="cpub-section-divider"
|
|
30
|
-
:data-variant="config.variant"
|
|
31
|
-
:data-spacing-y="config.spacingY"
|
|
32
|
-
:aria-label="`section ${meta.sectionId}`"
|
|
33
|
-
/>
|
|
34
|
-
</template>
|
|
35
|
-
|
|
36
|
-
<style scoped>
|
|
37
|
-
.cpub-section-divider {
|
|
38
|
-
margin-block: var(--space-4);
|
|
39
|
-
border: 0;
|
|
40
|
-
border-top: var(--border-width-default) solid var(--border2);
|
|
41
|
-
width: 100%;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
.cpub-section-divider[data-variant='dashed'] { border-top-style: dashed; }
|
|
45
|
-
.cpub-section-divider[data-variant='dotted'] { border-top-style: dotted; }
|
|
46
|
-
.cpub-section-divider[data-variant='accent'] {
|
|
47
|
-
border-top-color: var(--accent);
|
|
48
|
-
border-top-width: var(--border-width-thick);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
.cpub-section-divider[data-spacing-y='sm'] { margin-block: var(--space-2); }
|
|
52
|
-
.cpub-section-divider[data-spacing-y='md'] { margin-block: var(--space-4); }
|
|
53
|
-
.cpub-section-divider[data-spacing-y='lg'] { margin-block: var(--space-6); }
|
|
54
|
-
.cpub-section-divider[data-spacing-y='xl'] { margin-block: var(--space-8); }
|
|
55
|
-
</style>
|