@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.
- package/components/EventCard.vue +121 -0
- package/components/PollDisplay.vue +108 -0
- package/components/PostVoteButtons.vue +108 -0
- package/components/contest/ContestJudgeManager.vue +110 -0
- package/components/nav/MobileNavRenderer.vue +94 -0
- package/components/nav/NavDropdown.vue +101 -0
- package/components/nav/NavLink.vue +40 -0
- package/components/nav/NavRenderer.vue +51 -0
- package/composables/useFeatures.ts +2 -0
- package/layouts/admin.vue +1 -0
- package/layouts/default.vue +22 -86
- package/middleware/feature-gate.global.ts +1 -0
- package/package.json +7 -7
- package/pages/admin/navigation.vue +350 -0
- package/pages/events/[slug]/edit.vue +182 -0
- package/pages/events/[slug]/index.vue +249 -0
- package/pages/events/create.vue +140 -0
- package/pages/events/index.vue +47 -0
- package/server/api/admin/navigation/items.get.ts +11 -0
- package/server/api/admin/navigation/items.put.ts +51 -0
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +20 -0
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +20 -0
- package/server/api/contests/[slug]/judge.post.ts +5 -7
- package/server/api/contests/[slug]/judges/[userId].delete.ts +26 -0
- package/server/api/contests/[slug]/judges/accept.post.ts +21 -0
- package/server/api/contests/[slug]/judges/index.get.ts +17 -0
- package/server/api/contests/[slug]/judges/index.post.ts +36 -0
- package/server/api/events/[slug]/attendees.get.ts +23 -0
- package/server/api/events/[slug]/rsvp.delete.ts +23 -0
- package/server/api/events/[slug]/rsvp.post.ts +23 -0
- package/server/api/events/[slug].delete.ts +22 -0
- package/server/api/events/[slug].get.ts +17 -0
- package/server/api/events/[slug].put.ts +38 -0
- package/server/api/events/index.get.ts +21 -0
- package/server/api/events/index.post.ts +40 -0
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +18 -0
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +27 -0
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +21 -0
- package/server/api/navigation/items.get.ts +10 -0
- 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>
|
package/layouts/default.vue
CHANGED
|
@@ -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
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
</
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "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.
|
|
33
|
-
"@commonpub/server": "^2.
|
|
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/
|
|
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>
|