@commonpub/layer 0.83.2 → 0.84.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/ContestAdvancementPanel.vue +138 -0
- package/components/contest/ContestBodyCanvas.vue +23 -14
- package/components/contest/ContestEditor.vue +61 -128
- package/components/contest/ContestStageCard.vue +200 -0
- package/components/contest/ContestStageTemplateEditor.vue +191 -0
- package/components/contest/ContestStagesEditor.vue +25 -325
- package/composables/useContestEditor.ts +26 -1
- package/package.json +7 -7
- package/utils/contestStages.ts +80 -51
- package/utils/contestTemplates.ts +116 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContestAdvancementPanel — the edit-only Top-N / manual cohort cut, extracted from
|
|
4
|
+
* ContestEditor. Crucially it operates on the PERSISTED review stages + REAL entries
|
|
5
|
+
* (not the editable `stages` model), so it self-fetches entries and takes the
|
|
6
|
+
* persisted review stages as a prop; the parent passes `contest.value.stages`
|
|
7
|
+
* filtered to review. Emits `advanced` after a cut so the parent refetches the
|
|
8
|
+
* contest. Mounted inside the Stages tab, below the stage editor.
|
|
9
|
+
*/
|
|
10
|
+
import type { ContestStage } from '@commonpub/schema';
|
|
11
|
+
|
|
12
|
+
type ReviewStage = Pick<ContestStage, 'id' | 'name' | 'advanceCount'>;
|
|
13
|
+
interface EntryLite { id: string; contentTitle: string; score?: number | null; eliminated?: boolean }
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
slug: string;
|
|
17
|
+
reviewStages: ReviewStage[];
|
|
18
|
+
}>();
|
|
19
|
+
const emit = defineEmits<{ advanced: [] }>();
|
|
20
|
+
|
|
21
|
+
const toast = useToast();
|
|
22
|
+
const { extract: extractError } = useApiError();
|
|
23
|
+
|
|
24
|
+
const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: EntryLite[] }>(
|
|
25
|
+
() => `/api/contests/${props.slug}/entries`,
|
|
26
|
+
);
|
|
27
|
+
const eligibleEntries = computed(() => (entriesData.value?.items ?? []).filter((e) => !e.eliminated));
|
|
28
|
+
|
|
29
|
+
const advancing = ref<string | null>(null);
|
|
30
|
+
const advanceN = ref<Record<string, number>>({});
|
|
31
|
+
const advanceMode = ref<Record<string, 'topN' | 'manual'>>({});
|
|
32
|
+
const manualPick = ref<Record<string, string[]>>({});
|
|
33
|
+
|
|
34
|
+
function toggleManual(stageId: string, entryId: string): void {
|
|
35
|
+
const cur = manualPick.value[stageId] ?? [];
|
|
36
|
+
manualPick.value[stageId] = cur.includes(entryId) ? cur.filter((x) => x !== entryId) : [...cur, entryId];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function postAdvance(stageId: string, body: Record<string, unknown>): Promise<void> {
|
|
40
|
+
advancing.value = stageId;
|
|
41
|
+
try {
|
|
42
|
+
const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(
|
|
43
|
+
`/api/contests/${props.slug}/advance`,
|
|
44
|
+
{ method: 'POST', body },
|
|
45
|
+
);
|
|
46
|
+
toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
|
|
47
|
+
await refreshEntries();
|
|
48
|
+
emit('advanced');
|
|
49
|
+
} catch (err: unknown) {
|
|
50
|
+
toast.error(extractError(err));
|
|
51
|
+
} finally {
|
|
52
|
+
advancing.value = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function advanceStage(stageId: string): Promise<void> {
|
|
57
|
+
const topN = advanceN.value[stageId];
|
|
58
|
+
if (!topN || topN < 1) { toast.error('Enter how many entries advance.'); return; }
|
|
59
|
+
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;
|
|
60
|
+
await postAdvance(stageId, { reviewStageId: stageId, mode: 'topN', topN });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function advanceStageManual(stageId: string): Promise<void> {
|
|
64
|
+
const ids = manualPick.value[stageId] ?? [];
|
|
65
|
+
if (!ids.length) { toast.error('Select at least one entry to advance.'); return; }
|
|
66
|
+
if (!confirm(`Advance the ${ids.length} selected ${ids.length === 1 ? 'entry' : 'entries'}? The rest of the cohort is marked "not advanced" and drops out of later judging + final results.`)) return;
|
|
67
|
+
await postAdvance(stageId, { reviewStageId: stageId, mode: 'manual', advancedEntryIds: ids });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Prefill each review stage's Top-N from its persisted advanceCount, when present.
|
|
71
|
+
watch(() => props.reviewStages, (stages) => {
|
|
72
|
+
for (const s of stages) {
|
|
73
|
+
if (typeof s.advanceCount === 'number' && advanceN.value[s.id] === undefined) advanceN.value[s.id] = s.advanceCount;
|
|
74
|
+
}
|
|
75
|
+
}, { immediate: true });
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<div v-if="reviewStages.length" class="cpub-advance-section">
|
|
80
|
+
<h3 class="cpub-form-subtitle"><i class="fa-solid fa-arrow-up-right-dots"></i> Advancement</h3>
|
|
81
|
+
<p class="cpub-form-hint">After judging a review stage, advance the top entries to the next stage. Entries below the cut are marked "not advanced". Re-running re-computes the cut. (Save any stage changes above first.)</p>
|
|
82
|
+
<div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-block">
|
|
83
|
+
<div class="cpub-advance-row">
|
|
84
|
+
<span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
|
|
85
|
+
<div class="cpub-advance-mode">
|
|
86
|
+
<label class="cpub-form-check"><input type="radio" :name="`mode-${rs.id}`" :checked="(advanceMode[rs.id] ?? 'topN') === 'topN'" @change="advanceMode[rs.id] = 'topN'" /> <span>Top N</span></label>
|
|
87
|
+
<label class="cpub-form-check"><input type="radio" :name="`mode-${rs.id}`" :checked="advanceMode[rs.id] === 'manual'" @change="advanceMode[rs.id] = 'manual'" /> <span>Pick manually</span></label>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<div v-if="(advanceMode[rs.id] ?? 'topN') === 'topN'" class="cpub-advance-ctl">
|
|
91
|
+
<label class="cpub-form-label" :for="`adv-${rs.id}`">Advance top</label>
|
|
92
|
+
<input :id="`adv-${rs.id}`" v-model.number="advanceN[rs.id]" type="number" min="1" class="cpub-form-input cpub-advance-n" placeholder="50" />
|
|
93
|
+
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id" @click="advanceStage(rs.id)">
|
|
94
|
+
<i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : 'Advance' }}
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
<div v-else class="cpub-advance-manual">
|
|
98
|
+
<p v-if="!eligibleEntries.length" class="cpub-form-hint" style="margin: 0;">No entries in the current cohort to pick from yet.</p>
|
|
99
|
+
<template v-else>
|
|
100
|
+
<label v-for="e in eligibleEntries" :key="e.id" class="cpub-advance-pick">
|
|
101
|
+
<input type="checkbox" :checked="(manualPick[rs.id] ?? []).includes(e.id)" @change="toggleManual(rs.id, e.id)" />
|
|
102
|
+
<span class="cpub-advance-pick-title">{{ e.contentTitle }}</span>
|
|
103
|
+
<span v-if="e.score != null" class="cpub-advance-pick-score">{{ e.score }}</span>
|
|
104
|
+
</label>
|
|
105
|
+
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id || !(manualPick[rs.id] ?? []).length" @click="advanceStageManual(rs.id)">
|
|
106
|
+
<i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : `Advance ${(manualPick[rs.id] ?? []).length} selected` }}
|
|
107
|
+
</button>
|
|
108
|
+
</template>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</template>
|
|
113
|
+
|
|
114
|
+
<style scoped>
|
|
115
|
+
/* Form-control + advancement styles travel with the markup (scoped CSS is per
|
|
116
|
+
component; the global theme only ships .cpub-form-label/-hint/-btn). */
|
|
117
|
+
.cpub-form-input { width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans); }
|
|
118
|
+
.cpub-form-input:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
|
|
119
|
+
.cpub-form-subtitle { font-size: 12px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; margin: 0 0 8px; }
|
|
120
|
+
.cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
|
121
|
+
.cpub-form-check input { width: 14px; height: 14px; flex-shrink: 0; }
|
|
122
|
+
|
|
123
|
+
.cpub-advance-section { margin-top: 20px; padding-top: 16px; border-top: var(--border-width-default) solid var(--border2); }
|
|
124
|
+
.cpub-advance-block { padding: 12px 0; border-top: var(--border-width-default) solid var(--border); }
|
|
125
|
+
.cpub-advance-block:first-of-type { border-top: 0; }
|
|
126
|
+
.cpub-advance-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
127
|
+
.cpub-advance-name { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
|
|
128
|
+
.cpub-advance-name i { color: var(--accent); font-size: 11px; }
|
|
129
|
+
.cpub-advance-mode { display: inline-flex; gap: 12px; }
|
|
130
|
+
.cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; margin-top: 10px; }
|
|
131
|
+
.cpub-advance-ctl .cpub-form-label { margin: 0; }
|
|
132
|
+
.cpub-advance-n { width: 80px; }
|
|
133
|
+
.cpub-advance-manual { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
|
|
134
|
+
.cpub-advance-pick { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); padding: 4px 8px; border: var(--border-width-default) solid var(--border); background: var(--surface2); cursor: pointer; }
|
|
135
|
+
.cpub-advance-pick-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
136
|
+
.cpub-advance-pick-score { font-family: var(--font-mono); font-size: 11px; color: var(--accent); flex-shrink: 0; }
|
|
137
|
+
.cpub-advance-manual .cpub-btn { align-self: flex-start; margin-top: 6px; }
|
|
138
|
+
</style>
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { BlockCanvas, type BlockEditor, type BlockTypeGroup } from '@commonpub/editor/vue';
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// 'stages' is a FORM tab (not a block body): it renders the `#stages` slot and has
|
|
17
|
+
// no Write/Preview/Code mode. The three block tabs share one BlockCanvas.
|
|
18
|
+
type BodyTab = 'overview' | 'rules' | 'prizes' | 'stages';
|
|
17
19
|
type BodyMode = 'write' | 'preview' | 'code';
|
|
18
20
|
|
|
19
21
|
const props = defineProps<{
|
|
@@ -32,7 +34,10 @@ const TABS: { key: BodyTab; label: string; icon: string }[] = [
|
|
|
32
34
|
{ key: 'overview', label: 'Overview', icon: 'fa-circle-info' },
|
|
33
35
|
{ key: 'rules', label: 'Rules', icon: 'fa-file-lines' },
|
|
34
36
|
{ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' },
|
|
37
|
+
{ key: 'stages', label: 'Stages', icon: 'fa-diagram-project' },
|
|
35
38
|
];
|
|
39
|
+
// Block tabs share the canvas + the Write/Preview/Code switch; 'stages' is a form.
|
|
40
|
+
const isBlockTab = computed(() => props.activeTab !== 'stages');
|
|
36
41
|
const MODES: { key: BodyMode; label: string; icon: string }[] = [
|
|
37
42
|
{ key: 'write', label: 'Write', icon: 'fa-pen' },
|
|
38
43
|
{ key: 'preview', label: 'Preview', icon: 'fa-eye' },
|
|
@@ -86,7 +91,7 @@ function onTabKey(e: KeyboardEvent, key: BodyTab): void {
|
|
|
86
91
|
<i class="fa-solid" :class="t.icon"></i> {{ t.label }}
|
|
87
92
|
</button>
|
|
88
93
|
</div>
|
|
89
|
-
<div class="cpub-cbc-mode" role="group" aria-label="Body view mode">
|
|
94
|
+
<div v-if="isBlockTab" class="cpub-cbc-mode" role="group" aria-label="Body view mode">
|
|
90
95
|
<button
|
|
91
96
|
v-for="m in MODES"
|
|
92
97
|
:key="m.key"
|
|
@@ -102,18 +107,22 @@ function onTabKey(e: KeyboardEvent, key: BodyTab): void {
|
|
|
102
107
|
</div>
|
|
103
108
|
|
|
104
109
|
<div class="cpub-cbc-panel" role="tabpanel" tabindex="0" :aria-labelledby="`cpub-cbc-tab-${activeTab}`">
|
|
105
|
-
<!--
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
<!-- Stages: a form tab (timeline + submission forms + advancement). -->
|
|
111
|
+
<slot v-if="activeTab === 'stages'" name="stages" />
|
|
112
|
+
<template v-else>
|
|
113
|
+
<!-- Overview-only lead (inline banner + cover); the parent fills the slot. -->
|
|
114
|
+
<div v-if="activeTab === 'overview' && mode === 'write'" class="cpub-cbc-lead">
|
|
115
|
+
<slot name="overview-lead" />
|
|
116
|
+
</div>
|
|
117
|
+
<!-- One canvas, keyed by tab so each body gets a clean canvas instance; the
|
|
118
|
+
underlying block state persists in the parent's hoisted editors. -->
|
|
119
|
+
<BlockCanvas v-show="mode === 'write'" :key="activeTab" :block-editor="editor" :block-types="groups" />
|
|
120
|
+
<div v-if="mode === 'preview'" class="cpub-cbc-preview">
|
|
121
|
+
<BlocksBlockContentRenderer v-if="previewBlocks.length" :blocks="previewBlocks" class="cpub-prose cpub-md" />
|
|
122
|
+
<p v-else class="cpub-cbc-empty">Nothing to preview yet. Switch to Write and add some blocks.</p>
|
|
123
|
+
</div>
|
|
124
|
+
<pre v-else-if="mode === 'code'" class="cpub-cbc-code" aria-label="Block content as JSON"><code>{{ codeJson }}</code></pre>
|
|
125
|
+
</template>
|
|
117
126
|
</div>
|
|
118
127
|
</div>
|
|
119
128
|
</template>
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { Ref } from 'vue';
|
|
16
16
|
import { EditorBlocks, EditorSection, useBlockEditor, BLOCK_COMPONENTS_KEY, UPLOAD_HANDLER_KEY, type BlockTypeGroup } from '@commonpub/editor/vue';
|
|
17
|
-
import type { Serialized, ContestEntryItem } from '@commonpub/server';
|
|
18
17
|
import type { ContestEditorSource } from '../../composables/useContestEditor';
|
|
19
18
|
import JudgesShowcaseBlock from './blocks/JudgesShowcaseBlock.vue';
|
|
20
19
|
import HtmlBlock from './blocks/HtmlBlock.vue';
|
|
@@ -25,6 +24,7 @@ import SponsorsBlock from './blocks/SponsorsBlock.vue';
|
|
|
25
24
|
import CompareColumnsBlock from './blocks/CompareColumnsBlock.vue';
|
|
26
25
|
import RoadmapBlock from './blocks/RoadmapBlock.vue';
|
|
27
26
|
import { CONTEST_SCHEDULE_KEY, roadmapFromSchedule } from '../../utils/contestBlocks';
|
|
27
|
+
import { standardContestTemplate } from '../../utils/contestTemplates';
|
|
28
28
|
|
|
29
29
|
const props = defineProps<{ mode: 'create' | 'edit' }>();
|
|
30
30
|
|
|
@@ -70,6 +70,13 @@ const {
|
|
|
70
70
|
saving, formDirty, dateError, canSubmit, slugify, toggleType, toggleRole, addPrize, removePrize, prizeLabel, save,
|
|
71
71
|
} = editor;
|
|
72
72
|
|
|
73
|
+
// Contest builder feature flags (drive the submission-form field types + the
|
|
74
|
+
// new-contest template's proposal-vs-attach choice). Reactive; hydrate from
|
|
75
|
+
// /api/features after mount.
|
|
76
|
+
const { features } = useFeatures();
|
|
77
|
+
const proposalsEnabled = computed(() => features.value.contestProposals === true);
|
|
78
|
+
const piiEnabled = computed(() => features.value.contestPii === true);
|
|
79
|
+
|
|
73
80
|
// --- Hoisted body block editors (the one refactor: a single left palette inserts
|
|
74
81
|
// into the CURRENTLY-active body, so the three useBlockEditor instances live here
|
|
75
82
|
// where the palette lives, not inside per-body components). ---
|
|
@@ -78,10 +85,15 @@ const overviewEditor = useBlockEditor(seedBodyBlocks(descriptionBlocks.value, de
|
|
|
78
85
|
const rulesEditor = useBlockEditor(seedBodyBlocks(rulesBlocks.value, rules.value, rulesFormat.value), blockDefaults);
|
|
79
86
|
const prizesEditor = useBlockEditor(seedBodyBlocks(prizesBlocks.value, prizesDescription.value, prizesDescriptionFormat.value), blockDefaults);
|
|
80
87
|
|
|
81
|
-
type BodyTab = 'overview' | 'rules' | 'prizes';
|
|
88
|
+
type BodyTab = 'overview' | 'rules' | 'prizes' | 'stages';
|
|
82
89
|
const activeTab = ref<BodyTab>('overview');
|
|
83
90
|
const bodyMode = ref<'write' | 'preview' | 'code'>('write');
|
|
84
|
-
|
|
91
|
+
// The Stages tab has no block editor; it falls back to overview (the palette is
|
|
92
|
+
// hidden there anyway, so nothing inserts into it).
|
|
93
|
+
const activeBodyEditor = computed(() => {
|
|
94
|
+
const map: Partial<Record<BodyTab, typeof overviewEditor>> = { overview: overviewEditor, rules: rulesEditor, prizes: prizesEditor };
|
|
95
|
+
return map[activeTab.value] ?? overviewEditor;
|
|
96
|
+
});
|
|
85
97
|
|
|
86
98
|
// Contest-specific edit block + image upload, provided once for all three bodies.
|
|
87
99
|
provide(BLOCK_COMPONENTS_KEY, { judgesShowcase: JudgesShowcaseBlock, html: HtmlBlock, criteriaBar: CriteriaBarBlock, table: TableBlock, tabs: TabsBlock, sponsors: SponsorsBlock, compareColumns: CompareColumnsBlock, roadmap: RoadmapBlock });
|
|
@@ -240,15 +252,9 @@ function toggleSection(key: string): void {
|
|
|
240
252
|
openSections.value[key] = !openSections.value[key];
|
|
241
253
|
}
|
|
242
254
|
|
|
243
|
-
// Edit-only advancement state (operates on real entries, not the editable model).
|
|
244
|
-
const advancing = ref<string | null>(null);
|
|
245
|
-
const advanceN = ref<Record<string, number>>({});
|
|
246
|
-
const advanceMode = ref<Record<string, 'topN' | 'manual'>>({});
|
|
247
|
-
const manualPick = ref<Record<string, string[]>>({});
|
|
248
255
|
const deleting = ref(false);
|
|
249
256
|
|
|
250
|
-
// Hydrate the form model when the contest loads (edit)
|
|
251
|
-
// stage's advancement cut from its persisted advanceCount.
|
|
257
|
+
// Hydrate the form model when the contest loads (edit).
|
|
252
258
|
watch(contest, (c) => {
|
|
253
259
|
if (!c) return;
|
|
254
260
|
// Never clobber unsaved edits with a refetch (e.g. an autosave rename swaps the
|
|
@@ -256,12 +262,21 @@ watch(contest, (c) => {
|
|
|
256
262
|
if (formDirty.value) return;
|
|
257
263
|
editor.hydrate(c as ContestEditorSource);
|
|
258
264
|
reseedBodies();
|
|
259
|
-
advanceN.value = {};
|
|
260
|
-
for (const s of stages.value) {
|
|
261
|
-
if (s.kind === 'review' && typeof s.advanceCount === 'number') advanceN.value[s.id] = s.advanceCount;
|
|
262
|
-
}
|
|
263
265
|
}, { immediate: true });
|
|
264
266
|
|
|
267
|
+
// Create mode: seed the standard starter template (a Proposals stage with a form +
|
|
268
|
+
// rules agreement, a Judging stage, a Results stage, a default rubric, and starter
|
|
269
|
+
// Overview/Rules copy) so a new contest doesn't start blank. Flag-adaptive: proposal
|
|
270
|
+
// mode + the agreement field only seed where those builder features are on; else it
|
|
271
|
+
// degrades to an attach-mode entry stage. Runs once on mount (client-only editor, so
|
|
272
|
+
// the feature flags are already SSR-primed). reseedBodies() pushes the seeded body
|
|
273
|
+
// blocks into the hoisted block editors.
|
|
274
|
+
onMounted(() => {
|
|
275
|
+
if (props.mode !== 'create') return;
|
|
276
|
+
editor.applyTemplate(standardContestTemplate({ proposals: proposalsEnabled.value, pii: piiEnabled.value }));
|
|
277
|
+
reseedBodies();
|
|
278
|
+
});
|
|
279
|
+
|
|
265
280
|
async function handleDelete(): Promise<void> {
|
|
266
281
|
if (!confirm('Permanently delete this contest? All entries, judges, and reviewers are removed. This cannot be undone.')) return;
|
|
267
282
|
deleting.value = true;
|
|
@@ -335,55 +350,10 @@ onUnmounted(() => {
|
|
|
335
350
|
document.removeEventListener('keydown', onStatusDocKey);
|
|
336
351
|
});
|
|
337
352
|
|
|
338
|
-
// Advancement
|
|
339
|
-
// editable `stages` ref, since
|
|
353
|
+
// Advancement (ContestAdvancementPanel, in the Stages tab) operates on the
|
|
354
|
+
// PERSISTED review stages (contest.value), not the editable `stages` ref, since it
|
|
355
|
+
// acts on real entries.
|
|
340
356
|
const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) => s.kind === 'review'));
|
|
341
|
-
const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: Serialized<ContestEntryItem>[] }>(
|
|
342
|
-
() => `/api/contests/${slug.value}/entries`,
|
|
343
|
-
{ immediate: props.mode === 'edit' },
|
|
344
|
-
);
|
|
345
|
-
const eligibleEntries = computed(() => (entriesData.value?.items ?? []).filter((e) => !e.eliminated));
|
|
346
|
-
|
|
347
|
-
function toggleManual(stageId: string, entryId: string): void {
|
|
348
|
-
const cur = manualPick.value[stageId] ?? [];
|
|
349
|
-
manualPick.value[stageId] = cur.includes(entryId) ? cur.filter((x) => x !== entryId) : [...cur, entryId];
|
|
350
|
-
}
|
|
351
|
-
async function advanceStageManual(stageId: string): Promise<void> {
|
|
352
|
-
const ids = manualPick.value[stageId] ?? [];
|
|
353
|
-
if (!ids.length) { toast.error('Select at least one entry to advance.'); return; }
|
|
354
|
-
if (!confirm(`Advance the ${ids.length} selected ${ids.length === 1 ? 'entry' : 'entries'}? The rest of the cohort is marked "not advanced" and drops out of later judging + final results.`)) return;
|
|
355
|
-
advancing.value = stageId;
|
|
356
|
-
try {
|
|
357
|
-
const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(`/api/contests/${slug.value}/advance`, {
|
|
358
|
-
method: 'POST',
|
|
359
|
-
body: { reviewStageId: stageId, mode: 'manual', advancedEntryIds: ids },
|
|
360
|
-
});
|
|
361
|
-
toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
|
|
362
|
-
await Promise.all([refresh(), refreshEntries()]);
|
|
363
|
-
} catch (err: unknown) {
|
|
364
|
-
toast.error(extractError(err));
|
|
365
|
-
} finally {
|
|
366
|
-
advancing.value = null;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
async function advanceStage(stageId: string): Promise<void> {
|
|
370
|
-
const topN = advanceN.value[stageId];
|
|
371
|
-
if (!topN || topN < 1) { toast.error('Enter how many entries advance.'); return; }
|
|
372
|
-
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;
|
|
373
|
-
advancing.value = stageId;
|
|
374
|
-
try {
|
|
375
|
-
const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(`/api/contests/${slug.value}/advance`, {
|
|
376
|
-
method: 'POST',
|
|
377
|
-
body: { reviewStageId: stageId, mode: 'topN', topN },
|
|
378
|
-
});
|
|
379
|
-
toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
|
|
380
|
-
await Promise.all([refresh(), refreshEntries()]);
|
|
381
|
-
} catch (err: unknown) {
|
|
382
|
-
toast.error(extractError(err));
|
|
383
|
-
} finally {
|
|
384
|
-
advancing.value = null;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
357
|
</script>
|
|
388
358
|
|
|
389
359
|
<template>
|
|
@@ -477,8 +447,9 @@ async function advanceStage(stageId: string): Promise<void> {
|
|
|
477
447
|
</header>
|
|
478
448
|
|
|
479
449
|
<div class="cpub-ce-shell">
|
|
480
|
-
<!-- LEFT: block palette — inserts into the currently-active body.
|
|
481
|
-
|
|
450
|
+
<!-- LEFT: block palette — inserts into the currently-active body. Hidden on
|
|
451
|
+
the Stages tab (a form, not a block body), giving the editor more room. -->
|
|
452
|
+
<aside v-show="activeTab !== 'stages'" class="cpub-ce-library" aria-label="Block palette">
|
|
482
453
|
<EditorBlocks :groups="contestBlockGroups" :block-editor="activeBodyEditor" />
|
|
483
454
|
</aside>
|
|
484
455
|
|
|
@@ -529,12 +500,33 @@ async function advanceStage(stageId: string): Promise<void> {
|
|
|
529
500
|
<p class="cpub-form-hint cpub-ce-media-hint">Banner is the wide hero (~4:1). Cover is the card thumbnail in listings (~4:3); it falls back to the banner if unset.</p>
|
|
530
501
|
</div>
|
|
531
502
|
</template>
|
|
503
|
+
|
|
504
|
+
<!-- Stages tab: the public timeline + per-stage submission forms, plus the
|
|
505
|
+
edit-only Top-N/manual advancement (which acts on real entries). -->
|
|
506
|
+
<template #stages>
|
|
507
|
+
<div class="cpub-ce-stages-tab">
|
|
508
|
+
<p class="cpub-form-hint">Stages are the public timeline (Submissions → Judging → Results, or your own rounds). Each <strong>Submissions</strong> stage carries a submission form; each <strong>Judging</strong> stage its own rubric + Top-N cut. The <strong>Status</strong> control (top bar) is what's actually open now; mark a stage <strong>Current</strong> to point judges + the countdown at it.</p>
|
|
509
|
+
<ContestStagesEditor
|
|
510
|
+
v-model="stages"
|
|
511
|
+
v-model:current-stage-id="currentStageId"
|
|
512
|
+
:start-date="startDate"
|
|
513
|
+
:end-date="endDate"
|
|
514
|
+
:judging-end-date="judgingEndDate"
|
|
515
|
+
/>
|
|
516
|
+
<ContestAdvancementPanel
|
|
517
|
+
v-if="mode === 'edit' && reviewStages.length"
|
|
518
|
+
:slug="slug"
|
|
519
|
+
:review-stages="reviewStages"
|
|
520
|
+
@advanced="refresh()"
|
|
521
|
+
/>
|
|
522
|
+
</div>
|
|
523
|
+
</template>
|
|
532
524
|
</ContestBodyCanvas>
|
|
533
|
-
<p class="cpub-form-hint cpub-ce-body-hint">
|
|
525
|
+
<p v-if="activeTab !== 'stages'" class="cpub-form-hint cpub-ce-body-hint">
|
|
534
526
|
The <strong>Overview</strong>, <strong>Rules</strong>, and <strong>Prizes</strong> bodies are blocks
|
|
535
527
|
(headings, lists, images, callouts, and the <strong>Judges Showcase</strong>), like the project and blog
|
|
536
|
-
editors. Add blocks from the palette on the left. Stages
|
|
537
|
-
Legacy text converts to blocks on first edit.
|
|
528
|
+
editors. Add blocks from the palette on the left. The <strong>Stages</strong> tab holds the timeline +
|
|
529
|
+
submission forms; judging, prizes, and access live in the settings rail. Legacy text converts to blocks on first edit.
|
|
538
530
|
</p>
|
|
539
531
|
</div>
|
|
540
532
|
|
|
@@ -567,51 +559,6 @@ async function advanceStage(stageId: string): Promise<void> {
|
|
|
567
559
|
<p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
|
|
568
560
|
</EditorSection>
|
|
569
561
|
|
|
570
|
-
<EditorSection title="Stages" icon="fa-diagram-project" :open="openSections.stages" @toggle="toggleSection('stages')">
|
|
571
|
-
<p class="cpub-form-hint">Optional. The standard flow (Submissions → Judging → Results) is derived from the schedule. Add custom stages for multi-round contests, proposal rounds, a Top-N selection, a build sprint, or a showcase event.</p>
|
|
572
|
-
<p class="cpub-form-hint">How the pieces fit: <strong>Stages</strong> are the public timeline. The <strong>Status</strong> control is what's actually open now. <strong>Advancement</strong> (below) runs each review round's Top-N cut. Mark a stage <strong>Current</strong> to point judges + the countdown at it.</p>
|
|
573
|
-
<ContestStagesEditor
|
|
574
|
-
v-model="stages"
|
|
575
|
-
v-model:current-stage-id="currentStageId"
|
|
576
|
-
:start-date="startDate"
|
|
577
|
-
:end-date="endDate"
|
|
578
|
-
:judging-end-date="judgingEndDate"
|
|
579
|
-
/>
|
|
580
|
-
<div v-if="mode === 'edit' && reviewStages.length" class="cpub-advance-section">
|
|
581
|
-
<h3 class="cpub-form-subtitle">Advancement</h3>
|
|
582
|
-
<p class="cpub-form-hint">After judging a review stage, advance the top entries to the next stage. Entries below the cut are marked "not advanced". Re-running re-computes the cut. (Save any stage changes above first.)</p>
|
|
583
|
-
<div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-block">
|
|
584
|
-
<div class="cpub-advance-row">
|
|
585
|
-
<span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
|
|
586
|
-
<div class="cpub-advance-mode">
|
|
587
|
-
<label class="cpub-form-check"><input type="radio" :name="`mode-${rs.id}`" :checked="(advanceMode[rs.id] ?? 'topN') === 'topN'" @change="advanceMode[rs.id] = 'topN'" /> <span>Top N</span></label>
|
|
588
|
-
<label class="cpub-form-check"><input type="radio" :name="`mode-${rs.id}`" :checked="advanceMode[rs.id] === 'manual'" @change="advanceMode[rs.id] = 'manual'" /> <span>Pick manually</span></label>
|
|
589
|
-
</div>
|
|
590
|
-
</div>
|
|
591
|
-
<div v-if="(advanceMode[rs.id] ?? 'topN') === 'topN'" class="cpub-advance-ctl">
|
|
592
|
-
<label class="cpub-form-label" :for="`adv-${rs.id}`">Advance top</label>
|
|
593
|
-
<input :id="`adv-${rs.id}`" v-model.number="advanceN[rs.id]" type="number" min="1" class="cpub-form-input cpub-advance-n" placeholder="50" />
|
|
594
|
-
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id" @click="advanceStage(rs.id)">
|
|
595
|
-
<i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : 'Advance' }}
|
|
596
|
-
</button>
|
|
597
|
-
</div>
|
|
598
|
-
<div v-else class="cpub-advance-manual">
|
|
599
|
-
<p v-if="!eligibleEntries.length" class="cpub-form-hint" style="margin: 0;">No entries in the current cohort to pick from yet.</p>
|
|
600
|
-
<template v-else>
|
|
601
|
-
<label v-for="e in eligibleEntries" :key="e.id" class="cpub-advance-pick">
|
|
602
|
-
<input type="checkbox" :checked="(manualPick[rs.id] ?? []).includes(e.id)" @change="toggleManual(rs.id, e.id)" />
|
|
603
|
-
<span class="cpub-advance-pick-title">{{ e.contentTitle }}</span>
|
|
604
|
-
<span v-if="e.score != null" class="cpub-advance-pick-score">{{ e.score }}</span>
|
|
605
|
-
</label>
|
|
606
|
-
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id || !(manualPick[rs.id] ?? []).length" @click="advanceStageManual(rs.id)">
|
|
607
|
-
<i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : `Advance ${(manualPick[rs.id] ?? []).length} selected` }}
|
|
608
|
-
</button>
|
|
609
|
-
</template>
|
|
610
|
-
</div>
|
|
611
|
-
</div>
|
|
612
|
-
</div>
|
|
613
|
-
</EditorSection>
|
|
614
|
-
|
|
615
562
|
<EditorSection title="Entries" icon="fa-inbox" :open="openSections.entries" @toggle="toggleSection('entries')">
|
|
616
563
|
<div class="cpub-form-field">
|
|
617
564
|
<span class="cpub-form-label">Eligible content types</span>
|
|
@@ -863,22 +810,8 @@ async function advanceStage(stageId: string): Promise<void> {
|
|
|
863
810
|
.cpub-prize-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; }
|
|
864
811
|
.cpub-prize-remove:hover { color: var(--red); }
|
|
865
812
|
|
|
866
|
-
/*
|
|
867
|
-
.cpub-
|
|
868
|
-
.cpub-advance-block { padding: 12px 0; border-top: var(--border-width-default) solid var(--border); }
|
|
869
|
-
.cpub-advance-block:first-of-type { border-top: 0; }
|
|
870
|
-
.cpub-advance-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
871
|
-
.cpub-advance-name { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
|
|
872
|
-
.cpub-advance-name i { color: var(--accent); font-size: 11px; }
|
|
873
|
-
.cpub-advance-mode { display: inline-flex; gap: 12px; }
|
|
874
|
-
.cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; margin-top: 10px; }
|
|
875
|
-
.cpub-advance-ctl .cpub-form-label { margin: 0; }
|
|
876
|
-
.cpub-advance-n { width: 80px; }
|
|
877
|
-
.cpub-advance-manual { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
|
|
878
|
-
.cpub-advance-pick { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); padding: 4px 8px; border: var(--border-width-default) solid var(--border); background: var(--surface2); cursor: pointer; }
|
|
879
|
-
.cpub-advance-pick-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
880
|
-
.cpub-advance-pick-score { font-family: var(--font-mono); font-size: 11px; color: var(--accent); flex-shrink: 0; }
|
|
881
|
-
.cpub-advance-manual .cpub-btn { align-self: flex-start; margin-top: 6px; }
|
|
813
|
+
/* Stages tab (center) — the form tab gets a little breathing room. */
|
|
814
|
+
.cpub-ce-stages-tab { display: flex; flex-direction: column; gap: 4px; }
|
|
882
815
|
|
|
883
816
|
/* Danger zone */
|
|
884
817
|
.cpub-danger-label { font-size: 13px; font-weight: 600; margin: 0 0 2px; color: var(--red); }
|