@commonpub/layer 0.52.0 → 0.54.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/ContestEntries.vue +9 -0
- package/components/contest/ContestStagesEditor.vue +15 -23
- package/package.json +6 -6
- package/pages/contests/[slug]/edit.vue +80 -22
- package/pages/contests/create.vue +2 -7
- package/server/api/contests/[slug]/advance.post.ts +22 -0
- package/utils/contestStages.ts +44 -0
|
@@ -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); }
|
|
@@ -20,11 +20,6 @@ const props = defineProps<{
|
|
|
20
20
|
|
|
21
21
|
const KINDS: ContestStage['kind'][] = ['submission', 'review', 'interim', 'results', 'event', 'custom'];
|
|
22
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
23
|
// datetime-local <-> ISO (mirrors the rest of the contest forms' convention).
|
|
29
24
|
function toLocal(iso?: string): string {
|
|
30
25
|
if (!iso) return '';
|
|
@@ -50,39 +45,28 @@ function onDate(i: number, field: 'startsAt' | 'endsAt', e: Event): void {
|
|
|
50
45
|
setField(i, { [field]: v } as Partial<ContestStage>);
|
|
51
46
|
}
|
|
52
47
|
|
|
48
|
+
// Array operations live as pure functions in utils/contestStages.ts (unit-tested).
|
|
53
49
|
function addStage(): void {
|
|
54
|
-
commit(
|
|
50
|
+
commit(withStageAdded(stages.value));
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
function duplicateStage(i: number): void {
|
|
58
|
-
|
|
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)]);
|
|
54
|
+
commit(withStageDuplicated(stages.value, i));
|
|
62
55
|
}
|
|
63
56
|
|
|
64
57
|
function removeStage(i: number): void {
|
|
65
58
|
const removed = stages.value[i];
|
|
66
|
-
commit(stages.value
|
|
59
|
+
commit(withStageRemoved(stages.value, i));
|
|
67
60
|
if (removed && currentId.value === removed.id) currentId.value = null;
|
|
68
61
|
}
|
|
69
62
|
|
|
70
63
|
function move(i: number, dir: -1 | 1): void {
|
|
71
|
-
|
|
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);
|
|
64
|
+
commit(withStageMoved(stages.value, i, dir));
|
|
76
65
|
}
|
|
77
66
|
|
|
78
67
|
// Seed the editor with the standard three stages so the owner has a starting point.
|
|
79
68
|
function customize(): void {
|
|
80
|
-
|
|
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
|
-
]);
|
|
69
|
+
commit(seedStandardStages(props));
|
|
86
70
|
}
|
|
87
71
|
|
|
88
72
|
function resetToStandard(): void {
|
|
@@ -108,6 +92,13 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
108
92
|
</div>
|
|
109
93
|
|
|
110
94
|
<template v-else>
|
|
95
|
+
<div class="cpub-stage-tophead">
|
|
96
|
+
<span class="cpub-stage-count">{{ stages.length }} stage{{ stages.length === 1 ? '' : 's' }}</span>
|
|
97
|
+
<div class="cpub-stage-toolbar">
|
|
98
|
+
<button type="button" class="cpub-btn cpub-btn-sm" @click="addStage"><i class="fa-solid fa-plus"></i> Add stage</button>
|
|
99
|
+
<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</button>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
111
102
|
<p v-if="missingSubmission" class="cpub-form-error" role="alert" style="margin: 0 0 10px;">
|
|
112
103
|
Add at least one <strong>Submissions</strong> stage, or reset to the standard flow.
|
|
113
104
|
</p>
|
|
@@ -193,7 +184,6 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
193
184
|
|
|
194
185
|
<div class="cpub-stage-toolbar">
|
|
195
186
|
<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
187
|
</div>
|
|
198
188
|
</template>
|
|
199
189
|
</div>
|
|
@@ -201,6 +191,8 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
|
|
|
201
191
|
|
|
202
192
|
<style scoped>
|
|
203
193
|
.cpub-stages-standard { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
|
194
|
+
.cpub-stage-tophead { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
195
|
+
.cpub-stage-count { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); }
|
|
204
196
|
.cpub-stage-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
|
205
197
|
.cpub-stage-row { border: var(--border-width-default) solid var(--border); background: var(--surface2); padding: 12px; }
|
|
206
198
|
.cpub-stage-row-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.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",
|
|
57
56
|
"@commonpub/auth": "0.8.0",
|
|
58
57
|
"@commonpub/config": "0.18.0",
|
|
58
|
+
"@commonpub/editor": "0.7.11",
|
|
59
59
|
"@commonpub/docs": "0.6.3",
|
|
60
60
|
"@commonpub/protocol": "0.13.0",
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/server": "2.76.0",
|
|
63
|
-
"@commonpub/ui": "0.9.2",
|
|
61
|
+
"@commonpub/explainer": "0.7.15",
|
|
64
62
|
"@commonpub/learning": "0.5.2",
|
|
65
|
-
"@commonpub/
|
|
63
|
+
"@commonpub/server": "2.77.0",
|
|
64
|
+
"@commonpub/ui": "0.9.2",
|
|
65
|
+
"@commonpub/schema": "0.30.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -216,6 +216,30 @@ async function handleDelete(): Promise<void> {
|
|
|
216
216
|
const availableTransitions = computed<string[]>(() => contestTransitionsFrom(contest.value?.status));
|
|
217
217
|
const statusAction = contestStatusAction;
|
|
218
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
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
219
243
|
async function transitionStatus(newStatus: string): Promise<void> {
|
|
220
244
|
// Only the consequential transitions confirm; reversible nudges (pause/resume,
|
|
221
245
|
// go-back) just apply.
|
|
@@ -244,6 +268,8 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
244
268
|
</p>
|
|
245
269
|
|
|
246
270
|
<form class="cpub-edit-form" @submit.prevent="handleSave">
|
|
271
|
+
<div class="cpub-edit-layout">
|
|
272
|
+
<div class="cpub-edit-main">
|
|
247
273
|
<section class="cpub-form-section">
|
|
248
274
|
<h2 class="cpub-form-section-title">Details</h2>
|
|
249
275
|
<div class="cpub-form-field">
|
|
@@ -309,22 +335,19 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
309
335
|
/>
|
|
310
336
|
</section>
|
|
311
337
|
|
|
312
|
-
<section class="cpub-form-section">
|
|
313
|
-
<h2 class="cpub-form-section-title">
|
|
314
|
-
<
|
|
315
|
-
|
|
316
|
-
<
|
|
317
|
-
<div class="cpub-
|
|
318
|
-
<label
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
338
|
+
<section v-if="reviewStages.length" class="cpub-form-section">
|
|
339
|
+
<h2 class="cpub-form-section-title">Advancement</h2>
|
|
340
|
+
<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>
|
|
341
|
+
<div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-row">
|
|
342
|
+
<span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
|
|
343
|
+
<div class="cpub-advance-ctl">
|
|
344
|
+
<label class="cpub-form-label" :for="`adv-${rs.id}`">Advance top</label>
|
|
345
|
+
<input :id="`adv-${rs.id}`" v-model.number="advanceN[rs.id]" type="number" min="1" class="cpub-form-input cpub-advance-n" placeholder="50" />
|
|
346
|
+
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id" @click="advanceStage(rs.id)">
|
|
347
|
+
<i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : 'Advance' }}
|
|
348
|
+
</button>
|
|
322
349
|
</div>
|
|
323
350
|
</div>
|
|
324
|
-
<div class="cpub-form-field">
|
|
325
|
-
<label class="cpub-form-label">Max entries per person</label>
|
|
326
|
-
<input v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" style="max-width: 160px;" />
|
|
327
|
-
</div>
|
|
328
351
|
</section>
|
|
329
352
|
|
|
330
353
|
<section class="cpub-form-section">
|
|
@@ -443,6 +466,26 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
443
466
|
<p class="cpub-form-hint">Invited judges receive a notification and must accept before they can score.</p>
|
|
444
467
|
<ContestJudgeManager :contest-slug="slug" :is-owner="true" />
|
|
445
468
|
</section>
|
|
469
|
+
</div><!-- /cpub-edit-main -->
|
|
470
|
+
|
|
471
|
+
<aside class="cpub-edit-side">
|
|
472
|
+
<section class="cpub-form-section">
|
|
473
|
+
<h2 class="cpub-form-section-title">Entries</h2>
|
|
474
|
+
<div class="cpub-form-field">
|
|
475
|
+
<span class="cpub-form-label">Eligible content types</span>
|
|
476
|
+
<p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
|
|
477
|
+
<div class="cpub-type-options" role="group" aria-label="Eligible content types">
|
|
478
|
+
<label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
|
|
479
|
+
<input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
|
|
480
|
+
<span>{{ t.label }}</span>
|
|
481
|
+
</label>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
<div class="cpub-form-field">
|
|
485
|
+
<label class="cpub-form-label">Max entries per person</label>
|
|
486
|
+
<input v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" />
|
|
487
|
+
</div>
|
|
488
|
+
</section>
|
|
446
489
|
|
|
447
490
|
<section class="cpub-form-section">
|
|
448
491
|
<h2 class="cpub-form-section-title">Stage & Status</h2>
|
|
@@ -487,6 +530,8 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
487
530
|
</button>
|
|
488
531
|
</div>
|
|
489
532
|
</section>
|
|
533
|
+
</aside><!-- /cpub-edit-side -->
|
|
534
|
+
</div><!-- /cpub-edit-layout -->
|
|
490
535
|
|
|
491
536
|
<!-- Sticky save bar — always reachable without scrolling to the bottom. -->
|
|
492
537
|
<div class="cpub-edit-actionbar">
|
|
@@ -506,7 +551,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
506
551
|
</template>
|
|
507
552
|
|
|
508
553
|
<style scoped>
|
|
509
|
-
.cpub-contest-edit { max-width:
|
|
554
|
+
.cpub-contest-edit { max-width: 1080px; margin: 0 auto; padding: 32px; }
|
|
510
555
|
.cpub-back-link { font-size: 11px; font-family: var(--font-mono); color: var(--text-faint); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 16px; }
|
|
511
556
|
.cpub-back-link:hover { color: var(--accent); }
|
|
512
557
|
.cpub-edit-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
|
@@ -522,15 +567,16 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
522
567
|
.cpub-status-cancelled { color: var(--red); border-color: var(--red-border); background: var(--red-bg); }
|
|
523
568
|
|
|
524
569
|
.cpub-edit-form { display: flex; flex-direction: column; gap: 16px; }
|
|
570
|
+
/* Two-column editor: wide content column + a sticky meta rail (Stage & Status,
|
|
571
|
+
Entry rules, Danger Zone) so lifecycle controls stay reachable while editing. */
|
|
572
|
+
.cpub-edit-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; align-items: start; }
|
|
573
|
+
.cpub-edit-main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
|
574
|
+
.cpub-edit-side { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 76px; }
|
|
525
575
|
.cpub-form-section { border: var(--border-width-default) solid var(--border); background: var(--surface); padding: 20px; box-shadow: var(--shadow-md); }
|
|
526
576
|
.cpub-form-section-title { font-size: 14px; font-weight: 700; margin-bottom: 14px; }
|
|
527
|
-
|
|
528
|
-
.
|
|
529
|
-
|
|
530
|
-
.cpub-form-input, .cpub-form-textarea { padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: 13px; font-family: inherit; }
|
|
531
|
-
.cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; }
|
|
532
|
-
.cpub-form-textarea { resize: vertical; }
|
|
533
|
-
.cpub-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
577
|
+
/* `.cpub-form-input/-textarea/-field/-row/-label` are sourced from the global
|
|
578
|
+
theme (forms.css) so the extracted ContestStagesEditor inherits them across the
|
|
579
|
+
Vue scoped-style boundary — don't re-scope them here. */
|
|
534
580
|
|
|
535
581
|
.cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
|
|
536
582
|
.cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
|
@@ -583,9 +629,21 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
583
629
|
border-top: 2px solid var(--border);
|
|
584
630
|
box-shadow: var(--shadow-lg);
|
|
585
631
|
}
|
|
632
|
+
.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); }
|
|
633
|
+
.cpub-advance-row:first-of-type { border-top: 0; }
|
|
634
|
+
.cpub-advance-name { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
|
|
635
|
+
.cpub-advance-name i { color: var(--accent); font-size: 11px; }
|
|
636
|
+
.cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; }
|
|
637
|
+
.cpub-advance-ctl .cpub-form-label { margin: 0; }
|
|
638
|
+
.cpub-advance-n { width: 80px; }
|
|
586
639
|
.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
640
|
.cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
|
|
588
641
|
|
|
642
|
+
/* Collapse the meta rail under the main column on narrower viewports. */
|
|
643
|
+
@media (max-width: 900px) {
|
|
644
|
+
.cpub-edit-layout { grid-template-columns: 1fr; }
|
|
645
|
+
.cpub-edit-side { position: static; }
|
|
646
|
+
}
|
|
589
647
|
@media (max-width: 768px) {
|
|
590
648
|
.cpub-contest-edit { padding: 16px; }
|
|
591
649
|
.cpub-form-row { grid-template-columns: 1fr; }
|
|
@@ -403,13 +403,8 @@ function prizeLabel(prize: Prize): string {
|
|
|
403
403
|
.cpub-form-section-title { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
|
|
404
404
|
.cpub-form-section-header .cpub-form-section-title { margin-bottom: 0; }
|
|
405
405
|
|
|
406
|
-
|
|
407
|
-
.
|
|
408
|
-
.cpub-form-label { font-size: 10px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); }
|
|
409
|
-
.cpub-form-input, .cpub-form-textarea { padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: 13px; font-family: inherit; }
|
|
410
|
-
.cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; }
|
|
411
|
-
.cpub-form-textarea { resize: vertical; }
|
|
412
|
-
.cpub-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
|
406
|
+
/* `.cpub-form-input/-textarea/-field/-row/-label` come from the global theme
|
|
407
|
+
(forms.css) — shared with the extracted ContestStagesEditor. */
|
|
413
408
|
|
|
414
409
|
.cpub-prize-card { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
|
|
415
410
|
.cpub-prize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
|
@@ -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
|
+
});
|
package/utils/contestStages.ts
CHANGED
|
@@ -49,6 +49,50 @@ export function currentStageId(c: StageSource): string | null {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// ─── Pure stage-array operations (used by ContestStagesEditor; unit-tested) ───
|
|
53
|
+
|
|
54
|
+
export function newStageId(): string {
|
|
55
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
56
|
+
return c?.randomUUID?.() ?? `s-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function blankStage(): ContestStage {
|
|
60
|
+
return { id: newStageId(), name: '', kind: 'custom' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The three standard stages seeded when an operator chooses to customise. */
|
|
64
|
+
export function seedStandardStages(c: { startDate?: string | null; endDate?: string | null; judgingEndDate?: string | null }): ContestStage[] {
|
|
65
|
+
const i = (d?: string | null): string | undefined => (d ? new Date(d).toISOString() : undefined);
|
|
66
|
+
return [
|
|
67
|
+
{ id: newStageId(), name: 'Submissions', kind: 'submission', startsAt: i(c.startDate), endsAt: i(c.endDate) },
|
|
68
|
+
{ id: newStageId(), name: 'Judging', kind: 'review', endsAt: i(c.judgingEndDate) ?? i(c.endDate) },
|
|
69
|
+
{ id: newStageId(), name: 'Results', kind: 'results' },
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function withStageAdded(stages: ContestStage[]): ContestStage[] {
|
|
74
|
+
return [...stages, blankStage()];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function withStageDuplicated(stages: ContestStage[], i: number): ContestStage[] {
|
|
78
|
+
const src = stages[i];
|
|
79
|
+
if (!src) return stages;
|
|
80
|
+
const copy: ContestStage = { ...src, id: newStageId(), name: `${src.name} (copy)`, core: false };
|
|
81
|
+
return [...stages.slice(0, i + 1), copy, ...stages.slice(i + 1)];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function withStageRemoved(stages: ContestStage[], i: number): ContestStage[] {
|
|
85
|
+
return stages.filter((_, idx) => idx !== i);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function withStageMoved(stages: ContestStage[], i: number, dir: -1 | 1): ContestStage[] {
|
|
89
|
+
const j = i + dir;
|
|
90
|
+
if (j < 0 || j >= stages.length) return stages;
|
|
91
|
+
const next = [...stages];
|
|
92
|
+
[next[i], next[j]] = [next[j]!, next[i]!];
|
|
93
|
+
return next;
|
|
94
|
+
}
|
|
95
|
+
|
|
52
96
|
/** FontAwesome icon (no `fa-solid` prefix) for each stage kind. */
|
|
53
97
|
export const STAGE_KIND_ICON: Record<ContestStage['kind'], string> = {
|
|
54
98
|
submission: 'fa-pen-to-square',
|