@commonpub/layer 0.48.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
@@ -61,91 +91,114 @@ const isEnded = computed(() => c.value?.status === 'completed' || c.value?.statu
61
91
  const tagline = computed<string>(() => {
62
92
  const sub = (c.value?.subheading ?? '').trim();
63
93
  if (sub) return sub;
64
- return markdownToExcerpt(c.value?.description) || 'No description available.';
94
+ return markdownToExcerpt(c.value?.description) || '';
95
+ });
96
+
97
+ const dateRange = computed<string>(() => {
98
+ const fmt = (d: string, withYear = false) =>
99
+ new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(withYear ? { year: 'numeric' } : {}) });
100
+ const start = c.value?.startDate ? fmt(c.value.startDate) : '';
101
+ const end = c.value?.endDate ? fmt(c.value.endDate, true) : '';
102
+ if (start && end) return `${start} — ${end}`;
103
+ return start || end;
65
104
  });
66
105
  </script>
67
106
 
68
107
  <template>
69
108
  <div class="cpub-hero">
70
- <div class="cpub-hero-pattern">
71
- <div class="cpub-hero-dots"></div>
72
- <div class="cpub-hero-lines"></div>
109
+ <!-- Banner band — full-width image at the top, the same way other content
110
+ pages render their hero banner (clean band, never overlaid by text). -->
111
+ <div v-if="c?.bannerUrl" class="cpub-hero-banner">
112
+ <img :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" />
73
113
  </div>
74
114
 
75
- <div class="cpub-hero-inner">
76
- <!-- Banner image: a clean band at the top of the hero. Title/tagline sit
77
- BELOW it (never overlaid) so text stays legible regardless of image. -->
78
- <img v-if="c?.bannerUrl" :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" class="cpub-hero-banner" />
79
-
80
- <div v-if="c?.status === 'cancelled'" class="cpub-cancelled-banner">
81
- <i class="fa-solid fa-ban"></i> This contest has been cancelled.
115
+ <!-- Hero body — the contest's dark, patterned section. Two columns:
116
+ title + details on the left, the countdown on the right. -->
117
+ <div class="cpub-hero-body">
118
+ <div class="cpub-hero-pattern" aria-hidden="true">
119
+ <div class="cpub-hero-dots"></div>
120
+ <div class="cpub-hero-lines"></div>
82
121
  </div>
83
122
 
84
- <div class="cpub-hero-eyebrow">
85
- <span class="cpub-contest-badge"><i class="fa fa-trophy"></i> Contest</span>
86
- </div>
123
+ <div class="cpub-hero-inner">
124
+ <div v-if="c?.status === 'cancelled'" class="cpub-cancelled-banner">
125
+ <i class="fa-solid fa-ban"></i> This contest has been cancelled.
126
+ </div>
87
127
 
88
- <div class="cpub-hero-title">{{ c?.title || 'Contest' }}</div>
89
- <div class="cpub-hero-tagline">{{ tagline }}</div>
128
+ <div class="cpub-hero-grid">
129
+ <!-- LEFT: title + details + actions -->
130
+ <div class="cpub-hero-main">
131
+ <div class="cpub-hero-eyebrow">
132
+ <span class="cpub-contest-badge"><i class="fa fa-trophy"></i> Contest</span>
133
+ <span class="cpub-status-pill" :data-status="c?.status || 'upcoming'">{{ c?.status || 'upcoming' }}</span>
134
+ </div>
90
135
 
