@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.
@@ -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>