@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.
Files changed (47) hide show
  1. package/components/DiscussionItem.vue +4 -1
  2. package/components/EventCard.vue +121 -0
  3. package/components/PollDisplay.vue +108 -0
  4. package/components/PostVoteButtons.vue +108 -0
  5. package/components/contest/ContestJudgeManager.vue +110 -0
  6. package/components/hub/HubDiscussions.vue +2 -0
  7. package/components/nav/MobileNavRenderer.vue +94 -0
  8. package/components/nav/NavDropdown.vue +101 -0
  9. package/components/nav/NavLink.vue +40 -0
  10. package/components/nav/NavRenderer.vue +51 -0
  11. package/composables/useAuth.ts +20 -15
  12. package/composables/useFeatures.ts +34 -13
  13. package/layouts/admin.vue +1 -0
  14. package/layouts/default.vue +50 -108
  15. package/middleware/feature-gate.global.ts +9 -4
  16. package/package.json +6 -6
  17. package/pages/admin/navigation.vue +350 -0
  18. package/pages/contests/[slug]/index.vue +1 -0
  19. package/pages/events/[slug]/edit.vue +182 -0
  20. package/pages/events/[slug]/index.vue +249 -0
  21. package/pages/events/create.vue +140 -0
  22. package/pages/events/index.vue +47 -0
  23. package/pages/federated-hubs/[id]/index.vue +1 -0
  24. package/pages/hubs/[slug]/index.vue +1 -0
  25. package/server/api/admin/navigation/items.get.ts +11 -0
  26. package/server/api/admin/navigation/items.put.ts +51 -0
  27. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +20 -0
  28. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +20 -0
  29. package/server/api/contests/[slug]/judge.post.ts +5 -7
  30. package/server/api/contests/[slug]/judges/[userId].delete.ts +26 -0
  31. package/server/api/contests/[slug]/judges/accept.post.ts +21 -0
  32. package/server/api/contests/[slug]/judges/index.get.ts +17 -0
  33. package/server/api/contests/[slug]/judges/index.post.ts +36 -0
  34. package/server/api/events/[slug]/attendees.get.ts +23 -0
  35. package/server/api/events/[slug]/rsvp.delete.ts +23 -0
  36. package/server/api/events/[slug]/rsvp.post.ts +23 -0
  37. package/server/api/events/[slug].delete.ts +22 -0
  38. package/server/api/events/[slug].get.ts +17 -0
  39. package/server/api/events/[slug].put.ts +38 -0
  40. package/server/api/events/index.get.ts +21 -0
  41. package/server/api/events/index.post.ts +40 -0
  42. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +18 -0
  43. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +27 -0
  44. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +21 -0
  45. package/server/api/navigation/items.get.ts +10 -0
  46. package/server/middleware/features.ts +1 -0
  47. package/types/hub.ts +1 -0
