@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.
- package/components/CountdownTimer.vue +39 -1
- package/components/contest/ContestHero.vue +192 -106
- package/components/contest/ContestSidebar.vue +7 -1
- package/package.json +4 -4
- package/pages/contests/[slug]/edit.vue +83 -27
- package/pages/contests/[slug]/index.vue +2 -2
- package/pages/contests/create.vue +34 -9
- package/pages/contests/index.vue +11 -6
- package/server/api/contests/[slug]/index.put.ts +9 -1
- package/server/api/contests/index.post.ts +2 -1
|
@@ -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) || '
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
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-
|
|
85
|
-
<
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
.cpub-
|
|
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-
|
|
176
|
-
.cpub-
|
|
177
|
-
.cpub-
|
|
178
|
-
.cpub-
|
|
179
|
-
.cpub-
|
|
180
|
-
|
|
181
|
-
.cpub-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (!confirm(
|
|
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
|
|
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
|
|
446
|
+
<h2 class="cpub-form-section-title">Stage & Status</h2>
|
|
389
447
|
<p class="cpub-form-hint">
|
|
390
|
-
A contest
|
|
391
|
-
<strong>
|
|
392
|
-
<strong>
|
|
393
|
-
|
|
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 & Publish Results
|
|
406
|
-
</button>
|
|
407
455
|
<button
|
|
408
|
-
v-
|
|
456
|
+
v-for="t in availableTransitions"
|
|
457
|
+
:key="t"
|
|
409
458
|
type="button"
|
|
410
|
-
class="cpub-btn cpub-transition-btn
|
|
411
|
-
|
|
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
|
|
467
|
+
<i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}
|
|
414
468
|
</button>
|
|
415
|
-
<p v-if="
|
|
469
|
+
<p v-if="!availableTransitions.length" class="cpub-status-terminal">
|
|
416
470
|
<i class="fa-solid fa-circle-check"></i>
|
|
417
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
|
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>
|
package/pages/contests/index.vue
CHANGED
|
@@ -51,13 +51,15 @@ const canCreateContest = computed(() => {
|
|
|
51
51
|
:to="`/contests/${contest.slug}`"
|
|
52
52
|
class="cpub-card cpub-contest-card"
|
|
53
53
|
>
|
|
54
|
-
<!--
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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;
|