@commonpub/layer 0.28.1 → 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.
- package/components/contest/ContestStakeholderManager.vue +126 -0
- package/package.json +5 -5
- package/pages/contests/[slug]/edit.vue +39 -0
- package/pages/contests/[slug]/index.vue +59 -0
- package/pages/contests/create.vue +36 -0
- package/server/api/contests/[slug]/entries.get.ts +5 -1
- package/server/api/contests/[slug]/entries.post.ts +5 -1
- package/server/api/contests/[slug]/index.get.ts +7 -1
- package/server/api/contests/[slug]/judges/index.get.ts +4 -1
- package/server/api/contests/[slug]/stakeholders/[userId].delete.ts +24 -0
- package/server/api/contests/[slug]/stakeholders/index.get.ts +21 -0
- package/server/api/contests/[slug]/stakeholders/index.post.ts +33 -0
- package/server/api/contests/[slug]/votes.get.ts +4 -1
- package/server/api/contests/index.get.ts +4 -1
|
@@ -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.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,16 +53,16 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
+
"@commonpub/auth": "0.6.0",
|
|
56
57
|
"@commonpub/config": "0.15.0",
|
|
57
58
|
"@commonpub/docs": "0.6.3",
|
|
58
59
|
"@commonpub/explainer": "0.7.15",
|
|
59
|
-
"@commonpub/protocol": "0.12.0",
|
|
60
60
|
"@commonpub/learning": "0.5.2",
|
|
61
|
-
"@commonpub/schema": "0.
|
|
61
|
+
"@commonpub/schema": "0.22.0",
|
|
62
|
+
"@commonpub/server": "2.63.0",
|
|
62
63
|
"@commonpub/ui": "0.9.1",
|
|
63
64
|
"@commonpub/editor": "0.7.11",
|
|
64
|
-
"@commonpub/
|
|
65
|
-
"@commonpub/auth": "0.6.0"
|
|
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 & 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
|
});
|
|
@@ -207,6 +227,12 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
207
227
|
</button>
|
|
208
228
|
</div>
|
|
209
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
|
+
|
|
210
236
|
<!-- Tab bar -->
|
|
211
237
|
<div class="cpub-tabbar" role="tablist" aria-label="Contest sections">
|
|
212
238
|
<button
|
|
@@ -261,6 +287,23 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
261
287
|
/>
|
|
262
288
|
</div>
|
|
263
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
|
+
|
|
264
307
|
<!-- JUDGES -->
|
|
265
308
|
<div v-show="activeTab === 'judges'" id="cpub-panel-judges" role="tabpanel" aria-labelledby="cpub-tab-judges" tabindex="0">
|
|
266
309
|
<ContestJudges :judges="judges" />
|
|
@@ -309,9 +352,25 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
309
352
|
|
|
310
353
|
[role="tabpanel"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
|
311
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
|
+
|
|
312
359
|
/* SECTION HEADERS */
|
|
313
360
|
.cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
|
314
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; } }
|
|
315
374
|
|
|
316
375
|
/* ABOUT */
|
|
317
376
|
.cpub-about-section { margin-bottom: 20px; }
|
|
@@ -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 & 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
|
-
|
|
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
|
});
|