@commonpub/layer 0.23.3 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/sections/SectionContests.vue +193 -0
- package/components/sections/SectionCustomHtml.vue +70 -0
- package/components/sections/SectionEditorial.vue +138 -0
- package/components/sections/SectionHubs.vue +247 -0
- package/components/sections/SectionLearning.vue +232 -0
- package/components/sections/SectionStats.vue +151 -0
- package/composables/useFeatures.ts +32 -5
- package/composables/useLayout.ts +12 -2
- package/nuxt.config.ts +14 -0
- package/package.json +6 -6
- package/sections/builtin/contests.ts +38 -0
- package/sections/builtin/custom-html.ts +50 -0
- package/sections/builtin/editorial.ts +39 -0
- package/sections/builtin/hubs.ts +45 -0
- package/sections/builtin/learning.ts +38 -0
- package/sections/builtin/stats.ts +36 -0
- package/sections/registry.ts +27 -7
- package/server/api/admin/layouts/migrate-homepage.post.ts +56 -0
- package/server/plugins/feature-flags-prime.ts +39 -0
|
@@ -0,0 +1,193 @@
|
|
|
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>
|
|
@@ -0,0 +1,70 @@
|
|
|
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>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Built-in section: editorial — a Staff-Picks grid backed by /api/content.
|
|
4
|
+
*
|
|
5
|
+
* Identical query pattern to content-feed but with `editorial=true` +
|
|
6
|
+
* `sort=editorial` baked in. Renders `<ContentCard>` so it matches the
|
|
7
|
+
* surrounding visual identity.
|
|
8
|
+
*
|
|
9
|
+
* Non-await `useFetch` per the session-158 pitfall (top-level await
|
|
10
|
+
* inside `<LayoutSlot>` requires Suspense, which neither prod render
|
|
11
|
+
* nor editor preview wraps). Pending / empty / loaded surfaced via the
|
|
12
|
+
* template.
|
|
13
|
+
*
|
|
14
|
+
* `var(--*)` only.
|
|
15
|
+
*/
|
|
16
|
+
import { computed } from 'vue';
|
|
17
|
+
import type { PaginatedResponse, Serialized, ContentListItem } from '@commonpub/server';
|
|
18
|
+
import type { SectionRenderProps } from '@commonpub/ui';
|
|
19
|
+
|
|
20
|
+
interface EditorialConfig extends Record<string, unknown> {
|
|
21
|
+
heading: string;
|
|
22
|
+
limit: number;
|
|
23
|
+
columns: 1 | 2 | 3 | 4;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const props = defineProps<SectionRenderProps<EditorialConfig>>();
|
|
27
|
+
|
|
28
|
+
const apiQuery = computed(() => ({
|
|
29
|
+
status: 'published' as const,
|
|
30
|
+
editorial: true,
|
|
31
|
+
sort: 'editorial' as const,
|
|
32
|
+
limit: Math.min(Math.max(props.config.limit, 1), 12),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const fetchKey = computed(
|
|
36
|
+
() => `section-editorial:${JSON.stringify(apiQuery.value)}`,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const { data: editorialPicks, pending } = useFetch<PaginatedResponse<Serialized<ContentListItem>>>(
|
|
40
|
+
'/api/content',
|
|
41
|
+
{
|
|
42
|
+
query: apiQuery,
|
|
43
|
+
key: fetchKey.value,
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const items = computed(() => editorialPicks.value?.items ?? []);
|
|
48
|
+
const isEmpty = computed(() => !pending.value && items.value.length === 0);
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<section
|
|
53
|
+
class="cpub-section-editorial"
|
|
54
|
+
:aria-labelledby="config.heading ? `section-editorial-${meta.sectionId}` : undefined"
|
|
55
|
+
>
|
|
56
|
+
<h2
|
|
57
|
+
v-if="config.heading"
|
|
58
|
+
:id="`section-editorial-${meta.sectionId}`"
|
|
59
|
+
class="cpub-section-editorial-heading"
|
|
60
|
+
>
|
|
61
|
+
<i class="fa-solid fa-pen-fancy" aria-hidden="true" />
|
|
62
|
+
{{ config.heading }}
|
|
63
|
+
</h2>
|
|
64
|
+
|
|
65
|
+
<div v-if="pending" class="cpub-section-editorial-loading">
|
|
66
|
+
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
|
|
67
|
+
<span>Loading…</span>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div
|
|
71
|
+
v-else-if="!isEmpty"
|
|
72
|
+
class="cpub-section-editorial-grid"
|
|
73
|
+
:data-columns="config.columns"
|
|
74
|
+
>
|
|
75
|
+
<ContentCard
|
|
76
|
+
v-for="item in items"
|
|
77
|
+
:key="item.id"
|
|
78
|
+
:item="item"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<p v-else class="cpub-section-editorial-empty">
|
|
83
|
+
No staff picks yet.
|
|
84
|
+
</p>
|
|
85
|
+
</section>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
<style scoped>
|
|
89
|
+
.cpub-section-editorial {
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
gap: var(--space-3);
|
|
93
|
+
}
|
|
94
|
+
.cpub-section-editorial-heading {
|
|
95
|
+
font-family: var(--font-mono);
|
|
96
|
+
font-size: var(--text-xs);
|
|
97
|
+
font-weight: 700;
|
|
98
|
+
text-transform: uppercase;
|
|
99
|
+
letter-spacing: 0.08em;
|
|
100
|
+
color: var(--accent);
|
|
101
|
+
margin: 0;
|
|
102
|
+
padding-bottom: var(--space-2);
|
|
103
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: var(--space-2);
|
|
107
|
+
}
|
|
108
|
+
.cpub-section-editorial-heading i {
|
|
109
|
+
font-size: 0.9em;
|
|
110
|
+
}
|
|
111
|
+
.cpub-section-editorial-grid {
|
|
112
|
+
display: grid;
|
|
113
|
+
gap: var(--space-3);
|
|
114
|
+
}
|
|
115
|
+
.cpub-section-editorial-grid[data-columns='1'] { grid-template-columns: 1fr; }
|
|
116
|
+
.cpub-section-editorial-grid[data-columns='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
117
|
+
.cpub-section-editorial-grid[data-columns='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
118
|
+
.cpub-section-editorial-grid[data-columns='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
119
|
+
|
|
120
|
+
@media (max-width: 1024px) {
|
|
121
|
+
.cpub-section-editorial-grid[data-columns='3'],
|
|
122
|
+
.cpub-section-editorial-grid[data-columns='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
123
|
+
}
|
|
124
|
+
@media (max-width: 640px) {
|
|
125
|
+
.cpub-section-editorial-grid { grid-template-columns: 1fr; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.cpub-section-editorial-loading,
|
|
129
|
+
.cpub-section-editorial-empty {
|
|
130
|
+
display: flex;
|
|
131
|
+
align-items: center;
|
|
132
|
+
justify-content: center;
|
|
133
|
+
gap: var(--space-2);
|
|
134
|
+
padding: var(--space-6);
|
|
135
|
+
color: var(--text-faint);
|
|
136
|
+
font-size: var(--text-sm);
|
|
137
|
+
}
|
|
138
|
+
</style>
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Built-in section: hubs — trending hubs list with join action.
|
|
4
|
+
*
|
|
5
|
+
* Fetches `/api/hubs?limit=N`, renders sidebar-style card. Mirrors
|
|
6
|
+
* legacy `HubsSection.vue`: icon, name, member count, Join CTA per row.
|
|
7
|
+
*
|
|
8
|
+
* Join flow:
|
|
9
|
+
* - Anonymous visitor → navigate to /auth/login?redirect=/
|
|
10
|
+
* - Authenticated → POST /api/hubs/:slug/join, flip local state to
|
|
11
|
+
* "Joined" + show toast
|
|
12
|
+
* - Failure → error toast, leave button as Join
|
|
13
|
+
*
|
|
14
|
+
* Non-await useFetch + reactive local `joinedHubs` set per session-158
|
|
15
|
+
* pattern. `var(--*)` only.
|
|
16
|
+
*/
|
|
17
|
+
import { computed, ref } from 'vue';
|
|
18
|
+
import type { SectionRenderProps } from '@commonpub/ui';
|
|
19
|
+
|
|
20
|
+
interface HubItem {
|
|
21
|
+
id: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
name: string;
|
|
24
|
+
iconUrl?: string | null;
|
|
25
|
+
memberCount?: number | null;
|
|
26
|
+
source?: 'local' | 'federated';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface HubsResponse {
|
|
30
|
+
items?: HubItem[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface HubsConfig extends Record<string, unknown> {
|
|
34
|
+
heading: string;
|
|
35
|
+
limit: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const props = defineProps<SectionRenderProps<HubsConfig>>();
|
|
39
|
+
|
|
40
|
+
const apiQuery = computed(() => ({
|
|
41
|
+
limit: Math.min(Math.max(props.config.limit, 1), 20),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const { data: hubs, pending } = useFetch<HubsResponse>(
|
|
45
|
+
'/api/hubs',
|
|
46
|
+
{
|
|
47
|
+
query: apiQuery,
|
|
48
|
+
key: `section-hubs:${JSON.stringify(apiQuery.value)}`,
|
|
49
|
+
// Sidebar widget — lazy so initial SSR doesn't block on the trending-
|
|
50
|
+
// hubs query. Pending state renders a spinner; hub list pops in
|
|
51
|
+
// client-side after first paint. Matches the legacy HubsSection.vue
|
|
52
|
+
// pattern that we're replacing.
|
|
53
|
+
lazy: true,
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const items = computed(() => hubs.value?.items ?? []);
|
|
58
|
+
const isEmpty = computed(() => !pending.value && items.value.length === 0);
|
|
59
|
+
|
|
60
|
+
const { user } = useAuth();
|
|
61
|
+
const isAuthenticated = computed(() => !!user.value);
|
|
62
|
+
const joinedHubs = ref(new Set<string>());
|
|
63
|
+
const toast = useToast();
|
|
64
|
+
|
|
65
|
+
async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
66
|
+
if (!isAuthenticated.value) {
|
|
67
|
+
await navigateTo('/auth/login?redirect=/');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await $fetch(`/api/hubs/${hubSlug}/join`, { method: 'POST' });
|
|
72
|
+
joinedHubs.value.add(hubSlug);
|
|
73
|
+
toast.success('Joined hub!');
|
|
74
|
+
} catch {
|
|
75
|
+
toast.error('Failed to join hub');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hubHref(hub: HubItem): string {
|
|
80
|
+
return hub.source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`;
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<template>
|
|
85
|
+
<section
|
|
86
|
+
class="cpub-section-hubs"
|
|
87
|
+
:aria-labelledby="config.heading ? `section-hubs-${meta.sectionId}` : undefined"
|
|
88
|
+
>
|
|
89
|
+
<header
|
|
90
|
+
v-if="config.heading"
|
|
91
|
+
class="cpub-section-hubs-header"
|
|
92
|
+
>
|
|
93
|
+
<h2
|
|
94
|
+
:id="`section-hubs-${meta.sectionId}`"
|
|
95
|
+
class="cpub-section-hubs-heading"
|
|
96
|
+
>
|
|
97
|
+
{{ config.heading }}
|
|
98
|
+
</h2>
|
|
99
|
+
<NuxtLink to="/hubs" class="cpub-section-hubs-browse">Browse</NuxtLink>
|
|
100
|
+
</header>
|
|
101
|
+
|
|
102
|
+
<div v-if="pending" class="cpub-section-hubs-loading">
|
|
103
|
+
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<ul v-else-if="!isEmpty" class="cpub-section-hubs-list">
|
|
107
|
+
<li v-for="hub in items" :key="hub.id" class="cpub-section-hubs-item">
|
|
108
|
+
<div class="cpub-section-hubs-icon">
|
|
109
|
+
<img v-if="hub.iconUrl" :src="hub.iconUrl" :alt="hub.name" />
|
|
110
|
+
<i v-else class="fa-solid fa-users" aria-hidden="true" />
|
|
111
|
+
</div>
|
|
112
|
+
<div class="cpub-section-hubs-info">
|
|
113
|
+
<NuxtLink :to="hubHref(hub)" class="cpub-section-hubs-name">{{ hub.name }}</NuxtLink>
|
|
114
|
+
<div class="cpub-section-hubs-members">{{ hub.memberCount ?? 0 }} members</div>
|
|
115
|
+
</div>
|
|
116
|
+
<button
|
|
117
|
+
v-if="joinedHubs.has(hub.slug)"
|
|
118
|
+
type="button"
|
|
119
|
+
class="cpub-section-hubs-joined"
|
|
120
|
+
disabled
|
|
121
|
+
:aria-label="`Already joined ${hub.name}`"
|
|
122
|
+
>
|
|
123
|
+
<i class="fa-solid fa-check" aria-hidden="true" /> Joined
|
|
124
|
+
</button>
|
|
125
|
+
<button
|
|
126
|
+
v-else
|
|
127
|
+
type="button"
|
|
128
|
+
class="cpub-section-hubs-join"
|
|
129
|
+
:aria-label="`Join ${hub.name}`"
|
|
130
|
+
@click.prevent="handleHubJoin(hub.slug)"
|
|
131
|
+
>
|
|
132
|
+
Join
|
|
133
|
+
</button>
|
|
134
|
+
</li>
|
|
135
|
+
</ul>
|
|
136
|
+
|
|
137
|
+
<p v-else class="cpub-section-hubs-empty">No hubs yet.</p>
|
|
138
|
+
</section>
|
|
139
|
+
</template>
|
|
140
|
+
|
|
141
|
+
<style scoped>
|
|
142
|
+
.cpub-section-hubs {
|
|
143
|
+
background: var(--surface);
|
|
144
|
+
border: var(--border-width-default) solid var(--border);
|
|
145
|
+
padding: var(--space-4);
|
|
146
|
+
}
|
|
147
|
+
.cpub-section-hubs-header {
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
justify-content: space-between;
|
|
151
|
+
padding-bottom: var(--space-2);
|
|
152
|
+
border-bottom: var(--border-width-default) solid var(--border-soft);
|
|
153
|
+
margin-bottom: var(--space-3);
|
|
154
|
+
}
|
|
155
|
+
.cpub-section-hubs-heading {
|
|
156
|
+
font-family: var(--font-mono);
|
|
157
|
+
font-size: var(--text-xxs);
|
|
158
|
+
font-weight: 700;
|
|
159
|
+
text-transform: uppercase;
|
|
160
|
+
letter-spacing: 0.08em;
|
|
161
|
+
color: var(--text-faint);
|
|
162
|
+
margin: 0;
|
|
163
|
+
}
|
|
164
|
+
.cpub-section-hubs-browse {
|
|
165
|
+
font-family: var(--font-mono);
|
|
166
|
+
font-size: var(--text-xxs);
|
|
167
|
+
color: var(--accent);
|
|
168
|
+
text-decoration: none;
|
|
169
|
+
}
|
|
170
|
+
.cpub-section-hubs-list {
|
|
171
|
+
list-style: none;
|
|
172
|
+
margin: 0;
|
|
173
|
+
padding: 0;
|
|
174
|
+
}
|
|
175
|
+
.cpub-section-hubs-item {
|
|
176
|
+
display: flex;
|
|
177
|
+
align-items: center;
|
|
178
|
+
gap: var(--space-2);
|
|
179
|
+
padding: var(--space-2) 0;
|
|
180
|
+
border-bottom: var(--border-width-default) solid var(--border-soft);
|
|
181
|
+
}
|
|
182
|
+
.cpub-section-hubs-item:last-child { border-bottom: none; }
|
|
183
|
+
|
|
184
|
+
.cpub-section-hubs-icon {
|
|
185
|
+
width: 32px;
|
|
186
|
+
height: 32px;
|
|
187
|
+
background: var(--accent-bg);
|
|
188
|
+
border: var(--border-width-default) solid var(--border);
|
|
189
|
+
display: flex;
|
|
190
|
+
align-items: center;
|
|
191
|
+
justify-content: center;
|
|
192
|
+
font-size: var(--text-xs);
|
|
193
|
+
color: var(--accent);
|
|
194
|
+
flex-shrink: 0;
|
|
195
|
+
overflow: hidden;
|
|
196
|
+
}
|
|
197
|
+
.cpub-section-hubs-icon img {
|
|
198
|
+
width: 100%;
|
|
199
|
+
height: 100%;
|
|
200
|
+
object-fit: cover;
|
|
201
|
+
}
|
|
202
|
+
.cpub-section-hubs-info { flex: 1; min-width: 0; }
|
|
203
|
+
.cpub-section-hubs-name {
|
|
204
|
+
font-size: var(--text-sm);
|
|
205
|
+
font-weight: 600;
|
|
206
|
+
color: var(--text);
|
|
207
|
+
text-decoration: none;
|
|
208
|
+
display: block;
|
|
209
|
+
}
|
|
210
|
+
.cpub-section-hubs-name:hover { color: var(--accent); }
|
|
211
|
+
.cpub-section-hubs-members {
|
|
212
|
+
font-family: var(--font-mono);
|
|
213
|
+
font-size: var(--text-xxs);
|
|
214
|
+
color: var(--text-faint);
|
|
215
|
+
}
|
|
216
|
+
.cpub-section-hubs-join,
|
|
217
|
+
.cpub-section-hubs-joined {
|
|
218
|
+
font-family: var(--font-mono);
|
|
219
|
+
font-size: var(--text-xxs);
|
|
220
|
+
text-transform: uppercase;
|
|
221
|
+
letter-spacing: 0.06em;
|
|
222
|
+
padding: var(--space-1) var(--space-2);
|
|
223
|
+
border: var(--border-width-default) solid var(--accent);
|
|
224
|
+
color: var(--accent);
|
|
225
|
+
background: none;
|
|
226
|
+
cursor: pointer;
|
|
227
|
+
}
|
|
228
|
+
.cpub-section-hubs-join:hover { background: var(--accent-bg); }
|
|
229
|
+
.cpub-section-hubs-joined {
|
|
230
|
+
border-color: var(--green-border);
|
|
231
|
+
color: var(--green);
|
|
232
|
+
background: var(--green-bg);
|
|
233
|
+
cursor: default;
|
|
234
|
+
display: flex;
|
|
235
|
+
align-items: center;
|
|
236
|
+
gap: var(--space-1);
|
|
237
|
+
}
|
|
238
|
+
.cpub-section-hubs-loading,
|
|
239
|
+
.cpub-section-hubs-empty {
|
|
240
|
+
display: flex;
|
|
241
|
+
align-items: center;
|
|
242
|
+
justify-content: center;
|
|
243
|
+
padding: var(--space-4);
|
|
244
|
+
color: var(--text-faint);
|
|
245
|
+
font-size: var(--text-sm);
|
|
246
|
+
}
|
|
247
|
+
</style>
|