@commonpub/layer 0.28.0 → 0.29.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.
@@ -176,9 +176,9 @@ function confirmWithdraw(entryId: string): void {
176
176
  .cpub-entry-score { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); display: flex; align-items: center; gap: 3px; }
177
177
 
178
178
  .cpub-entry-vote-btn {
179
- display: inline-flex; align-items: center; gap: 4px;
179
+ display: inline-flex; align-items: center; justify-content: center; gap: 4px;
180
180
  font-size: 11px; font-family: var(--font-mono); font-weight: 600;
181
- padding: 3px 8px; border: var(--border-width-default) solid var(--border2);
181
+ padding: 4px 10px; min-height: 28px; border: var(--border-width-default) solid var(--border2);
182
182
  background: var(--surface); color: var(--text-dim); cursor: pointer;
183
183
  transition: all 0.15s;
184
184
  }
@@ -187,7 +187,7 @@ function confirmWithdraw(entryId: string): void {
187
187
  .cpub-entry-vote-btn:disabled { opacity: 0.4; cursor: default; }
188
188
  .cpub-entry-vote-btn i { font-size: 10px; }
189
189
 
190
- .cpub-withdraw-btn { display: flex; align-items: center; gap: 4px; font-size: 10px; font-family: var(--font-mono); padding: 3px 8px; border-radius: var(--radius); border: var(--border-width-default) solid var(--red-border); background: var(--surface); color: var(--red); cursor: pointer; margin-left: auto; }
190
+ .cpub-withdraw-btn { display: flex; align-items: center; justify-content: center; gap: 4px; font-size: 10px; font-family: var(--font-mono); padding: 4px 10px; min-height: 28px; border-radius: var(--radius); border: var(--border-width-default) solid var(--red-border); background: var(--surface); color: var(--red); cursor: pointer; margin-left: auto; }
191
191
  .cpub-withdraw-btn:hover { background: var(--red-bg); }
192
192
 
193
193
  .cpub-empty-state { text-align: center; padding: 32px 0; }
@@ -0,0 +1,126 @@
1
+ <script setup lang="ts">
2
+ import type { ContestStakeholderItem } from '@commonpub/server';
3
+
4
+ const props = defineProps<{ contestSlug: string }>();
5
+
6
+ const toast = useToast();
7
+ const { data: stakeholders, refresh } = useLazyFetch<ContestStakeholderItem[]>(
8
+ `/api/contests/${props.contestSlug}/stakeholders`,
9
+ );
10
+
11
+ const searchQuery = ref('');
12
+ const searchResults = ref<Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>>([]);
13
+ const searching = ref(false);
14
+ const adding = ref(false);
15
+ let searchTimeout: ReturnType<typeof setTimeout> | null = null;
16
+
17
+ function handleSearch(): void {
18
+ if (searchTimeout) clearTimeout(searchTimeout);
19
+ if (!searchQuery.value || searchQuery.value.length < 2) { searchResults.value = []; return; }
20
+ searchTimeout = setTimeout(async () => {
21
+ searching.value = true;
22
+ try {
23
+ const data = await ($fetch as Function)('/api/admin/users', { query: { search: searchQuery.value, limit: 8 } }) as { items: Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }> };
24
+ const existing = new Set((stakeholders.value ?? []).map((s) => s.userId));
25
+ searchResults.value = data.items.filter((u) => !existing.has(u.id));
26
+ } catch {
27
+ searchResults.value = [];
28
+ } finally {
29
+ searching.value = false;
30
+ }
31
+ }, 300);
32
+ }
33
+
34
+ async function addStakeholder(userId: string): Promise<void> {
35
+ adding.value = true;
36
+ try {
37
+ await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders`, { method: 'POST', body: { userId } });
38
+ toast.success('Reviewer added');
39
+ searchQuery.value = '';
40
+ searchResults.value = [];
41
+ await refresh();
42
+ } catch {
43
+ toast.error('Failed to add reviewer');
44
+ } finally {
45
+ adding.value = false;
46
+ }
47
+ }
48
+
49
+ async function removeStakeholder(userId: string): Promise<void> {
50
+ if (!confirm('Remove this reviewer’s access?')) return;
51
+ try {
52
+ await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders/${userId}`, { method: 'DELETE' });
53
+ toast.success('Reviewer removed');
54
+ await refresh();
55
+ } catch {
56
+ toast.error('Failed to remove reviewer');
57
+ }
58
+ }
59
+ </script>
60
+
61
+ <template>
62
+ <div class="cpub-sh">
63
+ <p class="cpub-sh-hint">Reviewers can view this contest (even while private/unpublished) but can't edit it or judge.</p>
64
+ <div v-if="stakeholders?.length" class="cpub-sh-list">
65
+ <div v-for="s in stakeholders" :key="s.id" class="cpub-sh-row">
66
+ <NuxtLink :to="`/u/${s.userUsername}`" class="cpub-sh-link">
67
+ <span class="cpub-sh-av">
68
+ <img v-if="s.userAvatar" :src="s.userAvatar" :alt="s.userName" />
69
+ <span v-else>{{ s.userName.charAt(0) }}</span>
70
+ </span>
71
+ <span class="cpub-sh-name">{{ s.userName }}</span>
72
+ </NuxtLink>
73
+ <button class="cpub-sh-remove" :aria-label="`Remove ${s.userName}`" @click="removeStakeholder(s.userId)">
74
+ <i class="fa-solid fa-xmark"></i>
75
+ </button>
76
+ </div>
77
+ </div>
78
+ <p v-else class="cpub-sh-empty">No reviewers yet.</p>
79
+
80
+ <div class="cpub-sh-search">
81
+ <input
82
+ v-model="searchQuery"
83
+ class="cpub-sh-input"
84
+ placeholder="Search users by name or username..."
85
+ aria-label="Search users to add as reviewers"
86
+ @input="handleSearch"
87
+ />
88
+ <div v-if="searchResults.length" class="cpub-sh-dropdown">
89
+ <button v-for="u in searchResults" :key="u.id" class="cpub-sh-option" :disabled="adding" @click="addStakeholder(u.id)">
90
+ <span class="cpub-sh-av cpub-sh-av-sm">
91
+ <img v-if="u.avatarUrl" :src="u.avatarUrl" :alt="u.displayName || u.username" />
92
+ <span v-else>{{ (u.displayName || u.username).charAt(0) }}</span>
93
+ </span>
94
+ <span class="cpub-sh-opt-name">{{ u.displayName || u.username }}</span>
95
+ <span class="cpub-sh-opt-handle">@{{ u.username }}</span>
96
+ </button>
97
+ </div>
98
+ <div v-else-if="searching" class="cpub-sh-dropdown"><span class="cpub-sh-dropdown-empty">Searching...</span></div>
99
+ <div v-else-if="searchQuery.length >= 2" class="cpub-sh-dropdown"><span class="cpub-sh-dropdown-empty">No users found</span></div>
100
+ </div>
101
+ </div>
102
+ </template>
103
+
104
+ <style scoped>
105
+ .cpub-sh-hint { font-size: 11px; color: var(--text-faint); margin: 0 0 12px; line-height: 1.5; }
106
+ .cpub-sh-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
107
+ .cpub-sh-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
108
+ .cpub-sh-link { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text); flex: 1; min-width: 0; }
109
+ .cpub-sh-av { width: 24px; height: 24px; border-radius: 50%; background: var(--surface2); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; overflow: hidden; flex-shrink: 0; }
110
+ .cpub-sh-av img { width: 100%; height: 100%; object-fit: cover; }
111
+ .cpub-sh-av-sm { width: 20px; height: 20px; font-size: 8px; }
112
+ .cpub-sh-name { font-size: 12px; font-weight: 600; }
113
+ .cpub-sh-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 6px; min-height: 28px; }
114
+ .cpub-sh-remove:hover { color: var(--red); }
115
+ .cpub-sh-empty { font-size: 12px; color: var(--text-faint); font-style: italic; margin-bottom: 12px; }
116
+ .cpub-sh-search { position: relative; }
117
+ .cpub-sh-input { font-size: 12px; padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; width: 100%; }
118
+ .cpub-sh-input:focus { border-color: var(--accent); }
119
+ .cpub-sh-dropdown { position: absolute; top: 100%; left: 0; right: 0; z-index: 10; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); margin-top: 2px; max-height: 200px; overflow-y: auto; }
120
+ .cpub-sh-option { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: none; border: none; width: 100%; text-align: left; cursor: pointer; }
121
+ .cpub-sh-option:hover { background: var(--surface2); }
122
+ .cpub-sh-option:disabled { opacity: 0.5; cursor: default; }
123
+ .cpub-sh-opt-name { font-size: 12px; font-weight: 600; color: var(--text); }
124
+ .cpub-sh-opt-handle { font-size: 11px; color: var(--text-faint); margin-left: auto; }
125
+ .cpub-sh-dropdown-empty { display: block; padding: 8px 12px; font-size: 11px; color: var(--text-faint); }
126
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -58,11 +58,11 @@
58
58
  "@commonpub/docs": "0.6.3",
