@commonpub/layer 0.10.1 → 0.11.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,133 @@
1
+ <script setup lang="ts">
2
+ import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/server';
3
+ import type { HomepageSectionConfig } from '@commonpub/server';
4
+
5
+ const props = defineProps<{
6
+ config: HomepageSectionConfig;
7
+ title?: string;
8
+ }>();
9
+
10
+ const { user: authUser } = useAuth();
11
+ const { enabledTypeMeta } = useContentTypes();
12
+ const toast = useToast();
13
+
14
+ const activeTab = ref(authUser.value ? 'foryou' : 'latest');
15
+ const tabs = computed(() => [
16
+ { value: 'foryou', label: 'For You', icon: 'fa-solid fa-sparkles' },
17
+ { value: 'latest', label: 'Latest', icon: 'fa-solid fa-clock' },
18
+ { value: 'following', label: 'Following', icon: 'fa-solid fa-user-group' },
19
+ ...enabledTypeMeta.value.map(ct => ({ value: ct.type, label: ct.plural, icon: ct.icon })),
20
+ ]);
21
+
22
+ const limit = computed(() => props.config.limit ?? 12);
23
+
24
+ const contentQuery = computed(() => ({
25
+ status: 'published',
26
+ type: ['foryou', 'latest', 'following'].includes(activeTab.value)
27
+ ? (props.config.contentType || undefined)
28
+ : activeTab.value,
29
+ sort: activeTab.value === 'latest' ? 'recent' : activeTab.value === 'following' ? 'recent' : (props.config.sort ?? 'popular'),
30
+ ...(activeTab.value === 'following' && authUser.value?.id ? { followedBy: authUser.value.id } : {}),
31
+ ...(props.config.categorySlug ? { categorySlug: props.config.categorySlug } : {}),
32
+ limit: limit.value,
33
+ }));
34
+
35
+ const { data: feed, pending: feedPending } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
36
+ query: contentQuery,
37
+ watch: [contentQuery],
38
+ });
39
+
40
+ const loadingMore = ref(false);
41
+ const allLoaded = ref(false);
42
+
43
+ watch(activeTab, () => { allLoaded.value = false; });
44
+
45
+ async function loadMore(): Promise<void> {
46
+ loadingMore.value = true;
47
+ try {
48
+ const nextOffset = (feed.value?.items?.length ?? 0);
49
+ const more = await $fetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
50
+ query: { ...contentQuery.value, offset: nextOffset },
51
+ });
52
+ if (more.items?.length && feed.value?.items) {
53
+ feed.value.items.push(...more.items);
54
+ }
55
+ if (!more.items?.length || more.items.length < limit.value) {
56
+ allLoaded.value = true;
57
+ }
58
+ } catch {
59
+ toast.error('Failed to load more');
60
+ } finally {
61
+ loadingMore.value = false;
62
+ }
63
+ }
64
+
65
+ const isAuthenticated = computed(() => !!authUser.value);
66
+ const columns = computed(() => props.config.columns ?? 2);
67
+ </script>
68
+
69
+ <template>
70
+ <div>
71
+ <!-- Tabs -->
72
+ <div class="cpub-tabs-bar">
73
+ <div class="cpub-tabs-inner">
74
+ <button
75
+ v-for="tab in tabs"
76
+ :key="tab.value"
77
+ class="cpub-tab"
78
+ :class="{ active: activeTab === tab.value }"
79
+ @click="activeTab = tab.value"
80
+ >
81
+ {{ tab.label }}
82
+ </button>
83
+ </div>
84
+ </div>
85
+
86
+ <!-- Grid -->
87
+ <div v-if="feedPending" class="cpub-loading-state">
88
+ <i class="fa-solid fa-circle-notch fa-spin"></i> Loading content...
89
+ </div>
90
+ <div v-else-if="feed?.items?.length" class="cpub-content-grid" :style="{ '--grid-cols': columns }">
91
+ <ContentCard v-for="item in feed.items" :key="item.id" :item="item" />
92
+ </div>
93
+ <div v-else class="cpub-empty-state">
94
+ <div class="cpub-empty-state-icon"><i :class="activeTab === 'following' ? 'fa-solid fa-user-group' : 'fa-solid fa-inbox'"></i></div>
95
+ <template v-if="activeTab === 'following' && !isAuthenticated">
96
+ <p class="cpub-empty-state-title">Sign in to see your feed</p>
97
+ <p class="cpub-empty-state-desc">Follow creators to see their content here.</p>
98
+ <NuxtLink to="/auth/login" class="cpub-btn cpub-btn-primary" style="margin-top: 12px;">Sign In</NuxtLink>
99
+ </template>
100
+ <template v-else-if="activeTab === 'following'">
101
+ <p class="cpub-empty-state-title">No posts from people you follow</p>
102
+ <NuxtLink to="/explore" class="cpub-btn" style="margin-top: 12px;"><i class="fa-solid fa-compass"></i> Explore</NuxtLink>
103
+ </template>
104
+ <template v-else>
105
+ <p class="cpub-empty-state-title">No content yet</p>
106
+ <p class="cpub-empty-state-desc">Be the first to create something!</p>
107
+ </template>
108
+ </div>
109
+
110
+ <div v-if="!allLoaded && feed?.items?.length" class="cpub-load-more-row">
111
+ <button class="cpub-btn-load-more" :disabled="loadingMore" @click="loadMore">
112
+ <i :class="loadingMore ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-rotate'"></i>
113
+ {{ loadingMore ? 'Loading...' : 'Load more' }}
114
+ </button>
115
+ </div>
116
+ </div>
117
+ </template>
118
+
119
+ <style scoped>
120
+ .cpub-tabs-bar { border-bottom: var(--border-width-default) solid var(--border); margin-bottom: 0; }
121
+ .cpub-tabs-inner { display: flex; max-width: var(--content-max-width, 1280px); margin: 0 auto; padding: 0 var(--space-4); overflow-x: auto; }
122
+ .cpub-tab { padding: 10px 16px; font-family: var(--font-mono); font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-dim); background: none; border: none; border-bottom: 3px solid transparent; cursor: pointer; white-space: nowrap; }
123
+ .cpub-tab:hover { color: var(--text); }
124
+ .cpub-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
125
+
126
+ .cpub-content-grid { display: grid; grid-template-columns: repeat(var(--grid-cols, 2), 1fr); gap: 16px; }
127
+
128
+ .cpub-load-more-row { text-align: center; padding: 24px 0; }
129
+ .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; }
130
+ .cpub-btn-load-more:hover { border-color: var(--accent); color: var(--accent); }
131
+
132
+ @media (max-width: 768px) { .cpub-content-grid { grid-template-columns: 1fr; } }
133
+ </style>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import type { HomepageSectionConfig } from '@commonpub/server';
3
+
4
+ const props = defineProps<{ config: HomepageSectionConfig }>();
5
+
6
+ const limit = computed(() => props.config.limit ?? 3);
7
+ const { data: contests } = await useFetch('/api/contests', { query: { limit }, lazy: true });
8
+ </script>
9
+
10
+ <template>
11
+ <div v-if="contests?.items?.length" class="cpub-sb-card">
12
+ <div class="cpub-sb-head">Active Contests <NuxtLink to="/contests">View all</NuxtLink></div>
13
+ <div v-for="c in contests.items" :key="c.id" class="cpub-contest-item">
14
+ <NuxtLink :to="`/contests/${c.slug}`" class="cpub-contest-name">{{ c.title }}</NuxtLink>
15
+ <div class="cpub-contest-row">
16
+ <span class="cpub-contest-entries">{{ c.entryCount ?? 0 }} entries</span>
17
+ <span v-if="c.endDate" class="cpub-contest-deadline">
18
+ <i class="fa-regular fa-clock"></i> {{ Math.max(0, Math.ceil((new Date(c.endDate).getTime() - Date.now()) / 86400000)) }}d left
19
+ </span>
20
+ </div>
21
+ <NuxtLink :to="`/contests/${c.slug}`" class="cpub-btn-enter">Enter Contest</NuxtLink>
22
+ </div>
23
+ </div>
24
+ </template>
25
+
26
+ <style scoped>
27
+ .cpub-sb-card { background: var(--surface); border: var(--border-width-default) solid var(--border); padding: 16px; margin-bottom: 16px; }
28
+ .cpub-sb-head { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); padding-bottom: 10px; border-bottom: var(--border-width-default) solid var(--border2); margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
29
+ .cpub-sb-head a { color: var(--accent); text-decoration: none; font-size: 10px; }
30
+ .cpub-contest-item { padding: 8px 0; border-bottom: var(--border-width-default) solid var(--border2); }
31
+ .cpub-contest-item:last-child { border-bottom: none; }
32
+ .cpub-contest-name { font-size: 13px; font-weight: 600; color: var(--text); text-decoration: none; display: block; margin-bottom: 4px; }
33
+ .cpub-contest-name:hover { color: var(--accent); }
34
+ .cpub-contest-row { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
35
+ .cpub-contest-entries { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); }
36
+ .cpub-contest-deadline { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); display: flex; align-items: center; gap: 4px; }
37
+ .cpub-btn-enter { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; padding: 4px 10px; border: var(--border-width-default) solid var(--accent); color: var(--accent); text-decoration: none; display: inline-block; }
38
+ .cpub-btn-enter:hover { background: var(--accent-bg); }
39
+ </style>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ import type { HomepageSectionConfig } from '@commonpub/server';
3
+
4
+ const props = defineProps<{
5
+ config: HomepageSectionConfig;
6
+ title?: string;
7
+ }>();
8
+ </script>
9
+
10
+ <template>
11
+ <section v-if="config.html" class="cpub-custom-section">
12
+ <h2 v-if="title" class="cpub-custom-title">{{ title }}</h2>
13
+ <div class="cpub-custom-content" v-html="config.html" />
14
+ </section>
15
+ </template>
16
+
17
+ <style scoped>
18
+ .cpub-custom-section { margin-bottom: 24px; }
19
+ .cpub-custom-title { font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-faint); margin-bottom: 12px; }
20
+ </style>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/server';
3
+ import type { HomepageSectionConfig } from '@commonpub/server';
4
+
5
+ const props = defineProps<{ config: HomepageSectionConfig }>();
6
+
7
+ const limit = computed(() => props.config.limit ?? 3);
8
+ const { data: editorialPicks } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
9
+ query: { status: 'published', editorial: true, sort: 'editorial', limit },
10
+ });
11
+ </script>
12
+
13
+ <template>
14
+ <section v-if="editorialPicks?.items?.length" class="cpub-editorial-section">
15
+ <div class="cpub-editorial-header">
16
+ <h2 class="cpub-editorial-heading"><i class="fa-solid fa-pen-fancy"></i> Staff Picks</h2>
17
+ </div>
18
+ <div class="cpub-editorial-grid" :class="{ 'cpub-editorial-single': editorialPicks.items.length === 1 }">
19
+ <ContentCard v-for="item in editorialPicks.items" :key="item.id" :item="item" />
20
+ </div>
21
+ </section>
22
+ </template>
23
+
24
+ <style scoped>
25
+ .cpub-editorial-section { margin-bottom: 24px; }
26
+ .cpub-editorial-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
27
+ .cpub-editorial-heading { font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--teal); display: flex; align-items: center; gap: 6px; }
28
+ .cpub-editorial-heading i { font-size: 10px; }
29
+ .cpub-editorial-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
30
+ .cpub-editorial-single { grid-template-columns: 1fr; max-width: 400px; }
31
+ @media (max-width: 768px) { .cpub-editorial-grid { grid-template-columns: 1fr; } }
32
+ </style>
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ import type { HomepageSectionConfig } from '@commonpub/server';
3
+
4
+ defineProps<{ config: HomepageSectionConfig }>();
5
+
6
+ const { contests: contestsEnabled } = useFeatures();
7
+ const { data: contests } = await useFetch('/api/contests', { query: { limit: 3 }, lazy: true });
8
+
9
+ const activeContest = computed(() => {
10
+ const items = (contests.value as { items?: Array<Record<string, unknown>> })?.items;
11
+ return items?.find((c) => c.status === 'active') ?? null;
12
+ });
13
+
14
+ const heroDismissed = ref(false);
15
+ </script>
16
+
17
+ <template>
18
+ <section v-if="!heroDismissed" class="cpub-hero-banner">
19
+ <div class="cpub-hero-grid-bg" />
20
+ <div class="cpub-hero-gradient" />
21
+ <button class="cpub-hero-dismiss" title="Dismiss" @click="heroDismissed = true">
22
+ <i class="fa-solid fa-xmark"></i>
23
+ </button>
24
+ <div class="cpub-hero-inner">
25
+ <div class="cpub-hero-content">
26
+ <template v-if="contestsEnabled && activeContest">
27
+ <div class="cpub-hero-eyebrow">
28
+ <span class="cpub-hero-badge cpub-hero-badge-live"><span class="cpub-live-dot" /> Live Contest</span>
29
+ <span class="cpub-hero-badge">{{ activeContest.entryCount ?? 0 }} entries</span>
30
+ </div>
31
+ <h1 class="cpub-hero-title">{{ activeContest.title }}</h1>
32
+ <p v-if="activeContest.description" class="cpub-hero-excerpt">{{ activeContest.description }}</p>
33
+ <div class="cpub-hero-actions">
34
+ <NuxtLink :to="`/contests/${activeContest.slug}`" class="cpub-btn cpub-btn-primary"><i class="fa-solid fa-trophy"></i> Enter Contest</NuxtLink>
35
+ <NuxtLink :to="`/contests/${activeContest.slug}`" class="cpub-btn"><i class="fa-solid fa-circle-info"></i> View Details</NuxtLink>
36
+ </div>
37
+ </template>
38
+ <template v-else>
39
+ <div class="cpub-hero-eyebrow">
40
+ <span class="cpub-hero-badge cpub-hero-badge-live"><span class="cpub-live-dot" /> Open Source</span>
41
+ </div>
42
+ <h1 class="cpub-hero-title">Build. Document.<br><span>Share.</span></h1>
43
+ <p class="cpub-hero-excerpt">
44
+ 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.
45
+ </p>
46
+ <div class="cpub-hero-actions">
47
+ <NuxtLink to="/create" class="cpub-btn cpub-btn-primary"><i class="fa-solid fa-plus"></i> Start Building</NuxtLink>
48
+ <NuxtLink to="/explore" class="cpub-btn"><i class="fa-solid fa-compass"></i> Explore</NuxtLink>
49
+ </div>
50
+ </template>
51
+ </div>
52
+ </div>
53
+ </section>
54
+ </template>
55
+
56
+ <style scoped>
57
+ .cpub-hero-banner { position: relative; background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); overflow: hidden; min-height: 200px; display: flex; align-items: stretch; }
58
+ .cpub-hero-grid-bg { position: absolute; inset: 0; background-image: linear-gradient(var(--border2) 1px, transparent 1px), linear-gradient(90deg, var(--border2) 1px, transparent 1px); background-size: 32px 32px; opacity: 0.25; }
59
+ .cpub-hero-gradient { position: absolute; inset: 0; background: var(--surface2); opacity: 0.5; }
60
+ .cpub-hero-dismiss { position: absolute; top: 12px; right: 16px; background: transparent; border: none; color: var(--text-faint); font-size: 12px; cursor: pointer; padding: 4px; z-index: 2; }
61
+ .cpub-hero-dismiss:hover { color: var(--text-dim); }
62
+ .cpub-hero-inner { position: relative; z-index: 1; max-width: 1280px; margin: 0 auto; padding: 36px 32px; width: 100%; display: flex; align-items: center; gap: 48px; }
63
+ .cpub-hero-content { flex: 1; }
64
+ .cpub-hero-eyebrow { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
65
+ .cpub-hero-badge { font-size: 9px; font-family: var(--font-mono); letter-spacing: 0.1em; text-transform: uppercase; padding: 3px 9px; background: var(--yellow-bg); border: var(--border-width-default) solid var(--yellow); color: var(--yellow); }
66
+ .cpub-hero-badge-live { background: var(--green-bg); border-color: var(--green); color: var(--green); display: flex; align-items: center; gap: 5px; }
67
+ .cpub-live-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--green); animation: cpub-pulse 2s ease-in-out infinite; }
68
+ @keyframes cpub-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
69
+ .cpub-hero-title { font-size: 22px; font-weight: 700; line-height: 1.25; margin-bottom: 10px; }
70
+ .cpub-hero-title span { color: var(--accent); }
71
+ .cpub-hero-excerpt { font-size: 13px; color: var(--text-dim); line-height: 1.65; margin-bottom: 20px; max-width: 560px; }
72
+ .cpub-hero-actions { display: flex; gap: 8px; }
73
+ </style>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ import type { HomepageSection } from '@commonpub/server';
3
+
4
+ defineProps<{
5
+ sections: HomepageSection[];
6
+ /** Which zone to render: 'main' for feed column, 'sidebar' for sidebar */
7
+ zone: 'main' | 'sidebar' | 'full-width';
8
+ }>();
9
+
10
+ const features = useFeatures();
11
+
12
+ function isFeatureEnabled(featureGate?: string): boolean {
13
+ if (!featureGate) return true;
14
+ return (features.features as unknown as Record<string, boolean>)?.[featureGate] ?? true;
15
+ }
16
+
17
+ /** Section types that render in the full-width zone (above the 2-column layout) */
18
+ const FULL_WIDTH_TYPES = new Set(['hero']);
19
+
20
+ /** Section types that render in the sidebar */
21
+ const SIDEBAR_TYPES = new Set(['stats', 'contests', 'hubs']);
22
+
23
+ function sectionZone(section: HomepageSection): 'full-width' | 'main' | 'sidebar' {
24
+ if (FULL_WIDTH_TYPES.has(section.type)) return 'full-width';
25
+ if (SIDEBAR_TYPES.has(section.type)) return 'sidebar';
26
+ return 'main';
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <template v-for="section in sections" :key="section.id">
32
+ <template v-if="section.enabled && sectionZone(section) === zone && isFeatureEnabled(section.config.featureGate)">
33
+ <HomepageHeroSection
34
+ v-if="section.type === 'hero'"
35
+ :config="section.config"
36
+ />
37
+ <HomepageEditorialSection
38
+ v-else-if="section.type === 'editorial'"
39
+ :config="section.config"
40
+ />
41
+ <HomepageContentGridSection
42
+ v-else-if="section.type === 'content-grid'"
43
+ :config="section.config"
44
+ :title="section.title"
45
+ />
46
+ <HomepageContestsSection
47
+ v-else-if="section.type === 'contests'"
48
+ :config="section.config"
49
+ />
50
+ <HomepageHubsSection
51
+ v-else-if="section.type === 'hubs'"
52
+ :config="section.config"
53
+ />
54
+ <HomepageStatsSection
55
+ v-else-if="section.type === 'stats'"
56
+ />
57
+ <HomepageCustomHtmlSection
58
+ v-else-if="section.type === 'custom-html'"
59
+ :config="section.config"
60
+ :title="section.title"
61
+ />
62
+ </template>
63
+ </template>
64
+ </template>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import type { HomepageSectionConfig } from '@commonpub/server';
3
+
4
+ const props = defineProps<{ config: HomepageSectionConfig }>();
5
+
6
+ const limit = computed(() => props.config.limit ?? 4);
7
+ const { data: communities, pending } = await useFetch('/api/hubs', { query: { limit }, lazy: true });
8
+
9
+ const { user } = useAuth();
10
+ const isAuthenticated = computed(() => !!user.value);
11
+ const joinedHubs = ref(new Set<string>());
12
+ const toast = useToast();
13
+
14
+ async function handleHubJoin(hubSlug: string): Promise<void> {
15
+ if (!isAuthenticated.value) {
16
+ await navigateTo('/auth/login?redirect=/');
17
+ return;
18
+ }
19
+ try {
20
+ await $fetch(`/api/hubs/${hubSlug}/join`, { method: 'POST' });
21
+ joinedHubs.value.add(hubSlug);
22
+ toast.success('Joined hub!');
23
+ } catch {
24
+ toast.error('Failed to join hub');
25
+ }
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <div v-if="pending" class="cpub-sb-card">
31
+ <div class="cpub-sb-head">Trending Hubs</div>
32
+ <div class="cpub-loading-state"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
33
+ </div>
34
+ <div v-else-if="communities?.items?.length" class="cpub-sb-card">
35
+ <div class="cpub-sb-head">Trending Hubs <NuxtLink to="/hubs">Browse</NuxtLink></div>
36
+ <div v-for="hub in communities.items" :key="hub.id" class="cpub-hub-item">
37
+ <div class="cpub-hub-icon">
38
+ <img v-if="hub.iconUrl" :src="hub.iconUrl" :alt="hub.name" class="cpub-hub-icon-img" />
39
+ <i v-else class="fa-solid fa-users"></i>
40
+ </div>
41
+ <div class="cpub-hub-info">
42
+ <NuxtLink :to="(hub as Record<string, unknown>).source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`" class="cpub-hub-name">{{ hub.name }}</NuxtLink>
43
+ <div class="cpub-hub-members">{{ hub.memberCount ?? 0 }} members</div>
44
+ </div>
45
+ <button v-if="joinedHubs.has(hub.slug)" class="cpub-btn-joined" disabled><i class="fa-solid fa-check"></i> Joined</button>
46
+ <button v-else class="cpub-btn-join" @click.prevent="handleHubJoin(hub.slug)">Join</button>
47
+ </div>
48
+ </div>
49
+ </template>
50
+
51
+ <style scoped>
52
+ .cpub-sb-card { background: var(--surface); border: var(--border-width-default) solid var(--border); padding: 16px; margin-bottom: 16px; }
53
+ .cpub-sb-head { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); padding-bottom: 10px; border-bottom: var(--border-width-default) solid var(--border2); margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
54
+ .cpub-sb-head a { color: var(--accent); text-decoration: none; font-size: 10px; }
55
+ .cpub-hub-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: var(--border-width-default) solid var(--border2); }
56
+ .cpub-hub-item:last-child { border-bottom: none; }
57
+ .cpub-hub-icon { width: 32px; height: 32px; background: var(--accent-bg); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 13px; color: var(--accent); flex-shrink: 0; overflow: hidden; }
58
+ .cpub-hub-icon-img { width: 100%; height: 100%; object-fit: cover; }
59
+ .cpub-hub-info { flex: 1; min-width: 0; }
60
+ .cpub-hub-name { font-size: 12px; font-weight: 600; color: var(--text); text-decoration: none; display: block; }
61
+ .cpub-hub-name:hover { color: var(--accent); }
62
+ .cpub-hub-members { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); }
63
+ .cpub-btn-join { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 10px; border: var(--border-width-default) solid var(--accent); color: var(--accent); background: none; cursor: pointer; }
64
+ .cpub-btn-join:hover { background: var(--accent-bg); }
65
+ .cpub-btn-joined { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 10px; border: var(--border-width-default) solid var(--green-border); color: var(--green); background: var(--green-bg); cursor: default; display: flex; align-items: center; gap: 3px; }
66
+ </style>
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ const { hubs: hubsEnabled } = useFeatures();
3
+ const { data: stats, pending } = await useFetch('/api/stats', { lazy: true });
4
+ </script>
5
+
6
+ <template>
7
+ <div class="cpub-sb-card">
8
+ <div class="cpub-sb-head">Platform Stats</div>
9
+ <div v-if="pending" class="cpub-loading-state"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
10
+ <div v-else class="cpub-stats-grid">
11
+ <div class="cpub-stat-block">
12
+ <span class="cpub-stat-num">{{ stats?.content?.byType?.project ?? 0 }}</span>
13
+ <span class="cpub-stat-lbl">Projects</span>
14
+ </div>
15
+ <div class="cpub-stat-block">
16
+ <span class="cpub-stat-num">{{ (stats?.content?.byType?.blog ?? 0) + (stats?.content?.byType?.article ?? 0) }}</span>
17
+ <span class="cpub-stat-lbl">Posts</span>
18
+ </div>
19
+ <div class="cpub-stat-block">
20
+ <span class="cpub-stat-num">{{ stats?.users?.total ?? 0 }}</span>
21
+ <span class="cpub-stat-lbl">Members</span>
22
+ </div>
23
+ <div v-if="hubsEnabled" class="cpub-stat-block">
24
+ <span class="cpub-stat-num">{{ stats?.hubs?.total ?? 0 }}</span>
25
+ <span class="cpub-stat-lbl">Hubs</span>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ </template>
30
+
31
+ <style scoped>
32
+ .cpub-sb-card { background: var(--surface); border: var(--border-width-default) solid var(--border); padding: 16px; margin-bottom: 16px; }
33
+ .cpub-sb-head { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); padding-bottom: 10px; border-bottom: var(--border-width-default) solid var(--border2); margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
34
+ .cpub-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
35
+ .cpub-stat-block { text-align: center; padding: 8px 0; }
36
+ .cpub-stat-num { display: block; font-family: var(--font-mono); font-size: 18px; font-weight: 700; color: var(--text); }
37
+ .cpub-stat-lbl { display: block; font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); }
38
+ </style>
package/layouts/admin.vue CHANGED
@@ -33,6 +33,8 @@ const sidebarOpen = ref(false);
33
33
  <NuxtLink to="/admin/reports" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-flag"></i> Reports</NuxtLink>
