@commonpub/layer 0.11.0 → 0.13.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 (40) hide show
  1. package/components/EventCard.vue +121 -0
  2. package/components/PollDisplay.vue +108 -0
  3. package/components/PostVoteButtons.vue +108 -0
  4. package/components/contest/ContestJudgeManager.vue +110 -0
  5. package/components/nav/MobileNavRenderer.vue +94 -0
  6. package/components/nav/NavDropdown.vue +101 -0
  7. package/components/nav/NavLink.vue +40 -0
  8. package/components/nav/NavRenderer.vue +51 -0
  9. package/composables/useFeatures.ts +2 -0
  10. package/layouts/admin.vue +1 -0
  11. package/layouts/default.vue +22 -86
  12. package/middleware/feature-gate.global.ts +1 -0
  13. package/package.json +7 -7
  14. package/pages/admin/navigation.vue +350 -0
  15. package/pages/events/[slug]/edit.vue +182 -0
  16. package/pages/events/[slug]/index.vue +249 -0
  17. package/pages/events/create.vue +140 -0
  18. package/pages/events/index.vue +47 -0
  19. package/server/api/admin/navigation/items.get.ts +11 -0
  20. package/server/api/admin/navigation/items.put.ts +51 -0
  21. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +20 -0
  22. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +20 -0
  23. package/server/api/contests/[slug]/judge.post.ts +5 -7
  24. package/server/api/contests/[slug]/judges/[userId].delete.ts +26 -0
  25. package/server/api/contests/[slug]/judges/accept.post.ts +21 -0
  26. package/server/api/contests/[slug]/judges/index.get.ts +17 -0
  27. package/server/api/contests/[slug]/judges/index.post.ts +36 -0
  28. package/server/api/events/[slug]/attendees.get.ts +23 -0
  29. package/server/api/events/[slug]/rsvp.delete.ts +23 -0
  30. package/server/api/events/[slug]/rsvp.post.ts +23 -0
  31. package/server/api/events/[slug].delete.ts +22 -0
  32. package/server/api/events/[slug].get.ts +17 -0
  33. package/server/api/events/[slug].put.ts +38 -0
  34. package/server/api/events/index.get.ts +21 -0
  35. package/server/api/events/index.post.ts +40 -0
  36. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +18 -0
  37. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +27 -0
  38. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +21 -0
  39. package/server/api/navigation/items.get.ts +10 -0
  40. package/server/middleware/features.ts +1 -0