59
59
  "@commonpub/explainer": "0.7.15",
60
60
  "@commonpub/learning": "0.5.2",
61
- "@commonpub/schema": "0.21.0",
62
- "@commonpub/editor": "0.7.11",
61
+ "@commonpub/schema": "0.22.0",
62
+ "@commonpub/server": "2.63.0",
63
63
  "@commonpub/ui": "0.9.1",
64
- "@commonpub/protocol": "0.12.0",
65
- "@commonpub/server": "2.62.0"
64
+ "@commonpub/editor": "0.7.11",
65
+ "@commonpub/protocol": "0.12.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -31,6 +31,15 @@ function toggleType(type: string): void {
31
31
  else eligibleContentTypes.value.push(type);
32
32
  }
33
33
 
34
+ const visibility = ref<'public' | 'unlisted' | 'private'>('public');
35
+ const visibleToRoles = ref<string[]>([]);
36
+ const ROLE_OPTIONS = ['member', 'pro', 'verified', 'staff', 'admin'];
37
+ function toggleRole(r: string): void {
38
+ const i = visibleToRoles.value.indexOf(r);
39
+ if (i >= 0) visibleToRoles.value.splice(i, 1);
40
+ else visibleToRoles.value.push(r);
41
+ }
42
+
34
43
  interface Prize { place: number | null; category: string; title: string; description: string; value: string }
