@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.
@@ -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
- const isJudge = computed(() => {
19
- if (!contest.value || !user.value) return false;
20
- return ((contest.value.judges ?? []) as string[]).includes(user.value.id);
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
- <!-- Auth guard -->
114
- <div v-else-if="!isJudge" class="cpub-judge-unauthorized">
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: number; title: string; value?: string }) => p.place === rank);
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
- prizes: prizes.value.filter(p => p.title.trim()),
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
- const placeLabels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
72
- const placeColors = ['var(--gold)', 'var(--silver)', 'var(--bronze)', 'var(--accent)', 'var(--accent)', 'var(--accent)'];
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" :style="{ color: placeColors[idx] || 'var(--accent)' }">
129
- <i class="fa-solid fa-trophy"></i> {{ placeLabels[idx] || `${idx + 1}th` }} Place
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
  });