@commonpub/layer 0.28.1 → 0.30.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/ContentCard.vue +13 -3
- package/components/CpubMarkdown.vue +46 -0
- package/components/NotificationItem.vue +45 -14
- package/components/contest/ContestEntries.vue +6 -3
- package/components/contest/ContestHero.vue +23 -2
- package/components/contest/ContestPrizes.vue +2 -2
- package/components/contest/ContestRules.vue +9 -9
- package/components/contest/ContestStakeholderManager.vue +126 -0
- package/composables/useFeatures.ts +8 -0
- package/nuxt.config.ts +1 -0
- package/package.json +8 -8
- package/pages/contests/[slug]/edit.vue +119 -15
- package/pages/contests/[slug]/index.vue +61 -1
- package/pages/contests/[slug]/results.vue +20 -5
- package/pages/contests/create.vue +60 -13
- package/pages/events/[slug]/index.vue +1 -1
- package/pages/notifications.vue +9 -0
- package/server/api/admin/api-keys/[id]/usage.get.ts +1 -1
- package/server/api/admin/api-keys/[id].delete.ts +1 -1
- package/server/api/admin/api-keys/index.get.ts +1 -1
- package/server/api/admin/api-keys/index.post.ts +1 -1
- package/server/api/admin/audit.get.ts +1 -1
- package/server/api/admin/categories/[id].delete.ts +1 -1
- package/server/api/admin/categories/[id].patch.ts +1 -1
- package/server/api/admin/categories/index.get.ts +1 -1
- package/server/api/admin/categories/index.post.ts +1 -1
- package/server/api/admin/content/[id].delete.ts +1 -1
- package/server/api/admin/content/[id].patch.ts +1 -1
- package/server/api/admin/content/bulk-editorial.post.ts +1 -1
- package/server/api/admin/features/index.get.ts +1 -1
- package/server/api/admin/features/index.put.ts +1 -1
- package/server/api/admin/federation/activity.get.ts +1 -1
- package/server/api/admin/federation/clients.get.ts +1 -1
- package/server/api/admin/federation/clients.post.ts +1 -1
- package/server/api/admin/federation/hub-mirrors/[id]/backfill.post.ts +1 -1
- package/server/api/admin/federation/hub-mirrors/index.get.ts +1 -1
- package/server/api/admin/federation/hub-mirrors/index.post.ts +1 -1
- package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
- package/server/api/admin/federation/mirrors/[id].delete.ts +1 -1
- package/server/api/admin/federation/mirrors/[id].get.ts +1 -1
- package/server/api/admin/federation/mirrors/[id].put.ts +1 -1
- package/server/api/admin/federation/mirrors/index.get.ts +1 -1
- package/server/api/admin/federation/mirrors/index.post.ts +1 -1
- package/server/api/admin/federation/pending.get.ts +1 -1
- package/server/api/admin/federation/refederate.post.ts +1 -1
- package/server/api/admin/federation/repair-types.post.ts +1 -1
- package/server/api/admin/federation/retry.post.ts +1 -1
- package/server/api/admin/federation/stats.get.ts +1 -1
- package/server/api/admin/federation/trusted-instances.delete.ts +1 -1
- package/server/api/admin/federation/trusted-instances.get.ts +1 -1
- package/server/api/admin/federation/trusted-instances.post.ts +1 -1
- package/server/api/admin/homepage/sections.get.ts +1 -1
- package/server/api/admin/homepage/sections.put.ts +1 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -1
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -1
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -1
- package/server/api/admin/layouts/[id].delete.ts +1 -1
- package/server/api/admin/layouts/[id].get.ts +1 -1
- package/server/api/admin/layouts/[id].put.ts +1 -1
- package/server/api/admin/layouts/index.get.ts +1 -1
- package/server/api/admin/layouts/index.post.ts +1 -1
- package/server/api/admin/layouts/migrate-homepage.post.ts +1 -1
- package/server/api/admin/layouts/seed-homepage.post.ts +1 -1
- package/server/api/admin/navigation/items.get.ts +1 -1
- package/server/api/admin/navigation/items.put.ts +1 -1
- package/server/api/admin/reports/[id]/resolve.post.ts +1 -1
- package/server/api/admin/reports.get.ts +1 -1
- package/server/api/admin/search/reindex.post.ts +1 -1
- package/server/api/admin/settings.get.ts +1 -1
- package/server/api/admin/settings.put.ts +1 -1
- package/server/api/admin/stats.get.ts +1 -1
- package/server/api/admin/storage/backfill-cdn-urls.post.ts +1 -1
- package/server/api/admin/themes/[id].delete.ts +1 -1
- package/server/api/admin/themes/[id].get.ts +1 -1
- package/server/api/admin/themes/[id].put.ts +1 -1
- package/server/api/admin/themes/discover.get.ts +1 -1
- package/server/api/admin/themes/index.get.ts +1 -1
- package/server/api/admin/themes/index.post.ts +1 -1
- package/server/api/admin/users/[id]/role.put.ts +1 -1
- package/server/api/admin/users/[id]/status.put.ts +1 -1
- package/server/api/admin/users/[id].delete.ts +1 -1
- package/server/api/admin/users.get.ts +1 -1
- package/server/api/contests/[slug]/entries.get.ts +8 -2
- package/server/api/contests/[slug]/entries.post.ts +5 -1
- package/server/api/contests/[slug]/index.delete.ts +4 -1
- package/server/api/contests/[slug]/index.get.ts +7 -1
- package/server/api/contests/[slug]/judges/[userId].delete.ts +1 -1
- package/server/api/contests/[slug]/judges/index.get.ts +4 -1
- package/server/api/contests/[slug]/judges/index.post.ts +1 -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
- package/server/api/docs/migrate-content.post.ts +1 -1
- package/server/api/events/[slug].delete.ts +1 -1
- package/server/api/events/[slug].put.ts +1 -1
- package/server/api/layouts/by-route.get.ts +1 -1
- package/server/api/products/[id].delete.ts +1 -1
- package/server/api/videos/categories/[id].delete.ts +1 -1
- package/server/api/videos/categories/[id].put.ts +1 -1
- package/server/api/videos/categories.post.ts +1 -1
- package/server/middleware/auth.ts +22 -0
- package/server/utils/auth.ts +12 -5
- package/server/utils/permissions.ts +97 -0
- package/server/utils/requirePermission.ts +102 -0
|
@@ -13,6 +13,7 @@ useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'} — ${useS
|
|
|
13
13
|
|
|
14
14
|
const saving = ref(false);
|
|
15
15
|
const title = ref('');
|
|
16
|
+
const subheading = ref('');
|
|
16
17
|
const description = ref('');
|
|
17
18
|
const rules = ref('');
|
|
18
19
|
const bannerUrl = ref('');
|
|
@@ -31,6 +32,15 @@ function toggleType(type: string): void {
|
|
|
31
32
|
else eligibleContentTypes.value.push(type);
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
const visibility = ref<'public' | 'unlisted' | 'private'>('public');
|
|
36
|
+
const visibleToRoles = ref<string[]>([]);
|
|
37
|
+
const ROLE_OPTIONS = ['member', 'pro', 'verified', 'staff', 'admin'];
|
|
38
|
+
function toggleRole(r: string): void {
|
|
39
|
+
const i = visibleToRoles.value.indexOf(r);
|
|
40
|
+
if (i >= 0) visibleToRoles.value.splice(i, 1);
|
|
41
|
+
else visibleToRoles.value.push(r);
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
interface Prize { place: number | null; category: string; title: string; description: string; value: string }
|
|
35
45
|
const prizes = ref<Prize[]>([]);
|
|
36
46
|
|
|
@@ -41,6 +51,7 @@ const criteria = ref<Criterion[]>([]);
|
|
|
41
51
|
watch(contest, (c) => {
|
|
42
52
|
if (!c) return;
|
|
43
53
|
title.value = c.title ?? '';
|
|
54
|
+
subheading.value = c.subheading ?? '';
|
|
44
55
|
description.value = c.description ?? '';
|
|
45
56
|
rules.value = c.rules ?? '';
|
|
46
57
|
bannerUrl.value = c.bannerUrl ?? '';
|
|
@@ -51,10 +62,12 @@ watch(contest, (c) => {
|
|
|
51
62
|
judgingVisibility.value = (c.judgingVisibility as typeof judgingVisibility.value) ?? 'judges-only';
|
|
52
63
|
eligibleContentTypes.value = [...(c.eligibleContentTypes ?? [])];
|
|
53
64
|
maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
|
|
54
|
-
|
|
65
|
+
visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
|
|
66
|
+
visibleToRoles.value = [...(c.visibleToRoles ?? [])];
|
|
67
|
+
prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
|
|
55
68
|
place: p.place ?? null,
|
|
56
69
|
category: p.category ?? '',
|
|
57
|
-
title: p.title,
|
|
70
|
+
title: p.title ?? '',
|
|
58
71
|
description: p.description ?? '',
|
|
59
72
|
value: p.value ?? '',
|
|
60
73
|
}));
|
|
@@ -71,10 +84,15 @@ function addPrize(): void {
|
|
|
71
84
|
function removePrize(index: number): void {
|
|
72
85
|
prizes.value.splice(index, 1);
|
|
73
86
|
}
|
|
74
|
-
function prizeLabel(prize: Prize
|
|
87
|
+
function prizeLabel(prize: Prize): string {
|
|
75
88
|
if (prize.category.trim()) return prize.category;
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
if (prize.place && prize.place > 0) {
|
|
90
|
+
const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
|
|
91
|
+
return `${labels[prize.place - 1] || `${prize.place}th`} Place`;
|
|
92
|
+
}
|
|
93
|
+
// No place + no category: a flexible/description-only prize — don't invent
|
|
94
|
+
// a placement (the old code labelled these "Nth Place" by row index).
|
|
95
|
+
return 'Prize';
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
function addCriterion(): void {
|
|
@@ -100,13 +118,13 @@ async function handleSave(): Promise<void> {
|
|
|
100
118
|
saving.value = true;
|
|
101
119
|
try {
|
|
102
120
|
const prizeData = prizes.value
|
|
103
|
-
.filter((p) => p.title.trim())
|
|
121
|
+
.filter((p) => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
|
|
104
122
|
.map((p) => ({
|
|
105
123
|
place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
|
|
106
124
|
category: p.category.trim() || undefined,
|
|
107
|
-
title: p.title,
|
|
108
|
-
description: p.description || undefined,
|
|
109
|
-
value: p.value || undefined,
|
|
125
|
+
title: p.title.trim() || undefined,
|
|
126
|
+
description: p.description.trim() || undefined,
|
|
127
|
+
value: p.value.trim() || undefined,
|
|
110
128
|
}));
|
|
111
129
|
const criteriaData = criteria.value
|
|
112
130
|
.filter((c) => c.label.trim())
|
|
@@ -120,6 +138,7 @@ async function handleSave(): Promise<void> {
|
|
|
120
138
|
method: 'PUT',
|
|
121
139
|
body: {
|
|
122
140
|
title: title.value,
|
|
141
|
+
subheading: subheading.value || undefined,
|
|
123
142
|
description: description.value || undefined,
|
|
124
143
|
rules: rules.value || undefined,
|
|
125
144
|
bannerUrl: bannerUrl.value || undefined,
|
|
@@ -130,6 +149,8 @@ async function handleSave(): Promise<void> {
|
|
|
130
149
|
judgingVisibility: judgingVisibility.value,
|
|
131
150
|
eligibleContentTypes: eligibleContentTypes.value,
|
|
132
151
|
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
152
|
+
visibility: visibility.value,
|
|
153
|
+
visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
|
|
133
154
|
prizes: prizeData,
|
|
134
155
|
judgingCriteria: criteriaData,
|
|
135
156
|
},
|
|
@@ -143,6 +164,20 @@ async function handleSave(): Promise<void> {
|
|
|
143
164
|
}
|
|
144
165
|
}
|
|
145
166
|
|
|
167
|
+
const deleting = ref(false);
|
|
168
|
+
async function handleDelete(): Promise<void> {
|
|
169
|
+
if (!confirm('Permanently delete this contest? All entries, judges, and reviewers are removed. This cannot be undone.')) return;
|
|
170
|
+
deleting.value = true;
|
|
171
|
+
try {
|
|
172
|
+
await $fetch(`/api/contests/${slug}`, { method: 'DELETE' });
|
|
173
|
+
toast.success('Contest deleted');
|
|
174
|
+
await navigateTo('/contests');
|
|
175
|
+
} catch (err: unknown) {
|
|
176
|
+
toast.error(extractError(err));
|
|
177
|
+
deleting.value = false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
146
181
|
async function transitionStatus(newStatus: string): Promise<void> {
|
|
147
182
|
const msg = newStatus === 'cancelled'
|
|
148
183
|
? 'Cancel this contest? This cannot be undone.'
|
|
@@ -177,17 +212,23 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
177
212
|
<label class="cpub-form-label">Title</label>
|
|
178
213
|
<input v-model="title" type="text" class="cpub-form-input" />
|
|
179
214
|
</div>
|
|
215
|
+
<div class="cpub-form-field">
|
|
216
|
+
<label class="cpub-form-label">Subheading</label>
|
|
217
|
+
<input v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
|
|
218
|
+
<p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
|
|
219
|
+
</div>
|
|
180
220
|
<div class="cpub-form-field">
|
|
181
221
|
<label class="cpub-form-label">Description</label>
|
|
182
|
-
<textarea v-model="description" class="cpub-form-textarea" rows="
|
|
222
|
+
<textarea v-model="description" class="cpub-form-textarea" rows="4" />
|
|
223
|
+
<p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
|
|
183
224
|
</div>
|
|
184
225
|
<div class="cpub-form-field">
|
|
185
226
|
<label class="cpub-form-label">Rules</label>
|
|
186
|
-
<textarea v-model="rules" class="cpub-form-textarea" rows="
|
|
227
|
+
<textarea v-model="rules" class="cpub-form-textarea" rows="6" placeholder="One rule per line, or full Markdown" />
|
|
228
|
+
<p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
|
|
187
229
|
</div>
|
|
188
230
|
<div class="cpub-form-field">
|
|
189
|
-
<
|
|
190
|
-
<input v-model="bannerUrl" type="url" class="cpub-form-input" placeholder="https://..." />
|
|
231
|
+
<ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide image shown across the top of the contest page (~4:1)." />
|
|
191
232
|
</div>
|
|
192
233
|
</section>
|
|
193
234
|
|
|
@@ -230,9 +271,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
230
271
|
|
|
231
272
|
<section class="cpub-form-section">
|
|
232
273
|
<h2 class="cpub-form-section-title">Prizes</h2>
|
|
274
|
+
<p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes, a <strong>category</strong> for themed awards, or just a <strong>description</strong> — whatever fits. Cash value is optional.</p>
|
|
233
275
|
<div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
|
|
234
276
|
<div class="cpub-prize-header">
|
|
235
|
-
<span class="cpub-prize-label">{{ prizeLabel(prize
|
|
277
|
+
<span class="cpub-prize-label">{{ prizeLabel(prize) }}</span>
|
|
236
278
|
<button type="button" class="cpub-prize-remove" aria-label="Remove prize" @click="removePrize(i)"><i class="fa-solid fa-times"></i></button>
|
|
237
279
|
</div>
|
|
238
280
|
<div class="cpub-form-row">
|
|
@@ -300,6 +342,33 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
300
342
|
</div>
|
|
301
343
|
</section>
|
|
302
344
|
|
|
345
|
+
<!-- Visibility & Access -->
|
|
346
|
+
<section class="cpub-form-section">
|
|
347
|
+
<h2 class="cpub-form-section-title">Visibility & Access</h2>
|
|
348
|
+
<div class="cpub-form-field">
|
|
349
|
+
<label class="cpub-form-label">Who can see this contest</label>
|
|
350
|
+
<select v-model="visibility" class="cpub-form-input">
|
|
351
|
+
<option value="public">Public — listed and visible to everyone</option>
|
|
352
|
+
<option value="unlisted">Unlisted — visible by direct link, hidden from listings</option>
|
|
353
|
+
<option value="private">Private — restricted</option>
|
|
354
|
+
</select>
|
|
355
|
+
</div>
|
|
356
|
+
<div v-if="visibility === 'private'" class="cpub-form-field">
|
|
357
|
+
<span class="cpub-form-label">Also visible to roles</span>
|
|
358
|
+
<div class="cpub-type-options" role="group" aria-label="Roles that can view">
|
|
359
|
+
<label v-for="r in ROLE_OPTIONS" :key="r" class="cpub-form-check">
|
|
360
|
+
<input type="checkbox" :checked="visibleToRoles.includes(r)" @change="toggleRole(r)" />
|
|
361
|
+
<span>{{ r }}</span>
|
|
362
|
+
</label>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
<div class="cpub-subhead">
|
|
366
|
+
<h3 class="cpub-form-subtitle">Reviewers</h3>
|
|
367
|
+
</div>
|
|
368
|
+
<p class="cpub-form-hint">Reviewers can view this contest (even while it's private or in draft) without being a judge or an admin — view access scoped to this contest only. They can't edit or score entries.</p>
|
|
369
|
+
<ContestStakeholderManager :contest-slug="slug" />
|
|
370
|
+
</section>
|
|
371
|
+
|
|
303
372
|
<!-- Judge panel (single source of truth: contest_judges table) -->
|
|
304
373
|
<section class="cpub-form-section">
|
|
305
374
|
<h2 class="cpub-form-section-title">Judges</h2>
|
|
@@ -309,6 +378,14 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
309
378
|
|
|
310
379
|
<section class="cpub-form-section">
|
|
311
380
|
<h2 class="cpub-form-section-title">Status Transitions</h2>
|
|
381
|
+
<p class="cpub-form-hint">
|
|
382
|
+
A contest moves through a lifecycle:
|
|
383
|
+
<strong>Upcoming</strong> → <strong>Active</strong> (accepting entries) →
|
|
384
|
+
<strong>Judging</strong> (entries closed, judges scoring) →
|
|
385
|
+
<strong>Completed</strong> (results & rankings published). You can cancel at any
|
|
386
|
+
point before it completes. Current status:
|
|
387
|
+
<span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
|
|
388
|
+
</p>
|
|
312
389
|
<div class="cpub-status-actions">
|
|
313
390
|
<button v-if="contest.status === 'upcoming'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-activate" @click="transitionStatus('active')">
|
|
314
391
|
<i class="fa-solid fa-play"></i> Start Contest
|
|
@@ -317,7 +394,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
317
394
|
<i class="fa-solid fa-gavel"></i> Begin Judging
|
|
318
395
|
</button>
|
|
319
396
|
<button v-if="contest.status === 'judging'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-complete" @click="transitionStatus('completed')">
|
|
320
|
-
<i class="fa-solid fa-flag-checkered"></i> Complete
|
|
397
|
+
<i class="fa-solid fa-flag-checkered"></i> Complete & Publish Results
|
|
321
398
|
</button>
|
|
322
399
|
<button
|
|
323
400
|
v-if="contest.status !== 'completed' && contest.status !== 'cancelled'"
|
|
@@ -327,12 +404,29 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
327
404
|
>
|
|
328
405
|
<i class="fa-solid fa-ban"></i> Cancel Contest
|
|
329
406
|
</button>
|
|
407
|
+
<p v-if="contest.status === 'completed' || contest.status === 'cancelled'" class="cpub-status-terminal">
|
|
408
|
+
<i class="fa-solid fa-circle-check"></i>
|
|
409
|
+
This contest is {{ contest.status }} — no further status changes are available.
|
|
410
|
+
</p>
|
|
330
411
|
</div>
|
|
331
412
|
</section>
|
|
332
413
|
|
|
333
414
|
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
|
|
334
415
|
<i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving...' : 'Save Changes' }}
|
|
335
416
|
</button>
|
|
417
|
+
|
|
418
|
+
<section class="cpub-form-section cpub-danger-zone">
|
|
419
|
+
<h2 class="cpub-form-section-title cpub-danger-title">Danger Zone</h2>
|
|
420
|
+
<div class="cpub-danger-row">
|
|
421
|
+
<div>
|
|
422
|
+
<p class="cpub-danger-label">Delete this contest</p>
|
|
423
|
+
<p class="cpub-form-hint">Permanently removes the contest and all of its entries, judges, and reviewers. This cannot be undone.</p>
|
|
424
|
+
</div>
|
|
425
|
+
<button type="button" class="cpub-btn cpub-btn-danger cpub-danger-btn" :disabled="deleting" @click="handleDelete">
|
|
426
|
+
<i class="fa-solid fa-trash"></i> {{ deleting ? 'Deleting...' : 'Delete Contest' }}
|
|
427
|
+
</button>
|
|
428
|
+
</div>
|
|
429
|
+
</section>
|
|
336
430
|
</form>
|
|
337
431
|
</div>
|
|
338
432
|
<div v-else class="cpub-not-found"><p>Contest not found</p></div>
|
|
@@ -387,6 +481,16 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
387
481
|
.cpub-transition-complete { color: var(--accent); border-color: var(--accent-border); }
|
|
388
482
|
.cpub-transition-cancel { color: var(--red); border-color: var(--red-border); }
|
|
389
483
|
|
|
484
|
+
.cpub-status-terminal { font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 8px; margin: 0; }
|
|
485
|
+
.cpub-status-terminal i { color: var(--green); }
|
|
486
|
+
|
|
487
|
+
.cpub-danger-zone { border-color: var(--red-border); }
|
|
488
|
+
.cpub-danger-title { color: var(--red); }
|
|
489
|
+
.cpub-danger-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
|
490
|
+
.cpub-danger-label { font-size: 13px; font-weight: 600; margin: 0 0 2px; }
|
|
491
|
+
.cpub-danger-btn { color: var(--red); border-color: var(--red-border); flex-shrink: 0; }
|
|
492
|
+
.cpub-danger-btn:hover:not(:disabled) { background: var(--red-bg); }
|
|
493
|
+
|
|
390
494
|
.cpub-not-found { text-align: center; padding: 64px; color: var(--text-dim); display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
|
391
495
|
|
|
392
496
|
@media (max-width: 768px) {
|
|
@@ -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
|
|
@@ -233,7 +259,8 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
233
259
|
<div class="cpub-about-section">
|
|
234
260
|
<div class="cpub-sec-head"><h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2></div>
|
|
235
261
|
<div class="cpub-about-card">
|
|
236
|
-
<
|
|
262
|
+
<CpubMarkdown v-if="c?.description" :source="c.description" />
|
|
263
|
+
<p v-else>No description available for this contest.</p>
|
|
237
264
|
</div>
|
|
238
265
|
</div>
|
|
239
266
|
<ContestJudgingCriteria v-if="c?.judgingCriteria?.length" :criteria="c.judgingCriteria" />
|
|
@@ -261,6 +288,23 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
261
288
|
/>
|
|
262
289
|
</div>
|
|
263
290
|
|
|
291
|
+
<!-- PARTICIPANTS -->
|
|
292
|
+
<div v-show="activeTab === 'participants'" id="cpub-panel-participants" role="tabpanel" aria-labelledby="cpub-tab-participants" tabindex="0">
|
|
293
|
+
<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>
|
|
294
|
+
<div class="cpub-participant-grid">
|
|
295
|
+
<NuxtLink v-for="p in participants" :key="p.username" :to="`/u/${p.username}`" class="cpub-participant">
|
|
296
|
+
<span class="cpub-participant-av">
|
|
297
|
+
<img v-if="p.avatar" :src="p.avatar" :alt="p.name" />
|
|
298
|
+
<span v-else>{{ (p.name || p.username || '?').charAt(0).toUpperCase() }}</span>
|
|
299
|
+
</span>
|
|
300
|
+
<span class="cpub-participant-info">
|
|
301
|
+
<span class="cpub-participant-name">{{ p.name }}</span>
|
|
302
|
+
<span class="cpub-participant-meta">{{ p.count }} {{ p.count === 1 ? 'entry' : 'entries' }}</span>
|
|
303
|
+
</span>
|
|
304
|
+
</NuxtLink>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
264
308
|
<!-- JUDGES -->
|
|
265
309
|
<div v-show="activeTab === 'judges'" id="cpub-panel-judges" role="tabpanel" aria-labelledby="cpub-tab-judges" tabindex="0">
|
|
266
310
|
<ContestJudges :judges="judges" />
|
|
@@ -309,9 +353,25 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
309
353
|
|
|
310
354
|
[role="tabpanel"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
|
311
355
|
|
|
356
|
+
/* VISIBILITY BANNER */
|
|
357
|
+
.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); }
|
|
358
|
+
.cpub-visibility-banner i { color: var(--accent); }
|
|
359
|
+
|
|
312
360
|
/* SECTION HEADERS */
|
|
313
361
|
.cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
|
314
362
|
.cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
|
363
|
+
.cpub-sec-sub { font-size: 11px; color: var(--text-faint); margin-left: auto; font-family: var(--font-mono); }
|
|
364
|
+
|
|
365
|
+
/* PARTICIPANTS */
|
|
366
|
+
.cpub-participant-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
|
|
367
|
+
.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; }
|
|
368
|
+
.cpub-participant:hover { box-shadow: var(--shadow-accent); }
|
|
369
|
+
.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; }
|
|
370
|
+
.cpub-participant-av img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
|
371
|
+
.cpub-participant-info { display: flex; flex-direction: column; min-width: 0; }
|
|
372
|
+
.cpub-participant-name { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
373
|
+
.cpub-participant-meta { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); }
|
|
374
|
+
@media (max-width: 480px) { .cpub-participant-grid { grid-template-columns: 1fr; } }
|
|
315
375
|
|
|
316
376
|
/* ABOUT */
|
|
317
377
|
.cpub-about-section { margin-bottom: 20px; }
|
|
@@ -5,7 +5,14 @@ const route = useRoute();
|
|
|
5
5
|
const slug = route.params.slug as string;
|
|
6
6
|
|
|
7
7
|
const { data: contest } = useLazyFetch<Serialized<ContestDetail>>(`/api/contests/${slug}`);
|
|
8
|
-
|
|
8
|
+
// Full standings: rank-ordered (not submit-ordered) + a high cap so every
|
|
9
|
+
// finalist surfaces, not just the 20 most-recent entries.
|
|
10
|
+
const { data: entriesData } = useLazyFetch<{ items: Serialized<ContestEntryItem>[]; total: number }>(
|
|
11
|
+
`/api/contests/${slug}/entries`,
|
|
12
|
+
{ query: { order: 'rank', limit: 100 } },
|
|
13
|
+
);
|
|
14
|
+
const totalEntries = computed(() => entriesData.value?.total ?? 0);
|
|
15
|
+
const shownEntries = computed(() => rankedEntries.value.length);
|
|
9
16
|
const { data: votesData } = useLazyFetch<ContestEntryVoteInfo[]>(`/api/contests/${slug}/votes`);
|
|
10
17
|
|
|
11
18
|
useSeoMeta({
|
|
@@ -43,8 +50,8 @@ const leaderboard = computed(() => rankedEntries.value);
|
|
|
43
50
|
|
|
44
51
|
const prizes = computed(() => contest.value?.prizes ?? []);
|
|
45
52
|
|
|
46
|
-
function prizeForRank(rank: number): { title
|
|
47
|
-
const prize = prizes.value.find((p: { place?: number; title
|
|
53
|
+
function prizeForRank(rank: number): { title?: string; value?: string } | null {
|
|
54
|
+
const prize = prizes.value.find((p: { place?: number; title?: string; value?: string }) => p.place === rank);
|
|
48
55
|
return prize ?? null;
|
|
49
56
|
}
|
|
50
57
|
|
|
@@ -120,7 +127,13 @@ function medalColor(rank: number): string {
|
|
|
120
127
|
|
|
121
128
|
<!-- LEADERBOARD -->
|
|
122
129
|
<div v-if="leaderboard.length > 0" class="cpub-leaderboard">
|
|
123
|
-
<
|
|
130
|
+
<div class="cpub-leaderboard-head">
|
|
131
|
+
<h2 class="cpub-leaderboard-title">Full Standings</h2>
|
|
132
|
+
<span class="cpub-leaderboard-count">
|
|
133
|
+
{{ totalEntries }} {{ totalEntries === 1 ? 'entry' : 'entries' }}
|
|
134
|
+
<template v-if="shownEntries < totalEntries"> · showing top {{ shownEntries }}</template>
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
124
137
|
<div class="cpub-leaderboard-scroll">
|
|
125
138
|
<table class="cpub-leaderboard-table">
|
|
126
139
|
<thead>
|
|
@@ -191,7 +204,9 @@ function medalColor(rank: number): string {
|
|
|
191
204
|
|
|
192
205
|
/* LEADERBOARD */
|
|
193
206
|
.cpub-leaderboard { margin-bottom: 32px; }
|
|
194
|
-
.cpub-leaderboard-
|
|
207
|
+
.cpub-leaderboard-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
|
|
208
|
+
.cpub-leaderboard-title { font-size: 16px; font-weight: 700; }
|
|
209
|
+
.cpub-leaderboard-count { font-size: 11px; font-family: var(--font-mono); color: var(--text-faint); }
|
|
195
210
|
/* Horizontal scroll on narrow screens instead of overflowing the page. */
|
|
196
211
|
.cpub-leaderboard-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
|
197
212
|
.cpub-leaderboard-table { width: 100%; border-collapse: collapse; font-size: 12px; min-width: 420px; }
|
|
@@ -8,6 +8,7 @@ const { extract: extractError } = useApiError();
|
|
|
8
8
|
const saving = ref(false);
|
|
9
9
|
|
|
10
10
|
const title = ref('');
|
|
11
|
+
const subheading = ref('');
|
|
11
12
|
const description = ref('');
|
|
12
13
|
const rules = ref('');
|
|
13
14
|
const bannerUrl = ref('');
|
|
@@ -17,6 +18,16 @@ const judgingEndDate = ref('');
|
|
|
17
18
|
const communityVotingEnabled = ref(false);
|
|
18
19
|
const judgingVisibility = ref<'public' | 'judges-only' | 'private'>('judges-only');
|
|
19
20
|
|
|
21
|
+
// Visibility & access
|
|
22
|
+
const visibility = ref<'public' | 'unlisted' | 'private'>('public');
|
|
23
|
+
const visibleToRoles = ref<string[]>([]);
|
|
24
|
+
const ROLE_OPTIONS = ['member', 'pro', 'verified', 'staff', 'admin'];
|
|
25
|
+
function toggleRole(r: string): void {
|
|
26
|
+
const i = visibleToRoles.value.indexOf(r);
|
|
27
|
+
if (i >= 0) visibleToRoles.value.splice(i, 1);
|
|
28
|
+
else visibleToRoles.value.push(r);
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
// Entry rules
|
|
21
32
|
const { enabledTypeMeta } = useContentTypes();
|
|
22
33
|
const eligibleContentTypes = ref<string[]>([]); // empty = all types allowed
|
|
@@ -80,6 +91,7 @@ async function handleCreate(): Promise<void> {
|
|
|
80
91
|
method: 'POST',
|
|
81
92
|
body: {
|
|
82
93
|
title: title.value,
|
|
94
|
+
subheading: subheading.value || undefined,
|
|
83
95
|
description: description.value || undefined,
|
|
84
96
|
rules: rules.value || undefined,
|
|
85
97
|
bannerUrl: bannerUrl.value || undefined,
|
|
@@ -88,16 +100,18 @@ async function handleCreate(): Promise<void> {
|
|
|
88
100
|
judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
|
|
89
101
|
communityVotingEnabled: communityVotingEnabled.value,
|
|
90
102
|
judgingVisibility: judgingVisibility.value,
|
|
103
|
+
visibility: visibility.value,
|
|
104
|
+
visibleToRoles: visibility.value === 'private' && visibleToRoles.value.length ? visibleToRoles.value : undefined,
|
|
91
105
|
eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
|
|
92
106
|
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
93
107
|
prizes: prizes.value
|
|
94
|
-
.filter(p => p.title.trim())
|
|
108
|
+
.filter(p => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
|
|
95
109
|
.map(p => ({
|
|
96
110
|
place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
|
|
97
111
|
category: p.category.trim() || undefined,
|
|
98
|
-
title: p.title,
|
|
99
|
-
description: p.description || undefined,
|
|
100
|
-
value: p.value || undefined,
|
|
112
|
+
title: p.title.trim() || undefined,
|
|
113
|
+
description: p.description.trim() || undefined,
|
|
114
|
+
value: p.value.trim() || undefined,
|
|
101
115
|
})),
|
|
102
116
|
judgingCriteria: criteria.value
|
|
103
117
|
.filter(c => c.label.trim())
|
|
@@ -117,10 +131,13 @@ async function handleCreate(): Promise<void> {
|
|
|
117
131
|
}
|
|
118
132
|
}
|
|
119
133
|
|
|
120
|
-
function prizeLabel(prize: Prize
|
|
134
|
+
function prizeLabel(prize: Prize): string {
|
|
121
135
|
if (prize.category.trim()) return prize.category;
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
if (prize.place && prize.place > 0) {
|
|
137
|
+
const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
|
|
138
|
+
return `${labels[prize.place - 1] || `${prize.place}th`} Place`;
|
|
139
|
+
}
|
|
140
|
+
return 'Prize';
|
|
124
141
|
}
|
|
125
142
|
</script>
|
|
126
143
|
|
|
@@ -137,17 +154,23 @@ function prizeLabel(prize: Prize, idx: number): string {
|
|
|
137
154
|
<label for="contest-title" class="cpub-form-label">Title</label>
|
|
138
155
|
<input id="contest-title" v-model="title" type="text" class="cpub-form-input" required placeholder="Maker Challenge 2026" />
|
|
139
156
|
</div>
|
|
157
|
+
<div class="cpub-form-field">
|
|
158
|
+
<label for="contest-subheading" class="cpub-form-label">Subheading</label>
|
|
159
|
+
<input id="contest-subheading" v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
|
|
160
|
+
<p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
|
|
161
|
+
</div>
|
|
140
162
|
<div class="cpub-form-field">
|
|
141
163
|
<label for="contest-desc" class="cpub-form-label">Description</label>
|
|
142
|
-
<textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="
|
|
164
|
+
<textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="4" placeholder="Describe your contest. Supports Markdown — # headings, - lists, **bold**, [links](url)…" />
|
|
165
|
+
<p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
|
|
143
166
|
</div>
|
|
144
167
|
<div class="cpub-form-field">
|
|
145
168
|
<label for="contest-rules" class="cpub-form-label">Rules</label>
|
|
146
|
-
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="
|
|
169
|
+
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="6" placeholder="Contest rules and requirements. Supports Markdown — one rule per line, or full Markdown." />
|
|
170
|
+
<p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
|
|
147
171
|
</div>
|
|
148
172
|
<div class="cpub-form-field">
|
|
149
|
-
<
|
|
150
|
-
<input id="contest-banner" v-model="bannerUrl" type="url" class="cpub-form-input" placeholder="https://..." />
|
|
173
|
+
<ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide image shown across the top of the contest page (~4:1)." />
|
|
151
174
|
</div>
|
|
152
175
|
</section>
|
|
153
176
|
|
|
@@ -171,6 +194,30 @@ function prizeLabel(prize: Prize, idx: number): string {
|
|
|
171
194
|
<p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
|
|
172
195
|
</section>
|
|
173
196
|
|
|
197
|
+
<!-- Visibility & Access -->
|
|
198
|
+
<section class="cpub-form-section">
|
|
199
|
+
<h2 class="cpub-form-section-title">Visibility & Access</h2>
|
|
200
|
+
<div class="cpub-form-field">
|
|
201
|
+
<label for="visibility" class="cpub-form-label">Who can see this contest</label>
|
|
202
|
+
<select id="visibility" v-model="visibility" class="cpub-form-input">
|
|
203
|
+
<option value="public">Public — listed and visible to everyone</option>
|
|
204
|
+
<option value="unlisted">Unlisted — visible by direct link, hidden from listings</option>
|
|
205
|
+
<option value="private">Private — restricted (you can publish it later)</option>
|
|
206
|
+
</select>
|
|
207
|
+
</div>
|
|
208
|
+
<div v-if="visibility === 'private'" class="cpub-form-field">
|
|
209
|
+
<span class="cpub-form-label">Also visible to roles</span>
|
|
210
|
+
<p class="cpub-form-hint">Owner, admins, judges, and reviewers (added after creation) can always see it. Optionally grant whole roles too.</p>
|
|
211
|
+
<div class="cpub-type-options" role="group" aria-label="Roles that can view">
|
|
212
|
+
<label v-for="r in ROLE_OPTIONS" :key="r" class="cpub-form-check">
|
|
213
|
+
<input type="checkbox" :checked="visibleToRoles.includes(r)" @change="toggleRole(r)" />
|
|
214
|
+
<span>{{ r }}</span>
|
|
215
|
+
</label>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
<p v-if="visibility === 'private'" class="cpub-form-hint">Add named reviewers (stakeholders) from the contest's Edit page after creating it.</p>
|
|
219
|
+
</section>
|
|
220
|
+
|
|
174
221
|
<!-- Entry Rules -->
|
|
175
222
|
<section class="cpub-form-section">
|
|
176
223
|
<h2 class="cpub-form-section-title">Entries</h2>
|
|
@@ -238,11 +285,11 @@ function prizeLabel(prize: Prize, idx: number): string {
|
|
|
238
285
|
</button>
|
|
239
286
|
</div>
|
|
240
287
|
|
|
241
|
-
<p class="cpub-form-hint">Use <strong>place</strong> for ranked prizes (1st/2nd/3rd)
|
|
288
|
+
<p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes (1st/2nd/3rd), a <strong>category</strong> for themed awards (e.g. "Best in Show"), or just a <strong>description</strong>. Cash value is optional.</p>
|
|
242
289
|
<div v-for="(prize, idx) in prizes" :key="idx" class="cpub-prize-card">
|
|
243
290
|
<div class="cpub-prize-header">
|
|
244
291
|
<span class="cpub-prize-place">
|
|
245
|
-
<i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize
|
|
292
|
+
<i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize) }}
|
|
246
293
|
</span>
|
|
247
294
|
<button v-if="prizes.length > 1" type="button" class="cpub-delete-btn" aria-label="Remove prize" @click="removePrize(idx)">
|
|
248
295
|
<i class="fa-solid fa-xmark"></i>
|
package/pages/notifications.vue
CHANGED
|
@@ -27,6 +27,14 @@ async function deleteNotification(id: string): Promise<void> {
|
|
|
27
27
|
await $fetch(`/api/notifications/${id}`, { method: 'DELETE' });
|
|
28
28
|
refresh();
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
// Fired when a row is clicked/navigated — mark it read so the unread highlight
|
|
32
|
+
// + nav badge clear. Fire-and-forget: navigation proceeds regardless.
|
|
33
|
+
function onNotificationRead(id: string): void {
|
|
34
|
+
$fetch('/api/notifications/read', { method: 'POST', body: { notificationId: id } })
|
|
35
|
+
.then(() => refresh())
|
|
36
|
+
.catch(() => {});
|
|
37
|
+
}
|
|
30
38
|
</script>
|
|
31
39
|
|
|
32
40
|
<template>
|
|
@@ -59,6 +67,7 @@ async function deleteNotification(id: string): Promise<void> {
|
|
|
59
67
|
v-for="n in filteredNotifications"
|
|
60
68
|
:key="n.id"
|
|
61
69
|
:notification="n"
|
|
70
|
+
@read="onNotificationRead"
|
|
62
71
|
/>
|
|
63
72
|
<div v-if="!filteredNotifications.length" class="cpub-empty-state">
|
|
64
73
|
<div class="cpub-empty-state-icon"><i class="fa-solid fa-bell-slash"></i></div>
|
|
@@ -6,7 +6,7 @@ const querySchema = z.object({
|
|
|
6
6
|
});
|
|
7
7
|
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
9
|
-
|
|
9
|
+
requirePermission(event, 'apikeys.manage');
|
|
10
10
|
const id = getRouterParam(event, 'id');
|
|
11
11
|
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing id' });
|
|
12
12
|
const parsed = querySchema.safeParse(getQuery(event));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { revokeApiKey, createAuditEntry } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
|
-
const user =
|
|
4
|
+
const user = requirePermission(event, 'apikeys.manage');
|
|
5
5
|
const id = getRouterParam(event, 'id');
|
|
6
6
|
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing id' });
|
|
7
7
|
const db = useDB();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { listApiKeys } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
|
-
|
|
4
|
+
requirePermission(event, 'apikeys.manage');
|
|
5
5
|
const query = getQuery(event);
|
|
6
6
|
const includeRevoked = query.includeRevoked === 'true' || query.includeRevoked === '1';
|
|
7
7
|
const db = useDB();
|
|
@@ -14,7 +14,7 @@ import { createApiKeySchema } from '@commonpub/schema';
|
|
|
14
14
|
* land in the metadata column.
|
|
15
15
|
*/
|
|
16
16
|
export default defineEventHandler(async (event) => {
|
|
17
|
-
const user =
|
|
17
|
+
const user = requirePermission(event, 'apikeys.manage');
|
|
18
18
|
const body = await readBody(event);
|
|
19
19
|
const parsed = createApiKeySchema.safeParse(body);
|
|
20
20
|
if (!parsed.success) {
|