35
44
  const prizes = ref<Prize[]>([]);
36
45
 
@@ -51,6 +60,8 @@ watch(contest, (c) => {
51
60
  judgingVisibility.value = (c.judgingVisibility as typeof judgingVisibility.value) ?? 'judges-only';
52
61
  eligibleContentTypes.value = [...(c.eligibleContentTypes ?? [])];
53
62
  maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
63
+ visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
64
+ visibleToRoles.value = [...(c.visibleToRoles ?? [])];
54
65
  prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title: string; description?: string; value?: string }) => ({
55
66
  place: p.place ?? null,
56
67
  category: p.category ?? '',
@@ -130,6 +141,8 @@ async function handleSave(): Promise<void> {
130
141
  judgingVisibility: judgingVisibility.value,
131
142
  eligibleContentTypes: eligibleContentTypes.value,
132
143
  maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
144
+ visibility: visibility.value,
145
+ visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
133
146
  prizes: prizeData,
134
147
  judgingCriteria: criteriaData,
135
148
  },
@@ -300,6 +313,32 @@ async function transitionStatus(newStatus: string): Promise<void> {
300
313
  </div>
301
314
  </section>
302
315
 
316
+ <!-- Visibility & Access -->
317
+ <section class="cpub-form-section">
318
+ <h2 class="cpub-form-section-title">Visibility &amp; Access</h2>
319
+ <div class="cpub-form-field">
320
+ <label class="cpub-form-label">Who can see this contest</label>
321
+ <select v-model="visibility" class="cpub-form-input">
322
+ <option value="public">Public — listed and visible to everyone</option>
323
+ <option value="unlisted">Unlisted — visible by direct link, hidden from listings</option>
324
+ <option value="private">Private — restricted</option>
325
+ </select>
326
+ </div>
327
+ <div v-if="visibility === 'private'" class="cpub-form-field">
328
+ <span class="cpub-form-label">Also visible to roles</span>
329
+ <div class="cpub-type-options" role="group" aria-label="Roles that can view">
330
+ <label v-for="r in ROLE_OPTIONS" :key="r" class="cpub-form-check">
331
+ <input type="checkbox" :checked="visibleToRoles.includes(r)" @change="toggleRole(r)" />
332
+ <span>{{ r }}</span>
333
+ </label>
334
+ </div>
335
+ </div>
336
+ <div class="cpub-subhead">
337
+ <h3 class="cpub-form-subtitle">Reviewers</h3>
338
+ </div>
339
+ <ContestStakeholderManager :contest-slug="slug" />
340
+ </section>
341
+
303
342
  <!-- Judge panel (single source of truth: contest_judges table) -->
304
343
  <section class="cpub-form-section">
305
344
  <h2 class="cpub-form-section-title">Judges</h2>
@@ -27,6 +27,25 @@ const myJudge = computed(() => judges.value.find((j) => j.userId === user.value?
27
27
  const pendingInvite = computed(() => !!myJudge.value && !myJudge.value.acceptedAt);
28
28
  const canJudge = computed(() => !!myJudge.value && !!myJudge.value.acceptedAt && myJudge.value.role !== 'guest');
29
29
 
30
+ // Unique entrants (the people), distinct from entries (the submissions).
31
+ interface Participant { username: string; name: string; avatar: string | null; count: number }
32
+ const participants = computed<Participant[]>(() => {
33
+ const map = new Map<string, Participant>();
34
+ for (const e of entries.value) {
35
+ const cur = map.get(e.authorUsername);
36
+ if (cur) cur.count++;
37
+ else map.set(e.authorUsername, { username: e.authorUsername, name: e.authorName, avatar: e.authorAvatarUrl, count: 1 });
38
+ }
39
+ return [...map.values()];
40
+ });
41
+
42
+ // Visibility banner shown to those who can see a non-public contest.
43
+ const visibilityNote = computed(() => {
44
+ if (!c.value || c.value.visibility === 'public') return null;
45
+ if (c.value.visibility === 'unlisted') return { icon: 'fa-link', text: 'Unlisted — visible by direct link only, hidden from listings.' };
46
+ return { icon: 'fa-lock', text: 'Private — visible only to you, reviewers, judges, and allowed roles.' };
47
+ });
48
+
30
49
  // Tabs ----------------------------------------------------------------------
31
50
  interface Tab { key: string; label: string; icon: string; count?: number }
32
51
  const tabs = computed<Tab[]>(() => {
@@ -34,6 +53,7 @@ const tabs = computed<Tab[]>(() => {
34
53
  if (c.value?.rules) t.push({ key: 'rules', label: 'Rules', icon: 'fa-file-lines' });
35
54
  if (c.value?.prizes?.length) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
36
55
  t.push({ key: 'entries', label: 'Entries', icon: 'fa-box-open', count: c.value?.entryCount ?? entries.value.length });
56
+ if (participants.value.length) t.push({ key: 'participants', label: 'Participants', icon: 'fa-users', count: participants.value.length });
37
57
  if (judges.value.length || isOwner.value) t.push({ key: 'judges', label: 'Judges', icon: 'fa-gavel', count: judges.value.length || undefined });
38
58
  return t;
39
59
  });
@@ -92,6 +112,8 @@ async function acceptInvite(): Promise<void> {
92
112
 
93
113
  // Entry submission
94
114
  const showSubmitDialog = ref(false);
115
+ const submitDialogRef = ref<HTMLElement | null>(null);
116
+ useFocusTrap(submitDialogRef, () => showSubmitDialog.value, () => { showSubmitDialog.value = false; });
95
117
  const submitContentId = ref('');
96
118
  const submitting = ref(false);
97
119
  const { data: userContent } = useFetch('/api/content', {
@@ -156,7 +178,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
156
178
 
157
179
  <!-- SUBMIT ENTRY DIALOG -->
158
180
  <div v-if="showSubmitDialog" class="cpub-submit-overlay" @click.self="showSubmitDialog = false">
159
- <div class="cpub-submit-dialog" role="dialog" aria-label="Submit entry">
181
+ <div ref="submitDialogRef" class="cpub-submit-dialog" role="dialog" aria-modal="true" aria-label="Submit entry">
160
182
  <div class="cpub-submit-header">
161
183
  <h2>Submit Entry</h2>
162
184
  <button class="cpub-submit-close" aria-label="Close" @click="showSubmitDialog = false"><i class="fa-solid fa-times"></i></button>
@@ -205,6 +227,12 @@ async function withdrawEntry(entryId: string): Promise<void> {
205
227
  </button>
206
228
  </div>
207
229
 
230
+ <!-- Visibility banner (non-public contests, shown to those who can see it) -->
231
+ <div v-if="visibilityNote" class="cpub-visibility-banner">
232
+ <i class="fa-solid" :class="visibilityNote.icon"></i>
233
+ <span>{{ visibilityNote.text }}</span>
234
+ </div>
235
+
208
236
  <!-- Tab bar -->
209
237
  <div class="cpub-tabbar" role="tablist" aria-label="Contest sections">
210
238
  <button
@@ -259,6 +287,23 @@ async function withdrawEntry(entryId: string): Promise<void> {
259
287
  />
260
288
  </div>
261
289
 
290
+ <!-- PARTICIPANTS -->
291
+ <div v-show="activeTab === 'participants'" id="cpub-panel-participants" role="tabpanel" aria-labelledby="cpub-tab-participants" tabindex="0">
292
+ <div class="cpub-sec-head"><h2><i class="fa-solid fa-users" style="color: var(--accent);"></i> Participants</h2><span class="cpub-sec-sub">{{ participants.length }}</span></div>
293
+ <div class="cpub-participant-grid">
294
+ <NuxtLink v-for="p in participants" :key="p.username" :to="`/u/${p.username}`" class="cpub-participant">
295
+ <span class="cpub-participant-av">
296
+ <img v-if="p.avatar" :src="p.avatar" :alt="p.name" />
297
+ <span v-else>{{ (p.name || p.username || '?').charAt(0).toUpperCase() }}</span>
298
+ </span>
299
+ <span class="cpub-participant-info">
300
+ <span class="cpub-participant-name">{{ p.name }}</span>
301
+ <span class="cpub-participant-meta">{{ p.count }} {{ p.count === 1 ? 'entry' : 'entries' }}</span>
302
+ </span>
303
+ </NuxtLink>
304
+ </div>
305
+ </div>
306
+
262
307
  <!-- JUDGES -->
263
308
  <div v-show="activeTab === 'judges'" id="cpub-panel-judges" role="tabpanel" aria-labelledby="cpub-tab-judges" tabindex="0">
264
309
  <ContestJudges :judges="judges" />
@@ -296,8 +341,9 @@ async function withdrawEntry(entryId: string): Promise<void> {
296
341
  .cpub-invite-text i { color: var(--accent); }
297
342
 
298
343
  /* 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; }
344
+ .cpub-tabbar { display: flex; gap: 2px; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; border-bottom: var(--border-width-default) solid var(--border); margin-bottom: 20px; }
345
+ .cpub-tabbar::-webkit-scrollbar { display: none; }
346
+ .cpub-tab { display: inline-flex; align-items: center; gap: 6px; padding: 9px 14px; min-height: 40px; flex-shrink: 0; white-space: nowrap; 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
347
  .cpub-tab:hover { color: var(--text-dim); }
302
348
  .cpub-tab-active { color: var(--accent); border-bottom-color: var(--accent); }
303
349
  .cpub-tab i { font-size: 11px; }
@@ -306,9 +352,25 @@ async function withdrawEntry(entryId: string): Promise<void> {
306
352
 
307
353
  [role="tabpanel"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
308
354
 
355
+ /* VISIBILITY BANNER */
356
+ .cpub-visibility-banner { 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); }
357
+ .cpub-visibility-banner i { color: var(--accent); }
358
+
309
359
  /* SECTION HEADERS */
310
360
  .cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
311
361
  .cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
362
+ .cpub-sec-sub { font-size: 11px; color: var(--text-faint); margin-left: auto; font-family: var(--font-mono); }
363
+
364
+ /* PARTICIPANTS */
365
+ .cpub-participant-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
366
+ .cpub-participant { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); text-decoration: none; }
367
+ .cpub-participant:hover { box-shadow: var(--shadow-accent); }
368
+ .cpub-participant-av { width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; font-family: var(--font-mono); border: var(--border-width-default) solid var(--border); background: var(--surface3); color: var(--text-dim); overflow: hidden; }
369
+ .cpub-participant-av img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
370
+ .cpub-participant-info { display: flex; flex-direction: column; min-width: 0; }
371
+ .cpub-participant-name { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
372
+ .cpub-participant-meta { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); }
373
+ @media (max-width: 480px) { .cpub-participant-grid { grid-template-columns: 1fr; } }
312
374
 
313
375
  /* ABOUT */
314
376
  .cpub-about-section { margin-bottom: 20px; }
@@ -121,6 +121,7 @@ function medalColor(rank: number): string {
121
121
  <!-- LEADERBOARD -->
122
122
  <div v-if="leaderboard.length > 0" class="cpub-leaderboard">
123
123
  <h2 class="cpub-leaderboard-title">Full Leaderboard</h2>
124
+ <div class="cpub-leaderboard-scroll">
124
125
  <table class="cpub-leaderboard-table">
125
126
  <thead>
126
127
  <tr>
@@ -150,6 +151,7 @@ function medalColor(rank: number): string {
150
151
  </tr>
151
152
  </tbody>
152
153
  </table>
154
+ </div>
153
155
  </div>
154
156
 
155
157
  <div v-else class="cpub-results-empty">
@@ -190,7 +192,9 @@ function medalColor(rank: number): string {
190
192
  /* LEADERBOARD */
191
193
  .cpub-leaderboard { margin-bottom: 32px; }
192
194
  .cpub-leaderboard-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; }
193
- .cpub-leaderboard-table { width: 100%; border-collapse: collapse; font-size: 12px; }
195
+ /* Horizontal scroll on narrow screens instead of overflowing the page. */
196
+ .cpub-leaderboard-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
197
+ .cpub-leaderboard-table { width: 100%; border-collapse: collapse; font-size: 12px; min-width: 420px; }
194
198
  .cpub-leaderboard-table th { text-align: left; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); padding: 8px 12px; border-bottom: var(--border-width-default) solid var(--border); }
195
199
  .cpub-leaderboard-table td { padding: 10px 12px; border-bottom: var(--border-width-default) solid var(--border); }
196
200
  .cpub-lb-top3 { background: var(--surface2); }
@@ -17,6 +17,16 @@ const judgingEndDate = ref('');
17
17
  const communityVotingEnabled = ref(false);
18
18
  const judgingVisibility = ref<'public' | 'judges-only' | 'private'>('judges-only');
19
19
 
20
+ // Visibility & access
21
+ const visibility = ref<'public' | 'unlisted' | 'private'>('public');
22
+ const visibleToRoles = ref<string[]>([]);
23
+ const ROLE_OPTIONS = ['member', 'pro', 'verified', 'staff', 'admin'];
24
+ function toggleRole(r: string): void {
25
+ const i = visibleToRoles.value.indexOf(r);
26
+ if (i >= 0) visibleToRoles.value.splice(i, 1);
27
+ else visibleToRoles.value.push(r);
28
+ }
29
+
20
30
  // Entry rules
21
31
  const { enabledTypeMeta } = useContentTypes();
22
32
  const eligibleContentTypes = ref<string[]>([]); // empty = all types allowed
@@ -88,6 +98,8 @@ async function handleCreate(): Promise<void> {
88
98
  judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
89
99
  communityVotingEnabled: communityVotingEnabled.value,
90
100
  judgingVisibility: judgingVisibility.value,
101
+ visibility: visibility.value,
102
+ visibleToRoles: visibility.value === 'private' && visibleToRoles.value.length ? visibleToRoles.value : undefined,
91
103
  eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
92
104
  maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
93
105
  prizes: prizes.value
@@ -171,6 +183,30 @@ function prizeLabel(prize: Prize, idx: number): string {
171
183
  <p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
172
184
  </section>
173
185
 
186
+ <!-- Visibility & Access -->
187
+ <section class="cpub-form-section">
188
+ <h2 class="cpub-form-section-title">Visibility &amp; Access</h2>
189
+ <div class="cpub-form-field">
190
+ <label for="visibility" class="cpub-form-label">Who can see this contest</label>
191
+ <select id="visibility" v-model="visibility" class="cpub-form-input">
192
+ <option value="public">Public — listed and visible to everyone</option>
193
+ <option value="unlisted">Unlisted — visible by direct link, hidden from listings</option>
194
+ <option value="private">Private — restricted (you can publish it later)</option>
195
+ </select>
196
+ </div>
197
+ <div v-if="visibility === 'private'" class="cpub-form-field">
198
+ <span class="cpub-form-label">Also visible to roles</span>
199
+ <p class="cpub-form-hint">Owner, admins, judges, and reviewers (added after creation) can always see it. Optionally grant whole roles too.</p>
200
+ <div class="cpub-type-options" role="group" aria-label="Roles that can view">
201
+ <label v-for="r in ROLE_OPTIONS" :key="r" class="cpub-form-check">
202
+ <input type="checkbox" :checked="visibleToRoles.includes(r)" @change="toggleRole(r)" />
203
+ <span>{{ r }}</span>
204
+ </label>
205
+ </div>
206
+ </div>
207
+ <p v-if="visibility === 'private'" class="cpub-form-hint">Add named reviewers (stakeholders) from the contest's Edit page after creating it.</p>
208
+ </section>
209
+
174
210
  <!-- Entry Rules -->
175
211
  <section class="cpub-form-section">
176
212
  <h2 class="cpub-form-section-title">Entries</h2>
@@ -1,4 +1,4 @@
1
- import { listContestEntries, getContestBySlug, isContestJudge, shouldRevealScores } from '@commonpub/server';
1
+ import { listContestEntries, getContestBySlug, isContestJudge, shouldRevealScores, canViewContest } from '@commonpub/server';
2
2
  import type { ContestEntryItem } from '@commonpub/server';
3
3
  import { z } from 'zod';
4
4
 
@@ -21,6 +21,10 @@ export default defineEventHandler(async (event): Promise<{ items: ContestEntryIt
21
21
  // Aggregate score visibility additionally honours the contest's
22
22
  // judgingVisibility setting (public / judges-only / private).
23
23
  const user = getOptionalUser(event);
24
+ // Don't leak a private contest's entries to viewers who can't see the contest.
25
+ if (!(await canViewContest(db, contest, user))) {
26
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
27
+ }
24
28
  let privileged = false;
25
29
  if (user) {
26
30
  privileged =
@@ -1,4 +1,4 @@
1
- import { submitContestEntry, getContestBySlug } from '@commonpub/server';
1
+ import { submitContestEntry, getContestBySlug, canViewContest } from '@commonpub/server';
2
2
  import type { ContestEntryItem } from '@commonpub/server';
3
3
  import { z } from 'zod';
4
4
 
@@ -13,6 +13,10 @@ export default defineEventHandler(async (event): Promise<ContestEntryItem> => {
13
13
  const { slug } = parseParams(event, { slug: 'string' });
14
14
  const contest = await getContestBySlug(db, slug);
15
15
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
16
+ // Can't enter a contest you can't see.
17
+ if (!(await canViewContest(db, contest, user))) {
18
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
19
+ }
16
20
  const input = await parseBody(event, submitEntrySchema);
17
21
 
18
22
  const entry = await submitContestEntry(db, contest.id, input.contentId, user.id);
@@ -1,4 +1,4 @@
1
- import { getContestBySlug } from '@commonpub/server';
1
+ import { getContestBySlug, canViewContest } from '@commonpub/server';
2
2
  import type { ContestDetail } from '@commonpub/server';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<ContestDetail> => {
@@ -7,5 +7,11 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
7
7
  const { slug } = parseParams(event, { slug: 'string' });
8
8
  const contest = await getContestBySlug(db, slug);
9
9
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
10
+ // Access control: private contests are only visible to owner/admin/stakeholders/
11
+ // judges/allowed-roles. 404 (not 403) so we don't leak that the contest exists.
12
+ const user = getOptionalUser(event);
13
+ if (!(await canViewContest(db, contest, user))) {
14
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
+ }
10
16
  return contest;
11
17
  });
@@ -1,4 +1,4 @@
1
- import { getContestBySlug, listContestJudges } from '@commonpub/server';
1
+ import { getContestBySlug, listContestJudges, canViewContest } from '@commonpub/server';
2
2
 
3
3
  /**
4
4
  * GET /api/contests/:slug/judges
@@ -12,6 +12,9 @@ export default defineEventHandler(async (event) => {
12
12
 
13
13
  const contest = await getContestBySlug(db, slug);
14
14
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
+ if (!(await canViewContest(db, contest, getOptionalUser(event)))) {
16
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
17
+ }
15
18
 
16
19
  return listContestJudges(db, contest.id);
17
20
  });
@@ -0,0 +1,24 @@
1
+ import { getContestBySlug, removeContestStakeholder } from '@commonpub/server';
2
+
3
+ /**
4
+ * DELETE /api/contests/:slug/stakeholders/:userId
5
+ * Revoke a stakeholder's review access (contest owner or admin only).
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireFeature('contests');
9
+ const user = requireAuth(event);
10
+ const db = useDB();
11
+ const slug = getRouterParam(event, 'slug');
12
+ const userId = getRouterParam(event, 'userId');
13
+ if (!slug || !userId) throw createError({ statusCode: 400, statusMessage: 'Missing slug or userId' });
14
+
15
+ const contest = await getContestBySlug(db, slug);
16
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
17
+ if (contest.createdById !== user.id && user.role !== 'admin') {
18
+ throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can manage stakeholders' });
19
+ }
20
+
21
+ const removed = await removeContestStakeholder(db, contest.id, userId);
22
+ if (!removed) throw createError({ statusCode: 404, statusMessage: 'Stakeholder not found' });
23
+ return { removed: true };
24
+ });
@@ -0,0 +1,21 @@
1
+ import { getContestBySlug, listContestStakeholders } from '@commonpub/server';
2
+
3
+ /**
4
+ * GET /api/contests/:slug/stakeholders
5
+ * List view-only stakeholders (contest owner or admin only).
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireFeature('contests');
9
+ const user = requireAuth(event);
10
+ const db = useDB();
11
+ const slug = getRouterParam(event, 'slug');
12
+ if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
13
+
14
+ const contest = await getContestBySlug(db, slug);
15
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
16
+ if (contest.createdById !== user.id && user.role !== 'admin') {
17
+ throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can view stakeholders' });
18
+ }
19
+
20
+ return listContestStakeholders(db, contest.id);
21
+ });
@@ -0,0 +1,33 @@
1
+ import { getContestBySlug, addContestStakeholder } from '@commonpub/server';
2
+ import { z } from 'zod';
3
+
4
+ const addStakeholderSchema = z.object({ userId: z.string().uuid() });
5
+
6
+ /**
7
+ * POST /api/contests/:slug/stakeholders
8
+ * Grant a user view-only review access (contest owner or admin only).
9
+ */
10
+ export default defineEventHandler(async (event) => {
11
+ requireFeature('contests');
12
+ const user = requireAuth(event);
13
+ const db = useDB();
14
+ const slug = getRouterParam(event, 'slug');
15
+ if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
16
+
17
+ const contest = await getContestBySlug(db, slug);
18
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
19
+ if (contest.createdById !== user.id && user.role !== 'admin') {
20
+ throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can manage stakeholders' });
21
+ }
22
+
23
+ const body = await parseBody(event, addStakeholderSchema);
24
+ const result = await addContestStakeholder(db, contest.id, body.userId, {
25
+ contestSlug: slug,
26
+ contestTitle: contest.title,
27
+ invitedBy: user.id,
28
+ });
29
+ if (!result.added) {
30
+ throw createError({ statusCode: 400, statusMessage: result.error ?? 'Failed to add stakeholder' });
31
+ }
32
+ return { added: true };
33
+ });
@@ -1,4 +1,4 @@
1
- import { getContestBySlug, getContestEntryVotes } from '@commonpub/server';
1
+ import { getContestBySlug, getContestEntryVotes, canViewContest } from '@commonpub/server';
2
2
  import type { ContestEntryVoteInfo } from '@commonpub/server';
3
3
 
4
4
  /**
@@ -13,6 +13,9 @@ export default defineEventHandler(async (event): Promise<ContestEntryVoteInfo[]>
13
13
 
14
14
  const contest = await getContestBySlug(db, slug);
15
15
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
16
+ if (!(await canViewContest(db, contest, user))) {
17
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
18
+ }
16
19
  // Voting disabled, or contest not yet open (no entries) → empty array, not an error.
17
20
  if (!contest.communityVotingEnabled || contest.status === 'upcoming') return [];
18
21
 
@@ -6,5 +6,8 @@ export default defineEventHandler(async (event): Promise<PaginatedResponse<Conte
6
6
  requireFeature('contests');
7
7
  const db = useDB();
8
8
  const filters = parseQueryParams(event, contestFiltersSchema);
9
- return listContests(db, filters);
9
+ // Pass the viewer so the list can include their own drafts/hidden contests
10
+ // (and everything for admins) while keeping the public list public-only.
11
+ const user = getOptionalUser(event);
12
+ return listContests(db, filters, user ? { userId: user.id, role: user.role } : null);
10
13
  });