@commonpub/layer 0.49.0 → 0.51.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>
@@ -18,18 +18,28 @@ const c = computed(() => props.contest);
18
18
 
19
19
  // Countdown timer
20
20
  const countdown = ref({ days: '00', hours: '00', mins: '00', secs: '00' });
21
+ const targetPassed = ref(false);
21
22
  let countdownInterval: ReturnType<typeof setInterval> | null = null;
22
23
 
23
24
  function pad(n: number): string { return String(n).padStart(2, '0'); }
24
25
 
26
+ // The countdown target depends on the lifecycle stage: an UPCOMING contest counts
27
+ // down to when it OPENS (startDate); while JUDGING, to the judging deadline;
28
+ // otherwise (active) to the submission close (endDate).
29
+ const countdownTargetStr = computed<string | null>(() => {
30
+ const s = c.value?.status;
31
+ if (s === 'judging') return c.value?.judgingEndDate ?? c.value?.endDate ?? null;
32
+ if (s === 'upcoming') return c.value?.startDate ?? null;
33
+ return c.value?.endDate ?? null;
34
+ });
35
+
25
36
  function updateCountdown(): void {
26
- // During judging, count down to the judging deadline (if set); otherwise the
27
- // submission end date.
28
- const isJudging = c.value?.status === 'judging';
29
- const targetStr = isJudging ? (c.value?.judgingEndDate ?? c.value?.endDate) : c.value?.endDate;
37
+ const targetStr = countdownTargetStr.value;
30
38
  const target = targetStr ? new Date(targetStr) : new Date();
31
39
  const now = new Date();
32
- let diff = Math.max(0, Math.floor((target.getTime() - now.getTime()) / 1000));
40
+ const rawDiff = Math.floor((target.getTime() - now.getTime()) / 1000);
41
+ targetPassed.value = rawDiff <= 0;
42
+ let diff = Math.max(0, rawDiff);
33
43
  const days = Math.floor(diff / 86400); diff %= 86400;
34
44
  const hours = Math.floor(diff / 3600); diff %= 3600;
35
45
  const mins = Math.floor(diff / 60);
@@ -47,12 +57,58 @@ onUnmounted(() => {
47
57
  });
48
58
 
49
59
  const countdownLabel = computed(() => {
50
- if (c.value?.status === 'completed' || c.value?.status === 'cancelled') return 'Contest ended';
51
- if (c.value?.status === 'judging') return 'Judging ends in';
60
+ const s = c.value?.status;
61
+ if (s === 'completed' || s === 'cancelled') return 'Contest ended';
62
+ if (s === 'judging') return 'Judging ends in';
63
+ if (s === 'upcoming') return 'Opens in';
52
64
  return 'Submissions close in';
53
65
  });
54
66
 
55
67
  const isEnded = computed(() => c.value?.status === 'completed' || c.value?.status === 'cancelled');
68
+ const isPaused = computed(() => c.value?.status === 'paused');
69
+ const isDraft = computed(() => c.value?.status === 'draft');
70
+ // Live countdown only while the clock is actually running AND its target is still
71
+ // in the future. Once the target passes (an upcoming contest whose open date has
72
+ // arrived, or an active one past its close), fall back to a static date note.
73
+ const showCountdown = computed(() => !isEnded.value && !isPaused.value && !isDraft.value && !!countdownTargetStr.value && !targetPassed.value);
74
+
75
+ function fmtDate(s: string | null | undefined): string {
76
+ if (!s) return '';
77
+ return new Date(s).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
78
+ }
79
+ // Static date shown when the relevant target is in the past but the contest hasn't
80
+ // been advanced yet (e.g. an upcoming contest whose open date arrived).
81
+ const dateNote = computed<string | null>(() => {
82
+ if (isEnded.value || isPaused.value || isDraft.value || !targetPassed.value) return null;
83
+ if (c.value?.status === 'upcoming') return c.value?.startDate ? `Opens ${fmtDate(c.value.startDate)}` : null;
84
+ return c.value?.endDate ? `Closed ${fmtDate(c.value.endDate)}` : null;
85
+ });
86
+
87
+ // Client-side mirror of the server VALID_TRANSITIONS map (server/src/contest/contest.ts).
88
+ // Keeps the inline admin controls in sync with what the API will actually accept —
89
+ // bidirectional: go back a stage, pause/resume, reopen, etc.
90
+ const VALID_TRANSITIONS: Record<string, string[]> = {
91
+ draft: ['upcoming', 'active', 'cancelled'],
92
+ upcoming: ['draft', 'active', 'cancelled'],
93
+ active: ['upcoming', 'paused', 'judging', 'cancelled'],
94
+ paused: ['active', 'upcoming', 'judging', 'cancelled'],
95
+ judging: ['active', 'paused', 'completed', 'cancelled'],
96
+ completed: ['judging'],
97
+ cancelled: ['draft', 'upcoming'],
98
+ };
99
+ const STATUS_ACTION: Record<string, { label: string; icon: string }> = {
100
+ draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
101
+ upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
102
+ active: { label: 'Activate', icon: 'fa-play' },
103
+ paused: { label: 'Pause', icon: 'fa-pause' },
104
+ judging: { label: 'Start Judging', icon: 'fa-gavel' },
105
+ completed: { label: 'Complete', icon: 'fa-check' },
106
+ cancelled: { label: 'Cancel', icon: 'fa-ban' },
107
+ };
108
+ const availableTransitions = computed<string[]>(() => VALID_TRANSITIONS[c.value?.status ?? 'upcoming'] ?? []);
109
+ function statusAction(s: string): { label: string; icon: string } {
110
+ return STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
111
+ }
56
112
 
57
113
  // The hero shows the short `subheading` (a dedicated tagline field). For older
58
114
  // contests without one, fall back to a clean, plain-text, CSS-clamped excerpt of
@@ -116,19 +172,23 @@ const dateRange = computed<string>(() => {
116
172
  <button class="cpub-btn cpub-btn-lg cpub-btn-dark" @click="emit('copy-link')"><i class="fa fa-link"></i> Share</button>
117
173
  </div>
118
174
 
119
- <!-- Admin controls -->
175
+ <!-- Admin controls — bidirectional, derived from the valid-transition map. -->
120
176
  <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>
177
+ <span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i> Stage</span>
178
+ <button
179
+ v-for="t in availableTransitions"
180
+ :key="t"
181
+ class="cpub-btn cpub-btn-sm"
182
+ :class="{ 'cpub-btn-cancel': t === 'cancelled' }"
183
+ :disabled="transitioning"
184
+ @click="emit('transition', t)"
185
+ ><i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}</button>
126
186
  </div>
127
187
  </div>
128
188
 
129
189
  <!-- RIGHT: countdown -->
130
190
  <aside class="cpub-hero-side">
131
- <div v-if="!isEnded" class="cpub-countdown-section">
191
+ <div v-if="showCountdown" class="cpub-countdown-section">
132
192
  <div class="cpub-countdown-label"><i class="fa fa-clock"></i> {{ countdownLabel }}</div>
133
193
  <div class="cpub-countdown-row">
134
194
  <div class="cpub-countdown-block">
@@ -152,6 +212,18 @@ const dateRange = computed<string>(() => {
152
212
  </div>
153
213
  </div>
154
214
  </div>
215
+ <div v-else-if="isPaused" class="cpub-countdown-ended">
216
+ <i class="fa-solid fa-circle-pause"></i>
217
+ <span>Submissions paused</span>
218
+ </div>
219
+ <div v-else-if="isDraft" class="cpub-countdown-ended">
220
+ <i class="fa-solid fa-pen-ruler"></i>
221
+ <span>Draft — not launched</span>
222
+ </div>
223
+ <div v-else-if="dateNote" class="cpub-countdown-ended">
224
+ <i class="fa-regular fa-calendar"></i>
225
+ <span>{{ dateNote }}</span>
226
+ </div>
155
227
  <div v-else class="cpub-countdown-ended">
156
228
  <i class="fa-solid fa-flag-checkered"></i>
157
229
  <span>{{ countdownLabel }}</span>
@@ -215,6 +287,8 @@ const dateRange = computed<string>(() => {
215
287
  .cpub-status-pill[data-status="active"] { color: var(--green); border-color: var(--green); background: color-mix(in srgb, var(--green) 14%, transparent); }
216
288
  .cpub-status-pill[data-status="judging"] { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
217
289
  .cpub-status-pill[data-status="upcoming"] { color: var(--yellow); border-color: var(--yellow); }
290
+ .cpub-status-pill[data-status="paused"] { color: var(--yellow); border-color: var(--yellow); background: color-mix(in srgb, var(--yellow) 12%, transparent); }
291
+ .cpub-status-pill[data-status="draft"] { color: var(--hero-text-dim); border-color: var(--hero-border); border-style: dashed; }
218
292
  .cpub-status-pill[data-status="completed"], .cpub-status-pill[data-status="cancelled"] { color: var(--red); border-color: var(--red-border); }
219
293
 
220
294
  .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.51.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -55,14 +55,14 @@
55
55
  "zod": "^4.3.6",
56
56
  "@commonpub/auth": "0.8.0",
57
57
  "@commonpub/config": "0.18.0",
58
- "@commonpub/learning": "0.5.2",
59
- "@commonpub/explainer": "0.7.15",
60
58
  "@commonpub/docs": "0.6.3",
59
+ "@commonpub/explainer": "0.7.15",
61
60
  "@commonpub/editor": "0.7.11",
61
+ "@commonpub/learning": "0.5.2",
62
+ "@commonpub/schema": "0.28.0",
62
63
  "@commonpub/protocol": "0.13.0",
63
64
  "@commonpub/ui": "0.9.2",
64
- "@commonpub/server": "2.74.0",
65
- "@commonpub/schema": "0.27.0"
65
+ "@commonpub/server": "2.75.0"
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,44 +443,36 @@ 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>
427
475
 
428
- <button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
429
- <i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving...' : 'Save Changes' }}
430
- </button>
431
-
432
476
  <section class="cpub-form-section cpub-danger-zone">
433
477
  <h2 class="cpub-form-section-title cpub-danger-title">Danger Zone</h2>
434
478
  <div class="cpub-danger-row">
@@ -441,6 +485,19 @@ async function transitionStatus(newStatus: string): Promise<void> {
441
485
  </button>
442
486
  </div>
443
487
  </section>
488
+
489
+ <!-- Sticky save bar — always reachable without scrolling to the bottom. -->
490
+ <div class="cpub-edit-actionbar">
491
+ <span class="cpub-edit-actionbar-status">
492
+ Status <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
493
+ </span>
494
+ <div class="cpub-edit-actionbar-btns">
495
+ <NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-edit-cancel">Cancel</NuxtLink>
496
+ <button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
497
+ <i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving…' : 'Save Changes' }}
498
+ </button>
499
+ </div>
500
+ </div>
444
501
  </form>
445
502
  </div>
446
503
  <div v-else class="cpub-not-found"><p>Contest not found</p></div>
@@ -454,8 +511,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
454
511
  .cpub-edit-subtitle { font-size: 13px; color: var(--text-dim); margin-bottom: 24px; display: flex; align-items: center; gap: 8px; }
455
512
 
456
513
  .cpub-status-badge { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; padding: 2px 8px; border: var(--border-width-default) solid; }
514
+ .cpub-status-draft { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); border-style: dashed; }
457
515
  .cpub-status-upcoming { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
458
516
  .cpub-status-active { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
517
+ .cpub-status-paused { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
459
518
  .cpub-status-judging { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
460
519
  .cpub-status-completed { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); }
461
520
  .cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
@@ -507,8 +566,27 @@ async function transitionStatus(newStatus: string): Promise<void> {
507
566
 
508
567
  .cpub-not-found { text-align: center; padding: 64px; color: var(--text-dim); display: flex; flex-direction: column; align-items: center; gap: 12px; }
509
568
 
569
+ /* Sticky save bar — pinned to the viewport bottom while editing the long form. */
570
+ .cpub-edit-actionbar {
571
+ position: sticky;
572
+ bottom: 0;
573
+ z-index: 20;
574
+ display: flex;
575
+ align-items: center;
576
+ justify-content: space-between;
577
+ gap: 12px;
578
+ margin: 4px -32px -32px;
579
+ padding: 14px 32px;
580
+ background: var(--surface);
581
+ border-top: 2px solid var(--border);
582
+ box-shadow: var(--shadow-lg);
583
+ }
584
+ .cpub-edit-actionbar-status { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); display: flex; align-items: center; gap: 8px; }
585
+ .cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
586
+
510
587
  @media (max-width: 768px) {
511
588
  .cpub-contest-edit { padding: 16px; }
512
589
  .cpub-form-row { grid-template-columns: 1fr; }
590
+ .cpub-edit-actionbar { margin: 4px -16px -16px; padding: 12px 16px; }
513
591
  }
514
592
  </style>
@@ -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>
@@ -333,9 +354,15 @@ function prizeLabel(prize: Prize): string {
333
354
  </div>
334
355
  </section>
335
356
 
336
- <button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-lg" :disabled="saving || !title.trim() || !startDate || !endDate || !!dateError">
337
- <i class="fa-solid fa-trophy"></i> {{ saving ? 'Creating...' : 'Create Contest' }}
338
- </button>
357
+ <div class="cpub-edit-actionbar">
358
+ <span class="cpub-edit-actionbar-hint">Required: title, start &amp; end dates.</span>
359
+ <div class="cpub-edit-actionbar-btns">
360
+ <NuxtLink to="/contests" class="cpub-btn">Cancel</NuxtLink>
361
+ <button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !startDate || !endDate || !!dateError">
362
+ <i class="fa-solid fa-trophy"></i> {{ saving ? 'Creating…' : 'Create Contest' }}
363
+ </button>
364
+ </div>
365
+ </div>
339
366
  </form>
340
367
  </div>
341
368
  </template>
@@ -380,10 +407,29 @@ function prizeLabel(prize: Prize): string {
380
407
  .cpub-delete-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; }
381
408
  .cpub-delete-btn:hover { color: var(--red); }
382
409
 
410
+ /* Sticky create bar — Create button always reachable on the long form. */
411
+ .cpub-edit-actionbar {
412
+ position: sticky;
413
+ bottom: 0;
414
+ z-index: 20;
415
+ display: flex;
416
+ align-items: center;
417
+ justify-content: space-between;
418
+ gap: 12px;
419
+ margin: 4px -32px -32px;
420
+ padding: 14px 32px;
421
+ background: var(--surface);
422
+ border-top: 2px solid var(--border);
423
+ box-shadow: var(--shadow-lg);
424
+ }
425
+ .cpub-edit-actionbar-hint { font-size: 11px; color: var(--text-faint); }
426
+ .cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
427
+
383
428
  @media (max-width: 768px) {
384
429
  .cpub-contest-create { padding: 16px; }
385
430
  .cpub-page-title { font-size: 20px; }
386
431
  .cpub-form-section { padding: 14px; }
387
432
  .cpub-form-row { grid-template-columns: 1fr; }
433
+ .cpub-edit-actionbar { margin: 4px -16px -16px; padding: 12px 16px; }
388
434
  }
389
435
  </style>
@@ -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;