@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.
Files changed (82) hide show
  1. package/README.md +41 -12
  2. package/components/LayoutRow.vue +944 -0
  3. package/components/LayoutSection.vue +1028 -0
  4. package/components/LayoutSlot.vue +104 -162
  5. package/components/PageFrame.vue +116 -0
  6. package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
  7. package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
  8. package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
  9. package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
  10. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
  11. package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
  13. package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
  14. package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
  15. package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
  16. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
  17. package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
  18. package/components/blocks/BlockDividerView.vue +52 -2
  19. package/components/homepage/ContentGridSection.vue +23 -1
  20. package/components/homepage/HeroSection.vue +69 -8
  21. package/components/sections/SectionCta.vue +175 -0
  22. package/composables/autoFormSchema.ts +319 -0
  23. package/composables/useAdminSidebar.ts +116 -0
  24. package/composables/useEditorChrome.ts +56 -0
  25. package/composables/useLayout.ts +34 -41
  26. package/composables/useLayoutAnnouncer.ts +332 -0
  27. package/composables/useLayoutAutoSave.ts +117 -0
  28. package/composables/useLayoutDrag.ts +290 -0
  29. package/composables/useLayoutEditor.ts +593 -0
  30. package/composables/useLayoutHistory.ts +583 -0
  31. package/composables/useLayoutHotkeys.ts +366 -0
  32. package/composables/useLayoutResize.ts +783 -0
  33. package/layouts/admin.vue +137 -24
  34. package/middleware/admin-layouts.ts +29 -0
  35. package/package.json +10 -7
  36. package/pages/[...customPath].vue +154 -0
  37. package/pages/admin/homepage.vue +46 -0
  38. package/pages/admin/index.vue +16 -0
  39. package/pages/admin/layouts/[id].vue +1110 -0
  40. package/pages/admin/layouts/index.vue +356 -0
  41. package/pages/explore.vue +16 -6
  42. package/sections/builtin/content-feed.ts +18 -29
  43. package/sections/builtin/contests.ts +11 -19
  44. package/sections/builtin/cta.ts +46 -0
  45. package/sections/builtin/custom-html.ts +16 -30
  46. package/sections/builtin/divider.ts +15 -17
  47. package/sections/builtin/editorial.ts +11 -21
  48. package/sections/builtin/embed.ts +31 -0
  49. package/sections/builtin/gallery.ts +29 -0
  50. package/sections/builtin/heading.ts +14 -19
  51. package/sections/builtin/hero.ts +16 -51
  52. package/sections/builtin/hubs.ts +11 -26
  53. package/sections/builtin/image.ts +12 -49
  54. package/sections/builtin/learning.ts +5 -13
  55. package/sections/builtin/markdown.ts +29 -0
  56. package/sections/builtin/paragraph.ts +14 -17
  57. package/sections/builtin/stats.ts +17 -18
  58. package/sections/builtin/video.ts +30 -0
  59. package/sections/registry.ts +11 -0
  60. package/server/api/admin/homepage/sections.put.ts +52 -1
  61. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  62. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  63. package/server/api/admin/layouts/[id].delete.ts +33 -1
  64. package/server/api/admin/layouts/[id].put.ts +78 -0
  65. package/server/api/admin/layouts/index.post.ts +60 -4
  66. package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
  67. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  68. package/server/api/layouts/by-route.get.ts +64 -12
  69. package/server/utils/layoutCache.ts +37 -1
  70. package/server/utils/validateSectionConfigs.ts +123 -0
  71. package/theme/base.css +1 -0
  72. package/components/sections/SectionContentFeed.vue +0 -160
  73. package/components/sections/SectionContests.vue +0 -193
  74. package/components/sections/SectionCustomHtml.vue +0 -70
  75. package/components/sections/SectionDivider.vue +0 -55
  76. package/components/sections/SectionEditorial.vue +0 -138
  77. package/components/sections/SectionHeading.vue +0 -78
  78. package/components/sections/SectionHero.vue +0 -164
  79. package/components/sections/SectionHubs.vue +0 -247
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
  82. package/components/sections/SectionStats.vue +0 -151
