@commonpub/layer 0.25.1 → 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 +8 -8
- 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
|
@@ -4,10 +4,12 @@ definePageMeta({ middleware: 'auth' });
|
|
|
4
4
|
const route = useRoute();
|
|
5
5
|
const slug = route.params.slug as string;
|
|
6
6
|
const { user } = useAuth();
|
|
7
|
+
const toast = useToast();
|
|
7
8
|
|
|
8
|
-
import type { Serialized, ContestDetail, ContestEntryItem } from '@commonpub/server';
|
|
9
|
+
import type { Serialized, ContestDetail, ContestEntryItem, ContestJudgeItem } from '@commonpub/server';
|
|
9
10
|
|
|
10
11
|
const { data: contest } = useLazyFetch<Serialized<ContestDetail>>(`/api/contests/${slug}`);
|
|
12
|
+
const { data: judgesData, refresh: refreshJudges } = useLazyFetch<ContestJudgeItem[]>(`/api/contests/${slug}/judges`);
|
|
11
13
|
const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: (Serialized<ContestEntryItem> & { judgeScores?: Array<{ judgeId: string; score: number; feedback?: string }> })[]; total: number }>(
|
|
12
14
|
`/api/contests/${slug}/entries`,
|
|
13
15
|
{ query: { includeJudgeScores: true } },
|
|
@@ -15,10 +17,26 @@ const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: (Se
|
|
|
15
17
|
|
|
16
18
|
useSeoMeta({ title: () => `Judge: ${contest.value?.title || 'Contest'} — ${useSiteName()}` });
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// Judge authorization derives from the contest_judges table.
|
|
21
|
+
const myJudge = computed(() => (judgesData.value ?? []).find((j) => j.userId === user.value?.id) ?? null);
|
|
22
|
+
const pendingInvite = computed(() => !!myJudge.value && !myJudge.value.acceptedAt);
|
|
23
|
+
const isGuest = computed(() => myJudge.value?.role === 'guest');
|
|
24
|
+
const canScore = computed(() => !!myJudge.value && !!myJudge.value.acceptedAt && !isGuest.value);
|
|
25
|
+
const inJudgingPhase = computed(() => contest.value?.status === 'judging');
|
|
26
|
+
|
|
27
|
+
const accepting = ref(false);
|
|
28
|
+
async function acceptInvite(): Promise<void> {
|
|
29
|
+
accepting.value = true;
|
|
30
|
+
try {
|
|
31
|
+
await $fetch(`/api/contests/${slug}/judges/accept`, { method: 'POST' });
|
|
32
|
+
toast.success('Invitation accepted');
|
|
33
|
+
await refreshJudges();
|
|
34
|
+
} catch {
|
|
35
|
+
toast.error('Failed to accept invitation');
|
|
36
|
+
} finally {
|
|
37
|
+
accepting.value = false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
22
40
|
|
|
23
41
|
const entryList = computed(() => {
|
|
24
42
|
const items = entriesData.value?.items ?? [];
|
|
@@ -63,6 +81,10 @@ watch(entryList, (list) => {
|
|
|
63
81
|
}, { immediate: true });
|
|
64
82
|
|
|
65
83
|
async function submitScore(entryId: string): Promise<void> {
|
|
84
|
+
if (!inJudgingPhase.value) {
|
|
85
|
+
error.value = 'Scoring is only open during the judging phase.';
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
66
88
|
const score = scoring.value[entryId];
|
|
67
89
|
if (score === undefined || score < 1 || score > 100) {
|
|
68
90
|
error.value = 'Score must be between 1 and 100.';
|
|
@@ -106,18 +128,45 @@ async function submitScore(entryId: string): Promise<void> {
|
|
|
106
128
|
</header>
|
|
107
129
|
|
|
108
130
|
<!-- Loading -->
|
|
109
|
-
<div v-if="!contest" class="cpub-judge-empty">
|
|
131
|
+
<div v-if="!contest || !judgesData" class="cpub-judge-empty">
|
|
110
132
|
<p>Loading...</p>
|
|
111
133
|
</div>
|
|
112
134
|
|
|
113
|
-
<!--
|
|
114
|
-
<div v-else-if="!
|
|
135
|
+
<!-- Not a judge -->
|
|
136
|
+
<div v-else-if="!myJudge" class="cpub-judge-unauthorized">
|
|
115
137
|
<i class="fa-solid fa-lock"></i>
|
|
116
138
|
<p>You are not a judge for this contest.</p>
|
|
117
139
|
<NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-btn-sm">Back to Contest</NuxtLink>
|
|
118
140
|
</div>
|
|
119
141
|
|
|
142
|
+
<!-- Pending invitation -->
|
|
143
|
+
<div v-else-if="pendingInvite" class="cpub-judge-unauthorized">
|
|
144
|
+
<i class="fa-solid fa-envelope-open-text"></i>
|
|
145
|
+
<p>You've been invited to judge this contest. Accept to begin scoring.</p>
|
|
146
|
+
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="accepting" @click="acceptInvite">
|
|
147
|
+
{{ accepting ? 'Accepting...' : 'Accept invitation' }}
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<!-- Guest judge (view-only) -->
|
|
152
|
+
<div v-else-if="isGuest" class="cpub-judge-unauthorized">
|
|
153
|
+
<i class="fa-solid fa-eye"></i>
|
|
154
|
+
<p>You are a guest judge and can view entries but cannot submit scores.</p>
|
|
155
|
+
<NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-btn-sm">Back to Contest</NuxtLink>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
120
158
|
<template v-else>
|
|
159
|
+
<!-- Judging not open yet -->
|
|
160
|
+
<div v-if="!inJudgingPhase" class="cpub-judge-notice" role="status">
|
|
161
|
+
<i class="fa-solid fa-circle-info"></i>
|
|
162
|
+
Scoring opens when the contest enters the judging phase (currently <strong>{{ contest.status }}</strong>).
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<!-- Rubric guidance -->
|
|
166
|
+
<div v-if="contest.judgingCriteria?.length" class="cpub-judge-rubric">
|
|
167
|
+
<ContestJudgingCriteria :criteria="contest.judgingCriteria" compact />
|
|
168
|
+
</div>
|
|
169
|
+
|
|
121
170
|
<!-- Progress bar -->
|
|
122
171
|
<div v-if="totalCount > 0" class="cpub-judge-progress">
|
|
123
172
|
<div class="cpub-judge-progress-label">
|
|
@@ -162,7 +211,7 @@ async function submitScore(entryId: string): Promise<void> {
|
|
|
162
211
|
/>
|
|
163
212
|
<button
|
|
164
213
|
class="cpub-judge-score-btn"
|
|
165
|
-
:disabled="submitting === entry.id"
|
|
214
|
+
:disabled="submitting === entry.id || !inJudgingPhase"
|
|
166
215
|
@click="submitScore(entry.id)"
|
|
167
216
|
>
|
|
168
217
|
{{ submitting === entry.id ? '...' : entry.myScore !== null ? 'Update' : 'Score' }}
|
|
@@ -195,6 +244,10 @@ async function submitScore(entryId: string): Promise<void> {
|
|
|
195
244
|
.cpub-judge-unauthorized { text-align: center; padding: 48px 0; color: var(--text-faint); font-size: 13px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
|
196
245
|
.cpub-judge-unauthorized i { font-size: 24px; }
|
|
197
246
|
|
|
247
|
+
.cpub-judge-notice { display: flex; align-items: center; gap: 8px; padding: 10px 14px; margin-bottom: 16px; font-size: 12px; color: var(--text-dim); background: var(--surface2); border: var(--border-width-default) solid var(--border); }
|
|
248
|
+
.cpub-judge-notice i { color: var(--accent); }
|
|
249
|
+
.cpub-judge-rubric { margin-bottom: 20px; }
|
|
250
|
+
|
|
198
251
|
.cpub-judge-progress { margin-bottom: 20px; }
|
|
199
252
|
.cpub-judge-progress-label { font-size: 12px; color: var(--text-dim); font-family: var(--font-mono); margin-bottom: 6px; }
|
|
200
253
|
.cpub-judge-progress-bar { height: 6px; background: var(--surface2); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
@@ -23,7 +23,7 @@ const leaderboard = computed(() => rankedEntries.value);
|
|
|
23
23
|
const prizes = computed(() => contest.value?.prizes ?? []);
|
|
24
24
|
|
|
25
25
|
function prizeForRank(rank: number): { title: string; value?: string } | null {
|
|
26
|
-
const prize = prizes.value.find((p: { place
|
|
26
|
+
const prize = prizes.value.find((p: { place?: number; title: string; value?: string }) => p.place === rank);
|
|
27
27
|
return prize ?? null;
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -10,41 +10,60 @@ 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');
|
|
16
19
|
|
|
17
20
|
// Prizes
|
|
18
21
|
interface Prize {
|
|
19
|
-
place: number;
|
|
22
|
+
place: number | null;
|
|
23
|
+
category: string;
|
|
20
24
|
title: string;
|
|
21
25
|
description: string;
|
|
22
26
|
value: string;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
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: '' },
|
|
30
|
+
{ place: 1, category: '', title: '1st Place', description: '', value: '' },
|
|
31
|
+
{ place: 2, category: '', title: '2nd Place', description: '', value: '' },
|
|
32
|
+
{ place: 3, category: '', title: '3rd Place', description: '', value: '' },
|
|
29
33
|
]);
|
|
30
34
|
|
|
31
35
|
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
|
-
});
|
|
36
|
+
prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
function removePrize(index: number): void {
|
|
41
40
|
prizes.value.splice(index, 1);
|
|
42
|
-
// Renumber
|
|
43
|
-
prizes.value.forEach((p, i) => { p.place = i + 1; });
|
|
44
41
|
}
|
|
45
42
|
|
|
43
|
+
// Judging criteria (rubric)
|
|
44
|
+
interface Criterion { label: string; weight: number | null; description: string }
|
|
45
|
+
const criteria = ref<Criterion[]>([]);
|
|
46
|
+
function addCriterion(): void {
|
|
47
|
+
criteria.value.push({ label: '', weight: null, description: '' });
|
|
48
|
+
}
|
|
49
|
+
function removeCriterion(index: number): void {
|
|
50
|
+
criteria.value.splice(index, 1);
|
|
51
|
+
}
|
|
52
|
+
const criteriaTotal = computed(() => criteria.value.reduce((s, c) => s + (c.weight ?? 0), 0));
|
|
53
|
+
|
|
54
|
+
const dateError = computed(() => {
|
|
55
|
+
if (startDate.value && endDate.value && new Date(endDate.value) <= new Date(startDate.value)) {
|
|
56
|
+
return 'End date must be after the start date.';
|
|
57
|
+
}
|
|
58
|
+
if (judgingEndDate.value && endDate.value && new Date(judgingEndDate.value) < new Date(endDate.value)) {
|
|
59
|
+
return 'Judging end date must be on or after the end date.';
|
|
60
|
+
}
|
|
61
|
+
return '';
|
|
62
|
+
});
|
|
63
|
+
|
|
46
64
|
async function handleCreate(): Promise<void> {
|
|
47
65
|
if (!title.value.trim() || !startDate.value || !endDate.value) return;
|
|
66
|
+
if (dateError.value) { toast.error(dateError.value); return; }
|
|
48
67
|
saving.value = true;
|
|
49
68
|
try {
|
|
50
69
|
const result = await $fetch<{ slug: string }>('/api/contests', {
|
|
@@ -53,10 +72,28 @@ async function handleCreate(): Promise<void> {
|
|
|
53
72
|
title: title.value,
|
|
54
73
|
description: description.value || undefined,
|
|
55
74
|
rules: rules.value || undefined,
|
|
75
|
+
bannerUrl: bannerUrl.value || undefined,
|
|
56
76
|
startDate: new Date(startDate.value).toISOString(),
|
|
57
77
|
endDate: new Date(endDate.value).toISOString(),
|
|
58
78
|
judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
|
|
59
|
-
|
|
79
|
+
communityVotingEnabled: communityVotingEnabled.value,
|
|
80
|
+
judgingVisibility: judgingVisibility.value,
|
|
81
|
+
prizes: prizes.value
|
|
82
|
+
.filter(p => p.title.trim())
|
|
83
|
+
.map(p => ({
|
|
84
|
+
place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
|
|
85
|
+
category: p.category.trim() || undefined,
|
|
86
|
+
title: p.title,
|
|
87
|
+
description: p.description || undefined,
|
|
88
|
+
value: p.value || undefined,
|
|
89
|
+
})),
|
|
90
|
+
judgingCriteria: criteria.value
|
|
91
|
+
.filter(c => c.label.trim())
|
|
92
|
+
.map(c => ({
|
|
93
|
+
label: c.label.trim(),
|
|
94
|
+
weight: typeof c.weight === 'number' && Number.isFinite(c.weight) ? c.weight : undefined,
|
|
95
|
+
description: c.description.trim() || undefined,
|
|
96
|
+
})),
|
|
60
97
|
},
|
|
61
98
|
});
|
|
62
99
|
toast.success('Contest created!');
|
|
@@ -68,8 +105,11 @@ async function handleCreate(): Promise<void> {
|
|
|
68
105
|
}
|
|
69
106
|
}
|
|
70
107
|
|
|
71
|
-
|
|
72
|
-
|
|
108
|
+
function prizeLabel(prize: Prize, idx: number): string {
|
|
109
|
+
if (prize.category.trim()) return prize.category;
|
|
110
|
+
const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
|
|
111
|
+
return `${labels[idx] || `${idx + 1}th`} Place`;
|
|
112
|
+
}
|
|
73
113
|
</script>
|
|
74
114
|
|
|
75
115
|
<template>
|
|
@@ -91,7 +131,11 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
|
|
|
91
131
|
</div>
|
|
92
132
|
<div class="cpub-form-field">
|
|
93
133
|
<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..." />
|
|
134
|
+
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="4" placeholder="Contest rules and requirements (one per line)..." />
|
|
135
|
+
</div>
|
|
136
|
+
<div class="cpub-form-field">
|
|
137
|
+
<label for="contest-banner" class="cpub-form-label">Banner Image URL</label>
|
|
138
|
+
<input id="contest-banner" v-model="bannerUrl" type="url" class="cpub-form-input" placeholder="https://..." />
|
|
95
139
|
</div>
|
|
96
140
|
</section>
|
|
97
141
|
|
|
@@ -112,6 +156,46 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
|
|
|
112
156
|
<input id="judging-date" v-model="judgingEndDate" type="datetime-local" class="cpub-form-input" />
|
|
113
157
|
</div>
|
|
114
158
|
</div>
|
|
159
|
+
<p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
|
|
160
|
+
</section>
|
|
161
|
+
|
|
162
|
+
<!-- Judging -->
|
|
163
|
+
<section class="cpub-form-section">
|
|
164
|
+
<h2 class="cpub-form-section-title">Judging</h2>
|
|
165
|
+
<div class="cpub-form-field">
|
|
166
|
+
<label for="judging-visibility" class="cpub-form-label">Score Visibility</label>
|
|
167
|
+
<select id="judging-visibility" v-model="judgingVisibility" class="cpub-form-input">
|
|
168
|
+
<option value="judges-only">Judges only — scores hidden until results</option>
|
|
169
|
+
<option value="public">Public — show scores during judging</option>
|
|
170
|
+
<option value="private">Private — scores stay with organizers</option>
|
|
171
|
+
</select>
|
|
172
|
+
</div>
|
|
173
|
+
<label class="cpub-form-check">
|
|
174
|
+
<input v-model="communityVotingEnabled" type="checkbox" />
|
|
175
|
+
<span>Enable community voting (let signed-in members upvote entries)</span>
|
|
176
|
+
</label>
|
|
177
|
+
|
|
178
|
+
<div class="cpub-form-section-header" style="margin-top: 16px;">
|
|
179
|
+
<h3 class="cpub-form-subtitle">Judging Criteria <span v-if="criteriaTotal" class="cpub-form-hint-inline">{{ criteriaTotal }} pts</span></h3>
|
|
180
|
+
<button type="button" class="cpub-btn cpub-btn-sm" @click="addCriterion"><i class="fa-solid fa-plus"></i> Add Criterion</button>
|
|
181
|
+
</div>
|
|
182
|
+
<p v-if="!criteria.length" class="cpub-form-hint">Optional rubric shown to entrants and judges (e.g. Documentation — 20 pts).</p>
|
|
183
|
+
<div v-for="(crit, ci) in criteria" :key="ci" class="cpub-criterion-row">
|
|
184
|
+
<div class="cpub-form-row">
|
|
185
|
+
<div class="cpub-form-field" style="flex: 3">
|
|
186
|
+
<label class="cpub-form-label">Criterion</label>
|
|
187
|
+
<input v-model="crit.label" type="text" class="cpub-form-input" placeholder="e.g. Documentation" />
|
|
188
|
+
</div>
|
|
189
|
+
<div class="cpub-form-field" style="flex: 1">
|
|
190
|
+
<label class="cpub-form-label">Points</label>
|
|
191
|
+
<input v-model.number="crit.weight" type="number" min="0" max="100" class="cpub-form-input" placeholder="20" />
|
|
192
|
+
</div>
|
|
193
|
+
<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>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="cpub-form-field">
|
|
196
|
+
<input v-model="crit.description" type="text" class="cpub-form-input" placeholder="What judges look for (optional)" />
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
115
199
|
</section>
|
|
116
200
|
|
|
117
201
|
<!-- Prizes -->
|
|
@@ -123,15 +207,26 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
|
|
|
123
207
|
</button>
|
|
124
208
|
</div>
|
|
125
209
|
|
|
210
|
+
<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
211
|
<div v-for="(prize, idx) in prizes" :key="idx" class="cpub-prize-card">
|
|
127
212
|
<div class="cpub-prize-header">
|
|
128
|
-
<span class="cpub-prize-place"
|
|
129
|
-
<i class="fa-solid fa-trophy"></i> {{
|
|
213
|
+
<span class="cpub-prize-place">
|
|
214
|
+
<i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize, idx) }}
|
|
130
215
|
</span>
|
|
131
|
-
<button v-if="prizes.length > 1" type="button" class="cpub-delete-btn" @click="removePrize(idx)">
|
|
216
|
+
<button v-if="prizes.length > 1" type="button" class="cpub-delete-btn" aria-label="Remove prize" @click="removePrize(idx)">
|
|
132
217
|
<i class="fa-solid fa-xmark"></i>
|
|
133
218
|
</button>
|
|
134
219
|
</div>
|
|
220
|
+
<div class="cpub-form-row">
|
|
221
|
+
<div class="cpub-form-field" style="flex: 1">
|
|
222
|
+
<label class="cpub-form-label">Place</label>
|
|
223
|
+
<input v-model.number="prize.place" type="number" min="1" class="cpub-form-input" placeholder="1" />
|
|
224
|
+
</div>
|
|
225
|
+
<div class="cpub-form-field" style="flex: 2">
|
|
226
|
+
<label class="cpub-form-label">Category (optional)</label>
|
|
227
|
+
<input v-model="prize.category" type="text" class="cpub-form-input" placeholder="e.g. Best in Show" />
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
135
230
|
<div class="cpub-form-row">
|
|
136
231
|
<div class="cpub-form-field" style="flex: 2">
|
|
137
232
|
<label class="cpub-form-label">Title</label>
|
|
@@ -149,7 +244,7 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
|
|
|
149
244
|
</div>
|
|
150
245
|
</section>
|
|
151
246
|
|
|
152
|
-
<button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-lg" :disabled="saving || !title.trim() || !startDate || !endDate">
|
|
247
|
+
<button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-lg" :disabled="saving || !title.trim() || !startDate || !endDate || !!dateError">
|
|
153
248
|
<i class="fa-solid fa-trophy"></i> {{ saving ? 'Creating...' : 'Create Contest' }}
|
|
154
249
|
</button>
|
|
155
250
|
</form>
|
|
@@ -181,7 +276,19 @@ const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--acc
|
|
|
181
276
|
|
|
182
277
|
.cpub-prize-card { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
|
|
183
278
|
.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; }
|
|
279
|
+
.cpub-prize-place { font-size: 12px; font-weight: 700; font-family: var(--font-mono); display: flex; align-items: center; gap: 6px; color: var(--accent); }
|
|
280
|
+
|
|
281
|
+
.cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
|
|
282
|
+
.cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; margin-top: 4px; }
|
|
283
|
+
.cpub-form-check input { width: 14px; height: 14px; }
|
|
284
|
+
.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
|
+
.cpub-form-hint-inline { font-size: 10px; color: var(--accent); }
|
|
286
|
+
.cpub-form-hint { font-size: 11px; color: var(--text-faint); margin: 8px 0; line-height: 1.5; }
|
|
287
|
+
.cpub-criterion-row { border: var(--border-width-default) solid var(--border); padding: 12px; margin-bottom: 8px; background: var(--surface2); }
|
|
288
|
+
.cpub-criterion-row .cpub-form-row { align-items: flex-end; }
|
|
289
|
+
.cpub-criterion-del { align-self: flex-end; margin-bottom: 12px; }
|
|
290
|
+
.cpub-delete-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; }
|
|
291
|
+
.cpub-delete-btn:hover { color: var(--red); }
|
|
185
292
|
|
|
186
293
|
@media (max-width: 768px) {
|
|
187
294
|
.cpub-contest-create { padding: 16px; }
|
|
@@ -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
|
});
|