@commonpub/layer 0.51.0 → 0.53.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.
@@ -91,6 +91,7 @@ function confirmWithdraw(entryId: string): void {
91
91
  v-for="(entry, i) in entries"
92
92
  :key="entry.id"
93
93
  class="cpub-entry-card"
94
+ :class="{ 'cpub-entry-out': entry.eliminated }"
94
95
  >
95
96
  <div class="cpub-entry-thumb" :class="i % 2 === 0 ? 'cpub-entry-bg-light' : 'cpub-entry-bg-dark'">
96
97
  <img v-if="entry.contentCoverImageUrl" :src="entry.contentCoverImageUrl" :alt="entry.contentTitle" class="cpub-entry-cover-img" />
@@ -99,6 +100,8 @@ function confirmWithdraw(entryId: string): void {
99
100
  <div class="cpub-entry-icon"><i class="fa-solid fa-microchip"></i></div>
100
101
  </template>
101
102
  <span v-if="entry.rank" class="cpub-entry-rank" :class="`cpub-rank-${entry.rank <= 3 ? entry.rank : 'other'}`">#{{ entry.rank }}</span>
103
+ <span v-if="entry.eliminated" class="cpub-entry-cohort cpub-cohort-out"><i class="fa-solid fa-circle-minus"></i> Not advanced</span>
104
+ <span v-else-if="entry.stageState && entry.stageState.some((s) => s.status === 'advanced')" class="cpub-entry-cohort cpub-cohort-in"><i class="fa-solid fa-circle-check"></i> Advanced</span>
102
105
  </div>
103
106
  <div class="cpub-entry-body">
104
107
  <NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-entry-title">{{ entry.contentTitle || `Entry #${i + 1}` }}</NuxtLink>
@@ -165,6 +168,12 @@ function confirmWithdraw(entryId: string): void {
165
168
  .cpub-rank-2 { background: var(--surface2); color: var(--text-faint); border: var(--border-width-default) solid var(--text-faint); }
166
169
  .cpub-rank-3 { background: var(--surface2); color: var(--bronze); border: var(--border-width-default) solid var(--bronze); }
167
170
  .cpub-rank-other { background: var(--surface2); color: var(--text-dim); border: var(--border-width-default) solid var(--border); }
171
+ .cpub-entry-cohort { position: absolute; top: 8px; right: 8px; z-index: 2; font-size: 9px; font-family: var(--font-mono); font-weight: 700; text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: var(--radius); display: inline-flex; align-items: center; gap: 4px; }
172
+ .cpub-cohort-in { background: var(--green-bg); color: var(--green); border: var(--border-width-default) solid var(--green-border); }
173
+ .cpub-cohort-out { background: var(--surface2); color: var(--text-faint); border: var(--border-width-default) solid var(--border2); }
174
+ .cpub-entry-cohort i { font-size: 8px; }
175
+ .cpub-entry-out { opacity: .6; }
176
+ .cpub-entry-out:hover { opacity: 1; }
168
177
  .cpub-entry-body { padding: 10px 12px; }
169
178
  .cpub-entry-title { font-size: 12px; font-weight: 600; margin-bottom: 3px; line-height: 1.3; color: var(--text); text-decoration: none; }
170
179
  .cpub-entry-title:hover { color: var(--accent); }
@@ -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
- // Client-side mirror of the server VALID_TRANSITIONS map (server/src/contest/contest.ts).
88
- // Keeps the inline admin controls in sync with what the API will actually accept —
89
- // bidirectional: go back a stage, pause/resume, reopen, etc.
90
- const VALID_TRANSITIONS: Record<string, string[]> = {
91
- draft: ['upcoming', 'active', 'cancelled'],
92
- upcoming: ['draft', 'active', 'cancelled'],
93
- active: ['upcoming', 'paused', 'judging', 'cancelled'],
94
- paused: ['active', 'upcoming', 'judging', 'cancelled'],
95
- judging: ['active', 'paused', 'completed', 'cancelled'],
96
- completed: ['judging'],
97
- cancelled: ['draft', 'upcoming'],
98
- };
99
- const STATUS_ACTION: Record<string, { label: string; icon: string }> = {
100
- draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
101
- upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
102
- active: { label: 'Activate', icon: 'fa-play' },
103
- paused: { label: 'Pause', icon: 'fa-pause' },
104
- judging: { label: 'Start Judging', icon: 'fa-gavel' },
105
- completed: { label: 'Complete', icon: 'fa-check' },
106
- cancelled: { label: 'Cancel', icon: 'fa-ban' },
107
- };
108
- const availableTransitions = computed<string[]>(() => VALID_TRANSITIONS[c.value?.status ?? 'upcoming'] ?? []);
109
- function statusAction(s: string): { label: string; icon: string } {
110
- return STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
111
- }
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
- // 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.51.0",
3
+ "version": "0.53.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/auth": "0.8.0",
57
56
  "@commonpub/config": "0.18.0",
58
- "@commonpub/docs": "0.6.3",
59
- "@commonpub/explainer": "0.7.15",
60
- "@commonpub/editor": "0.7.11",
61
57
  "@commonpub/learning": "0.5.2",
62
- "@commonpub/schema": "0.28.0",
58
+ "@commonpub/editor": "0.7.11",
59
+ "@commonpub/schema": "0.30.0",
63
60
  "@commonpub/protocol": "0.13.0",
61
+ "@commonpub/docs": "0.6.3",
62
+ "@commonpub/explainer": "0.7.15",
63
+ "@commonpub/server": "2.77.0",
64
64
  "@commonpub/ui": "0.9.2",
65
- "@commonpub/server": "2.75.0"
65
+ "@commonpub/auth": "0.8.0"
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,29 +211,33 @@ 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' };
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;
218
+
219
+ // Phase B2 advancement cuts. Operates on the PERSISTED stages (contest.value),
220
+ // not the editable `stages` ref, since it acts on real entries.
221
+ const advancing = ref<string | null>(null);
222
+ const advanceN = ref<Record<string, number>>({});
223
+ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) => s.kind === 'review'));
224
+ async function advanceStage(stageId: string): Promise<void> {
225
+ const topN = advanceN.value[stageId];
226
+ if (!topN || topN < 1) { toast.error('Enter how many entries advance.'); return; }
227
+ if (!confirm(`Advance the top ${topN} entries from this stage? Entries below the cut are marked "not advanced" and drop out of later judging + final results. You can re-run this.`)) return;
228
+ advancing.value = stageId;
229
+ try {
230
+ const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(`/api/contests/${slug}/advance`, {
231
+ method: 'POST',
232
+ body: { reviewStageId: stageId, mode: 'topN', topN },
233
+ });
234
+ toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
235
+ await refresh();
236
+ } catch (err: unknown) {
237
+ toast.error(extractError(err));
238
+ } finally {
239
+ advancing.value = null;
240
+ }
227
241
  }
