@commonpub/layer 0.29.0 → 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 (100) 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/composables/useFeatures.ts +8 -0
  9. package/nuxt.config.ts +1 -0
  10. package/package.json +8 -8
  11. package/pages/contests/[slug]/edit.vue +80 -15
  12. package/pages/contests/[slug]/index.vue +2 -1
  13. package/pages/contests/[slug]/results.vue +20 -5
  14. package/pages/contests/create.vue +24 -13
  15. package/pages/events/[slug]/index.vue +1 -1
  16. package/pages/notifications.vue +9 -0
  17. package/server/api/admin/api-keys/[id]/usage.get.ts +1 -1
  18. package/server/api/admin/api-keys/[id].delete.ts +1 -1
  19. package/server/api/admin/api-keys/index.get.ts +1 -1
  20. package/server/api/admin/api-keys/index.post.ts +1 -1
  21. package/server/api/admin/audit.get.ts +1 -1
  22. package/server/api/admin/categories/[id].delete.ts +1 -1
  23. package/server/api/admin/categories/[id].patch.ts +1 -1
  24. package/server/api/admin/categories/index.get.ts +1 -1
  25. package/server/api/admin/categories/index.post.ts +1 -1
  26. package/server/api/admin/content/[id].delete.ts +1 -1
  27. package/server/api/admin/content/[id].patch.ts +1 -1
  28. package/server/api/admin/content/bulk-editorial.post.ts +1 -1
  29. package/server/api/admin/features/index.get.ts +1 -1
  30. package/server/api/admin/features/index.put.ts +1 -1
  31. package/server/api/admin/federation/activity.get.ts +1 -1
  32. package/server/api/admin/federation/clients.get.ts +1 -1
  33. package/server/api/admin/federation/clients.post.ts +1 -1
  34. package/server/api/admin/federation/hub-mirrors/[id]/backfill.post.ts +1 -1
  35. package/server/api/admin/federation/hub-mirrors/index.get.ts +1 -1
  36. package/server/api/admin/federation/hub-mirrors/index.post.ts +1 -1
  37. package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
  38. package/server/api/admin/federation/mirrors/[id].delete.ts +1 -1
  39. package/server/api/admin/federation/mirrors/[id].get.ts +1 -1
  40. package/server/api/admin/federation/mirrors/[id].put.ts +1 -1
  41. package/server/api/admin/federation/mirrors/index.get.ts +1 -1
  42. package/server/api/admin/federation/mirrors/index.post.ts +1 -1
  43. package/server/api/admin/federation/pending.get.ts +1 -1
  44. package/server/api/admin/federation/refederate.post.ts +1 -1
  45. package/server/api/admin/federation/repair-types.post.ts +1 -1
  46. package/server/api/admin/federation/retry.post.ts +1 -1
  47. package/server/api/admin/federation/stats.get.ts +1 -1
  48. package/server/api/admin/federation/trusted-instances.delete.ts +1 -1
  49. package/server/api/admin/federation/trusted-instances.get.ts +1 -1
  50. package/server/api/admin/federation/trusted-instances.post.ts +1 -1
  51. package/server/api/admin/homepage/sections.get.ts +1 -1
  52. package/server/api/admin/homepage/sections.put.ts +1 -1
  53. package/server/api/admin/layouts/[id]/publish.post.ts +1 -1
  54. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -1
  55. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -1
  56. package/server/api/admin/layouts/[id].delete.ts +1 -1
  57. package/server/api/admin/layouts/[id].get.ts +1 -1
  58. package/server/api/admin/layouts/[id].put.ts +1 -1
  59. package/server/api/admin/layouts/index.get.ts +1 -1
  60. package/server/api/admin/layouts/index.post.ts +1 -1
  61. package/server/api/admin/layouts/migrate-homepage.post.ts +1 -1
  62. package/server/api/admin/layouts/seed-homepage.post.ts +1 -1
  63. package/server/api/admin/navigation/items.get.ts +1 -1
  64. package/server/api/admin/navigation/items.put.ts +1 -1
  65. package/server/api/admin/reports/[id]/resolve.post.ts +1 -1
  66. package/server/api/admin/reports.get.ts +1 -1
  67. package/server/api/admin/search/reindex.post.ts +1 -1
  68. package/server/api/admin/settings.get.ts +1 -1
  69. package/server/api/admin/settings.put.ts +1 -1
  70. package/server/api/admin/stats.get.ts +1 -1
  71. package/server/api/admin/storage/backfill-cdn-urls.post.ts +1 -1
  72. package/server/api/admin/themes/[id].delete.ts +1 -1
  73. package/server/api/admin/themes/[id].get.ts +1 -1
  74. package/server/api/admin/themes/[id].put.ts +1 -1
  75. package/server/api/admin/themes/discover.get.ts +1 -1
  76. package/server/api/admin/themes/index.get.ts +1 -1
  77. package/server/api/admin/themes/index.post.ts +1 -1
  78. package/server/api/admin/users/[id]/role.put.ts +1 -1
  79. package/server/api/admin/users/[id]/status.put.ts +1 -1
  80. package/server/api/admin/users/[id].delete.ts +1 -1
  81. package/server/api/admin/users.get.ts +1 -1
  82. package/server/api/contests/[slug]/entries.get.ts +3 -1
  83. package/server/api/contests/[slug]/index.delete.ts +4 -1
  84. package/server/api/contests/[slug]/judges/[userId].delete.ts +1 -1
  85. package/server/api/contests/[slug]/judges/index.post.ts +1 -1
  86. package/server/api/contests/[slug]/stakeholders/[userId].delete.ts +1 -1
  87. package/server/api/contests/[slug]/stakeholders/index.get.ts +1 -1
  88. package/server/api/contests/[slug]/stakeholders/index.post.ts +1 -1
  89. package/server/api/docs/migrate-content.post.ts +1 -1
  90. package/server/api/events/[slug].delete.ts +1 -1
  91. package/server/api/events/[slug].put.ts +1 -1
  92. package/server/api/layouts/by-route.get.ts +1 -1
  93. package/server/api/products/[id].delete.ts +1 -1
  94. package/server/api/videos/categories/[id].delete.ts +1 -1
  95. package/server/api/videos/categories/[id].put.ts +1 -1
  96. package/server/api/videos/categories.post.ts +1 -1
  97. package/server/middleware/auth.ts +22 -0
  98. package/server/utils/auth.ts +12 -5
  99. package/server/utils/permissions.ts +97 -0
  100. package/server/utils/requirePermission.ts +102 -0