@@ -0,0 +1,249 @@
1
+ <script setup lang="ts">
2
+ import type { EventDetail, AttendeeItem, AttendeeStatus } from '@commonpub/server';
3
+
4
+ const route = useRoute();
5
+ const slug = route.params.slug as string;
6
+ const toast = useToast();
7
+ const { isAuthenticated, user } = useAuth();
8
+
9
+ const { data: event, refresh } = await useFetch<EventDetail>(`/api/events/${slug}`);
10
+ const { data: attendees } = await useFetch<{ items: AttendeeItem[]; total: number }>(`/api/events/${slug}/attendees`);
11
+
12
+ useSeoMeta({ title: event.value ? `${event.value.title} — Events — ${useSiteName()}` : `Event — ${useSiteName()}` });
13
+
14
+ const rsvpLoading = ref(false);
15
+
16
+ const myRsvpStatus = computed((): AttendeeStatus | null => {
17
+ if (!isAuthenticated.value || !attendees.value) return null;
18
+ const found = attendees.value.items.find((a: AttendeeItem) => a.userId === user.value?.id);
19
+ return found?.status ?? null;
20
+ });
21
+
22
+ const isOwner = computed(() => user.value?.id === event.value?.createdById);
23
+ const isAdmin = computed(() => user.value?.role === 'admin');
24
+ const canEdit = computed(() => isOwner.value || isAdmin.value);
25
+ const spotsLeft = computed(() => {
26
+ if (!event.value?.capacity) return null;
27
+ return Math.max(0, event.value.capacity - event.value.attendeeCount);
28
+ });
29
+
30
+ async function rsvp(): Promise<void> {
31
+ rsvpLoading.value = true;
32
+ try {
33
+ await $fetch(`/api/events/${slug}/rsvp`, { method: 'POST' });
34
+ toast.success('RSVP confirmed');
35
+ await refresh();
36
+ } catch (e: unknown) {
37
+ const msg = (e as { data?: { message?: string } })?.data?.message ?? 'RSVP failed';
38
+ toast.error(msg);
39
+ } finally {
40
+ rsvpLoading.value = false;
41
+ }
42
+ }
43
+
44
+ async function cancelRsvp(): Promise<void> {
45
+ rsvpLoading.value = true;
46
+ try {
47
+ await $fetch(`/api/events/${slug}/rsvp`, { method: 'DELETE' });
48
+ toast.success('RSVP cancelled');
49
+ await refresh();
50
+ } catch {
51
+ toast.error('Failed to cancel RSVP');
52
+ } finally {
53
+ rsvpLoading.value = false;
54
+ }
55
+ }
56
+
57
+ function formatDate(date: string | Date): string {
58
+ return new Date(date).toLocaleDateString('en-US', {
59
+ weekday: 'long',
60
+ month: 'long',
61
+ day: 'numeric',
62
+ year: 'numeric',
63
+ });
64
+ }
65
+
66
+ function formatTime(date: string | Date): string {
67
+ return new Date(date).toLocaleTimeString('en-US', {
68
+ hour: 'numeric',
69
+ minute: '2-digit',
70
+ });
71
+ }
72
+
73
+ const typeIcon = computed(() => {
74
+ const map: Record<string, string> = {
75
+ 'in-person': 'fa-solid fa-location-dot',
76
+ 'online': 'fa-solid fa-video',
77
+ 'hybrid': 'fa-solid fa-arrows-split-up-and-left',
78
+ };
79
+ return map[event.value?.eventType ?? ''] ?? 'fa-solid fa-calendar';
80
+ });
81
+ </script>
82
+
83
+ <template>
84
+ <div v-if="event" class="cpub-event-detail">
85
+ <div v-if="event.coverImage" class="cpub-event-hero">
86
+ <img :src="event.coverImage" :alt="event.title" />
87
+ </div>
88
+
89
+ <div class="cpub-event-content">
90
+ <div class="cpub-event-main">
91
+ <div class="cpub-event-badges">
92
+ <span class="cpub-badge" :class="{
93
+ 'cpub-badge-green': event.status === 'active',
94
+ 'cpub-badge-accent': event.status === 'published',
95
+ 'cpub-badge-dim': event.status === 'completed',
96
+ 'cpub-badge-red': event.status === 'cancelled',
97
+ 'cpub-badge-yellow': event.status === 'draft',
98
+ }">{{ event.status }}</span>
99
+ <span v-if="event.isFeatured" class="cpub-badge cpub-badge-accent"><i class="fa-solid fa-star"></i> Featured</span>
100
+ <span class="cpub-badge"><i :class="typeIcon"></i> {{ event.eventType }}</span>
101
+ </div>
102
+
103
+ <h1 class="cpub-event-title">{{ event.title }}</h1>
104
+
105
+ <div class="cpub-event-info-grid">
106
+ <div class="cpub-event-info-item">
107
+ <i class="fa-solid fa-calendar"></i>
108
+ <div>
109
+ <div>{{ formatDate(event.startDate) }}</div>
110
+ <div class="cpub-event-info-sub">{{ formatTime(event.startDate) }} — {{ formatTime(event.endDate) }}</div>
111
+ </div>
112
+ </div>
113
+ <div v-if="event.location" class="cpub-event-info-item">
114
+ <i class="fa-solid fa-map-pin"></i>
115
+ <div>
116
+ <div>{{ event.location }}</div>
117
+ <a v-if="event.locationUrl" :href="event.locationUrl" target="_blank" rel="noopener" class="cpub-event-info-sub">View map <i class="fa-solid fa-arrow-up-right-from-square" style="font-size: 8px;"></i></a>
118
+ </div>
119
+ </div>
120
+ <div v-if="event.onlineUrl" class="cpub-event-info-item">
121
+ <i class="fa-solid fa-video"></i>
122
+ <div>
123
+ <a :href="event.onlineUrl" target="_blank" rel="noopener">Join online <i class="fa-solid fa-arrow-up-right-from-square" style="font-size: 8px;"></i></a>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <div v-if="event.description" class="cpub-event-description">
129
+ {{ event.description }}
130
+ </div>
131
+
132
+ <div class="cpub-event-organizer">
133
+ <span class="cpub-event-organizer-label">Organized by</span>
134
+ <NuxtLink :to="`/u/${event.createdByUsername}`" class="cpub-event-organizer-link">
135
+ <span class="cpub-event-avatar">
136
+ <img v-if="event.createdByAvatar" :src="event.createdByAvatar" :alt="event.createdByName" />
137
+ <span v-else>{{ event.createdByName.charAt(0) }}</span>
138
+ </span>
139
+ {{ event.createdByName }}
140
+ </NuxtLink>
141
+ </div>
142
+ </div>
143
+
144
+ <aside class="cpub-event-sidebar">
145
+ <div class="cpub-event-rsvp-card">
146
+ <div class="cpub-event-rsvp-count">
147
+ <span class="cpub-event-rsvp-num">{{ event.attendeeCount }}</span>
148
+ <span class="cpub-event-rsvp-label">{{ event.attendeeCount === 1 ? 'attendee' : 'attendees' }}</span>
149
+ </div>
150
+ <div v-if="spotsLeft !== null" class="cpub-event-rsvp-spots">
151
+ {{ spotsLeft > 0 ? `${spotsLeft} spots left` : 'Event is full' }}
152
+ </div>
153
+
154
+ <template v-if="isAuthenticated && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'draft'">
155
+ <button
156
+ v-if="!myRsvpStatus"
157
+ class="cpub-btn cpub-btn-primary cpub-btn-block"
158
+ :disabled="rsvpLoading || (spotsLeft !== null && spotsLeft <= 0)"
159
+ @click="rsvp"
160
+ >
161
+ <i :class="rsvpLoading ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-check'"></i>
162
+ {{ spotsLeft !== null && spotsLeft <= 0 ? 'Join Waitlist' : 'RSVP' }}
163
+ </button>
164
+ <div v-else class="cpub-event-rsvp-status">
165
+ <span class="cpub-badge cpub-badge-green" v-if="myRsvpStatus === 'registered'">Registered</span>
166
+ <span class="cpub-badge cpub-badge-yellow" v-else-if="myRsvpStatus === 'waitlisted'">Waitlisted</span>
167
+ <button class="cpub-btn cpub-btn-sm cpub-btn-block" :disabled="rsvpLoading" @click="cancelRsvp">
168
+ Cancel RSVP
169
+ </button>
170
+ </div>
171
+ </template>
172
+
173
+ <NuxtLink v-if="canEdit" :to="`/events/${event.slug}/edit`" class="cpub-btn cpub-btn-sm cpub-btn-block" style="margin-top: 8px; text-align: center;">
174
+ <i class="fa-solid fa-pencil"></i> Edit Event
175
+ </NuxtLink>
176
+ </div>
177
+
178
+ <div v-if="attendees?.items?.length" class="cpub-event-attendees-preview">
179
+ <h3 class="cpub-event-sidebar-title">Attendees</h3>
180
+ <div class="cpub-event-attendees-list">
181
+ <NuxtLink
182
+ v-for="att in attendees.items.slice(0, 8)"
183
+ :key="att.id"
184
+ :to="`/u/${att.userUsername}`"
185
+ class="cpub-event-attendee"
186
+ :title="att.userName"
187
+ >
188
+ <span class="cpub-event-avatar cpub-event-avatar-sm">
189
+ <img v-if="att.userAvatar" :src="att.userAvatar" :alt="att.userName" />
190
+ <span v-else>{{ att.userName.charAt(0) }}</span>
191
+ </span>
192
+ </NuxtLink>
193
+ <span v-if="attendees.total > 8" class="cpub-event-attendee-more">+{{ attendees.total - 8 }}</span>
194
+ </div>
195
+ </div>
196
+ </aside>
197
+ </div>
198
+ </div>
199
+ </template>
200
+
201
+ <style scoped>
202
+ .cpub-event-detail { max-width: 960px; margin: 0 auto; padding: 32px; }
203
+ .cpub-event-hero { height: 240px; overflow: hidden; border: var(--border-width-default) solid var(--border); margin-bottom: 24px; }
204
+ .cpub-event-hero img { width: 100%; height: 100%; object-fit: cover; }
205
+
206
+ .cpub-event-content { display: grid; grid-template-columns: 1fr 280px; gap: 32px; }
207
+ .cpub-event-main { min-width: 0; }
208
+
209
+ .cpub-event-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
210
+ .cpub-event-title { font-size: 24px; font-weight: 700; margin: 0 0 20px; }
211
+
212
+ .cpub-event-info-grid { display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; }
213
+ .cpub-event-info-item { display: flex; gap: 10px; align-items: flex-start; font-size: 13px; }
214
+ .cpub-event-info-item > i { width: 16px; text-align: center; margin-top: 3px; color: var(--accent); }
215
+ .cpub-event-info-sub { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
216
+ .cpub-event-info-sub a { color: var(--accent); text-decoration: none; }
217
+
218
+ .cpub-event-description { font-size: 14px; line-height: 1.7; color: var(--text-dim); white-space: pre-wrap; margin-bottom: 24px; }
219
+
220
+ .cpub-event-organizer { padding-top: 16px; border-top: var(--border-width-default) solid var(--border2); }
221
+ .cpub-event-organizer-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-faint); display: block; margin-bottom: 8px; }
222
+ .cpub-event-organizer-link { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text); font-size: 13px; font-weight: 600; }
223
+
224
+ .cpub-event-avatar { width: 28px; height: 28px; border-radius: 50%; background: var(--surface2); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; overflow: hidden; flex-shrink: 0; }
225
+ .cpub-event-avatar img { width: 100%; height: 100%; object-fit: cover; }
226
+ .cpub-event-avatar-sm { width: 24px; height: 24px; font-size: 9px; }
227
+
228
+ .cpub-event-sidebar { display: flex; flex-direction: column; gap: 16px; }
229
+ .cpub-event-rsvp-card { padding: 16px; border: var(--border-width-default) solid var(--border); background: var(--surface); display: flex; flex-direction: column; gap: 12px; }
230
+ .cpub-event-rsvp-count { display: flex; align-items: baseline; gap: 6px; }
231
+ .cpub-event-rsvp-num { font-size: 24px; font-weight: 700; font-family: var(--font-mono); }
232
+ .cpub-event-rsvp-label { font-size: 12px; color: var(--text-dim); }
233
+ .cpub-event-rsvp-spots { font-family: var(--font-mono); font-size: 11px; color: var(--text-faint); }
234
+ .cpub-event-rsvp-status { display: flex; flex-direction: column; gap: 8px; }
235
+ .cpub-btn-block { width: 100%; justify-content: center; }
236
+
237
+ .cpub-event-attendees-preview { padding: 16px; border: var(--border-width-default) solid var(--border); background: var(--surface); }
238
+ .cpub-event-sidebar-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; }
239
+ .cpub-event-attendees-list { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
240
+ .cpub-event-attendee { text-decoration: none; }
241
+ .cpub-event-attendee-more { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: 4px; }
242
+
243
+ @media (max-width: 768px) {
244
+ .cpub-event-detail { padding: 16px; }
245
+ .cpub-event-content { grid-template-columns: 1fr; gap: 20px; }
246
+ .cpub-event-hero { height: 180px; }
247
+ .cpub-event-title { font-size: 20px; }
248
+ }
249
+ </style>
@@ -0,0 +1,140 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ middleware: 'auth' });
3
+ useSeoMeta({ title: `Create Event — ${useSiteName()}` });
4
+
5
+ const toast = useToast();
6
+ const router = useRouter();
7
+ const saving = ref(false);
8
+
9
+ const form = reactive({
10
+ title: '',
11
+ description: '',
12
+ eventType: 'in-person' as 'in-person' | 'online' | 'hybrid',
13
+ startDate: '',
14
+ endDate: '',
15
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
16
+ location: '',
17
+ locationUrl: '',
18
+ onlineUrl: '',
19
+ capacity: undefined as number | undefined,
20
+ });
21
+
22
+ async function submit(): Promise<void> {
23
+ if (!form.title || !form.startDate || !form.endDate) {
24
+ toast.error('Title, start date, and end date are required');
25
+ return;
26
+ }
27
+
28
+ saving.value = true;
29
+ try {
30
+ const body: Record<string, unknown> = {
31
+ title: form.title,
32
+ startDate: new Date(form.startDate).toISOString(),
33
+ endDate: new Date(form.endDate).toISOString(),
34
+ eventType: form.eventType,
35
+ timezone: form.timezone,
36
+ };
37
+ if (form.description) body.description = form.description;
38
+ if (form.location) body.location = form.location;
39
+ if (form.locationUrl) body.locationUrl = form.locationUrl;
40
+ if (form.onlineUrl) body.onlineUrl = form.onlineUrl;
41
+ if (form.capacity) body.capacity = form.capacity;
42
+
43
+ const result = await $fetch<{ slug: string }>('/api/events', {
44
+ method: 'POST',
45
+ body,
46
+ });
47
+ toast.success('Event created');
48
+ router.push(`/events/${result.slug}`);
49
+ } catch {
50
+ toast.error('Failed to create event');
51
+ } finally {
52
+ saving.value = false;
53
+ }
54
+ }
55
+ </script>
56
+
57
+ <template>
58
+ <div class="cpub-create-event">
59
+ <SectionHeader title="Create Event" large />
60
+
61
+ <form class="cpub-form" @submit.prevent="submit">
62
+ <div class="cpub-form-field">
63
+ <label class="cpub-form-label">Title *</label>
64
+ <input v-model="form.title" class="cpub-form-input" placeholder="Event title" required />
65
+ </div>
66
+
67
+ <div class="cpub-form-field">
68
+ <label class="cpub-form-label">Description</label>
69
+ <textarea v-model="form.description" class="cpub-form-textarea" rows="4" placeholder="Describe the event..." />
70
+ </div>
71
+
72
+ <div class="cpub-form-row">
73
+ <div class="cpub-form-field">
74
+ <label class="cpub-form-label">Type</label>
75
+ <select v-model="form.eventType" class="cpub-form-input">
76
+ <option value="in-person">In-Person</option>
77
+ <option value="online">Online</option>
78
+ <option value="hybrid">Hybrid</option>
79
+ </select>
80
+ </div>
81
+ <div class="cpub-form-field">
82
+ <label class="cpub-form-label">Capacity</label>
83
+ <input v-model.number="form.capacity" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" />
84
+ </div>
85
+ </div>
86
+
87
+ <div class="cpub-form-row">
88
+ <div class="cpub-form-field">
89
+ <label class="cpub-form-label">Start Date *</label>
90
+ <input v-model="form.startDate" type="datetime-local" class="cpub-form-input" required />
91
+ </div>
92
+ <div class="cpub-form-field">
93
+ <label class="cpub-form-label">End Date *</label>
94
+ <input v-model="form.endDate" type="datetime-local" class="cpub-form-input" required />
95
+ </div>
96
+ </div>
97
+
98
+ <div v-if="form.eventType !== 'online'" class="cpub-form-row">
99
+ <div class="cpub-form-field">
100
+ <label class="cpub-form-label">Location</label>
101
+ <input v-model="form.location" class="cpub-form-input" placeholder="Venue name / address" />
102
+ </div>
103
+ <div class="cpub-form-field">
104
+ <label class="cpub-form-label">Location URL</label>
105
+ <input v-model="form.locationUrl" type="url" class="cpub-form-input" placeholder="Map link" />
106
+ </div>
107
+ </div>
108
+
109
+ <div v-if="form.eventType !== 'in-person'" class="cpub-form-field">
110
+ <label class="cpub-form-label">Online URL</label>
111
+ <input v-model="form.onlineUrl" type="url" class="cpub-form-input" placeholder="Meeting link" />
112
+ </div>
113
+
114
+ <div class="cpub-form-actions">
115
+ <NuxtLink to="/events" class="cpub-btn cpub-btn-sm">Cancel</NuxtLink>
116
+ <button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="saving">
117
+ <i :class="saving ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-check'"></i>
118
+ Create Event
119
+ </button>
120
+ </div>
121
+ </form>
122
+ </div>
123
+ </template>
124
+
125
+ <style scoped>
126
+ .cpub-create-event { max-width: 640px; margin: 0 auto; padding: 32px; }
127
+ .cpub-form { display: flex; flex-direction: column; gap: var(--space-4); margin-top: 24px; }
128
+ .cpub-form-field { display: flex; flex-direction: column; gap: 4px; }
129
+ .cpub-form-label { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
130
+ .cpub-form-input { font-size: 13px; padding: 8px 12px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); outline: none; }
131
+ .cpub-form-input:focus { border-color: var(--accent); }
132
+ .cpub-form-textarea { font-size: 13px; padding: 8px 12px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); outline: none; resize: vertical; font-family: inherit; }
133
+ .cpub-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
134
+ .cpub-form-actions { display: flex; gap: var(--space-3); justify-content: flex-end; padding-top: var(--space-4); border-top: var(--border-width-default) solid var(--border2); }
135
+
136
+ @media (max-width: 768px) {
137
+ .cpub-create-event { padding: 16px; }
138
+ .cpub-form-row { grid-template-columns: 1fr; }
139
+ }
140
+ </style>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import type { EventListItem } from '@commonpub/server';
3
+
4
+ useSeoMeta({ title: `Events — ${useSiteName()}` });
5
+
6
+ const { isAuthenticated } = useAuth();
7
+ const { data } = await useFetch<{ items: EventListItem[]; total: number }>('/api/events');
8
+ </script>
9
+
10
+ <template>
11
+ <div class="cpub-events-page">
12
+ <div class="cpub-events-header">
13
+ <SectionHeader title="Events" large />
14
+ <NuxtLink v-if="isAuthenticated" to="/events/create" class="cpub-btn-create">
15
+ <i class="fa-solid fa-plus"></i> Create Event
16
+ </NuxtLink>
17
+ </div>
18
+
19
+ <div v-if="data?.items?.length" class="cpub-events-grid">
20
+ <EventCard v-for="event in data.items" :key="event.id" :event="event" />
21
+ </div>
22
+ <div v-else class="cpub-empty-state">
23
+ <div class="cpub-empty-state-icon"><i class="fa-solid fa-calendar-days"></i></div>
24
+ <p class="cpub-empty-state-title">No events yet</p>
25
+ <p class="cpub-empty-state-desc">Check back soon for upcoming events.</p>
26
+ </div>
27
+ </div>
28
+ </template>
29
+
30
+ <style scoped>
31
+ .cpub-events-page { max-width: 960px; margin: 0 auto; padding: 32px; }
32
+ .cpub-events-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
33
+ .cpub-btn-create {
34
+ font-size: 12px; padding: 6px 14px; background: var(--accent); color: var(--color-text-inverse);
35
+ border: var(--border-width-default) solid var(--border); text-decoration: none;
36
+ display: inline-flex; align-items: center; gap: 6px; box-shadow: var(--shadow-sm);
37
+ transition: all 0.15s;
38
+ }
39
+ .cpub-btn-create:hover { box-shadow: var(--shadow-md); transform: translate(-1px, -1px); }
40
+
41
+ .cpub-events-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
42
+
43
+ @media (max-width: 768px) {
44
+ .cpub-events-page { padding: 16px; }
45
+ .cpub-events-grid { grid-template-columns: 1fr; }
46
+ }
47
+ </style>
@@ -74,6 +74,7 @@ const postsVM = computed<HubPostViewModel[]>(() => {
74
74
  },
75
75
  createdAt: String(p.publishedAt ?? p.receivedAt),
76
76
  likeCount: (p.localLikeCount ?? 0) + (p.remoteLikeCount ?? 0),
77
+ voteScore: 0,
77
78
  replyCount: (p.localReplyCount ?? 0) + (p.remoteReplyCount ?? 0),
78
79
  isPinned: p.isPinned ?? false,
79
80
  isLocked: false,
@@ -72,6 +72,7 @@ const postsVM = computed<HubPostViewModel[]>(() => {
72
72
  },
73
73
  createdAt: p.createdAt,
74
74
  likeCount: p.likeCount ?? 0,
75
+ voteScore: p.voteScore ?? 0,
75
76
  replyCount: p.replyCount ?? 0,
76
77
  isPinned: p.isPinned ?? false,
77
78
  isLocked: p.isLocked ?? false,
@@ -0,0 +1,11 @@
1
+ import { getNavItems } from '@commonpub/server';
2
+
3
+ /**
4
+ * GET /api/admin/navigation/items
5
+ * Returns navigation items for admin editing.
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireAdmin(event);
9
+ const db = useDB();
10
+ return getNavItems(db);
11
+ });
@@ -0,0 +1,51 @@
1
+ import type { NavItem } from '@commonpub/server';
2
+ import { setNavItems } from '@commonpub/server';
3
+ import { z } from 'zod';
4
+
5
+ const navItemSchema: z.ZodType<NavItem> = z.lazy(() =>
6
+ z.object({
7
+ id: z.string().min(1).max(64),
8
+ type: z.enum(['link', 'dropdown', 'external']),
9
+ label: z.string().min(1).max(128),
10
+ icon: z.string().max(128).optional(),
11
+ route: z.string().max(255).optional(),
12
+ href: z.string().url().max(1024).optional(),
13
+ featureGate: z.string().max(64).optional(),
14
+ children: z.array(navItemSchema).max(20).optional(),
15
+ visibleTo: z.enum(['all', 'authenticated', 'admin']).optional(),
16
+ disabled: z.boolean().optional(),
17
+ }),
18
+ ) as z.ZodType<NavItem>;
19
+
20
+ const updateNavSchema = z.object({
21
+ items: z.array(navItemSchema).min(1).max(30),
22
+ });
23
+
24
+ /**
25
+ * PUT /api/admin/navigation/items
26
+ * Save navigation item configuration.
27
+ */
28
+ export default defineEventHandler(async (event) => {
29
+ const user = requireAdmin(event);
30
+ const db = useDB();
31
+ const body = await parseBody(event, updateNavSchema);
32
+
33
+ // Validate unique IDs (flatten to check children too)
34
+ const ids = new Set<string>();
35
+ function collectIds(items: NavItem[]): void {
36
+ for (const item of items) {
37
+ if (ids.has(item.id)) {
38
+ throw createError({ statusCode: 400, statusMessage: `Duplicate nav item ID: ${item.id}` });
39
+ }
40
+ ids.add(item.id);
41
+ if (item.children) {
42
+ collectIds(item.children);
43
+ }
44
+ }
45
+ }
46
+ collectIds(body.items);
47
+
48
+ await setNavItems(db, body.items, user.id, getRequestIP(event) ?? undefined);
49
+
50
+ return { items: body.items, message: 'Navigation updated' };
51
+ });
@@ -0,0 +1,20 @@
1
+ import { removeContestEntryVote } from '@commonpub/server';
2
+
3
+ /**
4
+ * DELETE /api/contests/:slug/entries/:entryId/vote
5
+ * Remove community vote from a contest entry.
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireFeature('contests');
9
+ const user = requireAuth(event);
10
+ const db = useDB();
11
+ const entryId = getRouterParam(event, 'entryId');
12
+ if (!entryId) throw createError({ statusCode: 400, statusMessage: 'Missing entryId' });
13
+
14
+ const removed = await removeContestEntryVote(db, entryId, user.id);
15
+ if (!removed) {
16
+ throw createError({ statusCode: 400, statusMessage: 'No vote found' });
17
+ }
18
+
19
+ return { removed: true };
20
+ });
@@ -0,0 +1,20 @@
1
+ import { voteOnContestEntry } from '@commonpub/server';
2
+
3
+ /**
4
+ * POST /api/contests/:slug/entries/:entryId/vote
5
+ * Vote on a contest entry (community voting).
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireFeature('contests');
9
+ const user = requireAuth(event);
10
+ const db = useDB();
11
+ const entryId = getRouterParam(event, 'entryId');
12
+ if (!entryId) throw createError({ statusCode: 400, statusMessage: 'Missing entryId' });
13
+
14
+ const result = await voteOnContestEntry(db, entryId, user.id);
15
+ if (!result.voted) {
16
+ throw createError({ statusCode: 400, statusMessage: result.error ?? 'Vote failed' });
17
+ }
18
+
19
+ return { voted: true };
20
+ });
@@ -1,18 +1,16 @@
1
- import { judgeContestEntry, getContestBySlug } from '@commonpub/server';
1
+ import { judgeContestEntry } from '@commonpub/server';
2
2
  import { judgeEntrySchema } from '@commonpub/schema';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
5
5
  requireFeature('contests');
6
6
  const user = requireAuth(event);
7
7
  const db = useDB();
8
- const { slug } = parseParams(event, { slug: 'string' });
9
8
  const input = await parseBody(event, judgeEntrySchema);
10
9
 
11
- const contest = await getContestBySlug(db, slug);
12
- if (!contest) throw createError({ statusCode: 404, message: 'Contest not found' });
13
- const judges = (contest.judges ?? []) as string[];
14
- if (!judges.includes(user.id)) throw createError({ statusCode: 403, message: 'Not a judge for this contest' });
10
+ const result = await judgeContestEntry(db, input.entryId, input.score, user.id, input.feedback);
11
+ if (!result.judged) {
12
+ throw createError({ statusCode: 403, statusMessage: result.error ?? 'Judging failed' });
13
+ }
15
14
 
16
- await judgeContestEntry(db, input.entryId, input.score, user.id, input.feedback);
17
15
  return { success: true };
18
16
  });
@@ -0,0 +1,26 @@
1
+ import { getContestBySlug, removeContestJudge } from '@commonpub/server';
2
+
3
+ /**
4
+ * DELETE /api/contests/:slug/judges/:userId
5
+ * Remove a judge from a contest (contest owner or admin only).
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireFeature('contests');
9
+ const user = requireAuth(event);
10
+ const db = useDB();
11
+ const slug = getRouterParam(event, 'slug');
12
+ const targetUserId = getRouterParam(event, 'userId');
13
+ if (!slug || !targetUserId) throw createError({ statusCode: 400, statusMessage: 'Missing parameters' });
14
+
15
+ const contest = await getContestBySlug(db, slug);
16
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
17
+
18
+ if (contest.createdById !== user.id && user.role !== 'admin') {
19
+ throw createError({ statusCode: 403, statusMessage: 'Only contest owner or admin can manage judges' });
20
+ }
21
+
22
+ const removed = await removeContestJudge(db, contest.id, targetUserId);
23
+ if (!removed) throw createError({ statusCode: 404, statusMessage: 'Judge not found' });
24
+
25
+ return { removed: true };
26
+ });
@@ -0,0 +1,21 @@
1
+ import { getContestBySlug, acceptJudgeInvite } from '@commonpub/server';
2
+
3
+ /**
4
+ * POST /api/contests/:slug/judges/accept
5
+ * Accept a judge invitation (authenticated user).
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireFeature('contests');
9
+ const user = requireAuth(event);
10
+ const db = useDB();
11
+ const slug = getRouterParam(event, 'slug');
12
+ if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
13
+
14
+ const contest = await getContestBySlug(db, slug);
15
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
16
+
17
+ const accepted = await acceptJudgeInvite(db, contest.id, user.id);
18
+ if (!accepted) throw createError({ statusCode: 400, statusMessage: 'No pending invitation found' });
19
+
20
+ return { accepted: true };
21
+ });
@@ -0,0 +1,17 @@
1
+ import { getContestBySlug, listContestJudges } from '@commonpub/server';
2
+
3
+ /**
4
+ * GET /api/contests/:slug/judges
5
+ * List judges for a contest.
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireFeature('contests');
9
+ const db = useDB();
10
+ const slug = getRouterParam(event, 'slug');
11
+ if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
12
+
13
+ const contest = await getContestBySlug(db, slug);
14
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
+
16
+ return listContestJudges(db, contest.id);
17
+ });