@commonpub/layer 0.25.1 → 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/ContestHero.vue +6 -2
- package/components/contest/ContestJudgeManager.vue +6 -2
- package/components/contest/ContestJudges.vue +16 -20
- package/components/contest/ContestJudgingCriteria.vue +51 -0
- package/components/contest/ContestPrizes.vue +22 -17
- package/components/contest/ContestSidebar.vue +64 -7
- package/components/homepage/ContestsSection.vue +6 -1
- package/package.json +8 -8
- package/pages/contests/[slug]/edit.vue +163 -102
- package/pages/contests/[slug]/index.vue +170 -38
- package/pages/contests/[slug]/judge.vue +65 -10
- package/pages/contests/[slug]/results.vue +40 -2
- package/pages/contests/create.vue +160 -21
- package/server/api/contests/[slug]/entries/[entryId].delete.ts +1 -1
- package/server/api/contests/[slug]/entries.get.ts +17 -2
- package/server/api/contests/[slug]/votes.get.ts +3 -2
- package/server/api/contests/index.post.ts +9 -3
|
@@ -10,41 +10,70 @@ const saving = ref(false);
|
|
|
10
10
|
const title = ref('');
|
|
11
11
|
const description = ref('');
|
|
12
12
|
const rules = ref('');
|
|
13
|
+
const bannerUrl = ref('');
|
|
13
14
|
const startDate = ref('');
|
|
14
15
|
const endDate = ref('');
|
|
15
16
|
const judgingEndDate = ref('');
|
|
17
|
+
const communityVotingEnabled = ref(false);
|
|
18
|
+
const judgingVisibility = ref<'public' | 'judges-only' | 'private'>('judges-only');
|
|
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
|
+
}
|
|
16
29
|
|
|
17
30
|
// Prizes
|
|
18
31
|
interface Prize {
|
|
19
|
-
place: number;
|
|
32
|
+
place: number | null;
|
|
33
|
+
category: string;
|
|
20
34
|
title: string;
|
|
21
35
|
description: string;
|
|
22
36
|
value: string;
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
const prizes = ref<Prize[]>([
|
|
26
|
-
{ place: 1, title: '1st Place', description: '', value: '' },
|
|
27
|
-
{ place: 2, title: '2nd Place', description: '', value: '' },
|
|
28
|
-
{ place: 3, title: '3rd Place', description: '', value: '' },
|
|
40
|
+
{ place: 1, category: '', title: '1st Place', description: '', value: '' },
|
|
41
|
+
{ place: 2, category: '', title: '2nd Place', description: '', value: '' },
|
|
42
|
+
{ place: 3, category: '', title: '3rd Place', description: '', value: '' },
|
|
29
43
|
]);
|
|
30
44
|
|
|
31
45
|
function addPrize(): void {
|
|
32
|
-
prizes.value.push({
|
|
33
|
-
place: prizes.value.length + 1,
|
|
34
|
-
title: `${prizes.value.length + 1}th Place`,
|
|
35
|
-
description: '',
|
|
36
|
-
value: '',
|
|
37
|
-
});
|
|
46
|
+
prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
function removePrize(index: number): void {
|
|
41
50
|
prizes.value.splice(index, 1);
|
|
42
|
-
// Renumber
|
|
43
|
-
prizes.value.forEach((p, i) => { p.place = i + 1; });
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
// Judging criteria (rubric)
|
|
54
|
+
interface Criterion { label: string; weight: number | null; description: string }
|
|
55
|
+
const criteria = ref<Criterion[]>([]);
|
|
56
|
+
function addCriterion(): void {
|
|
57
|
+
criteria.value.push({ label: '', weight: null, description: '' });
|
|
58
|
+
}
|
|
59
|
+
function removeCriterion(index: number): void {
|
|
60
|
+
criteria.value.splice(index, 1);
|
|
61
|
+
}
|
|
62
|
+
const criteriaTotal = computed(() => criteria.value.reduce((s, c) => s + (c.weight ?? 0), 0));
|
|
63
|
+
|
|
64
|
+
const dateError = computed(() => {
|
|
65
|
+
if (startDate.value && endDate.value && new Date(endDate.value) <= new Date(startDate.value)) {
|
|
66
|
+
return 'End date must be after the start date.';
|
|
67
|
+
}
|
|
68
|
+
if (judgingEndDate.value && endDate.value && new Date(judgingEndDate.value) < new Date(endDate.value)) {
|
|
69
|
+
return 'Judging end date must be on or after the end date.';
|
|
70
|
+
}
|
|
71
|
+
return '';
|
|
72
|
+
});
|
|
73
|
+
|
|
46
74
|
async function handleCreate(): Promise<void> {
|
|
47
75
|
if (!title.value.trim() || !startDate.value || !endDate.value) return;
|
|
76
|
+
if (dateError.value) { toast.error(dateError.value); return; }
|
|
48
77
|
saving.value = true;
|
|
49
78
|
try {
|
|
50
79
|
const result = await $fetch<{ slug: string }>('/api/contests', {
|
|
@@ -53,10 +82,30 @@ async function handleCreate(): Promise<void> {
|
|
|
53
82
|
title: title.value,
|
|
54
83
|
description: description.value || undefined,
|
|
55
84
|
rules: rules.value || undefined,
|
|
85
|
+
bannerUrl: bannerUrl.value || undefined,
|
|
56
86
|
startDate: new Date(startDate.value).toISOString(),
|
|
57
87
|
endDate: new Date(endDate.value).toISOString(),
|
|
58
88
|
judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
|
|
59
|
-
|
|
89
|
+
communityVotingEnabled: communityVotingEnabled.value,
|
|
90
|
+
judgingVisibility: judgingVisibility.value,
|
|
91
|
+
eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
|
|
92
|
+
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
93
|
+
prizes: prizes.value
|
|
94
|
+
.filter(p => p.title.trim())
|
|
95
|
+
.map(p => ({
|
|
96
|
+
place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
|
|
97
|
+
category: p.category.trim() || undefined,
|
|
98
|
+
title: p.title,
|
|
99
|
+
description: p.description || undefined,
|
|
100
|
+
value: p.value || undefined,
|
|
101
|
+
})),
|
|
102
|
+
judgingCriteria: criteria.value
|
|
103
|
+
.filter(c => c.label.trim())
|
|
104
|
+
.map(c => ({
|
|
105
|
+
label: c.label.trim(),
|
|
106
|
+
weight: typeof c.weight === 'number' && Number.isFinite(c.weight) ? c.weight : undefined,
|
|
107
|
+
description: c.description.trim() || undefined,
|
|
108
|
+
})),
|
|
60
109
|
},
|
|
61
110
|
});
|
|
62
111
|
toast.success('Contest created!');
|
|
@@ -68,8 +117,11 @@ async function handleCreate(): Promise<void> {
|
|
|
68
117
|
}
|
|
69
118
|
}
|
|
70
119
|
|
|
71
|
-
|
|
72
|
-
|
|
120
|
+
function prizeLabel(prize: Prize, idx: number): string {
|
|
121
|
+
if (prize.category.trim()) return prize.category;
|
|
122
|
+
const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
|
|
123
|
+
return `${labels[idx] || `${idx + 1}th`} Place`;
|
|
124
|
+
}
|
|
73
125
|
</script>
|
|
74
126
|
|
|
75
127
|
<template>
|
|
@@ -91,7 +143,11 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
|
|
|
91
143
|
</div>
|
|
92
144
|
<div class="cpub-form-field">
|
|
93
145
|
<label for="contest-rules" class="cpub-form-label">Rules</label>
|
|
94
|
-
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="4" placeholder="Contest rules and requirements..." />
|
|
146
|
+
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="4" placeholder="Contest rules and requirements (one per line)..." />
|
|
147
|
+
</div>
|
|
148
|
+
<div class="cpub-form-field">
|
|
149
|
+
<label for="contest-banner" class="cpub-form-label">Banner Image URL</label>
|
|
150
|
+
<input id="contest-banner" v-model="bannerUrl" type="url" class="cpub-form-input" placeholder="https://..." />
|
|
95
151
|
</div>
|
|
96
152
|
</section>
|
|
97
153
|
|
|
@@ -112,6 +168,65 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
|
|
|
112
168
|
<input id="judging-date" v-model="judgingEndDate" type="datetime-local" class="cpub-form-input" />
|
|
113
169
|
</div>
|
|
114
170
|
</div>
|
|
171
|
+
<p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
|
|
172
|
+
</section>
|
|
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
|
+
|
|
193
|
+
<!-- Judging -->
|
|
194
|
+
<section class="cpub-form-section">
|
|
195
|
+
<h2 class="cpub-form-section-title">Judging</h2>
|
|
196
|
+
<div class="cpub-form-field">
|
|
197
|
+
<label for="judging-visibility" class="cpub-form-label">Score Visibility</label>
|
|
198
|
+
<select id="judging-visibility" v-model="judgingVisibility" class="cpub-form-input">
|
|
199
|
+
<option value="judges-only">Judges only — scores hidden until results</option>
|
|
200
|
+
<option value="public">Public — show scores during judging</option>
|
|
201
|
+
<option value="private">Private — scores stay with organizers</option>
|
|
202
|
+
</select>
|
|
203
|
+
</div>
|
|
204
|
+
<label class="cpub-form-check">
|
|
205
|
+
<input v-model="communityVotingEnabled" type="checkbox" />
|
|
206
|
+
<span>Enable community voting (let signed-in members upvote entries)</span>
|
|
207
|
+
</label>
|
|
208
|
+
|
|
209
|
+
<div class="cpub-form-section-header" style="margin-top: 16px;">
|
|
210
|
+
<h3 class="cpub-form-subtitle">Judging Criteria <span v-if="criteriaTotal" class="cpub-form-hint-inline">{{ criteriaTotal }} pts</span></h3>
|
|
211
|
+
<button type="button" class="cpub-btn cpub-btn-sm" @click="addCriterion"><i class="fa-solid fa-plus"></i> Add Criterion</button>
|
|
212
|
+
</div>
|
|
213
|
+
<p v-if="!criteria.length" class="cpub-form-hint">Optional rubric shown to entrants and judges (e.g. Documentation — 20 pts).</p>
|
|
214
|
+
<div v-for="(crit, ci) in criteria" :key="ci" class="cpub-criterion-row">
|
|
215
|
+
<div class="cpub-form-row">
|
|
216
|
+
<div class="cpub-form-field" style="flex: 3">
|
|
217
|
+
<label class="cpub-form-label">Criterion</label>
|
|
218
|
+
<input v-model="crit.label" type="text" class="cpub-form-input" placeholder="e.g. Documentation" />
|
|
219
|
+
</div>
|
|
220
|
+
<div class="cpub-form-field" style="flex: 1">
|
|
221
|
+
<label class="cpub-form-label">Points</label>
|
|
222
|
+
<input v-model.number="crit.weight" type="number" min="0" max="100" class="cpub-form-input" placeholder="20" />
|
|
223
|
+
</div>
|
|
224
|
+
<button type="button" class="cpub-delete-btn cpub-criterion-del" aria-label="Remove criterion" @click="removeCriterion(ci)"><i class="fa-solid fa-xmark"></i></button>
|
|
225
|
+
</div>
|
|
226
|
+
<div class="cpub-form-field">
|
|
227
|
+
<input v-model="crit.description" type="text" class="cpub-form-input" placeholder="What judges look for (optional)" />
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
115
230
|
</section>
|
|
116
231
|
|
|
117
232
|
<!-- Prizes -->
|
|
@@ -123,15 +238,26 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
|
|
|
123
238
|
</button>
|
|
124
239
|
</div>
|
|
125
240
|
|
|
241
|
+
<p class="cpub-form-hint">Use <strong>place</strong> for ranked prizes (1st/2nd/3rd) or a <strong>category</strong> for themed awards (e.g. "Best in Show").</p>
|
|
126
242
|
<div v-for="(prize, idx) in prizes" :key="idx" class="cpub-prize-card">
|
|
127
243
|
<div class="cpub-prize-header">
|
|
128
|
-
<span class="cpub-prize-place"
|
|
129
|
-
<i class="fa-solid fa-trophy"></i> {{
|
|
244
|
+
<span class="cpub-prize-place">
|
|
245
|
+
<i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize, idx) }}
|
|
130
246
|
</span>
|
|
131
|
-
<button v-if="prizes.length > 1" type="button" class="cpub-delete-btn" @click="removePrize(idx)">
|
|
247
|
+
<button v-if="prizes.length > 1" type="button" class="cpub-delete-btn" aria-label="Remove prize" @click="removePrize(idx)">
|
|
132
248
|
<i class="fa-solid fa-xmark"></i>
|
|
133
249
|
</button>
|
|
134
250
|
</div>
|
|
251
|
+
<div class="cpub-form-row">
|
|
252
|
+
<div class="cpub-form-field" style="flex: 1">
|
|
253
|
+
<label class="cpub-form-label">Place</label>
|
|
254
|
+
<input v-model.number="prize.place" type="number" min="1" class="cpub-form-input" placeholder="1" />
|
|
255
|
+
</div>
|
|
256
|
+
<div class="cpub-form-field" style="flex: 2">
|
|
257
|
+
<label class="cpub-form-label">Category (optional)</label>
|
|
258
|
+
<input v-model="prize.category" type="text" class="cpub-form-input" placeholder="e.g. Best in Show" />
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
135
261
|
<div class="cpub-form-row">
|
|
136
262
|
<div class="cpub-form-field" style="flex: 2">
|
|
137
263
|
<label class="cpub-form-label">Title</label>
|
|
@@ -149,7 +275,7 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
|
|
|
149
275
|
</div>
|
|
150
276
|
</section>
|
|
151
277
|
|
|
152
|
-
<button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-lg" :disabled="saving || !title.trim() || !startDate || !endDate">
|
|
278
|
+
<button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-lg" :disabled="saving || !title.trim() || !startDate || !endDate || !!dateError">
|
|
153
279
|
<i class="fa-solid fa-trophy"></i> {{ saving ? 'Creating...' : 'Create Contest' }}
|
|
154
280
|
</button>
|
|
155
281
|
</form>
|
|
@@ -181,7 +307,20 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
|
|
|
181
307
|
|
|
182
308
|
.cpub-prize-card { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
|
|
183
309
|
.cpub-prize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
|
184
|
-
.cpub-prize-place { font-size: 12px; font-weight: 700; font-family: var(--font-mono); display: flex; align-items: center; gap: 6px; }
|
|
310
|
+
.cpub-prize-place { font-size: 12px; font-weight: 700; font-family: var(--font-mono); display: flex; align-items: center; gap: 6px; color: var(--accent); }
|
|
311
|
+
|
|
312
|
+
.cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
|
|
313
|
+
.cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; margin-top: 4px; }
|
|
314
|
+
.cpub-form-check input { width: 14px; height: 14px; }
|
|
315
|
+
.cpub-type-options { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 6px; }
|
|
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; }
|
|
317
|
+
.cpub-form-hint-inline { font-size: 10px; color: var(--accent); }
|
|
318
|
+
.cpub-form-hint { font-size: 11px; color: var(--text-faint); margin: 8px 0; line-height: 1.5; }
|
|
319
|
+
.cpub-criterion-row { border: var(--border-width-default) solid var(--border); padding: 12px; margin-bottom: 8px; background: var(--surface2); }
|
|
320
|
+
.cpub-criterion-row .cpub-form-row { align-items: flex-end; }
|
|
321
|
+
.cpub-criterion-del { align-self: flex-end; margin-bottom: 12px; }
|
|
322
|
+
.cpub-delete-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; }
|
|
323
|
+
.cpub-delete-btn:hover { color: var(--red); }
|
|
185
324
|
|
|
186
325
|
@media (max-width: 768px) {
|
|
187
326
|
.cpub-contest-create { padding: 16px; }
|
|
@@ -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
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listContestEntries, getContestBySlug } from '@commonpub/server';
|
|
1
|
+
import { listContestEntries, getContestBySlug, isContestJudge, shouldRevealScores } from '@commonpub/server';
|
|
2
2
|
import type { ContestEntryItem } from '@commonpub/server';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
@@ -15,9 +15,24 @@ export default defineEventHandler(async (event): Promise<{ items: ContestEntryIt
|
|
|
15
15
|
const query = parseQueryParams(event, entriesQuerySchema);
|
|
16
16
|
const contest = await getContestBySlug(db, slug);
|
|
17
17
|
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
18
|
+
|
|
19
|
+
// A privileged viewer is the contest owner, an admin, or a panel judge.
|
|
20
|
+
// Only privileged viewers may read per-judge scores + written feedback.
|
|
21
|
+
// Aggregate score visibility additionally honours the contest's
|
|
22
|
+
// judgingVisibility setting (public / judges-only / private).
|
|
23
|
+
const user = getOptionalUser(event);
|
|
24
|
+
let privileged = false;
|
|
25
|
+
if (user) {
|
|
26
|
+
privileged =
|
|
27
|
+
user.id === contest.createdById ||
|
|
28
|
+
user.role === 'admin' ||
|
|
29
|
+
(await isContestJudge(db, contest.id, user.id));
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
return listContestEntries(db, contest.id, {
|
|
19
33
|
limit: query.limit,
|
|
20
34
|
offset: query.offset,
|
|
21
|
-
includeJudgeScores: query.includeJudgeScores,
|
|
35
|
+
includeJudgeScores: privileged && query.includeJudgeScores,
|
|
36
|
+
revealScores: shouldRevealScores(contest.judgingVisibility, contest.status, privileged),
|
|
22
37
|
});
|
|
23
38
|
});
|
|
@@ -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,
|