@commonpub/layer 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/components/EventCard.vue +121 -0
  2. package/components/PollDisplay.vue +108 -0
  3. package/components/PostVoteButtons.vue +108 -0
  4. package/components/contest/ContestJudgeManager.vue +110 -0
  5. package/components/nav/MobileNavRenderer.vue +94 -0
  6. package/components/nav/NavDropdown.vue +101 -0
  7. package/components/nav/NavLink.vue +40 -0
  8. package/components/nav/NavRenderer.vue +51 -0
  9. package/composables/useFeatures.ts +2 -0
  10. package/layouts/admin.vue +1 -0
  11. package/layouts/default.vue +22 -86
  12. package/middleware/feature-gate.global.ts +1 -0
  13. package/package.json +7 -7
  14. package/pages/admin/navigation.vue +350 -0
  15. package/pages/events/[slug]/edit.vue +182 -0
  16. package/pages/events/[slug]/index.vue +249 -0
  17. package/pages/events/create.vue +140 -0
  18. package/pages/events/index.vue +47 -0
  19. package/server/api/admin/navigation/items.get.ts +11 -0
  20. package/server/api/admin/navigation/items.put.ts +51 -0
  21. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +20 -0
  22. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +20 -0
  23. package/server/api/contests/[slug]/judge.post.ts +5 -7
  24. package/server/api/contests/[slug]/judges/[userId].delete.ts +26 -0
  25. package/server/api/contests/[slug]/judges/accept.post.ts +21 -0
  26. package/server/api/contests/[slug]/judges/index.get.ts +17 -0
  27. package/server/api/contests/[slug]/judges/index.post.ts +36 -0
  28. package/server/api/events/[slug]/attendees.get.ts +23 -0
  29. package/server/api/events/[slug]/rsvp.delete.ts +23 -0
  30. package/server/api/events/[slug]/rsvp.post.ts +23 -0
  31. package/server/api/events/[slug].delete.ts +22 -0
  32. package/server/api/events/[slug].get.ts +17 -0
  33. package/server/api/events/[slug].put.ts +38 -0
  34. package/server/api/events/index.get.ts +21 -0
  35. package/server/api/events/index.post.ts +40 -0
  36. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +18 -0
  37. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +27 -0
  38. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +21 -0
  39. package/server/api/navigation/items.get.ts +10 -0
  40. package/server/middleware/features.ts +1 -0
