@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.
@@ -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
- // During judging, count down to the judging deadline (if set); otherwise the
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
- let diff = Math.max(0, Math.floor((target.getTime() - now.getTime()) / 1000));
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
- if (c.value?.status === 'completed' || c.value?.status === 'cancelled') return 'Contest ended';
51
- if (c.value?.status === 'judging') return 'Judging ends in';
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 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' };
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
- // Ordinal position of each status along the lifecycle, used to mark steps
24
- // done / current / upcoming.
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 };
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 pos = STATUS_ORDER[c.status] ?? 0;
33
- const stepState = (idx: number): StepState => (idx < pos ? 'done' : idx === pos ? 'current' : 'upcoming');
34
- const steps: TimelineStep[] = [
35
- { label: 'Opens', date: fmt(c.startDate), state: stepState(0), icon: 'fa-flag' },
36
- { label: 'Submissions close', date: fmt(c.endDate), state: stepState(1), icon: 'fa-pen-to-square' },
37
- { label: 'Judging', date: fmt(c.judgingEndDate) ?? fmt(c.endDate), state: stepState(2), icon: 'fa-gavel' },
38
- { label: 'Results', date: null, state: stepState(3), icon: 'fa-ranking-star' },
39
- ];
40
- return steps;
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.50.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/learning": "0.5.2",
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/ui": "0.9.2"
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
- // 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
- }
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 &amp; Access</h2>
@@ -354,9 +375,15 @@ function prizeLabel(prize: Prize): string {
354
375
  </div>
355
376
  </section>
356
377
 
357
- <button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-lg" :disabled="saving || !title.trim() || !startDate || !endDate || !!dateError">
358
- <i class="fa-solid fa-trophy"></i> {{ saving ? 'Creating...' : 'Create Contest' }}
359
- </button>
378
+ <div class="cpub-edit-actionbar">
379
+ <span class="cpub-edit-actionbar-hint">Required: title, start &amp; 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
+ }