91
- <div class="cpub-hero-meta">
92
- <span v-if="c?.startDate || c?.endDate" class="cpub-hero-meta-item">
93
- <i class="fa fa-calendar"></i>
94
- {{ c?.startDate ? new Date(c.startDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '' }}{{ c?.startDate && c?.endDate ? ' — ' : '' }}{{ c?.endDate ? new Date(c.endDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '' }}
95
- </span>
96
- <span v-if="c?.startDate || c?.endDate" class="cpub-hero-meta-sep">|</span>
97
- <span class="cpub-hero-meta-item"><i class="fa fa-folder-open"></i> {{ c?.entryCount ?? 0 }} entries</span>
98
- </div>
136
+ <h1 class="cpub-hero-title">{{ c?.title || 'Contest' }}</h1>
137
+ <p v-if="tagline" class="cpub-hero-tagline">{{ tagline }}</p>
99
138
 
100
- <!-- COUNTDOWN -->
101
- <div v-if="!isEnded" class="cpub-countdown-section">
102
- <div class="cpub-countdown-label"><i class="fa fa-clock"></i> {{ countdownLabel }}</div>
103
- <div class="cpub-countdown-row">
104
- <div class="cpub-countdown-block">
105
- <div class="cpub-countdown-val">{{ countdown.days }}</div>
106
- <div class="cpub-countdown-unit">Days</div>
107
- </div>
108
- <div class="cpub-countdown-sep">:</div>
109
- <div class="cpub-countdown-block">
110
- <div class="cpub-countdown-val">{{ countdown.hours }}</div>
111
- <div class="cpub-countdown-unit">Hours</div>
112
- </div>
113
- <div class="cpub-countdown-sep">:</div>
114
- <div class="cpub-countdown-block">
115
- <div class="cpub-countdown-val">{{ countdown.mins }}</div>
116
- <div class="cpub-countdown-unit">Minutes</div>
117
- </div>
118
- <div class="cpub-countdown-sep">:</div>
119
- <div class="cpub-countdown-block">
120
- <div class="cpub-countdown-val">{{ countdown.secs }}</div>
121
- <div class="cpub-countdown-unit">Seconds</div>
122
- </div>
123
- </div>
124
- </div>
139
+ <div class="cpub-hero-meta">
140
+ <span v-if="dateRange" class="cpub-hero-meta-item"><i class="fa fa-calendar"></i> {{ dateRange }}</span>
141
+ <span class="cpub-hero-meta-item"><i class="fa fa-folder-open"></i> {{ c?.entryCount ?? 0 }} {{ (c?.entryCount ?? 0) === 1 ? 'entry' : 'entries' }}</span>
142
+ </div>
125
143
 
126
- <div class="cpub-hero-cta">
127
- <button v-if="isAuthenticated && c?.status === 'active'" class="cpub-btn cpub-btn-primary cpub-btn-lg" @click="emit('submit-entry')"><i class="fa fa-upload"></i> Submit Entry</button>
128
- <button class="cpub-btn cpub-btn-lg cpub-btn-dark" @click="emit('copy-link')"><i class="fa fa-link"></i> Share</button>
129
- </div>
144
+ <div class="cpub-hero-cta">
145
+ <button v-if="isAuthenticated && c?.status === 'active'" class="cpub-btn cpub-btn-primary cpub-btn-lg" @click="emit('submit-entry')"><i class="fa fa-upload"></i> Submit Entry</button>
146
+ <button class="cpub-btn cpub-btn-lg cpub-btn-dark" @click="emit('copy-link')"><i class="fa fa-link"></i> Share</button>
147
+ </div>
130
148
 
131
- <!-- Admin controls -->
132
- <div v-if="isAdmin && c" class="cpub-admin-controls">
133
- <span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i> Admin</span>
134
- <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>
135
- <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>
136
- <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>
137
- <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>
138
- <span class="cpub-admin-status">Status: <strong>{{ c.status }}</strong></span>
139
- </div>
149
+ <!-- Admin controls — bidirectional, derived from the valid-transition map. -->
150
+ <div v-if="isAdmin && c" class="cpub-admin-controls">
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>
160
+ </div>
161
+ </div>
140
162
 
141
- <div class="cpub-hero-stats">
142
- <div class="cpub-hero-stat">
143
- <div class="cpub-hero-stat-val">{{ c?.entryCount ?? 0 }}</div>
144
- <div class="cpub-hero-stat-label">Entries</div>
145
- </div>
146
- <div class="cpub-hero-stat">
147
- <div class="cpub-hero-stat-val">{{ c?.status ?? 'upcoming' }}</div>
148
- <div class="cpub-hero-stat-label">Status</div>
163
+ <!-- RIGHT: countdown -->
164
+ <aside class="cpub-hero-side">
165
+ <div v-if="showCountdown" class="cpub-countdown-section">
166
+ <div class="cpub-countdown-label"><i class="fa fa-clock"></i> {{ countdownLabel }}</div>
167
+ <div class="cpub-countdown-row">
168
+ <div class="cpub-countdown-block">
169
+ <div class="cpub-countdown-val">{{ countdown.days }}</div>
170
+ <div class="cpub-countdown-unit">Days</div>
171
+ </div>
172
+ <div class="cpub-countdown-sep">:</div>
173
+ <div class="cpub-countdown-block">
174
+ <div class="cpub-countdown-val">{{ countdown.hours }}</div>
175
+ <div class="cpub-countdown-unit">Hours</div>
176
+ </div>
177
+ <div class="cpub-countdown-sep">:</div>
178
+ <div class="cpub-countdown-block">
179
+ <div class="cpub-countdown-val">{{ countdown.mins }}</div>
180
+ <div class="cpub-countdown-unit">Minutes</div>
181
+ </div>
182
+ <div class="cpub-countdown-sep">:</div>
183
+ <div class="cpub-countdown-block">
184
+ <div class="cpub-countdown-val">{{ countdown.secs }}</div>
185
+ <div class="cpub-countdown-unit">Seconds</div>
186
+ </div>
187
+ </div>
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>
197
+ <div v-else class="cpub-countdown-ended">
198
+ <i class="fa-solid fa-flag-checkered"></i>
199
+ <span>{{ countdownLabel }}</span>
200
+ </div>
201
+ </aside>
149
202
  </div>
150
203
  </div>
151
204
  </div>
@@ -157,37 +210,61 @@ const tagline = computed<string>(() => {
157
210
  --hero-bg: var(--text);
158
211
  --hero-text: var(--color-text-inverse);
159
212
  --hero-text-dim: var(--text-faint);
160
- /* Alpha of the hero foreground so the structure lines/surfaces track
161
- the inverted hero in both themes (white-on-dark in light mode,
162
- dark-on-light in dark mode) instead of vanishing white-on-white. */
213
+ /* Alpha of the hero foreground so the structure lines/surfaces track the
214
+ inverted hero in both themes (white-on-dark in light mode, dark-on-light
215
+ in dark mode) instead of vanishing white-on-white. */
163
216
  --hero-border: color-mix(in srgb, var(--hero-text) 18%, transparent);
164
217
  --hero-surface: color-mix(in srgb, var(--hero-text) 7%, transparent);
165
- position: relative; overflow: hidden; background: var(--hero-bg); padding: 56px 0 48px;
218
+ }
219
+
220
+ /* ── BANNER BAND ── full-width, clean, like other content pages' hero banner. */
221
+ .cpub-hero-banner {
222
+ width: 100%;
223
+ background: var(--surface2);
224
+ border-bottom: var(--border-width-default) solid var(--border);
225
+ overflow: hidden;
226
+ }
227
+ .cpub-hero-banner img {
228
+ display: block;
229
+ width: 100%;
230
+ max-height: 300px;
231
+ object-fit: cover;
232
+ margin: 0 auto;
233
+ }
234
+
235
+ /* ── HERO BODY ── the contest's dark, patterned section. */
236
+ .cpub-hero-body {
237
+ position: relative;
238
+ overflow: hidden;
239
+ background: var(--hero-bg);
240
+ padding: 44px 0;
166
241
  }
167
242
  .cpub-hero-pattern { position: absolute; inset: 0; }
168
243
  .cpub-hero-dots { position: absolute; inset: 0; background-image: radial-gradient(var(--accent-border) 1.5px, transparent 1.5px); background-size: 28px 28px; opacity: .3; }
169
244
  .cpub-hero-lines { position: absolute; inset: 0; background-image: linear-gradient(var(--accent-bg) 1px, transparent 1px), linear-gradient(90deg, var(--accent-bg) 1px, transparent 1px); background-size: 56px 56px; }
170
245
  .cpub-hero-inner { max-width: 1100px; margin: 0 auto; padding: 0 32px; position: relative; z-index: 1; }
171
- .cpub-hero-banner { display: block; width: 100%; max-height: 195px; object-fit: cover; margin-bottom: 28px; border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); }
172
- .cpub-hero-eyebrow { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
246
+
247
+ .cpub-cancelled-banner { background: var(--red-bg); border: var(--border-width-default) solid var(--red-border); color: var(--red); padding: 10px 14px; font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 8px; margin-bottom: 20px; }
248
+
249
+ /* 2-column: details (flex) + countdown (auto width). */
250
+ .cpub-hero-grid { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 48px; align-items: start; }
251
+ .cpub-hero-main { min-width: 0; }
252
+
253
+ .cpub-hero-eyebrow { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
173
254
  .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; }
174
255
  .cpub-contest-badge i { font-size: 8px; }
175
- .cpub-hero-title { font-size: 36px; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin-bottom: 10px; color: var(--hero-text); }
176
- .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; }
177
- .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; }
178
- .cpub-hero-meta-item { display: flex; align-items: center; gap: 5px; }
179
- .cpub-hero-meta-sep { color: var(--hero-border); }
180
-
181
- .cpub-countdown-section { margin-bottom: 28px; }
182
- .cpub-countdown-label { font-size: 10px; font-family: var(--font-mono); color: var(--hero-text-dim); letter-spacing: .1em; text-transform: uppercase; margin-bottom: 10px; display: flex; align-items: center; gap: 4px; }
183
- .cpub-countdown-label i { color: var(--accent); }
184
- .cpub-countdown-row { display: flex; align-items: center; gap: 8px; }
185
- .cpub-countdown-block { display: flex; flex-direction: column; align-items: center; background: var(--hero-surface); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 10px 16px; min-width: 60px; box-shadow: 4px 4px 0 var(--hero-surface); }
186
- .cpub-countdown-val { font-size: 26px; font-weight: 700; font-family: var(--font-mono); color: var(--hero-text); line-height: 1; margin-bottom: 4px; }
187
- .cpub-countdown-unit { font-size: 9px; text-transform: uppercase; letter-spacing: .1em; color: var(--hero-text-dim); font-family: var(--font-mono); }
188
- .cpub-countdown-sep { font-size: 20px; font-weight: 700; color: var(--hero-border); margin-top: -8px; font-family: var(--font-mono); }
256
+ .cpub-status-pill { font-size: 9px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; font-family: var(--font-mono); padding: 3px 10px; border-radius: var(--radius); border: var(--border-width-default) solid var(--hero-border); color: var(--hero-text-dim); }
257
+ .cpub-status-pill[data-status="active"] { color: var(--green); border-color: var(--green); background: color-mix(in srgb, var(--green) 14%, transparent); }
258
+ .cpub-status-pill[data-status="judging"] { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
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; }
262
+ .cpub-status-pill[data-status="completed"], .cpub-status-pill[data-status="cancelled"] { color: var(--red); border-color: var(--red-border); }
189
263
 
190
- .cpub-cancelled-banner { background: var(--red-bg); border: var(--border-width-default) solid var(--red-border); color: var(--red); padding: 10px 14px; font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
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); }
265
+ .cpub-hero-tagline { font-size: 14px; color: var(--hero-text-dim); line-height: 1.55; max-width: 600px; margin: 0 0 20px; display: -webkit-box; -webkit-line-clamp: 4; line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
266
+ .cpub-hero-meta { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; font-size: 11px; color: var(--hero-text-dim); font-family: var(--font-mono); margin-bottom: 24px; }
267
+ .cpub-hero-meta-item { display: flex; align-items: center; gap: 6px; }
191
268
 
192
269
  .cpub-hero-cta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
193
270
  .cpub-btn-lg { padding: 10px 22px; font-size: 13px; }
@@ -196,31 +273,40 @@ const tagline = computed<string>(() => {
196
273
  .cpub-btn-cancel { color: var(--red); border-color: var(--red-border); }
197
274
  .cpub-btn-cancel:hover { background: var(--red-bg); }
198
275
 
199
- .cpub-hero-stats { display: flex; gap: 24px; margin-top: 28px; padding-top: 24px; border-top: var(--border-width-default) solid var(--hero-border); }
200
- .cpub-hero-stat { display: flex; flex-direction: column; }
201
- .cpub-hero-stat-val { font-size: 20px; font-weight: 700; font-family: var(--font-mono); color: var(--hero-text); }
202
- .cpub-hero-stat-label { font-size: 10px; color: var(--hero-text-dim); text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono); }
203
-
204
- .cpub-admin-controls { display: flex; align-items: center; gap: 8px; margin-top: 16px; padding: 10px 14px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
276
+ .cpub-admin-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 18px; padding: 10px 14px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
205
277
  .cpub-admin-controls-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); margin-right: 4px; font-family: var(--font-mono); }
206
- .cpub-admin-status { font-size: 11px; color: var(--text-dim); margin-left: auto; font-family: var(--font-mono); }
207
- .cpub-admin-status strong { color: var(--accent); text-transform: capitalize; }
208
278
 
279
+ /* ── COUNTDOWN (right column) ── */
280
+ .cpub-hero-side { display: flex; flex-direction: column; }
281
+ .cpub-countdown-section { background: var(--hero-surface); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 16px 18px; }
282
+ .cpub-countdown-label { font-size: 10px; font-family: var(--font-mono); color: var(--hero-text-dim); letter-spacing: .1em; text-transform: uppercase; margin-bottom: 12px; display: flex; align-items: center; gap: 5px; white-space: nowrap; }
283
+ .cpub-countdown-label i { color: var(--accent); }
284
+ .cpub-countdown-row { display: flex; align-items: flex-start; gap: 8px; }
285
+ .cpub-countdown-block { display: flex; flex-direction: column; align-items: center; background: var(--hero-bg); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 10px 14px; min-width: 56px; }
286
+ .cpub-countdown-val { font-size: 24px; font-weight: 700; font-family: var(--font-mono); color: var(--hero-text); line-height: 1; margin-bottom: 4px; }
287
+ .cpub-countdown-unit { font-size: 8px; text-transform: uppercase; letter-spacing: .1em; color: var(--hero-text-dim); font-family: var(--font-mono); }
288
+ .cpub-countdown-sep { font-size: 20px; font-weight: 700; color: var(--hero-border); font-family: var(--font-mono); padding-top: 10px; }
289
+ .cpub-countdown-ended { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .08em; color: var(--hero-text-dim); background: var(--hero-surface); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 14px 18px; }
290
+ .cpub-countdown-ended i { color: var(--accent); }
291
+
292
+ /* ── RESPONSIVE ── stack the countdown below the details. */
293
+ @media (max-width: 900px) {
294
+ .cpub-hero-grid { grid-template-columns: 1fr; gap: 28px; }
295
+ .cpub-hero-side { align-items: flex-start; }
296
+ }
209
297
  @media (max-width: 768px) {
210
- .cpub-hero { padding: 32px 0 28px; }
298
+ .cpub-hero-body { padding: 32px 0; }
211
299
  .cpub-hero-inner { padding: 0 16px; }
300
+ .cpub-hero-banner img { max-height: 200px; }
212
301
  .cpub-hero-title { font-size: 24px; }
213
- .cpub-hero-tagline { font-size: 13px; }
214
- .cpub-hero-meta { flex-wrap: wrap; gap: 10px; }
215
- .cpub-countdown-block { padding: 8px 12px; min-width: 48px; }
216
- .cpub-countdown-val { font-size: 20px; }
302
+ .cpub-hero-meta { gap: 10px; }
217
303
  }
218
304
  @media (max-width: 480px) {
219
305
  .cpub-hero-title { font-size: 20px; }
220
306
  .cpub-hero-tagline { font-size: 12px; margin-bottom: 16px; }
221
- .cpub-hero-stats { flex-wrap: wrap; gap: 16px; }
222
- .cpub-hero-stat-val { font-size: 16px; }
223
307
  .cpub-hero-cta { flex-direction: column; align-items: stretch; }
224
308
  .cpub-countdown-row { flex-wrap: wrap; justify-content: center; }
309
+ .cpub-countdown-block { min-width: 48px; padding: 8px 12px; }
310
+ .cpub-countdown-val { font-size: 20px; }
225
311
  }
226
312
  </style>
@@ -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.48.0",
3
+ "version": "0.50.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -54,14 +54,14 @@
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
- "@commonpub/docs": "0.6.3",
60
60
  "@commonpub/explainer": "0.7.15",
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/schema": "0.26.0",
64
- "@commonpub/server": "2.73.0",
65
65
  "@commonpub/ui": "0.9.2"
66
66
  },
