@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
|
@@ -123,7 +123,29 @@ const columns = computed(() => props.config.columns ?? 2);
|
|
|
123
123
|
.cpub-tab:hover { color: var(--text); }
|
|
124
124
|
.cpub-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
/*
|
|
127
|
+
* Session 164 polish: cap the grid's max width so cards don't stretch
|
|
128
|
+
* into a "giant cards in mobile layout" pattern at desktop widths.
|
|
129
|
+
*
|
|
130
|
+
* Was: `grid-template-columns: repeat(2, 1fr)` with NO width cap → at
|
|
131
|
+
* 1440px window, 2 columns of ~700px each. Cards stretched far past
|
|
132
|
+
* their natural design width and read as "single column of bigs".
|
|
133
|
+
*
|
|
134
|
+
* Now: same column behavior but bounded by --content-max-width (default
|
|
135
|
+
* 1280px) — matches the tabs-inner cap above (same theme token). At
|
|
136
|
+
* 1280px and below, behaves identically to before. Above, the grid
|
|
137
|
+
* stays at 1280 centered and cards stay at their design proportions.
|
|
138
|
+
*
|
|
139
|
+
* `var(--grid-cols)` config still drives column count. Mobile drops to
|
|
140
|
+
* 1-column at <=768px (unchanged).
|
|
141
|
+
*/
|
|
142
|
+
.cpub-content-grid {
|
|
143
|
+
display: grid;
|
|
144
|
+
grid-template-columns: repeat(var(--grid-cols, 2), 1fr);
|
|
145
|
+
gap: 16px;
|
|
146
|
+
max-width: var(--content-max-width, 1280px);
|
|
147
|
+
margin-inline: auto;
|
|
148
|
+
}
|
|
127
149
|
|
|
128
150
|
.cpub-load-more-row { text-align: center; padding: 24px 0; }
|
|
129
151
|
.cpub-btn-load-more { font-family: var(--font-mono); font-size: 11px; padding: 8px 20px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text-dim); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
2
3
|
import type { HomepageSectionConfig } from '@commonpub/server';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
interface HeroCta {
|
|
6
|
+
label: string;
|
|
7
|
+
href: string;
|
|
8
|
+
variant?: 'primary' | 'secondary';
|
|
9
|
+
icon?: string; // Font Awesome class (e.g. 'fa-plus'); optional
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{ config: HomepageSectionConfig }>();
|
|
5
13
|
|
|
6
14
|
const { contests: contestsEnabled } = useFeatures();
|
|
7
15
|
const { data: contests } = await useFetch('/api/contests', { query: { limit: 3 }, lazy: true });
|
|
@@ -11,6 +19,49 @@ const activeContest = computed(() => {
|
|
|
11
19
|
return items?.find((c) => c.status === 'active') ?? null;
|
|
12
20
|
});
|
|
13
21
|
|
|
22
|
+
// Config-driven overrides (Stage E session 159 — finally wired). The
|
|
23
|
+
// legacy admin form (/admin/homepage) has accepted `customTitle` +
|
|
24
|
+
// `customSubtitle` for a long time, but the renderer ignored them —
|
|
25
|
+
// admin would type a custom title and see no change. Now respected.
|
|
26
|
+
//
|
|
27
|
+
// Hardcoded fallbacks preserve the existing "Build. Document. Share."
|
|
28
|
+
// + Open Source eyebrow + Start Building/Explore CTAs when admin
|
|
29
|
+
// hasn't customised anything.
|
|
30
|
+
//
|
|
31
|
+
// Contest-aware swap still wins when there's an active contest +
|
|
32
|
+
// `features.contests` is on — the contest hero is intentionally not
|
|
33
|
+
// overridable because it pulls live data; admins who want fully-static
|
|
34
|
+
// copy can either disable the contests flag or set customTitle which
|
|
35
|
+
// applies in the non-contest branch.
|
|
36
|
+
const heroTitle = computed(() => {
|
|
37
|
+
const cfg = props.config as { customTitle?: string };
|
|
38
|
+
return cfg.customTitle?.trim() || 'Build. Document. Share.';
|
|
39
|
+
});
|
|
40
|
+
const heroTitleIsCustom = computed(() => {
|
|
41
|
+
const cfg = props.config as { customTitle?: string };
|
|
42
|
+
return !!cfg.customTitle?.trim();
|
|
43
|
+
});
|
|
44
|
+
const heroSubtitle = computed(() => {
|
|
45
|
+
const cfg = props.config as { customSubtitle?: string };
|
|
46
|
+
return cfg.customSubtitle?.trim() ||
|
|
47
|
+
'CommonPub is an open platform for maker communities. Document your builds with rich editors, join hubs, learn with structured paths, and share with the world.';
|
|
48
|
+
});
|
|
49
|
+
const heroEyebrow = computed(() => {
|
|
50
|
+
const cfg = props.config as { eyebrow?: string };
|
|
51
|
+
return cfg.eyebrow?.trim() || 'Open Source';
|
|
52
|
+
});
|
|
53
|
+
const heroCtas = computed<HeroCta[]>(() => {
|
|
54
|
+
const cfg = props.config as { ctas?: HeroCta[] };
|
|
55
|
+
if (Array.isArray(cfg.ctas) && cfg.ctas.length > 0) return cfg.ctas;
|
|
56
|
+
// Hardcoded fallbacks keep the legacy hero's exact look: icons +
|
|
57
|
+
// canonical labels. Admin-set ctas (no icon field in the legacy
|
|
58
|
+
// admin form) render without icons by design.
|
|
59
|
+
return [
|
|
60
|
+
{ label: 'Start Building', href: '/create', variant: 'primary', icon: 'fa-plus' },
|
|
61
|
+
{ label: 'Explore', href: '/explore', variant: 'secondary', icon: 'fa-compass' },
|
|
62
|
+
];
|
|
63
|
+
});
|
|
64
|
+
|
|
14
65
|
// Shared via useState so the dismiss sticks across component remounts.
|
|
15
66
|
// HomepageSectionRenderer's v-if wrappers can remount HeroSection when the
|
|
16
67
|
// `sections` useFetch revalidates on hydration or when feature flags flip
|
|
@@ -51,15 +102,25 @@ function dismissHero(): void {
|
|
|
51
102
|
</template>
|
|
52
103
|
<template v-else>
|
|
53
104
|
<div class="cpub-hero-eyebrow">
|
|
54
|
-
<span class="cpub-hero-badge cpub-hero-badge-live"><span class="cpub-live-dot" />
|
|
105
|
+
<span class="cpub-hero-badge cpub-hero-badge-live"><span class="cpub-live-dot" /> {{ heroEyebrow }}</span>
|
|
55
106
|
</div>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
</
|
|
107
|
+
<!-- When admin supplied a customTitle, show as plain text. The
|
|
108
|
+
hardcoded fallback uses inline <br> + accent span for the
|
|
109
|
+
canonical "Build. Document. Share." typography. -->
|
|
110
|
+
<h1 v-if="heroTitleIsCustom" class="cpub-hero-title">{{ heroTitle }}</h1>
|
|
111
|
+
<h1 v-else class="cpub-hero-title">Build. Document.<br><span>Share.</span></h1>
|
|
112
|
+
<p class="cpub-hero-excerpt">{{ heroSubtitle }}</p>
|
|
60
113
|
<div class="cpub-hero-actions">
|
|
61
|
-
<NuxtLink
|
|
62
|
-
|
|
114
|
+
<NuxtLink
|
|
115
|
+
v-for="(cta, i) in heroCtas"
|
|
116
|
+
:key="i"
|
|
117
|
+
:to="cta.href"
|
|
118
|
+
class="cpub-btn"
|
|
119
|
+
:class="cta.variant === 'primary' ? 'cpub-btn-primary' : ''"
|
|
120
|
+
>
|
|
121
|
+
<i v-if="cta.icon" :class="['fa-solid', cta.icon]" aria-hidden="true" />
|
|
122
|
+
{{ cta.label }}
|
|
123
|
+
</NuxtLink>
|
|
63
124
|
</div>
|
|
64
125
|
</template>
|
|
65
126
|
</div>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Built-in section: cta — call-to-action panel.
|
|
4
|
+
*
|
|
5
|
+
* Heading + body + up to 3 buttons. Three variants:
|
|
6
|
+
* default → boxed panel with subtle border
|
|
7
|
+
* contrast → accent-background inverse
|
|
8
|
+
* minimal → no panel, just text + buttons
|
|
9
|
+
*
|
|
10
|
+
* URL safety: href values are validated at WRITE time by the section's
|
|
11
|
+
* Zod schema (SAFE_LINK_URL regex). Renderer doesn't re-validate
|
|
12
|
+
* because Vue's :href binding doesn't sanitise — the regex IS the guard.
|
|
13
|
+
*
|
|
14
|
+
* `var(--*)` only.
|
|
15
|
+
*/
|
|
16
|
+
import type { SectionRenderProps } from '@commonpub/ui';
|
|
17
|
+
|
|
18
|
+
interface CtaButton {
|
|
19
|
+
label: string;
|
|
20
|
+
href: string;
|
|
21
|
+
variant: 'primary' | 'secondary' | 'ghost';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CtaConfig extends Record<string, unknown> {
|
|
25
|
+
variant: 'default' | 'contrast' | 'minimal';
|
|
26
|
+
heading: string;
|
|
27
|
+
body: string;
|
|
28
|
+
buttons: CtaButton[];
|
|
29
|
+
align: 'left' | 'center';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const props = defineProps<SectionRenderProps<CtaConfig>>();
|
|
33
|
+
void props;
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<section
|
|
38
|
+
class="cpub-section-cta"
|
|
39
|
+
:data-variant="config.variant"
|
|
40
|
+
:data-align="config.align"
|
|
41
|
+
:aria-labelledby="`section-cta-${meta.sectionId}`"
|
|
42
|
+
>
|
|
43
|
+
<h2
|
|
44
|
+
:id="`section-cta-${meta.sectionId}`"
|
|
45
|
+
class="cpub-section-cta-heading"
|
|
46
|
+
>
|
|
47
|
+
{{ config.heading }}
|
|
48
|
+
</h2>
|
|
49
|
+
|
|
50
|
+
<p v-if="config.body" class="cpub-section-cta-body">
|
|
51
|
+
{{ config.body }}
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
<div v-if="config.buttons.length > 0" class="cpub-section-cta-actions">
|
|
55
|
+
<a
|
|
56
|
+
v-for="(btn, idx) in config.buttons"
|
|
57
|
+
:key="idx"
|
|
58
|
+
:href="btn.href"
|
|
59
|
+
:class="`cpub-section-cta-btn cpub-section-cta-btn-${btn.variant}`"
|
|
60
|
+
>
|
|
61
|
+
{{ btn.label }}
|
|
62
|
+
</a>
|
|
63
|
+
</div>
|
|
64
|
+
</section>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<style scoped>
|
|
68
|
+
.cpub-section-cta {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
gap: var(--space-3);
|
|
72
|
+
padding: var(--space-5);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* default — boxed */
|
|
76
|
+
.cpub-section-cta[data-variant='default'] {
|
|
77
|
+
background: var(--surface);
|
|
78
|
+
border: var(--border-width-default) solid var(--border);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* contrast — accent inverse */
|
|
82
|
+
.cpub-section-cta[data-variant='contrast'] {
|
|
83
|
+
background: var(--accent);
|
|
84
|
+
color: var(--surface);
|
|
85
|
+
border: var(--border-width-default) solid var(--accent);
|
|
86
|
+
}
|
|
87
|
+
.cpub-section-cta[data-variant='contrast'] .cpub-section-cta-heading,
|
|
88
|
+
.cpub-section-cta[data-variant='contrast'] .cpub-section-cta-body {
|
|
89
|
+
color: inherit;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* minimal — no panel */
|
|
93
|
+
.cpub-section-cta[data-variant='minimal'] {
|
|
94
|
+
background: transparent;
|
|
95
|
+
border: none;
|
|
96
|
+
padding: var(--space-3) 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* align */
|
|
100
|
+
.cpub-section-cta[data-align='center'] {
|
|
101
|
+
text-align: center;
|
|
102
|
+
align-items: center;
|
|
103
|
+
}
|
|
104
|
+
.cpub-section-cta[data-align='center'] .cpub-section-cta-actions {
|
|
105
|
+
justify-content: center;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.cpub-section-cta-heading {
|
|
109
|
+
font-size: var(--text-xl);
|
|
110
|
+
font-weight: 700;
|
|
111
|
+
color: var(--text);
|
|
112
|
+
margin: 0;
|
|
113
|
+
}
|
|
114
|
+
.cpub-section-cta-body {
|
|
115
|
+
font-size: var(--text-base);
|
|
116
|
+
line-height: 1.7;
|
|
117
|
+
color: var(--text-soft);
|
|
118
|
+
margin: 0;
|
|
119
|
+
max-width: 60ch;
|
|
120
|
+
}
|
|
121
|
+
.cpub-section-cta-actions {
|
|
122
|
+
display: flex;
|
|
123
|
+
flex-wrap: wrap;
|
|
124
|
+
gap: var(--space-2);
|
|
125
|
+
margin-top: var(--space-2);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* buttons — three variants */
|
|
129
|
+
.cpub-section-cta-btn {
|
|
130
|
+
font-family: var(--font-mono);
|
|
131
|
+
font-size: var(--text-xs);
|
|
132
|
+
font-weight: 700;
|
|
133
|
+
text-transform: uppercase;
|
|
134
|
+
letter-spacing: 0.06em;
|
|
135
|
+
padding: var(--space-2) var(--space-4);
|
|
136
|
+
border: var(--border-width-default) solid var(--accent);
|
|
137
|
+
text-decoration: none;
|
|
138
|
+
display: inline-block;
|
|
139
|
+
}
|
|
140
|
+
.cpub-section-cta-btn-primary {
|
|
141
|
+
background: var(--accent);
|
|
142
|
+
color: var(--surface);
|
|
143
|
+
}
|
|
144
|
+
.cpub-section-cta-btn-primary:hover {
|
|
145
|
+
background: var(--accent-strong, var(--accent));
|
|
146
|
+
}
|
|
147
|
+
.cpub-section-cta-btn-secondary {
|
|
148
|
+
background: transparent;
|
|
149
|
+
color: var(--accent);
|
|
150
|
+
}
|
|
151
|
+
.cpub-section-cta-btn-secondary:hover {
|
|
152
|
+
background: var(--accent-bg);
|
|
153
|
+
}
|
|
154
|
+
.cpub-section-cta-btn-ghost {
|
|
155
|
+
border-color: transparent;
|
|
156
|
+
background: transparent;
|
|
157
|
+
color: var(--text);
|
|
158
|
+
}
|
|
159
|
+
.cpub-section-cta-btn-ghost:hover {
|
|
160
|
+
color: var(--accent);
|
|
161
|
+
}
|
|
162
|
+
/* Contrast variant — invert button colours so they read on accent bg */
|
|
163
|
+
.cpub-section-cta[data-variant='contrast'] .cpub-section-cta-btn-primary {
|
|
164
|
+
background: var(--surface);
|
|
165
|
+
color: var(--accent);
|
|
166
|
+
border-color: var(--surface);
|
|
167
|
+
}
|
|
168
|
+
.cpub-section-cta[data-variant='contrast'] .cpub-section-cta-btn-secondary {
|
|
169
|
+
border-color: var(--surface);
|
|
170
|
+
color: var(--surface);
|
|
171
|
+
}
|
|
172
|
+
.cpub-section-cta[data-variant='contrast'] .cpub-section-cta-btn-ghost {
|
|
173
|
+
color: var(--surface);
|
|
174
|
+
}
|
|
175
|
+
</style>
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Built-in section: learning — grid of learning paths.
|
|
4
|
+
*
|
|
5
|
+
* Fetches `/api/learn?limit=N`, renders responsive card grid (title +
|
|
6
|
+
* description + difficulty + duration + enrollment). Feature-gated
|
|
7
|
+
* upstream on `features.learning`.
|
|
8
|
+
*
|
|
9
|
+
* Same shape as editorial / content-feed (paginated /api/* endpoint →
|
|
10
|
+
* responsive grid) but a distinct entity type with its own metadata.
|
|
11
|
+
*
|
|
12
|
+
* `var(--*)` only.
|
|
13
|
+
*/
|
|
14
|
+
import { computed } from 'vue';
|
|
15
|
+
import type { SectionRenderProps } from '@commonpub/ui';
|
|
16
|
+
|
|
17
|
+
// Loose shape — full LearningPathListItem lives in @commonpub/server
|
|
18
|
+
// types but the section only touches a subset. Keeping local avoids a
|
|
19
|
+
// transient type import that test stubs would need to satisfy.
|
|
20
|
+
interface LearningPathItem {
|
|
21
|
+
id: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
title: string;
|
|
24
|
+
description: string | null;
|
|
25
|
+
coverImageUrl: string | null;
|
|
26
|
+
difficulty: string | null;
|
|
27
|
+
estimatedHours: string | null;
|
|
28
|
+
enrollmentCount: number;
|
|
29
|
+
moduleCount: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface LearningResponse {
|
|
33
|
+
items?: LearningPathItem[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface LearningConfig extends Record<string, unknown> {
|
|
37
|
+
heading: string;
|
|
38
|
+
limit: number;
|
|
39
|
+
columns: 1 | 2 | 3 | 4;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const props = defineProps<SectionRenderProps<LearningConfig>>();
|
|
43
|
+
|
|
44
|
+
const apiQuery = computed(() => ({
|
|
45
|
+
limit: Math.min(Math.max(props.config.limit, 1), 12),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
const { data: paths, pending } = useFetch<LearningResponse>(
|
|
49
|
+
'/api/learn',
|
|
50
|
+
{
|
|
51
|
+
query: apiQuery,
|
|
52
|
+
key: `section-learning:${JSON.stringify(apiQuery.value)}`,
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const items = computed(() => paths.value?.items ?? []);
|
|
57
|
+
const isEmpty = computed(() => !pending.value && items.value.length === 0);
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<template>
|
|
61
|
+
<section
|
|
62
|
+
class="cpub-section-learning"
|
|
63
|
+
:aria-labelledby="config.heading ? `section-learning-${meta.sectionId}` : undefined"
|
|
64
|
+
>
|
|
65
|
+
<h2
|
|
66
|
+
v-if="config.heading"
|
|
67
|
+
:id="`section-learning-${meta.sectionId}`"
|
|
68
|
+
class="cpub-section-learning-heading"
|
|
69
|
+
>
|
|
70
|
+
{{ config.heading }}
|
|
71
|
+
</h2>
|
|
72
|
+
|
|
73
|
+
<div v-if="pending" class="cpub-section-learning-loading">
|
|
74
|
+
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
|
|
75
|
+
<span>Loading…</span>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<ul
|
|
79
|
+
v-else-if="!isEmpty"
|
|
80
|
+
class="cpub-section-learning-grid"
|
|
81
|
+
:data-columns="config.columns"
|
|
82
|
+
>
|
|
83
|
+
<li v-for="path in items" :key="path.id" class="cpub-section-learning-card">
|
|
84
|
+
<NuxtLink :to="`/learn/${path.slug}`" class="cpub-section-learning-link">
|
|
85
|
+
<!--
|
|
86
|
+
Using <img> rather than background-image: (a) Vue auto-escapes
|
|
87
|
+
attribute bindings so a path.coverImageUrl containing `");
|
|
88
|
+
evil(` can't escape the url(...) context (modern browsers
|
|
89
|
+
ignore JS in CSS URLs but still — defence in depth), and (b)
|
|
90
|
+
the cover IS semantically information when present, so giving
|
|
91
|
+
it an `alt` of the path title is better a11y than `role=
|
|
92
|
+
presentation`. Empty alt would also be fine here; the title
|
|
93
|
+
text directly follows.
|
|
94
|
+
-->
|
|
95
|
+
<img
|
|
96
|
+
v-if="path.coverImageUrl"
|
|
97
|
+
:src="path.coverImageUrl"
|
|
98
|
+
:alt="''"
|
|
99
|
+
loading="lazy"
|
|
100
|
+
class="cpub-section-learning-cover"
|
|
101
|
+
/>
|
|
102
|
+
<div class="cpub-section-learning-body">
|
|
103
|
+
<h3 class="cpub-section-learning-title">{{ path.title }}</h3>
|
|
104
|
+
<p v-if="path.description" class="cpub-section-learning-desc">
|
|
105
|
+
{{ path.description }}
|
|
106
|
+
</p>
|
|
107
|
+
<div class="cpub-section-learning-meta">
|
|
108
|
+
<span v-if="path.difficulty" class="cpub-section-learning-chip">
|
|
109
|
+
{{ path.difficulty }}
|
|
110
|
+
</span>
|
|
111
|
+
<span v-if="path.estimatedHours" class="cpub-section-learning-chip">
|
|
112
|
+
<i class="fa-regular fa-clock" aria-hidden="true" />
|
|
113
|
+
{{ path.estimatedHours }}h
|
|
114
|
+
</span>
|
|
115
|
+
<span class="cpub-section-learning-chip">
|
|
116
|
+
{{ path.enrollmentCount }} enrolled
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</NuxtLink>
|
|
121
|
+
</li>
|
|
122
|
+
</ul>
|
|
123
|
+
|
|
124
|
+
<p v-else class="cpub-section-learning-empty">No learning paths yet.</p>
|
|
125
|
+
</section>
|
|
126
|
+
</template>
|
|
127
|
+
|
|
128
|
+
<style scoped>
|
|
129
|
+
.cpub-section-learning {
|
|
130
|
+
display: flex;
|
|
131
|
+
flex-direction: column;
|
|
132
|
+
gap: var(--space-3);
|
|
133
|
+
}
|
|
134
|
+
.cpub-section-learning-heading {
|
|
135
|
+
font-family: var(--font-mono);
|
|
136
|
+
font-size: var(--text-xs);
|
|
137
|
+
font-weight: 700;
|
|
138
|
+
text-transform: uppercase;
|
|
139
|
+
letter-spacing: 0.08em;
|
|
140
|
+
color: var(--text-faint);
|
|
141
|
+
margin: 0;
|
|
142
|
+
padding-bottom: var(--space-2);
|
|
143
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
144
|
+
}
|
|
145
|
+
.cpub-section-learning-grid {
|
|
146
|
+
display: grid;
|
|
147
|
+
gap: var(--space-3);
|
|
148
|
+
list-style: none;
|
|
149
|
+
margin: 0;
|
|
150
|
+
padding: 0;
|
|
151
|
+
}
|
|
152
|
+
.cpub-section-learning-grid[data-columns='1'] { grid-template-columns: 1fr; }
|
|
153
|
+
.cpub-section-learning-grid[data-columns='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
154
|
+
.cpub-section-learning-grid[data-columns='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
155
|
+
.cpub-section-learning-grid[data-columns='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
156
|
+
|
|
157
|
+
@media (max-width: 1024px) {
|
|
158
|
+
.cpub-section-learning-grid[data-columns='3'],
|
|
159
|
+
.cpub-section-learning-grid[data-columns='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
160
|
+
}
|
|
161
|
+
@media (max-width: 640px) {
|
|
162
|
+
.cpub-section-learning-grid { grid-template-columns: 1fr; }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.cpub-section-learning-card {
|
|
166
|
+
background: var(--surface);
|
|
167
|
+
border: var(--border-width-default) solid var(--border);
|
|
168
|
+
overflow: hidden;
|
|
169
|
+
}
|
|
170
|
+
.cpub-section-learning-link {
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-direction: column;
|
|
173
|
+
color: inherit;
|
|
174
|
+
text-decoration: none;
|
|
175
|
+
}
|
|
176
|
+
.cpub-section-learning-cover {
|
|
177
|
+
display: block;
|
|
178
|
+
width: 100%;
|
|
179
|
+
aspect-ratio: 16 / 9;
|
|
180
|
+
object-fit: cover;
|
|
181
|
+
background-color: var(--surface-2);
|
|
182
|
+
}
|
|
183
|
+
.cpub-section-learning-body {
|
|
184
|
+
padding: var(--space-3);
|
|
185
|
+
display: flex;
|
|
186
|
+
flex-direction: column;
|
|
187
|
+
gap: var(--space-2);
|
|
188
|
+
}
|
|
189
|
+
.cpub-section-learning-title {
|
|
190
|
+
font-size: var(--text-base);
|
|
191
|
+
font-weight: 700;
|
|
192
|
+
color: var(--text);
|
|
193
|
+
margin: 0;
|
|
194
|
+
}
|
|
195
|
+
.cpub-section-learning-link:hover .cpub-section-learning-title { color: var(--accent); }
|
|
196
|
+
.cpub-section-learning-desc {
|
|
197
|
+
font-size: var(--text-sm);
|
|
198
|
+
color: var(--text-soft);
|
|
199
|
+
margin: 0;
|
|
200
|
+
display: -webkit-box;
|
|
201
|
+
-webkit-line-clamp: 2;
|
|
202
|
+
-webkit-box-orient: vertical;
|
|
203
|
+
overflow: hidden;
|
|
204
|
+
}
|
|
205
|
+
.cpub-section-learning-meta {
|
|
206
|
+
display: flex;
|
|
207
|
+
flex-wrap: wrap;
|
|
208
|
+
gap: var(--space-2);
|
|
209
|
+
}
|
|
210
|
+
.cpub-section-learning-chip {
|
|
211
|
+
font-family: var(--font-mono);
|
|
212
|
+
font-size: var(--text-xxs);
|
|
213
|
+
text-transform: uppercase;
|
|
214
|
+
letter-spacing: 0.06em;
|
|
215
|
+
color: var(--text-faint);
|
|
216
|
+
padding: var(--space-1) var(--space-2);
|
|
217
|
+
border: var(--border-width-default) solid var(--border-soft);
|
|
218
|
+
display: inline-flex;
|
|
219
|
+
align-items: center;
|
|
220
|
+
gap: var(--space-1);
|
|
221
|
+
}
|
|
222
|
+
.cpub-section-learning-loading,
|
|
223
|
+
.cpub-section-learning-empty {
|
|
224
|
+
display: flex;
|
|
225
|
+
align-items: center;
|
|
226
|
+
justify-content: center;
|
|
227
|
+
gap: var(--space-2);
|
|
228
|
+
padding: var(--space-6);
|
|
229
|
+
color: var(--text-faint);
|
|
230
|
+
font-size: var(--text-sm);
|
|
231
|
+
}
|
|
232
|
+
</style>
|