@@ -0,0 +1,121 @@
1
+ <script setup lang="ts">
2
+ import type { EventListItem } from '@commonpub/server';
3
+
4
+ const props = defineProps<{
5
+ event: EventListItem;
6
+ }>();
7
+
8
+ const statusClass = computed(() => {
9
+ const map: Record<string, string> = {
10
+ published: 'cpub-badge-accent',
11
+ active: 'cpub-badge-green',
12
+ completed: 'cpub-badge-dim',
13
+ cancelled: 'cpub-badge-red',
14
+ draft: 'cpub-badge-yellow',
15
+ };
16
+ return map[props.event.status] ?? 'cpub-badge-dim';
17
+ });
18
+
19
+ const typeIcon = computed(() => {
20
+ const map: Record<string, string> = {
21
+ 'in-person': 'fa-solid fa-location-dot',
22
+ 'online': 'fa-solid fa-video',
23
+ 'hybrid': 'fa-solid fa-arrows-split-up-and-left',
24
+ };
25
+ return map[props.event.eventType] ?? 'fa-solid fa-calendar';
26
+ });
27
+
28
+ function formatDate(date: string | Date): string {
29
+ return new Date(date).toLocaleDateString('en-US', {
30
+ month: 'short',
31
+ day: 'numeric',
32
+ year: 'numeric',
33
+ });
34
+ }
35
+
36
+ function formatTime(date: string | Date): string {
37
+ return new Date(date).toLocaleTimeString('en-US', {
38
+ hour: 'numeric',
39
+ minute: '2-digit',
40
+ });
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <NuxtLink :to="`/events/${event.slug}`" class="cpub-event-card">
46
+ <div v-if="event.coverImage" class="cpub-event-cover">
47
+ <img :src="event.coverImage" :alt="event.title" />
48
+ </div>
49
+ <div class="cpub-event-body">
50
+ <div class="cpub-event-header">
51
+ <span class="cpub-badge" :class="statusClass">{{ event.status }}</span>
52
+ <span v-if="event.isFeatured" class="cpub-badge cpub-badge-accent"><i class="fa-solid fa-star"></i> Featured</span>
53
+ </div>
54
+ <div class="cpub-event-date">
55
+ <i class="fa-solid fa-calendar"></i>
56
+ {{ formatDate(event.startDate) }} at {{ formatTime(event.startDate) }}
57
+ </div>
58
+ <h3 class="cpub-event-title">{{ event.title }}</h3>
59
+ <p v-if="event.description" class="cpub-event-desc">{{ event.description }}</p>
60
+ <div class="cpub-event-meta">
61
+ <span><i :class="typeIcon"></i> {{ event.eventType }}</span>
62
+ <span v-if="event.location"><i class="fa-solid fa-map-pin"></i> {{ event.location }}</span>
63
+ <span><i class="fa-solid fa-users"></i> {{ event.attendeeCount }}{{ event.capacity ? ` / ${event.capacity}` : '' }}</span>
64
+ </div>
65
+ </div>
66
+ </NuxtLink>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .cpub-event-card {
71
+ display: flex;
72
+ flex-direction: column;
73
+ border: var(--border-width-default) solid var(--border);
74
+ background: var(--surface);
75
+ text-decoration: none;
76
+ color: var(--text);
77
+ transition: box-shadow 0.15s, transform 0.15s;
78
+ box-shadow: var(--shadow-md);
79
+ overflow: hidden;
80
+ }
81
+ .cpub-event-card:hover { box-shadow: var(--shadow-lg); transform: translate(-1px, -1px); }
82
+
83
+ .cpub-event-cover { height: 140px; overflow: hidden; }
84
+ .cpub-event-cover img { width: 100%; height: 100%; object-fit: cover; }
85
+
86
+ .cpub-event-body { padding: 16px; display: flex; flex-direction: column; gap: 8px; }
87
+
88
+ .cpub-event-header { display: flex; gap: 6px; flex-wrap: wrap; }
89
+
90
+ .cpub-event-date {
91
+ font-family: var(--font-mono);
92
+ font-size: 11px;
93
+ color: var(--accent);
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 6px;
97
+ }
98
+ .cpub-event-date i { font-size: 10px; }
99
+
100
+ .cpub-event-title { font-size: 15px; font-weight: 600; margin: 0; }
101
+
102
+ .cpub-event-desc {
103
+ font-size: 12px;
104
+ color: var(--text-dim);
105
+ display: -webkit-box;
106
+ -webkit-line-clamp: 2;
107
+ -webkit-box-orient: vertical;
108
+ overflow: hidden;
109
+ }
110
+
111
+ .cpub-event-meta {
112
+ display: flex;
113
+ flex-wrap: wrap;
114
+ gap: 12px;
115
+ font-size: 11px;
116
+ color: var(--text-faint);
117
+ font-family: var(--font-mono);
118
+ margin-top: 4px;
119
+ }
120
+ .cpub-event-meta i { font-size: 10px; margin-right: 3px; }
121
+ </style>
@@ -0,0 +1,108 @@
1
+ <script setup lang="ts">
2
+ import type { PollOptionResult } from '@commonpub/server';
3
+
4
+ const props = defineProps<{
5
+ hubSlug: string;
6
+ postId: string;
7
+ }>();
8
+
9
+ const { isAuthenticated } = useAuth();
10
+ const toast = useToast();
11
+ const loading = ref(false);
12
+
13
+ const { data, refresh } = await useFetch<{ options: PollOptionResult[]; userVote: string | null }>(
14
+ `/api/hubs/${props.hubSlug}/posts/${props.postId}/poll-options`,
15
+ );
16
+
17
+ const totalVotes = computed(() => {
18
+ if (!data.value) return 0;
19
+ return data.value.options.reduce((sum, opt) => sum + opt.voteCount, 0);
20
+ });
21
+
22
+ const hasVoted = computed(() => !!data.value?.userVote);
23
+
24
+ function percentage(count: number): number {
25
+ if (totalVotes.value === 0) return 0;
26
+ return Math.round((count / totalVotes.value) * 100);
27
+ }
28
+
29
+ async function vote(optionId: string): Promise<void> {
30
+ if (!isAuthenticated.value || loading.value || hasVoted.value) return;
31
+ loading.value = true;
32
+ try {
33
+ await $fetch(`/api/hubs/${props.hubSlug}/posts/${props.postId}/poll-vote`, {
34
+ method: 'POST',
35
+ body: { optionId },
36
+ });
37
+ await refresh();
38
+ } catch {
39
+ toast.error('Failed to submit vote');
40
+ } finally {
41
+ loading.value = false;
42
+ }
43
+ }
44
+ </script>
45
+
46
+ <template>
47
+ <div v-if="data?.options?.length" class="cpub-poll" role="group" aria-label="Poll">
48
+ <button
49
+ v-for="option in data.options"
50
+ :key="option.id"
51
+ type="button"
52
+ class="cpub-poll-option"
53
+ :class="{ voted: data.userVote === option.id, clickable: !hasVoted && isAuthenticated }"
54
+ :disabled="hasVoted || !isAuthenticated"
55
+ :aria-pressed="data.userVote === option.id"
56
+ :aria-label="`${option.label}${hasVoted ? ` — ${percentage(option.voteCount)}%` : ''}`"
57
+ @click="vote(option.id)"
58
+ >
59
+ <div class="cpub-poll-bar" :style="{ width: hasVoted || !isAuthenticated ? `${percentage(option.voteCount)}%` : '0%' }" />
60
+ <span class="cpub-poll-label">{{ option.label }}</span>
61
+ <span v-if="hasVoted || !isAuthenticated" class="cpub-poll-pct">{{ percentage(option.voteCount) }}%</span>
62
+ </button>
63
+ <div class="cpub-poll-meta">
64
+ {{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }}
65
+ </div>
66
+ </div>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .cpub-poll { display: flex; flex-direction: column; gap: 6px; }
71
+
72
+ .cpub-poll-option {
73
+ position: relative;
74
+ padding: 8px 12px;
75
+ border: var(--border-width-default) solid var(--border);
76
+ background: transparent;
77
+ font-size: 13px;
78
+ font-family: inherit;
79
+ color: var(--text);
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 8px;
83
+ overflow: hidden;
84
+ transition: border-color 0.12s;
85
+ text-align: left;
86
+ width: 100%;
87
+ }
88
+ .cpub-poll-option.clickable { cursor: pointer; }
89
+ .cpub-poll-option.clickable:hover { border-color: var(--accent); }
90
+ .cpub-poll-option:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
91
+ .cpub-poll-option.voted { border-color: var(--accent); font-weight: 600; }
92
+ .cpub-poll-option:disabled:not(.voted) { opacity: 0.7; cursor: default; }
93
+
94
+ .cpub-poll-bar {
95
+ position: absolute;
96
+ left: 0;
97
+ top: 0;
98
+ bottom: 0;
99
+ background: var(--accent-bg);
100
+ transition: width 0.3s ease;
101
+ z-index: 0;
102
+ }
103
+
104
+ .cpub-poll-label { position: relative; z-index: 1; flex: 1; }
105
+ .cpub-poll-pct { position: relative; z-index: 1; font-family: var(--font-mono); font-size: 11px; color: var(--text-dim); }
106
+
107
+ .cpub-poll-meta { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-top: 2px; }
108
+ </style>
@@ -0,0 +1,108 @@
1
+ <script setup lang="ts">
2
+ import type { VoteDirection } from '@commonpub/server';
3
+
4
+ const props = defineProps<{
5
+ hubSlug: string;
6
+ postId: string;
7
+ voteScore: number;
8
+ userVote?: VoteDirection | null;
9
+ }>();
10
+
11
+ const emit = defineEmits<{
12
+ voted: [result: { voteScore: number; direction: VoteDirection | null }];
13
+ }>();
14
+
15
+ const { isAuthenticated } = useAuth();
16
+ const toast = useToast();
17
+ const loading = ref(false);
18
+ const currentScore = ref(props.voteScore);
19
+ const currentVote = ref<VoteDirection | null>(props.userVote ?? null);
20
+
21
+ watch(() => props.voteScore, (v) => { currentScore.value = v; });
22
+ watch(() => props.userVote, (v) => { currentVote.value = v ?? null; });
23
+
24
+ async function vote(direction: VoteDirection): Promise<void> {
25
+ if (!isAuthenticated.value || loading.value) return;
26
+ loading.value = true;
27
+ try {
28
+ const result = await $fetch<{ voted: boolean; direction: VoteDirection | null; voteScore: number }>(
29
+ `/api/hubs/${props.hubSlug}/posts/${props.postId}/vote`,
30
+ { method: 'POST', body: { direction } },
31
+ );
32
+ currentScore.value = result.voteScore;
33
+ currentVote.value = result.direction;
34
+ emit('voted', { voteScore: result.voteScore, direction: result.direction });
35
+ } catch {
36
+ toast.error('Vote failed');
37
+ } finally {
38
+ loading.value = false;
39
+ }
40
+ }
41
+ </script>
42
+
43
+ <template>
44
+ <div class="cpub-vote-buttons">
45
+ <button
46
+ class="cpub-vote-btn cpub-vote-up"
47
+ :class="{ active: currentVote === 'up' }"
48
+ :disabled="!isAuthenticated || loading"
49
+ :aria-pressed="currentVote === 'up'"
50
+ aria-label="Upvote"
51
+ @click="vote('up')"
52
+ >
53
+ <i class="fa-solid fa-chevron-up"></i>
54
+ </button>
55
+ <span class="cpub-vote-score" :class="{ positive: currentScore > 0, negative: currentScore < 0 }">
56
+ {{ currentScore }}
57
+ </span>
58
+ <button
59
+ class="cpub-vote-btn cpub-vote-down"
60
+ :class="{ active: currentVote === 'down' }"
61
+ :disabled="!isAuthenticated || loading"
62
+ :aria-pressed="currentVote === 'down'"
63
+ aria-label="Downvote"
64
+ @click="vote('down')"
65
+ >
66
+ <i class="fa-solid fa-chevron-down"></i>
67
+ </button>
68
+ </div>
69
+ </template>
70
+
71
+ <style scoped>
72
+ .cpub-vote-buttons {
73
+ display: flex;
74
+ flex-direction: column;
75
+ align-items: center;
76
+ gap: 2px;
77
+ }
78
+
79
+ .cpub-vote-btn {
80
+ width: 28px;
81
+ height: 24px;
82
+ display: flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ background: none;
86
+ border: var(--border-width-default) solid transparent;
87
+ color: var(--text-faint);
88
+ font-size: 11px;
89
+ cursor: pointer;
90
+ transition: all 0.12s;
91
+ }
92
+ .cpub-vote-btn:hover:not(:disabled) { color: var(--text); background: var(--surface2); }
93
+ .cpub-vote-btn:disabled { opacity: 0.3; cursor: default; }
94
+ .cpub-vote-btn.active { color: var(--accent); }
95
+ .cpub-vote-up.active { color: var(--green, var(--accent)); }
96
+ .cpub-vote-down.active { color: var(--red, var(--accent)); }
97
+
98
+ .cpub-vote-score {
99
+ font-family: var(--font-mono);
100
+ font-size: 12px;
101
+ font-weight: 700;
102
+ color: var(--text-dim);
103
+ min-width: 20px;
104
+ text-align: center;
105
+ }
106
+ .cpub-vote-score.positive { color: var(--green, var(--accent)); }
107
+ .cpub-vote-score.negative { color: var(--red); }
108
+ </style>
@@ -0,0 +1,110 @@
1
+ <script setup lang="ts">
2
+ import type { ContestJudgeItem } from '@commonpub/server';
3
+
4
+ const props = defineProps<{
5
+ contestSlug: string;
6
+ isOwner: boolean;
7
+ }>();
8
+
9
+ const toast = useToast();
10
+ const { data: judges, refresh } = await useFetch<ContestJudgeItem[]>(
11
+ `/api/contests/${props.contestSlug}/judges`,
12
+ );
13
+
14
+ const newJudgeId = ref('');
15
+ const newJudgeRole = ref<'lead' | 'judge' | 'guest'>('judge');
16
+ const adding = ref(false);
17
+
18
+ async function addJudge(): Promise<void> {
19
+ if (!newJudgeId.value) return;
20
+ adding.value = true;
21
+ try {
22
+ await $fetch(`/api/contests/${props.contestSlug}/judges`, {
23
+ method: 'POST',
24
+ body: { userId: newJudgeId.value, role: newJudgeRole.value },
25
+ });
26
+ toast.success('Judge added');
27
+ newJudgeId.value = '';
28
+ await refresh();
29
+ } catch {
30
+ toast.error('Failed to add judge');
31
+ } finally {
32
+ adding.value = false;
33
+ }
34
+ }
35
+
36
+ async function removeJudge(userId: string): Promise<void> {
37
+ if (!confirm('Remove this judge?')) return;
38
+ try {
39
+ await $fetch(`/api/contests/${props.contestSlug}/judges/${userId}`, { method: 'DELETE' });
40
+ toast.success('Judge removed');
41
+ await refresh();
42
+ } catch {
43
+ toast.error('Failed to remove judge');
44
+ }
45
+ }
46
+
47
+ const roleLabels: Record<string, string> = {
48
+ lead: 'Lead Judge',
49
+ judge: 'Judge',
50
+ guest: 'Guest Judge',
51
+ };
52
+ </script>
53
+
54
+ <template>
55
+ <div class="cpub-contest-judges">
56
+ <h3 class="cpub-judges-title">Judges</h3>
57
+
58
+ <div v-if="judges?.length" class="cpub-judges-list">
59
+ <div v-for="judge in judges" :key="judge.id" class="cpub-judge-row">
60
+ <NuxtLink :to="`/u/${judge.userUsername}`" class="cpub-judge-link">
61
+ <span class="cpub-judge-avatar">
62
+ <img v-if="judge.userAvatar" :src="judge.userAvatar" :alt="judge.userName" />
63
+ <span v-else>{{ judge.userName.charAt(0) }}</span>
64
+ </span>
65
+ <span class="cpub-judge-name">{{ judge.userName }}</span>
66
+ </NuxtLink>
67
+ <span class="cpub-judge-role">{{ roleLabels[judge.role] || judge.role }}</span>
68
+ <span v-if="!judge.acceptedAt" class="cpub-judge-pending">Pending</span>
69
+ <button v-if="isOwner" class="cpub-judge-remove" :aria-label="`Remove ${judge.userName} from judges`" @click="removeJudge(judge.userId)">
70
+ <i class="fa-solid fa-xmark"></i>
71
+ </button>
72
+ </div>
73
+ </div>
74
+ <p v-else class="cpub-judges-empty">No judges assigned yet.</p>
75
+
76
+ <div v-if="isOwner" class="cpub-judges-add">
77
+ <input v-model="newJudgeId" class="cpub-judges-input" placeholder="User ID" />
78
+ <select v-model="newJudgeRole" class="cpub-judges-input cpub-judges-select">
79
+ <option value="lead">Lead</option>
80
+ <option value="judge">Judge</option>
81
+ <option value="guest">Guest</option>
82
+ </select>
83
+ <button class="cpub-btn cpub-btn-sm" :disabled="adding || !newJudgeId" @click="addJudge">
84
+ <i class="fa-solid fa-plus"></i> Add
85
+ </button>
86
+ </div>
87
+ </div>
88
+ </template>
89
+
90
+ <style scoped>
91
+ .cpub-judges-title { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-faint); margin: 0 0 12px; }
92
+
93
+ .cpub-judges-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
94
+ .cpub-judge-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; }
95
+ .cpub-judge-link { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text); flex: 1; min-width: 0; }
96
+ .cpub-judge-avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--surface2); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; overflow: hidden; flex-shrink: 0; }
97
+ .cpub-judge-avatar img { width: 100%; height: 100%; object-fit: cover; }
98
+ .cpub-judge-name { font-size: 12px; font-weight: 600; }
99
+ .cpub-judge-role { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; color: var(--text-faint); }
100
+ .cpub-judge-pending { font-family: var(--font-mono); font-size: 9px; color: var(--yellow, var(--text-faint)); }
101
+ .cpub-judge-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 10px; padding: 4px; }
102
+ .cpub-judge-remove:hover { color: var(--red); }
103
+
104
+ .cpub-judges-empty { font-size: 12px; color: var(--text-faint); font-style: italic; margin-bottom: 12px; }
105
+
106
+ .cpub-judges-add { display: flex; gap: 6px; align-items: center; }
107
+ .cpub-judges-input { font-size: 12px; padding: 4px 8px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; }
108
+ .cpub-judges-input:focus { border-color: var(--accent); }
109
+ .cpub-judges-select { max-width: 100px; }
110
+ </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>