@commonpub/layer 0.25.0 → 0.26.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 +5 -1
- 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 +6 -6
- package/pages/contests/[slug]/edit.vue +131 -102
- package/pages/contests/[slug]/index.vue +155 -37
- package/pages/contests/[slug]/judge.vue +62 -9
- package/pages/contests/[slug]/results.vue +1 -1
- package/pages/contests/create.vue +128 -21
- package/server/api/contests/[slug]/entries.get.ts +17 -2
|
@@ -15,96 +15,95 @@ const saving = ref(false);
|
|
|
15
15
|
const title = ref('');
|
|
16
16
|
const description = ref('');
|
|
17
17
|
const rules = ref('');
|
|
18
|
+
const bannerUrl = ref('');
|
|
18
19
|
const startDate = ref('');
|
|
19
20
|
const endDate = ref('');
|
|
20
21
|
const judgingEndDate = ref('');
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const judgeSearch = ref('');
|
|
24
|
-
const judgeSearchResults = ref<Array<{ id: string; username: string; displayName: string | null }>>([]);
|
|
25
|
-
const searchingJudges = ref(false);
|
|
22
|
+
const communityVotingEnabled = ref(false);
|
|
23
|
+
const judgingVisibility = ref<'public' | 'judges-only' | 'private'>('judges-only');
|
|
26
24
|
|
|
27
|
-
interface
|
|
28
|
-
const
|
|
25
|
+
interface Prize { place: number | null; category: string; title: string; description: string; value: string }
|
|
26
|
+
const prizes = ref<Prize[]>([]);
|
|
27
|
+
|
|
28
|
+
interface Criterion { label: string; weight: number | null; description: string }
|
|
29
|
+
const criteria = ref<Criterion[]>([]);
|
|
29
30
|
|
|
30
31
|
// Load current data
|
|
31
|
-
watch(contest,
|
|
32
|
+
watch(contest, (c) => {
|
|
32
33
|
if (!c) return;
|
|
33
34
|
title.value = c.title ?? '';
|
|
34
35
|
description.value = c.description ?? '';
|
|
35
36
|
rules.value = c.rules ?? '';
|
|
37
|
+
bannerUrl.value = c.bannerUrl ?? '';
|
|
36
38
|
startDate.value = c.startDate ? new Date(c.startDate).toISOString().slice(0, 16) : '';
|
|
37
39
|
endDate.value = c.endDate ? new Date(c.endDate).toISOString().slice(0, 16) : '';
|
|
38
40
|
judgingEndDate.value = c.judgingEndDate ? new Date(c.judgingEndDate).toISOString().slice(0, 16) : '';
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
communityVotingEnabled.value = !!c.communityVotingEnabled;
|
|
42
|
+
judgingVisibility.value = (c.judgingVisibility as typeof judgingVisibility.value) ?? 'judges-only';
|
|
43
|
+
prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title: string; description?: string; value?: string }) => ({
|
|
44
|
+
place: p.place ?? null,
|
|
45
|
+
category: p.category ?? '',
|
|
41
46
|
title: p.title,
|
|
42
47
|
description: p.description ?? '',
|
|
43
48
|
value: p.value ?? '',
|
|
44
49
|
}));
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
resolvedJudges.value = data.items;
|
|
51
|
-
} catch { /* ignore */ }
|
|
52
|
-
}
|
|
50
|
+
criteria.value = (c.judgingCriteria ?? []).map((cr: { label: string; weight?: number; description?: string }) => ({
|
|
51
|
+
label: cr.label,
|
|
52
|
+
weight: cr.weight ?? null,
|
|
53
|
+
description: cr.description ?? '',
|
|
54
|
+
}));
|
|
53
55
|
}, { immediate: true });
|
|
54
56
|
|
|
55
|
-
async function searchJudge(): Promise<void> {
|
|
56
|
-
const q = judgeSearch.value.trim();
|
|
57
|
-
if (!q || q.length < 2) { judgeSearchResults.value = []; return; }
|
|
58
|
-
searchingJudges.value = true;
|
|
59
|
-
try {
|
|
60
|
-
const data = await $fetch<{ items: Array<{ id: string; username: string; displayName: string | null }> }>('/api/users', { query: { q, limit: 5 } });
|
|
61
|
-
judgeSearchResults.value = data.items.filter((u) => !judgeIds.value.includes(u.id));
|
|
62
|
-
} catch { judgeSearchResults.value = []; }
|
|
63
|
-
finally { searchingJudges.value = false; }
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function addJudge(u: { id: string; username: string; displayName: string | null }): void {
|
|
67
|
-
if (!judgeIds.value.includes(u.id)) {
|
|
68
|
-
judgeIds.value.push(u.id);
|
|
69
|
-
resolvedJudges.value.push(u);
|
|
70
|
-
}
|
|
71
|
-
judgeSearch.value = '';
|
|
72
|
-
judgeSearchResults.value = [];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function removeJudge(id: string): void {
|
|
76
|
-
judgeIds.value = judgeIds.value.filter((jid) => jid !== id);
|
|
77
|
-
resolvedJudges.value = resolvedJudges.value.filter((j) => j.id !== id);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
57
|
function addPrize(): void {
|
|
81
|
-
|
|
82
|
-
prizes.value.push({ place: nextPlace, title: '', description: '', value: '' });
|
|
58
|
+
prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
|
|
83
59
|
}
|
|
84
|
-
|
|
85
60
|
function removePrize(index: number): void {
|
|
86
61
|
prizes.value.splice(index, 1);
|
|
87
|
-
|
|
62
|
+
}
|
|
63
|
+
function prizeLabel(prize: Prize, idx: number): string {
|
|
64
|
+
if (prize.category.trim()) return prize.category;
|
|
65
|
+
const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
|
|
66
|
+
return prize.place ? `${labels[prize.place - 1] || `${prize.place}th`} Place` : `${labels[idx] || `${idx + 1}th`} Place`;
|
|
88
67
|
}
|
|
89
68
|
|
|
90
|
-
function
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
69
|
+
function addCriterion(): void {
|
|
70
|
+
criteria.value.push({ label: '', weight: null, description: '' });
|
|
71
|
+
}
|
|
72
|
+
function removeCriterion(index: number): void {
|
|
73
|
+
criteria.value.splice(index, 1);
|
|
95
74
|
}
|
|
75
|
+
const criteriaTotal = computed(() => criteria.value.reduce((s, c) => s + (c.weight ?? 0), 0));
|
|
76
|
+
|
|
77
|
+
const dateError = computed(() => {
|
|
78
|
+
if (startDate.value && endDate.value && new Date(endDate.value) <= new Date(startDate.value)) {
|
|
79
|
+
return 'End date must be after the start date.';
|
|
80
|
+
}
|
|
81
|
+
if (judgingEndDate.value && endDate.value && new Date(judgingEndDate.value) < new Date(endDate.value)) {
|
|
82
|
+
return 'Judging end date must be on or after the end date.';
|
|
83
|
+
}
|
|
84
|
+
return '';
|
|
85
|
+
});
|
|
96
86
|
|
|
97
87
|
async function handleSave(): Promise<void> {
|
|
88
|
+
if (dateError.value) { toast.error(dateError.value); return; }
|
|
98
89
|
saving.value = true;
|
|
99
90
|
try {
|
|
100
91
|
const prizeData = prizes.value
|
|
101
92
|
.filter((p) => p.title.trim())
|
|
102
93
|
.map((p) => ({
|
|
103
|
-
place: p.place,
|
|
94
|
+
place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
|
|
95
|
+
category: p.category.trim() || undefined,
|
|
104
96
|
title: p.title,
|
|
105
97
|
description: p.description || undefined,
|
|
106
98
|
value: p.value || undefined,
|
|
107
99
|
}));
|
|
100
|
+
const criteriaData = criteria.value
|
|
101
|
+
.filter((c) => c.label.trim())
|
|
102
|
+
.map((c) => ({
|
|
103
|
+
label: c.label.trim(),
|
|
104
|
+
weight: typeof c.weight === 'number' && Number.isFinite(c.weight) ? c.weight : undefined,
|
|
105
|
+
description: c.description.trim() || undefined,
|
|
106
|
+
}));
|
|
108
107
|
|
|
109
108
|
await $fetch(`/api/contests/${slug}`, {
|
|
110
109
|
method: 'PUT',
|
|
@@ -112,11 +111,14 @@ async function handleSave(): Promise<void> {
|
|
|
112
111
|
title: title.value,
|
|
113
112
|
description: description.value || undefined,
|
|
114
113
|
rules: rules.value || undefined,
|
|
114
|
+
bannerUrl: bannerUrl.value || undefined,
|
|
115
115
|
startDate: startDate.value ? new Date(startDate.value).toISOString() : undefined,
|
|
116
116
|
endDate: endDate.value ? new Date(endDate.value).toISOString() : undefined,
|
|
117
117
|
judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
communityVotingEnabled: communityVotingEnabled.value,
|
|
119
|
+
judgingVisibility: judgingVisibility.value,
|
|
120
|
+
prizes: prizeData,
|
|
121
|
+
judgingCriteria: criteriaData,
|
|
120
122
|
},
|
|
121
123
|
});
|
|
122
124
|
toast.success('Contest updated');
|
|
@@ -134,10 +136,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
134
136
|
: `Change contest status to "${newStatus}"?`;
|
|
135
137
|
if (!confirm(msg)) return;
|
|
136
138
|
try {
|
|
137
|
-
await $fetch(`/api/contests/${slug}/transition`, {
|
|
138
|
-
method: 'POST',
|
|
139
|
-
body: { status: newStatus },
|
|
140
|
-
});
|
|
139
|
+
await $fetch(`/api/contests/${slug}/transition`, { method: 'POST', body: { status: newStatus } });
|
|
141
140
|
toast.success(`Status changed to ${newStatus}`);
|
|
142
141
|
await refresh();
|
|
143
142
|
} catch (err: unknown) {
|
|
@@ -173,6 +172,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
173
172
|
<label class="cpub-form-label">Rules</label>
|
|
174
173
|
<textarea v-model="rules" class="cpub-form-textarea" rows="4" placeholder="One rule per line" />
|
|
175
174
|
</div>
|
|
175
|
+
<div class="cpub-form-field">
|
|
176
|
+
<label class="cpub-form-label">Banner Image URL</label>
|
|
177
|
+
<input v-model="bannerUrl" type="url" class="cpub-form-input" placeholder="https://..." />
|
|
178
|
+
</div>
|
|
176
179
|
</section>
|
|
177
180
|
|
|
178
181
|
<section class="cpub-form-section">
|
|
@@ -191,14 +194,25 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
191
194
|
<label class="cpub-form-label">Judging End Date</label>
|
|
192
195
|
<input v-model="judgingEndDate" type="datetime-local" class="cpub-form-input" />
|
|
193
196
|
</div>
|
|
197
|
+
<p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
|
|
194
198
|
</section>
|
|
195
199
|
|
|
196
200
|
<section class="cpub-form-section">
|
|
197
201
|
<h2 class="cpub-form-section-title">Prizes</h2>
|
|
198
202
|
<div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
|
|
199
203
|
<div class="cpub-prize-header">
|
|
200
|
-
<span class="cpub-prize-label">{{
|
|
201
|
-
<button type="button" class="cpub-prize-remove" @click="removePrize(i)"><i class="fa-solid fa-times"></i></button>
|
|
204
|
+
<span class="cpub-prize-label">{{ prizeLabel(prize, i) }}</span>
|
|
205
|
+
<button type="button" class="cpub-prize-remove" aria-label="Remove prize" @click="removePrize(i)"><i class="fa-solid fa-times"></i></button>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="cpub-form-row">
|
|
208
|
+
<div class="cpub-form-field">
|
|
209
|
+
<label class="cpub-form-label">Place</label>
|
|
210
|
+
<input v-model.number="prize.place" type="number" min="1" class="cpub-form-input" placeholder="1" />
|
|
211
|
+
</div>
|
|
212
|
+
<div class="cpub-form-field">
|
|
213
|
+
<label class="cpub-form-label">Category (optional)</label>
|
|
214
|
+
<input v-model="prize.category" type="text" class="cpub-form-input" placeholder="e.g. Best in Show" />
|
|
215
|
+
</div>
|
|
202
216
|
</div>
|
|
203
217
|
<div class="cpub-form-row">
|
|
204
218
|
<div class="cpub-form-field">
|
|
@@ -219,37 +233,49 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
219
233
|
</section>
|
|
220
234
|
|
|
221
235
|
<section class="cpub-form-section">
|
|
222
|
-
<h2 class="cpub-form-section-title">
|
|
223
|
-
<div
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
<
|
|
227
|
-
|
|
236
|
+
<h2 class="cpub-form-section-title">Judging</h2>
|
|
237
|
+
<div class="cpub-form-field">
|
|
238
|
+
<label class="cpub-form-label">Score Visibility</label>
|
|
239
|
+
<select v-model="judgingVisibility" class="cpub-form-input">
|
|
240
|
+
<option value="judges-only">Judges only — scores hidden until results</option>
|
|
241
|
+
<option value="public">Public — show scores during judging</option>
|
|
242
|
+
<option value="private">Private — scores stay with organizers</option>
|
|
243
|
+
</select>
|
|
244
|
+
</div>
|
|
245
|
+
<label class="cpub-form-check">
|
|
246
|
+
<input v-model="communityVotingEnabled" type="checkbox" />
|
|
247
|
+
<span>Enable community voting</span>
|
|
248
|
+
</label>
|
|
249
|
+
|
|
250
|
+
<div class="cpub-subhead">
|
|
251
|
+
<h3 class="cpub-form-subtitle">Judging Criteria <span v-if="criteriaTotal" class="cpub-form-hint-inline">{{ criteriaTotal }} pts</span></h3>
|
|
252
|
+
<button type="button" class="cpub-btn cpub-btn-sm" @click="addCriterion"><i class="fa-solid fa-plus"></i> Add Criterion</button>
|
|
228
253
|
</div>
|
|
229
|
-
<div v-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
<button
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
class="cpub-judge-option"
|
|
244
|
-
@click="addJudge(u)"
|
|
245
|
-
>
|
|
246
|
-
<strong>{{ u.displayName || u.username }}</strong>
|
|
247
|
-
<span class="cpub-judge-option-username">@{{ u.username }}</span>
|
|
248
|
-
</button>
|
|
254
|
+
<div v-for="(crit, ci) in criteria" :key="ci" class="cpub-criterion-row">
|
|
255
|
+
<div class="cpub-form-row">
|
|
256
|
+
<div class="cpub-form-field" style="flex: 3">
|
|
257
|
+
<label class="cpub-form-label">Criterion</label>
|
|
258
|
+
<input v-model="crit.label" type="text" class="cpub-form-input" placeholder="e.g. Documentation" />
|
|
259
|
+
</div>
|
|
260
|
+
<div class="cpub-form-field" style="flex: 1">
|
|
261
|
+
<label class="cpub-form-label">Points</label>
|
|
262
|
+
<input v-model.number="crit.weight" type="number" min="0" max="100" class="cpub-form-input" placeholder="20" />
|
|
263
|
+
</div>
|
|
264
|
+
<button type="button" class="cpub-prize-remove cpub-criterion-del" aria-label="Remove criterion" @click="removeCriterion(ci)"><i class="fa-solid fa-times"></i></button>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="cpub-form-field">
|
|
267
|
+
<input v-model="crit.description" type="text" class="cpub-form-input" placeholder="What judges look for (optional)" />
|
|
249
268
|
</div>
|
|
250
269
|
</div>
|
|
251
270
|
</section>
|
|
252
271
|
|
|
272
|
+
<!-- Judge panel (single source of truth: contest_judges table) -->
|
|
273
|
+
<section class="cpub-form-section">
|
|
274
|
+
<h2 class="cpub-form-section-title">Judges</h2>
|
|
275
|
+
<p class="cpub-form-hint">Invited judges receive a notification and must accept before they can score.</p>
|
|
276
|
+
<ContestJudgeManager :contest-slug="slug" :is-owner="true" />
|
|
277
|
+
</section>
|
|
278
|
+
|
|
253
279
|
<section class="cpub-form-section">
|
|
254
280
|
<h2 class="cpub-form-section-title">Status Transitions</h2>
|
|
255
281
|
<div class="cpub-status-actions">
|
|
@@ -273,7 +299,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
273
299
|
</div>
|
|
274
300
|
</section>
|
|
275
301
|
|
|
276
|
-
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim()">
|
|
302
|
+
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
|
|
277
303
|
<i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving...' : 'Save Changes' }}
|
|
278
304
|
</button>
|
|
279
305
|
</form>
|
|
@@ -306,11 +332,21 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
306
332
|
.cpub-form-textarea { resize: vertical; }
|
|
307
333
|
.cpub-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
308
334
|
|
|
309
|
-
.cpub-
|
|
335
|
+
.cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
|
|
336
|
+
.cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
|
337
|
+
.cpub-form-check input { width: 14px; height: 14px; }
|
|
338
|
+
.cpub-subhead { display: flex; align-items: center; justify-content: space-between; margin: 18px 0 10px; }
|
|
339
|
+
.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
|
+
.cpub-form-hint-inline { font-size: 10px; color: var(--accent); }
|
|
341
|
+
.cpub-form-hint { font-size: 11px; color: var(--text-faint); margin: 0 0 12px; line-height: 1.5; }
|
|
342
|
+
|
|
343
|
+
.cpub-prize-row, .cpub-criterion-row { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
|
|
310
344
|
.cpub-prize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
|
311
|
-
.cpub-prize-label { font-size: 11px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
345
|
+
.cpub-prize-label { font-size: 11px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent); }
|
|
312
346
|
.cpub-prize-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; }
|
|
313
347
|
.cpub-prize-remove:hover { color: var(--red); }
|
|
348
|
+
.cpub-criterion-row .cpub-form-row { align-items: flex-end; }
|
|
349
|
+
.cpub-criterion-del { align-self: flex-end; margin-bottom: 12px; }
|
|
314
350
|
|
|
315
351
|
.cpub-status-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
316
352
|
.cpub-transition-btn { display: inline-flex; align-items: center; gap: 6px; }
|
|
@@ -319,17 +355,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
319
355
|
.cpub-transition-complete { color: var(--accent); border-color: var(--accent-border); }
|
|
320
356
|
.cpub-transition-cancel { color: var(--red); border-color: var(--red-border); }
|
|
321
357
|
|
|
322
|
-
.cpub-judge-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
|
323
|
-
.cpub-judge-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); font-size: 11px; font-family: var(--font-mono); color: var(--accent); }
|
|
324
|
-
.cpub-judge-chip-remove { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 10px; padding: 0; }
|
|
325
|
-
.cpub-judge-chip-remove:hover { color: var(--red); }
|
|
326
|
-
.cpub-judge-empty { font-size: 12px; color: var(--text-faint); margin-bottom: 12px; }
|
|
327
|
-
.cpub-judge-search { position: relative; }
|
|
328
|
-
.cpub-judge-dropdown { position: absolute; z-index: 10; top: 100%; left: 0; right: 0; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-lg); max-height: 200px; overflow-y: auto; }
|
|
329
|
-
.cpub-judge-option { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 12px; background: none; border: none; border-bottom: var(--border-width-default) solid var(--border); cursor: pointer; font-size: 12px; color: var(--text); text-align: left; }
|
|
330
|
-
.cpub-judge-option:last-child { border-bottom: none; }
|
|
331
|
-
.cpub-judge-option:hover { background: var(--surface2); }
|
|
332
|
-
.cpub-judge-option-username { color: var(--text-faint); font-family: var(--font-mono); font-size: 11px; }
|
|
333
|
-
|
|
334
358
|
.cpub-not-found { text-align: center; padding: 64px; color: var(--text-dim); display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
|
359
|
+
|
|
360
|
+
@media (max-width: 768px) {
|
|
361
|
+
.cpub-contest-edit { padding: 16px; }
|
|
362
|
+
.cpub-form-row { grid-template-columns: 1fr; }
|
|
363
|
+
}
|
|
335
364
|
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { Serialized, ContestEntryItem } from '@commonpub/server';
|
|
2
|
+
import type { Serialized, ContestEntryItem, ContestJudgeItem } from '@commonpub/server';
|
|
3
3
|
|
|
4
4
|
const route = useRoute();
|
|
5
5
|
const slug = route.params.slug as string;
|
|
@@ -8,29 +8,64 @@ const { isAuthenticated, isAdmin, user } = useAuth();
|
|
|
8
8
|
|
|
9
9
|
const { data: contest } = useLazyFetch(`/api/contests/${slug}`);
|
|
10
10
|
const { data: apiEntriesData, refresh: refreshEntries } = useLazyFetch<{ items: Serialized<ContestEntryItem>[]; total: number }>(`/api/contests/${slug}/entries`);
|
|
11
|
+
const { data: judgesData, refresh: refreshJudges } = useLazyFetch<ContestJudgeItem[]>(`/api/contests/${slug}/judges`);
|
|
11
12
|
|
|
12
13
|
useSeoMeta({
|
|
13
14
|
title: () => `${contest.value?.title || 'Contest'} — ${useSiteName()}`,
|
|
14
15
|
ogTitle: () => `${contest.value?.title || 'Contest'} — ${useSiteName()}`,
|
|
15
|
-
ogImage: '/og-default.png',
|
|
16
|
+
ogImage: () => contest.value?.bannerUrl || '/og-default.png',
|
|
16
17
|
});
|
|
17
18
|
|
|
18
19
|
const c = computed(() => contest.value);
|
|
19
20
|
const entries = computed(() => apiEntriesData.value?.items ?? []);
|
|
21
|
+
const judges = computed<ContestJudgeItem[]>(() => judgesData.value ?? []);
|
|
20
22
|
const isOwner = computed(() => isAdmin.value || !!(user.value?.id && c.value?.createdById === user.value.id));
|
|
21
|
-
|
|
23
|
+
|
|
24
|
+
// Judge state derives entirely from the contest_judges table (single source of
|
|
25
|
+
// truth) — not the legacy `judges` jsonb column.
|
|
26
|
+
const myJudge = computed(() => judges.value.find((j) => j.userId === user.value?.id) ?? null);
|
|
27
|
+
const pendingInvite = computed(() => !!myJudge.value && !myJudge.value.acceptedAt);
|
|
28
|
+
const canJudge = computed(() => !!myJudge.value && !!myJudge.value.acceptedAt && myJudge.value.role !== 'guest');
|
|
29
|
+
|
|
30
|
+
// Tabs ----------------------------------------------------------------------
|
|
31
|
+
interface Tab { key: string; label: string; icon: string; count?: number }
|
|
32
|
+
const tabs = computed<Tab[]>(() => {
|
|
33
|
+
const t: Tab[] = [{ key: 'overview', label: 'Overview', icon: 'fa-circle-info' }];
|
|
34
|
+
if (c.value?.rules) t.push({ key: 'rules', label: 'Rules', icon: 'fa-file-lines' });
|
|
35
|
+
if (c.value?.prizes?.length) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
|
|
36
|
+
t.push({ key: 'entries', label: 'Entries', icon: 'fa-box-open', count: c.value?.entryCount ?? entries.value.length });
|
|
37
|
+
if (judges.value.length || isOwner.value) t.push({ key: 'judges', label: 'Judges', icon: 'fa-gavel', count: judges.value.length || undefined });
|
|
38
|
+
return t;
|
|
39
|
+
});
|
|
40
|
+
const activeTab = ref('overview');
|
|
41
|
+
watch(tabs, (list) => {
|
|
42
|
+
if (!list.some((t) => t.key === activeTab.value)) activeTab.value = 'overview';
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// WAI-ARIA tabs keyboard pattern (arrow keys + Home/End, roving focus).
|
|
46
|
+
function focusTab(key: string): void {
|
|
47
|
+
activeTab.value = key;
|
|
48
|
+
nextTick(() => {
|
|
49
|
+
if (typeof document !== 'undefined') document.getElementById(`cpub-tab-${key}`)?.focus();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function onTabKey(e: KeyboardEvent, key: string): void {
|
|
53
|
+
const keys = tabs.value.map((t) => t.key);
|
|
54
|
+
const i = keys.indexOf(key);
|
|
55
|
+
if (i < 0) return;
|
|
56
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); focusTab(keys[(i + 1) % keys.length]!); }
|
|
57
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); focusTab(keys[(i - 1 + keys.length) % keys.length]!); }
|
|
58
|
+
else if (e.key === 'Home') { e.preventDefault(); focusTab(keys[0]!); }
|
|
59
|
+
else if (e.key === 'End') { e.preventDefault(); focusTab(keys[keys.length - 1]!); }
|
|
60
|
+
}
|
|
22
61
|
|
|
23
62
|
// Admin contest management
|
|
24
63
|
const transitioning = ref(false);
|
|
25
|
-
|
|
26
64
|
async function transitionStatus(newStatus: string): Promise<void> {
|
|
27
65
|
if (newStatus === 'cancelled' && !confirm('Cancel this contest? This cannot be undone.')) return;
|
|
28
66
|
transitioning.value = true;
|
|
29
67
|
try {
|
|
30
|
-
await $fetch(`/api/contests/${slug}/transition`, {
|
|
31
|
-
method: 'POST',
|
|
32
|
-
body: { status: newStatus },
|
|
33
|
-
});
|
|
68
|
+
await $fetch(`/api/contests/${slug}/transition`, { method: 'POST', body: { status: newStatus } });
|
|
34
69
|
toast.success(`Contest ${newStatus}`);
|
|
35
70
|
refreshNuxtData();
|
|
36
71
|
} catch {
|
|
@@ -40,6 +75,21 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
40
75
|
}
|
|
41
76
|
}
|
|
42
77
|
|
|
78
|
+
// Judge invite acceptance
|
|
79
|
+
const accepting = ref(false);
|
|
80
|
+
async function acceptInvite(): Promise<void> {
|
|
81
|
+
accepting.value = true;
|
|
82
|
+
try {
|
|
83
|
+
await $fetch(`/api/contests/${slug}/judges/accept`, { method: 'POST' });
|
|
84
|
+
toast.success('You are now a judge for this contest');
|
|
85
|
+
await refreshJudges();
|
|
86
|
+
} catch {
|
|
87
|
+
toast.error('Failed to accept invitation');
|
|
88
|
+
} finally {
|
|
89
|
+
accepting.value = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
43
93
|
// Entry submission
|
|
44
94
|
const showSubmitDialog = ref(false);
|
|
45
95
|
const submitContentId = ref('');
|
|
@@ -48,6 +98,7 @@ const { data: userContent } = useFetch('/api/content', {
|
|
|
48
98
|
query: { status: 'published', limit: 50 },
|
|
49
99
|
immediate: isAuthenticated.value,
|
|
50
100
|
});
|
|
101
|
+
const enteredContentIds = computed(() => new Set(entries.value.map((e) => e.contentId)));
|
|
51
102
|
|
|
52
103
|
function copyLink(): void {
|
|
53
104
|
if (typeof window !== 'undefined' && window.navigator?.clipboard) {
|
|
@@ -60,10 +111,7 @@ async function submitEntry(): Promise<void> {
|
|
|
60
111
|
if (!submitContentId.value) return;
|
|
61
112
|
submitting.value = true;
|
|
62
113
|
try {
|
|
63
|
-
await $fetch(`/api/contests/${slug}/entries`, {
|
|
64
|
-
method: 'POST',
|
|
65
|
-
body: { contentId: submitContentId.value },
|
|
66
|
-
});
|
|
114
|
+
await $fetch(`/api/contests/${slug}/entries`, { method: 'POST', body: { contentId: submitContentId.value } });
|
|
67
115
|
showSubmitDialog.value = false;
|
|
68
116
|
submitContentId.value = '';
|
|
69
117
|
toast.success('Entry submitted!');
|
|
@@ -103,14 +151,19 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
103
151
|
<div class="cpub-submit-dialog" role="dialog" aria-label="Submit entry">
|
|
104
152
|
<div class="cpub-submit-header">
|
|
105
153
|
<h2>Submit Entry</h2>
|
|
106
|
-
<button class="cpub-submit-close" @click="showSubmitDialog = false"><i class="fa-solid fa-times"></i></button>
|
|
154
|
+
<button class="cpub-submit-close" aria-label="Close" @click="showSubmitDialog = false"><i class="fa-solid fa-times"></i></button>
|
|
107
155
|
</div>
|
|
108
156
|
<div class="cpub-submit-body">
|
|
109
157
|
<p class="cpub-submit-hint">Select one of your published projects to submit as an entry.</p>
|
|
110
|
-
<select v-model="submitContentId" class="cpub-submit-select">
|
|
158
|
+
<select v-model="submitContentId" class="cpub-submit-select" aria-label="Select a project to submit">
|
|
111
159
|
<option value="">Select a project...</option>
|
|
112
|
-
<option
|
|
113
|
-
|
|
160
|
+
<option
|
|
161
|
+
v-for="item in (userContent?.items ?? [])"
|
|
162
|
+
:key="item.id"
|
|
163
|
+
:value="item.id"
|
|
164
|
+
:disabled="enteredContentIds.has(item.id)"
|
|
165
|
+
>
|
|
166
|
+
{{ item.title }} ({{ item.type }}){{ enteredContentIds.has(item.id) ? ' — already entered' : '' }}
|
|
114
167
|
</option>
|
|
115
168
|
</select>
|
|
116
169
|
</div>
|
|
@@ -126,32 +179,80 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
126
179
|
<!-- MAIN CONTENT -->
|
|
127
180
|
<div class="cpub-contest-main">
|
|
128
181
|
<div class="cpub-contest-layout">
|
|
129
|
-
<div>
|
|
130
|
-
<!--
|
|
131
|
-
<div class="cpub-
|
|
132
|
-
<div class="cpub-
|
|
133
|
-
<
|
|
182
|
+
<div class="cpub-contest-body">
|
|
183
|
+
<!-- Judge invite banner -->
|
|
184
|
+
<div v-if="pendingInvite" class="cpub-invite-banner">
|
|
185
|
+
<div class="cpub-invite-text">
|
|
186
|
+
<i class="fa-solid fa-gavel"></i>
|
|
187
|
+
<span>You've been invited to judge this contest.</span>
|
|
134
188
|
</div>
|
|
135
|
-
<
|
|
136
|
-
|
|
189
|
+
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="accepting" @click="acceptInvite">
|
|
190
|
+
{{ accepting ? 'Accepting...' : 'Accept invitation' }}
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<!-- Tab bar -->
|
|
195
|
+
<div class="cpub-tabbar" role="tablist" aria-label="Contest sections">
|
|
196
|
+
<button
|
|
197
|
+
v-for="tab in tabs"
|
|
198
|
+
:id="`cpub-tab-${tab.key}`"
|
|
199
|
+
:key="tab.key"
|
|
200
|
+
role="tab"
|
|
201
|
+
type="button"
|
|
202
|
+
class="cpub-tab"
|
|
203
|
+
:class="{ 'cpub-tab-active': activeTab === tab.key }"
|
|
204
|
+
:aria-selected="activeTab === tab.key"
|
|
205
|
+
:aria-controls="`cpub-panel-${tab.key}`"
|
|
206
|
+
:tabindex="activeTab === tab.key ? 0 : -1"
|
|
207
|
+
@click="activeTab = tab.key"
|
|
208
|
+
@keydown="onTabKey($event, tab.key)"
|
|
209
|
+
>
|
|
210
|
+
<i class="fa-solid" :class="tab.icon"></i> {{ tab.label }}
|
|
211
|
+
<span v-if="tab.count != null" class="cpub-tab-count">{{ tab.count }}</span>
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<!-- OVERVIEW -->
|
|
216
|
+
<div v-show="activeTab === 'overview'" id="cpub-panel-overview" role="tabpanel" aria-labelledby="cpub-tab-overview" tabindex="0">
|
|
217
|
+
<div class="cpub-about-section">
|
|
218
|
+
<div class="cpub-sec-head"><h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2></div>
|
|
219
|
+
<div class="cpub-about-card">
|
|
220
|
+
<p>{{ c?.description || 'No description available for this contest.' }}</p>
|
|
221
|
+
</div>
|
|
137
222
|
</div>
|
|
223
|
+
<ContestJudgingCriteria v-if="c?.judgingCriteria?.length" :criteria="c.judgingCriteria" />
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<!-- RULES -->
|
|
227
|
+
<div v-show="activeTab === 'rules'" id="cpub-panel-rules" role="tabpanel" aria-labelledby="cpub-tab-rules" tabindex="0">
|
|
228
|
+
<ContestRules v-if="c?.rules" :rules="c.rules" />
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<!-- PRIZES -->
|
|
232
|
+
<div v-show="activeTab === 'prizes'" id="cpub-panel-prizes" role="tabpanel" aria-labelledby="cpub-tab-prizes" tabindex="0">
|
|
233
|
+
<ContestPrizes v-if="c?.prizes?.length" :prizes="c.prizes" />
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<!-- ENTRIES -->
|
|
237
|
+
<div v-show="activeTab === 'entries'" id="cpub-panel-entries" role="tabpanel" aria-labelledby="cpub-tab-entries" tabindex="0">
|
|
238
|
+
<ContestEntries
|
|
239
|
+
:entries="entries"
|
|
240
|
+
:contest-status="c?.status"
|
|
241
|
+
:contest-slug="slug"
|
|
242
|
+
:current-user-id="user?.id"
|
|
243
|
+
:community-voting-enabled="c?.communityVotingEnabled"
|
|
244
|
+
@withdraw="withdrawEntry"
|
|
245
|
+
/>
|
|
138
246
|
</div>
|
|
139
247
|
|
|
140
|
-
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
:entries="entries"
|
|
146
|
-
:contest-status="c?.status"
|
|
147
|
-
:contest-slug="slug"
|
|
148
|
-
:current-user-id="user?.id"
|
|
149
|
-
:community-voting-enabled="c?.communityVotingEnabled"
|
|
150
|
-
@withdraw="withdrawEntry"
|
|
151
|
-
/>
|
|
248
|
+
<!-- JUDGES -->
|
|
249
|
+
<div v-show="activeTab === 'judges'" id="cpub-panel-judges" role="tabpanel" aria-labelledby="cpub-tab-judges" tabindex="0">
|
|
250
|
+
<ContestJudges :judges="judges" />
|
|
251
|
+
<ContestJudgeManager v-if="isOwner && c" :contest-slug="slug" :is-owner="isOwner" @changed="refreshJudges" />
|
|
252
|
+
</div>
|
|
152
253
|
</div>
|
|
153
254
|
|
|
154
|
-
<ContestSidebar :contest="c" :is-owner="isOwner" :
|
|
255
|
+
<ContestSidebar :contest="c" :is-owner="isOwner" :can-judge="canJudge" @copy-link="copyLink" />
|
|
155
256
|
</div>
|
|
156
257
|
</div>
|
|
157
258
|
</div>
|
|
@@ -173,6 +274,23 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
173
274
|
/* LAYOUT */
|
|
174
275
|
.cpub-contest-main { max-width: 1100px; margin: 0 auto; padding: 32px; }
|
|
175
276
|
.cpub-contest-layout { display: grid; grid-template-columns: 1fr 300px; gap: 28px; align-items: start; }
|
|
277
|
+
.cpub-contest-body { min-width: 0; }
|
|
278
|
+
|
|
279
|
+
/* INVITE BANNER */
|
|
280
|
+
.cpub-invite-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 12px 16px; margin-bottom: 18px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
|
|
281
|
+
.cpub-invite-text { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--text); }
|
|
282
|
+
.cpub-invite-text i { color: var(--accent); }
|
|
283
|
+
|
|
284
|
+
/* TABS */
|
|
285
|
+
.cpub-tabbar { display: flex; gap: 2px; flex-wrap: wrap; border-bottom: var(--border-width-default) solid var(--border); margin-bottom: 20px; }
|
|
286
|
+
.cpub-tab { display: inline-flex; align-items: center; gap: 6px; padding: 9px 14px; background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-faint); cursor: pointer; }
|
|
287
|
+
.cpub-tab:hover { color: var(--text-dim); }
|
|
288
|
+
.cpub-tab-active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
289
|
+
.cpub-tab i { font-size: 11px; }
|
|
290
|
+
.cpub-tab-count { font-size: 9px; padding: 1px 6px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); }
|
|
291
|
+
.cpub-tab-active .cpub-tab-count { background: var(--accent-bg); border-color: var(--accent-border); color: var(--accent); }
|
|
292
|
+
|
|
293
|
+
[role="tabpanel"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
|
176
294
|
|
|
177
295
|
/* SECTION HEADERS */
|
|
178
296
|
.cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
|
@@ -181,7 +299,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
181
299
|
/* ABOUT */
|
|
182
300
|
.cpub-about-section { margin-bottom: 20px; }
|
|
183
301
|
.cpub-about-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-md); font-size: 12px; color: var(--text-dim); line-height: 1.7; }
|
|
184
|
-
.cpub-about-card p { margin: 0; }
|
|
302
|
+
.cpub-about-card p { margin: 0; white-space: pre-line; }
|
|
185
303
|
|
|
186
304
|
@media (max-width: 768px) {
|
|
187
305
|
.cpub-contest-main { padding: 20px 16px; }
|