@commonpub/layer 0.15.1 → 0.15.3

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.
@@ -12,7 +12,8 @@ interface Comment {
12
12
  createdAt: string;
13
13
  parentId: string | null;
14
14
  author: CommentAuthor | null;
15
- replies?: Comment[];
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ replies?: any[];
16
17
  }
17
18
 
18
19
  const props = defineProps<{
@@ -38,6 +39,8 @@ const queryParams = computed(() =>
38
39
  isFederated.value ? undefined : { targetType: props.targetType, targetId: props.targetId, limit: commentLimit },
39
40
  );
40
41
 
42
+ // @ts-ignore TS2589: useFetch<Comment[]> with this query shape hits deep
43
+ // type instantiation in some consumer apps (notably shell). Runtime is fine.
41
44
  const { data: comments, refresh } = await useFetch<Comment[]>(commentUrl, { query: queryParams, lazy: true });
42
45
 
43
46
  const allCommentsLoaded = ref(false);
@@ -0,0 +1,301 @@
1
+ <script setup lang="ts">
2
+ import type { EventListItem } from '@commonpub/server';
3
+
4
+ const props = defineProps<{
5
+ events: EventListItem[];
6
+ }>();
7
+
8
+ const today = new Date();
9
+ const currentMonth = ref(today.getMonth());
10
+ const currentYear = ref(today.getFullYear());
11
+
12
+ const monthName = computed(() =>
13
+ new Date(currentYear.value, currentMonth.value).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }),
14
+ );
15
+
16
+ const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
17
+
18
+ interface CalendarDay {
19
+ date: number;
20
+ isCurrentMonth: boolean;
21
+ isToday: boolean;
22
+ events: EventListItem[];
23
+ key: string;
24
+ }
25
+
26
+ const calendarDays = computed<CalendarDay[]>(() => {
27
+ const year = currentYear.value;
28
+ const month = currentMonth.value;
29
+ const firstDay = new Date(year, month, 1).getDay();
30
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
31
+ const daysInPrevMonth = new Date(year, month, 0).getDate();
32
+
33
+ // Build event lookup: dateString → events[]
34
+ const eventMap = new Map<string, EventListItem[]>();
35
+ for (const ev of props.events) {
36
+ const start = new Date(ev.startDate);
37
+ const end = new Date(ev.endDate);
38
+ // Mark each day the event spans
39
+ const d = new Date(start);
40
+ d.setHours(0, 0, 0, 0);
41
+ const endDay = new Date(end);
42
+ endDay.setHours(23, 59, 59, 999);
43
+ let safetyCount = 0;
44
+ while (d <= endDay && safetyCount++ < 366) {
45
+ const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
46
+ const list = eventMap.get(key) ?? [];
47
+ list.push(ev);
48
+ eventMap.set(key, list);
49
+ d.setDate(d.getDate() + 1);
50
+ }
51
+ }
52
+
53
+ const days: CalendarDay[] = [];
54
+
55
+ // Previous month padding
56
+ for (let i = firstDay - 1; i >= 0; i--) {
57
+ const date = daysInPrevMonth - i;
58
+ const m = month === 0 ? 11 : month - 1;
59
+ const y = month === 0 ? year - 1 : year;
60
+ const key = `${y}-${m}-${date}`;
61
+ days.push({ date, isCurrentMonth: false, isToday: false, events: eventMap.get(key) ?? [], key: `p-${date}` });
62
+ }
63
+
64
+ // Current month
65
+ for (let date = 1; date <= daysInMonth; date++) {
66
+ const isToday = date === today.getDate() && month === today.getMonth() && year === today.getFullYear();
67
+ const key = `${year}-${month}-${date}`;
68
+ days.push({ date, isCurrentMonth: true, isToday, events: eventMap.get(key) ?? [], key: `c-${date}` });
69
+ }
70
+
71
+ // Next month padding (fill to complete 6 rows)
72
+ const remaining = 42 - days.length;
73
+ for (let date = 1; date <= remaining; date++) {
74
+ const m = month === 11 ? 0 : month + 1;
75
+ const y = month === 11 ? year + 1 : year;
76
+ const key = `${y}-${m}-${date}`;
77
+ days.push({ date, isCurrentMonth: false, isToday: false, events: eventMap.get(key) ?? [], key: `n-${date}` });
78
+ }
79
+
80
+ return days;
81
+ });
82
+
83
+ function prevMonth(): void {
84
+ if (currentMonth.value === 0) {
85
+ currentMonth.value = 11;
86
+ currentYear.value--;
87
+ } else {
88
+ currentMonth.value--;
89
+ }
90
+ }
91
+
92
+ function nextMonth(): void {
93
+ if (currentMonth.value === 11) {
94
+ currentMonth.value = 0;
95
+ currentYear.value++;
96
+ } else {
97
+ currentMonth.value++;
98
+ }
99
+ }
100
+
101
+ function goToday(): void {
102
+ currentMonth.value = today.getMonth();
103
+ currentYear.value = today.getFullYear();
104
+ }
105
+
106
+ const typeIcon: Record<string, string> = {
107
+ 'in-person': 'fa-solid fa-location-dot',
108
+ 'online': 'fa-solid fa-video',
109
+ 'hybrid': 'fa-solid fa-arrows-split-up-and-left',
110
+ };
111
+ </script>
112
+
113
+ <template>
114
+ <div class="cpub-calendar">
115
+ <div class="cpub-cal-header">
116
+ <button class="cpub-cal-nav" aria-label="Previous month" @click="prevMonth">
117
+ <i class="fa-solid fa-chevron-left"></i>
118
+ </button>
119
+ <button class="cpub-cal-title" @click="goToday">{{ monthName }}</button>
120
+ <button class="cpub-cal-nav" aria-label="Next month" @click="nextMonth">
121
+ <i class="fa-solid fa-chevron-right"></i>
122
+ </button>
123
+ </div>
124
+
125
+ <div class="cpub-cal-weekdays">
126
+ <div v-for="day in WEEKDAYS" :key="day" class="cpub-cal-weekday">{{ day }}</div>
127
+ </div>
128
+
129
+ <div class="cpub-cal-grid">
130
+ <div
131
+ v-for="day in calendarDays"
132
+ :key="day.key"
133
+ class="cpub-cal-day"
134
+ :class="{
135
+ 'cpub-cal-other': !day.isCurrentMonth,
136
+ 'cpub-cal-today': day.isToday,
137
+ 'cpub-cal-has-events': day.events.length > 0,
138
+ }"
139
+ >
140
+ <span class="cpub-cal-date">{{ day.date }}</span>
141
+ <div v-if="day.events.length > 0" class="cpub-cal-events">
142
+ <NuxtLink
143
+ v-for="ev in day.events.slice(0, 2)"
144
+ :key="ev.id"
145
+ :to="`/events/${ev.slug}`"
146
+ class="cpub-cal-event"
147
+ :title="ev.title"
148
+ >
149
+ <i :class="typeIcon[ev.eventType] ?? 'fa-solid fa-calendar'" class="cpub-cal-event-icon"></i>
150
+ <span class="cpub-cal-event-title">{{ ev.title }}</span>
151
+ </NuxtLink>
152
+ <span v-if="day.events.length > 2" class="cpub-cal-more">
153
+ +{{ day.events.length - 2 }} more
154
+ </span>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ </template>
160
+
161
+ <style scoped>
162
+ .cpub-calendar {
163
+ border: var(--border-width-default) solid var(--border);
164
+ background: var(--surface);
165
+ box-shadow: var(--shadow-md);
166
+ }
167
+
168
+ .cpub-cal-header {
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: space-between;
172
+ padding: 12px 16px;
173
+ border-bottom: var(--border-width-default) solid var(--border);
174
+ }
175
+
176
+ .cpub-cal-nav {
177
+ background: none;
178
+ border: var(--border-width-default) solid var(--border2);
179
+ color: var(--text-dim);
180
+ width: 28px;
181
+ height: 28px;
182
+ display: flex;
183
+ align-items: center;
184
+ justify-content: center;
185
+ cursor: pointer;
186
+ font-size: 11px;
187
+ transition: all 0.15s;
188
+ }
189
+ .cpub-cal-nav:hover { border-color: var(--border); color: var(--text); }
190
+
191
+ .cpub-cal-title {
192
+ font-family: var(--font-mono);
193
+ font-size: 13px;
194
+ font-weight: 700;
195
+ text-transform: uppercase;
196
+ letter-spacing: 0.04em;
197
+ color: var(--text);
198
+ background: none;
199
+ border: none;
200
+ cursor: pointer;
201
+ }
202
+ .cpub-cal-title:hover { color: var(--accent); }
203
+
204
+ .cpub-cal-weekdays {
205
+ display: grid;
206
+ grid-template-columns: repeat(7, 1fr);
207
+ border-bottom: var(--border-width-default) solid var(--border2);
208
+ }
209
+
210
+ .cpub-cal-weekday {
211
+ padding: 6px 0;
212
+ text-align: center;
213
+ font-family: var(--font-mono);
214
+ font-size: 9px;
215
+ font-weight: 600;
216
+ text-transform: uppercase;
217
+ letter-spacing: 0.06em;
218
+ color: var(--text-faint);
219
+ }
220
+
221
+ .cpub-cal-grid {
222
+ display: grid;
223
+ grid-template-columns: repeat(7, 1fr);
224
+ }
225
+
226
+ .cpub-cal-day {
227
+ min-height: 80px;
228
+ padding: 4px 6px;
229
+ border-right: var(--border-width-default) solid var(--border2);
230
+ border-bottom: var(--border-width-default) solid var(--border2);
231
+ position: relative;
232
+ }
233
+ .cpub-cal-day:nth-child(7n) { border-right: none; }
234
+
235
+ .cpub-cal-other { background: var(--surface2); }
236
+ .cpub-cal-other .cpub-cal-date { color: var(--text-faint); }
237
+
238
+ .cpub-cal-today { background: var(--accent-bg); }
239
+ .cpub-cal-today .cpub-cal-date {
240
+ color: var(--accent);
241
+ font-weight: 700;
242
+ }
243
+
244
+ .cpub-cal-date {
245
+ font-family: var(--font-mono);
246
+ font-size: 11px;
247
+ font-weight: 500;
248
+ color: var(--text-dim);
249
+ display: block;
250
+ margin-bottom: 2px;
251
+ }
252
+
253
+ .cpub-cal-events {
254
+ display: flex;
255
+ flex-direction: column;
256
+ gap: 2px;
257
+ }
258
+
259
+ .cpub-cal-event {
260
+ display: flex;
261
+ align-items: center;
262
+ gap: 3px;
263
+ padding: 1px 4px;
264
+ background: var(--accent-bg);
265
+ border-left: 2px solid var(--accent);
266
+ text-decoration: none;
267
+ overflow: hidden;
268
+ transition: background 0.12s;
269
+ }
270
+ .cpub-cal-event:hover { background: var(--surface3); }
271
+
272
+ .cpub-cal-event-icon {
273
+ font-size: 8px;
274
+ color: var(--accent);
275
+ flex-shrink: 0;
276
+ }
277
+
278
+ .cpub-cal-event-title {
279
+ font-size: 10px;
280
+ color: var(--text);
281
+ white-space: nowrap;
282
+ overflow: hidden;
283
+ text-overflow: ellipsis;
284
+ line-height: 1.4;
285
+ }
286
+
287
+ .cpub-cal-more {
288
+ font-size: 9px;
289
+ font-family: var(--font-mono);
290
+ color: var(--text-faint);
291
+ padding-left: 4px;
292
+ }
293
+
294
+ @media (max-width: 768px) {
295
+ .cpub-cal-day { min-height: 50px; padding: 2px 3px; }
296
+ .cpub-cal-event-title { display: none; }
297
+ .cpub-cal-event { padding: 2px; justify-content: center; border-left: none; }
298
+ .cpub-cal-event-icon { font-size: 10px; }
299
+ .cpub-cal-more { display: none; }
300
+ }
301
+ </style>
@@ -1,16 +1,78 @@
1
1
  <script setup lang="ts">
