@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.
- package/components/CommentSection.vue +4 -1
- package/components/EventCalendar.vue +301 -0
- package/components/contest/ContestEntries.vue +88 -1
- package/error.vue +7 -0
- package/package.json +6 -6
- package/pages/admin/content.vue +21 -2
- package/pages/contests/[slug]/index.vue +2 -0
- package/pages/dashboard.vue +28 -9
- package/pages/docs/[siteSlug]/edit.vue +3 -2
- package/pages/events/[slug]/edit.vue +4 -0
- package/pages/events/create.vue +8 -0
- package/pages/events/index.vue +161 -9
- package/pages/index.vue +15 -3
- package/pages/settings/profile.vue +1 -1
- package/server/api/contests/[slug]/judges/index.post.ts +5 -1
- package/server/api/contests/[slug]/votes.get.ts +19 -0
- package/server/api/events/[slug]/rsvp.delete.ts +3 -3
- package/server/api/events/[slug].put.ts +1 -1
- package/server/api/events/index.get.ts +15 -1
|
@@ -12,7 +12,8 @@ interface Comment {
|
|
|
12
12
|
createdAt: string;
|
|
13
13
|
parentId: string | null;
|
|
14
14
|
author: CommentAuthor | null;
|
|
15
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
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",
|
package/pages/admin/content.vue
CHANGED
|
@@ -12,7 +12,22 @@ interface Category {
|
|
|
12
12
|
icon: string | null;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
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(
|
|
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>
|
package/pages/dashboard.vue
CHANGED
|
@@ -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(
|
|
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(
|
|
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:
|
|
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:
|
|
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>
|
package/pages/events/create.vue
CHANGED
|
@@ -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>
|
package/pages/events/index.vue
CHANGED
|
@@ -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
|
|
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
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
});
|