@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.
- package/components/DiscussionItem.vue +4 -1
- 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/hub/HubDiscussions.vue +2 -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/useAuth.ts +20 -15
- package/composables/useFeatures.ts +34 -13
- package/layouts/admin.vue +1 -0
- package/layouts/default.vue +50 -108
- package/middleware/feature-gate.global.ts +9 -4
- package/package.json +6 -6
- package/pages/admin/navigation.vue +350 -0
- package/pages/contests/[slug]/index.vue +1 -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/pages/federated-hubs/[id]/index.vue +1 -0
- package/pages/hubs/[slug]/index.vue +1 -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
- 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>
|
package/composables/useAuth.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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>
|
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,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
|
-
<
|
|
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>
|
|
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
|
-
<
|
|
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>
|
|
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
|
-
</
|
|
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
|
-
|
|
293
|
-
.cpub-nav
|
|
294
|
-
.cpub-nav-link
|
|
295
|
-
.cpub-nav-link
|
|
296
|
-
.cpub-nav-link
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
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.
|
|
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.
|
|
33
|
-
"@commonpub/server": "^2.
|
|
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/
|
|
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": {
|