@commonpub/layer 0.49.0 → 0.50.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.
@@ -1,6 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  const props = defineProps<{
3
3
  targetDate: string;
4
+ /** Tight single-line variant for listing cards (no big boxes, seconds dropped). */
5
+ compact?: boolean;
4
6
  }>();
5
7
 
6
8
  const timeLeft = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 });
@@ -25,7 +27,14 @@ onUnmounted(() => clearInterval(timer));
25
27
  </script>
26
28
 
27
29
  <template>
28
- <div class="cpub-countdown">
30
+ <div v-if="compact" class="cpub-countdown-compact">
31
+ <i class="fa-regular fa-clock"></i>
32
+ <span class="cpub-countdown-compact-time">
33
+ <template v-if="timeLeft.days > 0">{{ timeLeft.days }}d </template>{{ String(timeLeft.hours).padStart(2, '0') }}h {{ String(timeLeft.minutes).padStart(2, '0') }}m
34
+ </span>
35
+ <span class="cpub-countdown-compact-label">left</span>
36
+ </div>
37
+ <div v-else class="cpub-countdown">
29
38
  <div v-for="(val, key) in timeLeft" :key="key" class="cpub-countdown-unit">
30
39
  <span class="cpub-countdown-num">{{ String(val).padStart(2, '0') }}</span>
31
40
  <span class="cpub-countdown-label">{{ key }}</span>
@@ -65,4 +74,33 @@ onUnmounted(() => clearInterval(timer));
65
74
  display: block;
66
75
  margin-top: 4px;
67
76
  }
77
+
78
+ /* Compact card variant — one tight line, no boxes. */
79
+ .cpub-countdown-compact {
80
+ display: inline-flex;
81
+ align-items: baseline;
82
+ gap: 5px;
83
+ font-family: var(--font-mono);
84
+ font-size: 12px;
85
+ color: var(--text-muted);
86
+ }
87
+
88
+ .cpub-countdown-compact > .fa-clock {
89
+ font-size: 11px;
90
+ color: var(--text-faint);
91
+ align-self: center;
92
+ }
93
+
94
+ .cpub-countdown-compact-time {
95
+ font-weight: 700;
96
+ color: var(--text);
97
+ letter-spacing: 0.01em;
98
+ }
99
+
100
+ .cpub-countdown-compact-label {
101
+ font-size: 10px;
102
+ text-transform: uppercase;
103
+ letter-spacing: 0.08em;
104
+ color: var(--text-faint);
105
+ }
68
106
  </style>
@@ -53,6 +53,36 @@ const countdownLabel = computed(() => {
53
53
  });
54
54
 
55
55
  const isEnded = computed(() => c.value?.status === 'completed' || c.value?.status === 'cancelled');
56
+ const isPaused = computed(() => c.value?.status === 'paused');
57
+ const isDraft = computed(() => c.value?.status === 'draft');
58
+ // Live countdown only makes sense while the clock is actually running.
59
+ const showCountdown = computed(() => !isEnded.value && !isPaused.value && !isDraft.value);
60
+
61
+ // Client-side mirror of the server VALID_TRANSITIONS map (server/src/contest/contest.ts).
62
+ // Keeps the inline admin controls in sync with what the API will actually accept —
63
+ // bidirectional: go back a stage, pause/resume, reopen, etc.
64
+ const VALID_TRANSITIONS: Record<string, string[]> = {
65
+ draft: ['upcoming', 'active', 'cancelled'],
66
+ upcoming: ['draft', 'active', 'cancelled'],
67
+ active: ['upcoming', 'paused', 'judging', 'cancelled'],
68
+ paused: ['active', 'upcoming', 'judging', 'cancelled'],
69
+ judging: ['active', 'paused', 'completed', 'cancelled'],
70
+ completed: ['judging'],
71
+ cancelled: ['draft', 'upcoming'],
72
+ };
73
+ const STATUS_ACTION: Record<string, { label: string; icon: string }> = {
74
+ draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
75
+ upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
76
+ active: { label: 'Activate', icon: 'fa-play' },
77
+ paused: { label: 'Pause', icon: 'fa-pause' },
78
+ judging: { label: 'Start Judging', icon: 'fa-gavel' },
79
+ completed: { label: 'Complete', icon: 'fa-check' },
80
+ cancelled: { label: 'Cancel', icon: 'fa-ban' },
81
+ };
82
+ const availableTransitions = computed<string[]>(() => VALID_TRANSITIONS[c.value?.status ?? 'upcoming'] ?? []);
83
+ function statusAction(s: string): { label: string; icon: string } {
84
+ return STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
85
+ }
56
86
 