34
34
  <NuxtLink to="/admin/audit" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-clipboard-list"></i> Audit Log</NuxtLink>
35
35
  <NuxtLink to="/admin/theme" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-palette"></i> Theme</NuxtLink>
36
+ <NuxtLink to="/admin/homepage" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-house"></i> Homepage</NuxtLink>
37
+ <NuxtLink to="/admin/features" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-toggle-on"></i> Features</NuxtLink>
36
38
  <NuxtLink to="/admin/federation" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-globe"></i> Federation</NuxtLink>
37
39
  <NuxtLink to="/admin/settings" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
38
40
  </nav>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -30,7 +30,7 @@
30
30
  "@aws-sdk/client-s3": "^3.1010.0",
31
31
  "@commonpub/explainer": "^0.7.11",
32
32
  "@commonpub/schema": "^0.10.0",
33
- "@commonpub/server": "^2.32.1",
33
+ "@commonpub/server": "^2.33.0",
34
34
  "@tiptap/core": "^2.11.0",
35
35
  "@tiptap/extension-bold": "^2.11.0",
36
36
  "@tiptap/extension-bullet-list": "^2.11.0",
@@ -54,12 +54,12 @@
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
56
  "@commonpub/config": "0.9.2",
57
- "@commonpub/docs": "0.6.2",
58
57
  "@commonpub/auth": "0.5.1",
58
+ "@commonpub/docs": "0.6.2",
59
59
  "@commonpub/editor": "0.7.9",
60
- "@commonpub/ui": "0.8.5",
60
+ "@commonpub/protocol": "0.9.9",
61
61
  "@commonpub/learning": "0.5.0",
62
- "@commonpub/protocol": "0.9.9"
62
+ "@commonpub/ui": "0.8.5"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",