2
- import type { Serialized, ContestEntryItem } from '@commonpub/server';
2
+ import type { Serialized, ContestEntryItem, ContestEntryVoteInfo } from '@commonpub/server';
3
3
 
4
4
  const props = defineProps<{
5
5
  entries: Serialized<ContestEntryItem>[];
6
6
  contestStatus?: string;
7
+ contestSlug?: string;
7
8
  currentUserId?: string;
9
+ communityVotingEnabled?: boolean;
8
10
  }>();
9
11
 
10
12
  const emit = defineEmits<{
11
13
  (e: 'withdraw', entryId: string): void;
12
14
  }>();
13
15
 
16
+ const { isAuthenticated } = useAuth();
17
+ const toast = useToast();
18
+
19
+ // Vote state: entryId → { count, voted }
20
+ const voteMap = ref<Map<string, { count: number; voted: boolean }>>(new Map());
21
+ const votingEntry = ref<string | null>(null);
22
+
23
+ // Fetch vote data — always set up the fetch (server returns [] when voting is disabled).
24
+ // Cannot conditionally call useLazyFetch because props from useLazyFetch parent
25
+ // may still be undefined during setup.
26
+ if (props.contestSlug) {
27
+ const { data: voteData } = useLazyFetch<ContestEntryVoteInfo[]>(
28
+ `/api/contests/${props.contestSlug}/votes`,
29
+ );
30
+ watch(voteData, (data) => {
31
+ if (!data) return;
32
+ const map = new Map<string, { count: number; voted: boolean }>();
33
+ for (const v of data) {
34
+ map.set(v.entryId, { count: v.count, voted: v.voted });
35
+ }
36
+ voteMap.value = map;
37
+ }, { immediate: true });
38
+ }
39
+
40
+ function getVoteCount(entryId: string): number {
41
+ return voteMap.value.get(entryId)?.count ?? 0;
42
+ }
43
+
44
+ function hasVoted(entryId: string): boolean {
45
+ return voteMap.value.get(entryId)?.voted ?? false;
46
+ }
47
+
48
+ async function toggleVote(entryId: string): Promise<void> {
49
+ if (!isAuthenticated.value || !props.contestSlug || votingEntry.value) return;
50
+ votingEntry.value = entryId;
51
+
52
+ const currentlyVoted = hasVoted(entryId);
53
+ const currentCount = getVoteCount(entryId);
54
+
55
+ // Optimistic update
56
+ voteMap.value.set(entryId, {
57
+ count: currentlyVoted ? currentCount - 1 : currentCount + 1,
58
+ voted: !currentlyVoted,
59
+ });
60
+
61
+ try {
62
+ if (currentlyVoted) {
63
+ await $fetch(`/api/contests/${props.contestSlug}/entries/${entryId}/vote`, { method: 'DELETE' });
64
+ } else {
65
+ await $fetch(`/api/contests/${props.contestSlug}/entries/${entryId}/vote`, { method: 'POST' });
66
+ }
67
+ } catch {
68
+ // Revert optimistic update
69
+ voteMap.value.set(entryId, { count: currentCount, voted: currentlyVoted });
70
+ toast.error(currentlyVoted ? 'Failed to remove vote' : 'Failed to vote');
71
+ } finally {
72
+ votingEntry.value = null;
73
+ }
74
+ }
75
+
14
76
  function confirmWithdraw(entryId: string): void {
15
77
  if (confirm('Withdraw this entry? This cannot be undone.')) {
16
78
  emit('withdraw', entryId);
@@ -50,6 +112,18 @@ function confirmWithdraw(entryId: string): void {
50
112
  </div>
51
113
  <div class="cpub-entry-footer">
52
114
  <span v-if="entry.score != null" class="cpub-entry-score">Score: {{ entry.score }}</span>
115
+ <button
116
+ v-if="communityVotingEnabled"
117
+ class="cpub-entry-vote-btn"
118
+ :class="{ voted: hasVoted(entry.id) }"
119
+ :disabled="!isAuthenticated || votingEntry === entry.id"
120
+ :aria-pressed="hasVoted(entry.id)"
121
+ :aria-label="hasVoted(entry.id) ? 'Remove vote' : 'Vote for this entry'"
122
+ @click.prevent="toggleVote(entry.id)"
123
+ >
124
+ <i :class="hasVoted(entry.id) ? 'fa-solid fa-heart' : 'fa-regular fa-heart'"></i>
125
+ <span>{{ getVoteCount(entry.id) }}</span>
126
+ </button>
53
127
  <button
54
128
  v-if="currentUserId && entry.userId === currentUserId && contestStatus === 'active'"
55
129
  class="cpub-withdraw-btn"
@@ -100,6 +174,19 @@ function confirmWithdraw(entryId: string): void {
100
174
  .cpub-entry-meta { color: var(--text-faint); }
101
175
  .cpub-entry-footer { display: flex; align-items: center; gap: 6px; }
102
176
  .cpub-entry-score { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); display: flex; align-items: center; gap: 3px; }
177
+
178
+ .cpub-entry-vote-btn {
179
+ display: inline-flex; align-items: center; gap: 4px;
180
+ font-size: 11px; font-family: var(--font-mono); font-weight: 600;
181
+ padding: 3px 8px; border: var(--border-width-default) solid var(--border2);
182
+ background: var(--surface); color: var(--text-dim); cursor: pointer;
183
+ transition: all 0.15s;
184
+ }
185
+ .cpub-entry-vote-btn:hover:not(:disabled) { border-color: var(--red); color: var(--red); }
186
+ .cpub-entry-vote-btn.voted { color: var(--red); border-color: var(--red); }
187
+ .cpub-entry-vote-btn:disabled { opacity: 0.4; cursor: default; }
188
+ .cpub-entry-vote-btn i { font-size: 10px; }
189
+
103
190
  .cpub-withdraw-btn { display: flex; align-items: center; gap: 4px; font-size: 10px; font-family: var(--font-mono); padding: 3px 8px; border-radius: var(--radius); border: var(--border-width-default) solid var(--red-border); background: var(--surface); color: var(--red); cursor: pointer; margin-left: auto; }
104
191
  .cpub-withdraw-btn:hover { background: var(--red-bg); }
105
192
 
package/error.vue CHANGED
@@ -9,6 +9,13 @@ const props = defineProps<{
9
9
 
10
10
  useSeoMeta({ title: `${props.error.statusCode} — CommonPub` });
11
11
 
12
+ // Error pages render outside app.vue's NuxtLayout tree during SSR,
13
+ // so the theme plugin's useHead may not propagate. Re-apply here.
14
+ const themeId = useState<string>('cpub-theme', () => 'base');
15
+ if (themeId.value && themeId.value !== 'base') {
16
+ useHead({ htmlAttrs: { 'data-theme': themeId.value } });
17
+ }
18
+
12
19
  const isNotFound = computed(() => props.error.statusCode === 404);
13
20
 
14
21
  function handleBack(): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -30,7 +30,7 @@
30
30
  "@aws-sdk/client-s3": "^3.1010.0",
31
31
  "@commonpub/explainer": "^0.7.11",
32
32
  "@commonpub/schema": "^0.13.0",
33
- "@commonpub/server": "^2.42.0",
33
+ "@commonpub/server": "^2.43.0",
34
34
  "@tiptap/core": "^2.11.0",
35
35
  "@tiptap/extension-bold": "^2.11.0",
36
36
  "@tiptap/extension-bullet-list": "^2.11.0",
@@ -53,13 +53,13 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/auth": "0.5.1",
57
56
  "@commonpub/docs": "0.6.2",
58
57
  "@commonpub/config": "0.10.0",
59
- "@commonpub/editor": "0.7.9",
60
- "@commonpub/learning": "0.5.0",
61
58
  "@commonpub/protocol": "0.9.9",
62
- "@commonpub/ui": "0.8.5"
59
+ "@commonpub/learning": "0.5.0",
60
+ "@commonpub/ui": "0.8.5",
61
+ "@commonpub/auth": "0.5.1",
62
+ "@commonpub/editor": "0.7.9"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -12,7 +12,22 @@ interface Category {
12
12
  icon: string | null;
13
13
  }
14
14
 
15
- const { data, refresh } = await useFetch('/api/content', {
15
+ interface AdminContentItem {
16
+ id: string;
17
+ source?: string;
18
+ title: string;
19
+ slug: string;
20
+ type: string;
21
+ status: string;
22
+ isEditorial?: boolean;
23
+ isFeatured?: boolean;
24
+ categoryId?: string | null;
25
+ viewCount?: number;
26
+ createdAt: string;
27
+ author?: { id: string; username: string; displayName: string | null } | null;
28
+ }
29
+
30
+ const { data, refresh } = await useFetch<{ items: AdminContentItem[] }>('/api/content', {
16
31
  query: { limit: 50, sort: 'recent' },
17
32
  });
18
33
  const { data: categories } = await useFetch<Category[]>('/api/categories');
@@ -22,7 +37,11 @@ const selectAll = ref(false);
22
37
 
23
38
  watch(selectAll, (val) => {
24
39
  if (val && data.value?.items) {
25
- selectedIds.value = new Set(data.value.items.filter(i => i.source !== 'federated').map(i => i.id));
40
+ selectedIds.value = new Set(
41
+ data.value.items
42
+ .filter((i: AdminContentItem) => i.source !== 'federated')
43
+ .map((i: AdminContentItem) => i.id),
44
+ );
26
45
  } else {
27
46
  selectedIds.value = new Set();
28
47
  }
@@ -144,7 +144,9 @@ async function withdrawEntry(entryId: string): Promise<void> {
144
144
  <ContestEntries
145
145
  :entries="entries"
146
146
  :contest-status="c?.status"
147
+ :contest-slug="slug"
147
148
  :current-user-id="user?.id"
149
+ :community-voting-enabled="c?.communityVotingEnabled"
148
150
  @withdraw="withdrawEntry"
149
151
  />
150
152
  </div>
@@ -15,8 +15,19 @@ const activeTab = ref<'content' | 'bookmarks' | 'learning'>('content');
15
15
  const contentSort = ref<'newest' | 'oldest' | 'popular'>('newest');
16
16
  const contentTypeFilter = ref('');
17
17
 
18
+ interface DashContentItem {
19
+ id: string;
20
+ type: string;
21
+ slug: string;
22
+ title: string;
23
+ status: string;
24
+ createdAt: string;
25
+ viewCount?: number;
26
+ likeCount?: number;
27
+ }
28
+
18
29
  // My content (all statuses)
19
- const { data: myContent, status: contentStatus } = await useFetch('/api/content', {
30
+ const { data: myContent, status: contentStatus } = await useFetch<{ items: DashContentItem[] }>('/api/content', {
20
31
  query: { authorId: user.value?.id },
21
32
  headers: reqHeaders,
22
33
  });
@@ -65,19 +76,27 @@ function sortItems<T extends { createdAt: string; viewCount?: number; likeCount?
65
76
  return sorted;
66
77
  }
67
78
 
68
- const drafts = computed(() =>
69
- sortItems(filterByType((myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'draft'))),
79
+ const drafts = computed<DashContentItem[]>(() =>
80
+ sortItems<DashContentItem>(
81
+ filterByType<DashContentItem>(
82
+ (myContent.value?.items ?? []).filter((i: DashContentItem) => i.status === 'draft'),
83
+ ),
84
+ ),
70
85
  );
71
- const published = computed(() =>
72
- sortItems(filterByType((myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'published'))),
86
+ const published = computed<DashContentItem[]>(() =>
87
+ sortItems<DashContentItem>(
88
+ filterByType<DashContentItem>(
89
+ (myContent.value?.items ?? []).filter((i: DashContentItem) => i.status === 'published'),
90
+ ),
91
+ ),
73
92
  );
74
93
 
75
94
  // Stats use ALL items (unfiltered) so totals don't change with filter selection
76
- const allPublished = computed(() =>
77
- (myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'published'),
95
+ const allPublished = computed<DashContentItem[]>(() =>
96
+ (myContent.value?.items ?? []).filter((i: DashContentItem) => i.status === 'published'),
78
97
  );
79
- const allDrafts = computed(() =>
80
- (myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'draft'),
98
+ const allDrafts = computed<DashContentItem[]>(() =>
99
+ (myContent.value?.items ?? []).filter((i: DashContentItem) => i.status === 'draft'),
81
100
  );
82
101
  const totalViews = computed(() =>
83
102
  allPublished.value.reduce((sum, item) => sum + (item.viewCount ?? 0), 0),
@@ -24,9 +24,10 @@ const { data: site, refresh: refreshSite } = await useFetch<{ id: string; name:
24
24
 
25
25
  // Version selector
26
26
  const selectedVersion = ref('');
27
+ type SiteVersion = { id: string; version: string; isDefault: boolean };
27
28
  watch(site, (s) => {
28
29
  if (s?.versions?.length && !selectedVersion.value) {
29
- const def = s.versions.find((v) => v.isDefault) ?? s.versions[0];
30
+ const def = s.versions.find((v: SiteVersion) => v.isDefault) ?? s.versions[0];
30
31
  if (def) selectedVersion.value = def.version;
31
32
  }
32
33
  }, { immediate: true });
@@ -34,7 +35,7 @@ watch(site, (s) => {
34
35
  // Resolve selected version string → version UUID for write operations
35
36
  const selectedVersionId = computed(() => {
36
37
  if (!site.value?.versions?.length || !selectedVersion.value) return undefined;
37
- return site.value.versions.find((v) => v.version === selectedVersion.value)?.id;
38
+ return site.value.versions.find((v: SiteVersion) => v.version === selectedVersion.value)?.id;
38
39
  });
39
40
 
40
41
  const { data: rawPages, refresh: refreshPages } = await useFetch<Array<{ id: string; title: string; slug: string; sortOrder: number; parentId: string | null; content: string | BlockTuple[] | null; format?: string }>>(() => {
@@ -27,6 +27,7 @@ const saving = ref(false);
27
27
  const form = reactive({
28
28
  title: event.value.title,
29
29
  description: event.value.description ?? '',
30
+ coverImage: event.value.coverImage ?? '',
30
31
  eventType: event.value.eventType,
31
32
  status: event.value.status,
32
33
  startDate: new Date(event.value.startDate).toISOString().slice(0, 16),
@@ -61,6 +62,7 @@ async function submit(): Promise<void> {
61
62
  isFeatured: form.isFeatured,
62
63
  };
63
64
  if (form.description) body.description = form.description;
65
+ body.coverImage = form.coverImage || null;
64
66
  if (form.location) body.location = form.location;
65
67
  if (form.locationUrl) body.locationUrl = form.locationUrl;
66
68
  if (form.onlineUrl) body.onlineUrl = form.onlineUrl;
@@ -92,6 +94,8 @@ async function submit(): Promise<void> {
92
94
  <textarea v-model="form.description" class="cpub-form-textarea" rows="4" />
93
95
  </div>
94
96
 
97
+ <ImageUpload v-model="form.coverImage" purpose="cover" label="Cover Image" hint="Recommended: 16:9 aspect ratio" />
98
+
95
99
  <div class="cpub-form-row">
96
100
  <div class="cpub-form-field">
97
101
  <label class="cpub-form-label">Type</label>
@@ -9,6 +9,7 @@ const saving = ref(false);
9
9
  const form = reactive({
10
10
  title: '',
11
11
  description: '',
12
+ coverImage: '',
12
13
  eventType: 'in-person' as 'in-person' | 'online' | 'hybrid',
13
14
  startDate: '',
14
15
  endDate: '',
@@ -24,6 +25,10 @@ async function submit(): Promise<void> {
24
25
  toast.error('Title, start date, and end date are required');
25
26
  return;
26
27
  }
28
+ if (new Date(form.startDate) >= new Date(form.endDate)) {
29
+ toast.error('End date must be after start date');
30
+ return;
31
+ }
27
32
 
28
33
  saving.value = true;
29
34
  try {
@@ -35,6 +40,7 @@ async function submit(): Promise<void> {
35
40
  timezone: form.timezone,
36
41
  };
37
42
  if (form.description) body.description = form.description;
43
+ if (form.coverImage) body.coverImage = form.coverImage;
38
44
  if (form.location) body.location = form.location;
39
45
  if (form.locationUrl) body.locationUrl = form.locationUrl;
40
46
  if (form.onlineUrl) body.onlineUrl = form.onlineUrl;
@@ -69,6 +75,8 @@ async function submit(): Promise<void> {
69
75
  <textarea v-model="form.description" class="cpub-form-textarea" rows="4" placeholder="Describe the event..." />
70
76
  </div>
71
77
 
78
+ <ImageUpload v-model="form.coverImage" purpose="cover" label="Cover Image" hint="Recommended: 16:9 aspect ratio" />
79
+
72
80
  <div class="cpub-form-row">
73
81
  <div class="cpub-form-field">
74
82
  <label class="cpub-form-label">Type</label>
@@ -4,7 +4,53 @@ import type { EventListItem } from '@commonpub/server';
4
4
  useSeoMeta({ title: `Events — ${useSiteName()}` });
5
5
 
6
6
  const { isAuthenticated } = useAuth();
7
- const { data } = await useFetch<{ items: EventListItem[]; total: number }>('/api/events');
7
+ const route = useRoute();
8
+ const router = useRouter();
9
+
10
+ const LIMIT = 12;
11
+
12
+ const activeFilter = ref<string>((route.query.filter as string) || 'upcoming');
13
+ const page = ref(Math.max(1, Number(route.query.page) || 1));
14
+ const viewMode = ref<'grid' | 'calendar'>(route.query.view === 'calendar' ? 'calendar' : 'grid');
15
+
16
+ const queryParams = computed(() => {
17
+ // Calendar view fetches more events (no pagination, wider window)
18
+ if (viewMode.value === 'calendar') {
19
+ const q: Record<string, string | number> = { limit: 100 };
20
+ if (activeFilter.value === 'mine') q.myEvents = 'true';
21
+ return q;
22
+ }
23
+ const q: Record<string, string | number> = { limit: LIMIT, offset: (page.value - 1) * LIMIT };
24
+ if (activeFilter.value === 'upcoming') q.upcoming = 'true';
25
+ if (activeFilter.value === 'featured') q.featured = 'true';
26
+ if (activeFilter.value === 'past') {
27
+ q.status = 'completed';
28
+ }
29
+ if (activeFilter.value === 'mine') q.myEvents = 'true';
30
+ return q;
31
+ });
32
+
33
+ const { data, refresh } = await useFetch<{ items: EventListItem[]; total: number }>('/api/events', {
34
+ query: queryParams,
35
+ });
36
+
37
+ const totalPages = computed(() => Math.max(1, Math.ceil((data.value?.total ?? 0) / LIMIT)));
38
+
39
+ function setFilter(filter: string): void {
40
+ activeFilter.value = filter;
41
+ page.value = 1;
42
+ router.replace({ query: { filter, page: undefined, view: viewMode.value !== 'grid' ? viewMode.value : undefined } });
43
+ }
44
+
45
+ function setPage(p: number): void {
46
+ page.value = p;
47
+ router.replace({ query: { ...route.query, page: p > 1 ? String(p) : undefined } });
48
+ }
49
+
50
+ function setView(mode: 'grid' | 'calendar'): void {
51
+ viewMode.value = mode;
52
+ router.replace({ query: { ...route.query, view: mode !== 'grid' ? mode : undefined } });
53
+ }
8
54
  </script>
9
55
 
10
56
  <template>
@@ -16,20 +62,80 @@ const { data } = await useFetch<{ items: EventListItem[]; total: number }>('/api
16
62
  </NuxtLink>
17
63
  </div>
18
64
 
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>
65
+ <div class="cpub-events-toolbar">
66
+ <div v-if="viewMode === 'grid'" class="cpub-events-filters" role="group" aria-label="Filter events">
67
+ <button
68
+ v-for="f in [
69
+ { key: 'upcoming', label: 'Upcoming', icon: 'fa-solid fa-arrow-right', auth: false },
70
+ { key: 'featured', label: 'Featured', icon: 'fa-solid fa-star', auth: false },
71
+ { key: 'mine', label: 'My Events', icon: 'fa-solid fa-user', auth: true },
72
+ { key: 'all', label: 'All', icon: 'fa-solid fa-calendar-days', auth: false },
73
+ { key: 'past', label: 'Past', icon: 'fa-solid fa-clock-rotate-left', auth: false },
74
+ ].filter(f => !f.auth || isAuthenticated)"
75
+ :key="f.key"
76
+ class="cpub-filter-btn"
77
+ :class="{ active: activeFilter === f.key }"
78
+ :aria-pressed="activeFilter === f.key"
79
+ @click="setFilter(f.key)"
80
+ >
81
+ <i :class="f.icon"></i> {{ f.label }}
82
+ </button>
83
+ </div>
84
+ <div v-else class="cpub-events-filters" />
85
+
86
+ <div class="cpub-view-toggle" role="group" aria-label="View mode">
87
+ <button
88
+ class="cpub-view-btn"
89
+ :class="{ active: viewMode === 'grid' }"
90
+ :aria-pressed="viewMode === 'grid'"
91
+ aria-label="Grid view"
92
+ @click="setView('grid')"
93
+ >
94
+ <i class="fa-solid fa-grid-2"></i>
95
+ </button>
96
+ <button
97
+ class="cpub-view-btn"
98
+ :class="{ active: viewMode === 'calendar' }"
99
+ :aria-pressed="viewMode === 'calendar'"
100
+ aria-label="Calendar view"
101
+ @click="setView('calendar')"
102
+ >
103
+ <i class="fa-solid fa-calendar"></i>
104
+ </button>
105
+ </div>
26
106
  </div>
107
+
108
+ <template v-if="viewMode === 'calendar'">
109
+ <EventCalendar :events="data?.items ?? []" />
110
+ </template>
111
+ <template v-else>
112
+ <div v-if="data?.items?.length" class="cpub-events-grid">
113
+ <EventCard v-for="event in data.items" :key="event.id" :event="event" />
114
+ </div>
115
+ <div v-else class="cpub-empty-state">
116
+ <div class="cpub-empty-state-icon"><i class="fa-solid fa-calendar-days"></i></div>
117
+ <p class="cpub-empty-state-title">No events found</p>
118
+ <p class="cpub-empty-state-desc">
119
+ {{ activeFilter === 'upcoming' ? 'No upcoming events scheduled.' : activeFilter === 'past' ? 'No past events.' : activeFilter === 'mine' ? "You haven't created or registered for any events yet." : 'Check back soon for events.' }}
120
+ </p>
121
+ </div>
122
+
123
+ <nav v-if="totalPages > 1" class="cpub-events-pagination" aria-label="Events pagination">
124
+ <button class="cpub-page-btn" :disabled="page <= 1" aria-label="Previous page" @click="setPage(page - 1)">
125
+ <i class="fa-solid fa-chevron-left"></i>
126
+ </button>
127
+ <span class="cpub-page-info">{{ page }} / {{ totalPages }}</span>
128
+ <button class="cpub-page-btn" :disabled="page >= totalPages" aria-label="Next page" @click="setPage(page + 1)">
129
+ <i class="fa-solid fa-chevron-right"></i>
130
+ </button>
131
+ </nav>
132
+ </template>
27
133
  </div>
28
134
  </template>
29
135
 
30
136
  <style scoped>
31
137
  .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; }
138
+ .cpub-events-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
33
139
  .cpub-btn-create {
34
140
  font-size: 12px; padding: 6px 14px; background: var(--accent); color: var(--color-text-inverse);
35
141
  border: var(--border-width-default) solid var(--border); text-decoration: none;
@@ -38,8 +144,54 @@ const { data } = await useFetch<{ items: EventListItem[]; total: number }>('/api
38
144
  }
39
145
  .cpub-btn-create:hover { box-shadow: var(--shadow-md); transform: translate(-1px, -1px); }
40
146
 
147
+ .cpub-events-toolbar {
148
+ display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; gap: 12px;
149
+ }
150
+ .cpub-events-filters {
151
+ display: flex; gap: 6px; flex-wrap: wrap;
152
+ }
153
+ .cpub-filter-btn {
154
+ font-family: var(--font-mono); font-size: 11px; font-weight: 500;
155
+ text-transform: uppercase; letter-spacing: 0.04em;
156
+ padding: 5px 12px; border: var(--border-width-default) solid var(--border2);
157
+ background: var(--surface); color: var(--text-dim); cursor: pointer;
158
+ display: inline-flex; align-items: center; gap: 5px;
159
+ transition: all 0.15s;
160
+ }
161
+ .cpub-filter-btn:hover { border-color: var(--border); color: var(--text); }
162
+ .cpub-filter-btn.active {
163
+ background: var(--accent); color: var(--color-text-inverse);
164
+ border-color: var(--border); box-shadow: var(--shadow-sm);
165
+ }
166
+ .cpub-filter-btn i { font-size: 10px; }
167
+
168
+ .cpub-view-toggle { display: flex; gap: 2px; flex-shrink: 0; }
169
+ .cpub-view-btn {
170
+ width: 30px; height: 30px; display: flex; align-items: center; justify-content: center;
171
+ border: var(--border-width-default) solid var(--border2); background: var(--surface);
172
+ color: var(--text-faint); cursor: pointer; font-size: 12px; transition: all 0.15s;
173
+ }
174
+ .cpub-view-btn:hover { border-color: var(--border); color: var(--text); }
175
+ .cpub-view-btn.active { background: var(--accent); color: var(--color-text-inverse); border-color: var(--border); }
176
+
41
177
  .cpub-events-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
42
178
 
179
+ .cpub-events-pagination {
180
+ display: flex; align-items: center; justify-content: center; gap: 12px;
181
+ margin-top: 24px; padding-top: 20px; border-top: var(--border-width-default) solid var(--border2);
182
+ }
183
+ .cpub-page-btn {
184
+ padding: 6px 10px; border: var(--border-width-default) solid var(--border);
185
+ background: var(--surface); color: var(--text); cursor: pointer;
186
+ font-size: 12px; transition: all 0.15s;
187
+ }
188
+ .cpub-page-btn:hover:not(:disabled) { box-shadow: var(--shadow-sm); }
189
+ .cpub-page-btn:disabled { opacity: 0.3; cursor: default; }
190
+ .cpub-page-info {
191
+ font-family: var(--font-mono); font-size: 11px; color: var(--text-dim);
192
+ letter-spacing: 0.04em;
193
+ }
194
+
43
195
  @media (max-width: 768px) {
44
196
  .cpub-events-page { padding: 16px; }
45
197
  .cpub-events-grid { grid-template-columns: 1fr; }
package/pages/index.vue CHANGED
@@ -58,7 +58,19 @@ const { data: communities, pending: communitiesPending } = await useFetch('/api/
58
58
  query: { limit: 4 },
59
59
  });
60
60
 
61
- const { data: contests, pending: contestsPending } = await useFetch('/api/contests', {
61
+ interface ContestListItem {
62
+ id: string;
63
+ slug: string;
64
+ title: string;
65
+ status: string;
66
+ description?: string | null;
67
+ bannerUrl?: string | null;
68
+ startDate?: string | null;
69
+ endDate?: string | null;
70
+ entryCount?: number;
71
+ }
72
+
73
+ const { data: contests, pending: contestsPending } = await useFetch<{ items: ContestListItem[] }>('/api/contests', {
62
74
  query: { limit: 3 },
63
75
  });
64
76
 
@@ -66,9 +78,9 @@ const heroDismissed = ref(false);
66
78
  const joinedHubs = ref(new Set<string>());
67
79
 
68
80
  // Active contest for hero banner
69
- const activeContest = computed(() => {
81
+ const activeContest = computed<ContestListItem | null>(() => {
70
82
  const items = contests.value?.items;
71
- return items?.find((c) => c.status === 'active') ?? null;
83
+ return items?.find((c: ContestListItem) => c.status === 'active') ?? null;
72
84
  });
73
85
 
74
86
  const isAuthenticated = computed(() => !!user.value);
@@ -79,7 +79,7 @@ if (profile.value) {
79
79
  form.value.bannerUrl = p.bannerUrl || '';
80
80
 
81
81
  if (Array.isArray(p.skills)) {
82
- skills.value = p.skills.filter((s): s is string => typeof s === 'string');
82
+ skills.value = (p.skills as unknown[]).filter((s): s is string => typeof s === 'string');
83
83
  }
84
84
  pronouns.value = p.pronouns || '';
85
85
  if (p.socialLinks) {
@@ -26,7 +26,11 @@ export default defineEventHandler(async (event) => {
26
26
  }
27
27
 
28
28
  const body = await parseBody(event, addJudgeSchema);
29
- const result = await addContestJudge(db, contest.id, body.userId, (body.role ?? 'judge') as JudgeRole);
29
+ const result = await addContestJudge(db, contest.id, body.userId, (body.role ?? 'judge') as JudgeRole, {
30
+ contestSlug: slug,
31
+ contestTitle: contest.title,
32
+ invitedBy: user.id,
33
+ });
30
34
 
31
35
  if (!result.added) {
32
36
  throw createError({ statusCode: 400, statusMessage: result.error ?? 'Failed to add judge' });
@@ -0,0 +1,19 @@
1
+ import { getContestBySlug, getContestEntryVotes } from '@commonpub/server';
2
+ import type { ContestEntryVoteInfo } from '@commonpub/server';
3
+
4
+ /**
5
+ * GET /api/contests/:slug/votes
6
+ * Batch-fetch vote counts + current user's vote status for all entries.
7
+ */
8
+ export default defineEventHandler(async (event): Promise<ContestEntryVoteInfo[]> => {
9
+ requireFeature('contests');
10
+ const db = useDB();
11
+ const { slug } = parseParams(event, { slug: 'string' });
12
+ const user = getOptionalUser(event);
13
+
14
+ const contest = await getContestBySlug(db, slug);
15
+ if (!contest || contest.status === 'upcoming') throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
16
+ if (!contest.communityVotingEnabled) return [];
17
+
18
+ return getContestEntryVotes(db, contest.id, user?.id ?? null);
19
+ });
@@ -14,10 +14,10 @@ export default defineEventHandler(async (event) => {
14
14
  const existing = await getEventBySlug(db, slug);
15
15
  if (!existing) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
16
16
 
17
- const cancelled = await cancelRsvp(db, existing.id, user.id);
18
- if (!cancelled) {
17
+ const result = await cancelRsvp(db, existing.id, user.id);
18
+ if (!result.cancelled) {
19
19
  throw createError({ statusCode: 400, statusMessage: 'No RSVP found' });
20
20
  }
21
21
 
22
- return { cancelled: true };
22
+ return { cancelled: true, promoted: !!result.promoted };
23
23
  });
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  const updateEventSchema = z.object({
5
5
  title: z.string().min(1).max(255).optional(),
6
6
  description: z.string().max(10000).optional(),
7
- coverImage: z.string().max(500).optional(),
7
+ coverImage: z.string().max(500).nullable().optional(),
8
8
  eventType: z.enum(['in-person', 'online', 'hybrid']).optional(),
9
9
  status: z.enum(['draft', 'published', 'active', 'completed', 'cancelled']).optional(),
10
10
  startDate: z.string().datetime().optional(),
@@ -1,6 +1,8 @@
1
1
  import { listEvents } from '@commonpub/server';
2
2
  import type { EventStatus } from '@commonpub/server';
3
3
 
4
+ const PUBLIC_STATUSES = new Set<string>(['published', 'active', 'completed']);
5
+
4
6
  /**
5
7
  * GET /api/events
6
8
  * List published/active events (public).
@@ -9,12 +11,24 @@ export default defineEventHandler(async (event) => {
9
11
  requireFeature('events');
10
12
  const db = useDB();
11
13
  const query = getQuery(event);
14
+ const user = getOptionalUser(event);
15
+
16
+ // Only allow public-safe status values; ignore anything else
17
+ const rawStatus = query.status as string | undefined;
18
+ const status = rawStatus && PUBLIC_STATUSES.has(rawStatus) ? (rawStatus as EventStatus) : undefined;
19
+
20
+ // "My Events" filter: only allowed for the authenticated user's own ID
21
+ let userId: string | undefined;
22
+ if (query.myEvents === 'true' && user?.id) {
23
+ userId = user.id;
24
+ }
12
25
 
13
26
  return listEvents(db, {
14
- status: (query.status as EventStatus) || undefined,
27
+ status,
15
28
  hubId: (query.hubId as string) || undefined,
16
29
  upcoming: query.upcoming === 'true',
17
30
  featured: query.featured === 'true',
31
+ userId,
18
32
  limit: query.limit ? Number(query.limit) : undefined,
19
33
  offset: query.offset ? Number(query.offset) : undefined,
20
34
  });