67
67
  "devDependencies": {
@@ -13,10 +13,16 @@ 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('');
19
24
  const bannerUrl = ref('');
25
+ const coverImageUrl = ref('');
20
26
  const startDate = ref('');
21
27
  const endDate = ref('');
22
28
  const judgingEndDate = ref('');
@@ -41,6 +47,7 @@ function toggleRole(r: string): void {
41
47
  else visibleToRoles.value.push(r);
42
48
  }
43
49
 
50
+ const showPrizes = ref(true);
44
51
  const prizesDescription = ref('');
45
52
  interface Prize { place: number | null; category: string; title: string; description: string; value: string }
46
53
  const prizes = ref<Prize[]>([]);
@@ -52,10 +59,12 @@ const criteria = ref<Criterion[]>([]);
52
59
  watch(contest, (c) => {
53
60
  if (!c) return;
54
61
  title.value = c.title ?? '';
62
+ slugInput.value = c.slug ?? '';
55
63
  subheading.value = c.subheading ?? '';
56
64
  description.value = c.description ?? '';
57
65
  rules.value = c.rules ?? '';
58
66
  bannerUrl.value = c.bannerUrl ?? '';
67
+ coverImageUrl.value = c.coverImageUrl ?? '';
59
68
  startDate.value = c.startDate ? new Date(c.startDate).toISOString().slice(0, 16) : '';
60
69
  endDate.value = c.endDate ? new Date(c.endDate).toISOString().slice(0, 16) : '';
61
70
  judgingEndDate.value = c.judgingEndDate ? new Date(c.judgingEndDate).toISOString().slice(0, 16) : '';
@@ -65,6 +74,7 @@ watch(contest, (c) => {
65
74
  maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
66
75
  visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
67
76
  visibleToRoles.value = [...(c.visibleToRoles ?? [])];
77
+ showPrizes.value = c.showPrizes !== false;
68
78
  prizesDescription.value = c.prizesDescription ?? '';
69
79
  prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
70
80
  place: p.place ?? null,
@@ -136,14 +146,16 @@ async function handleSave(): Promise<void> {
136
146
  description: c.description.trim() || undefined,
137
147
  }));
138
148
 
139
- await $fetch(`/api/contests/${slug}`, {
149
+ const updated = await $fetch<{ slug: string }>(`/api/contests/${slug}`, {
140
150
  method: 'PUT',
141
151
  body: {
142
152
  title: title.value,
153
+ slug: slugify(slugInput.value) || undefined,
143
154
  subheading: subheading.value || undefined,
144
155
  description: description.value || undefined,
145
156
  rules: rules.value || undefined,
146
157
  bannerUrl: bannerUrl.value || undefined,
158
+ coverImageUrl: coverImageUrl.value || undefined,
147
159
  startDate: startDate.value ? new Date(startDate.value).toISOString() : undefined,
148
160
  endDate: endDate.value ? new Date(endDate.value).toISOString() : undefined,
149
161
  judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
@@ -153,12 +165,20 @@ async function handleSave(): Promise<void> {
153
165
  maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
154
166
  visibility: visibility.value,
155
167
  visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
168
+ showPrizes: showPrizes.value,
156
169
  prizesDescription: prizesDescription.value || undefined,
157
170
  prizes: prizeData,
158
171
  judgingCriteria: criteriaData,
159
172
  },
160
173
  });
161
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
+ }
162
182
  await refresh();
163
183
  } catch (err: unknown) {
164
184
  toast.error(extractError(err));
@@ -181,11 +201,36 @@ async function handleDelete(): Promise<void> {
181
201
  }
182
202
  }
183
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
+
184
229
  async function transitionStatus(newStatus: string): Promise<void> {
185
- const msg = newStatus === 'cancelled'
186
- ? 'Cancel this contest? This cannot be undone.'
187
- : `Change contest status to "${newStatus}"?`;
188
- 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;
189
234
  try {
190
235
  await $fetch(`/api/contests/${slug}/transition`, { method: 'POST', body: { status: newStatus } });
191
236
  toast.success(`Status changed to ${newStatus}`);
@@ -215,6 +260,11 @@ async function transitionStatus(newStatus: string): Promise<void> {
215
260
  <label class="cpub-form-label">Title</label>
216
261
  <input v-model="title" type="text" class="cpub-form-input" />
217
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>
218
268
  <div class="cpub-form-field">
219
269
  <label class="cpub-form-label">Subheading</label>
220
270
  <input v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
@@ -231,7 +281,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
231
281
  <p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
232
282
  </div>
233
283
  <div class="cpub-form-field">
234
- <ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide image shown across the top of the contest page (~4:1)." />
284
+ <ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide hero image across the top of the contest page (~4:1)." />
285
+ </div>
286
+ <div class="cpub-form-field">
287
+ <ImageUpload v-model="coverImageUrl" purpose="cover" label="Cover Image (optional)" hint="Card/thumbnail image shown in listings (~4:3). Falls back to the banner if unset." />
235
288
  </div>
236
289
  </section>
237
290
 
@@ -274,6 +327,11 @@ async function transitionStatus(newStatus: string): Promise<void> {
274
327
 
275
328
  <section class="cpub-form-section">
276
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>
277
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>
278
336
  <div class="cpub-form-field">
279
337
  <label class="cpub-form-label">Prizes overview (optional)</label>
@@ -385,36 +443,32 @@ async function transitionStatus(newStatus: string): Promise<void> {
385
443
  </section>
386
444
 
387
445
  <section class="cpub-form-section">
388
- <h2 class="cpub-form-section-title">Status Transitions</h2>
446
+ <h2 class="cpub-form-section-title">Stage &amp; Status</h2>
389
447
  <p class="cpub-form-hint">
390
- A contest moves through a lifecycle:
391
- <strong>Upcoming</strong> → <strong>Active</strong> (accepting entries)
392
- <strong>Judging</strong> (entries closed, judges scoring)
393
- <strong>Completed</strong> (results &amp; rankings published). You can cancel at any
394
- 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:
395
452
  <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
396
453
  </p>
397
454
  <div class="cpub-status-actions">
398
- <button v-if="contest.status === 'upcoming'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-activate" @click="transitionStatus('active')">
399
- <i class="fa-solid fa-play"></i> Start Contest
400
- </button>
401
- <button v-if="contest.status === 'active'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-judging" @click="transitionStatus('judging')">
402
- <i class="fa-solid fa-gavel"></i> Begin Judging
403
- </button>
404
- <button v-if="contest.status === 'judging'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-complete" @click="transitionStatus('completed')">
405
- <i class="fa-solid fa-flag-checkered"></i> Complete &amp; Publish Results
406
- </button>
407
455
  <button
408
- v-if="contest.status !== 'completed' && contest.status !== 'cancelled'"
456
+ v-for="t in availableTransitions"
457
+ :key="t"
409
458
  type="button"
410
- class="cpub-btn cpub-transition-btn cpub-transition-cancel"
411
- @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)"
412
466
  >
413
- <i class="fa-solid fa-ban"></i> Cancel Contest
467
+ <i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}
414
468
  </button>
415
- <p v-if="contest.status === 'completed' || contest.status === 'cancelled'" class="cpub-status-terminal">
469
+ <p v-if="!availableTransitions.length" class="cpub-status-terminal">
416
470
  <i class="fa-solid fa-circle-check"></i>
417
- This contest is {{ contest.status }} — no further status changes are available.
471
+ No status changes available from <strong>{{ contest.status }}</strong>.
418
472
  </p>
419
473
  </div>
420
474
  </section>
@@ -448,8 +502,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
448
502
  .cpub-edit-subtitle { font-size: 13px; color: var(--text-dim); margin-bottom: 24px; display: flex; align-items: center; gap: 8px; }
449
503
 
450
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; }
451
506
  .cpub-status-upcoming { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
452
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); }
453
509
  .cpub-status-judging { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
454
510
  .cpub-status-completed { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); }
455
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,10 +8,18 @@ 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('');
14
21
  const bannerUrl = ref('');
22
+ const coverImageUrl = ref('');
15
23
  const startDate = ref('');
16
24
  const endDate = ref('');
17
25
  const judgingEndDate = ref('');
@@ -47,12 +55,12 @@ interface Prize {
47
55
  value: string;
48
56
  }
49
57
 
58
+ const showPrizes = ref(true);
50
59
  const prizesDescription = ref('');
51
- const prizes = ref<Prize[]>([
52
- { place: 1, category: '', title: '1st Place', description: '', value: '' },
53
- { place: 2, category: '', title: '2nd Place', description: '', value: '' },
54
- { place: 3, category: '', title: '3rd Place', description: '', value: '' },
55
- ]);
60
+ // Prizes are entirely optional — start empty so a contest has NO prizes unless
61
+ // the operator explicitly adds them (the old 3 pre-filled rows forced prizes
62
+ // onto every contest, since their non-empty titles survived the submit filter).
63
+ const prizes = ref<Prize[]>([]);
56
64
 
57
65
  function addPrize(): void {
58
66
  prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
@@ -92,10 +100,12 @@ 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,
98
107
  bannerUrl: bannerUrl.value || undefined,
108
+ coverImageUrl: coverImageUrl.value || undefined,
99
109
  startDate: new Date(startDate.value).toISOString(),
100
110
  endDate: new Date(endDate.value).toISOString(),
101
111
  judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
@@ -105,6 +115,7 @@ async function handleCreate(): Promise<void> {
105
115
  visibleToRoles: visibility.value === 'private' && visibleToRoles.value.length ? visibleToRoles.value : undefined,
106
116
  eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
107
117
  maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
118
+ showPrizes: showPrizes.value,
108
119
  prizesDescription: prizesDescription.value || undefined,
109
120
  prizes: prizes.value
110
121
  .filter(p => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
@@ -156,6 +167,11 @@ function prizeLabel(prize: Prize): string {
156
167
  <label for="contest-title" class="cpub-form-label">Title</label>
157
168
  <input id="contest-title" v-model="title" type="text" class="cpub-form-input" required placeholder="Maker Challenge 2026" />
158
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>
159
175
  <div class="cpub-form-field">
160
176
  <label for="contest-subheading" class="cpub-form-label">Subheading</label>
161
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" />
@@ -172,7 +188,10 @@ function prizeLabel(prize: Prize): string {
172
188
  <p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
173
189
  </div>
174
190
  <div class="cpub-form-field">
175
- <ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide image shown across the top of the contest page (~4:1)." />
191
+ <ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide hero image across the top of the contest page (~4:1)." />
192
+ </div>
193
+ <div class="cpub-form-field">
194
+ <ImageUpload v-model="coverImageUrl" purpose="cover" label="Cover Image (optional)" hint="Card/thumbnail image shown in listings (~4:3). Falls back to the banner if unset." />
176
195
  </div>
177
196
  </section>
178
197
 
@@ -281,13 +300,19 @@ function prizeLabel(prize: Prize): string {
281
300
  <!-- Prizes -->
282
301
  <section class="cpub-form-section">
283
302
  <div class="cpub-form-section-header">
284
- <h2 class="cpub-form-section-title">Prizes</h2>
303
+ <h2 class="cpub-form-section-title">Prizes <span style="color: var(--text-faint); font-weight: 400; font-size: 0.75em; font-family: var(--font-mono);">— optional</span></h2>
285
304
  <button type="button" class="cpub-btn cpub-btn-sm" @click="addPrize">
286
305
  <i class="fa-solid fa-plus"></i> Add Prize
287
306
  </button>
288
307
  </div>
289
308
 
290
- <p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes (1st/2nd/3rd), a <strong>category</strong> for themed awards (e.g. "Best in Show"), or just a <strong>description</strong>. Cash value is optional.</p>
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
+
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>
291
316
  <div class="cpub-form-field">
292
317
  <label for="prizes-desc" class="cpub-form-label">Prizes overview (optional)</label>
293
318
  <textarea id="prizes-desc" v-model="prizesDescription" class="cpub-form-textarea" rows="3" placeholder="Intro shown above the prize cards. Supports Markdown." />
@@ -298,7 +323,7 @@ function prizeLabel(prize: Prize): string {
298
323
  <span class="cpub-prize-place">
299
324
  <i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize) }}
300
325
  </span>
301
- <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)">
302
327
  <i class="fa-solid fa-xmark"></i>
303
328
  </button>
304
329
  </div>
@@ -51,13 +51,15 @@ const canCreateContest = computed(() => {
51
51
  :to="`/contests/${contest.slug}`"
52
52
  class="cpub-card cpub-contest-card"
53
53
  >
54
- <!-- Banner thumbnail (contest.bannerUrl) with trophy fallback + status badge overlay -->
54
+ <!-- Card image: coverImageUrl (cover-cropped) bannerUrl (contained, so a
55
+ wide hero/logo isn't crop-mangled) → trophy fallback. Status badge overlaid. -->
55
56
  <div class="cpub-contest-thumb">
56
57
  <img
57
- v-if="coverFor(contest.bannerUrl)"
58
- :src="coverFor(contest.bannerUrl)!"
58
+ v-if="coverFor(contest.coverImageUrl ?? contest.bannerUrl)"
59
+ :src="coverFor(contest.coverImageUrl ?? contest.bannerUrl)!"
59
60
  :alt="contest.title"
60
61
  class="cpub-contest-cover"
62
+ :class="{ 'cpub-contest-cover--contain': !contest.coverImageUrl && !!contest.bannerUrl }"
61
63
  loading="lazy"
62
64
  />
63
65
  <template v-else>
@@ -66,7 +68,7 @@ const canCreateContest = computed(() => {
66
68
  </template>
67
69
  <span class="cpub-badge cpub-contest-thumb-badge" :class="{
68
70
  'cpub-badge-green': contest.status === 'active',
69
- 'cpub-badge-yellow': contest.status === 'upcoming',
71
+ 'cpub-badge-yellow': contest.status === 'upcoming' || contest.status === 'paused',
70
72
  'cpub-badge-accent': contest.status === 'judging',
71
73
  'cpub-badge-red': contest.status === 'completed' || contest.status === 'cancelled',
72
74
  }">{{ contest.status }}</span>
@@ -76,8 +78,8 @@ const canCreateContest = computed(() => {
76
78
  <p v-if="cardBlurb(contest)" class="cpub-contest-card-blurb">
77
79
  {{ cardBlurb(contest) }}
78
80
  </p>
79
- <div v-if="contest.endDate" style="margin-top: 8px">
80
- <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 />
81
83
  </div>
82
84
  <div class="cpub-contest-card-meta">
83
85
  <span><i class="fa-solid fa-users"></i> {{ contest.entryCount }} entries</span>
@@ -116,6 +118,8 @@ const canCreateContest = computed(() => {
116
118
  overflow: hidden;
117
119
  }
118
120
  .cpub-contest-cover { width: 100%; height: 100%; object-fit: cover; display: block; }
121
+ /* Banner-as-fallback: contain (+ breathing room) so a wide hero/logo shows whole, not crop-mangled. */
122
+ .cpub-contest-cover--contain { object-fit: contain; padding: 12px; background: var(--surface2); }
119
123
  .cpub-contest-thumb-grid {
120
124
  position: absolute;
121
125
  inset: 0;
@@ -131,6 +135,7 @@ const canCreateContest = computed(() => {
131
135
 
132
136
  .cpub-contest-card-title { font-size: 15px; font-weight: 600; margin: 0 0 6px; color: var(--text); }
133
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; }
134
139
 
135
140
  .cpub-contest-card-blurb {
136
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;