@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.
@@ -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
- type BodyTab = 'overview' | 'rules' | 'prizes';
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
- <!-- Overview-only lead (inline banner + cover); the parent fills the slot. -->
106
- <div v-if="activeTab === 'overview' && mode === 'write'" class="cpub-cbc-lead">
107
- <slot name="overview-lead" />
108
- </div>
109
- <!-- One canvas, keyed by tab so each body gets a clean canvas instance; the
110
- underlying block state persists in the parent's hoisted editors. -->
111
- <BlockCanvas v-show="mode === 'write'" :key="activeTab" :block-editor="editor" :block-types="groups" />
112
- <div v-if="mode === 'preview'" class="cpub-cbc-preview">
113
- <BlocksBlockContentRenderer v-if="previewBlocks.length" :blocks="previewBlocks" class="cpub-prose cpub-md" />
114
- <p v-else class="cpub-cbc-empty">Nothing to preview yet. Switch to Write and add some blocks.</p>
115
- </div>
116
- <pre v-else-if="mode === 'code'" class="cpub-cbc-code" aria-label="Block content as JSON"><code>{{ codeJson }}</code></pre>
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
- const activeBodyEditor = computed(() => ({ overview: overviewEditor, rules: rulesEditor, prizes: prizesEditor })[activeTab.value] ?? overviewEditor);
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), and pre-fill each review
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 cuts operate on the PERSISTED stages (contest.value), not the
339
- // editable `stages` ref, since they act on real entries.
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
- <aside class="cpub-ce-library" aria-label="Block palette">
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, judging, and the rest live in the settings rail.
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
- /* Advancement (edit-only, inside the Stages section) */
867
- .cpub-advance-section { margin-top: 16px; padding-top: 12px; border-top: var(--border-width-default) solid var(--border2); }
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); }