@commonpub/layer 0.50.0 → 0.52.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/contest/ContestHero.vue +55 -34
- package/components/contest/ContestSidebar.vue +12 -15
- package/components/contest/ContestStagesEditor.vue +217 -0
- package/package.json +8 -8
- package/pages/contests/[slug]/edit.vue +58 -28
- package/pages/contests/create.vue +49 -3
- package/utils/contestStages.ts +70 -0
- package/utils/contestTransitions.ts +34 -0
|
@@ -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,42 +57,37 @@ 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');
|
|
56
68
|
const isPaused = computed(() => c.value?.status === 'paused');
|
|
57
69
|
const isDraft = computed(() => c.value?.status === 'draft');
|
|
58
|
-
// Live countdown only
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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' };
|
|
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' });
|
|
85
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
|
+
// Bidirectional lifecycle controls — the valid-transition map + button metadata
|
|
88
|
+
// live in utils/contestTransitions.ts (shared with the contest edit page).
|
|
89
|
+
const availableTransitions = computed<string[]>(() => contestTransitionsFrom(c.value?.status));
|
|
90
|
+
const statusAction = contestStatusAction;
|
|
86
91
|
|
|
87
92
|
// The hero shows the short `subheading` (a dedicated tagline field). For older
|
|
88
93
|
// contests without one, fall back to a clean, plain-text, CSS-clamped excerpt of
|
|
@@ -94,6 +99,15 @@ const tagline = computed<string>(() => {
|
|
|
94
99
|
return markdownToExcerpt(c.value?.description) || '';
|
|
95
100
|
});
|
|
96
101
|
|
|
102
|
+
// Phase B1 — when a contest defines explicit stages, surface the current stage's
|
|
103
|
+
// name beside the status pill (default-flow contests show nothing extra).
|
|
104
|
+
const currentStageName = computed<string | null>(() => {
|
|
105
|
+
const cv = c.value;
|
|
106
|
+
if (!cv || !cv.stages || cv.stages.length === 0) return null;
|
|
107
|
+
const cid = currentStageId(cv);
|
|
108
|
+
return cv.stages.find((s) => s.id === cid)?.name ?? null;
|
|
109
|
+
});
|
|
110
|
+
|
|
97
111
|
const dateRange = computed<string>(() => {
|
|
98
112
|
const fmt = (d: string, withYear = false) =>
|
|
99
113
|
new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(withYear ? { year: 'numeric' } : {}) });
|
|
@@ -131,6 +145,7 @@ const dateRange = computed<string>(() => {
|
|
|
131
145
|
<div class="cpub-hero-eyebrow">
|
|
132
146
|
<span class="cpub-contest-badge"><i class="fa fa-trophy"></i> Contest</span>
|
|
133
147
|
<span class="cpub-status-pill" :data-status="c?.status || 'upcoming'">{{ c?.status || 'upcoming' }}</span>
|
|
148
|
+
<span v-if="currentStageName" class="cpub-stage-chip"><i class="fa-solid fa-diagram-project"></i> {{ currentStageName }}</span>
|
|
134
149
|
</div>
|
|
135
150
|
|
|
136
151
|
<h1 class="cpub-hero-title">{{ c?.title || 'Contest' }}</h1>
|
|
@@ -194,6 +209,10 @@ const dateRange = computed<string>(() => {
|
|
|
194
209
|
<i class="fa-solid fa-pen-ruler"></i>
|
|
195
210
|
<span>Draft — not launched</span>
|
|
196
211
|
</div>
|
|
212
|
+
<div v-else-if="dateNote" class="cpub-countdown-ended">
|
|
213
|
+
<i class="fa-regular fa-calendar"></i>
|
|
214
|
+
<span>{{ dateNote }}</span>
|
|
215
|
+
</div>
|
|
197
216
|
<div v-else class="cpub-countdown-ended">
|
|
198
217
|
<i class="fa-solid fa-flag-checkered"></i>
|
|
199
218
|
<span>{{ countdownLabel }}</span>
|
|
@@ -260,6 +279,8 @@ const dateRange = computed<string>(() => {
|
|
|
260
279
|
.cpub-status-pill[data-status="paused"] { color: var(--yellow); border-color: var(--yellow); background: color-mix(in srgb, var(--yellow) 12%, transparent); }
|
|
261
280
|
.cpub-status-pill[data-status="draft"] { color: var(--hero-text-dim); border-color: var(--hero-border); border-style: dashed; }
|
|
262
281
|
.cpub-status-pill[data-status="completed"], .cpub-status-pill[data-status="cancelled"] { color: var(--red); border-color: var(--red-border); }
|
|
282
|
+
.cpub-stage-chip { font-size: 9px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; font-family: var(--font-mono); padding: 3px 10px; border-radius: var(--radius); border: var(--border-width-default) solid var(--accent); color: var(--accent); background: var(--accent-bg); display: inline-flex; align-items: center; gap: 5px; }
|
|
283
|
+
.cpub-stage-chip i { font-size: 8px; }
|
|
263
284
|
|
|
264
285
|
.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
286
|
.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; }
|
|
@@ -20,24 +20,21 @@ function fmt(d: string | null | undefined): string | null {
|
|
|
20
20
|
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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 };
|
|
28
|
-
|
|
23
|
+
// Phase B1 — the timeline renders the contest's stages (its explicit `stages`, or
|
|
24
|
+
// the synthesized classic Submissions → Judging → Results when none are defined).
|
|
25
|
+
// done/current/upcoming derive from the position of the current stage.
|
|
29
26
|
const timeline = computed<TimelineStep[]>(() => {
|
|
30
27
|
const c = props.contest;
|
|
31
28
|
if (!c || c.status === 'cancelled') return [];
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
29
|
+
const stages = normalizeStages(c);
|
|
30
|
+
const curId = currentStageId(c);
|
|
31
|
+
const curIdx = curId ? stages.findIndex((s) => s.id === curId) : -1;
|
|
32
|
+
return stages.map((s, i): TimelineStep => ({
|
|
33
|
+
label: s.name,
|
|
34
|
+
date: fmt(s.endsAt ?? s.startsAt ?? null),
|
|
35
|
+
state: curIdx < 0 ? 'upcoming' : i < curIdx ? 'done' : i === curIdx ? 'current' : 'upcoming',
|
|
36
|
+
icon: STAGE_KIND_ICON[s.kind] ?? 'fa-circle-dot',
|
|
37
|
+
}));
|
|
41
38
|
});
|
|
42
39
|
|
|
43
40
|
function statusClass(status: string): string {
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ContestStage } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
// Phase B1 — define an arbitrary, ordered stage timeline for a contest. Empty ⇒
|
|
5
|
+
// the contest uses the synthesized standard flow (Submissions → Judging → Results),
|
|
6
|
+
// so this editor is opt-in. `kind` drives display + how the stage maps to the coarse
|
|
7
|
+
// status; `name`/dates are arbitrary. Used by both create.vue and edit.vue.
|
|
8
|
+
|
|
9
|
+
const stages = defineModel<ContestStage[]>({ required: true });
|
|
10
|
+
// Local name `currentId` avoids colliding with the auto-imported `currentStageId`
|
|
11
|
+
// util (the model name string stays `currentStageId` for the parent v-model).
|
|
12
|
+
const currentId = defineModel<string | null>('currentStageId', { default: null });
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
// Contest dates — used to seed the synthesized stages when the owner customizes.
|
|
16
|
+
startDate: string;
|
|
17
|
+
endDate: string;
|
|
18
|
+
judgingEndDate?: string | null;
|
|
19
|
+
}>();
|
|
20
|
+
|
|
21
|
+
const KINDS: ContestStage['kind'][] = ['submission', 'review', 'interim', 'results', 'event', 'custom'];
|
|
22
|
+
|
|
23
|
+
function newId(): string {
|
|
24
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
25
|
+
return c?.randomUUID?.() ?? `s-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// datetime-local <-> ISO (mirrors the rest of the contest forms' convention).
|
|
29
|
+
function toLocal(iso?: string): string {
|
|
30
|
+
if (!iso) return '';
|
|
31
|
+
try { return new Date(iso).toISOString().slice(0, 16); } catch { return ''; }
|
|
32
|
+
}
|
|
33
|
+
function toIso(local: string): string | undefined {
|
|
34
|
+
if (!local) return undefined;
|
|
35
|
+
const d = new Date(local);
|
|
36
|
+
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function commit(next: ContestStage[]): void {
|
|
40
|
+
stages.value = next;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setField(i: number, patch: Partial<ContestStage>): void {
|
|
44
|
+
const next = stages.value.map((s, idx) => (idx === i ? { ...s, ...patch } : s));
|
|
45
|
+
commit(next);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function onDate(i: number, field: 'startsAt' | 'endsAt', e: Event): void {
|
|
49
|
+
const v = toIso((e.target as HTMLInputElement).value);
|
|
50
|
+
setField(i, { [field]: v } as Partial<ContestStage>);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function addStage(): void {
|
|
54
|
+
commit([...stages.value, { id: newId(), name: '', kind: 'custom' }]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function duplicateStage(i: number): void {
|
|
58
|
+
const src = stages.value[i];
|
|
59
|
+
if (!src) return;
|
|
60
|
+
const copy: ContestStage = { ...src, id: newId(), name: `${src.name} (copy)`, core: false };
|
|
61
|
+
commit([...stages.value.slice(0, i + 1), copy, ...stages.value.slice(i + 1)]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function removeStage(i: number): void {
|
|
65
|
+
const removed = stages.value[i];
|
|
66
|
+
commit(stages.value.filter((_, idx) => idx !== i));
|
|
67
|
+
if (removed && currentId.value === removed.id) currentId.value = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function move(i: number, dir: -1 | 1): void {
|
|
71
|
+
const j = i + dir;
|
|
72
|
+
if (j < 0 || j >= stages.value.length) return;
|
|
73
|
+
const next = [...stages.value];
|
|
74
|
+
[next[i], next[j]] = [next[j]!, next[i]!];
|
|
75
|
+
commit(next);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Seed the editor with the standard three stages so the owner has a starting point.
|
|
79
|
+
function customize(): void {
|
|
80
|
+
const iso = (d?: string | null) => (d ? new Date(d).toISOString() : undefined);
|
|
81
|
+
commit([
|
|
82
|
+
{ id: newId(), name: 'Submissions', kind: 'submission', startsAt: iso(props.startDate), endsAt: iso(props.endDate) },
|
|
83
|
+
{ id: newId(), name: 'Judging', kind: 'review', endsAt: iso(props.judgingEndDate) ?? iso(props.endDate) },
|
|
84
|
+
{ id: newId(), name: 'Results', kind: 'results' },
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resetToStandard(): void {
|
|
89
|
+
currentId.value = null;
|
|
90
|
+
commit([]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const missingSubmission = computed(() => stages.value.length > 0 && !stages.value.some((s) => s.kind === 'submission'));
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<template>
|
|
97
|
+
<div class="cpub-stages-editor">
|
|
98
|
+
<!-- Empty state: standard flow -->
|
|
99
|
+
<div v-if="!stages.length" class="cpub-stages-standard">
|
|
100
|
+
<p class="cpub-form-hint" style="margin: 0;">
|
|
101
|
+
This contest uses the <strong>standard flow</strong>: Submissions → Judging → Results, driven by
|
|
102
|
+
the schedule dates above. Customize only if you need extra rounds (e.g. a proposal round, a
|
|
103
|
+
Top-N selection, a build sprint, multiple judging rounds, or a showcase event).
|
|
104
|
+
</p>
|
|
105
|
+
<button type="button" class="cpub-btn cpub-btn-sm" @click="customize">
|
|
106
|
+
<i class="fa-solid fa-diagram-project"></i> Customize stages
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<template v-else>
|
|
111
|
+
<p v-if="missingSubmission" class="cpub-form-error" role="alert" style="margin: 0 0 10px;">
|
|
112
|
+
Add at least one <strong>Submissions</strong> stage, or reset to the standard flow.
|
|
113
|
+
</p>
|
|
114
|
+
|
|
115
|
+
<ol class="cpub-stage-list">
|
|
116
|
+
<li v-for="(stage, i) in stages" :key="stage.id" class="cpub-stage-row">
|
|
117
|
+
<div class="cpub-stage-row-head">
|
|
118
|
+
<span class="cpub-stage-num">{{ i + 1 }}</span>
|
|
119
|
+
<label class="cpub-stage-current" :title="currentId === stage.id ? 'This is the current stage' : 'Mark as the current stage'">
|
|
120
|
+
<input
|
|
121
|
+
type="radio"
|
|
122
|
+
name="cpub-current-stage"
|
|
123
|
+
:checked="currentId === stage.id"
|
|
124
|
+
@change="currentId = stage.id"
|
|
125
|
+
/>
|
|
126
|
+
<span>Current</span>
|
|
127
|
+
</label>
|
|
128
|
+
<div class="cpub-stage-row-actions">
|
|
129
|
+
<button type="button" class="cpub-stage-iconbtn" :disabled="i === 0" aria-label="Move up" @click="move(i, -1)"><i class="fa-solid fa-arrow-up"></i></button>
|
|
130
|
+
<button type="button" class="cpub-stage-iconbtn" :disabled="i === stages.length - 1" aria-label="Move down" @click="move(i, 1)"><i class="fa-solid fa-arrow-down"></i></button>
|
|
131
|
+
<button type="button" class="cpub-stage-iconbtn" aria-label="Duplicate stage" @click="duplicateStage(i)"><i class="fa-solid fa-clone"></i></button>
|
|
132
|
+
<button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove stage" @click="removeStage(i)"><i class="fa-solid fa-xmark"></i></button>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div class="cpub-form-row">
|
|
137
|
+
<div class="cpub-form-field" style="flex: 2;">
|
|
138
|
+
<label class="cpub-form-label">Stage name</label>
|
|
139
|
+
<input
|
|
140
|
+
:value="stage.name"
|
|
141
|
+
type="text"
|
|
142
|
+
class="cpub-form-input"
|
|
143
|
+
placeholder="e.g. Proposals Open"
|
|
144
|
+
@input="setField(i, { name: ($event.target as HTMLInputElement).value })"
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="cpub-form-field" style="flex: 1;">
|
|
148
|
+
<label class="cpub-form-label">Type</label>
|
|
149
|
+
<select
|
|
150
|
+
:value="stage.kind"
|
|
151
|
+
class="cpub-form-input"
|
|
152
|
+
@change="setField(i, { kind: ($event.target as HTMLSelectElement).value as ContestStage['kind'] })"
|
|
153
|
+
>
|
|
154
|
+
<option v-for="k in KINDS" :key="k" :value="k">{{ STAGE_KIND_LABEL[k] }}</option>
|
|
155
|
+
</select>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div class="cpub-form-row">
|
|
160
|
+
<div class="cpub-form-field">
|
|
161
|
+
<label class="cpub-form-label">Starts</label>
|
|
162
|
+
<input type="datetime-local" class="cpub-form-input" :value="toLocal(stage.startsAt)" @input="onDate(i, 'startsAt', $event)" />
|
|
163
|
+
</div>
|
|
164
|
+
<div class="cpub-form-field">
|
|
165
|
+
<label class="cpub-form-label">Ends (countdown target)</label>
|
|
166
|
+
<input type="datetime-local" class="cpub-form-input" :value="toLocal(stage.endsAt)" @input="onDate(i, 'endsAt', $event)" />
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div class="cpub-form-field">
|
|
171
|
+
<label class="cpub-form-label">Description (optional)</label>
|
|
172
|
+
<input
|
|
173
|
+
:value="stage.description ?? ''"
|
|
174
|
+
type="text"
|
|
175
|
+
class="cpub-form-input"
|
|
176
|
+
placeholder="What happens — or what to submit/refine — this stage"
|
|
177
|
+
@input="setField(i, { description: ($event.target as HTMLInputElement).value || undefined })"
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div v-if="stage.kind === 'event'" class="cpub-form-row">
|
|
182
|
+
<div class="cpub-form-field">
|
|
183
|
+
<label class="cpub-form-label">Location</label>
|
|
184
|
+
<input :value="stage.location ?? ''" type="text" class="cpub-form-input" placeholder="e.g. Washington, D.C." @input="setField(i, { location: ($event.target as HTMLInputElement).value || undefined })" />
|
|
185
|
+
</div>
|
|
186
|
+
<div class="cpub-form-field">
|
|
187
|
+
<label class="cpub-form-label">Link</label>
|
|
188
|
+
<input :value="stage.url ?? ''" type="url" class="cpub-form-input" placeholder="https://…" @input="setField(i, { url: ($event.target as HTMLInputElement).value || undefined })" />
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</li>
|
|
192
|
+
</ol>
|
|
193
|
+
|
|
194
|
+
<div class="cpub-stage-toolbar">
|
|
195
|
+
<button type="button" class="cpub-btn cpub-btn-sm" @click="addStage"><i class="fa-solid fa-plus"></i> Add stage</button>
|
|
196
|
+
<button type="button" class="cpub-btn cpub-btn-sm cpub-stage-reset" @click="resetToStandard"><i class="fa-solid fa-rotate-left"></i> Reset to standard flow</button>
|
|
197
|
+
</div>
|
|
198
|
+
</template>
|
|
199
|
+
</div>
|
|
200
|
+
</template>
|
|
201
|
+
|
|
202
|
+
<style scoped>
|
|
203
|
+
.cpub-stages-standard { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
|
204
|
+
.cpub-stage-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
|
205
|
+
.cpub-stage-row { border: var(--border-width-default) solid var(--border); background: var(--surface2); padding: 12px; }
|
|
206
|
+
.cpub-stage-row-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
|
207
|
+
.cpub-stage-num { width: 22px; height: 22px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; font-family: var(--font-mono); background: var(--accent-bg); color: var(--accent); border: var(--border-width-default) solid var(--accent-border); }
|
|
208
|
+
.cpub-stage-current { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); cursor: pointer; }
|
|
209
|
+
.cpub-stage-current input { width: 13px; height: 13px; }
|
|
210
|
+
.cpub-stage-row-actions { margin-left: auto; display: flex; gap: 4px; }
|
|
211
|
+
.cpub-stage-iconbtn { background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text-dim); cursor: pointer; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; }
|
|
212
|
+
.cpub-stage-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
213
|
+
.cpub-stage-iconbtn:disabled { opacity: .4; cursor: not-allowed; }
|
|
214
|
+
.cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
|
|
215
|
+
.cpub-stage-toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
|
216
|
+
.cpub-stage-reset { color: var(--text-faint); }
|
|
217
|
+
</style>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,16 +53,16 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
+
"@commonpub/editor": "0.7.11",
|
|
56
57
|
"@commonpub/auth": "0.8.0",
|
|
57
|
-
"@commonpub/docs": "0.6.3",
|
|
58
58
|
"@commonpub/config": "0.18.0",
|
|
59
|
-
"@commonpub/
|
|
60
|
-
"@commonpub/explainer": "0.7.15",
|
|
61
|
-
"@commonpub/editor": "0.7.11",
|
|
62
|
-
"@commonpub/server": "2.75.0",
|
|
63
|
-
"@commonpub/schema": "0.28.0",
|
|
59
|
+
"@commonpub/docs": "0.6.3",
|
|
64
60
|
"@commonpub/protocol": "0.13.0",
|
|
65
|
-
"@commonpub/
|
|
61
|
+
"@commonpub/schema": "0.29.0",
|
|
62
|
+
"@commonpub/server": "2.76.0",
|
|
63
|
+
"@commonpub/ui": "0.9.2",
|
|
64
|
+
"@commonpub/learning": "0.5.2",
|
|
65
|
+
"@commonpub/explainer": "0.7.15"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { ContestStage } from '@commonpub/schema';
|
|
3
|
+
|
|
2
4
|
definePageMeta({ middleware: 'auth' });
|
|
3
5
|
|
|
4
6
|
const route = useRoute();
|
|
@@ -55,6 +57,10 @@ const prizes = ref<Prize[]>([]);
|
|
|
55
57
|
interface Criterion { label: string; weight: number | null; description: string }
|
|
56
58
|
const criteria = ref<Criterion[]>([]);
|
|
57
59
|
|
|
60
|
+
// Phase B1 — explicit stage timeline (empty ⇒ standard synthesized flow).
|
|
61
|
+
const stages = ref<ContestStage[]>([]);
|
|
62
|
+
const currentStageIdRef = ref<string | null>(null);
|
|
63
|
+
|
|
58
64
|
// Load current data
|
|
59
65
|
watch(contest, (c) => {
|
|
60
66
|
if (!c) return;
|
|
@@ -75,6 +81,8 @@ watch(contest, (c) => {
|
|
|
75
81
|
visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
|
|
76
82
|
visibleToRoles.value = [...(c.visibleToRoles ?? [])];
|
|
77
83
|
showPrizes.value = c.showPrizes !== false;
|
|
84
|
+
stages.value = Array.isArray(c.stages) ? [...c.stages] : [];
|
|
85
|
+
currentStageIdRef.value = c.currentStageId ?? null;
|
|
78
86
|
prizesDescription.value = c.prizesDescription ?? '';
|
|
79
87
|
prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
|
|
80
88
|
place: p.place ?? null,
|
|
@@ -166,6 +174,8 @@ async function handleSave(): Promise<void> {
|
|
|
166
174
|
visibility: visibility.value,
|
|
167
175
|
visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
|
|
168
176
|
showPrizes: showPrizes.value,
|
|
177
|
+
stages: stages.value,
|
|
178
|
+
currentStageId: currentStageIdRef.value ?? undefined,
|
|
169
179
|
prizesDescription: prizesDescription.value || undefined,
|
|
170
180
|
prizes: prizeData,
|
|
171
181
|
judgingCriteria: criteriaData,
|
|
@@ -201,30 +211,10 @@ async function handleDelete(): Promise<void> {
|
|
|
201
211
|
}
|
|
202
212
|
}
|
|
203
213
|
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
const
|
|
207
|
-
|
|
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
|
-
}
|
|
214
|
+
// Bidirectional lifecycle controls — the valid-transition map + button metadata
|
|
215
|
+
// live in utils/contestTransitions.ts (shared with ContestHero).
|
|
216
|
+
const availableTransitions = computed<string[]>(() => contestTransitionsFrom(contest.value?.status));
|
|
217
|
+
const statusAction = contestStatusAction;
|
|
228
218
|
|
|
229
219
|
async function transitionStatus(newStatus: string): Promise<void> {
|
|
230
220
|
// Only the consequential transitions confirm; reversible nudges (pause/resume,
|
|
@@ -307,6 +297,18 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
307
297
|
<p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
|
|
308
298
|
</section>
|
|
309
299
|
|
|
300
|
+
<section class="cpub-form-section">
|
|
301
|
+
<h2 class="cpub-form-section-title">Stages</h2>
|
|
302
|
+
<p class="cpub-form-hint">Optional. The standard flow (Submissions → Judging → Results) is derived from the schedule above. Add custom stages for multi-round contests — proposal rounds, a Top-N selection, a build sprint, multiple judging rounds, or a showcase event.</p>
|
|
303
|
+
<ContestStagesEditor
|
|
304
|
+
v-model="stages"
|
|
305
|
+
v-model:current-stage-id="currentStageIdRef"
|
|
306
|
+
:start-date="startDate"
|
|
307
|
+
:end-date="endDate"
|
|
308
|
+
:judging-end-date="judgingEndDate"
|
|
309
|
+
/>
|
|
310
|
+
</section>
|
|
311
|
+
|
|
310
312
|
<section class="cpub-form-section">
|
|
311
313
|
<h2 class="cpub-form-section-title">Entries</h2>
|
|
312
314
|
<div class="cpub-form-field">
|
|
@@ -473,10 +475,6 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
473
475
|
</div>
|
|
474
476
|
</section>
|
|
475
477
|
|
|
476
|
-
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
|
|
477
|
-
<i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving...' : 'Save Changes' }}
|
|
478
|
-
</button>
|
|
479
|
-
|
|
480
478
|
<section class="cpub-form-section cpub-danger-zone">
|
|
481
479
|
<h2 class="cpub-form-section-title cpub-danger-title">Danger Zone</h2>
|
|
482
480
|
<div class="cpub-danger-row">
|
|
@@ -489,6 +487,19 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
489
487
|
</button>
|
|
490
488
|
</div>
|
|
491
489
|
</section>
|
|
490
|
+
|
|
491
|
+
<!-- Sticky save bar — always reachable without scrolling to the bottom. -->
|
|
492
|
+
<div class="cpub-edit-actionbar">
|
|
493
|
+
<span class="cpub-edit-actionbar-status">
|
|
494
|
+
Status <span class="cpub-status-badge" :class="`cpub-status-${contest.status}`">{{ contest.status }}</span>
|
|
495
|
+
</span>
|
|
496
|
+
<div class="cpub-edit-actionbar-btns">
|
|
497
|
+
<NuxtLink :to="`/contests/${slug}`" class="cpub-btn cpub-edit-cancel">Cancel</NuxtLink>
|
|
498
|
+
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !!dateError">
|
|
499
|
+
<i class="fa-solid fa-floppy-disk"></i> {{ saving ? 'Saving…' : 'Save Changes' }}
|
|
500
|
+
</button>
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
492
503
|
</form>
|
|
493
504
|
</div>
|
|
494
505
|
<div v-else class="cpub-not-found"><p>Contest not found</p></div>
|
|
@@ -557,8 +568,27 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
557
568
|
|
|
558
569
|
.cpub-not-found { text-align: center; padding: 64px; color: var(--text-dim); display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
|
559
570
|
|
|
571
|
+
/* Sticky save bar — pinned to the viewport bottom while editing the long form. */
|
|
572
|
+
.cpub-edit-actionbar {
|
|
573
|
+
position: sticky;
|
|
574
|
+
bottom: 0;
|
|
575
|
+
z-index: 20;
|
|
576
|
+
display: flex;
|
|
577
|
+
align-items: center;
|
|
578
|
+
justify-content: space-between;
|
|
579
|
+
gap: 12px;
|
|
580
|
+
margin: 4px -32px -32px;
|
|
581
|
+
padding: 14px 32px;
|
|
582
|
+
background: var(--surface);
|
|
583
|
+
border-top: 2px solid var(--border);
|
|
584
|
+
box-shadow: var(--shadow-lg);
|
|
585
|
+
}
|
|
586
|
+
.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; }
|
|
587
|
+
.cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
|
|
588
|
+
|
|
560
589
|
@media (max-width: 768px) {
|
|
561
590
|
.cpub-contest-edit { padding: 16px; }
|
|
562
591
|
.cpub-form-row { grid-template-columns: 1fr; }
|
|
592
|
+
.cpub-edit-actionbar { margin: 4px -16px -16px; padding: 12px 16px; }
|
|
563
593
|
}
|
|
564
594
|
</style>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { ContestStage } from '@commonpub/schema';
|
|
3
|
+
|
|
2
4
|
definePageMeta({ middleware: 'auth' });
|
|
3
5
|
|
|
4
6
|
useSeoMeta({ title: `Create Contest — ${useSiteName()}` });
|
|
@@ -57,6 +59,10 @@ interface Prize {
|
|
|
57
59
|
|
|
58
60
|
const showPrizes = ref(true);
|
|
59
61
|
const prizesDescription = ref('');
|
|
62
|
+
|
|
63
|
+
// Phase B1 — explicit stage timeline (empty ⇒ standard synthesized flow).
|
|
64
|
+
const stages = ref<ContestStage[]>([]);
|
|
65
|
+
const currentStageIdRef = ref<string | null>(null);
|
|
60
66
|
// Prizes are entirely optional — start empty so a contest has NO prizes unless
|
|
61
67
|
// the operator explicitly adds them (the old 3 pre-filled rows forced prizes
|
|
62
68
|
// onto every contest, since their non-empty titles survived the submit filter).
|
|
@@ -116,6 +122,8 @@ async function handleCreate(): Promise<void> {
|
|
|
116
122
|
eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
|
|
117
123
|
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
118
124
|
showPrizes: showPrizes.value,
|
|
125
|
+
stages: stages.value.length ? stages.value : undefined,
|
|
126
|
+
currentStageId: currentStageIdRef.value ?? undefined,
|
|
119
127
|
prizesDescription: prizesDescription.value || undefined,
|
|
120
128
|
prizes: prizes.value
|
|
121
129
|
.filter(p => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
|
|
@@ -215,6 +223,19 @@ function prizeLabel(prize: Prize): string {
|
|
|
215
223
|
<p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
|
|
216
224
|
</section>
|
|
217
225
|
|
|
226
|
+
<!-- Stages -->
|
|
227
|
+
<section class="cpub-form-section">
|
|
228
|
+
<h2 class="cpub-form-section-title">Stages <span style="color: var(--text-faint); font-weight: 400; font-size: 0.75em; font-family: var(--font-mono);">— optional</span></h2>
|
|
229
|
+
<p class="cpub-form-hint">The standard flow (Submissions → Judging → Results) is derived from the schedule above. Add custom stages for multi-round contests — proposal rounds, a Top-N selection, a build sprint, multiple judging rounds, or a showcase event.</p>
|
|
230
|
+
<ContestStagesEditor
|
|
231
|
+
v-model="stages"
|
|
232
|
+
v-model:current-stage-id="currentStageIdRef"
|
|
233
|
+
:start-date="startDate"
|
|
234
|
+
:end-date="endDate"
|
|
235
|
+
:judging-end-date="judgingEndDate"
|
|
236
|
+
/>
|
|
237
|
+
</section>
|
|
238
|
+
|
|
218
239
|
<!-- Visibility & Access -->
|
|
219
240
|
<section class="cpub-form-section">
|
|
220
241
|
<h2 class="cpub-form-section-title">Visibility & Access</h2>
|
|
@@ -354,9 +375,15 @@ function prizeLabel(prize: Prize): string {
|
|
|
354
375
|
</div>
|
|
355
376
|
</section>
|
|
356
377
|
|
|
357
|
-
<
|
|
358
|
-
<
|
|
359
|
-
|
|
378
|
+
<div class="cpub-edit-actionbar">
|
|
379
|
+
<span class="cpub-edit-actionbar-hint">Required: title, start & end dates.</span>
|
|
380
|
+
<div class="cpub-edit-actionbar-btns">
|
|
381
|
+
<NuxtLink to="/contests" class="cpub-btn">Cancel</NuxtLink>
|
|
382
|
+
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !startDate || !endDate || !!dateError">
|
|
383
|
+
<i class="fa-solid fa-trophy"></i> {{ saving ? 'Creating…' : 'Create Contest' }}
|
|
384
|
+
</button>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
360
387
|
</form>
|
|
361
388
|
</div>
|
|
362
389
|
</template>
|
|
@@ -401,10 +428,29 @@ function prizeLabel(prize: Prize): string {
|
|
|
401
428
|
.cpub-delete-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; }
|
|
402
429
|
.cpub-delete-btn:hover { color: var(--red); }
|
|
403
430
|
|
|
431
|
+
/* Sticky create bar — Create button always reachable on the long form. */
|
|
432
|
+
.cpub-edit-actionbar {
|
|
433
|
+
position: sticky;
|
|
434
|
+
bottom: 0;
|
|
435
|
+
z-index: 20;
|
|
436
|
+
display: flex;
|
|
437
|
+
align-items: center;
|
|
438
|
+
justify-content: space-between;
|
|
439
|
+
gap: 12px;
|
|
440
|
+
margin: 4px -32px -32px;
|
|
441
|
+
padding: 14px 32px;
|
|
442
|
+
background: var(--surface);
|
|
443
|
+
border-top: 2px solid var(--border);
|
|
444
|
+
box-shadow: var(--shadow-lg);
|
|
445
|
+
}
|
|
446
|
+
.cpub-edit-actionbar-hint { font-size: 11px; color: var(--text-faint); }
|
|
447
|
+
.cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
|
|
448
|
+
|
|
404
449
|
@media (max-width: 768px) {
|
|
405
450
|
.cpub-contest-create { padding: 16px; }
|
|
406
451
|
.cpub-page-title { font-size: 20px; }
|
|
407
452
|
.cpub-form-section { padding: 14px; }
|
|
408
453
|
.cpub-form-row { grid-template-columns: 1fr; }
|
|
454
|
+
.cpub-edit-actionbar { margin: 4px -16px -16px; padding: 12px 16px; }
|
|
409
455
|
}
|
|
410
456
|
</style>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ContestStage } from '@commonpub/schema';
|
|
2
|
+
|
|
3
|
+
// Client mirror of the pure stage helpers in @commonpub/server `contest.ts`
|
|
4
|
+
// (synthesizeStages / normalizeStages / currentStage). Deliberately duplicated —
|
|
5
|
+
// importing the server package into the browser bundle would pull in DB drivers.
|
|
6
|
+
// If the server derivation changes, change this in lockstep (same contract as the
|
|
7
|
+
// VALID_TRANSITIONS mirror in ContestHero/edit.vue).
|
|
8
|
+
|
|
9
|
+
export interface StageSource {
|
|
10
|
+
status: string;
|
|
11
|
+
startDate: string;
|
|
12
|
+
endDate: string;
|
|
13
|
+
judgingEndDate: string | null;
|
|
14
|
+
stages?: ContestStage[] | null;
|
|
15
|
+
currentStageId?: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const iso = (d: string | null | undefined): string | undefined => (d ? new Date(d).toISOString() : undefined);
|
|
19
|
+
|
|
20
|
+
/** Classic Submissions → Judging → Results, synthesized from status + dates. */
|
|
21
|
+
export function synthesizeStages(c: StageSource): ContestStage[] {
|
|
22
|
+
return [
|
|
23
|
+
{ id: 'core-submission', name: 'Submissions', kind: 'submission', core: true, startsAt: iso(c.startDate), endsAt: iso(c.endDate) },
|
|
24
|
+
{ id: 'core-review', name: 'Judging', kind: 'review', core: true, endsAt: iso(c.judgingEndDate) ?? iso(c.endDate) },
|
|
25
|
+
{ id: 'core-results', name: 'Results', kind: 'results', core: true },
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Explicit stages if defined, else the synthesized classic flow (the default). */
|
|
30
|
+
export function normalizeStages(c: StageSource): ContestStage[] {
|
|
31
|
+
return c.stages && c.stages.length > 0 ? c.stages : synthesizeStages(c);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Id of the stage that is "now" — the resolvable `currentStageId`, else derived
|
|
35
|
+
* from `status`. Null while draft/cancelled (nothing running). */
|
|
36
|
+
export function currentStageId(c: StageSource): string | null {
|
|
37
|
+
const stages = normalizeStages(c);
|
|
38
|
+
if (c.currentStageId && stages.some((s) => s.id === c.currentStageId)) return c.currentStageId;
|
|
39
|
+
switch (c.status) {
|
|
40
|
+
case 'draft':
|
|
41
|
+
case 'cancelled':
|
|
42
|
+
return null;
|
|
43
|
+
case 'completed':
|
|
44
|
+
return (stages.find((s) => s.kind === 'results') ?? stages[stages.length - 1])?.id ?? null;
|
|
45
|
+
case 'judging':
|
|
46
|
+
return stages.find((s) => s.kind === 'review')?.id ?? null;
|
|
47
|
+
default: // upcoming | active | paused
|
|
48
|
+
return (stages.find((s) => s.kind === 'submission') ?? stages[0])?.id ?? null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** FontAwesome icon (no `fa-solid` prefix) for each stage kind. */
|
|
53
|
+
export const STAGE_KIND_ICON: Record<ContestStage['kind'], string> = {
|
|
54
|
+
submission: 'fa-pen-to-square',
|
|
55
|
+
review: 'fa-gavel',
|
|
56
|
+
interim: 'fa-screwdriver-wrench',
|
|
57
|
+
results: 'fa-ranking-star',
|
|
58
|
+
event: 'fa-flag-checkered',
|
|
59
|
+
custom: 'fa-circle-dot',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Human label for each stage kind (for the editor dropdown). */
|
|
63
|
+
export const STAGE_KIND_LABEL: Record<ContestStage['kind'], string> = {
|
|
64
|
+
submission: 'Submissions',
|
|
65
|
+
review: 'Judging / Review',
|
|
66
|
+
interim: 'Working period (sprint)',
|
|
67
|
+
results: 'Results',
|
|
68
|
+
event: 'Event / Showcase',
|
|
69
|
+
custom: 'Custom milestone',
|
|
70
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Client mirror of the server VALID_TRANSITIONS map (@commonpub/server contest.ts).
|
|
2
|
+
// Single source of truth for the contest lifecycle controls in ContestHero and
|
|
3
|
+
// the contest edit page — keeps the offered buttons in sync with what the API
|
|
4
|
+
// accepts. If the server map changes, change this in lockstep (a client-only or
|
|
5
|
+
// server-only edit silently desyncs: the UI offers a button the API rejects).
|
|
6
|
+
|
|
7
|
+
export const CONTEST_VALID_TRANSITIONS: Record<string, string[]> = {
|
|
8
|
+
draft: ['upcoming', 'active', 'cancelled'],
|
|
9
|
+
upcoming: ['draft', 'active', 'cancelled'],
|
|
10
|
+
active: ['upcoming', 'paused', 'judging', 'cancelled'],
|
|
11
|
+
paused: ['active', 'upcoming', 'judging', 'cancelled'],
|
|
12
|
+
judging: ['active', 'paused', 'completed', 'cancelled'],
|
|
13
|
+
completed: ['judging'],
|
|
14
|
+
cancelled: ['draft', 'upcoming'],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Display metadata for a transition target. `tone` styles the button (go/warn/danger). */
|
|
18
|
+
export const CONTEST_STATUS_ACTION: Record<string, { label: string; icon: string; tone?: 'go' | 'warn' | 'danger' }> = {
|
|
19
|
+
draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
|
|
20
|
+
upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
|
|
21
|
+
active: { label: 'Activate', icon: 'fa-play', tone: 'go' },
|
|
22
|
+
paused: { label: 'Pause', icon: 'fa-pause', tone: 'warn' },
|
|
23
|
+
judging: { label: 'Start Judging', icon: 'fa-gavel' },
|
|
24
|
+
completed: { label: 'Complete & Publish', icon: 'fa-flag-checkered', tone: 'go' },
|
|
25
|
+
cancelled: { label: 'Cancel', icon: 'fa-ban', tone: 'danger' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function contestTransitionsFrom(status: string | undefined): string[] {
|
|
29
|
+
return CONTEST_VALID_TRANSITIONS[status ?? 'upcoming'] ?? [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function contestStatusAction(s: string): { label: string; icon: string; tone?: string } {
|
|
33
|
+
return CONTEST_STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
|
|
34
|
+
}
|