@commonpub/layer 0.26.0 → 0.27.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.
- package/components/contest/ContestJudgeManager.vue +1 -1
- package/package.json +5 -5
- package/pages/contests/[slug]/edit.vue +32 -0
- package/pages/contests/[slug]/index.vue +16 -2
- package/pages/contests/[slug]/judge.vue +3 -1
- package/pages/contests/[slug]/results.vue +39 -1
- package/pages/contests/create.vue +32 -0
- package/server/api/contests/[slug]/entries/[entryId].delete.ts +1 -1
- package/server/api/contests/[slug]/votes.get.ts +3 -2
- package/server/api/contests/index.post.ts +9 -3
|
@@ -113,7 +113,7 @@ const roleLabels: Record<string, string> = {
|
|
|
113
113
|
placeholder="Search by name or username..."
|
|
114
114
|
@input="handleSearch"
|
|
115
115
|
/>
|
|
116
|
-
<select v-model="newJudgeRole" class="cpub-judges-input cpub-judges-select">
|
|
116
|
+
<select v-model="newJudgeRole" class="cpub-judges-input cpub-judges-select" aria-label="Judge role">
|
|
117
117
|
<option value="lead">Lead</option>
|
|
118
118
|
<option value="judge">Judge</option>
|
|
119
119
|
<option value="guest">Guest</option>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,16 +53,16 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
+
"@commonpub/config": "0.15.0",
|
|
56
57
|
"@commonpub/auth": "0.6.0",
|
|
57
58
|
"@commonpub/docs": "0.6.3",
|
|
58
59
|
"@commonpub/editor": "0.7.11",
|
|
60
|
+
"@commonpub/explainer": "0.7.15",
|
|
59
61
|
"@commonpub/learning": "0.5.2",
|
|
60
62
|
"@commonpub/protocol": "0.12.0",
|
|
61
|
-
"@commonpub/
|
|
63
|
+
"@commonpub/server": "2.61.0",
|
|
62
64
|
"@commonpub/ui": "0.9.1",
|
|
63
|
-
"@commonpub/schema": "0.
|
|
64
|
-
"@commonpub/server": "2.60.0",
|
|
65
|
-
"@commonpub/config": "0.15.0"
|
|
65
|
+
"@commonpub/schema": "0.20.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -22,6 +22,15 @@ const judgingEndDate = ref('');
|
|
|
22
22
|
const communityVotingEnabled = ref(false);
|
|
23
23
|
const judgingVisibility = ref<'public' | 'judges-only' | 'private'>('judges-only');
|
|
24
24
|
|
|
25
|
+
const { enabledTypeMeta } = useContentTypes();
|
|
26
|
+
const eligibleContentTypes = ref<string[]>([]);
|
|
27
|
+
const maxEntriesPerUser = ref<number | null>(null);
|
|
28
|
+
function toggleType(type: string): void {
|
|
29
|
+
const i = eligibleContentTypes.value.indexOf(type);
|
|
30
|
+
if (i >= 0) eligibleContentTypes.value.splice(i, 1);
|
|
31
|
+
else eligibleContentTypes.value.push(type);
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
interface Prize { place: number | null; category: string; title: string; description: string; value: string }
|
|
26
35
|
const prizes = ref<Prize[]>([]);
|
|
27
36
|
|
|
@@ -40,6 +49,8 @@ watch(contest, (c) => {
|
|
|
40
49
|
judgingEndDate.value = c.judgingEndDate ? new Date(c.judgingEndDate).toISOString().slice(0, 16) : '';
|
|
41
50
|
communityVotingEnabled.value = !!c.communityVotingEnabled;
|
|
42
51
|
judgingVisibility.value = (c.judgingVisibility as typeof judgingVisibility.value) ?? 'judges-only';
|
|
52
|
+
eligibleContentTypes.value = [...(c.eligibleContentTypes ?? [])];
|
|
53
|
+
maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
|
|
43
54
|
prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title: string; description?: string; value?: string }) => ({
|
|
44
55
|
place: p.place ?? null,
|
|
45
56
|
category: p.category ?? '',
|
|
@@ -117,6 +128,8 @@ async function handleSave(): Promise<void> {
|
|
|
117
128
|
judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
|
|
118
129
|
communityVotingEnabled: communityVotingEnabled.value,
|
|
119
130
|
judgingVisibility: judgingVisibility.value,
|
|
131
|
+
eligibleContentTypes: eligibleContentTypes.value,
|
|
132
|
+
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
120
133
|
prizes: prizeData,
|
|
121
134
|
judgingCriteria: criteriaData,
|
|
122
135
|
},
|
|
@@ -197,6 +210,24 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
197
210
|
<p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
|
|
198
211
|
</section>
|
|
199
212
|
|
|
213
|
+
<section class="cpub-form-section">
|
|
214
|
+
<h2 class="cpub-form-section-title">Entries</h2>
|
|
215
|
+
<div class="cpub-form-field">
|
|
216
|
+
<span class="cpub-form-label">Eligible content types</span>
|
|
217
|
+
<p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
|
|
218
|
+
<div class="cpub-type-options" role="group" aria-label="Eligible content types">
|
|
219
|
+
<label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
|
|
220
|
+
<input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
|
|
221
|
+
<span>{{ t.label }}</span>
|
|
222
|
+
</label>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
<div class="cpub-form-field">
|
|
226
|
+
<label class="cpub-form-label">Max entries per person</label>
|
|
227
|
+
<input v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" style="max-width: 160px;" />
|
|
228
|
+
</div>
|
|
229
|
+
</section>
|
|
230
|
+
|
|
200
231
|
<section class="cpub-form-section">
|
|
201
232
|
<h2 class="cpub-form-section-title">Prizes</h2>
|
|
202
233
|
<div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
|
|
@@ -335,6 +366,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
335
366
|
.cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
|
|
336
367
|
.cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
|
337
368
|
.cpub-form-check input { width: 14px; height: 14px; }
|
|
369
|
+
.cpub-type-options { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 6px; }
|
|
338
370
|
.cpub-subhead { display: flex; align-items: center; justify-content: space-between; margin: 18px 0 10px; }
|
|
339
371
|
.cpub-form-subtitle { font-size: 12px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; }
|
|
340
372
|
.cpub-form-hint-inline { font-size: 10px; color: var(--accent); }
|
|
@@ -100,6 +100,14 @@ const { data: userContent } = useFetch('/api/content', {
|
|
|
100
100
|
});
|
|
101
101
|
const enteredContentIds = computed(() => new Set(entries.value.map((e) => e.contentId)));
|
|
102
102
|
|
|
103
|
+
// Restrict the submit picker to the contest's eligible content types (if set).
|
|
104
|
+
const eligibleTypes = computed<string[]>(() => (c.value?.eligibleContentTypes as string[] | undefined) ?? []);
|
|
105
|
+
const submittableContent = computed(() => {
|
|
106
|
+
const items = (userContent.value?.items ?? []) as Array<{ id: string; title: string; type: string }>;
|
|
107
|
+
if (eligibleTypes.value.length === 0) return items;
|
|
108
|
+
return items.filter((i) => eligibleTypes.value.includes(i.type));
|
|
109
|
+
});
|
|
110
|
+
|
|
103
111
|
function copyLink(): void {
|
|
104
112
|
if (typeof window !== 'undefined' && window.navigator?.clipboard) {
|
|
105
113
|
window.navigator.clipboard.writeText(window.location.href);
|
|
@@ -154,11 +162,14 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
154
162
|
<button class="cpub-submit-close" aria-label="Close" @click="showSubmitDialog = false"><i class="fa-solid fa-times"></i></button>
|
|
155
163
|
</div>
|
|
156
164
|
<div class="cpub-submit-body">
|
|
157
|
-
<p class="cpub-submit-hint">
|
|
165
|
+
<p class="cpub-submit-hint">
|
|
166
|
+
Select one of your published projects to submit as an entry.
|
|
167
|
+
<template v-if="eligibleTypes.length"> This contest accepts: {{ eligibleTypes.join(', ') }}.</template>
|
|
168
|
+
</p>
|
|
158
169
|
<select v-model="submitContentId" class="cpub-submit-select" aria-label="Select a project to submit">
|
|
159
170
|
<option value="">Select a project...</option>
|
|
160
171
|
<option
|
|
161
|
-
v-for="item in
|
|
172
|
+
v-for="item in submittableContent"
|
|
162
173
|
:key="item.id"
|
|
163
174
|
:value="item.id"
|
|
164
175
|
:disabled="enteredContentIds.has(item.id)"
|
|
@@ -166,6 +177,9 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
166
177
|
{{ item.title }} ({{ item.type }}){{ enteredContentIds.has(item.id) ? ' — already entered' : '' }}
|
|
167
178
|
</option>
|
|
168
179
|
</select>
|
|
180
|
+
<p v-if="submittableContent.length === 0" class="cpub-submit-hint" style="margin-top: 10px; margin-bottom: 0;">
|
|
181
|
+
No eligible published content found.
|
|
182
|
+
</p>
|
|
169
183
|
</div>
|
|
170
184
|
<div class="cpub-submit-footer">
|
|
171
185
|
<button class="cpub-btn cpub-btn-sm" @click="showSubmitDialog = false">Cancel</button>
|
|
@@ -105,7 +105,9 @@ async function submitScore(entryId: string): Promise<void> {
|
|
|
105
105
|
},
|
|
106
106
|
});
|
|
107
107
|
success.value = 'Score submitted for entry.';
|
|
108
|
-
await refreshEntries()
|
|
108
|
+
await refreshEntries().catch(() => {
|
|
109
|
+
success.value = 'Score saved — refresh to see the updated totals.';
|
|
110
|
+
});
|
|
109
111
|
} catch (err: unknown) {
|
|
110
112
|
error.value = (err as { data?: { message?: string } })?.data?.message || 'Failed to submit score.';
|
|
111
113
|
} finally {
|
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { Serialized, ContestDetail, ContestEntryItem } from '@commonpub/server';
|
|
2
|
+
import type { Serialized, ContestDetail, ContestEntryItem, ContestEntryVoteInfo } from '@commonpub/server';
|
|
3
3
|
|
|
4
4
|
const route = useRoute();
|
|
5
5
|
const slug = route.params.slug as string;
|
|
6
6
|
|
|
7
7
|
const { data: contest } = useLazyFetch<Serialized<ContestDetail>>(`/api/contests/${slug}`);
|
|
8
8
|
const { data: entriesData } = useLazyFetch<{ items: Serialized<ContestEntryItem>[]; total: number }>(`/api/contests/${slug}/entries`);
|
|
9
|
+
const { data: votesData } = useLazyFetch<ContestEntryVoteInfo[]>(`/api/contests/${slug}/votes`);
|
|
9
10
|
|
|
10
11
|
useSeoMeta({
|
|
11
12
|
title: () => `Results: ${contest.value?.title || 'Contest'} — ${useSiteName()}`,
|
|
12
13
|
});
|
|
13
14
|
|
|
15
|
+
// Community-vote tallies (only when the contest enabled community voting).
|
|
16
|
+
const votingEnabled = computed(() => !!contest.value?.communityVotingEnabled);
|
|
17
|
+
const voteCounts = computed<Map<string, number>>(() => {
|
|
18
|
+
const m = new Map<string, number>();
|
|
19
|
+
for (const v of votesData.value ?? []) m.set(v.entryId, v.count);
|
|
20
|
+
return m;
|
|
21
|
+
});
|
|
22
|
+
function voteCount(entryId: string): number {
|
|
23
|
+
return voteCounts.value.get(entryId) ?? 0;
|
|
24
|
+
}
|
|
25
|
+
const communityChoice = computed(() => {
|
|
26
|
+
if (!votingEnabled.value) return null;
|
|
27
|
+
let best: { id: string; count: number } | null = null;
|
|
28
|
+
for (const [id, count] of voteCounts.value) {
|
|
29
|
+
if (count > 0 && (!best || count > best.count)) best = { id, count };
|
|
30
|
+
}
|
|
31
|
+
if (!best) return null;
|
|
32
|
+
return rankedEntries.value.find((e) => e.id === best!.id) ?? null;
|
|
33
|
+
});
|
|
34
|
+
|
|
14
35
|
const rankedEntries = computed(() => {
|
|
15
36
|
const items = [...(entriesData.value?.items ?? [])];
|
|
16
37
|
items.sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999));
|
|
@@ -62,6 +83,13 @@ function medalColor(rank: number): string {
|
|
|
62
83
|
</div>
|
|
63
84
|
|
|
64
85
|
<template v-else-if="contest">
|
|
86
|
+
<!-- COMMUNITY CHOICE -->
|
|
87
|
+
<div v-if="communityChoice" class="cpub-community-choice">
|
|
88
|
+
<div class="cpub-cc-label"><i class="fa-solid fa-heart"></i> Community Choice</div>
|
|
89
|
+
<NuxtLink :to="`/u/${communityChoice.authorUsername}/${communityChoice.contentType}/${communityChoice.contentSlug}`" class="cpub-cc-title">{{ communityChoice.contentTitle }}</NuxtLink>
|
|
90
|
+
<span class="cpub-cc-meta">by {{ communityChoice.authorName }} · {{ voteCount(communityChoice.id) }} votes</span>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
65
93
|
<!-- PODIUM -->
|
|
66
94
|
<div v-if="podium.length > 0" class="cpub-podium">
|
|
67
95
|
<div
|
|
@@ -100,6 +128,7 @@ function medalColor(rank: number): string {
|
|
|
100
128
|
<th>Entry</th>
|
|
101
129
|
<th>Author</th>
|
|
102
130
|
<th>Score</th>
|
|
131
|
+
<th v-if="votingEnabled">Votes</th>
|
|
103
132
|
</tr>
|
|
104
133
|
</thead>
|
|
105
134
|
<tbody>
|
|
@@ -117,6 +146,7 @@ function medalColor(rank: number): string {
|
|
|
117
146
|
<NuxtLink :to="`/u/${entry.authorUsername}`" class="cpub-lb-author-link">{{ entry.authorName }}</NuxtLink>
|
|
118
147
|
</td>
|
|
119
148
|
<td class="cpub-lb-score">{{ entry.score ?? '—' }}</td>
|
|
149
|
+
<td v-if="votingEnabled" class="cpub-lb-votes"><i class="fa-solid fa-heart"></i> {{ voteCount(entry.id) }}</td>
|
|
120
150
|
</tr>
|
|
121
151
|
</tbody>
|
|
122
152
|
</table>
|
|
@@ -166,6 +196,14 @@ function medalColor(rank: number): string {
|
|
|
166
196
|
.cpub-lb-top3 { background: var(--surface2); }
|
|
167
197
|
.cpub-lb-rank { font-family: var(--font-mono); font-weight: 700; display: flex; align-items: center; gap: 6px; }
|
|
168
198
|
.cpub-lb-score { font-family: var(--font-mono); font-weight: 600; color: var(--accent); }
|
|
199
|
+
.cpub-lb-votes { font-family: var(--font-mono); color: var(--red); }
|
|
200
|
+
.cpub-lb-votes i { font-size: 10px; }
|
|
201
|
+
|
|
202
|
+
.cpub-community-choice { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 16px; margin-bottom: 24px; background: var(--red-bg); border: var(--border-width-default) solid var(--red-border); }
|
|
203
|
+
.cpub-cc-label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--red); display: flex; align-items: center; gap: 5px; }
|
|
204
|
+
.cpub-cc-title { font-size: 14px; font-weight: 600; color: var(--text); text-decoration: none; }
|
|
205
|
+
.cpub-cc-title:hover { color: var(--accent); }
|
|
206
|
+
.cpub-cc-meta { font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); }
|
|
169
207
|
.cpub-lb-entry-link { color: var(--text); text-decoration: none; font-weight: 500; }
|
|
170
208
|
.cpub-lb-entry-link:hover { color: var(--accent); }
|
|
171
209
|
.cpub-lb-author-link { color: var(--text-dim); text-decoration: none; }
|
|
@@ -17,6 +17,16 @@ const judgingEndDate = ref('');
|
|
|
17
17
|
const communityVotingEnabled = ref(false);
|
|
18
18
|
const judgingVisibility = ref<'public' | 'judges-only' | 'private'>('judges-only');
|
|
19
19
|
|
|
20
|
+
// Entry rules
|
|
21
|
+
const { enabledTypeMeta } = useContentTypes();
|
|
22
|
+
const eligibleContentTypes = ref<string[]>([]); // empty = all types allowed
|
|
23
|
+
const maxEntriesPerUser = ref<number | null>(null);
|
|
24
|
+
function toggleType(type: string): void {
|
|
25
|
+
const i = eligibleContentTypes.value.indexOf(type);
|
|
26
|
+
if (i >= 0) eligibleContentTypes.value.splice(i, 1);
|
|
27
|
+
else eligibleContentTypes.value.push(type);
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
// Prizes
|
|
21
31
|
interface Prize {
|
|
22
32
|
place: number | null;
|
|
@@ -78,6 +88,8 @@ async function handleCreate(): Promise<void> {
|
|
|
78
88
|
judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
|
|
79
89
|
communityVotingEnabled: communityVotingEnabled.value,
|
|
80
90
|
judgingVisibility: judgingVisibility.value,
|
|
91
|
+
eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
|
|
92
|
+
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
81
93
|
prizes: prizes.value
|
|
82
94
|
.filter(p => p.title.trim())
|
|
83
95
|
.map(p => ({
|
|
@@ -159,6 +171,25 @@ function prizeLabel(prize: Prize, idx: number): string {
|
|
|
159
171
|
<p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
|
|
160
172
|
</section>
|
|
161
173
|
|
|
174
|
+
<!-- Entry Rules -->
|
|
175
|
+
<section class="cpub-form-section">
|
|
176
|
+
<h2 class="cpub-form-section-title">Entries</h2>
|
|
177
|
+
<div class="cpub-form-field">
|
|
178
|
+
<span class="cpub-form-label">Eligible content types</span>
|
|
179
|
+
<p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
|
|
180
|
+
<div class="cpub-type-options" role="group" aria-label="Eligible content types">
|
|
181
|
+
<label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
|
|
182
|
+
<input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
|
|
183
|
+
<span>{{ t.label }}</span>
|
|
184
|
+
</label>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="cpub-form-field">
|
|
188
|
+
<label for="max-entries" class="cpub-form-label">Max entries per person</label>
|
|
189
|
+
<input id="max-entries" v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" style="max-width: 160px;" />
|
|
190
|
+
</div>
|
|
191
|
+
</section>
|
|
192
|
+
|
|
162
193
|
<!-- Judging -->
|
|
163
194
|
<section class="cpub-form-section">
|
|
164
195
|
<h2 class="cpub-form-section-title">Judging</h2>
|
|
@@ -281,6 +312,7 @@ function prizeLabel(prize: Prize, idx: number): string {
|
|
|
281
312
|
.cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
|
|
282
313
|
.cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; margin-top: 4px; }
|
|
283
314
|
.cpub-form-check input { width: 14px; height: 14px; }
|
|
315
|
+
.cpub-type-options { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 6px; }
|
|
284
316
|
.cpub-form-subtitle { font-size: 12px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; }
|
|
285
317
|
.cpub-form-hint-inline { font-size: 10px; color: var(--accent); }
|
|
286
318
|
.cpub-form-hint { font-size: 11px; color: var(--text-faint); margin: 8px 0; line-height: 1.5; }
|
|
@@ -8,7 +8,7 @@ export default defineEventHandler(async (event): Promise<{ withdrawn: boolean }>
|
|
|
8
8
|
|
|
9
9
|
const result = await withdrawContestEntry(db, entryId, user.id);
|
|
10
10
|
if (!result.withdrawn) {
|
|
11
|
-
throw createError({ statusCode: 400,
|
|
11
|
+
throw createError({ statusCode: 400, statusMessage: result.error ?? 'Cannot withdraw entry' });
|
|
12
12
|
}
|
|
13
13
|
return { withdrawn: true };
|
|
14
14
|
});
|
|
@@ -12,8 +12,9 @@ export default defineEventHandler(async (event): Promise<ContestEntryVoteInfo[]>
|
|
|
12
12
|
const user = getOptionalUser(event);
|
|
13
13
|
|
|
14
14
|
const contest = await getContestBySlug(db, slug);
|
|
15
|
-
if (!contest
|
|
16
|
-
|
|
15
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
16
|
+
// Voting disabled, or contest not yet open (no entries) → empty array, not an error.
|
|
17
|
+
if (!contest.communityVotingEnabled || contest.status === 'upcoming') return [];
|
|
17
18
|
|
|
18
19
|
return getContestEntryVotes(db, contest.id, user?.id ?? null);
|
|
19
20
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContest } from '@commonpub/server';
|
|
1
|
+
import { createContest, getContestBySlug } from '@commonpub/server';
|
|
2
2
|
import type { ContestDetail, CreateContestInput } from '@commonpub/server';
|
|
3
3
|
import { createContestSchema } from '@commonpub/schema';
|
|
4
4
|
|
|
@@ -9,7 +9,7 @@ function slugify(text: string): string {
|
|
|
9
9
|
.replace(/\s+/g, '-')
|
|
10
10
|
.replace(/-+/g, '-')
|
|
11
11
|
.replace(/^-|-$/g, '')
|
|
12
|
-
.slice(0,
|
|
12
|
+
.slice(0, 120);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export default defineEventHandler(async (event): Promise<ContestDetail> => {
|
|
@@ -19,7 +19,13 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
|
|
|
19
19
|
const config = useConfig();
|
|
20
20
|
const input = await parseBody(event, createContestSchema);
|
|
21
21
|
|
|
22
|
-
const
|
|
22
|
+
const base = slugify(input.title) || `contest-${Date.now()}`;
|
|
23
|
+
// Ensure slug uniqueness so a duplicate title returns a clean contest instead
|
|
24
|
+
// of a 500 from the unique-constraint violation.
|
|
25
|
+
let slug = base;
|
|
26
|
+
for (let n = 2; n <= 50 && (await getContestBySlug(db, slug)); n++) {
|
|
27
|
+
slug = `${base}-${n}`;
|
|
28
|
+
}
|
|
23
29
|
|
|
24
30
|
return createContest(db, { ...input, slug, createdBy: user.id } as CreateContestInput, {
|
|
25
31
|
userRole: user.role,
|