@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
|
@@ -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,15 @@ 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)));
|
|
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
|
+
});
|
|
51
110
|
|
|
52
111
|
function copyLink(): void {
|
|
53
112
|
if (typeof window !== 'undefined' && window.navigator?.clipboard) {
|
|
@@ -60,10 +119,7 @@ async function submitEntry(): Promise<void> {
|
|
|
60
119
|
if (!submitContentId.value) return;
|
|
61
120
|
submitting.value = true;
|
|
62
121
|
try {
|
|
63
|
-
await $fetch(`/api/contests/${slug}/entries`, {
|
|
64
|
-
method: 'POST',
|
|
65
|
-
body: { contentId: submitContentId.value },
|
|
66
|
-
});
|
|
122
|
+
await $fetch(`/api/contests/${slug}/entries`, { method: 'POST', body: { contentId: submitContentId.value } });
|
|
67
123
|
showSubmitDialog.value = false;
|
|
68
124
|
submitContentId.value = '';
|
|
69
125
|
toast.success('Entry submitted!');
|
|
@@ -103,16 +159,27 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
103
159
|
<div class="cpub-submit-dialog" role="dialog" aria-label="Submit entry">
|
|
104
160
|
<div class="cpub-submit-header">
|
|
105
161
|
<h2>Submit Entry</h2>
|
|
106
|
-
<button class="cpub-submit-close" @click="showSubmitDialog = false"><i class="fa-solid fa-times"></i></button>
|
|
162
|
+
<button class="cpub-submit-close" aria-label="Close" @click="showSubmitDialog = false"><i class="fa-solid fa-times"></i></button>
|
|
107
163
|
</div>
|
|
108
164
|
<div class="cpub-submit-body">
|
|
109
|
-
<p class="cpub-submit-hint">
|
|
110
|
-
|
|
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>
|
|
169
|
+
<select v-model="submitContentId" class="cpub-submit-select" aria-label="Select a project to submit">
|
|
111
170
|
<option value="">Select a project...</option>
|
|
112
|
-
<option
|
|
113
|
-
|
|
171
|
+
<option
|
|
172
|
+
v-for="item in submittableContent"
|
|
173
|
+
:key="item.id"
|
|
174
|
+
:value="item.id"
|
|
175
|
+
:disabled="enteredContentIds.has(item.id)"
|
|
176
|
+
>
|
|
177
|
+
{{ item.title }} ({{ item.type }}){{ enteredContentIds.has(item.id) ? ' — already entered' : '' }}
|
|
114
178
|
</option>
|
|
115
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>
|
|
116
183
|
</div>
|
|
117
184
|
<div class="cpub-submit-footer">
|
|
118
185
|
<button class="cpub-btn cpub-btn-sm" @click="showSubmitDialog = false">Cancel</button>
|
|
@@ -126,32 +193,80 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
126
193
|
<!-- MAIN CONTENT -->
|
|
127
194
|
<div class="cpub-contest-main">
|
|
128
195
|
<div class="cpub-contest-layout">
|
|
129
|
-
<div>
|
|
130
|
-
<!--
|
|
131
|
-
<div class="cpub-
|
|
132
|
-
<div class="cpub-
|
|
133
|
-
<
|
|
196
|
+
<div class="cpub-contest-body">
|
|
197
|
+
<!-- Judge invite banner -->
|
|
198
|
+
<div v-if="pendingInvite" class="cpub-invite-banner">
|
|
199
|
+
<div class="cpub-invite-text">
|
|
200
|
+
<i class="fa-solid fa-gavel"></i>
|
|
201
|
+
<span>You've been invited to judge this contest.</span>
|
|
134
202
|
</div>
|
|
135
|
-
<
|
|
136
|
-
|
|
203
|
+
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="accepting" @click="acceptInvite">
|
|
204
|
+
{{ accepting ? 'Accepting...' : 'Accept invitation' }}
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<!-- Tab bar -->
|
|
209
|
+
<div class="cpub-tabbar" role="tablist" aria-label="Contest sections">
|
|
210
|
+
<button
|
|
211
|
+
v-for="tab in tabs"
|
|
212
|
+
:id="`cpub-tab-${tab.key}`"
|
|
213
|
+
:key="tab.key"
|
|
214
|
+
role="tab"
|
|
215
|
+
type="button"
|
|
216
|
+
class="cpub-tab"
|
|
217
|
+
:class="{ 'cpub-tab-active': activeTab === tab.key }"
|
|
218
|
+
:aria-selected="activeTab === tab.key"
|
|
219
|
+
:aria-controls="`cpub-panel-${tab.key}`"
|
|
220
|
+
:tabindex="activeTab === tab.key ? 0 : -1"
|
|
221
|
+
@click="activeTab = tab.key"
|
|
222
|
+
@keydown="onTabKey($event, tab.key)"
|
|
223
|
+
>
|
|
224
|
+
<i class="fa-solid" :class="tab.icon"></i> {{ tab.label }}
|
|
225
|
+
<span v-if="tab.count != null" class="cpub-tab-count">{{ tab.count }}</span>
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<!-- OVERVIEW -->
|
|
230
|
+
<div v-show="activeTab === 'overview'" id="cpub-panel-overview" role="tabpanel" aria-labelledby="cpub-tab-overview" tabindex="0">
|
|
231
|
+
<div class="cpub-about-section">
|
|
232
|
+
<div class="cpub-sec-head"><h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2></div>
|
|
233
|
+
<div class="cpub-about-card">
|
|
234
|
+
<p>{{ c?.description || 'No description available for this contest.' }}</p>
|
|
235
|
+
</div>
|
|
137
236
|
</div>
|
|
237
|
+
<ContestJudgingCriteria v-if="c?.judgingCriteria?.length" :criteria="c.judgingCriteria" />
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<!-- RULES -->
|
|
241
|
+
<div v-show="activeTab === 'rules'" id="cpub-panel-rules" role="tabpanel" aria-labelledby="cpub-tab-rules" tabindex="0">
|
|
242
|
+
<ContestRules v-if="c?.rules" :rules="c.rules" />
|
|
138
243
|
</div>
|
|
139
244
|
|
|
140
|
-
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
245
|
+
<!-- PRIZES -->
|
|
246
|
+
<div v-show="activeTab === 'prizes'" id="cpub-panel-prizes" role="tabpanel" aria-labelledby="cpub-tab-prizes" tabindex="0">
|
|
247
|
+
<ContestPrizes v-if="c?.prizes?.length" :prizes="c.prizes" />
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<!-- ENTRIES -->
|
|
251
|
+
<div v-show="activeTab === 'entries'" id="cpub-panel-entries" role="tabpanel" aria-labelledby="cpub-tab-entries" tabindex="0">
|
|
252
|
+
<ContestEntries
|
|
253
|
+
:entries="entries"
|
|
254
|
+
:contest-status="c?.status"
|
|
255
|
+
:contest-slug="slug"
|
|
256
|
+
:current-user-id="user?.id"
|
|
257
|
+
:community-voting-enabled="c?.communityVotingEnabled"
|
|
258
|
+
@withdraw="withdrawEntry"
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<!-- JUDGES -->
|
|
263
|
+
<div v-show="activeTab === 'judges'" id="cpub-panel-judges" role="tabpanel" aria-labelledby="cpub-tab-judges" tabindex="0">
|
|
264
|
+
<ContestJudges :judges="judges" />
|
|
265
|
+
<ContestJudgeManager v-if="isOwner && c" :contest-slug="slug" :is-owner="isOwner" @changed="refreshJudges" />
|
|
266
|
+
</div>
|
|
152
267
|
</div>
|
|
153
268
|
|
|
154
|
-
<ContestSidebar :contest="c" :is-owner="isOwner" :
|
|
269
|
+
<ContestSidebar :contest="c" :is-owner="isOwner" :can-judge="canJudge" @copy-link="copyLink" />
|
|
155
270
|
</div>
|
|
156
271
|
</div>
|
|
157
272
|
</div>
|
|
@@ -173,6 +288,23 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
173
288
|
/* LAYOUT */
|
|
174
289
|
.cpub-contest-main { max-width: 1100px; margin: 0 auto; padding: 32px; }
|
|
175
290
|
.cpub-contest-layout { display: grid; grid-template-columns: 1fr 300px; gap: 28px; align-items: start; }
|
|
291
|
+
.cpub-contest-body { min-width: 0; }
|
|
292
|
+
|
|
293
|
+
/* INVITE BANNER */
|
|
294
|
+
.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); }
|
|
295
|
+
.cpub-invite-text { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--text); }
|
|
296
|
+
.cpub-invite-text i { color: var(--accent); }
|
|
297
|
+
|
|
298
|
+
/* TABS */
|
|
299
|
+
.cpub-tabbar { display: flex; gap: 2px; flex-wrap: wrap; border-bottom: var(--border-width-default) solid var(--border); margin-bottom: 20px; }
|
|
300
|
+
.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; }
|
|
301
|
+
.cpub-tab:hover { color: var(--text-dim); }
|
|
302
|
+
.cpub-tab-active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
303
|
+
.cpub-tab i { font-size: 11px; }
|
|
304
|
+
.cpub-tab-count { font-size: 9px; padding: 1px 6px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); }
|
|
305
|
+
.cpub-tab-active .cpub-tab-count { background: var(--accent-bg); border-color: var(--accent-border); color: var(--accent); }
|
|
306
|
+
|
|
307
|
+
[role="tabpanel"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
|
176
308
|
|
|
177
309
|
/* SECTION HEADERS */
|
|
178
310
|
.cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
|
@@ -181,7 +313,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
181
313
|
/* ABOUT */
|
|
182
314
|
.cpub-about-section { margin-bottom: 20px; }
|
|
183
315
|
.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; }
|
|
316
|
+
.cpub-about-card p { margin: 0; white-space: pre-line; }
|
|
185
317
|
|
|
186
318
|
@media (max-width: 768px) {
|
|
187
319
|
.cpub-contest-main { padding: 20px 16px; }
|
|
@@ -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.';
|
|
@@ -83,7 +105,9 @@ async function submitScore(entryId: string): Promise<void> {
|
|
|
83
105
|
},
|
|
84
106
|
});
|
|
85
107
|
success.value = 'Score submitted for entry.';
|
|
86
|
-
await refreshEntries()
|
|
108
|
+
await refreshEntries().catch(() => {
|
|
109
|
+
success.value = 'Score saved — refresh to see the updated totals.';
|
|
110
|
+
});
|
|
87
111
|
} catch (err: unknown) {
|
|
88
112
|
error.value = (err as { data?: { message?: string } })?.data?.message || 'Failed to submit score.';
|
|
89
113
|
} finally {
|
|
@@ -106,18 +130,45 @@ async function submitScore(entryId: string): Promise<void> {
|
|
|
106
130
|
</header>
|
|
107
131
|
|
|
108
132
|
<!-- Loading -->
|
|
109
|
-
<div v-if="!contest" class="cpub-judge-empty">
|
|
133
|
+
<div v-if="!contest || !judgesData" class="cpub-judge-empty">
|
|
110
134
|
<p>Loading...</p>
|
|
111
135
|
</div>
|
|
112
136
|
|
|
113
|
-
<!--
|
|
114
|
-
<div v-else-if="!
|
|
137
|
+
<!-- Not a judge -->
|
|
138
|
+
<div v-else-if="!myJudge" class="cpub-judge-unauthorized">
|
|
115
139
|
<i class="fa-solid fa-lock"></i>
|
|
116
140
|
<p>You are not a judge for this contest.</p>
|
|
117
141
|
<NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-btn-sm">Back to Contest</NuxtLink>
|
|
118
142
|
</div>
|
|
119
143
|
|
|
144
|
+
<!-- Pending invitation -->
|
|
145
|
+
<div v-else-if="pendingInvite" class="cpub-judge-unauthorized">
|
|
146
|
+
<i class="fa-solid fa-envelope-open-text"></i>
|
|
147
|
+
<p>You've been invited to judge this contest. Accept to begin scoring.</p>
|
|
148
|
+
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="accepting" @click="acceptInvite">
|
|
149
|
+
{{ accepting ? 'Accepting...' : 'Accept invitation' }}
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<!-- Guest judge (view-only) -->
|
|
154
|
+
<div v-else-if="isGuest" class="cpub-judge-unauthorized">
|
|
155
|
+
<i class="fa-solid fa-eye"></i>
|
|
156
|
+
<p>You are a guest judge and can view entries but cannot submit scores.</p>
|
|
157
|
+
<NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-btn-sm">Back to Contest</NuxtLink>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
120
160
|
<template v-else>
|
|
161
|
+
<!-- Judging not open yet -->
|
|
162
|
+
<div v-if="!inJudgingPhase" class="cpub-judge-notice" role="status">
|
|
163
|
+
<i class="fa-solid fa-circle-info"></i>
|
|
164
|
+
Scoring opens when the contest enters the judging phase (currently <strong>{{ contest.status }}</strong>).
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<!-- Rubric guidance -->
|
|
168
|
+
<div v-if="contest.judgingCriteria?.length" class="cpub-judge-rubric">
|
|
169
|
+
<ContestJudgingCriteria :criteria="contest.judgingCriteria" compact />
|
|
170
|
+
</div>
|
|
171
|
+
|
|
121
172
|
<!-- Progress bar -->
|
|
122
173
|
<div v-if="totalCount > 0" class="cpub-judge-progress">
|
|
123
174
|
<div class="cpub-judge-progress-label">
|
|
@@ -162,7 +213,7 @@ async function submitScore(entryId: string): Promise<void> {
|
|
|
162
213
|
/>
|
|
163
214
|
<button
|
|
164
215
|
class="cpub-judge-score-btn"
|
|
165
|
-
:disabled="submitting === entry.id"
|
|
216
|
+
:disabled="submitting === entry.id || !inJudgingPhase"
|
|
166
217
|
@click="submitScore(entry.id)"
|
|
167
218
|
>
|
|
168
219
|
{{ submitting === entry.id ? '...' : entry.myScore !== null ? 'Update' : 'Score' }}
|
|
@@ -195,6 +246,10 @@ async function submitScore(entryId: string): Promise<void> {
|
|
|
195
246
|
.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
247
|
.cpub-judge-unauthorized i { font-size: 24px; }
|
|
197
248
|
|
|
249
|
+
.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); }
|
|
250
|
+
.cpub-judge-notice i { color: var(--accent); }
|
|
251
|
+
.cpub-judge-rubric { margin-bottom: 20px; }
|
|
252
|
+
|
|
198
253
|
.cpub-judge-progress { margin-bottom: 20px; }
|
|
199
254
|
.cpub-judge-progress-label { font-size: 12px; color: var(--text-dim); font-family: var(--font-mono); margin-bottom: 6px; }
|
|
200
255
|
.cpub-judge-progress-bar { height: 6px; background: var(--surface2); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
@@ -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));
|
|
@@ -23,7 +44,7 @@ const leaderboard = computed(() => rankedEntries.value);
|
|
|
23
44
|
const prizes = computed(() => contest.value?.prizes ?? []);
|
|
24
45
|
|
|
25
46
|
function prizeForRank(rank: number): { title: string; value?: string } | null {
|
|
26
|
-
const prize = prizes.value.find((p: { place
|
|
47
|
+
const prize = prizes.value.find((p: { place?: number; title: string; value?: string }) => p.place === rank);
|
|
27
48
|
return prize ?? null;
|
|
28
49
|
}
|
|
29
50
|
|
|
@@ -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; }
|