@commonpub/layer 0.49.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/CountdownTimer.vue +39 -1
- package/components/contest/ContestHero.vue +51 -7
- package/components/contest/ContestSidebar.vue +7 -1
- package/package.json +5 -5
- package/pages/contests/[slug]/edit.vue +76 -26
- package/pages/contests/[slug]/index.vue +2 -2
- package/pages/contests/create.vue +22 -1
- 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>
|
|
@@ -53,6 +53,36 @@ const countdownLabel = computed(() => {
|
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
const isEnded = computed(() => c.value?.status === 'completed' || c.value?.status === 'cancelled');
|
|
56
|
+
const isPaused = computed(() => c.value?.status === 'paused');
|
|
57
|
+
const isDraft = computed(() => c.value?.status === 'draft');
|
|
58
|
+
// Live countdown only makes sense while the clock is actually running.
|
|
59
|
+
const showCountdown = computed(() => !isEnded.value && !isPaused.value && !isDraft.value);
|
|
60
|
+
|
|
61
|
+
// Client-side mirror of the server VALID_TRANSITIONS map (server/src/contest/contest.ts).
|
|
62
|
+
// Keeps the inline admin controls in sync with what the API will actually accept —
|
|
63
|
+
// bidirectional: go back a stage, pause/resume, reopen, etc.
|
|
64
|
+
const VALID_TRANSITIONS: Record<string, string[]> = {
|
|
65
|
+
draft: ['upcoming', 'active', 'cancelled'],
|
|
66
|
+
upcoming: ['draft', 'active', 'cancelled'],
|
|
67
|
+
active: ['upcoming', 'paused', 'judging', 'cancelled'],
|
|
68
|
+
paused: ['active', 'upcoming', 'judging', 'cancelled'],
|
|
69
|
+
judging: ['active', 'paused', 'completed', 'cancelled'],
|
|
70
|
+
completed: ['judging'],
|
|
71
|
+
cancelled: ['draft', 'upcoming'],
|
|
72
|
+
};
|
|
73
|
+
const STATUS_ACTION: Record<string, { label: string; icon: string }> = {
|
|
74
|
+
draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
|
|
75
|
+
upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
|
|
76
|
+
active: { label: 'Activate', icon: 'fa-play' },
|
|
77
|
+
paused: { label: 'Pause', icon: 'fa-pause' },
|
|
78
|
+
judging: { label: 'Start Judging', icon: 'fa-gavel' },
|
|
79
|
+
completed: { label: 'Complete', icon: 'fa-check' },
|
|
80
|
+
cancelled: { label: 'Cancel', icon: 'fa-ban' },
|
|
81
|
+
};
|
|
82
|
+
const availableTransitions = computed<string[]>(() => VALID_TRANSITIONS[c.value?.status ?? 'upcoming'] ?? []);
|
|
83
|
+
function statusAction(s: string): { label: string; icon: string } {
|
|
84
|
+
return STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
|
|
85
|
+
}
|
|
56
86
|
|
|
57
87
|
// The hero shows the short `subheading` (a dedicated tagline field). For older
|
|
58
88
|
// contests without one, fall back to a clean, plain-text, CSS-clamped excerpt of
|
|
@@ -116,19 +146,23 @@ const dateRange = computed<string>(() => {
|
|
|
116
146
|
<button class="cpub-btn cpub-btn-lg cpub-btn-dark" @click="emit('copy-link')"><i class="fa fa-link"></i> Share</button>
|
|
117
147
|
</div>
|
|
118
148
|
|
|
119
|
-
<!-- Admin controls -->
|
|
149
|
+
<!-- Admin controls — bidirectional, derived from the valid-transition map. -->
|
|
120
150
|
<div v-if="isAdmin && c" class="cpub-admin-controls">
|
|
121
|
-
<span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i>
|
|
122
|
-
<button
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
151
|
+
<span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i> Stage</span>
|
|
152
|
+
<button
|
|
153
|
+
v-for="t in availableTransitions"
|
|
154
|
+
:key="t"
|
|
155
|
+
class="cpub-btn cpub-btn-sm"
|
|
156
|
+
:class="{ 'cpub-btn-cancel': t === 'cancelled' }"
|
|
157
|
+
:disabled="transitioning"
|
|
158
|
+
@click="emit('transition', t)"
|
|
159
|
+
><i class="fa-solid" :class="statusAction(t).icon"></i> {{ statusAction(t).label }}</button>
|
|
126
160
|
</div>
|
|
127
161
|
</div>
|
|
128
162
|
|
|
129
163
|
<!-- RIGHT: countdown -->
|
|
130
164
|
<aside class="cpub-hero-side">
|
|
131
|
-
<div v-if="
|
|
165
|
+
<div v-if="showCountdown" class="cpub-countdown-section">
|
|
132
166
|
<div class="cpub-countdown-label"><i class="fa fa-clock"></i> {{ countdownLabel }}</div>
|
|
133
167
|
<div class="cpub-countdown-row">
|
|
134
168
|
<div class="cpub-countdown-block">
|
|
@@ -152,6 +186,14 @@ const dateRange = computed<string>(() => {
|
|
|
152
186
|
</div>
|
|
153
187
|
</div>
|
|
154
188
|
</div>
|
|
189
|
+
<div v-else-if="isPaused" class="cpub-countdown-ended">
|
|
190
|
+
<i class="fa-solid fa-circle-pause"></i>
|
|
191
|
+
<span>Submissions paused</span>
|
|
192
|
+
</div>
|
|
193
|
+
<div v-else-if="isDraft" class="cpub-countdown-ended">
|
|
194
|
+
<i class="fa-solid fa-pen-ruler"></i>
|
|
195
|
+
<span>Draft — not launched</span>
|
|
196
|
+
</div>
|
|
155
197
|
<div v-else class="cpub-countdown-ended">
|
|
156
198
|
<i class="fa-solid fa-flag-checkered"></i>
|
|
157
199
|
<span>{{ countdownLabel }}</span>
|
|
@@ -215,6 +257,8 @@ const dateRange = computed<string>(() => {
|
|
|
215
257
|
.cpub-status-pill[data-status="active"] { color: var(--green); border-color: var(--green); background: color-mix(in srgb, var(--green) 14%, transparent); }
|
|
216
258
|
.cpub-status-pill[data-status="judging"] { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
|
217
259
|
.cpub-status-pill[data-status="upcoming"] { color: var(--yellow); border-color: var(--yellow); }
|
|
260
|
+
.cpub-status-pill[data-status="paused"] { color: var(--yellow); border-color: var(--yellow); background: color-mix(in srgb, var(--yellow) 12%, transparent); }
|
|
261
|
+
.cpub-status-pill[data-status="draft"] { color: var(--hero-text-dim); border-color: var(--hero-border); border-style: dashed; }
|
|
218
262
|
.cpub-status-pill[data-status="completed"], .cpub-status-pill[data-status="cancelled"] { color: var(--red); border-color: var(--red-border); }
|
|
219
263
|
|
|
220
264
|
.cpub-hero-title { font-size: 34px; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin: 0 0 10px; color: var(--hero-text); }
|
|
@@ -22,7 +22,9 @@ function fmt(d: string | null | undefined): string | null {
|
|
|
22
22
|
|
|
23
23
|
// Ordinal position of each status along the lifecycle, used to mark steps
|
|
24
24
|
// done / current / upcoming.
|
|
25
|
-
|
|
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,15 +54,15 @@
|
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
56
|
"@commonpub/auth": "0.8.0",
|
|
57
|
+
"@commonpub/docs": "0.6.3",
|
|
57
58
|
"@commonpub/config": "0.18.0",
|
|
58
59
|
"@commonpub/learning": "0.5.2",
|
|
59
60
|
"@commonpub/explainer": "0.7.15",
|
|
60
|
-
"@commonpub/docs": "0.6.3",
|
|
61
61
|
"@commonpub/editor": "0.7.11",
|
|
62
|
+
"@commonpub/server": "2.75.0",
|
|
63
|
+
"@commonpub/schema": "0.28.0",
|
|
62
64
|
"@commonpub/protocol": "0.13.0",
|
|
63
|
-
"@commonpub/ui": "0.9.2"
|
|
64
|
-
"@commonpub/server": "2.74.0",
|
|
65
|
-
"@commonpub/schema": "0.27.0"
|
|
65
|
+
"@commonpub/ui": "0.9.2"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -13,6 +13,11 @@ useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'} — ${useS
|
|
|
13
13
|
|
|
14
14
|
const saving = ref(false);
|
|
15
15
|
const title = ref('');
|
|
16
|
+
// Editable slug — initialised from the loaded contest, manual override allowed.
|
|
17
|
+
const slugInput = ref('');
|
|
18
|
+
function slugify(s: string): string {
|
|
19
|
+
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-+)|(-+$)/g, '').slice(0, 255);
|
|
20
|
+
}
|
|
16
21
|
const subheading = ref('');
|
|
17
22
|
const description = ref('');
|
|
18
23
|
const rules = ref('');
|
|
@@ -42,6 +47,7 @@ function toggleRole(r: string): void {
|
|
|
42
47
|
else visibleToRoles.value.push(r);
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
const showPrizes = ref(true);
|
|
45
51
|
const prizesDescription = ref('');
|
|
46
52
|
interface Prize { place: number | null; category: string; title: string; description: string; value: string }
|
|
47
53
|
const prizes = ref<Prize[]>([]);
|
|
@@ -53,6 +59,7 @@ const criteria = ref<Criterion[]>([]);
|
|
|
53
59
|
watch(contest, (c) => {
|
|
54
60
|
if (!c) return;
|
|
55
61
|
title.value = c.title ?? '';
|
|
62
|
+
slugInput.value = c.slug ?? '';
|
|
56
63
|
subheading.value = c.subheading ?? '';
|
|
57
64
|
description.value = c.description ?? '';
|
|
58
65
|
rules.value = c.rules ?? '';
|
|
@@ -67,6 +74,7 @@ watch(contest, (c) => {
|
|
|
67
74
|
maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
|
|
68
75
|
visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
|
|
69
76
|
visibleToRoles.value = [...(c.visibleToRoles ?? [])];
|
|
77
|
+
showPrizes.value = c.showPrizes !== false;
|
|
70
78
|
prizesDescription.value = c.prizesDescription ?? '';
|
|
71
79
|
prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
|
|
72
80
|
place: p.place ?? null,
|
|
@@ -138,10 +146,11 @@ async function handleSave(): Promise<void> {
|
|
|
138
146
|
description: c.description.trim() || undefined,
|
|
139
147
|
}));
|
|
140
148
|
|
|
141
|
-
await $fetch(`/api/contests/${slug}`, {
|
|
149
|
+
const updated = await $fetch<{ slug: string }>(`/api/contests/${slug}`, {
|
|
142
150
|
method: 'PUT',
|
|
143
151
|
body: {
|
|
144
152
|
title: title.value,
|
|
153
|
+
slug: slugify(slugInput.value) || undefined,
|
|
145
154
|
subheading: subheading.value || undefined,
|
|
146
155
|
description: description.value || undefined,
|
|
147
156
|
rules: rules.value || undefined,
|
|
@@ -156,12 +165,20 @@ async function handleSave(): Promise<void> {
|
|
|
156
165
|
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
157
166
|
visibility: visibility.value,
|
|
158
167
|
visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
|
|
168
|
+
showPrizes: showPrizes.value,
|
|
159
169
|
prizesDescription: prizesDescription.value || undefined,
|
|
160
170
|
prizes: prizeData,
|
|
161
171
|
judgingCriteria: criteriaData,
|
|
162
172
|
},
|
|
163
173
|
});
|
|
164
174
|
toast.success('Contest updated');
|
|
175
|
+
// Slug changed → the old URL no longer resolves. Navigate to the renamed
|
|
176
|
+
// contest's page — a different route component, so it loads fresh. (Navigating
|
|
177
|
+
// to the new /edit URL would reuse THIS component with its stale fetch key.)
|
|
178
|
+
if (updated?.slug && updated.slug !== slug) {
|
|
179
|
+
await navigateTo(`/contests/${updated.slug}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
165
182
|
await refresh();
|
|
166
183
|
} catch (err: unknown) {
|
|
167
184
|
toast.error(extractError(err));
|
|
@@ -184,11 +201,36 @@ async function handleDelete(): Promise<void> {
|
|
|
184
201
|
}
|
|
185
202
|
}
|
|
186
203
|
|
|
204
|
+
// Client mirror of the server VALID_TRANSITIONS map — bidirectional (go back a
|
|
205
|
+
// stage, pause/resume, reopen). Kept in sync with server/src/contest/contest.ts.
|
|
206
|
+
const VALID_TRANSITIONS: Record<string, string[]> = {
|
|
207
|
+
draft: ['upcoming', 'active', 'cancelled'],
|
|
208
|
+
upcoming: ['draft', 'active', 'cancelled'],
|
|
209
|
+
active: ['upcoming', 'paused', 'judging', 'cancelled'],
|
|
210
|
+
paused: ['active', 'upcoming', 'judging', 'cancelled'],
|
|
211
|
+
judging: ['active', 'paused', 'completed', 'cancelled'],
|
|
212
|
+
completed: ['judging'],
|
|
213
|
+
cancelled: ['draft', 'upcoming'],
|
|
214
|
+
};
|
|
215
|
+
const STATUS_ACTION: Record<string, { label: string; icon: string; tone?: 'go' | 'warn' | 'danger' }> = {
|
|
216
|
+
draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
|
|
217
|
+
upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
|
|
218
|
+
active: { label: 'Activate', icon: 'fa-play', tone: 'go' },
|
|
219
|
+
paused: { label: 'Pause', icon: 'fa-pause', tone: 'warn' },
|
|
220
|
+
judging: { label: 'Begin Judging', icon: 'fa-gavel' },
|
|
221
|
+
completed: { label: 'Complete & Publish', icon: 'fa-flag-checkered', tone: 'go' },
|
|
222
|
+
cancelled: { label: 'Cancel', icon: 'fa-ban', tone: 'danger' },
|
|
223
|
+
};
|
|
224
|
+
const availableTransitions = computed<string[]>(() => VALID_TRANSITIONS[contest.value?.status ?? 'upcoming'] ?? []);
|
|
225
|
+
function statusAction(s: string): { label: string; icon: string; tone?: string } {
|
|
226
|
+
return STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
|
|
227
|
+
}
|
|
228
|
+
|
|
187
229
|
async function transitionStatus(newStatus: string): Promise<void> {
|
|
188
|
-
|
|
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,36 +443,32 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
391
443
|
</section>
|
|
392
444
|
|
|
393
445
|
<section class="cpub-form-section">
|
|
394
|
-
<h2 class="cpub-form-section-title">Status
|
|
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>
|
|
@@ -454,8 +502,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
454
502
|
.cpub-edit-subtitle { font-size: 13px; color: var(--text-dim); margin-bottom: 24px; display: flex; align-items: center; gap: 8px; }
|
|
455
503
|
|
|
456
504
|
.cpub-status-badge { font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; padding: 2px 8px; border: var(--border-width-default) solid; }
|
|
505
|
+
.cpub-status-draft { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); border-style: dashed; }
|
|
457
506
|
.cpub-status-upcoming { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
|
|
458
507
|
.cpub-status-active { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
|
|
508
|
+
.cpub-status-paused { color: var(--yellow); border-color: var(--yellow-border); background: var(--yellow-bg); }
|
|
459
509
|
.cpub-status-judging { color: var(--accent); border-color: var(--accent-border); background: var(--accent-bg); }
|
|
460
510
|
.cpub-status-completed { color: var(--text-faint); border-color: var(--border2); background: var(--surface2); }
|
|
461
511
|
.cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
|
|
@@ -52,7 +52,7 @@ interface Tab { key: string; label: string; icon: string; count?: number }
|
|
|
52
52
|
const tabs = computed<Tab[]>(() => {
|
|
53
53
|
const t: Tab[] = [{ key: 'overview', label: 'Overview', icon: 'fa-circle-info' }];
|
|
54
54
|
if (c.value?.rules) t.push({ key: 'rules', label: 'Rules', icon: 'fa-file-lines' });
|
|
55
|
-
if (c.value?.prizes?.length || c.value?.prizesDescription) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
|
|
55
|
+
if (c.value?.showPrizes !== false && (c.value?.prizes?.length || c.value?.prizesDescription)) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
|
|
56
56
|
t.push({ key: 'entries', label: 'Entries', icon: 'fa-box-open', count: c.value?.entryCount ?? entries.value.length });
|
|
57
57
|
if (participants.value.length) t.push({ key: 'participants', label: 'Participants', icon: 'fa-users', count: participants.value.length });
|
|
58
58
|
if (judges.value.length || isOwner.value) t.push({ key: 'judges', label: 'Judges', icon: 'fa-gavel', count: judges.value.length || undefined });
|
|
@@ -303,7 +303,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
303
303
|
|
|
304
304
|
<!-- PRIZES -->
|
|
305
305
|
<div v-show="activeTab === 'prizes'" id="cpub-panel-prizes" role="tabpanel" aria-labelledby="cpub-tab-prizes" tabindex="0">
|
|
306
|
-
<ContestPrizes v-if="c?.prizes?.length || c?.prizesDescription" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" />
|
|
306
|
+
<ContestPrizes v-if="c?.showPrizes !== false && (c?.prizes?.length || c?.prizesDescription)" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" />
|
|
307
307
|
</div>
|
|
308
308
|
|
|
309
309
|
<!-- ENTRIES -->
|
|
@@ -8,6 +8,13 @@ const { extract: extractError } = useApiError();
|
|
|
8
8
|
const saving = ref(false);
|
|
9
9
|
|
|
10
10
|
const title = ref('');
|
|
11
|
+
// Slug auto-derives from the title until the operator edits it manually.
|
|
12
|
+
const slug = ref('');
|
|
13
|
+
const slugTouched = ref(false);
|
|
14
|
+
function slugify(s: string): string {
|
|
15
|
+
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-+)|(-+$)/g, '').slice(0, 255);
|
|
16
|
+
}
|
|
17
|
+
watch(title, (t) => { if (!slugTouched.value) slug.value = slugify(t); });
|
|
11
18
|
const subheading = ref('');
|
|
12
19
|
const description = ref('');
|
|
13
20
|
const rules = ref('');
|
|
@@ -48,6 +55,7 @@ interface Prize {
|
|
|
48
55
|
value: string;
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
const showPrizes = ref(true);
|
|
51
59
|
const prizesDescription = ref('');
|
|
52
60
|
// Prizes are entirely optional — start empty so a contest has NO prizes unless
|
|
53
61
|
// the operator explicitly adds them (the old 3 pre-filled rows forced prizes
|
|
@@ -92,6 +100,7 @@ async function handleCreate(): Promise<void> {
|
|
|
92
100
|
method: 'POST',
|
|
93
101
|
body: {
|
|
94
102
|
title: title.value,
|
|
103
|
+
slug: slugify(slug.value) || undefined,
|
|
95
104
|
subheading: subheading.value || undefined,
|
|
96
105
|
description: description.value || undefined,
|
|
97
106
|
rules: rules.value || undefined,
|
|
@@ -106,6 +115,7 @@ async function handleCreate(): Promise<void> {
|
|
|
106
115
|
visibleToRoles: visibility.value === 'private' && visibleToRoles.value.length ? visibleToRoles.value : undefined,
|
|
107
116
|
eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
|
|
108
117
|
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
118
|
+
showPrizes: showPrizes.value,
|
|
109
119
|
prizesDescription: prizesDescription.value || undefined,
|
|
110
120
|
prizes: prizes.value
|
|
111
121
|
.filter(p => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
|
|
@@ -157,6 +167,11 @@ function prizeLabel(prize: Prize): string {
|
|
|
157
167
|
<label for="contest-title" class="cpub-form-label">Title</label>
|
|
158
168
|
<input id="contest-title" v-model="title" type="text" class="cpub-form-input" required placeholder="Maker Challenge 2026" />
|
|
159
169
|
</div>
|
|
170
|
+
<div class="cpub-form-field">
|
|
171
|
+
<label for="contest-slug" class="cpub-form-label">URL Slug</label>
|
|
172
|
+
<input id="contest-slug" v-model="slug" type="text" class="cpub-form-input" placeholder="auto-generated from title" @input="slugTouched = true" @blur="slug = slugify(slug)" />
|
|
173
|
+
<p class="cpub-form-hint">Auto-fills from the title. Edit to set a custom URL: <code>/contests/{{ slugify(slug) || 'your-contest' }}</code></p>
|
|
174
|
+
</div>
|
|
160
175
|
<div class="cpub-form-field">
|
|
161
176
|
<label for="contest-subheading" class="cpub-form-label">Subheading</label>
|
|
162
177
|
<input id="contest-subheading" v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
|
|
@@ -291,6 +306,12 @@ function prizeLabel(prize: Prize): string {
|
|
|
291
306
|
</button>
|
|
292
307
|
</div>
|
|
293
308
|
|
|
309
|
+
<label class="cpub-form-check">
|
|
310
|
+
<input v-model="showPrizes" type="checkbox" />
|
|
311
|
+
<span>Show the Prizes tab on the contest page</span>
|
|
312
|
+
</label>
|
|
313
|
+
<p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden — any prizes below are saved but not shown to visitors.</p>
|
|
314
|
+
|
|
294
315
|
<p class="cpub-form-hint">Contests don't need prizes — leave this empty to skip them entirely. If you do add prizes, every field is optional: use <strong>place</strong> for ranked prizes (1st/2nd/3rd), a <strong>category</strong> for themed awards (e.g. "Best in Show"), or just a <strong>description</strong>. Cash value is optional.</p>
|
|
295
316
|
<div class="cpub-form-field">
|
|
296
317
|
<label for="prizes-desc" class="cpub-form-label">Prizes overview (optional)</label>
|
|
@@ -302,7 +323,7 @@ function prizeLabel(prize: Prize): string {
|
|
|
302
323
|
<span class="cpub-prize-place">
|
|
303
324
|
<i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize) }}
|
|
304
325
|
</span>
|
|
305
|
-
<button
|
|
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>
|
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;
|