@commonpub/layer 0.51.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 +16 -25
- package/components/contest/ContestSidebar.vue +12 -15
- package/components/contest/ContestStagesEditor.vue +217 -0
- package/package.json +6 -6
- package/pages/contests/[slug]/edit.vue +26 -24
- package/pages/contests/create.vue +21 -0
- package/utils/contestStages.ts +70 -0
- package/utils/contestTransitions.ts +34 -0
|
@@ -84,31 +84,10 @@ const dateNote = computed<string | null>(() => {
|
|
|
84
84
|
return c.value?.endDate ? `Closed ${fmtDate(c.value.endDate)}` : null;
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
const
|
|
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
|
-
}
|
|
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;
|
|
112
91
|
|
|
113
92
|
// The hero shows the short `subheading` (a dedicated tagline field). For older
|
|
114
93
|
// contests without one, fall back to a clean, plain-text, CSS-clamped excerpt of
|
|
@@ -120,6 +99,15 @@ const tagline = computed<string>(() => {
|
|
|
120
99
|
return markdownToExcerpt(c.value?.description) || '';
|
|
121
100
|
});
|
|
122
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
|
+
|
|
123
111
|
const dateRange = computed<string>(() => {
|
|
124
112
|
const fmt = (d: string, withYear = false) =>
|
|
125
113
|
new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(withYear ? { year: 'numeric' } : {}) });
|
|
@@ -157,6 +145,7 @@ const dateRange = computed<string>(() => {
|
|
|
157
145
|
<div class="cpub-hero-eyebrow">
|
|
158
146
|
<span class="cpub-contest-badge"><i class="fa fa-trophy"></i> Contest</span>
|
|
159
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>
|
|
160
149
|
</div>
|
|
161
150
|
|
|
162
151
|
<h1 class="cpub-hero-title">{{ c?.title || 'Contest' }}</h1>
|
|
@@ -290,6 +279,8 @@ const dateRange = computed<string>(() => {
|
|
|
290
279
|
.cpub-status-pill[data-status="paused"] { color: var(--yellow); border-color: var(--yellow); background: color-mix(in srgb, var(--yellow) 12%, transparent); }
|
|
291
280
|
.cpub-status-pill[data-status="draft"] { color: var(--hero-text-dim); border-color: var(--hero-border); border-style: dashed; }
|
|
292
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; }
|
|
293
284
|
|
|
294
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); }
|
|
295
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
58
|
"@commonpub/config": "0.18.0",
|
|
58
59
|
"@commonpub/docs": "0.6.3",
|
|
59
|
-
"@commonpub/explainer": "0.7.15",
|
|
60
|
-
"@commonpub/editor": "0.7.11",
|
|
61
|
-
"@commonpub/learning": "0.5.2",
|
|
62
|
-
"@commonpub/schema": "0.28.0",
|
|
63
60
|
"@commonpub/protocol": "0.13.0",
|
|
61
|
+
"@commonpub/schema": "0.29.0",
|
|
62
|
+
"@commonpub/server": "2.76.0",
|
|
64
63
|
"@commonpub/ui": "0.9.2",
|
|
65
|
-
"@commonpub/
|
|
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">
|
|
@@ -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>
|
|
@@ -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
|
+
}
|