@@ -294,6 +294,12 @@ function formatCount(n: number | undefined): string {
294
294
  min-width: 0;
295
295
  }
296
296
 
297
+ /* Two render modes share .cpub-cc-av: <img class="cpub-cc-av cpub-cc-av--img">
298
+ * (author photo) and <span class="cpub-cc-av"> (initials fallback). `display:flex`
299
+ * MUST NOT apply to the <img> — a replaced element with display:flex silently
300
+ * drops `object-fit:cover` in Chromium, squishing portrait avatars into the box
301
+ * (the recurring deveco.io blog-card bug). Centering is scoped to the span; the
302
+ * img keeps object-fit only. See ArticleView.vue for the same fix. */
297
303
  .cpub-cc-av {
298
304
  width: 18px;
299
305
  height: 18px;
@@ -303,14 +309,18 @@ function formatCount(n: number | undefined): string {
303
309
  font-size: 8px;
304
310
  font-weight: 700;
305
311
  font-family: var(--font-mono);
312
+ flex-shrink: 0;
313
+ border-radius: 50%;
314
+ }
315
+
316
+ span.cpub-cc-av {
306
317
  display: flex;
307
318
  align-items: center;
308
319
  justify-content: center;
309
- flex-shrink: 0;
310
- border-radius: 50%;
311
320
  }
312
321
 
313
- .cpub-cc-av--img {
322
+ .cpub-cc-av--img,
323
+ img.cpub-cc-av {
314
324
  object-fit: cover;
315
325
  }
316
326
 
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Renders a Markdown (or HTML-in-Markdown) string as formatted content, reusing
4
+ * the SAME pipeline articles/docs use: `markdownToBlockTuples` → the block
5
+ * content renderer. This is why a contest description like `## Mission` +
6
+ * `- **point**` + `[link](url)` renders as real headings/lists/links instead of
7
+ * the raw-markdown wall it used to show.
8
+ *
9
+ * Source is parsed once (computed) and memoised by Vue. Empty / parse-failure
10
+ * falls back to plain text so content never disappears.
11
+ */
12
+ import { markdownToBlockTuples } from '@commonpub/editor';
13
+ import type { BlockTuple } from '@commonpub/editor';
14
+
15
+ const props = defineProps<{
16
+ /** Markdown source (may contain inline/block HTML — passed through). */
17
+ source?: string | null;
18
+ }>();
19
+
20
+ const trimmed = computed(() => (props.source ?? '').trim());
21
+
22
+ const blocks = computed<BlockTuple[]>(() => {
23
+ if (!trimmed.value) return [];
24
+ try {
25
+ return markdownToBlockTuples(trimmed.value);
26
+ } catch {
27
+ return [];
28
+ }
29
+ });
30
+ </script>
31
+
32
+ <template>
33
+ <BlocksBlockContentRenderer
34
+ v-if="blocks.length"
35
+ :blocks="(blocks as [string, Record<string, unknown>][])"
36
+ class="cpub-prose cpub-md"
37
+ />
38
+ <p v-else-if="trimmed" class="cpub-md cpub-md-plain">{{ trimmed }}</p>
39
+ </template>
40
+
41
+ <style scoped>
42
+ .cpub-md-plain {
43
+ white-space: pre-wrap;
44
+ line-height: 1.7;
45
+ }
46
+ </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- defineProps<{
2
+ const props = defineProps<{
3
3
  notification: {
4
4
  id: string;
5
5
  type: string;
@@ -13,6 +13,18 @@ defineProps<{
13
13
  };
14
14
  }>();
15
15
 
16
+ const emit = defineEmits<{ read: [id: string] }>();
17
+
18
+ // The whole row is the click target when there's somewhere to go (previously
19
+ // only the tiny right-hand arrow navigated). When there's a destination the
20
+ // root renders as a NuxtLink so keyboard / middle-click / open-in-new-tab all
21
+ // work; otherwise it stays a plain div.
22
+ const destination = computed(() => props.notification.link || props.notification.targetUrl || null);
23
+
24
+ function onActivate(): void {
25
+ if (!props.notification.read) emit('read', props.notification.id);
26
+ }
27
+
16
28
  const iconMap: Record<string, string> = {
17
29
  like: 'fa-solid fa-heart',
18
30
  comment: 'fa-solid fa-comment',
@@ -28,7 +40,14 @@ const iconMap: Record<string, string> = {
28
40
  </script>
29
41
 
30
42
  <template>
31
- <div class="cpub-notif" :class="{ 'cpub-notif-unread': !notification.read }">
43
+ <component
44
+ :is="destination ? 'NuxtLink' : 'div'"
45
+ :to="destination || undefined"
46
+ class="cpub-notif"
47
+ :class="{ 'cpub-notif-unread': !notification.read, 'cpub-notif-link-row': destination }"
48
+ :aria-label="destination ? `${notification.actorName ? notification.actorName + ' ' : ''}${notification.message}` : undefined"
49
+ @click="onActivate"
50
+ >
32
51
  <div class="cpub-notif-avatar-wrap">
33
52
  <img v-if="notification.actorAvatarUrl" :src="notification.actorAvatarUrl" :alt="notification.actorName ?? ''" class="cpub-notif-avatar" />
34
53
  <div v-else class="cpub-notif-avatar cpub-notif-avatar-fallback">
@@ -47,10 +66,8 @@ const iconMap: Record<string, string> = {
47
66
  {{ new Date(notification.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
48
67
  </time>
49
68
  </div>
50
- <NuxtLink v-if="notification.link || notification.targetUrl" :to="notification.link || notification.targetUrl || '#'" class="cpub-notif-link" :aria-label="`View ${notification.type} notification`">
51
- <i class="fa-solid fa-arrow-right"></i>
52
- </NuxtLink>
53
- </div>
69
+ <i v-if="destination" class="fa-solid fa-chevron-right cpub-notif-chevron" aria-hidden="true"></i>
70
+ </component>
54
71
  </template>
55
72
 
56
73
  <style scoped>
@@ -61,6 +78,18 @@ const iconMap: Record<string, string> = {
61
78
  padding: 12px;
62
79
  border: var(--border-width-default) solid transparent;
63
80
  border-bottom: var(--border-width-default) solid var(--border2);
81
+ color: inherit;
82
+ text-decoration: none;
83
+ }
84
+
85
+ /* Whole-row link affordance: the entire item is the click target. */
86
+ .cpub-notif-link-row {
87
+ cursor: pointer;
88
+ transition: background 0.12s ease;
89
+ }
90
+
91
+ .cpub-notif-link-row:hover {
92
+ background: var(--surface2);
64
93
  }
65
94
 
66
95
  .cpub-notif.cpub-notif-unread {
@@ -68,6 +97,10 @@ const iconMap: Record<string, string> = {
68
97
  border-color: var(--accent-border);
69
98
  }
70
99
 
100
+ .cpub-notif-link-row.cpub-notif-unread:hover {
101
+ background: var(--accent-bg-hover, var(--accent-bg));
102
+ }
103
+
71
104
  .cpub-notif-avatar-wrap {
72
105
  position: relative;
73
106
  width: 32px;
@@ -126,18 +159,16 @@ const iconMap: Record<string, string> = {
126
159
  font-family: var(--font-mono);
127
160
  }
128
161
 
129
- .cpub-notif-link {
130
- width: 24px;
131
- height: 24px;
132
- display: flex;
133
- align-items: center;
134
- justify-content: center;
162
+ /* Decorative chevron — signals the whole row navigates (the row itself is the
163
+ link now; this is aria-hidden, not a separate tab stop). */
164
+ .cpub-notif-chevron {
165
+ align-self: center;
166
+ flex-shrink: 0;
135
167
  color: var(--text-faint);
136
- text-decoration: none;
137
168
  font-size: 10px;
138
169
  }
139
170
 
140
- .cpub-notif-link:hover {
171
+ .cpub-notif-link-row:hover .cpub-notif-chevron {
141
172
  color: var(--accent);
142
173
  }
143
174
  </style>
@@ -149,10 +149,13 @@ function confirmWithdraw(entryId: string): void {
149
149
  .cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
150
150
  .cpub-sec-sub { font-size: 11px; color: var(--text-faint); margin-left: auto; font-family: var(--font-mono); }
151
151
 
152
- .cpub-entry-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
153
- .cpub-entry-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-md); }
152
+ /* Match the content-card grid: responsive auto-fill columns + a 4:3 cover
153
+ (was a rigid 2-col grid with a squat fixed 110px strip that over-cropped
154
+ the cover photo). */
155
+ .cpub-entry-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-bottom: 12px; }
156
+ .cpub-entry-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-md); display: flex; flex-direction: column; }
154
157
  .cpub-entry-card:hover { box-shadow: var(--shadow-accent); }
155
- .cpub-entry-thumb { height: 110px; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; }
158
+ .cpub-entry-thumb { aspect-ratio: 4 / 3; width: 100%; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; }
156
159
  .cpub-entry-bg-light { background: var(--surface2); }
157
160
  .cpub-entry-bg-dark { background: var(--surface3); }
158
161
  .cpub-entry-grid-pat { position: absolute; inset: 0; background-image: linear-gradient(var(--border2) 1px, transparent 1px), linear-gradient(90deg, var(--border2) 1px, transparent 1px); background-size: 20px 20px; opacity: .3; }
@@ -53,6 +53,27 @@ const countdownLabel = computed(() => {
53
53
  });
54
54
 
55
55
  const isEnded = computed(() => c.value?.status === 'completed' || c.value?.status === 'cancelled');
56
+
57
+ // The hero shows the short `subheading` (a dedicated tagline field). For older
58
+ // contests without one, fall back to a clean, plain-text, CSS-clamped excerpt of
59
+ // the (possibly long Markdown) description — so the hero never dumps a raw
60
+ // `## ...` wall. The full formatted description renders in the About tab.
61
+ const tagline = computed<string>(() => {
62
+ const sub = (c.value?.subheading ?? '').trim();
63
+ if (sub) return sub;
64
+ const d = (c.value?.description ?? '').trim();
65
+ if (!d) return 'No description available.';
66
+ return d
67
+ .replace(/```[\s\S]*?```/g, ' ')
68
+ .replace(/`([^`]*)`/g, '$1')
69
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
70
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
71
+ .replace(/^#{1,6}\s+/gm, '')
72
+ .replace(/^\s*[-*+>]\s+/gm, '')
73
+ .replace(/(\*\*|__|~~|\*|_)/g, '')
74
+ .replace(/\s+/g, ' ')
75
+ .trim();
76
+ });
56
77
  </script>
57
78
 
58
79
  <template>
@@ -72,7 +93,7 @@ const isEnded = computed(() => c.value?.status === 'completed' || c.value?.statu
72
93
  </div>
73
94
 
74
95
  <div class="cpub-hero-title">{{ c?.title || 'Contest' }}</div>
75
- <div class="cpub-hero-tagline">{{ c?.description || 'No description available.' }}</div>
96
+ <div class="cpub-hero-tagline">{{ tagline }}</div>
76
97
 
77
98
  <div class="cpub-hero-meta">
78
99
  <span v-if="c?.startDate || c?.endDate" class="cpub-hero-meta-item">
@@ -158,7 +179,7 @@ const isEnded = computed(() => c.value?.status === 'completed' || c.value?.statu
158
179
  .cpub-contest-badge { font-size: 9px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; font-family: var(--font-mono); color: var(--accent); background: var(--accent-bg); border: var(--border-width-default) solid var(--accent); padding: 3px 10px; border-radius: var(--radius); display: inline-flex; align-items: center; gap: 5px; }
159
180
  .cpub-contest-badge i { font-size: 8px; }
160
181
  .cpub-hero-title { font-size: 36px; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin-bottom: 10px; color: var(--hero-text); }
161
- .cpub-hero-tagline { font-size: 14px; color: var(--hero-text-dim); line-height: 1.55; max-width: 580px; margin-bottom: 28px; }
182
+ .cpub-hero-tagline { font-size: 14px; color: var(--hero-text-dim); line-height: 1.55; max-width: 580px; margin-bottom: 28px; display: -webkit-box; -webkit-line-clamp: 4; line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
162
183
  .cpub-hero-meta { display: flex; align-items: center; gap: 20px; font-size: 11px; color: var(--hero-text-dim); font-family: var(--font-mono); margin-bottom: 28px; }
163
184
  .cpub-hero-meta-item { display: flex; align-items: center; gap: 5px; }
164
185
  .cpub-hero-meta-sep { color: var(--hero-border); }
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- interface Prize { place?: number; category?: string; title: string; description?: string; value?: string }
2
+ interface Prize { place?: number; category?: string; title?: string; description?: string; value?: string }
3
3
  defineProps<{
4
4
  prizes: Prize[];
5
5
  }>();
@@ -45,7 +45,7 @@ function prizeIcon(prize: Prize): string {
45
45
  <div class="cpub-prize-rank" :class="`cpub-prize-rank-${prizeColor(prize)}`">{{ prizeLabel(prize) }}</div>
46
46
  <div class="cpub-prize-icon" :class="`cpub-prize-icon-${prizeColor(prize)}`"><i class="fa-solid" :class="prizeIcon(prize)"></i></div>
47
47
  <div v-if="prize.value" class="cpub-prize-amount" :class="`cpub-prize-amount-${prizeColor(prize)}`">{{ prize.value }}</div>
48
- <div class="cpub-prize-title">{{ prize.title }}</div>
48
+ <div v-if="prize.title" class="cpub-prize-title">{{ prize.title }}</div>
49
49
  <div v-if="prize.description" class="cpub-prize-desc">{{ prize.description }}</div>
50
50
  </div>
51
51
  </div>
@@ -1,11 +1,14 @@
1
1
  <script setup lang="ts">
2
- const props = defineProps<{
2
+ /**
3
+ * Rules are authored as Markdown (and may contain inline HTML) — rendered the
4
+ * same way as the contest description, so headings/lists/links/HTML all format
5
+ * properly. Plain prose and plain one-rule-per-line text render fine through the
6
+ * Markdown pipeline too (as paragraphs / a tight list), so there's no separate
7
+ * "numbered list" special-case anymore (that produced the odd forced-list look).
8
+ */
9
+ defineProps<{
3
10
  rules: string;
4
11
  }>();
5
-
6
- const ruleLines = computed(() =>
7
- props.rules.split('\n').filter((line) => line.trim().length > 0),
8
- );
9
12
  </script>
10
13
 
11
14
  <template>
@@ -14,10 +17,7 @@ const ruleLines = computed(() =>
14
17
  <h2><i class="fa fa-file-lines" style="color: var(--purple);"></i> Rules</h2>
15
18
  </div>
16
19
  <div class="cpub-rules-card">
17
- <ol v-if="ruleLines.length > 1" class="cpub-rules-list">
18
- <li v-for="(line, i) in ruleLines" :key="i" class="cpub-rule-item">{{ line }}</li>
19
- </ol>
20
- <div v-else class="cpub-rules-text">{{ rules }}</div>
20
+ <CpubMarkdown :source="rules" />
21
21
  </div>
22
22
  </div>
23
23
  </template>
@@ -34,6 +34,12 @@ export interface FeatureFlags {
34
34
  * a default layout exists at scope ('route', '/'). Added session 158.
35
35
  */
36
36
  layoutEngine: boolean;
37
+ /**
38
+ * Global RBAC (session 175). Client-advisory only — drives `useCan`'s
39
+ * button-hiding; the server resolver is the enforcement boundary. Default
40
+ * OFF. See docs/plans/rbac.md.
41
+ */
42
+ rbac: boolean;
37
43
  /**
38
44
  * Cross-instance delegated authorization. All sub-flags default false.
39
45
  * Mirrors `@commonpub/config`'s `IdentityFeatures`. Phase 1b+ — see
@@ -55,6 +61,7 @@ export const DEFAULT_FLAGS: FeatureFlags = {
55
61
  editorial: true, federation: false, admin: false, emailNotifications: false,
56
62
  publicApi: false, contentImport: true,
57
63
  layoutEngine: false,
64
+ rbac: false,
58
65
  identity: {
59
66
  linkRemoteAccounts: false,
60
67
  signInWithRemote: false,
@@ -157,6 +164,7 @@ export function useFeatures() {
157
164
  publicApi: computed(() => flags.value.publicApi),
158
165
  contentImport: computed(() => flags.value.contentImport),
159
166
  layoutEngine: computed(() => flags.value.layoutEngine),
167
+ rbac: computed(() => flags.value.rbac),
160
168
  identity: computed(() => flags.value.identity),
161
169
  };
162
170
  }
package/nuxt.config.ts CHANGED
@@ -109,6 +109,7 @@ export default defineNuxtConfig({
109
109
  publicApi: false,
110
110
  contentImport: true,
111
111
  layoutEngine: false,
112
+ rbac: false,
112
113
  },
113
114
  contentTypes: 'project,blog,explainer',
114
115
  contestCreation: 'admin',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -53,16 +53,16 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/auth": "0.6.0",
57
- "@commonpub/config": "0.15.0",
56
+ "@commonpub/auth": "0.7.0",
58
57
  "@commonpub/docs": "0.6.3",
59
- "@commonpub/explainer": "0.7.15",
58
+ "@commonpub/config": "0.16.0",
60
59
  "@commonpub/learning": "0.5.2",
61
- "@commonpub/schema": "0.22.0",
62
- "@commonpub/server": "2.63.0",
63
- "@commonpub/ui": "0.9.1",
60
+ "@commonpub/explainer": "0.7.15",
64
61
  "@commonpub/editor": "0.7.11",
65
- "@commonpub/protocol": "0.12.0"
62
+ "@commonpub/schema": "0.23.0",
63
+ "@commonpub/ui": "0.9.1",
64
+ "@commonpub/protocol": "0.12.0",
65
+ "@commonpub/server": "2.64.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -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('');
@@ -50,6 +51,7 @@ const criteria = ref<Criterion[]>([]);
50
51
  watch(contest, (c) => {
51
52
  if (!c) return;
52
53
  title.value = c.title ?? '';
54
+ subheading.value = c.subheading ?? '';
53
55
  description.value = c.description ?? '';
54
56
  rules.value = c.rules ?? '';
55
57
  bannerUrl.value = c.bannerUrl ?? '';
@@ -62,10 +64,10 @@ watch(contest, (c) => {
62
64
  maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
63
65
  visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
64
66
  visibleToRoles.value = [...(c.visibleToRoles ?? [])];
65
- prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title: string; description?: string; value?: string }) => ({
67
+ prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
66
68
  place: p.place ?? null,
67
69
  category: p.category ?? '',
68
- title: p.title,
70
+ title: p.title ?? '',
69
71
  description: p.description ?? '',
70
72
  value: p.value ?? '',
71
73
  }));
@@ -82,10 +84,15 @@ function addPrize(): void {
82
84
  function removePrize(index: number): void {
83
85
  prizes.value.splice(index, 1);
84
86
  }
85
- function prizeLabel(prize: Prize, idx: number): string {
87
+ function prizeLabel(prize: Prize): string {
86
88
  if (prize.category.trim()) return prize.category;
87
- const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
88
- 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';
89
96
  }
90
97
 
91
98
  function addCriterion(): void {
@@ -111,13 +118,13 @@ async function handleSave(): Promise<void> {
111
118
  saving.value = true;
112
119
  try {
113
120
  const prizeData = prizes.value
114
- .filter((p) => p.title.trim())
121
+ .filter((p) => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
115
122
  .map((p) => ({
116
123
  place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
117
124
  category: p.category.trim() || undefined,
118
- title: p.title,
119
- description: p.description || undefined,
120
- value: p.value || undefined,
125
+ title: p.title.trim() || undefined,
126
+ description: p.description.trim() || undefined,
127
+ value: p.value.trim() || undefined,
121
128
  }));
122
129
  const criteriaData = criteria.value
123
130
  .filter((c) => c.label.trim())
@@ -131,6 +138,7 @@ async function handleSave(): Promise<void> {
131
138
  method: 'PUT',
132
139
  body: {
133
140
  title: title.value,
141
+ subheading: subheading.value || undefined,
134
142
  description: description.value || undefined,
135
143
  rules: rules.value || undefined,
136
144
  bannerUrl: bannerUrl.value || undefined,
@@ -156,6 +164,20 @@ async function handleSave(): Promise<void> {
156
164
  }
157
165
  }
158
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
+
159
181
  async function transitionStatus(newStatus: string): Promise<void> {
160
182
  const msg = newStatus === 'cancelled'
161
183
  ? 'Cancel this contest? This cannot be undone.'
@@ -190,17 +212,23 @@ async function transitionStatus(newStatus: string): Promise<void> {
190
212
  <label class="cpub-form-label">Title</label>
191
213
  <input v-model="title" type="text" class="cpub-form-input" />
192
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>
193
220
  <div class="cpub-form-field">
194
221
  <label class="cpub-form-label">Description</label>
195
- <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>
196
224
  </div>
197
225
  <div class="cpub-form-field">
198
226
  <label class="cpub-form-label">Rules</label>
199
- <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>
200
229
  </div>
201
230
  <div class="cpub-form-field">
202
- <label class="cpub-form-label">Banner Image URL</label>
203
- <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)." />
204
232
  </div>
205
233
  </section>
206
234
 
@@ -243,9 +271,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
243
271
 
244
272
  <section class="cpub-form-section">
245
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>
246
275
  <div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
247
276
  <div class="cpub-prize-header">
248
- <span class="cpub-prize-label">{{ prizeLabel(prize, i) }}</span>
277
+ <span class="cpub-prize-label">{{ prizeLabel(prize) }}</span>
249
278
  <button type="button" class="cpub-prize-remove" aria-label="Remove prize" @click="removePrize(i)"><i class="fa-solid fa-times"></i></button>
250
279
  </div>
251
280
  <div class="cpub-form-row">
@@ -336,6 +365,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
336
365
  <div class="cpub-subhead">
337
366
  <h3 class="cpub-form-subtitle">Reviewers</h3>
338
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>
339
369
  <ContestStakeholderManager :contest-slug="slug" />
340
370
  </section>
341
371
 
@@ -348,6 +378,14 @@ async function transitionStatus(newStatus: string): Promise<void> {
348
378
 
349
379
  <section class="cpub-form-section">
350
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>
351
389
  <div class="cpub-status-actions">
352
390
  <button v-if="contest.status === 'upcoming'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-activate" @click="transitionStatus('active')">
353
391
  <i class="fa-solid fa-play"></i> Start Contest
@@ -356,7 +394,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
356
394
  <i class="fa-solid fa-gavel"></i> Begin Judging
357
395
  </button>
358
396
  <button v-if="contest.status === 'judging'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-complete" @click="transitionStatus('completed')">
359
- <i class="fa-solid fa-flag-checkered"></i> Complete
397
+ <i class="fa-solid fa-flag-checkered"></i> Complete &amp; Publish Results
360
398
  </button>
361
399
  <button
362
400
  v-if="contest.status !== 'completed' && contest.status !== 'cancelled'"
@@ -366,12 +404,29 @@ async function transitionStatus(newStatus: string): Promise<void> {
366
404
  >
367
405
  <i class="fa-solid fa-ban"></i> Cancel Contest
368
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>
369
411
  </div>
370
412
  </section>
371
413
 
372
414
  <button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
373
415
  <i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving...' : 'Save Changes' }}
374
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>
375
430
  </form>
376
431
  </div>
377
432
  <div v-else class="cpub-not-found"><p>Contest not found</p></div>
@@ -426,6 +481,16 @@ async function transitionStatus(newStatus: string): Promise<void> {
426
481
  .cpub-transition-complete { color: var(--accent); border-color: var(--accent-border); }
427
482
  .cpub-transition-cancel { color: var(--red); border-color: var(--red-border); }
428
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
+
429
494
  .cpub-not-found { text-align: center; padding: 64px; color: var(--text-dim); display: flex; flex-direction: column; align-items: center; gap: 12px; }
430
495
 
431
496
  @media (max-width: 768px) {
@@ -259,7 +259,8 @@ async function withdrawEntry(entryId: string): Promise<void> {
259
259
  <div class="cpub-about-section">
260
260
  <div class="cpub-sec-head"><h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2></div>
261
261
  <div class="cpub-about-card">
262
- <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>
263
264
  </div>
264
265
  </div>
265
266
  <ContestJudgingCriteria v-if="c?.judgingCriteria?.length" :criteria="c.judgingCriteria" />
@@ -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; }