@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.
Files changed (106) hide show
  1. package/components/ContentCard.vue +13 -3
  2. package/components/CpubMarkdown.vue +46 -0
  3. package/components/NotificationItem.vue +45 -14
  4. package/components/contest/ContestEntries.vue +6 -3
  5. package/components/contest/ContestHero.vue +23 -2
  6. package/components/contest/ContestPrizes.vue +2 -2
  7. package/components/contest/ContestRules.vue +9 -9
  8. package/components/contest/ContestStakeholderManager.vue +126 -0
  9. package/composables/useFeatures.ts +8 -0
  10. package/nuxt.config.ts +1 -0
  11. package/package.json +8 -8
  12. package/pages/contests/[slug]/edit.vue +119 -15
  13. package/pages/contests/[slug]/index.vue +61 -1
  14. package/pages/contests/[slug]/results.vue +20 -5
  15. package/pages/contests/create.vue +60 -13
  16. package/pages/events/[slug]/index.vue +1 -1
  17. package/pages/notifications.vue +9 -0
  18. package/server/api/admin/api-keys/[id]/usage.get.ts +1 -1
  19. package/server/api/admin/api-keys/[id].delete.ts +1 -1
  20. package/server/api/admin/api-keys/index.get.ts +1 -1
  21. package/server/api/admin/api-keys/index.post.ts +1 -1
  22. package/server/api/admin/audit.get.ts +1 -1
  23. package/server/api/admin/categories/[id].delete.ts +1 -1
  24. package/server/api/admin/categories/[id].patch.ts +1 -1
  25. package/server/api/admin/categories/index.get.ts +1 -1
  26. package/server/api/admin/categories/index.post.ts +1 -1
  27. package/server/api/admin/content/[id].delete.ts +1 -1
  28. package/server/api/admin/content/[id].patch.ts +1 -1
  29. package/server/api/admin/content/bulk-editorial.post.ts +1 -1
  30. package/server/api/admin/features/index.get.ts +1 -1
  31. package/server/api/admin/features/index.put.ts +1 -1
  32. package/server/api/admin/federation/activity.get.ts +1 -1
  33. package/server/api/admin/federation/clients.get.ts +1 -1
  34. package/server/api/admin/federation/clients.post.ts +1 -1
  35. package/server/api/admin/federation/hub-mirrors/[id]/backfill.post.ts +1 -1
  36. package/server/api/admin/federation/hub-mirrors/index.get.ts +1 -1
  37. package/server/api/admin/federation/hub-mirrors/index.post.ts +1 -1
  38. package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
  39. package/server/api/admin/federation/mirrors/[id].delete.ts +1 -1
  40. package/server/api/admin/federation/mirrors/[id].get.ts +1 -1
  41. package/server/api/admin/federation/mirrors/[id].put.ts +1 -1
  42. package/server/api/admin/federation/mirrors/index.get.ts +1 -1
  43. package/server/api/admin/federation/mirrors/index.post.ts +1 -1
  44. package/server/api/admin/federation/pending.get.ts +1 -1
  45. package/server/api/admin/federation/refederate.post.ts +1 -1
  46. package/server/api/admin/federation/repair-types.post.ts +1 -1
  47. package/server/api/admin/federation/retry.post.ts +1 -1
  48. package/server/api/admin/federation/stats.get.ts +1 -1
  49. package/server/api/admin/federation/trusted-instances.delete.ts +1 -1
  50. package/server/api/admin/federation/trusted-instances.get.ts +1 -1
  51. package/server/api/admin/federation/trusted-instances.post.ts +1 -1
  52. package/server/api/admin/homepage/sections.get.ts +1 -1
  53. package/server/api/admin/homepage/sections.put.ts +1 -1
  54. package/server/api/admin/layouts/[id]/publish.post.ts +1 -1
  55. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -1
  56. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -1
  57. package/server/api/admin/layouts/[id].delete.ts +1 -1
  58. package/server/api/admin/layouts/[id].get.ts +1 -1
  59. package/server/api/admin/layouts/[id].put.ts +1 -1
  60. package/server/api/admin/layouts/index.get.ts +1 -1
  61. package/server/api/admin/layouts/index.post.ts +1 -1
  62. package/server/api/admin/layouts/migrate-homepage.post.ts +1 -1
  63. package/server/api/admin/layouts/seed-homepage.post.ts +1 -1
  64. package/server/api/admin/navigation/items.get.ts +1 -1
  65. package/server/api/admin/navigation/items.put.ts +1 -1
  66. package/server/api/admin/reports/[id]/resolve.post.ts +1 -1
  67. package/server/api/admin/reports.get.ts +1 -1
  68. package/server/api/admin/search/reindex.post.ts +1 -1
  69. package/server/api/admin/settings.get.ts +1 -1
  70. package/server/api/admin/settings.put.ts +1 -1
  71. package/server/api/admin/stats.get.ts +1 -1
  72. package/server/api/admin/storage/backfill-cdn-urls.post.ts +1 -1
  73. package/server/api/admin/themes/[id].delete.ts +1 -1
  74. package/server/api/admin/themes/[id].get.ts +1 -1
  75. package/server/api/admin/themes/[id].put.ts +1 -1
  76. package/server/api/admin/themes/discover.get.ts +1 -1
  77. package/server/api/admin/themes/index.get.ts +1 -1
  78. package/server/api/admin/themes/index.post.ts +1 -1
  79. package/server/api/admin/users/[id]/role.put.ts +1 -1
  80. package/server/api/admin/users/[id]/status.put.ts +1 -1
  81. package/server/api/admin/users/[id].delete.ts +1 -1
  82. package/server/api/admin/users.get.ts +1 -1
  83. package/server/api/contests/[slug]/entries.get.ts +8 -2
  84. package/server/api/contests/[slug]/entries.post.ts +5 -1
  85. package/server/api/contests/[slug]/index.delete.ts +4 -1
  86. package/server/api/contests/[slug]/index.get.ts +7 -1
  87. package/server/api/contests/[slug]/judges/[userId].delete.ts +1 -1
  88. package/server/api/contests/[slug]/judges/index.get.ts +4 -1
  89. package/server/api/contests/[slug]/judges/index.post.ts +1 -1
  90. package/server/api/contests/[slug]/stakeholders/[userId].delete.ts +24 -0
  91. package/server/api/contests/[slug]/stakeholders/index.get.ts +21 -0
  92. package/server/api/contests/[slug]/stakeholders/index.post.ts +33 -0
  93. package/server/api/contests/[slug]/votes.get.ts +4 -1
  94. package/server/api/contests/index.get.ts +4 -1
  95. package/server/api/docs/migrate-content.post.ts +1 -1
  96. package/server/api/events/[slug].delete.ts +1 -1
  97. package/server/api/events/[slug].put.ts +1 -1
  98. package/server/api/layouts/by-route.get.ts +1 -1
  99. package/server/api/products/[id].delete.ts +1 -1
  100. package/server/api/videos/categories/[id].delete.ts +1 -1
  101. package/server/api/videos/categories/[id].put.ts +1 -1
  102. package/server/api/videos/categories.post.ts +1 -1
  103. package/server/middleware/auth.ts +22 -0
  104. package/server/utils/auth.ts +12 -5
  105. package/server/utils/permissions.ts +97 -0
  106. 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
- prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title: string; description?: string; value?: string }) => ({
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, idx: number): string {
87
+ function prizeLabel(prize: Prize): string {
75
88
  if (prize.category.trim()) return prize.category;
76
- const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
77
- return prize.place ? `${labels[prize.place - 1] || `${prize.place}th`} Place` : `${labels[idx] || `${idx + 1}th`} Place`;
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="3" />
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="4" placeholder="One rule per line" />
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
- <label class="cpub-form-label">Banner Image URL</label>
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, i) }}</span>
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 &amp; 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 &amp; 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 &amp; 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
- <p>{{ c?.description || 'No description available for this contest.' }}</p>
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
- const { data: entriesData } = useLazyFetch<{ items: Serialized<ContestEntryItem>[]; total: number }>(`/api/contests/${slug}/entries`);
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: string; value?: string } | null {
47
- const prize = prizes.value.find((p: { place?: number; title: string; value?: string }) => p.place === rank);
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
- <h2 class="cpub-leaderboard-title">Full Leaderboard</h2>
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-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; }
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, idx: number): string {
134
+ function prizeLabel(prize: Prize): string {
121
135
  if (prize.category.trim()) return prize.category;
122
- const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
123
- return `${labels[idx] || `${idx + 1}th`} Place`;
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="3" placeholder="Describe your contest..." />
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="4" placeholder="Contest rules and requirements (one per line)..." />
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
- <label for="contest-banner" class="cpub-form-label">Banner Image URL</label>
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 &amp; 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) or a <strong>category</strong> for themed awards (e.g. "Best in Show").</p>
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, idx) }}
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>
@@ -126,7 +126,7 @@ const typeIcon = computed(() => {
126
126
  </div>
127
127
 
128
128
  <div v-if="event.description" class="cpub-event-description">
129
- {{ event.description }}
129
+ <CpubMarkdown :source="event.description" />
130
130
  </div>
131
131
 
132
132
  <div class="cpub-event-organizer">
@@ -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
- requireAdmin(event);
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 = requireAdmin(event);
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
- requireAdmin(event);
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 = requireAdmin(event);
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) {