228
242
 
229
243
  async function transitionStatus(newStatus: string): Promise<void> {
@@ -307,6 +321,33 @@ async function transitionStatus(newStatus: string): Promise<void> {
307
321
  <p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
308
322
  </section>
309
323
 
324
+ <section class="cpub-form-section">
325
+ <h2 class="cpub-form-section-title">Stages</h2>
326
+ <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>
327
+ <ContestStagesEditor
328
+ v-model="stages"
329
+ v-model:current-stage-id="currentStageIdRef"
330
+ :start-date="startDate"
331
+ :end-date="endDate"
332
+ :judging-end-date="judgingEndDate"
333
+ />
334
+ </section>
335
+
336
+ <section v-if="reviewStages.length" class="cpub-form-section">
337
+ <h2 class="cpub-form-section-title">Advancement</h2>
338
+ <p class="cpub-form-hint">Multi-round contests: after judging a review stage, advance the top entries to the next stage. Entries below the cut are marked "not advanced" and excluded from later judging + final results. Re-running re-computes the cut. (Save any stage changes above first.)</p>
339
+ <div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-row">
340
+ <span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
341
+ <div class="cpub-advance-ctl">
342
+ <label class="cpub-form-label" :for="`adv-${rs.id}`">Advance top</label>
343
+ <input :id="`adv-${rs.id}`" v-model.number="advanceN[rs.id]" type="number" min="1" class="cpub-form-input cpub-advance-n" placeholder="50" />
344
+ <button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id" @click="advanceStage(rs.id)">
345
+ <i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : 'Advance' }}
346
+ </button>
347
+ </div>
348
+ </div>
349
+ </section>
350
+
310
351
  <section class="cpub-form-section">
311
352
  <h2 class="cpub-form-section-title">Entries</h2>
312
353
  <div class="cpub-form-field">
@@ -581,6 +622,13 @@ async function transitionStatus(newStatus: string): Promise<void> {
581
622
  border-top: 2px solid var(--border);
582
623
  box-shadow: var(--shadow-lg);
583
624
  }
625
+ .cpub-advance-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 10px 0; border-top: var(--border-width-default) solid var(--border); }
626
+ .cpub-advance-row:first-of-type { border-top: 0; }
627
+ .cpub-advance-name { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
628
+ .cpub-advance-name i { color: var(--accent); font-size: 11px; }
629
+ .cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; }
630
+ .cpub-advance-ctl .cpub-form-label { margin: 0; }
631
+ .cpub-advance-n { width: 80px; }
584
632
  .cpub-edit-actionbar-status { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); display: flex; align-items: center; gap: 8px; }
585
633
  .cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
586
634
 
@@ -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>
@@ -0,0 +1,22 @@
1
+ import { getContestBySlug, advanceContestStage } from '@commonpub/server';
2
+ import { contestAdvanceSchema } from '@commonpub/schema';
3
+
4
+ // Phase B2 — apply an advancement cut at a review stage (cull the cohort to top-N
5
+ // or a manual pick, snapshot scores, advance to the next stage). Owner-gated.
6
+ export default defineEventHandler(async (event): Promise<{ advanced: boolean; advancedCount: number; eliminatedCount: number }> => {
7
+ requireFeature('contests');
8
+ const db = useDB();
9
+ const user = requireAuth(event);
10
+ const { slug } = parseParams(event, { slug: 'string' });
11
+ const input = await parseBody(event, contestAdvanceSchema);
12
+
13
+ const contest = await getContestBySlug(db, slug);
14
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
+
16
+ const result = await advanceContestStage(db, contest.id, user.id, input);
17
+ if (!result.advanced) {
18
+ const owner = /owner/i.test(result.error ?? '');
19
+ throw createError({ statusCode: owner ? 403 : 400, statusMessage: result.error || 'Advancement failed' });
20
+ }
21
+ return result;
22
+ });
@@ -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
+ }