@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.
- package/components/contest/ContestEntries.vue +3 -3
- 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 +65 -3
- package/pages/contests/[slug]/results.vue +5 -1
- 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
|
@@ -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:
|
|
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:
|
|
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.
|
|
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.
|
|
62
|
-
"@commonpub/
|
|
61
|
+
"@commonpub/schema": "0.22.0",
|
|
62
|
+
"@commonpub/server": "2.63.0",
|
|
63
63
|
"@commonpub/ui": "0.9.1",
|
|
64
|
-
"@commonpub/
|
|
65
|
-
"@commonpub/
|
|
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 & 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:
|
|
300
|
-
.cpub-
|
|
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
|
-
|
|
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 & 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
|
});
|