@@ -1,138 +0,0 @@
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>
@@ -1,78 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Built-in section: heading — a single configurable heading (h1–h4)
4
- * with optional eyebrow + subline.
5
- *
6
- * Phase 1c starter. Semantic level is admin-chosen — the auto-form
7
- * inspector (Phase 3e) will warn on multiple h1s per layout.
8
- *
9
- * `var(--*)` only.
10
- */
11
- import { computed } from 'vue';
12
- import type { SectionRenderProps } from '@commonpub/ui';
13
-
14
- interface HeadingConfig extends Record<string, unknown> {
15
- text: string;
16
- level: 1 | 2 | 3 | 4;
17
- align: 'left' | 'center';
18
- eyebrow: string;
19
- subline: string;
20
- }
21
-
22
- const props = defineProps<SectionRenderProps<HeadingConfig>>();
23
-
24
- // Vue's <component :is> with a tag string handles the level swap without
25
- // needing a v-if chain. Defensive clamp: an out-of-range level (shouldn't
26
- // happen — Zod gates it on write) falls back to h2.
27
- const headingTag = computed(() => {
28
- const n = props.config.level;
29
- return n >= 1 && n <= 4 ? `h${n}` : 'h2';
30
- });
31
- </script>
32
-
33
- <template>
34
- <section
35
- class="cpub-section-heading"
36
- :data-align="config.align"
37
- :aria-labelledby="`section-heading-${meta.sectionId}`"
38
- >
39
- <p v-if="config.eyebrow" class="cpub-section-heading-eyebrow">{{ config.eyebrow }}</p>
40
- <component
41
- :is="headingTag"
42
- :id="`section-heading-${meta.sectionId}`"
43
- class="cpub-section-heading-text"
44
- >
45
- {{ config.text }}
46
- </component>
47
- <p v-if="config.subline" class="cpub-section-heading-subline">{{ config.subline }}</p>
48
- </section>
49
- </template>
50
-
51
- <style scoped>
52
- .cpub-section-heading {
53
- margin-block: var(--space-4);
54
- }
55
- .cpub-section-heading[data-align='center'] {
56
- text-align: center;
57
- }
58
- .cpub-section-heading-eyebrow {
59
- font-family: var(--font-mono);
60
- font-size: var(--text-xs);
61
- text-transform: uppercase;
62
- letter-spacing: 0.1em;
63
- color: var(--text-faint);
64
- margin: 0 0 var(--space-2);
65
- }
66
- .cpub-section-heading-text {
67
- margin: 0;
68
- font-weight: 700;
69
- line-height: 1.25;
70
- color: var(--text);
71
- }
72
- .cpub-section-heading-subline {
73
- margin: var(--space-2) 0 0;
74
- color: var(--text-dim);
75
- font-size: var(--text-md);
76
- line-height: 1.6;
77
- }
78
- </style>
@@ -1,164 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Built-in section: hero — banner with title, optional eyebrow + subtitle,
4
- * up to two CTAs.
5
- *
6
- * Phase 1c starter. Variants: `default` (left-aligned, grid backdrop),
7
- * `compact` (smaller padding, no backdrop), `centered` (centered text
8
- * + CTAs, grid backdrop).
9
- *
10
- * Intentionally does NOT replicate the existing `HomepageHeroSection`'s
11
- * contest-dispatch logic — that becomes its own data-aware section in
12
- * Phase 6b (`contest-feature` or similar). This hero is pure
13
- * config-driven so the editor preview is deterministic.
14
- *
15
- * `var(--*)` only.
16
- */
17
- import type { SectionRenderProps } from '@commonpub/ui';
18
-
19
- interface HeroCta {
20
- label: string;
21
- href: string;
22
- variant: 'primary' | 'secondary';
23
- }
24
-
25
- interface HeroConfig extends Record<string, unknown> {
26
- variant: 'default' | 'compact' | 'centered';
27
- eyebrow: string;
28
- title: string;
29
- subtitle: string;
30
- ctas: HeroCta[];
31
- }
32
-
33
- defineProps<SectionRenderProps<HeroConfig>>();
34
- </script>
35
-
36
- <template>
37
- <section
38
- class="cpub-section-hero"
39
- :data-variant="config.variant"
40
- :aria-labelledby="`section-hero-${meta.sectionId}`"
41
- >
42
- <div v-if="config.variant !== 'compact'" class="cpub-section-hero-grid-bg" aria-hidden="true" />
43
- <div class="cpub-section-hero-inner">
44
- <div class="cpub-section-hero-content">
45
- <p v-if="config.eyebrow" class="cpub-section-hero-eyebrow">{{ config.eyebrow }}</p>
46
- <h1
47
- :id="`section-hero-${meta.sectionId}`"
48
- class="cpub-section-hero-title"
49
- >
50
- {{ config.title }}
51
- </h1>
52
- <p v-if="config.subtitle" class="cpub-section-hero-subtitle">{{ config.subtitle }}</p>
53
- <div v-if="config.ctas.length > 0" class="cpub-section-hero-actions">
54
- <NuxtLink
55
- v-for="(cta, i) in config.ctas"
56
- :key="i"
57
- :to="cta.href"
58
- class="cpub-btn"
59
- :class="cta.variant === 'primary' ? 'cpub-btn-primary' : ''"
60
- >
61
- {{ cta.label }}
62
- </NuxtLink>
63
- </div>
64
- </div>
65
- </div>
66
- </section>
67
- </template>
68
-
69
- <style scoped>
70
- .cpub-section-hero {
71
- position: relative;
72
- background: var(--surface);
73
- border-bottom: var(--border-width-default) solid var(--border);
74
- overflow: hidden;
75
- min-height: 180px;
76
- display: flex;
77
- align-items: stretch;
78
- }
79
- .cpub-section-hero[data-variant='compact'] {
80
- min-height: 120px;
81
- border-bottom: none;
82
- }
83
-
84
- .cpub-section-hero-grid-bg {
85
- position: absolute;
86
- inset: 0;
87
- background-image:
88
- linear-gradient(var(--border2) 1px, transparent 1px),
89
- linear-gradient(90deg, var(--border2) 1px, transparent 1px);
90
- background-size: 32px 32px;
91
- opacity: 0.25;
92
- pointer-events: none;
93
- }
94
-
95
- .cpub-section-hero-inner {
96
- position: relative;
97
- z-index: 1;
98
- max-width: 1280px;
99
- margin: 0 auto;
100
- padding: var(--space-6) var(--space-6);
101
- width: 100%;
102
- display: flex;
103
- align-items: center;
104
- }
105
- .cpub-section-hero[data-variant='compact'] .cpub-section-hero-inner {
106
- padding: var(--space-4) var(--space-6);
107
- }
108
-
109
- .cpub-section-hero-content { flex: 1; min-width: 0; }
110
-
111
- .cpub-section-hero[data-variant='centered'] .cpub-section-hero-inner {
112
- justify-content: center;
113
- }
114
- .cpub-section-hero[data-variant='centered'] .cpub-section-hero-content {
115
- text-align: center;
116
- max-width: 720px;
117
- }
118
-
119
- .cpub-section-hero-eyebrow {
120
- font-family: var(--font-mono);
121
- font-size: var(--text-xs);
122
- text-transform: uppercase;
123
- letter-spacing: 0.1em;
124
- color: var(--text-faint);
125
- margin: 0 0 var(--space-2);
126
- }
127
-
128
- .cpub-section-hero-title {
129
- font-size: var(--text-3xl);
130
- font-weight: 700;
131
- line-height: 1.2;
132
- margin: 0 0 var(--space-2);
133
- color: var(--text);
134
- }
135
- .cpub-section-hero[data-variant='compact'] .cpub-section-hero-title {
136
- font-size: var(--text-2xl);
137
- }
138
-
139
- .cpub-section-hero-subtitle {
140
- font-size: var(--text-md);
141
- color: var(--text-dim);
142
- line-height: 1.6;
143
- margin: 0 0 var(--space-4);
144
- max-width: 560px;
145
- }
146
- .cpub-section-hero[data-variant='centered'] .cpub-section-hero-subtitle {
147
- margin-inline: auto;
148
- }
149
-
150
- .cpub-section-hero-actions {
151
- display: flex;
152
- flex-wrap: wrap;
153
- gap: var(--space-2);
154
- }
155
- .cpub-section-hero[data-variant='centered'] .cpub-section-hero-actions {
156
- justify-content: center;
157
- }
158
-
159
- @media (max-width: 640px) {
160
- .cpub-section-hero-inner { padding: var(--space-4); }
161
- .cpub-section-hero-title { font-size: var(--text-2xl); }
162
- .cpub-section-hero-actions { width: 100%; }
163
- }
164
- </style>
@@ -1,247 +0,0 @@
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>