@commonpub/layer 0.10.1 → 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 (57) 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/homepage/ContentGridSection.vue +133 -0
  6. package/components/homepage/ContestsSection.vue +39 -0
  7. package/components/homepage/CustomHtmlSection.vue +20 -0
  8. package/components/homepage/EditorialSection.vue +32 -0
  9. package/components/homepage/HeroSection.vue +73 -0
  10. package/components/homepage/HomepageSectionRenderer.vue +64 -0
  11. package/components/homepage/HubsSection.vue +66 -0
  12. package/components/homepage/StatsSection.vue +38 -0
  13. package/components/nav/MobileNavRenderer.vue +94 -0
  14. package/components/nav/NavDropdown.vue +101 -0
  15. package/components/nav/NavLink.vue +40 -0
  16. package/components/nav/NavRenderer.vue +51 -0
  17. package/composables/useFeatures.ts +2 -0
  18. package/layouts/admin.vue +3 -0
  19. package/layouts/default.vue +22 -86
  20. package/middleware/feature-gate.global.ts +1 -0
  21. package/package.json +6 -6
  22. package/pages/admin/features.vue +338 -0
  23. package/pages/admin/homepage.vue +292 -0
  24. package/pages/admin/navigation.vue +350 -0
  25. package/pages/events/[slug]/edit.vue +182 -0
  26. package/pages/events/[slug]/index.vue +249 -0
  27. package/pages/events/create.vue +140 -0
  28. package/pages/events/index.vue +47 -0
  29. package/pages/index.vue +34 -1
  30. package/server/api/admin/features/index.get.ts +32 -0
  31. package/server/api/admin/features/index.put.ts +56 -0
  32. package/server/api/admin/homepage/sections.get.ts +11 -0
  33. package/server/api/admin/homepage/sections.put.ts +52 -0
  34. package/server/api/admin/navigation/items.get.ts +11 -0
  35. package/server/api/admin/navigation/items.put.ts +51 -0
  36. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +20 -0
  37. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +20 -0
  38. package/server/api/contests/[slug]/judge.post.ts +5 -7
  39. package/server/api/contests/[slug]/judges/[userId].delete.ts +26 -0
  40. package/server/api/contests/[slug]/judges/accept.post.ts +21 -0
  41. package/server/api/contests/[slug]/judges/index.get.ts +17 -0
  42. package/server/api/contests/[slug]/judges/index.post.ts +36 -0
  43. package/server/api/events/[slug]/attendees.get.ts +23 -0
  44. package/server/api/events/[slug]/rsvp.delete.ts +23 -0
  45. package/server/api/events/[slug]/rsvp.post.ts +23 -0
  46. package/server/api/events/[slug].delete.ts +22 -0
  47. package/server/api/events/[slug].get.ts +17 -0
  48. package/server/api/events/[slug].put.ts +38 -0
  49. package/server/api/events/index.get.ts +21 -0
  50. package/server/api/events/index.post.ts +40 -0
  51. package/server/api/features.get.ts +9 -0
  52. package/server/api/homepage/sections.get.ts +10 -0
  53. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +18 -0
  54. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +27 -0
  55. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +21 -0
  56. package/server/api/navigation/items.get.ts +10 -0
  57. package/server/middleware/features.ts +1 -0
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ import type { HomepageSectionConfig } from '@commonpub/server';
3
+
4
+ defineProps<{ config: HomepageSectionConfig }>();
5
+
6
+ const { contests: contestsEnabled } = useFeatures();
7
+ const { data: contests } = await useFetch('/api/contests', { query: { limit: 3 }, lazy: true });
8
+
9
+ const activeContest = computed(() => {
10
+ const items = (contests.value as { items?: Array<Record<string, unknown>> })?.items;
11
+ return items?.find((c) => c.status === 'active') ?? null;
12
+ });
13
+
14
+ const heroDismissed = ref(false);
15
+ </script>
16
+
17
+ <template>
18
+ <section v-if="!heroDismissed" class="cpub-hero-banner">
19
+ <div class="cpub-hero-grid-bg" />
20
+ <div class="cpub-hero-gradient" />
21
+ <button class="cpub-hero-dismiss" title="Dismiss" @click="heroDismissed = true">
22
+ <i class="fa-solid fa-xmark"></i>
23
+ </button>
24
+ <div class="cpub-hero-inner">
25
+ <div class="cpub-hero-content">
26
+ <template v-if="contestsEnabled && activeContest">
27
+ <div class="cpub-hero-eyebrow">
28
+ <span class="cpub-hero-badge cpub-hero-badge-live"><span class="cpub-live-dot" /> Live Contest</span>
29
+ <span class="cpub-hero-badge">{{ activeContest.entryCount ?? 0 }} entries</span>
30
+ </div>
31
+ <h1 class="cpub-hero-title">{{ activeContest.title }}</h1>
32
+ <p v-if="activeContest.description" class="cpub-hero-excerpt">{{ activeContest.description }}</p>
33
+ <div class="cpub-hero-actions">
34
+ <NuxtLink :to="`/contests/${activeContest.slug}`" class="cpub-btn cpub-btn-primary"><i class="fa-solid fa-trophy"></i> Enter Contest</NuxtLink>
35
+ <NuxtLink :to="`/contests/${activeContest.slug}`" class="cpub-btn"><i class="fa-solid fa-circle-info"></i> View Details</NuxtLink>
36
+ </div>
37
+ </template>
38
+ <template v-else>
39
+ <div class="cpub-hero-eyebrow">
40
+ <span class="cpub-hero-badge cpub-hero-badge-live"><span class="cpub-live-dot" /> Open Source</span>
41
+ </div>
42
+ <h1 class="cpub-hero-title">Build. Document.<br><span>Share.</span></h1>
43
+ <p class="cpub-hero-excerpt">
44
+ CommonPub is an open platform for maker communities. Document your builds with rich editors, join hubs, learn with structured paths, and share with the world.
45
+ </p>
46
+ <div class="cpub-hero-actions">
47
+ <NuxtLink to="/create" class="cpub-btn cpub-btn-primary"><i class="fa-solid fa-plus"></i> Start Building</NuxtLink>
48
+ <NuxtLink to="/explore" class="cpub-btn"><i class="fa-solid fa-compass"></i> Explore</NuxtLink>
49
+ </div>
50
+ </template>
51
+ </div>
52
+ </div>
53
+ </section>
54
+ </template>
55
+
56
+ <style scoped>
57
+ .cpub-hero-banner { position: relative; background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); overflow: hidden; min-height: 200px; display: flex; align-items: stretch; }
58
+ .cpub-hero-grid-bg { position: absolute; inset: 0; background-image: linear-gradient(var(--border2) 1px, transparent 1px), linear-gradient(90deg, var(--border2) 1px, transparent 1px); background-size: 32px 32px; opacity: 0.25; }
59
+ .cpub-hero-gradient { position: absolute; inset: 0; background: var(--surface2); opacity: 0.5; }
60
+ .cpub-hero-dismiss { position: absolute; top: 12px; right: 16px; background: transparent; border: none; color: var(--text-faint); font-size: 12px; cursor: pointer; padding: 4px; z-index: 2; }
61
+ .cpub-hero-dismiss:hover { color: var(--text-dim); }
62
+ .cpub-hero-inner { position: relative; z-index: 1; max-width: 1280px; margin: 0 auto; padding: 36px 32px; width: 100%; display: flex; align-items: center; gap: 48px; }
63
+ .cpub-hero-content { flex: 1; }
64
+ .cpub-hero-eyebrow { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
65
+ .cpub-hero-badge { font-size: 9px; font-family: var(--font-mono); letter-spacing: 0.1em; text-transform: uppercase; padding: 3px 9px; background: var(--yellow-bg); border: var(--border-width-default) solid var(--yellow); color: var(--yellow); }
66
+ .cpub-hero-badge-live { background: var(--green-bg); border-color: var(--green); color: var(--green); display: flex; align-items: center; gap: 5px; }
67
+ .cpub-live-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--green); animation: cpub-pulse 2s ease-in-out infinite; }
68
+ @keyframes cpub-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
69
+ .cpub-hero-title { font-size: 22px; font-weight: 700; line-height: 1.25; margin-bottom: 10px; }
70
+ .cpub-hero-title span { color: var(--accent); }
71
+ .cpub-hero-excerpt { font-size: 13px; color: var(--text-dim); line-height: 1.65; margin-bottom: 20px; max-width: 560px; }
72
+ .cpub-hero-actions { display: flex; gap: 8px; }
73
+ </style>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ import type { HomepageSection } from '@commonpub/server';
3
+
4
+ defineProps<{
5
+ sections: HomepageSection[];
6
+ /** Which zone to render: 'main' for feed column, 'sidebar' for sidebar */
7
+ zone: 'main' | 'sidebar' | 'full-width';
8
+ }>();
9
+
10
+ const features = useFeatures();
11
+
12
+ function isFeatureEnabled(featureGate?: string): boolean {
13
+ if (!featureGate) return true;
14
+ return (features.features as unknown as Record<string, boolean>)?.[featureGate] ?? true;
15
+ }
16
+
17
+ /** Section types that render in the full-width zone (above the 2-column layout) */
18
+ const FULL_WIDTH_TYPES = new Set(['hero']);
19
+
20
+ /** Section types that render in the sidebar */
21
+ const SIDEBAR_TYPES = new Set(['stats', 'contests', 'hubs']);
22
+
23
+ function sectionZone(section: HomepageSection): 'full-width' | 'main' | 'sidebar' {
24
+ if (FULL_WIDTH_TYPES.has(section.type)) return 'full-width';
25
+ if (SIDEBAR_TYPES.has(section.type)) return 'sidebar';
26
+ return 'main';
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <template v-for="section in sections" :key="section.id">
32
+ <template v-if="section.enabled && sectionZone(section) === zone && isFeatureEnabled(section.config.featureGate)">
33
+ <HomepageHeroSection
34
+ v-if="section.type === 'hero'"
35
+ :config="section.config"
36
+ />
37
+ <HomepageEditorialSection
38
+ v-else-if="section.type === 'editorial'"
39
+ :config="section.config"
40
+ />
41
+ <HomepageContentGridSection
42
+ v-else-if="section.type === 'content-grid'"
43
+ :config="section.config"
44
+ :title="section.title"
45
+ />
46
+ <HomepageContestsSection
47
+ v-else-if="section.type === 'contests'"
48
+ :config="section.config"
49
+ />
50
+ <HomepageHubsSection
51
+ v-else-if="section.type === 'hubs'"
52
+ :config="section.config"
53
+ />
54
+ <HomepageStatsSection
55
+ v-else-if="section.type === 'stats'"
56
+ />
57
+ <HomepageCustomHtmlSection
58
+ v-else-if="section.type === 'custom-html'"
59
+ :config="section.config"
60
+ :title="section.title"
61
+ />
62
+ </template>
63
+ </template>
64
+ </template>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import type { HomepageSectionConfig } from '@commonpub/server';
3
+
4
+ const props = defineProps<{ config: HomepageSectionConfig }>();
5
+
6
+ const limit = computed(() => props.config.limit ?? 4);
7
+ const { data: communities, pending } = await useFetch('/api/hubs', { query: { limit }, lazy: true });
8
+
9
+ const { user } = useAuth();
10
+ const isAuthenticated = computed(() => !!user.value);
11
+ const joinedHubs = ref(new Set<string>());
12
+ const toast = useToast();
13
+
14
+ async function handleHubJoin(hubSlug: string): Promise<void> {
15
+ if (!isAuthenticated.value) {
16
+ await navigateTo('/auth/login?redirect=/');
17
+ return;
18
+ }
19
+ try {
20
+ await $fetch(`/api/hubs/${hubSlug}/join`, { method: 'POST' });
21
+ joinedHubs.value.add(hubSlug);
22
+ toast.success('Joined hub!');
23
+ } catch {
24
+ toast.error('Failed to join hub');
25
+ }
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <div v-if="pending" class="cpub-sb-card">
31
+ <div class="cpub-sb-head">Trending Hubs</div>
32
+ <div class="cpub-loading-state"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
33
+ </div>
34
+ <div v-else-if="communities?.items?.length" class="cpub-sb-card">
35
+ <div class="cpub-sb-head">Trending Hubs <NuxtLink to="/hubs">Browse</NuxtLink></div>
36
+ <div v-for="hub in communities.items" :key="hub.id" class="cpub-hub-item">
37
+ <div class="cpub-hub-icon">
38
+ <img v-if="hub.iconUrl" :src="hub.iconUrl" :alt="hub.name" class="cpub-hub-icon-img" />
39
+ <i v-else class="fa-solid fa-users"></i>
40
+ </div>
41
+ <div class="cpub-hub-info">
42
+ <NuxtLink :to="(hub as Record<string, unknown>).source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`" class="cpub-hub-name">{{ hub.name }}</NuxtLink>
43
+ <div class="cpub-hub-members">{{ hub.memberCount ?? 0 }} members</div>
44
+ </div>
45
+ <button v-if="joinedHubs.has(hub.slug)" class="cpub-btn-joined" disabled><i class="fa-solid fa-check"></i> Joined</button>
46
+ <button v-else class="cpub-btn-join" @click.prevent="handleHubJoin(hub.slug)">Join</button>
47
+ </div>
48
+ </div>
49
+ </template>
50
+
51
+ <style scoped>
52
+ .cpub-sb-card { background: var(--surface); border: var(--border-width-default) solid var(--border); padding: 16px; margin-bottom: 16px; }
53
+ .cpub-sb-head { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); padding-bottom: 10px; border-bottom: var(--border-width-default) solid var(--border2); margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
54
+ .cpub-sb-head a { color: var(--accent); text-decoration: none; font-size: 10px; }
55
+ .cpub-hub-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: var(--border-width-default) solid var(--border2); }
56
+ .cpub-hub-item:last-child { border-bottom: none; }
57
+ .cpub-hub-icon { width: 32px; height: 32px; background: var(--accent-bg); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 13px; color: var(--accent); flex-shrink: 0; overflow: hidden; }
58
+ .cpub-hub-icon-img { width: 100%; height: 100%; object-fit: cover; }
59
+ .cpub-hub-info { flex: 1; min-width: 0; }
60
+ .cpub-hub-name { font-size: 12px; font-weight: 600; color: var(--text); text-decoration: none; display: block; }
61
+ .cpub-hub-name:hover { color: var(--accent); }
62
+ .cpub-hub-members { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); }
63
+ .cpub-btn-join { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 10px; border: var(--border-width-default) solid var(--accent); color: var(--accent); background: none; cursor: pointer; }
64
+ .cpub-btn-join:hover { background: var(--accent-bg); }
65
+ .cpub-btn-joined { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 10px; border: var(--border-width-default) solid var(--green-border); color: var(--green); background: var(--green-bg); cursor: default; display: flex; align-items: center; gap: 3px; }
66
+ </style>
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ const { hubs: hubsEnabled } = useFeatures();
3
+ const { data: stats, pending } = await useFetch('/api/stats', { lazy: true });
4
+ </script>
5
+
6
+ <template>
7
+ <div class="cpub-sb-card">
8
+ <div class="cpub-sb-head">Platform Stats</div>
9
+ <div v-if="pending" class="cpub-loading-state"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
10
+ <div v-else class="cpub-stats-grid">
11
+ <div class="cpub-stat-block">
12
+ <span class="cpub-stat-num">{{ stats?.content?.byType?.project ?? 0 }}</span>
13
+ <span class="cpub-stat-lbl">Projects</span>
14
+ </div>
15
+ <div class="cpub-stat-block">
16
+ <span class="cpub-stat-num">{{ (stats?.content?.byType?.blog ?? 0) + (stats?.content?.byType?.article ?? 0) }}</span>
17
+ <span class="cpub-stat-lbl">Posts</span>
18
+ </div>
19
+ <div class="cpub-stat-block">
20
+ <span class="cpub-stat-num">{{ stats?.users?.total ?? 0 }}</span>
21
+ <span class="cpub-stat-lbl">Members</span>
22
+ </div>
23
+ <div v-if="hubsEnabled" class="cpub-stat-block">
24
+ <span class="cpub-stat-num">{{ stats?.hubs?.total ?? 0 }}</span>
25
+ <span class="cpub-stat-lbl">Hubs</span>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ </template>
30
+
31
+ <style scoped>
32
+ .cpub-sb-card { background: var(--surface); border: var(--border-width-default) solid var(--border); padding: 16px; margin-bottom: 16px; }
33
+ .cpub-sb-head { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); padding-bottom: 10px; border-bottom: var(--border-width-default) solid var(--border2); margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
34
+ .cpub-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
35
+ .cpub-stat-block { text-align: center; padding: 8px 0; }
36
+ .cpub-stat-num { display: block; font-family: var(--font-mono); font-size: 18px; font-weight: 700; color: var(--text); }
37
+ .cpub-stat-lbl { display: block; font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); }
38
+ </style>
@@ -0,0 +1,94 @@
1
+ <script setup lang="ts">
2
+ import type { NavItem } from '@commonpub/server';
3
+
4
+ defineProps<{
5
+ items: NavItem[];
6
+ }>();
7
+
8
+ const emit = defineEmits<{
9
+ close: [];
10
+ }>();
11
+
12
+ const { isAuthenticated, isAdmin } = useAuth();
13
+ const features = useFeatures();
14
+
15
+ const featureMap = computed(() => {
16
+ const map: Record<string, boolean> = {};
17
+ for (const [key, val] of Object.entries(features)) {
18
+ if (typeof val === 'object' && val !== null && 'value' in val) {
19
+ map[key] = (val as { value: boolean }).value;
20
+ }
21
+ }
22
+ return map;
23
+ });
24
+
25
+ function isVisible(item: NavItem): boolean {
26
+ if (item.featureGate && !featureMap.value[item.featureGate]) return false;
27
+ if (item.visibleTo === 'authenticated' && !isAuthenticated.value) return false;
28
+ if (item.visibleTo === 'admin' && !isAdmin.value) return false;
29
+ return true;
30
+ }
31
+
32
+ function visibleChildren(item: NavItem): NavItem[] {
33
+ return (item.children ?? []).filter(c => isVisible(c));
34
+ }
35
+ </script>
36
+
37
+ <template>
38
+ <nav class="cpub-mobile-nav" aria-label="Mobile navigation">
39
+ <template v-for="item in items" :key="item.id">
40
+ <!-- Dropdown → section label + indented children -->
41
+ <template v-if="item.type === 'dropdown' && isVisible(item) && visibleChildren(item).length > 0">
42
+ <div class="cpub-mobile-section-label">{{ item.label }}</div>
43
+ <template v-for="child in visibleChildren(item)" :key="child.id">
44
+ <span
45
+ v-if="child.disabled"
46
+ class="cpub-mobile-link cpub-mobile-link--indent cpub-mobile-link--disabled"
47
+ >
48
+ <i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
49
+ </span>
50
+ <a
51
+ v-else-if="child.type === 'external' && child.href"
52
+ :href="child.href"
53
+ target="_blank"
54
+ rel="noopener"
55
+ class="cpub-mobile-link cpub-mobile-link--indent"
56
+ @click="emit('close')"
57
+ >
58
+ <i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
59
+ </a>
60
+ <NuxtLink
61
+ v-else-if="child.route"
62
+ :to="child.route"
63
+ class="cpub-mobile-link cpub-mobile-link--indent"
64
+ @click="emit('close')"
65
+ >
66
+ <i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
67
+ </NuxtLink>
68
+ </template>
69
+ </template>
70
+
71
+ <!-- Regular link -->
72
+ <template v-else-if="isVisible(item) && item.type !== 'dropdown'">
73
+ <a
74
+ v-if="item.type === 'external' && item.href"
75
+ :href="item.href"
76
+ target="_blank"
77
+ rel="noopener"
78
+ class="cpub-mobile-link"
79
+ @click="emit('close')"
80
+ >
81
+ <i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
82
+ </a>
83
+ <NuxtLink
84
+ v-else-if="item.route"
85
+ :to="item.route"
86
+ class="cpub-mobile-link"
87
+ @click="emit('close')"
88
+ >
89
+ <i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
90
+ </NuxtLink>
91
+ </template>
92
+ </template>
93
+ </nav>
94
+ </template>
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ import type { NavItem } from '@commonpub/server';
3
+
4
+ const props = defineProps<{
5
+ item: NavItem;
6
+ open: boolean;
7
+ }>();
8
+
9
+ const emit = defineEmits<{
10
+ toggle: [];
11
+ close: [];
12
+ }>();
13
+
14
+ const { isAuthenticated, isAdmin } = useAuth();
15
+ const features = useFeatures();
16
+ const featureMap = computed(() => {
17
+ const map: Record<string, boolean> = {};
18
+ for (const [key, val] of Object.entries(features)) {
19
+ if (typeof val === 'object' && val !== null && 'value' in val) {
20
+ map[key] = (val as { value: boolean }).value;
21
+ }
22
+ }
23
+ return map;
24
+ });
25
+
26
+ function isChildVisible(child: NavItem): boolean {
27
+ if (child.featureGate && !featureMap.value[child.featureGate]) return false;
28
+ if (child.visibleTo === 'authenticated' && !isAuthenticated.value) return false;
29
+ if (child.visibleTo === 'admin' && !isAdmin.value) return false;
30
+ return true;
31
+ }
32
+
33
+ const visibleChildren = computed(() =>
34
+ (props.item.children ?? []).filter(c => isChildVisible(c)),
35
+ );
36
+
37
+ function handleKeydown(e: KeyboardEvent): void {
38
+ if (e.key === 'Escape' && props.open) {
39
+ emit('close');
40
+ }
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <div v-if="visibleChildren.length > 0" class="cpub-nav-dropdown" @keydown="handleKeydown">
46
+ <button
47
+ class="cpub-nav-link cpub-nav-trigger"
48
+ :class="{ 'cpub-nav-trigger--open': open }"
49
+ :aria-label="`${item.label} menu`"
50
+ aria-haspopup="true"
51
+ :aria-expanded="open"
52
+ @click.stop="emit('toggle')"
53
+ @keydown.enter.stop="emit('toggle')"
54
+ @keydown.space.prevent.stop="emit('toggle')"
55
+ >
56
+ <i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
57
+ <i class="fa-solid fa-chevron-down cpub-nav-caret" />
58
+ </button>
59
+ <div v-if="open" class="cpub-nav-panel" role="menu">
60
+ <template v-for="child in visibleChildren" :key="child.id">
61
+ <span
62
+ v-if="child.disabled"
63
+ class="cpub-nav-panel-item cpub-nav-panel-item--disabled"
64
+ role="menuitem"
65
+ aria-disabled="true"
66
+ >
67
+ <i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
68
+ </span>
69
+ <a
70
+ v-else-if="child.type === 'external' && child.href"
71
+ :href="child.href"
72
+ target="_blank"
73
+ rel="noopener"
74
+ class="cpub-nav-panel-item"
75
+ role="menuitem"
76
+ @click="emit('close')"
77
+ >
78
+ <i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
79
+ <i class="fa-solid fa-arrow-up-right-from-square cpub-nav-external-icon"></i>
80
+ </a>
81
+ <NuxtLink
82
+ v-else-if="child.route"
83
+ :to="child.route"
84
+ class="cpub-nav-panel-item"
85
+ role="menuitem"
86
+ @click="emit('close')"
87
+ >
88
+ <i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
89
+ </NuxtLink>
90
+ </template>
91
+ </div>
92
+ </div>
93
+ </template>
94
+
95
+ <style scoped>
96
+ .cpub-nav-external-icon {
97
+ font-size: 8px;
98
+ color: var(--text-faint);
99
+ margin-left: 2px;
100
+ }
101
+ </style>
@@ -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-panel-item--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
+ opacity: 0.5;
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>
@@ -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
@@ -33,6 +33,9 @@ const sidebarOpen = ref(false);
33
33
  <NuxtLink to="/admin/reports" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-flag"></i> Reports</NuxtLink>
34
34
  <NuxtLink to="/admin/audit" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-clipboard-list"></i> Audit Log</NuxtLink>
35
35
  <NuxtLink to="/admin/theme" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-palette"></i> Theme</NuxtLink>
36
+ <NuxtLink to="/admin/homepage" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-house"></i> Homepage</NuxtLink>
37
+ <NuxtLink to="/admin/navigation" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-bars"></i> Navigation</NuxtLink>
38
+ <NuxtLink to="/admin/features" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-toggle-on"></i> Features</NuxtLink>
36
39
  <NuxtLink to="/admin/federation" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-globe"></i> Federation</NuxtLink>
37
40
  <NuxtLink to="/admin/settings" class="admin-nav-link" @click="sidebarOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
38
41
  </nav>