@commonpub/layer 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/CountdownTimer.vue +39 -1
- package/components/contest/ContestHero.vue +88 -14
- package/components/contest/ContestSidebar.vue +7 -1
- package/package.json +5 -5
- package/pages/contests/[slug]/edit.vue +108 -30
- package/pages/contests/[slug]/index.vue +2 -2
- package/pages/contests/create.vue +50 -4
- package/pages/contests/index.vue +4 -3
- 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>
|
|
@@ -18,18 +18,28 @@ const c = computed(() => props.contest);
|
|
|
18
18
|
|
|
19
19
|
// Countdown timer
|
|
20
20
|
const countdown = ref({ days: '00', hours: '00', mins: '00', secs: '00' });
|
|
21
|
+
const targetPassed = ref(false);
|
|
21
22
|
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
|
22
23
|
|
|
23
24
|
function pad(n: number): string { return String(n).padStart(2, '0'); }
|
|
24
25
|
|
|
26
|
+
// The countdown target depends on the lifecycle stage: an UPCOMING contest counts
|
|
27
|
+
// down to when it OPENS (startDate); while JUDGING, to the judging deadline;
|
|
28
|
+
// otherwise (active) to the submission close (endDate).
|
|
29
|
+
const countdownTargetStr = computed<string | null>(() => {
|
|
30
|
+
const s = c.value?.status;
|
|
31
|
+
if (s === 'judging') return c.value?.judgingEndDate ?? c.value?.endDate ?? null;
|
|
32
|
+
if (s === 'upcoming') return c.value?.startDate ?? null;
|
|
33
|
+
return c.value?.endDate ?? null;
|
|
34
|
+
});
|
|
35
|
+
|
|
25
36
|
function updateCountdown(): void {
|
|
26
|
-
|
|
27
|
-
// submission end date.
|
|
28
|
-
const isJudging = c.value?.status === 'judging';
|
|
29
|
-
const targetStr = isJudging ? (c.value?.judgingEndDate ?? c.value?.endDate) : c.value?.endDate;
|
|
37
|
+
const targetStr = countdownTargetStr.value;
|
|
30
38
|
const target = targetStr ? new Date(targetStr) : new Date();
|
|
31
39
|
const now = new Date();
|
|
32
|
-
|
|
40
|
+
const rawDiff = Math.floor((target.getTime() - now.getTime()) / 1000);
|
|
41
|
+
targetPassed.value = rawDiff <= 0;
|
|
42
|
+
let diff = Math.max(0, rawDiff);
|
|
33
43
|
const days = Math.floor(diff / 86400); diff %= 86400;
|
|
34
44
|
const hours = Math.floor(diff / 3600); diff %= 3600;
|
|
35
45
|
const mins = Math.floor(diff / 60);
|
|
@@ -47,12 +57,58 @@ onUnmounted(() => {
|
|
|
47
57
|
});
|
|
48
58
|
|
|
49
59
|
const countdownLabel = computed(() => {
|
|
50
|
-
|
|
51
|
-
if (
|
|
60
|
+
const s = c.value?.status;
|
|
61
|
+
if (s === 'completed' || s === 'cancelled') return 'Contest ended';
|
|
62
|
+
if (s === 'judging') return 'Judging ends in';
|
|
63
|
+
if (s === 'upcoming') return 'Opens in';
|
|
52
64
|
return 'Submissions close in';
|
|
53
65
|
});
|
|
54
66
|
|
|
55
67
|
const isEnded = computed(() => c.value?.status === 'completed' || c.value?.status === 'cancelled');
|
|
68
|
+
const isPaused = computed(() => c.value?.status === 'paused');
|
|
69
|
+
const isDraft = computed(() => c.value?.status === 'draft');
|
|
70
|
+
// Live countdown only while the clock is actually running AND its target is still
|
|
71
|
+
// in the future. Once the target passes (an upcoming contest whose open date has
|
|
72
|
+
// arrived, or an active one past its close), fall back to a static date note.
|
|
73
|
+
const showCountdown = computed(() => !isEnded.value && !isPaused.value && !isDraft.value && !!countdownTargetStr.value && !targetPassed.value);
|
|
74
|
+
|
|
75
|
+
function fmtDate(s: string | null | undefined): string {
|
|
76
|
+
if (!s) return '';
|
|
77
|
+
return new Date(s).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
78
|
+
}
|
|
79
|
+
// Static date shown when the relevant target is in the past but the contest hasn't
|
|
80
|
+
// been advanced yet (e.g. an upcoming contest whose open date arrived).
|
|
81
|
+
const dateNote = computed<string | null>(() => {
|
|
82
|
+
if (isEnded.value || isPaused.value || isDraft.value || !targetPassed.value) return null;
|
|
83
|
+
if (c.value?.status === 'upcoming') return c.value?.startDate ? `Opens ${fmtDate(c.value.startDate)}` : null;
|
|
84
|
+
return c.value?.endDate ? `Closed ${fmtDate(c.value.endDate)}` : null;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Client-side mirror of the server VALID_TRANSITIONS map (server/src/contest/contest.ts).
|
|
88
|
+
// Keeps the inline admin controls in sync with what the API will actually accept —
|
|
89
|
+
// bidirectional: go back a stage, pause/resume, reopen, etc.
|
|
90
|
+
const VALID_TRANSITIONS: Record<string, string[]> = {
|
|
91
|
+
draft: ['upcoming', 'active', 'cancelled'],
|
|
92
|
+
upcoming: ['draft', 'active', 'cancelled'],
|
|
93
|
+
active: ['upcoming', 'paused', 'judging', 'cancelled'],
|
|
94
|
+
paused: ['active', 'upcoming', 'judging', 'cancelled'],
|
|
95
|
+
judging: ['active', 'paused', 'completed', 'cancelled'],
|
|
96
|
+
completed: ['judging'],
|
|
97
|
+
cancelled: ['draft', 'upcoming'],
|
|
98
|
+
};
|
|
99
|
+
const STATUS_ACTION: Record<string, { label: string; icon: string }> = {
|
|
100
|
+
draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
|
|
101
|
+
upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
|
|
102
|
+
active: { label: 'Activate', icon: 'fa-play' },
|
|
103
|
+
paused: { label: 'Pause', icon: 'fa-pause' },
|
|
104
|
+
judging: { label: 'Start Judging', icon: 'fa-gavel' },
|
|
105
|
+
completed: { label: 'Complete', icon: 'fa-check' },
|
|
106
|
+
cancelled: { label: 'Cancel', icon: 'fa-ban' },
|
|
107
|
+
};
|
|
108
|
+
const availableTransitions = computed<string[]>(() => VALID_TRANSITIONS[c.value?.status ?? 'upcoming'] ?? []);
|
|
109
|
+
function statusAction(s: string): { label: string; icon: string } {
|
|
110
|
+
return STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
|
|
111
|
+
}
|
|
56
112
|
|
|
57
113
|
// The hero shows the short `subheading` (a dedicated tagline field). For older
|
|
58
114
|
// contests without one, fall back to a clean, plain-text, CSS-clamped excerpt of
|
|
@@ -116,19 +172,23 @@ const dateRange = computed<string>(() => {
|
|
|
116
172
|
<button class="cpub-btn cpub-btn-lg cpub-btn-dark" @click="emit('copy-link')"><i class="fa fa-link"></i> Share</button>
|
|
117
173
|
</div>
|
|
118
174
|
|
|
119
|
-
<!-- Admin controls -->
|
|
175
|
+
<!-- Admin controls — bidirectional, derived from the valid-transition map. -->
|
|
120
176
|
<div v-if="isAdmin && c" class="cpub-admin-controls">
|
|
121
|
-
<span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i>
|
|
122
|
-
<button
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
177
|
+
<span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i> Stage</span>
|
|
178
|
+
<button
|
|
179
|
+
v-for="t in availableTransitions"
|
|
180
|
+
:key="t"
|
|
181
|
+
class="cpub-btn cpub-btn-sm"
|
|
182
|
+
:class="{ 'cpub-btn-cancel': t === 'cancelled' }"
|
|
183
|
+
:disabled="transitioning"
|
|
184
|
+
@click="emit('transition', t)"
|
|
185
|
+
><i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}</button>
|
|
126
186
|
</div>
|
|
127
187
|
</div>
|
|
128
188
|
|
|
129
189
|
<!-- RIGHT: countdown -->
|
|
130
190
|
<aside class="cpub-hero-side">
|
|
131
|
-
<div v-if="
|
|
191
|
+
<div v-if="showCountdown" class="cpub-countdown-section">
|
|
132
192
|
<div class="cpub-countdown-label"><i class="fa fa-clock"></i> {{ countdownLabel }}</div>
|
|
133
193
|
<div class="cpub-countdown-row">
|
|
134
194
|
<div class="cpub-countdown-block">
|
|
@@ -152,6 +212,18 @@ const dateRange = computed<string>(() => {
|
|
|
152
212
|
</div>
|
|
153
213
|
</div>
|
|
154
214
|
</div>
|
|
215
|
+
<div v-else-if="isPaused" class="cpub-countdown-ended">
|
|
216
|
+
<i class="fa-solid fa-circle-pause"></i>
|
|
217
|
+
<span>Submissions paused</span>
|
|
218
|
+
</div>
|
|
219
|
+
<div v-else-if="isDraft" class="cpub-countdown-ended">
|
|
220
|
+
<i class="fa-solid fa-pen-ruler"></i>
|
|
221
|
+
<span>Draft — not launched</span>
|
|
222
|
+
</div>
|
|
223
|
+
<div v-else-if="dateNote" class="cpub-countdown-ended">
|
|
224
|
+
<i class="fa-regular fa-calendar"></i>
|
|
225
|
+
<span>{{ dateNote }}</span>
|
|
226
|
+
</div>
|
|
155
227
|
<div v-else class="cpub-countdown-ended">
|
|
156
228
|
<i class="fa-solid fa-flag-checkered"></i>
|
|
157
229
|
<span>{{ countdownLabel }}</span>
|
|
@@ -215,6 +287,8 @@ const dateRange = computed<string>(() => {
|
|
|
215
287
|
.cpub-status-pill[data-status="active"] { color: var(--green); border-color: var(--green); background: color-mix(in srgb, var(--green) 14%, transparent); }
|
|
216
288
|
.cpub-status-pill[data-status="judging"] { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
|
217
289
|
.cpub-status-pill[data-status="upcoming"] { color: var(--yellow); border-color: var(--yellow); }
|
|
290
|
+
.cpub-status-pill[data-status="paused"] { color: var(--yellow); border-color: var(--yellow); background: color-mix(in srgb, var(--yellow) 12%, transparent); }
|
|
291
|
+
.cpub-status-pill[data-status="draft"] { color: var(--hero-text-dim); border-color: var(--hero-border); border-style: dashed; }
|
|
218
292
|
.cpub-status-pill[data-status="completed"], .cpub-status-pill[data-status="cancelled"] { color: var(--red); border-color: var(--red-border); }
|
|
219
293
|
|
|
220
294
|
.cpub-hero-title { font-size: 34px; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin: 0 0 10px; color: var(--hero-text); }
|
|
@@ -22,7 +22,9 @@ function fmt(d: string | null | undefined): string | null {
|
|
|
22
22
|
|
|
23
23
|
// Ordinal position of each status along the lifecycle, used to mark steps
|
|
24
24
|
// done / current / upcoming.
|
|
25
|
-
|
|
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.51.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -55,14 +55,14 @@
|
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
56
|
"@commonpub/auth": "0.8.0",
|
|
57
57
|
"@commonpub/config": "0.18.0",
|
|
58
|
-
"@commonpub/learning": "0.5.2",
|
|
59
|
-
"@commonpub/explainer": "0.7.15",
|
|
60
58
|
"@commonpub/docs": "0.6.3",
|
|
59
|
+
"@commonpub/explainer": "0.7.15",
|
|
61
60
|
"@commonpub/editor": "0.7.11",
|
|
61
|
+
"@commonpub/learning": "0.5.2",
|
|
62
|
+
"@commonpub/schema": "0.28.0",
|
|
62
63
|
"@commonpub/protocol": "0.13.0",
|
|
63
64
|
"@commonpub/ui": "0.9.2",
|
|
64
|
-
"@commonpub/server": "2.
|
|
65
|
-
"@commonpub/schema": "0.27.0"
|
|
65
|
+
"@commonpub/server": "2.75.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -13,6 +13,11 @@ useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'} — ${useS
|
|
|
13
13
|
|
|
14
14
|
const saving = ref(false);
|
|
15
15
|
const title = ref('');
|
|
16
|
+
// Editable slug — initialised from the loaded contest, manual override allowed.
|
|
17
|
+
const slugInput = ref('');
|
|
18
|
+
function slugify(s: string): string {
|
|
19
|
+
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-+)|(-+$)/g, '').slice(0, 255);
|
|
20
|
+
}
|
|
16
21
|
const subheading = ref('');
|
|
17
22
|
const description = ref('');
|
|
18
23
|
const rules = ref('');
|
|
@@ -42,6 +47,7 @@ function toggleRole(r: string): void {
|
|
|
42
47
|
else visibleToRoles.value.push(r);
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
const showPrizes = ref(true);
|
|
45
51
|
const prizesDescription = ref('');
|
|
46
52
|
interface Prize { place: number | null; category: string; title: string; description: string; value: string }
|
|
47
53
|
const prizes = ref<Prize[]>([]);
|
|
@@ -53,6 +59,7 @@ const criteria = ref<Criterion[]>([]);
|
|
|
53
59
|
watch(contest, (c) => {
|
|
54
60
|
if (!c) return;
|
|
55
61
|
title.value = c.title ?? '';
|
|
62
|
+
slugInput.value = c.slug ?? '';
|
|
56
63
|
subheading.value = c.subheading ?? '';
|
|
57
64
|
description.value = c.description ?? '';
|
|
58
65
|
rules.value = c.rules ?? '';
|
|
@@ -67,6 +74,7 @@ watch(contest, (c) => {
|
|
|
67
74
|
maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
|
|
68
75
|
visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
|
|
69
76
|
visibleToRoles.value = [...(c.visibleToRoles ?? [])];
|
|
77
|
+
showPrizes.value = c.showPrizes !== false;
|
|
70
78
|
prizesDescription.value = c.prizesDescription ?? '';
|
|
71
79
|
prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
|
|
72
80
|
place: p.place ?? null,
|
|
@@ -138,10 +146,11 @@ async function handleSave(): Promise<void> {
|
|
|
138
146
|
description: c.description.trim() || undefined,
|
|
139
147
|
}));
|
|
140
148
|
|
|
141
|
-
await $fetch(`/api/contests/${slug}`, {
|
|
149
|
+
const updated = await $fetch<{ slug: string }>(`/api/contests/${slug}`, {
|
|
142
150
|
method: 'PUT',
|
|
143
151
|
body: {
|
|
144
152
|
title: title.value,
|
|
153
|
+
slug: slugify(slugInput.value) || undefined,
|
|
145
154
|
subheading: subheading.value || undefined,
|
|
146
155
|
description: description.value || undefined,
|
|
147
156
|
rules: rules.value || undefined,
|
|
@@ -156,12 +165,20 @@ async function handleSave(): Promise<void> {
|
|
|
156
165
|
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
157
166
|
visibility: visibility.value,
|
|
158
167
|
visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
|
|
168
|
+
showPrizes: showPrizes.value,
|
|
159
169
|
prizesDescription: prizesDescription.value || undefined,
|
|
160
170
|
prizes: prizeData,
|
|
161
171
|
judgingCriteria: criteriaData,
|
|
162
172
|
},
|
|
163
173
|
});
|
|
164
174
|
toast.success('Contest updated');
|
|
175
|
+
// Slug changed → the old URL no longer resolves. Navigate to the renamed
|
|
176
|
+
// contest's page — a different route component, so it loads fresh. (Navigating
|
|
177
|
+
// to the new /edit URL would reuse THIS component with its stale fetch key.)
|
|
178
|
+
if (updated?.slug && updated.slug !== slug) {
|
|
179
|
+
await navigateTo(`/contests/${updated.slug}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
165
182
|
await refresh();
|
|
166
183
|
} catch (err: unknown) {
|
|
167
184
|
toast.error(extractError(err));
|
|
@@ -184,11 +201,36 @@ async function handleDelete(): Promise<void> {
|
|
|
184
201
|
}
|
|
185
202
|
}
|
|
186
203
|
|
|
204
|
+
// Client mirror of the server VALID_TRANSITIONS map — bidirectional (go back a
|
|
205
|
+
// stage, pause/resume, reopen). Kept in sync with server/src/contest/contest.ts.
|
|
206
|
+
const VALID_TRANSITIONS: Record<string, string[]> = {
|
|
207
|
+
draft: ['upcoming', 'active', 'cancelled'],
|
|
208
|
+
upcoming: ['draft', 'active', 'cancelled'],
|
|
209
|
+
active: ['upcoming', 'paused', 'judging', 'cancelled'],
|
|
210
|
+
paused: ['active', 'upcoming', 'judging', 'cancelled'],
|
|
211
|
+
judging: ['active', 'paused', 'completed', 'cancelled'],
|
|
212
|
+
completed: ['judging'],
|
|
213
|
+
cancelled: ['draft', 'upcoming'],
|
|
214
|
+
};
|
|
215
|
+
const STATUS_ACTION: Record<string, { label: string; icon: string; tone?: 'go' | 'warn' | 'danger' }> = {
|
|
216
|
+
draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
|
|
217
|
+
upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
|
|
218
|
+
active: { label: 'Activate', icon: 'fa-play', tone: 'go' },
|
|
219
|
+
paused: { label: 'Pause', icon: 'fa-pause', tone: 'warn' },
|
|
220
|
+
judging: { label: 'Begin Judging', icon: 'fa-gavel' },
|
|
221
|
+
completed: { label: 'Complete & Publish', icon: 'fa-flag-checkered', tone: 'go' },
|
|
222
|
+
cancelled: { label: 'Cancel', icon: 'fa-ban', tone: 'danger' },
|
|
223
|
+
};
|
|
224
|
+
const availableTransitions = computed<string[]>(() => VALID_TRANSITIONS[contest.value?.status ?? 'upcoming'] ?? []);
|
|
225
|
+
function statusAction(s: string): { label: string; icon: string; tone?: string } {
|
|
226
|
+
return STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
|
|
227
|
+
}
|
|
228
|
+
|
|
187
229
|
async function transitionStatus(newStatus: string): Promise<void> {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
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;
|
|
192
234
|
try {
|
|
193
235
|
await $fetch(`/api/contests/${slug}/transition`, { method: 'POST', body: { status: newStatus } });
|
|
194
236
|
toast.success(`Status changed to ${newStatus}`);
|
|
@@ -218,6 +260,11 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
218
260
|
<label class="cpub-form-label">Title</label>
|
|
219
261
|
<input v-model="title" type="text" class="cpub-form-input" />
|
|
220
262
|
</div>
|
|
263
|
+
<div class="cpub-form-field">
|
|
264
|
+
<label class="cpub-form-label">URL Slug</label>
|
|
265
|
+
<input v-model="slugInput" type="text" class="cpub-form-input" @blur="slugInput = slugify(slugInput)" />
|
|
266
|
+
<p class="cpub-form-hint">The contest URL: <code>/contests/{{ slugify(slugInput) || 'your-contest' }}</code>. Changing it breaks old links — they won't redirect.</p>
|
|
267
|
+
</div>
|
|
221
268
|
<div class="cpub-form-field">
|
|
222
269
|
<label class="cpub-form-label">Subheading</label>
|
|
223
270
|
<input v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
|
|
@@ -280,6 +327,11 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
280
327
|
|
|
281
328
|
<section class="cpub-form-section">
|
|
282
329
|
<h2 class="cpub-form-section-title">Prizes</h2>
|
|
330
|
+
<label class="cpub-form-check" style="margin-bottom: 10px;">
|
|
331
|
+
<input v-model="showPrizes" type="checkbox" />
|
|
332
|
+
<span>Show the Prizes tab on the contest page</span>
|
|
333
|
+
</label>
|
|
334
|
+
<p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden — any prizes below are saved but not shown to visitors.</p>
|
|
283
335
|
<p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes, a <strong>category</strong> for themed awards, or just a <strong>description</strong> — whatever fits. Cash value is optional.</p>
|
|
284
336
|
<div class="cpub-form-field">
|
|
285
337
|
<label class="cpub-form-label">Prizes overview (optional)</label>
|
|
@@ -391,44 +443,36 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
391
443
|
</section>
|
|
392
444
|
|
|
393
445
|
<section class="cpub-form-section">
|
|
394
|
-
<h2 class="cpub-form-section-title">Status
|
|
446
|
+
<h2 class="cpub-form-section-title">Stage & Status</h2>
|
|
395
447
|
<p class="cpub-form-hint">
|
|
396
|
-
A contest
|
|
397
|
-
<strong>
|
|
398
|
-
<strong>
|
|
399
|
-
|
|
400
|
-
point before it completes. Current status:
|
|
448
|
+
A contest runs through <strong>Draft</strong> → <strong>Upcoming</strong> →
|
|
449
|
+
<strong>Active</strong> (accepting entries) → <strong>Judging</strong> →
|
|
450
|
+
<strong>Completed</strong>. You can move <em>backwards</em>, <strong>Pause</strong> to
|
|
451
|
+
temporarily stop submissions without cancelling, resume later, or cancel. Current status:
|
|
401
452
|
<span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
|
|
402
453
|
</p>
|
|
403
454
|
<div class="cpub-status-actions">
|
|
404
|
-
<button v-if="contest.status === 'upcoming'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-activate" @click="transitionStatus('active')">
|
|
405
|
-
<i class="fa-solid fa-play"></i> Start Contest
|
|
406
|
-
</button>
|
|
407
|
-
<button v-if="contest.status === 'active'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-judging" @click="transitionStatus('judging')">
|
|
408
|
-
<i class="fa-solid fa-gavel"></i> Begin Judging
|
|
409
|
-
</button>
|
|
410
|
-
<button v-if="contest.status === 'judging'" type="button" class="cpub-btn cpub-transition-btn cpub-transition-complete" @click="transitionStatus('completed')">
|
|
411
|
-
<i class="fa-solid fa-flag-checkered"></i> Complete & Publish Results
|
|
412
|
-
</button>
|
|
413
455
|
<button
|
|
414
|
-
v-
|
|
456
|
+
v-for="t in availableTransitions"
|
|
457
|
+
:key="t"
|
|
415
458
|
type="button"
|
|
416
|
-
class="cpub-btn cpub-transition-btn
|
|
417
|
-
|
|
459
|
+
class="cpub-btn cpub-transition-btn"
|
|
460
|
+
:class="{
|
|
461
|
+
'cpub-transition-activate': statusAction(t).tone === 'go',
|
|
462
|
+
'cpub-transition-judging': statusAction(t).tone === 'warn',
|
|
463
|
+
'cpub-transition-cancel': statusAction(t).tone === 'danger',
|
|
464
|
+
}"
|
|
465
|
+
@click="transitionStatus(t)"
|
|
418
466
|
>
|
|
419
|
-
<i class="fa-solid
|
|
467
|
+
<i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}
|
|
420
468
|
</button>
|
|
421
|
-
<p v-if="
|
|
469
|
+
<p v-if="!availableTransitions.length" class="cpub-status-terminal">
|
|
422
470
|
<i class="fa-solid fa-circle-check"></i>
|
|
423
|
-
|
|
471
|
+
No status changes available from <strong>{{ contest.status }}</strong>.
|
|
424
472
|
</p>
|
|
425
473
|
</div>
|
|
426
474
|
</section>
|
|
427
475
|
|
|
428
|
-
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
|
|
429
|
-
<i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving...' : 'Save Changes' }}
|
|
430
|
-
</button>
|
|
431
|
-
|
|
432
476
|
<section class="cpub-form-section cpub-danger-zone">
|
|
433
477
|
<h2 class="cpub-form-section-title cpub-danger-title">Danger Zone</h2>
|
|
434
478
|
<div class="cpub-danger-row">
|
|
@@ -441,6 +485,19 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
441
485
|
</button>
|
|
442
486
|
</div>
|
|
443
487
|
</section>
|
|
488
|
+
|
|
489
|
+
<!-- Sticky save bar — always reachable without scrolling to the bottom. -->
|
|
490
|
+
<div class="cpub-edit-actionbar">
|
|
491
|
+
<span class="cpub-edit-actionbar-status">
|
|
492
|
+
Status <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
|
|
493
|
+
</span>
|
|
494
|
+
<div class="cpub-edit-actionbar-btns">
|
|
495
|
+
<NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-edit-cancel">Cancel</NuxtLink>
|
|
496
|
+
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
|
|
497
|
+
<i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving…' : 'Save Changes' }}
|
|
498
|
+
</button>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
444
501
|
</form>
|
|
445
502
|
</div>
|
|
446
503
|
<div v-else class="cpub-not-found"><p>Contest not found</p></div>
|
|
@@ -454,8 +511,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
454
511
|
.cpub-edit-subtitle { font-size: 13px; color: var(--text-dim); margin-bottom: 24px; display: flex; align-items: center; gap: 8px; }
|
|
455
512
|
|
|
456
513
|
.cpub-status-badge { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; padding: 2px 8px; border: var(--border-width-default) solid; }
|
|
514
|
+
.cpub-status-draft { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); border-style: dashed; }
|
|
457
515
|
.cpub-status-upcoming { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
|
|
458
516
|
.cpub-status-active { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
|
|
517
|
+
.cpub-status-paused { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
|
|
459
518
|
.cpub-status-judging { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
|
|
460
519
|
.cpub-status-completed { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); }
|
|
461
520
|
.cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
|
|
@@ -507,8 +566,27 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
507
566
|
|
|
508
567
|
.cpub-not-found { text-align: center; padding: 64px; color: var(--text-dim); display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
|
509
568
|
|
|
569
|
+
/* Sticky save bar — pinned to the viewport bottom while editing the long form. */
|
|
570
|
+
.cpub-edit-actionbar {
|
|
571
|
+
position: sticky;
|
|
572
|
+
bottom: 0;
|
|
573
|
+
z-index: 20;
|
|
574
|
+
display: flex;
|
|
575
|
+
align-items: center;
|
|
576
|
+
justify-content: space-between;
|
|
577
|
+
gap: 12px;
|
|
578
|
+
margin: 4px -32px -32px;
|
|
579
|
+
padding: 14px 32px;
|
|
580
|
+
background: var(--surface);
|
|
581
|
+
border-top: 2px solid var(--border);
|
|
582
|
+
box-shadow: var(--shadow-lg);
|
|
583
|
+
}
|
|
584
|
+
.cpub-edit-actionbar-status { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); display: flex; align-items: center; gap: 8px; }
|
|
585
|
+
.cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
|
|
586
|
+
|
|
510
587
|
@media (max-width: 768px) {
|
|
511
588
|
.cpub-contest-edit { padding: 16px; }
|
|
512
589
|
.cpub-form-row { grid-template-columns: 1fr; }
|
|
590
|
+
.cpub-edit-actionbar { margin: 4px -16px -16px; padding: 12px 16px; }
|
|
513
591
|
}
|
|
514
592
|
</style>
|
|
@@ -52,7 +52,7 @@ interface Tab { key: string; label: string; icon: string; count?: number }
|
|
|
52
52
|
const tabs = computed<Tab[]>(() => {
|
|
53
53
|
const t: Tab[] = [{ key: 'overview', label: 'Overview', icon: 'fa-circle-info' }];
|
|
54
54
|
if (c.value?.rules) t.push({ key: 'rules', label: 'Rules', icon: 'fa-file-lines' });
|
|
55
|
-
if (c.value?.prizes?.length || c.value?.prizesDescription) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
|
|
55
|
+
if (c.value?.showPrizes !== false && (c.value?.prizes?.length || c.value?.prizesDescription)) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
|
|
56
56
|
t.push({ key: 'entries', label: 'Entries', icon: 'fa-box-open', count: c.value?.entryCount ?? entries.value.length });
|
|
57
57
|
if (participants.value.length) t.push({ key: 'participants', label: 'Participants', icon: 'fa-users', count: participants.value.length });
|
|
58
58
|
if (judges.value.length || isOwner.value) t.push({ key: 'judges', label: 'Judges', icon: 'fa-gavel', count: judges.value.length || undefined });
|
|
@@ -303,7 +303,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
303
303
|
|
|
304
304
|
<!-- PRIZES -->
|
|
305
305
|
<div v-show="activeTab === 'prizes'" id="cpub-panel-prizes" role="tabpanel" aria-labelledby="cpub-tab-prizes" tabindex="0">
|
|
306
|
-
<ContestPrizes v-if="c?.prizes?.length || c?.prizesDescription" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" />
|
|
306
|
+
<ContestPrizes v-if="c?.showPrizes !== false && (c?.prizes?.length || c?.prizesDescription)" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" />
|
|
307
307
|
</div>
|
|
308
308
|
|
|
309
309
|
<!-- ENTRIES -->
|
|
@@ -8,6 +8,13 @@ const { extract: extractError } = useApiError();
|
|
|
8
8
|
const saving = ref(false);
|
|
9
9
|
|
|
10
10
|
const title = ref('');
|
|
11
|
+
// Slug auto-derives from the title until the operator edits it manually.
|
|
12
|
+
const slug = ref('');
|
|
13
|
+
const slugTouched = ref(false);
|
|
14
|
+
function slugify(s: string): string {
|
|
15
|
+
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-+)|(-+$)/g, '').slice(0, 255);
|
|
16
|
+
}
|
|
17
|
+
watch(title, (t) => { if (!slugTouched.value) slug.value = slugify(t); });
|
|
11
18
|
const subheading = ref('');
|
|
12
19
|
const description = ref('');
|
|
13
20
|
const rules = ref('');
|
|
@@ -48,6 +55,7 @@ interface Prize {
|
|
|
48
55
|
value: string;
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
const showPrizes = ref(true);
|
|
51
59
|
const prizesDescription = ref('');
|
|
52
60
|
// Prizes are entirely optional — start empty so a contest has NO prizes unless
|
|
53
61
|
// the operator explicitly adds them (the old 3 pre-filled rows forced prizes
|
|
@@ -92,6 +100,7 @@ async function handleCreate(): Promise<void> {
|
|
|
92
100
|
method: 'POST',
|
|
93
101
|
body: {
|
|
94
102
|
title: title.value,
|
|
103
|
+
slug: slugify(slug.value) || undefined,
|
|
95
104
|
subheading: subheading.value || undefined,
|
|
96
105
|
description: description.value || undefined,
|
|
97
106
|
rules: rules.value || undefined,
|
|
@@ -106,6 +115,7 @@ async function handleCreate(): Promise<void> {
|
|
|
106
115
|
visibleToRoles: visibility.value === 'private' && visibleToRoles.value.length ? visibleToRoles.value : undefined,
|
|
107
116
|
eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
|
|
108
117
|
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
118
|
+
showPrizes: showPrizes.value,
|
|
109
119
|
prizesDescription: prizesDescription.value || undefined,
|
|
110
120
|
prizes: prizes.value
|
|
111
121
|
.filter(p => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
|
|
@@ -157,6 +167,11 @@ function prizeLabel(prize: Prize): string {
|
|
|
157
167
|
<label for="contest-title" class="cpub-form-label">Title</label>
|
|
158
168
|
<input id="contest-title" v-model="title" type="text" class="cpub-form-input" required placeholder="Maker Challenge 2026" />
|
|
159
169
|
</div>
|
|
170
|
+
<div class="cpub-form-field">
|
|
171
|
+
<label for="contest-slug" class="cpub-form-label">URL Slug</label>
|
|
172
|
+
<input id="contest-slug" v-model="slug" type="text" class="cpub-form-input" placeholder="auto-generated from title" @input="slugTouched = true" @blur="slug = slugify(slug)" />
|
|
173
|
+
<p class="cpub-form-hint">Auto-fills from the title. Edit to set a custom URL: <code>/contests/{{ slugify(slug) || 'your-contest' }}</code></p>
|
|
174
|
+
</div>
|
|
160
175
|
<div class="cpub-form-field">
|
|
161
176
|
<label for="contest-subheading" class="cpub-form-label">Subheading</label>
|
|
162
177
|
<input id="contest-subheading" v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
|
|
@@ -291,6 +306,12 @@ function prizeLabel(prize: Prize): string {
|
|
|
291
306
|
</button>
|
|
292
307
|
</div>
|
|
293
308
|
|
|
309
|
+
<label class="cpub-form-check">
|
|
310
|
+
<input v-model="showPrizes" type="checkbox" />
|
|
311
|
+
<span>Show the Prizes tab on the contest page</span>
|
|
312
|
+
</label>
|
|
313
|
+
<p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden — any prizes below are saved but not shown to visitors.</p>
|
|
314
|
+
|
|
294
315
|
<p class="cpub-form-hint">Contests don't need prizes — leave this empty to skip them entirely. If you do add prizes, every field is optional: use <strong>place</strong> for ranked prizes (1st/2nd/3rd), a <strong>category</strong> for themed awards (e.g. "Best in Show"), or just a <strong>description</strong>. Cash value is optional.</p>
|
|
295
316
|
<div class="cpub-form-field">
|
|
296
317
|
<label for="prizes-desc" class="cpub-form-label">Prizes overview (optional)</label>
|
|
@@ -302,7 +323,7 @@ function prizeLabel(prize: Prize): string {
|
|
|
302
323
|
<span class="cpub-prize-place">
|
|
303
324
|
<i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize) }}
|
|
304
325
|
</span>
|
|
305
|
-
<button
|
|
326
|
+
<button type="button" class="cpub-delete-btn" aria-label="Remove prize" @click="removePrize(idx)">
|
|
306
327
|
<i class="fa-solid fa-xmark"></i>
|
|
307
328
|
</button>
|
|
308
329
|
</div>
|
|
@@ -333,9 +354,15 @@ function prizeLabel(prize: Prize): string {
|
|
|
333
354
|
</div>
|
|
334
355
|
</section>
|
|
335
356
|
|
|
336
|
-
<
|
|
337
|
-
<
|
|
338
|
-
|
|
357
|
+
<div class="cpub-edit-actionbar">
|
|
358
|
+
<span class="cpub-edit-actionbar-hint">Required: title, start & end dates.</span>
|
|
359
|
+
<div class="cpub-edit-actionbar-btns">
|
|
360
|
+
<NuxtLink to="/contests" class="cpub-btn">Cancel</NuxtLink>
|
|
361
|
+
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !startDate || !endDate || !!dateError">
|
|
362
|
+
<i class="fa-solid fa-trophy"></i> {{ saving ? 'Creating…' : 'Create Contest' }}
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
339
366
|
</form>
|
|
340
367
|
</div>
|
|
341
368
|
</template>
|
|
@@ -380,10 +407,29 @@ function prizeLabel(prize: Prize): string {
|
|
|
380
407
|
.cpub-delete-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; }
|
|
381
408
|
.cpub-delete-btn:hover { color: var(--red); }
|
|
382
409
|
|
|
410
|
+
/* Sticky create bar — Create button always reachable on the long form. */
|
|
411
|
+
.cpub-edit-actionbar {
|
|
412
|
+
position: sticky;
|
|
413
|
+
bottom: 0;
|
|
414
|
+
z-index: 20;
|
|
415
|
+
display: flex;
|
|
416
|
+
align-items: center;
|
|
417
|
+
justify-content: space-between;
|
|
418
|
+
gap: 12px;
|
|
419
|
+
margin: 4px -32px -32px;
|
|
420
|
+
padding: 14px 32px;
|
|
421
|
+
background: var(--surface);
|
|
422
|
+
border-top: 2px solid var(--border);
|
|
423
|
+
box-shadow: var(--shadow-lg);
|
|
424
|
+
}
|
|
425
|
+
.cpub-edit-actionbar-hint { font-size: 11px; color: var(--text-faint); }
|
|
426
|
+
.cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
|
|
427
|
+
|
|
383
428
|
@media (max-width: 768px) {
|
|
384
429
|
.cpub-contest-create { padding: 16px; }
|
|
385
430
|
.cpub-page-title { font-size: 20px; }
|
|
386
431
|
.cpub-form-section { padding: 14px; }
|
|
387
432
|
.cpub-form-row { grid-template-columns: 1fr; }
|
|
433
|
+
.cpub-edit-actionbar { margin: 4px -16px -16px; padding: 12px 16px; }
|
|
388
434
|
}
|
|
389
435
|
</style>
|
package/pages/contests/index.vue
CHANGED
|
@@ -68,7 +68,7 @@ const canCreateContest = computed(() => {
|
|
|
68
68
|
</template>
|
|
69
69
|
<span class="cpub-badge cpub-contest-thumb-badge" :class="{
|
|
70
70
|
'cpub-badge-green': contest.status === 'active',
|
|
71
|
-
'cpub-badge-yellow': contest.status === 'upcoming',
|
|
71
|
+
'cpub-badge-yellow': contest.status === 'upcoming' || contest.status === 'paused',
|
|
72
72
|
'cpub-badge-accent': contest.status === 'judging',
|
|
73
73
|
'cpub-badge-red': contest.status === 'completed' || contest.status === 'cancelled',
|
|
74
74
|
}">{{ contest.status }}</span>
|
|
@@ -78,8 +78,8 @@ const canCreateContest = computed(() => {
|
|
|
78
78
|
<p v-if="cardBlurb(contest)" class="cpub-contest-card-blurb">
|
|
79
79
|
{{ cardBlurb(contest) }}
|
|
80
80
|
</p>
|
|
81
|
-
<div v-if="contest.endDate"
|
|
82
|
-
<CountdownTimer :target-date="contest.endDate" />
|
|
81
|
+
<div v-if="contest.endDate && (contest.status === 'active' || contest.status === 'upcoming')" class="cpub-contest-card-countdown">
|
|
82
|
+
<CountdownTimer :target-date="contest.endDate" compact />
|
|
83
83
|
</div>
|
|
84
84
|
<div class="cpub-contest-card-meta">
|
|
85
85
|
<span><i class="fa-solid fa-users"></i> {{ contest.entryCount }} entries</span>
|
|
@@ -135,6 +135,7 @@ const canCreateContest = computed(() => {
|
|
|
135
135
|
|
|
136
136
|
.cpub-contest-card-title { font-size: 15px; font-weight: 600; margin: 0 0 6px; color: var(--text); }
|
|
137
137
|
.cpub-contest-card-meta { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 11px; color: var(--text-faint); font-family: var(--font-mono); }
|
|
138
|
+
.cpub-contest-card-countdown { margin-top: 10px; }
|
|
138
139
|
|
|
139
140
|
.cpub-contest-card-blurb {
|
|
140
141
|
font-size: 12px;
|
|
@@ -9,7 +9,15 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
|
|
|
9
9
|
const { slug } = parseParams(event, { slug: 'string' });
|
|
10
10
|
const input = await parseBody(event, updateContestSchema);
|
|
11
11
|
|
|
12
|
-
|
|
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;
|