@commonpub/layer 0.11.0 → 0.14.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 (47) hide show
  1. package/components/DiscussionItem.vue +4 -1
  2. package/components/EventCard.vue +121 -0
  3. package/components/PollDisplay.vue +108 -0
  4. package/components/PostVoteButtons.vue +108 -0
  5. package/components/contest/ContestJudgeManager.vue +110 -0
  6. package/components/hub/HubDiscussions.vue +2 -0
  7. package/components/nav/MobileNavRenderer.vue +94 -0
  8. package/components/nav/NavDropdown.vue +101 -0
  9. package/components/nav/NavLink.vue +40 -0
  10. package/components/nav/NavRenderer.vue +51 -0
  11. package/composables/useAuth.ts +20 -15
  12. package/composables/useFeatures.ts +34 -13
  13. package/layouts/admin.vue +1 -0
  14. package/layouts/default.vue +50 -108
  15. package/middleware/feature-gate.global.ts +9 -4
  16. package/package.json +6 -6
  17. package/pages/admin/navigation.vue +350 -0
  18. package/pages/contests/[slug]/index.vue +1 -0
  19. package/pages/events/[slug]/edit.vue +182 -0
  20. package/pages/events/[slug]/index.vue +249 -0
  21. package/pages/events/create.vue +140 -0
  22. package/pages/events/index.vue +47 -0
  23. package/pages/federated-hubs/[id]/index.vue +1 -0
  24. package/pages/hubs/[slug]/index.vue +1 -0
  25. package/server/api/admin/navigation/items.get.ts +11 -0
  26. package/server/api/admin/navigation/items.put.ts +51 -0
  27. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +20 -0
  28. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +20 -0
  29. package/server/api/contests/[slug]/judge.post.ts +5 -7
  30. package/server/api/contests/[slug]/judges/[userId].delete.ts +26 -0
  31. package/server/api/contests/[slug]/judges/accept.post.ts +21 -0
  32. package/server/api/contests/[slug]/judges/index.get.ts +17 -0
  33. package/server/api/contests/[slug]/judges/index.post.ts +36 -0
  34. package/server/api/events/[slug]/attendees.get.ts +23 -0
  35. package/server/api/events/[slug]/rsvp.delete.ts +23 -0
  36. package/server/api/events/[slug]/rsvp.post.ts +23 -0
  37. package/server/api/events/[slug].delete.ts +22 -0
  38. package/server/api/events/[slug].get.ts +17 -0
  39. package/server/api/events/[slug].put.ts +38 -0
  40. package/server/api/events/index.get.ts +21 -0
  41. package/server/api/events/index.post.ts +40 -0
  42. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +18 -0
  43. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +27 -0
  44. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +21 -0
  45. package/server/api/navigation/items.get.ts +10 -0
  46. package/server/middleware/features.ts +1 -0
  47. package/types/hub.ts +1 -0
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ import type { NavItem } from '@commonpub/server';
3
+
4
+ const props = defineProps<{
5
+ item: NavItem;
6
+ }>();
7
+
8
+ const isExternal = computed(() => props.item.type === 'external' && props.item.href);
9
+ </script>
10
+
11
+ <template>
12
+ <span v-if="item.disabled" class="cpub-nav-link cpub-nav-link--disabled">
13
+ <i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
14
+ </span>
15
+ <a
16
+ v-else-if="isExternal"
17
+ :href="item.href"
18
+ target="_blank"
19
+ rel="noopener"
20
+ class="cpub-nav-link"
21
+ >
22
+ <i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
23
+ <i class="fa-solid fa-arrow-up-right-from-square cpub-nav-external-icon"></i>
24
+ </a>
25
+ <NuxtLink
26
+ v-else-if="item.route"
27
+ :to="item.route"
28
+ class="cpub-nav-link"
29
+ >
30
+ <i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
31
+ </NuxtLink>
32
+ </template>
33
+
34
+ <style scoped>
35
+ .cpub-nav-external-icon {
36
+ font-size: 8px;
37
+ color: var(--text-faint);
38
+ margin-left: 2px;
39
+ }
40
+ </style>
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ import type { NavItem } from '@commonpub/server';
3
+
4
+ defineProps<{
5
+ items: NavItem[];
6
+ openDropdown: string | null;
7
+ }>();
8
+
9
+ const emit = defineEmits<{
10
+ 'toggle-dropdown': [name: string];
11
+ 'close-dropdowns': [];
12
+ }>();
13
+
14
+ const { isAuthenticated, isAdmin } = useAuth();
15
+ const features = useFeatures();
16
+
17
+ const featureMap = computed(() => {
18
+ const map: Record<string, boolean> = {};
19
+ for (const [key, val] of Object.entries(features)) {
20
+ if (typeof val === 'object' && val !== null && 'value' in val) {
21
+ map[key] = (val as { value: boolean }).value;
22
+ }
23
+ }
24
+ return map;
25
+ });
26
+
27
+ function isVisible(item: NavItem): boolean {
28
+ if (item.featureGate && !featureMap.value[item.featureGate]) return false;
29
+ if (item.visibleTo === 'authenticated' && !isAuthenticated.value) return false;
30
+ if (item.visibleTo === 'admin' && !isAdmin.value) return false;
31
+ return true;
32
+ }
33
+ </script>
34
+
35
+ <template>
36
+ <nav class="cpub-topbar-nav" aria-label="Main navigation">
37
+ <template v-for="item in items" :key="item.id">
38
+ <NavDropdown
39
+ v-if="item.type === 'dropdown' && isVisible(item)"
40
+ :item="item"
41
+ :open="openDropdown === item.id"
42
+ @toggle="emit('toggle-dropdown', item.id)"
43
+ @close="emit('close-dropdowns')"
44
+ />
45
+ <NavLink
46
+ v-else-if="isVisible(item)"
47
+ :item="item"
48
+ />
49
+ </template>
50
+ </nav>
51
+ </template>
@@ -20,6 +20,21 @@ export interface ClientAuthSession {
20
20
  expiresAt: string;
21
21
  }