57
87
  // The hero shows the short `subheading` (a dedicated tagline field). For older
58
88
  // contests without one, fall back to a clean, plain-text, CSS-clamped excerpt of
@@ -116,19 +146,23 @@ const dateRange = computed<string>(() => {
116
146
  <button class="cpub-btn cpub-btn-lg cpub-btn-dark" @click="emit('copy-link')"><i class="fa fa-link"></i> Share</button>
117
147
  </div>
118
148
 
119
- <!-- Admin controls -->
149
+ <!-- Admin controls — bidirectional, derived from the valid-transition map. -->
120
150
  <div v-if="isAdmin && c" class="cpub-admin-controls">
121
- <span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i> Admin</span>
122
- <button v-if="c.status === 'upcoming'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'active')"><i class="fa-solid fa-play"></i> Activate</button>
123
- <button v-if="c.status === 'active'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'judging')"><i class="fa-solid fa-gavel"></i> Start Judging</button>
124
- <button v-if="c.status === 'judging'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'completed')"><i class="fa-solid fa-check"></i> Complete</button>
125
- <button v-if="c.status !== 'completed' && c.status !== 'cancelled'" class="cpub-btn cpub-btn-sm cpub-btn-cancel" :disabled="transitioning" @click="emit('transition', 'cancelled')"><i class="fa-solid fa-ban"></i> Cancel</button>
151
+ <span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i> Stage</span>
152
+ <button
153
+ v-for="t in availableTransitions"
154
+ :key="t"
155
+ class="cpub-btn cpub-btn-sm"
156
+ :class="{ 'cpub-btn-cancel': t === 'cancelled' }"
157
+ :disabled="transitioning"
158
+ @click="emit('transition', t)"
159
+ ><i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}</button>
126
160
  </div>
127
161
  </div>
128
162
 
129
163
  <!-- RIGHT: countdown -->
130
164
  <aside class="cpub-hero-side">
131
- <div v-if="!isEnded" class="cpub-countdown-section">
165
+ <div v-if="showCountdown" class="cpub-countdown-section">
132
166
  <div class="cpub-countdown-label"><i class="fa fa-clock"></i> {{ countdownLabel }}</div>
133
167
  <div class="cpub-countdown-row">
134
168
  <div class="cpub-countdown-block">
@@ -152,6 +186,14 @@ const dateRange = computed<string>(() => {
152
186
  </div>
153
187
  </div>
154
188
  </div>
189
+ <div v-else-if="isPaused" class="cpub-countdown-ended">
190
+ <i class="fa-solid fa-circle-pause"></i>
191
+ <span>Submissions paused</span>
192
+ </div>
193
+ <div v-else-if="isDraft" class="cpub-countdown-ended">
194
+ <i class="fa-solid fa-pen-ruler"></i>
195
+ <span>Draft — not launched</span>
196
+ </div>
155
197
  <div v-else class="cpub-countdown-ended">
156
198
  <i class="fa-solid fa-flag-checkered"></i>
157
199
  <span>{{ countdownLabel }}</span>
@@ -215,6 +257,8 @@ const dateRange = computed<string>(() => {
215
257
  .cpub-status-pill[data-status="active"] { color: var(--green); border-color: var(--green); background: color-mix(in srgb, var(--green) 14%, transparent); }
216
258
  .cpub-status-pill[data-status="judging"] { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
217
259
  .cpub-status-pill[data-status="upcoming"] { color: var(--yellow); border-color: var(--yellow); }
260
+ .cpub-status-pill[data-status="paused"] { color: var(--yellow); border-color: var(--yellow); background: color-mix(in srgb, var(--yellow) 12%, transparent); }
261
+ .cpub-status-pill[data-status="draft"] { color: var(--hero-text-dim); border-color: var(--hero-border); border-style: dashed; }
218
262
  .cpub-status-pill[data-status="completed"], .cpub-status-pill[data-status="cancelled"] { color: var(--red); border-color: var(--red-border); }
219
263
 
220
264
  .cpub-hero-title { font-size: 34px; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin: 0 0 10px; color: var(--hero-text); }
@@ -22,7 +22,9 @@ function fmt(d: string | null | undefined): string | null {
22
22
 
23
23
  // Ordinal position of each status along the lifecycle, used to mark steps
24
24
  // done / current / upcoming.
25
- const STATUS_ORDER: Record<string, number> = { upcoming: 0, active: 1, judging: 2, completed: 3 };
25
+ // `draft` precedes everything (nothing has started); `paused` sits at the active
26
+ // position (a deactivated-but-not-cancelled running contest).
27
+ const STATUS_ORDER: Record<string, number> = { draft: -1, upcoming: 0, active: 1, paused: 1, judging: 2, completed: 3 };
26
28
 
27
29
  const timeline = computed<TimelineStep[]>(() => {
28
30
  const c = props.contest;
@@ -40,8 +42,10 @@ const timeline = computed<TimelineStep[]>(() => {
40
42
 
41
43
  function statusClass(status: string): string {
42
44
  const map: Record<string, string> = {
45
+ draft: 'cpub-status-draft',
43
46
  upcoming: 'cpub-status-upcoming',
44
47
  active: 'cpub-status-active',
48
+ paused: 'cpub-status-paused',
45
49
  judging: 'cpub-status-judging',
46
50
  completed: 'cpub-status-completed',
47
51
  cancelled: 'cpub-status-cancelled',
@@ -110,8 +114,10 @@ function statusClass(status: string): string {
110
114
  .cpub-sb-body { font-size: 12px; color: var(--text-dim); display: flex; flex-direction: column; gap: 8px; }
111
115
  .cpub-sb-row { display: flex; align-items: center; gap: 6px; }
112
116
  .cpub-sb-status { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; padding: 2px 8px; border: var(--border-width-default) solid; }
117
+ .cpub-status-draft { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); border-style: dashed; }
113
118
  .cpub-status-upcoming { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
114
119
  .cpub-status-active { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
120
+ .cpub-status-paused { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
115
121
  .cpub-status-judging { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
116
122
  .cpub-status-completed { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); }
117
123
  .cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -54,15 +54,15 @@
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
56
  "@commonpub/auth": "0.8.0",
57
+ "@commonpub/docs": "0.6.3",
57
58
  "@commonpub/config": "0.18.0",
58
59
  "@commonpub/learning": "0.5.2",
59
60
  "@commonpub/explainer": "0.7.15",
60
- "@commonpub/docs": "0.6.3",
61
61
  "@commonpub/editor": "0.7.11",
62
+ "@commonpub/server": "2.75.0",
63
+ "@commonpub/schema": "0.28.0",
62
64
  "@commonpub/protocol": "0.13.0",
63
- "@commonpub/ui": "0.9.2",
64
- "@commonpub/server": "2.74.0",
65
- "@commonpub/schema": "0.27.0"
65
+ "@commonpub/ui": "0.9.2"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -13,6 +13,11 @@ useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'} — ${useS
13
13
 
14
14
  const saving = ref(false);
15
15
  const title = ref('');
16
+ // Editable slug — initialised from the loaded contest, manual override allowed.
17
+ const slugInput = ref('');
18
+ function slugify(s: string): string {
19
+ return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-+)|(-+$)/g, '').slice(0, 255);
20
+ }
16
21
  const subheading = ref('');
17
22
  const description = ref('');
18
23
  const rules = ref('');
@@ -42,6 +47,7 @@ function toggleRole(r: string): void {
42
47
  else visibleToRoles.value.push(r);
43
48
  }
44
49
 
50
+ const showPrizes = ref(true);
45
51
  const prizesDescription = ref('');
46
52
  interface Prize { place: number | null; category: string; title: string; description: string; value: string }
47
53
  const prizes = ref<Prize[]>([]);
@@ -53,6 +59,7 @@ const criteria = ref<Criterion[]>([]);
53
59
  watch(contest, (c) => {
54
60
  if (!c) return;
55
61
  title.value = c.title ?? '';
62
+ slugInput.value = c.slug ?? '';
56
63
  subheading.value = c.subheading ?? '';
57
64
  description.value = c.description ?? '';
58
65
  rules.value = c.rules ?? '';
@@ -67,6 +74,7 @@ watch(contest, (c) => {
67
74
  maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
68
75
  visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
69
76
  visibleToRoles.value = [...(c.visibleToRoles ?? [])];
77
+ showPrizes.value = c.showPrizes !== false;
70
78
  prizesDescription.value = c.prizesDescription ?? '';
71
79
  prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
72
80
  place: p.place ?? null,
@@ -138,10 +146,11 @@ async function handleSave(): Promise<void> {
138
146
  description: c.description.trim() || undefined,
139
147
  }));
140
148
 
141
- await $fetch(`/api/contests/${slug}`, {
149
+ const updated = await $fetch<{ slug: string }>(`/api/contests/${slug}`, {
142
150
  method: 'PUT',
143
151
  body: {
144
152
  title: title.value,
153
+ slug: slugify(slugInput.value) || undefined,
145
154
  subheading: subheading.value || undefined,
146
155
  description: description.value || undefined,
147
156
  rules: rules.value || undefined,
@@ -156,12 +165,20 @@ async function handleSave(): Promise<void> {
156
165
  maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
157
166
  visibility: visibility.value,
158
167
  visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
168
+ showPrizes: showPrizes.value,
159
169
  prizesDescription: prizesDescription.value || undefined,
160
170
  prizes: prizeData,
161
171
  judgingCriteria: criteriaData,
162
172
  },
163
173
  });
164
174
  toast.success('Contest updated');
175
+ // Slug changed → the old URL no longer resolves. Navigate to the renamed
176
+ // contest's page — a different route component, so it loads fresh. (Navigating
177
+ // to the new /edit URL would reuse THIS component with its stale fetch key.)
178
+ if (updated?.slug && updated.slug !== slug) {
179
+ await navigateTo(`/contests/${updated.slug}`);
180
+ return;
181
+ }
165
182
  await refresh();
166
183
  } catch (err: unknown) {
167
184
  toast.error(extractError(err));
@@ -184,11 +201,36 @@ async function handleDelete(): Promise<void> {
184
201
  }
185
202
  }
186
203
 
204
+ // Client mirror of the server VALID_TRANSITIONS map — bidirectional (go back a
205
+ // stage, pause/resume, reopen). Kept in sync with server/src/contest/contest.ts.
206
+ const VALID_TRANSITIONS: Record<string, string[]> = {
207
+ draft: ['upcoming', 'active', 'cancelled'],
208
+ upcoming: ['draft', 'active', 'cancelled'],
209
+ active: ['upcoming', 'paused', 'judging', 'cancelled'],
210
+ paused: ['active', 'upcoming', 'judging', 'cancelled'],
211
+ judging: ['active', 'paused', 'completed', 'cancelled'],
212
+ completed: ['judging'],
213
+ cancelled: ['draft', 'upcoming'],
214
+ };
215
+ const STATUS_ACTION: Record<string, { label: string; icon: string; tone?: 'go' | 'warn' | 'danger' }> = {
216
+ draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
217
+ upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
218
+ active: { label: 'Activate', icon: 'fa-play', tone: 'go' },
219
+ paused: { label: 'Pause', icon: 'fa-pause', tone: 'warn' },
220
+ judging: { label: 'Begin Judging', icon: 'fa-gavel' },
221
+ completed: { label: 'Complete & Publish', icon: 'fa-flag-checkered', tone: 'go' },
222
+ cancelled: { label: 'Cancel', icon: 'fa-ban', tone: 'danger' },
223
+ };
224
+ const availableTransitions = computed<string[]>(() => VALID_TRANSITIONS[contest.value?.status ?? 'upcoming'] ?? []);
225
+ function statusAction(s: string): { label: string; icon: string; tone?: string } {
226
+ return STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
227
+ }
228
+
187
229
  async function transitionStatus(newStatus: string): Promise<void> {
188
- const msg = newStatus === 'cancelled'
189
- ? 'Cancel this contest? This cannot be undone.'
190
- : `Change contest status to "${newStatus}"?`;
191
- if (!confirm(msg)) return;
230
+ // Only the consequential transitions confirm; reversible nudges (pause/resume,
231
+ // go-back) just apply.
232
+ if (newStatus === 'cancelled' && !confirm('Cancel this contest? This cannot be undone.')) return;
233
+ if (newStatus === 'completed' && !confirm('Complete this contest and publish results? Final rankings will be calculated.')) return;
192
234
  try {
193
235
  await $fetch(`/api/contests/${slug}/transition`, { method: 'POST', body: { status: newStatus } });
194
236
  toast.success(`Status changed to ${newStatus}`);
@@ -218,6 +260,11 @@ async function transitionStatus(newStatus: string): Promise<void> {
218
260
  <label class="cpub-form-label">Title</label>
219
261
  <input v-model="title" type="text" class="cpub-form-input" />
220
262
  </div>
263
+ <div class="cpub-form-field">
264
+ <label class="cpub-form-label">URL Slug</label>
265
+ <input v-model="slugInput" type="text" class="cpub-form-input" @blur="slugInput = slugify(slugInput)" />
266
+ <p class="cpub-form-hint">The contest URL: <code>/contests/{{ slugify(slugInput) || 'your-contest' }}</code>. Changing it breaks old links — they won't redirect.</p>
267
+ </div>
221
268
  <div class="cpub-form-field">
222
269
  <label class="cpub-form-label">Subheading</label>
223
270
  <input v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
@@ -280,6 +327,11 @@ async function transitionStatus(newStatus: string): Promise<void> {
280
327
 
281
328
  <section class="cpub-form-section">
282
329
  <h2 class="cpub-form-section-title">Prizes</h2>
330
+ <label class="cpub-form-check" style="margin-bottom: 10px;">
331
+ <input v-model="showPrizes" type="checkbox" />
332
+ <span>Show the Prizes tab on the contest page</span>
333
+ </label>
334
+ <p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden — any prizes below are saved but not shown to visitors.</p>
283
335
  <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>
284
336
  <div class="cpub-form-field">
285
337
  <label class="cpub-form-label">Prizes overview (optional)</label>
@@ -391,36 +443,32 @@ async function transitionStatus(newStatus: string): Promise<void> {
391
443
  </section>
392
444
 
393
445
  <section class="cpub-form-section">
394
- <h2 class="cpub-form-section-title">Status Transitions</h2>
446
+ <h2 class="cpub-form-section-title">Stage &amp; Status</h2>
395
447
  <p class="cpub-form-hint">
396
- A contest moves through a lifecycle:
397
- <strong>Upcoming</strong> → <strong>Active</strong> (accepting entries)
398
- <strong>Judging</strong> (entries closed, judges scoring)
399
- <strong>Completed</strong> (results &amp; rankings published). You can cancel at any
400
- point before it completes. Current status:
448
+ A contest runs through <strong>Draft</strong> → <strong>Upcoming</strong> →
449
+ <strong>Active</strong> (accepting entries) → <strong>Judging</strong> →
450
+ <strong>Completed</strong>. You can move <em>backwards</em>, <strong>Pause</strong> to
451
+ temporarily stop submissions without cancelling, resume later, or cancel. Current status:
401
452
  <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
402
453
  </p>
403
454
  <div class="cpub-status-actions">
404
- <button v-if="contest.status === 'upcoming'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-activate" @click="transitionStatus('active')">
405
- <i class="fa-solid fa-play"></i> Start Contest
406
- </button>
407
- <button v-if="contest.status === 'active'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-judging" @click="transitionStatus('judging')">
408
- <i class="fa-solid fa-gavel"></i> Begin Judging
409
- </button>
410
- <button v-if="contest.status === 'judging'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-complete" @click="transitionStatus('completed')">
411
- <i class="fa-solid fa-flag-checkered"></i> Complete &amp; Publish Results
412
- </button>
413
455
  <button
414
- v-if="contest.status !== 'completed' && contest.status !== 'cancelled'"
456
+ v-for="t in availableTransitions"
457
+ :key="t"
415
458
  type="button"
416
- class="cpub-btn cpub-transition-btn cpub-transition-cancel"
417
- @click="transitionStatus('cancelled')"
459
+ class="cpub-btn cpub-transition-btn"
460
+ :class="{
461
+ 'cpub-transition-activate': statusAction(t).tone === 'go',
462
+ 'cpub-transition-judging': statusAction(t).tone === 'warn',
463
+ 'cpub-transition-cancel': statusAction(t).tone === 'danger',
464
+ }"
465
+ @click="transitionStatus(t)"
418
466
  >
419
- <i class="fa-solid fa-ban"></i> Cancel Contest
467
+ <i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}
420
468
  </button>
421
- <p v-if="contest.status === 'completed' || contest.status === 'cancelled'" class="cpub-status-terminal">
469
+ <p v-if="!availableTransitions.length" class="cpub-status-terminal">
422
470
  <i class="fa-solid fa-circle-check"></i>
423
- This contest is {{ contest.status }} — no further status changes are available.
471
+ No status changes available from <strong>{{ contest.status }}</strong>.
424
472
  </p>
425
473
  </div>
426
474
  </section>
@@ -454,8 +502,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
454
502
  .cpub-edit-subtitle { font-size: 13px; color: var(--text-dim); margin-bottom: 24px; display: flex; align-items: center; gap: 8px; }
455
503
 
456
504
  .cpub-status-badge { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; padding: 2px 8px; border: var(--border-width-default) solid; }
505
+ .cpub-status-draft { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); border-style: dashed; }
457
506
  .cpub-status-upcoming { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
458
507
  .cpub-status-active { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
508
+ .cpub-status-paused { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
459
509
  .cpub-status-judging { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
460
510
  .cpub-status-completed { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); }
461
511
  .cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
@@ -52,7 +52,7 @@ interface Tab { key: string; label: string; icon: string; count?: number }
52
52
  const tabs = computed<Tab[]>(() => {
53
53
  const t: Tab[] = [{ key: 'overview', label: 'Overview', icon: 'fa-circle-info' }];
54
54
  if (c.value?.rules) t.push({ key: 'rules', label: 'Rules', icon: 'fa-file-lines' });
55
- if (c.value?.prizes?.length || c.value?.prizesDescription) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
55
+ if (c.value?.showPrizes !== false && (c.value?.prizes?.length || c.value?.prizesDescription)) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
56
56
  t.push({ key: 'entries', label: 'Entries', icon: 'fa-box-open', count: c.value?.entryCount ?? entries.value.length });
57
57
  if (participants.value.length) t.push({ key: 'participants', label: 'Participants', icon: 'fa-users', count: participants.value.length });
58
58
  if (judges.value.length || isOwner.value) t.push({ key: 'judges', label: 'Judges', icon: 'fa-gavel', count: judges.value.length || undefined });
@@ -303,7 +303,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
303
303
 
304
304
  <!-- PRIZES -->
305
305
  <div v-show="activeTab === 'prizes'" id="cpub-panel-prizes" role="tabpanel" aria-labelledby="cpub-tab-prizes" tabindex="0">
306
- <ContestPrizes v-if="c?.prizes?.length || c?.prizesDescription" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" />
306
+ <ContestPrizes v-if="c?.showPrizes !== false && (c?.prizes?.length || c?.prizesDescription)" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" />
307
307
  </div>
308
308
 
309
309
  <!-- ENTRIES -->
@@ -8,6 +8,13 @@ const { extract: extractError } = useApiError();
8
8
  const saving = ref(false);
9
9
 
10
10
  const title = ref('');
11
+ // Slug auto-derives from the title until the operator edits it manually.
12
+ const slug = ref('');
13
+ const slugTouched = ref(false);
14
+ function slugify(s: string): string {
15
+ return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-+)|(-+$)/g, '').slice(0, 255);
16
+ }
17
+ watch(title, (t) => { if (!slugTouched.value) slug.value = slugify(t); });
11
18
  const subheading = ref('');
12
19
  const description = ref('');
13
20
  const rules = ref('');
@@ -48,6 +55,7 @@ interface Prize {
48
55
  value: string;
49
56
  }
50
57
 
58
+ const showPrizes = ref(true);
51
59
  const prizesDescription = ref('');
52
60
  // Prizes are entirely optional — start empty so a contest has NO prizes unless
53
61
  // the operator explicitly adds them (the old 3 pre-filled rows forced prizes
@@ -92,6 +100,7 @@ async function handleCreate(): Promise<void> {
92
100
  method: 'POST',
93
101
  body: {
94
102
  title: title.value,
103
+ slug: slugify(slug.value) || undefined,
95
104
  subheading: subheading.value || undefined,
96
105
  description: description.value || undefined,
97
106
  rules: rules.value || undefined,
@@ -106,6 +115,7 @@ async function handleCreate(): Promise<void> {
106
115
  visibleToRoles: visibility.value === 'private' && visibleToRoles.value.length ? visibleToRoles.value : undefined,
107
116
  eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
108
117
  maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
118
+ showPrizes: showPrizes.value,
109
119
  prizesDescription: prizesDescription.value || undefined,
110
120
  prizes: prizes.value
111
121
  .filter(p => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
@@ -157,6 +167,11 @@ function prizeLabel(prize: Prize): string {
157
167
  <label for="contest-title" class="cpub-form-label">Title</label>
158
168
  <input id="contest-title" v-model="title" type="text" class="cpub-form-input" required placeholder="Maker Challenge 2026" />
159
169
  </div>
170
+ <div class="cpub-form-field">
171
+ <label for="contest-slug" class="cpub-form-label">URL Slug</label>
172
+ <input id="contest-slug" v-model="slug" type="text" class="cpub-form-input" placeholder="auto-generated from title" @input="slugTouched = true" @blur="slug = slugify(slug)" />
173
+ <p class="cpub-form-hint">Auto-fills from the title. Edit to set a custom URL: <code>/contests/{{ slugify(slug) || 'your-contest' }}</code></p>
174
+ </div>
160
175
  <div class="cpub-form-field">
161
176
  <label for="contest-subheading" class="cpub-form-label">Subheading</label>
162
177
  <input id="contest-subheading" v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
@@ -291,6 +306,12 @@ function prizeLabel(prize: Prize): string {
291
306
  </button>
292
307
  </div>
293
308
 
309
+ <label class="cpub-form-check">
310
+ <input v-model="showPrizes" type="checkbox" />
311
+ <span>Show the Prizes tab on the contest page</span>
312
+ </label>
313
+ <p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden — any prizes below are saved but not shown to visitors.</p>
314
+
294
315
  <p class="cpub-form-hint">Contests don't need prizes — leave this empty to skip them entirely. If you do add prizes, 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>
295
316
  <div class="cpub-form-field">
296
317
  <label for="prizes-desc" class="cpub-form-label">Prizes overview (optional)</label>
@@ -302,7 +323,7 @@ function prizeLabel(prize: Prize): string {
302
323
  <span class="cpub-prize-place">
303
324
  <i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize) }}
304
325
  </span>
305
- <button v-if="prizes.length > 1" type="button" class="cpub-delete-btn" aria-label="Remove prize" @click="removePrize(idx)">
326
+ <button type="button" class="cpub-delete-btn" aria-label="Remove prize" @click="removePrize(idx)">
306
327
  <i class="fa-solid fa-xmark"></i>
307
328
  </button>
308
329
  </div>
@@ -68,7 +68,7 @@ const canCreateContest = computed(() => {
68
68
  </template>
69
69
  <span class="cpub-badge cpub-contest-thumb-badge" :class="{
70
70
  'cpub-badge-green': contest.status === 'active',
71
- 'cpub-badge-yellow': contest.status === 'upcoming',
71
+ 'cpub-badge-yellow': contest.status === 'upcoming' || contest.status === 'paused',
72
72
  'cpub-badge-accent': contest.status === 'judging',
73
73
  'cpub-badge-red': contest.status === 'completed' || contest.status === 'cancelled',
74
74
  }">{{ contest.status }}</span>
@@ -78,8 +78,8 @@ const canCreateContest = computed(() => {
78
78
  <p v-if="cardBlurb(contest)" class="cpub-contest-card-blurb">
79
79
  {{ cardBlurb(contest) }}
80
80
  </p>
81
- <div v-if="contest.endDate" style="margin-top: 8px">
82
- <CountdownTimer :target-date="contest.endDate" />
81
+ <div v-if="contest.endDate && (contest.status === 'active' || contest.status === 'upcoming')" class="cpub-contest-card-countdown">
82
+ <CountdownTimer :target-date="contest.endDate" compact />
83
83
  </div>
84
84
  <div class="cpub-contest-card-meta">
85
85
  <span><i class="fa-solid fa-users"></i> {{ contest.entryCount }} entries</span>
@@ -135,6 +135,7 @@ const canCreateContest = computed(() => {
135
135
 
136
136
  .cpub-contest-card-title { font-size: 15px; font-weight: 600; margin: 0 0 6px; color: var(--text); }
137
137
  .cpub-contest-card-meta { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 11px; color: var(--text-faint); font-family: var(--font-mono); }
138
+ .cpub-contest-card-countdown { margin-top: 10px; }
138
139
 
139
140
  .cpub-contest-card-blurb {
140
141
  font-size: 12px;
@@ -9,7 +9,15 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
9
9
  const { slug } = parseParams(event, { slug: 'string' });
10
10
  const input = await parseBody(event, updateContestSchema);
11
11
 
12
- const result = await updateContest(db, slug, user.id, input);
12
+ let result;
13
+ try {
14
+ result = await updateContest(db, slug, user.id, input);
15
+ } catch (err) {
16
+ if (err instanceof Error && err.message === 'SLUG_TAKEN') {
17
+ throw createError({ statusCode: 409, statusMessage: 'That URL slug is already in use by another contest.' });
18
+ }
19
+ throw err;
20
+ }
13
21
  if (!result) throw createError({ statusCode: 403, statusMessage: 'Not authorized or contest not found' });
14
22
  return result;
15
23
  });
@@ -19,7 +19,8 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
19
19
  const config = useConfig();
20
20
  const input = await parseBody(event, createContestSchema);
21
21
 
22
- const base = slugify(input.title) || `contest-${Date.now()}`;
22
+ // Manual slug override (slugified defensively) falls back to the title.
23
+ const base = slugify(input.slug || input.title) || `contest-${Date.now()}`;
23
24
  // Ensure slug uniqueness so a duplicate title returns a clean contest instead
24
25
  // of a 500 from the unique-constraint violation.
25
26
  let slug = base;