@@ -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>
@@ -7,6 +7,7 @@ export interface FeatureFlags {
7
7
  docs: boolean;
8
8
  video: boolean;
9
9
  contests: boolean;
10
+ events: boolean;
10
11
  learning: boolean;
11
12
  explainers: boolean;
12
13
  editorial: boolean;
@@ -27,6 +28,7 @@ export function useFeatures() {
27
28
  docs: computed(() => flags.docs),
28
29
  video: computed(() => flags.video),
29
30
  contests: computed(() => flags.contests),
31
+ events: computed(() => flags.events),
30
32
  learning: computed(() => flags.learning),
31
33
  explainers: computed(() => flags.explainers),
32
34
  editorial: computed(() => flags.editorial),
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,9 @@ 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
+ const { data: navItems } = await useFetch<NavItem[]>('/api/navigation/items');
25
+
21
26
  function toggleDropdown(name: string): void {
22
27
  openDropdown.value = openDropdown.value === name ? null : name;
23
28
  }
@@ -80,58 +85,13 @@ const userUsername = computed(() => user.value?.username ?? '');
80
85
  <SiteLogo />
81
86
  </NuxtLink>
82
87
 
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>
88
+ <NavRenderer
89
+ v-if="navItems"
90
+ :items="navItems"
91
+ :open-dropdown="openDropdown"
92
+ @toggle-dropdown="toggleDropdown"
93
+ @close-dropdowns="closeDropdowns"
94
+ />
135
95
 
136
96
  <div class="cpub-topbar-spacer" />
137
97
 
@@ -183,38 +143,12 @@ const userUsername = computed(() => user.value?.username ?? '');
183
143
 
184
144
  <!-- Mobile menu -->
185
145
  <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>
146
+ <MobileNavRenderer
147
+ v-if="navItems"
148
+ :items="navItems"
149
+ @close="mobileMenuOpen = false"
150
+ />
151
+ <div class="cpub-mobile-nav cpub-mobile-nav-extra">
218
152
  <NuxtLink to="/search" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-magnifying-glass"></i> Search</NuxtLink>
219
153
  <template v-if="isAuthenticated">
220
154
  <div class="cpub-mobile-divider" />
@@ -223,7 +157,7 @@ const userUsername = computed(() => user.value?.username ?? '');
223
157
  <NuxtLink to="/messages" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-envelope"></i> Messages</NuxtLink>
224
158
  <NuxtLink to="/notifications" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-bell"></i> Notifications</NuxtLink>
225
159
  </template>
226
- </nav>
160
+ </div>
227
161
  </div>
228
162
 
229
163
  <!-- ═══ MAIN ═══ -->
@@ -254,6 +188,7 @@ const userUsername = computed(() => user.value?.username ?? '');
254
188
  <h4 class="cpub-footer-col-title">Community</h4>
255
189
  <NuxtLink v-if="hubs" to="/hubs" class="cpub-footer-link">Hubs</NuxtLink>
256
190
  <NuxtLink v-if="contests" to="/contests" class="cpub-footer-link">Contests</NuxtLink>
191
+ <NuxtLink v-if="events" to="/events" class="cpub-footer-link">Events</NuxtLink>
257
192
  <NuxtLink v-if="learning" to="/learn" class="cpub-footer-link">Learning Paths</NuxtLink>
258
193
  <NuxtLink v-if="video" to="/videos" class="cpub-footer-link">Videos</NuxtLink>
259
194
  <NuxtLink to="/search" class="cpub-footer-link">Explore</NuxtLink>
@@ -358,6 +293,7 @@ const userUsername = computed(() => user.value?.username ?? '');
358
293
  .cpub-mobile-link:hover { background: var(--surface2); color: var(--text); }
359
294
  .cpub-mobile-link i { width: 16px; text-align: center; font-size: 12px; }
360
295
  .cpub-mobile-divider { height: 2px; background: var(--border2); margin: 4px 16px; }
296
+ .cpub-mobile-nav-extra { border-top: var(--border-width-default) solid var(--border2); }
361
297
 
362
298
  #main-content { margin-top: 48px; flex: 1; }
363
299
 
@@ -7,6 +7,7 @@ const ROUTE_FEATURE_MAP: Record<string, keyof import('../composables/useFeatures
7
7
  '/videos': 'video',
8
8
  '/admin': 'admin',
9
9
  '/contests': 'contests',
10
+ '/events': 'events',
10
11
  '/explainer': 'explainers',
11
12
  };
12
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.11.0",
3
+ "version": "0.13.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.39.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,13 +53,13 @@
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
- "@commonpub/auth": "0.5.1",
58
56
  "@commonpub/docs": "0.6.2",
59
57
  "@commonpub/editor": "0.7.9",
60
- "@commonpub/protocol": "0.9.9",
61
58
  "@commonpub/learning": "0.5.0",
62
- "@commonpub/ui": "0.8.5"
59
+ "@commonpub/config": "0.10.0",
60
+ "@commonpub/auth": "0.5.1",
61
+ "@commonpub/ui": "0.8.5",
62
+ "@commonpub/protocol": "0.9.9"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -0,0 +1,350 @@
1
+ <script setup lang="ts">
2
+ import type { NavItem } from '@commonpub/server';
3
+
4
+ definePageMeta({ layout: 'admin', middleware: 'auth' });
5
+ useSeoMeta({ title: `Navigation — Admin — ${useSiteName()}` });
6
+
7
+ const toast = useToast();
8
+ const { data, refresh } = await useFetch<NavItem[]>('/api/admin/navigation/items');
9
+
10
+ const items = ref<NavItem[]>([]);
11
+ const saving = ref(false);
12
+ const hasChanges = ref(false);
13
+
14
+ watch(data, (val) => {
15
+ if (val) {
16
+ items.value = JSON.parse(JSON.stringify(val));
17
+ hasChanges.value = false;
18
+ }
19
+ }, { immediate: true });
20
+
21
+ function markChanged(): void { hasChanges.value = true; }
22
+
23
+ const NAV_TYPES: Array<{ value: NavItem['type']; label: string }> = [
24
+ { value: 'link', label: 'Internal Link' },
25
+ { value: 'dropdown', label: 'Dropdown Menu' },
26
+ { value: 'external', label: 'External Link' },
27
+ ];
28
+
29
+ const VISIBILITY_OPTIONS: Array<{ value: string; label: string }> = [
30
+ { value: 'all', label: 'Everyone' },
31
+ { value: 'authenticated', label: 'Logged-in users' },
32
+ { value: 'admin', label: 'Admins only' },
33
+ ];
34
+
35
+ function moveUp(index: number): void {
36
+ if (index <= 0) return;
37
+ const arr = [...items.value];
38
+ [arr[index - 1], arr[index]] = [arr[index]!, arr[index - 1]!];
39
+ items.value = arr;
40
+ markChanged();
41
+ }
42
+
43
+ function moveDown(index: number): void {
44
+ if (index >= items.value.length - 1) return;
45
+ const arr = [...items.value];
46
+ [arr[index], arr[index + 1]] = [arr[index + 1]!, arr[index]!];
47
+ items.value = arr;
48
+ markChanged();
49
+ }
50
+
51
+ function removeItem(index: number): void {
52
+ if (!confirm(`Remove "${items.value[index]!.label}" from navigation?`)) return;
53
+ items.value.splice(index, 1);
54
+ markChanged();
55
+ }
56
+
57
+ function addItem(): void {
58
+ items.value.push({
59
+ id: `nav-${Date.now()}`,
60
+ type: 'link',
61
+ label: 'New Link',
62
+ icon: 'fa-solid fa-link',
63
+ route: '/',
64
+ });
65
+ markChanged();
66
+ }
67
+
68
+ function addChild(parentIndex: number): void {
69
+ const parent = items.value[parentIndex]!;
70
+ if (!parent.children) parent.children = [];
71
+ parent.children.push({
72
+ id: `nav-child-${Date.now()}`,
73
+ type: 'link',
74
+ label: 'New Child',
75
+ icon: 'fa-solid fa-link',
76
+ route: '/',
77
+ });
78
+ markChanged();
79
+ }
80
+
81
+ function removeChild(parentIndex: number, childIndex: number): void {
82
+ const parent = items.value[parentIndex]!;
83
+ if (!parent.children) return;
84
+ parent.children.splice(childIndex, 1);
85
+ markChanged();
86
+ }
87
+
88
+ function moveChildUp(parentIndex: number, childIndex: number): void {
89
+ const children = items.value[parentIndex]!.children;
90
+ if (!children || childIndex <= 0) return;
91
+ [children[childIndex - 1], children[childIndex]] = [children[childIndex]!, children[childIndex - 1]!];
92
+ markChanged();
93
+ }
94
+
95
+ function moveChildDown(parentIndex: number, childIndex: number): void {
96
+ const children = items.value[parentIndex]!.children;
97
+ if (!children || childIndex >= children.length - 1) return;
98
+ [children[childIndex], children[childIndex + 1]] = [children[childIndex + 1]!, children[childIndex]!];
99
+ markChanged();
100
+ }
101
+
102
+ async function save(): Promise<void> {
103
+ saving.value = true;
104
+ try {
105
+ await $fetch('/api/admin/navigation/items', {
106
+ method: 'PUT',
107
+ body: { items: items.value },
108
+ });
109
+ toast.success('Navigation saved');
110
+ hasChanges.value = false;
111
+ await refresh();
112
+ } catch {
113
+ toast.error('Failed to save navigation');
114
+ } finally {
115
+ saving.value = false;
116
+ }
117
+ }
118
+
119
+ function discard(): void {
120
+ if (data.value) {
121
+ items.value = JSON.parse(JSON.stringify(data.value));
122
+ hasChanges.value = false;
123
+ }
124
+ }
125
+
126
+ const editingId = ref<string | null>(null);
127
+ </script>
128
+
129
+ <template>
130
+ <div class="cpub-admin-nav-page">
131
+ <div class="cpub-admin-header">
132
+ <div>
133
+ <h1 class="cpub-admin-title">Navigation</h1>
134
+ <p class="cpub-admin-subtitle">Configure the main site navigation bar.</p>
135
+ </div>
136
+ <div class="cpub-admin-header-actions">
137
+ <button class="cpub-btn cpub-btn-sm" @click="addItem">
138
+ <i class="fa-solid fa-plus"></i> Add Item
139
+ </button>
140
+ <button
141
+ v-if="hasChanges"
142
+ class="cpub-btn cpub-btn-primary cpub-btn-sm"
143
+ :disabled="saving"
144
+ @click="save"
145
+ >
146
+ <i :class="saving ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-check'"></i> Save
147
+ </button>
148
+ </div>
149
+ </div>
150
+
151
+ <div class="cpub-nav-list">
152
+ <div
153
+ v-for="(item, idx) in items"
154
+ :key="item.id"
155
+ class="cpub-nav-row"
156
+ >
157
+ <div class="cpub-nav-order">
158
+ <button class="cpub-order-btn" :disabled="idx === 0" title="Move up" @click="moveUp(idx)">
159
+ <i class="fa-solid fa-chevron-up"></i>
160
+ </button>
161
+ <span class="cpub-order-num">{{ idx + 1 }}</span>
162
+ <button class="cpub-order-btn" :disabled="idx === items.length - 1" title="Move down" @click="moveDown(idx)">
163
+ <i class="fa-solid fa-chevron-down"></i>
164
+ </button>
165
+ </div>
166
+
167
+ <div class="cpub-nav-icon-cell">
168
+ <i :class="item.icon || 'fa-solid fa-link'"></i>
169
+ </div>
170
+
171
+ <div class="cpub-nav-info">
172
+ <div class="cpub-nav-label">{{ item.label }}</div>
173
+ <div class="cpub-nav-meta">
174
+ <span class="cpub-nav-type-badge">{{ item.type }}</span>
175
+ <span v-if="item.route" class="cpub-nav-route">{{ item.route }}</span>
176
+ <span v-if="item.href" class="cpub-nav-route">{{ item.href }}</span>
177
+ <span v-if="item.featureGate" class="cpub-nav-gate">gate: {{ item.featureGate }}</span>
178
+ <span v-if="item.visibleTo && item.visibleTo !== 'all'" class="cpub-nav-gate">{{ item.visibleTo }}</span>
179
+ <span v-if="item.children?.length" class="cpub-nav-gate">{{ item.children.length }} children</span>
180
+ </div>
181
+ </div>
182
+
183
+ <div class="cpub-nav-actions">
184
+ <button
185
+ class="cpub-nav-action"
186
+ :title="editingId === item.id ? 'Close' : 'Edit'"
187
+ @click="editingId = editingId === item.id ? null : item.id"
188
+ >
189
+ <i :class="editingId === item.id ? 'fa-solid fa-xmark' : 'fa-solid fa-pencil'"></i>
190
+ </button>
191
+ <button class="cpub-nav-action cpub-nav-action--danger" title="Remove" @click="removeItem(idx)">
192
+ <i class="fa-solid fa-trash"></i>
193
+ </button>
194
+ </div>
195
+
196
+ <!-- Inline editor -->
197
+ <div v-if="editingId === item.id" class="cpub-nav-editor">
198
+ <div class="cpub-editor-grid">
199
+ <div class="cpub-editor-field">
200
+ <label class="cpub-editor-label">Label</label>
201
+ <input v-model="item.label" class="cpub-editor-input" @input="markChanged" />
202
+ </div>
203
+ <div class="cpub-editor-field">
204
+ <label class="cpub-editor-label">Type</label>
205
+ <select v-model="item.type" class="cpub-editor-input" @change="markChanged">
206
+ <option v-for="t in NAV_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
207
+ </select>
208
+ </div>
209
+ <div class="cpub-editor-field">
210
+ <label class="cpub-editor-label">Icon (FontAwesome class)</label>
211
+ <input v-model="item.icon" class="cpub-editor-input" placeholder="fa-solid fa-house" @input="markChanged" />
212
+ </div>
213
+ <div v-if="item.type === 'link' || item.type === 'dropdown'" class="cpub-editor-field">
214
+ <label class="cpub-editor-label">Route</label>
215
+ <input v-model="item.route" class="cpub-editor-input" placeholder="/page" @input="markChanged" />
216
+ </div>
217
+ <div v-if="item.type === 'external'" class="cpub-editor-field">
218
+ <label class="cpub-editor-label">URL</label>
219
+ <input v-model="item.href" class="cpub-editor-input" placeholder="https://..." @input="markChanged" />
220
+ </div>
221
+ <div class="cpub-editor-field">
222
+ <label class="cpub-editor-label">Feature Gate</label>
223
+ <input v-model="item.featureGate" class="cpub-editor-input" placeholder="e.g. hubs" @input="markChanged" />
224
+ </div>
225
+ <div class="cpub-editor-field">
226
+ <label class="cpub-editor-label">Visible To</label>
227
+ <select v-model="item.visibleTo" class="cpub-editor-input" @change="markChanged">
228
+ <option v-for="v in VISIBILITY_OPTIONS" :key="v.value" :value="v.value">{{ v.label }}</option>
229
+ </select>
230
+ </div>
231
+ </div>
232
+
233
+ <!-- Children (for dropdown type) -->
234
+ <div v-if="item.type === 'dropdown'" class="cpub-children-section">
235
+ <div class="cpub-children-header">
236
+ <span class="cpub-children-title">Children</span>
237
+ <button class="cpub-btn cpub-btn-sm" @click="addChild(idx)">
238
+ <i class="fa-solid fa-plus"></i> Add Child
239
+ </button>
240
+ </div>
241
+ <div v-if="item.children?.length" class="cpub-children-list">
242
+ <div
243
+ v-for="(child, ci) in item.children"
244
+ :key="child.id"
245
+ class="cpub-child-row"
246
+ >
247
+ <div class="cpub-child-order">
248
+ <button class="cpub-order-btn" :disabled="ci === 0" @click="moveChildUp(idx, ci)">
249
+ <i class="fa-solid fa-chevron-up"></i>
250
+ </button>
251
+ <button class="cpub-order-btn" :disabled="ci === (item.children?.length ?? 0) - 1" @click="moveChildDown(idx, ci)">
252
+ <i class="fa-solid fa-chevron-down"></i>
253
+ </button>
254
+ </div>
255
+ <div class="cpub-child-fields">
256
+ <input v-model="child.label" class="cpub-editor-input cpub-child-input" placeholder="Label" @input="markChanged" />
257
+ <input v-model="child.icon" class="cpub-editor-input cpub-child-input" placeholder="Icon class" @input="markChanged" />
258
+ <input v-model="child.route" class="cpub-editor-input cpub-child-input" placeholder="Route" @input="markChanged" />
259
+ <input v-model="child.featureGate" class="cpub-editor-input cpub-child-input" placeholder="Feature gate" @input="markChanged" />
260
+ <label class="cpub-child-disabled-label">
261
+ <input type="checkbox" v-model="child.disabled" @change="markChanged" /> Disabled
262
+ </label>
263
+ </div>
264
+ <button class="cpub-nav-action cpub-nav-action--danger" title="Remove child" @click="removeChild(idx, ci)">
265
+ <i class="fa-solid fa-trash"></i>
266
+ </button>
267
+ </div>
268
+ </div>
269
+ <p v-else class="cpub-children-empty">No children. Add items to populate this dropdown.</p>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ </div>
274
+
275
+ <div v-if="hasChanges" class="cpub-nav-footer">
276
+ <span class="cpub-nav-footer-text">Unsaved changes</span>
277
+ <button class="cpub-btn cpub-btn-sm" @click="discard">Discard</button>
278
+ <button class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="saving" @click="save">Save</button>
279
+ </div>
280
+ </div>
281
+ </template>
282
+
283
+ <style scoped>
284
+ .cpub-admin-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-6); gap: var(--space-4); }
285
+ .cpub-admin-header-actions { display: flex; gap: var(--space-2); }
286
+ .cpub-admin-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); }
287
+ .cpub-admin-subtitle { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
288
+
289
+ .cpub-nav-list { display: flex; flex-direction: column; border: var(--border-width-default) solid var(--border); }
290
+
291
+ .cpub-nav-row {
292
+ display: flex;
293
+ align-items: center;
294
+ padding: 12px 16px;
295
+ border-bottom: var(--border-width-default) solid var(--border2);
296
+ gap: 12px;
297
+ flex-wrap: wrap;
298
+ }
299
+ .cpub-nav-row:last-child { border-bottom: none; }
300
+
301
+ .cpub-nav-order { display: flex; flex-direction: column; align-items: center; gap: 2px; }
302
+ .cpub-order-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 10px; padding: 2px 4px; }
303
+ .cpub-order-btn:hover { color: var(--accent); }
304
+ .cpub-order-btn:disabled { opacity: 0.3; cursor: default; }
305
+ .cpub-order-num { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); }
306
+
307
+ .cpub-nav-icon-cell { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); font-size: 13px; flex-shrink: 0; }
308
+
309
+ .cpub-nav-info { flex: 1; min-width: 0; }
310
+ .cpub-nav-label { font-size: 13px; font-weight: 600; }
311
+ .cpub-nav-meta { display: flex; gap: 8px; margin-top: 2px; flex-wrap: wrap; }
312
+ .cpub-nav-type-badge { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; color: var(--text-faint); }
313
+ .cpub-nav-route { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); }
314
+ .cpub-nav-gate { font-family: var(--font-mono); font-size: 9px; color: var(--accent); }
315
+
316
+ .cpub-nav-actions { display: flex; gap: 6px; flex-shrink: 0; }
317
+ .cpub-nav-action { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 4px 6px; }
318
+ .cpub-nav-action:hover { color: var(--accent); }
319
+ .cpub-nav-action--danger:hover { color: var(--red); }
320
+
321
+ .cpub-nav-editor { width: 100%; padding: 12px 0 0; border-top: var(--border-width-default) solid var(--border2); margin-top: 8px; }
322
+ .cpub-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
323
+ .cpub-editor-field { display: flex; flex-direction: column; gap: 4px; }
324
+ .cpub-editor-label { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
325
+ .cpub-editor-input { font-size: 13px; padding: 6px 10px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; }
326
+ .cpub-editor-input:focus { border-color: var(--accent); }
327
+
328
+ .cpub-children-section { margin-top: var(--space-4); padding-top: var(--space-3); border-top: var(--border-width-default) solid var(--border2); }
329
+ .cpub-children-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-3); }
330
+ .cpub-children-title { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-faint); }
331
+ .cpub-children-list { display: flex; flex-direction: column; gap: 8px; }
332
+ .cpub-child-row { display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); }
333
+ .cpub-child-order { display: flex; flex-direction: column; gap: 2px; }
334
+ .cpub-child-fields { display: flex; gap: 6px; flex: 1; flex-wrap: wrap; align-items: center; }
335
+ .cpub-child-input { flex: 1; min-width: 100px; font-size: 12px; padding: 4px 8px; }
336
+ .cpub-child-disabled-label { font-size: 11px; color: var(--text-dim); display: flex; align-items: center; gap: 4px; white-space: nowrap; }
337
+ .cpub-children-empty { font-size: 12px; color: var(--text-faint); font-style: italic; }
338
+
339
+ .cpub-nav-footer { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-4); margin-top: var(--space-4); background: var(--yellow-bg, var(--surface2)); border: var(--border-width-default) solid var(--yellow, var(--border)); }
340
+ .cpub-nav-footer-text { font-family: var(--font-mono); font-size: 11px; font-weight: 600; color: var(--yellow, var(--text-dim)); flex: 1; }
341
+
342
+ @media (max-width: 768px) {
343
+ .cpub-admin-header { flex-direction: column; }
344
+ .cpub-editor-grid { grid-template-columns: 1fr; }
345
+ .cpub-nav-row { flex-direction: column; align-items: flex-start; }
346
+ .cpub-nav-actions { align-self: flex-end; }
347
+ .cpub-child-fields { flex-direction: column; }
348
+ .cpub-child-input { min-width: 0; }
349
+ }
350
+ </style>