22
22
 
23
+ interface AuthResponse {
24
+ user: ClientAuthUser | null;
25
+ session: ClientAuthSession | null;
26
+ }
27
+
28
+ /** Type-safe POST fetch that avoids Nuxt $fetch TS2589 deep instantiation */
29
+ async function authPost(url: string, body: Record<string, unknown>): Promise<AuthResponse | null> {
30
+ return ($fetch as (url: string, opts: Record<string, unknown>) => Promise<AuthResponse | null>)(url, {
31
+ method: 'POST',
32
+ body,
33
+ credentials: 'include',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ }
37
+
23
38
  export function useAuth() {
24
39
  const user = useState<ClientAuthUser | null>('auth-user', () => null);
25
40
  const session = useState<ClientAuthSession | null>('auth-session', () => null);
@@ -28,27 +43,19 @@ export function useAuth() {
28
43
  const isAdmin = computed(() => user.value?.role === 'admin');
29
44
 
30
45
  async function signIn(email: string, password: string): Promise<void> {
31
- const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>('/api/auth/sign-in/email', {
32
- method: 'POST',
33
- body: { email, password },
34
- credentials: 'include',
35
- });
46
+ const data = await authPost('/api/auth/sign-in/email', { email, password });
36
47
  user.value = data?.user ?? null;
37
48
  session.value = data?.session ?? null;
38
49
  }
39
50
 
40
51
  async function signUp(email: string, password: string, username: string): Promise<void> {
41
- const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>('/api/auth/sign-up/email', {
42
- method: 'POST',
43
- body: { email, password, username, name: username },
44
- credentials: 'include',
45
- });
52
+ const data = await authPost('/api/auth/sign-up/email', { email, password, username, name: username });
46
53
  user.value = data?.user ?? null;
47
54
  session.value = data?.session ?? null;
48
55
  }
49
56
 
50
57
  async function signOut(): Promise<void> {
51
- await $fetch('/api/auth/sign-out', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: {} });
58
+ await authPost('/api/auth/sign-out', {});
52
59
  user.value = null;
53
60
  session.value = null;
54
61
  await navigateTo('/');
@@ -61,14 +68,12 @@ export function useAuth() {
61
68
  async function refreshSession(): Promise<void> {
62
69
  if (import.meta.server) return;
63
70
  try {
64
- const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>(
65
- '/api/me',
66
- { credentials: 'include' },
71
+ const data = await ($fetch as (url: string, opts: Record<string, unknown>) => Promise<AuthResponse | null>)(
72
+ '/api/me', { credentials: 'include' },
67
73
  );
68
74
  user.value = data?.user ?? null;
69
75
  session.value = data?.session ?? null;
70
76
  } catch {
71
- // Session invalid or server unreachable — clear client state
72
77
  user.value = null;
73
78
  session.value = null;
74
79
  }
@@ -1,4 +1,6 @@
1
1
  // Feature flag composable — reactive access to enabled features
2
+ // Initializes from build-time runtime config, then hydrates from /api/features
3
+ // to pick up runtime DB overrides set via admin panel.
2
4
 
3
5
  export interface FeatureFlags {
4
6
  content: boolean;
@@ -7,6 +9,7 @@ export interface FeatureFlags {
7
9
  docs: boolean;
8
10
  video: boolean;
9
11
  contests: boolean;
12
+ events: boolean;
10
13
  learning: boolean;
11
14
  explainers: boolean;
12
15
  editorial: boolean;
@@ -15,23 +18,41 @@ export interface FeatureFlags {
15
18
  emailNotifications: boolean;
16
19
  }
17
20
 
21
+ let hydrated = false;
22
+
18
23
  export function useFeatures() {
19
24
  const config = useRuntimeConfig();
20
- const flags = config.public.features as unknown as FeatureFlags;
25
+ const buildFlags = config.public.features as unknown as FeatureFlags;
26
+
27
+ // Shared reactive state — initialized from build-time config
28
+ const flags = useState<FeatureFlags>('feature-flags', () => ({ ...buildFlags }));
29
+
30
+ // On client, fetch dynamic features once to pick up DB overrides
31
+ if (import.meta.client && !hydrated) {
32
+ hydrated = true;
33
+ ($fetch as Function)('/api/features')
34
+ .then((dynamic: FeatureFlags) => {
35
+ if (dynamic && typeof dynamic === 'object') {
36
+ flags.value = { ...flags.value, ...dynamic };
37
+ }
38
+ })
39
+ .catch(() => { /* use build-time defaults on failure */ });
40
+ }
21
41
 
22
42
  return {
23
43
  features: flags,
24
- content: computed(() => flags.content),
25
- social: computed(() => flags.social),
26
- hubs: computed(() => flags.hubs),
27
- docs: computed(() => flags.docs),
28
- video: computed(() => flags.video),
29
- contests: computed(() => flags.contests),
30
- learning: computed(() => flags.learning),
31
- explainers: computed(() => flags.explainers),
32
- editorial: computed(() => flags.editorial),
33
- federation: computed(() => flags.federation),
34
- admin: computed(() => flags.admin),
35
- emailNotifications: computed(() => flags.emailNotifications),
44
+ content: computed(() => flags.value.content),
45
+ social: computed(() => flags.value.social),
46
+ hubs: computed(() => flags.value.hubs),
47
+ docs: computed(() => flags.value.docs),
48
+ video: computed(() => flags.value.video),
49
+ contests: computed(() => flags.value.contests),
50
+ events: computed(() => flags.value.events),
51
+ learning: computed(() => flags.value.learning),
52
+ explainers: computed(() => flags.value.explainers),
53
+ editorial: computed(() => flags.value.editorial),
54
+ federation: computed(() => flags.value.federation),
55
+ admin: computed(() => flags.value.admin),
56
+ emailNotifications: computed(() => flags.value.emailNotifications),
36
57
  };
37
58
  }
package/layouts/admin.vue CHANGED
@@ -34,6 +34,7 @@ const sidebarOpen = ref(false);
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
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/navigation" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-bars"></i> Navigation</NuxtLink>
37
38
  <NuxtLink to="/admin/features" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-toggle-on"></i> Features</NuxtLink>
38
39
  <NuxtLink to="/admin/federation" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-globe"></i> Federation</NuxtLink>
39
40
  <NuxtLink to="/admin/settings" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
@@ -1,8 +1,10 @@
1
1
  <script setup lang="ts">
2
+ import type { NavItem } from '@commonpub/server';
3
+
2
4
  const { user, isAuthenticated, isAdmin, signOut, refreshSession } = useAuth();
3
5
  const { count: unreadCount, connect: connectNotifications, disconnect: disconnectNotifications } = useNotifications();
4
6
  const { count: unreadMessages, connect: connectMessages, disconnect: disconnectMessages } = useMessages();
5
- const { hubs, learning, video, docs, contests, admin, federation, explainers } = useFeatures();
7
+ const { hubs, learning, video, docs, contests, events, admin, federation, explainers } = useFeatures();
6
8
  const { isDark, setDarkMode } = useTheme();
7
9
  const { enabledTypeMeta } = useContentTypes();
8
10
  const runtimeConfig = useRuntimeConfig();
@@ -18,6 +20,13 @@ const userMenuOpen = ref(false);
18
20
  const mobileMenuOpen = ref(false);
19
21
  const openDropdown = ref<string | null>(null);
20
22
 
23
+ // Fetch configurable nav items (falls back to defaults on server)
24
+ // useAsyncData avoids Nuxt's typed route inference which triggers TS2589
25
+ const { data: navItems } = await useAsyncData('nav-items', () =>
26
+ ($fetch as Function)('/api/navigation/items') as Promise<NavItem[]>,
27
+ { default: () => [] as NavItem[] },
28
+ );
29
+
21
30
  function toggleDropdown(name: string): void {
22
31
  openDropdown.value = openDropdown.value === name ? null : name;
23
32
  }
@@ -80,58 +89,13 @@ const userUsername = computed(() => user.value?.username ?? '');
80
89
  <SiteLogo />
81
90
  </NuxtLink>
82
91
 
83
- <nav class="cpub-topbar-nav" aria-label="Main navigation">
84
- <NuxtLink to="/" class="cpub-nav-link"><i class="fa-solid fa-house"></i> Home</NuxtLink>
85
-
86
- <!-- Learn dropdown -->
87
- <div v-if="learning || docs" class="cpub-nav-dropdown">
88
- <button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'learn' }" aria-haspopup="true" :aria-expanded="openDropdown === 'learn'" @click.stop="toggleDropdown('learn')">
89
- <i class="fa-solid fa-graduation-cap"></i> Learn <i class="fa-solid fa-chevron-down cpub-nav-caret" />
90
- </button>
91
- <div v-if="openDropdown === 'learn'" class="cpub-nav-panel">
92
- <NuxtLink v-if="learning" to="/learn" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-route"></i> Learning Paths</NuxtLink>
93
- <NuxtLink v-if="explainers" to="/explainer" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-lightbulb"></i> Explainers</NuxtLink>
94
- <NuxtLink v-if="docs" to="/docs" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-book"></i> Docs</NuxtLink>
95
- </div>
96
- </div>
97
-
98
- <!-- Build dropdown -->
99
- <div class="cpub-nav-dropdown">
100
- <button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'build' }" aria-haspopup="true" :aria-expanded="openDropdown === 'build'" @click.stop="toggleDropdown('build')">
101
- <i class="fa-solid fa-hammer"></i> Build <i class="fa-solid fa-chevron-down cpub-nav-caret" />
102
- </button>
103
- <div v-if="openDropdown === 'build'" class="cpub-nav-panel">
104
- <NuxtLink to="/project" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-cube"></i> Projects</NuxtLink>
105
- <NuxtLink v-if="contests" to="/contests" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-trophy"></i> Contests</NuxtLink>
106
- </div>
107
- </div>
108
-
109
- <!-- Read dropdown -->
110
- <div class="cpub-nav-dropdown">
111
- <button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'read' }" aria-haspopup="true" :aria-expanded="openDropdown === 'read'" @click.stop="toggleDropdown('read')">
112
- <i class="fa-solid fa-newspaper"></i> Read <i class="fa-solid fa-chevron-down cpub-nav-caret" />
113
- </button>
114
- <div v-if="openDropdown === 'read'" class="cpub-nav-panel">
115
- <NuxtLink to="/blog" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-pen-nib"></i> Blog</NuxtLink>
116
- </div>
117
- </div>
118
-
119
- <!-- Watch dropdown -->
120
- <div v-if="video" class="cpub-nav-dropdown">
121
- <button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'watch' }" aria-haspopup="true" :aria-expanded="openDropdown === 'watch'" @click.stop="toggleDropdown('watch')">
122
- <i class="fa-solid fa-play"></i> Watch <i class="fa-solid fa-chevron-down cpub-nav-caret" />
123
- </button>
124
- <div v-if="openDropdown === 'watch'" class="cpub-nav-panel">
125
- <NuxtLink to="/videos" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-video"></i> Videos</NuxtLink>
126
- <span class="cpub-nav-panel-item cpub-nav-panel-item--disabled"><i class="fa-solid fa-tower-broadcast"></i> Live Streams</span>
127
- <span class="cpub-nav-panel-item cpub-nav-panel-item--disabled"><i class="fa-solid fa-podcast"></i> Podcasts</span>
128
- </div>
129
- </div>
130
-
131
- <NuxtLink v-if="hubs" to="/hubs" class="cpub-nav-link"><i class="fa-solid fa-users"></i> Hubs</NuxtLink>
132
- <NuxtLink v-if="federation" to="/federation" class="cpub-nav-link"><i class="fa-solid fa-globe"></i> Fediverse</NuxtLink>
133
- <NuxtLink v-if="isAdmin && admin" to="/admin" class="cpub-nav-link"><i class="fa-solid fa-shield-halved"></i> Admin</NuxtLink>
134
- </nav>
92
+ <NavRenderer
93
+ v-if="navItems"
94
+ :items="navItems"
95
+ :open-dropdown="openDropdown"
96
+ @toggle-dropdown="toggleDropdown"
97
+ @close-dropdowns="closeDropdowns"
98
+ />
135
99
 
136
100
  <div class="cpub-topbar-spacer" />
137
101
 
@@ -183,38 +147,12 @@ const userUsername = computed(() => user.value?.username ?? '');
183
147
 
184
148
  <!-- Mobile menu -->
185
149
  <div v-if="mobileMenuOpen" class="cpub-mobile-menu" @click.self="mobileMenuOpen = false">
186
- <nav class="cpub-mobile-nav" aria-label="Mobile navigation">
187
- <NuxtLink to="/" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-house"></i> Home</NuxtLink>
188
-
189
- <!-- Learn -->
190
- <template v-if="learning || docs">
191
- <div class="cpub-mobile-section-label">Learn</div>
192
- <NuxtLink v-if="learning" to="/learn" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-route"></i> Learning Paths</NuxtLink>
193
- <NuxtLink v-if="explainers" to="/explainer" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-lightbulb"></i> Explainers</NuxtLink>
194
- <NuxtLink v-if="docs" to="/docs" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-book"></i> Docs</NuxtLink>
195
- </template>
196
-
197
- <!-- Build -->
198
- <div class="cpub-mobile-section-label">Build</div>
199
- <NuxtLink to="/project" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-cube"></i> Projects</NuxtLink>
200
- <NuxtLink v-if="contests" to="/contests" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-trophy"></i> Contests</NuxtLink>
201
-
202
- <!-- Read -->
203
- <div class="cpub-mobile-section-label">Read</div>
204
- <NuxtLink to="/blog" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-pen-nib"></i> Blog</NuxtLink>
205
-
206
- <!-- Watch -->
207
- <template v-if="video">
208
- <div class="cpub-mobile-section-label">Watch</div>
209
- <NuxtLink to="/videos" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-video"></i> Videos</NuxtLink>
210
- <span class="cpub-mobile-link cpub-mobile-link--indent cpub-mobile-link--disabled"><i class="fa-solid fa-tower-broadcast"></i> Live Streams</span>
211
- <span class="cpub-mobile-link cpub-mobile-link--indent cpub-mobile-link--disabled"><i class="fa-solid fa-podcast"></i> Podcasts</span>
212
- </template>
213
-
214
- <div class="cpub-mobile-divider" />
215
- <NuxtLink v-if="hubs" to="/hubs" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-users"></i> Hubs</NuxtLink>
216
- <NuxtLink v-if="federation" to="/federation" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-globe"></i> Fediverse</NuxtLink>
217
- <NuxtLink v-if="isAdmin && admin" to="/admin" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-shield-halved"></i> Admin</NuxtLink>
150
+ <MobileNavRenderer
151
+ v-if="navItems"
152
+ :items="navItems"
153
+ @close="mobileMenuOpen = false"
154
+ />
155
+ <div class="cpub-mobile-nav cpub-mobile-nav-extra">
218
156
  <NuxtLink to="/search" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-magnifying-glass"></i> Search</NuxtLink>
219
157
  <template v-if="isAuthenticated">
220
158
  <div class="cpub-mobile-divider" />
@@ -223,7 +161,7 @@ const userUsername = computed(() => user.value?.username ?? '');
223
161
  <NuxtLink to="/messages" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-envelope"></i> Messages</NuxtLink>
224
162
  <NuxtLink to="/notifications" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-bell"></i> Notifications</NuxtLink>
225
163
  </template>
226
- </nav>
164
+ </div>
227
165
  </div>
228
166
 
229
167
  <!-- ═══ MAIN ═══ -->
@@ -254,6 +192,7 @@ const userUsername = computed(() => user.value?.username ?? '');
254
192
  <h4 class="cpub-footer-col-title">Community</h4>
255
193
  <NuxtLink v-if="hubs" to="/hubs" class="cpub-footer-link">Hubs</NuxtLink>
256
194
  <NuxtLink v-if="contests" to="/contests" class="cpub-footer-link">Contests</NuxtLink>
195
+ <NuxtLink v-if="events" to="/events" class="cpub-footer-link">Events</NuxtLink>
257
196
  <NuxtLink v-if="learning" to="/learn" class="cpub-footer-link">Learning Paths</NuxtLink>
258
197
  <NuxtLink v-if="video" to="/videos" class="cpub-footer-link">Videos</NuxtLink>
259
198
  <NuxtLink to="/search" class="cpub-footer-link">Explore</NuxtLink>
@@ -289,42 +228,44 @@ const userUsername = computed(() => user.value?.username ?? '');
289
228
  }
290
229
  .cpub-topbar-logo { display: flex; align-items: center; flex-shrink: 0; text-decoration: none; color: var(--text); }
291
230
 
292
- .cpub-topbar-nav { display: flex; align-items: center; gap: 2px; margin-left: 24px; }
293
- .cpub-nav-link { font-size: 12px; color: var(--text-dim); padding: 5px 12px; border: var(--border-width-default) solid transparent; background: none; text-decoration: none; transition: color 0.15s, background 0.15s; display: flex; align-items: center; gap: 6px; }
294
- .cpub-nav-link i { font-size: 10px; }
295
- .cpub-nav-link:hover { color: var(--text); background: var(--surface2); }
296
- .cpub-nav-link.router-link-active { color: var(--text); background: var(--surface2); border-color: var(--border); }
231
+ /* Nav styles use :deep() to reach into NavRenderer/NavDropdown/NavLink child components */
232
+ :deep(.cpub-topbar-nav) { display: flex; align-items: center; gap: 2px; margin-left: 24px; }
233
+ :deep(.cpub-nav-link) { font-size: 12px; color: var(--text-dim); padding: 5px 12px; border: var(--border-width-default) solid transparent; background: none; text-decoration: none; transition: color 0.15s, background 0.15s; display: flex; align-items: center; gap: 6px; }
234
+ :deep(.cpub-nav-link i) { font-size: 10px; }
235
+ :deep(.cpub-nav-link:hover) { color: var(--text); background: var(--surface2); }
236
+ :deep(.cpub-nav-link.router-link-active) { color: var(--text); background: var(--surface2); border-color: var(--border); }
237
+ :deep(.cpub-nav-link--disabled) { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
297
238
 
298
239
  /* Nav dropdowns */
299
- .cpub-nav-dropdown { position: relative; }
300
- .cpub-nav-trigger { cursor: pointer; }
301
- .cpub-nav-caret { font-size: 7px !important; margin-left: 2px; transition: transform 0.15s; }
302
- .cpub-nav-trigger--open .cpub-nav-caret { transform: rotate(180deg); }
303
- .cpub-nav-panel {
240
+ :deep(.cpub-nav-dropdown) { position: relative; }
241
+ :deep(.cpub-nav-trigger) { cursor: pointer; }
242
+ :deep(.cpub-nav-caret) { font-size: 7px !important; margin-left: 2px; transition: transform 0.15s; }
243
+ :deep(.cpub-nav-trigger--open .cpub-nav-caret) { transform: rotate(180deg); }
244
+ :deep(.cpub-nav-panel) {
304
245
  position: absolute; top: 100%; left: 0; min-width: 180px;
305
246
  background: var(--surface); border: var(--border-width-default) solid var(--border);
306
247
  box-shadow: var(--shadow-md); z-index: 200; display: flex; flex-direction: column; padding: 4px 0;
307
248
  margin-top: 4px;
308
249
  }
309
- .cpub-nav-panel-item {
250
+ :deep(.cpub-nav-panel-item) {
310
251
  display: flex; align-items: center; gap: 8px; padding: 8px 14px;
311
252
  font-size: 12px; color: var(--text-dim); text-decoration: none;
312
253
  transition: background 0.1s, color 0.1s; cursor: pointer;
313
254
  }
314
- .cpub-nav-panel-item:hover { background: var(--surface2); color: var(--text); }
315
- .cpub-nav-panel-item i { width: 14px; text-align: center; font-size: 11px; }
316
- .cpub-nav-panel-item--disabled {
255
+ :deep(.cpub-nav-panel-item:hover) { background: var(--surface2); color: var(--text); }
256
+ :deep(.cpub-nav-panel-item i) { width: 14px; text-align: center; font-size: 11px; }
257
+ :deep(.cpub-nav-panel-item--disabled) {
317
258
  opacity: 0.35; cursor: not-allowed; pointer-events: none;
318
259
  }
319
260
 
320
- /* Mobile nav sections */
321
- .cpub-mobile-section-label {
261
+ /* Mobile nav sections — :deep() for MobileNavRenderer child component */
262
+ :deep(.cpub-mobile-section-label) {
322
263
  font-family: var(--font-mono); font-size: 9px; font-weight: 700;
323
264
  text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-faint);
324
265
  padding: 10px 20px 2px; margin-top: 4px;
325
266
  }
326
- .cpub-mobile-link--indent { padding-left: 36px; }
327
- .cpub-mobile-link--disabled { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
267
+ :deep(.cpub-mobile-link--indent) { padding-left: 36px; }
268
+ :deep(.cpub-mobile-link--disabled) { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
328
269
 
329
270
  .cpub-topbar-spacer { flex: 1; }
330
271
  .cpub-topbar-actions { display: flex; align-items: center; gap: 6px; }
@@ -353,11 +294,12 @@ const userUsername = computed(() => user.value?.username ?? '');
353
294
 
354
295
  .cpub-mobile-toggle { display: none; width: 32px; height: 32px; background: none; border: var(--border-width-default) solid transparent; color: var(--text-dim); font-size: 16px; cursor: pointer; align-items: center; justify-content: center; }
355
296
  .cpub-mobile-menu { display: none; position: fixed; inset: 0; top: 48px; z-index: 99; background: var(--color-surface-overlay-light); }
356
- .cpub-mobile-nav { background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); padding: 8px 0; display: flex; flex-direction: column; box-shadow: var(--shadow-md); }
357
- .cpub-mobile-link { display: flex; align-items: center; gap: 10px; padding: 10px 20px; font-size: 13px; color: var(--text-dim); text-decoration: none; transition: background 0.1s; }
358
- .cpub-mobile-link:hover { background: var(--surface2); color: var(--text); }
359
- .cpub-mobile-link i { width: 16px; text-align: center; font-size: 12px; }
297
+ :deep(.cpub-mobile-nav) { background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); padding: 8px 0; display: flex; flex-direction: column; box-shadow: var(--shadow-md); }
298
+ :deep(.cpub-mobile-link) { display: flex; align-items: center; gap: 10px; padding: 10px 20px; font-size: 13px; color: var(--text-dim); text-decoration: none; transition: background 0.1s; }
299
+ :deep(.cpub-mobile-link:hover) { background: var(--surface2); color: var(--text); }
300
+ :deep(.cpub-mobile-link i) { width: 16px; text-align: center; font-size: 12px; }
360
301
  .cpub-mobile-divider { height: 2px; background: var(--border2); margin: 4px 16px; }
302
+ .cpub-mobile-nav-extra { border-top: var(--border-width-default) solid var(--border2); }
361
303
 
362
304
  #main-content { margin-top: 48px; flex: 1; }
363
305
 
@@ -1,21 +1,26 @@
1
1
  // Global client-side middleware that mirrors server/middleware/features.ts.
2
2
  // Prevents client-side navigation to feature-gated pages when the flag is disabled.
3
+ // Uses useState('feature-flags') which is hydrated by useFeatures() from /api/features.
3
4
 
4
- const ROUTE_FEATURE_MAP: Record<string, keyof import('../composables/useFeatures').FeatureFlags> = {
5
+ import type { FeatureFlags } from '../composables/useFeatures';
6
+
7
+ const ROUTE_FEATURE_MAP: Record<string, keyof FeatureFlags> = {
5
8
  '/learn': 'learning',
6
9
  '/docs': 'docs',
7
10
  '/videos': 'video',
8
11
  '/admin': 'admin',
9
12
  '/contests': 'contests',
13
+ '/events': 'events',
10
14
  '/explainer': 'explainers',
11
15
  };
12
16
 
13
17
  export default defineNuxtRouteMiddleware((to) => {
14
18
  for (const [prefix, feature] of Object.entries(ROUTE_FEATURE_MAP)) {
15
19
  if (to.path === prefix || to.path.startsWith(prefix + '/')) {
16
- const config = useRuntimeConfig();
17
- const flags = config.public.features as Record<string, boolean>;
18
- if (!flags[feature]) {
20
+ // Prefer reactive state (hydrated from /api/features), fall back to build-time config
21
+ const featureState = useState<FeatureFlags | null>('feature-flags', () => null);
22
+ const flags = featureState.value ?? (useRuntimeConfig().public.features as Record<string, boolean>);
23
+ if (!(flags as Record<string, boolean>)[feature]) {
19
24
  throw createError({ statusCode: 404, statusMessage: 'Not Found' });
20
25
  }
21
26
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.11.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -29,8 +29,8 @@
29
29
  "dependencies": {
30
30
  "@aws-sdk/client-s3": "^3.1010.0",
31
31
  "@commonpub/explainer": "^0.7.11",
32
- "@commonpub/schema": "^0.10.0",
33
- "@commonpub/server": "^2.33.0",
32
+ "@commonpub/schema": "^0.13.0",
33
+ "@commonpub/server": "^2.41.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",
@@ -53,12 +53,12 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/config": "0.9.2",
57
56
  "@commonpub/auth": "0.5.1",
58
- "@commonpub/docs": "0.6.2",
59
57
  "@commonpub/editor": "0.7.9",
60
- "@commonpub/protocol": "0.9.9",
58
+ "@commonpub/docs": "0.6.2",
61
59
  "@commonpub/learning": "0.5.0",
60
+ "@commonpub/protocol": "0.9.9",
61
+ "@commonpub/config": "0.10.0",
62
62
  "@commonpub/ui": "0.8.5"
63
63
  },
64
64
  "devDependencies": {