@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.
@@ -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 prizes = ref<Array<{ place: number; title: string; description: string; value: string }>>([]);
22
- const judgeIds = ref<string[]>([]);
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 JudgeDisplay { id: string; username: string; displayName: string | null }
28
- const resolvedJudges = ref<JudgeDisplay[]>([]);
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, async (c) => {
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
- prizes.value = (c.prizes ?? []).map((p: { place: number; title: string; description?: string; value?: string }) => ({
40
- place: p.place,
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
- judgeIds.value = [...(c.judges ?? [])];
46
- // Resolve judge IDs to display info
47
- if (judgeIds.value.length > 0) {
48
- try {
49
- const data = await $fetch<{ items: JudgeDisplay[] }>('/api/users', { query: { ids: judgeIds.value.join(',') } });
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
- const nextPlace = prizes.value.length + 1;
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
- prizes.value.forEach((p, i) => { p.place = i + 1; });
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 placeLabel(place: number): string {
91
- if (place === 1) return '1st Place';
92
- if (place === 2) return '2nd Place';
93
- if (place === 3) return '3rd Place';
94
- return `${place}th Place`;
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
- prizes: prizeData.length > 0 ? prizeData : undefined,
119
- judges: judgeIds.value.length > 0 ? judgeIds.value : undefined,
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">{{ placeLabel(prize.place) }}</span>
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">Judges</h2>
223
- <div v-if="resolvedJudges.length" class="cpub-judge-list">
224
- <div v-for="judge in resolvedJudges" :key="judge.id" class="cpub-judge-chip">
225
- <span>{{ judge.displayName || judge.username }}</span>
226
- <button type="button" class="cpub-judge-chip-remove" @click="removeJudge(judge.id)"><i class="fa-solid fa-times"></i></button>
227
- </div>
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-else class="cpub-judge-empty">No judges assigned.</div>
230
- <div class="cpub-judge-search">
231
- <input
232
- v-model="judgeSearch"
233
- type="text"
234
- class="cpub-form-input"
235
- placeholder="Search users by name or username..."
236
- @input="searchJudge"
237
- />
238
- <div v-if="judgeSearchResults.length" class="cpub-judge-dropdown">
239
- <button
240
- v-for="u in judgeSearchResults"
241
- :key="u.id"
242
- type="button"
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-prize-row { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
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
- const isJudge = computed(() => !!(user.value?.id && ((c.value?.judges ?? []) as string[]).includes(user.value.id)));
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 v-for="item in (userContent?.items ?? [])" :key="item.id" :value="item.id">
113
- {{ item.title }} ({{ item.type }})
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
- <!-- ABOUT -->
131
- <div class="cpub-about-section">
132
- <div class="cpub-sec-head">
133
- <h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2>
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
- <div class="cpub-about-card">
136
- <p>{{ c?.description || 'No description available for this contest.' }}</p>
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
- <ContestRules v-if="c?.rules" :rules="c.rules" />
141
- <ContestPrizes v-if="c?.prizes?.length" :prizes="c.prizes" />
142
- <ContestJudges v-if="c?.judges?.length" :judge-ids="c.judges" />
143
- <ContestJudgeManager v-if="isOwner && c" :contest-slug="slug" :is-owner="isOwner" />
144
- <ContestEntries
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" :is-judge="isJudge" @copy-link="copyLink" />
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; }