@commonpub/layer 0.83.2 → 0.85.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.
@@ -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
 
@@ -64,12 +64,19 @@ const editor = useContestEditor({
64
64
  const {
65
65
  title, slugInput, slugTouched, subheading, descriptionBlocks, rulesBlocks, prizesBlocks,
66
66
  description, descriptionFormat, rules, rulesFormat, prizesDescription, prizesDescriptionFormat,
67
- bannerUrl, coverImageUrl, startDate, endDate, judgingEndDate, communityVotingEnabled,
67
+ bannerUrl, coverImageUrl, bannerMeta, coverMeta, startDate, endDate, judgingEndDate, communityVotingEnabled,
68
68
  judgingVisibility, eligibleContentTypes, maxEntriesPerUser, visibility, visibleToRoles,
69
69
  showPrizes, prizes, criteria, stages, currentStageId,
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 });
@@ -93,6 +105,20 @@ const scheduleRoadmap = computed(() => roadmapFromSchedule(stages.value, { start
93
105
  provide(CONTEST_SCHEDULE_KEY, scheduleRoadmap);
94
106
  const { uploadFile } = useFileUpload();
95
107
  provide(UPLOAD_HANDLER_KEY, (file: File) => uploadFile<{ url: string; width?: number | null; height?: number | null }>(file, 'content'));
108
+ // Feed the Judges Showcase block a loader for the real scoring panel, so it can
109
+ // offer "Import panel judges" (name + account avatar). Empty in create mode (no
110
+ // slug yet). Maps the judges API row shape to the showcase's curated-row shape.
111
+ provide(CONTEST_JUDGES_KEY, async () => {
112
+ if (!slug.value) return [];
113
+ const rows = await $fetch<Array<{ userName: string; userAvatar?: string | null; userUsername: string; role: string }>>(
114
+ `/api/contests/${slug.value}/judges`,
115
+ );
116
+ return rows.map((r) => ({
117
+ name: r.userName,
118
+ avatarUrl: r.userAvatar ?? undefined,
119
+ link: r.userUsername ? `/u/${r.userUsername}` : undefined,
120
+ }));
121
+ });
96
122
 
97
123
  // Editor -> model write-back: each body's blocks flow into the composable's
98
124
  // descriptionBlocks/rulesBlocks/prizesBlocks refs (read by buildPayload).
@@ -230,6 +256,12 @@ function uploadMedia(event: Event, target: Ref<string>, purpose: string): void {
230
256
  function onBannerUpload(e: Event): void { uploadMedia(e, bannerUrl, 'banner'); }
231
257
  function onCoverUpload(e: Event): void { uploadMedia(e, coverImageUrl, 'cover'); }
232
258
  function onBannerUrl(): void { const url = window.prompt('Enter banner image URL:'); if (url) bannerUrl.value = url; }
259
+ // Non-destructive framing (P4): toggle the zoom/reposition panels per image; the
260
+ // inline previews mirror the public hero via the shared imageFramingStyle util.
261
+ const showBannerAdjust = ref(false);
262
+ const showCoverAdjust = ref(false);
263
+ const bannerPreviewStyle = computed(() => imageFramingStyle(bannerMeta.value));
264
+ const coverPreviewStyle = computed(() => imageFramingStyle(coverMeta.value));
233
265
 
234
266
  // --- Right-rail collapsible sections ---
235
267
  const openSections = ref<Record<string, boolean>>({
@@ -240,15 +272,9 @@ function toggleSection(key: string): void {
240
272
  openSections.value[key] = !openSections.value[key];
241
273
  }
242
274
 
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
275
  const deleting = ref(false);
249
276
 
250
- // Hydrate the form model when the contest loads (edit), and pre-fill each review
251
- // stage's advancement cut from its persisted advanceCount.
277
+ // Hydrate the form model when the contest loads (edit).
252
278
  watch(contest, (c) => {
253
279
  if (!c) return;
254
280
  // Never clobber unsaved edits with a refetch (e.g. an autosave rename swaps the
@@ -256,12 +282,21 @@ watch(contest, (c) => {
256
282
  if (formDirty.value) return;
257
283
  editor.hydrate(c as ContestEditorSource);
258
284
  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
285
  }, { immediate: true });
264
286
 
287
+ // Create mode: seed the standard starter template (a Proposals stage with a form +
288
+ // rules agreement, a Judging stage, a Results stage, a default rubric, and starter
289
+ // Overview/Rules copy) so a new contest doesn't start blank. Flag-adaptive: proposal
290
+ // mode + the agreement field only seed where those builder features are on; else it
291
+ // degrades to an attach-mode entry stage. Runs once on mount (client-only editor, so
292
+ // the feature flags are already SSR-primed). reseedBodies() pushes the seeded body
293
+ // blocks into the hoisted block editors.
294
+ onMounted(() => {
295
+ if (props.mode !== 'create') return;
296
+ editor.applyTemplate(standardContestTemplate({ proposals: proposalsEnabled.value, pii: piiEnabled.value }));
297
+ reseedBodies();
298
+ });
299
+
265
300
  async function handleDelete(): Promise<void> {
266
301
  if (!confirm('Permanently delete this contest? All entries, judges, and reviewers are removed. This cannot be undone.')) return;
267
302
  deleting.value = true;
@@ -335,55 +370,10 @@ onUnmounted(() => {
335
370
  document.removeEventListener('keydown', onStatusDocKey);
336
371
  });
337
372
 
338
- // Advancement cuts operate on the PERSISTED stages (contest.value), not the
339
- // editable `stages` ref, since they act on real entries.
373
+ // Advancement (ContestAdvancementPanel, in the Stages tab) operates on the
374
+ // PERSISTED review stages (contest.value), not the editable `stages` ref, since it
375
+ // acts on real entries.
340
376
  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
377
  </script>
388
378
 
389
379
  <template>
@@ -477,8 +467,9 @@ async function advanceStage(stageId: string): Promise<void> {
477
467
  </header>
478
468
 
479
469
  <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">
470
+ <!-- LEFT: block palette — inserts into the currently-active body. Hidden on
471
+ the Stages tab (a form, not a block body), giving the editor more room. -->
472
+ <aside v-show="activeTab !== 'stages'" class="cpub-ce-library" aria-label="Block palette">
482
473
  <EditorBlocks :groups="contestBlockGroups" :block-editor="activeBodyEditor" />
483
474
  </aside>
484
475
 
@@ -496,7 +487,7 @@ async function advanceStage(stageId: string): Promise<void> {
496
487
  <template #overview-lead>
497
488
  <div class="cpub-ce-media">
498
489
  <div class="cpub-ce-banner" :class="{ 'has-image': !!bannerUrl }">
499
- <img v-if="bannerUrl" :src="bannerUrl" alt="Contest banner" class="cpub-ce-banner-img" />
490
+ <img v-if="bannerUrl" :src="bannerUrl" alt="Contest banner" class="cpub-ce-banner-img" :style="bannerPreviewStyle" />
500
491
  <div v-else class="cpub-ce-media-placeholder">
501
492
  <i class="fa-regular fa-image"></i>
502
493
  <span>Banner image</span>
@@ -507,12 +498,13 @@ async function advanceStage(stageId: string): Promise<void> {
507
498
  <input type="file" accept="image/*" class="cpub-sr-only" aria-label="Upload banner image" @change="onBannerUpload">
508
499
  </label>
509
500
  <button type="button" class="cpub-ce-media-btn" @click="onBannerUrl"><i class="fa-solid fa-link"></i> URL</button>
501
+ <button v-if="bannerUrl" type="button" class="cpub-ce-media-btn" :class="{ active: showBannerAdjust }" @click="showBannerAdjust = !showBannerAdjust"><i class="fa-solid fa-crop-simple"></i> Adjust</button>
510
502
  <button v-if="bannerUrl" type="button" class="cpub-ce-media-btn" @click="bannerUrl = ''"><i class="fa-solid fa-trash"></i> Remove</button>
511
503
  </div>
512
504
 
513
505
  <!-- Cover thumbnail, inset over the banner's lower-left (mirrors the public hero). -->
514
506
  <div class="cpub-ce-cover" :class="{ 'has-image': !!coverImageUrl }">
515
- <img v-if="coverImageUrl" :src="coverImageUrl" alt="Contest cover" class="cpub-ce-cover-img" />
507
+ <img v-if="coverImageUrl" :src="coverImageUrl" alt="Contest cover" class="cpub-ce-cover-img" :style="coverPreviewStyle" />
516
508
  <div v-else class="cpub-ce-media-placeholder cpub-ce-media-placeholder-sm">
517
509
  <i class="fa-regular fa-image"></i>
518
510
  <span>Cover</span>
@@ -522,19 +514,46 @@ async function advanceStage(stageId: string): Promise<void> {
522
514
  <i class="fa-solid fa-arrow-up-from-bracket"></i>
523
515
  <input type="file" accept="image/*" class="cpub-sr-only" aria-label="Upload cover image" @change="onCoverUpload">
524
516
  </label>
517
+ <button v-if="coverImageUrl" type="button" class="cpub-ce-media-btn cpub-ce-media-btn-icon" :class="{ active: showCoverAdjust }" title="Adjust cover framing" @click="showCoverAdjust = !showCoverAdjust"><i class="fa-solid fa-crop-simple"></i></button>
525
518
  <button v-if="coverImageUrl" type="button" class="cpub-ce-media-btn cpub-ce-media-btn-icon" title="Remove cover" @click="coverImageUrl = ''"><i class="fa-solid fa-trash"></i></button>
526
519
  </div>
527
520
  </div>
528
521
  </div>
529
- <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>
522
+
523
+ <!-- Non-destructive framing panels (P4) -->
524
+ <ContestBannerAdjust v-if="bannerUrl && showBannerAdjust" v-model="bannerMeta" :image-url="bannerUrl" aspect="4 / 1" label="Banner" class="cpub-ce-adjust" />
525
+ <ContestBannerAdjust v-if="coverImageUrl && showCoverAdjust" v-model="coverMeta" :image-url="coverImageUrl" aspect="4 / 3" label="Cover" class="cpub-ce-adjust" />
526
+
527
+ <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. Use Adjust to zoom and reposition without re-cropping.</p>
528
+ </div>
529
+ </template>
530
+
531
+ <!-- Stages tab: the public timeline + per-stage submission forms, plus the
532
+ edit-only Top-N/manual advancement (which acts on real entries). -->
533
+ <template #stages>
534
+ <div class="cpub-ce-stages-tab">
535
+ <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>
536
+ <ContestStagesEditor
537
+ v-model="stages"
538
+ v-model:current-stage-id="currentStageId"
539
+ :start-date="startDate"
540
+ :end-date="endDate"
541
+ :judging-end-date="judgingEndDate"
542
+ />
543
+ <ContestAdvancementPanel
544
+ v-if="mode === 'edit' && reviewStages.length"
545
+ :slug="slug"
546
+ :review-stages="reviewStages"
547
+ @advanced="refresh()"
548
+ />
530
549
  </div>
531
550
  </template>
532
551
  </ContestBodyCanvas>
533
- <p class="cpub-form-hint cpub-ce-body-hint">
552
+ <p v-if="activeTab !== 'stages'" class="cpub-form-hint cpub-ce-body-hint">
534
553
  The <strong>Overview</strong>, <strong>Rules</strong>, and <strong>Prizes</strong> bodies are blocks
535
554
  (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.
555
+ editors. Add blocks from the palette on the left. The <strong>Stages</strong> tab holds the timeline +
556
+ submission forms; judging, prizes, and access live in the settings rail. Legacy text converts to blocks on first edit.
538
557
  </p>
539
558
  </div>
540
559
 
@@ -567,51 +586,6 @@ async function advanceStage(stageId: string): Promise<void> {
567
586
  <p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
568
587
  </EditorSection>
569
588
 
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
589
  <EditorSection title="Entries" icon="fa-inbox" :open="openSections.entries" @toggle="toggleSection('entries')">
616
590
  <div class="cpub-form-field">
617
591
  <span class="cpub-form-label">Eligible content types</span>
@@ -863,22 +837,8 @@ async function advanceStage(stageId: string): Promise<void> {
863
837
  .cpub-prize-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; }
864
838
  .cpub-prize-remove:hover { color: var(--red); }
865
839
 
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; }
840
+ /* Stages tab (center) the form tab gets a little breathing room. */
841
+ .cpub-ce-stages-tab { display: flex; flex-direction: column; gap: 4px; }
882
842
 
883
843
  /* Danger zone */
884
844
  .cpub-danger-label { font-size: 13px; font-weight: 600; margin: 0 0 2px; color: var(--red); }
@@ -927,7 +887,9 @@ async function advanceStage(stageId: string): Promise<void> {
927
887
  .cpub-ce-media-btn:hover { background: var(--surface2); }
928
888
  .cpub-ce-media-btn.primary:hover { opacity: 0.9; background: var(--accent); }
929
889
  .cpub-ce-media-btn-icon { padding: 5px 7px; }
890
+ .cpub-ce-media-btn.active { background: var(--accent-bg); color: var(--accent); border-color: var(--accent); }
930
891
  .cpub-ce-media-hint { margin: 0; }
892
+ .cpub-ce-adjust { margin-top: 8px; padding: 10px; border: var(--border-width-default) solid var(--border); background: var(--surface2); }
931
893
  .cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
932
894
 
933
895
  /* --- Responsive: stack the rail under the body on narrow viewports --- */
@@ -16,6 +16,10 @@ const emit = defineEmits<{
16
16
 
17
17
  const c = computed(() => props.contest);
18
18
 
19
+ // Non-destructive banner framing (P4). null/absent ⇒ the legacy cover fit, so
20
+ // existing contests look identical until an organiser adjusts framing.
21
+ const bannerStyle = computed(() => imageFramingStyle(c.value?.bannerMeta ?? null));
22
+
19
23
  // Local wall-clock formatting (dates) and the live countdown are timezone- and
20
24
  // clock-dependent, so they would mismatch between the server's TZ and the viewer's
21
25
  // on hydration (and Vue won't rectify it in prod). Gate them on a client `mounted`
@@ -142,7 +146,7 @@ const entryCount = computed<number>(() => c.value?.entryCount ?? 0);
142
146
  <div class="cpub-hero">
143
147
  <!-- Slim banner band (constrained) — the hero image, not a tall block. -->
144
148
  <div v-if="c?.bannerUrl" class="cpub-hero-banner">
145
- <img :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" />
149
+ <img :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" :style="bannerStyle" />
146
150
  </div>
147
151
 
148
152
  <!-- Compact bar — title + status + meta + actions in one tight, clean band
@@ -20,6 +20,7 @@ const toast = useToast();
20
20
  const { extract: extractError } = useApiError();
21
21
 
22
22
  const template = computed(() => props.stage.submissionTemplate ?? []);
23
+ const instructions = computed(() => (props.stage.instructionsBlocks ?? []) as [string, Record<string, unknown>][]);
23
24
  const values = ref<Record<string, string>>({});
24
25
  watch(template, (t) => {
25
26
  const next: Record<string, string> = {};
@@ -59,6 +60,7 @@ async function submit(): Promise<void> {
59
60
  <h3 class="cpub-proposal-title"><i class="fa-solid fa-clipboard-list"></i> {{ stage.name }}: submit a proposal</h3>
60
61
  </div>
61
62
  <p v-if="stage.description" class="cpub-proposal-desc">{{ stage.description }}</p>
63
+ <BlocksBlockContentRenderer v-if="instructions.length" :blocks="instructions" class="cpub-prose cpub-md cpub-proposal-intro" />
62
64
  <p class="cpub-proposal-desc">Submitting creates a draft project you can develop for later rounds. You can edit it any time.</p>
63
65
 
64
66
  <ContestSubmissionField
@@ -84,5 +86,6 @@ async function submit(): Promise<void> {
84
86
  .cpub-proposal-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin: 0; }
85
87
  .cpub-proposal-title i { color: var(--accent); }
86
88
  .cpub-proposal-desc { font-size: 12px; color: var(--text-dim); margin: 0 0 12px; line-height: 1.6; }
89
+ .cpub-proposal-intro { margin: 0 0 12px; }
87
90
  .cpub-proposal-actions { display: flex; align-items: center; gap: 10px; }
88
91
  </style>
@@ -0,0 +1,207 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ContestStageCard — one stage row in the ContestStagesEditor list, extracted so
4
+ * the orchestrator stays a thin list + toolbar. Presentational + intent-emitting:
5
+ * it renders one stage's fields and emits granular changes (`patch`, `move`,
6
+ * `duplicate`, `remove`, `set-current`); the parent applies them with the pure
7
+ * array ops in utils/contestStages.ts. Flag-gated extras (submission mode, the
8
+ * submission-form builder) re-derive `useFeatures()` here rather than prop-drilling.
9
+ */
10
+ import type { ContestStage } from '@commonpub/schema';
11
+ import type { BlockTuple } from '@commonpub/editor';
12
+ import ContestStageTemplateEditor from './ContestStageTemplateEditor.vue';
13
+
14
+ const props = defineProps<{
15
+ stage: ContestStage;
16
+ index: number;
17
+ isCurrent: boolean;
18
+ isFirst: boolean;
19
+ isLast: boolean;
20
+ }>();
21
+
22
+ const emit = defineEmits<{
23
+ patch: [patch: Partial<ContestStage>];
24
+ move: [dir: -1 | 1];
25
+ duplicate: [];
26
+ remove: [];
27
+ 'set-current': [];
28
+ }>();
29
+
30
+ const KINDS: ContestStage['kind'][] = ['submission', 'review', 'interim', 'results', 'event', 'custom'];
31
+
32
+ const { features } = useFeatures();
33
+ const templatesEnabled = computed(() => features.value.contestStageSubmissions !== false);
34
+ const proposalsEnabled = computed(() => features.value.contestProposals === true);
35
+
36
+ function setField(patch: Partial<ContestStage>): void {
37
+ emit('patch', patch);
38
+ }
39
+ function advanceCountInput(e: Event): void {
40
+ const v = (e.target as HTMLInputElement).value;
41
+ setField({ advanceCount: v === '' ? undefined : Math.max(1, Math.round(Number(v))) });
42
+ }
43
+ // The template editor hands back the whole field array; mirror the stored shape
44
+ // (empty → undefined) so a cleared form drops the key entirely.
45
+ function onTemplateUpdate(template: ContestStage['submissionTemplate']): void {
46
+ setField({ submissionTemplate: template && template.length ? template : undefined });
47
+ }
48
+ // Same for the block intro (empty → drop the key).
49
+ function onInstructionsUpdate(blocks: BlockTuple[]): void {
50
+ setField({ instructionsBlocks: blocks.length ? (blocks as ContestStage['instructionsBlocks']) : undefined });
51
+ }
52
+ </script>
53
+
54
+ <template>
55
+ <li class="cpub-stage-row">
56
+ <div class="cpub-stage-row-head">
57
+ <span class="cpub-stage-num">{{ index + 1 }}</span>
58
+ <label class="cpub-stage-current" :title="isCurrent ? 'This is the current stage' : 'Mark as the current stage'">
59
+ <input
60
+ type="radio"
61
+ name="cpub-current-stage"
62
+ :checked="isCurrent"
63
+ @change="emit('set-current')"
64
+ />
65
+ <span>Current</span>
66
+ </label>
67
+ <div class="cpub-stage-row-actions">
68
+ <button type="button" class="cpub-stage-iconbtn" :disabled="isFirst" aria-label="Move up" @click="emit('move', -1)"><i class="fa-solid fa-arrow-up"></i></button>
69
+ <button type="button" class="cpub-stage-iconbtn" :disabled="isLast" aria-label="Move down" @click="emit('move', 1)"><i class="fa-solid fa-arrow-down"></i></button>
70
+ <button type="button" class="cpub-stage-iconbtn" aria-label="Duplicate stage" @click="emit('duplicate')"><i class="fa-solid fa-clone"></i></button>
71
+ <button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove stage" @click="emit('remove')"><i class="fa-solid fa-xmark"></i></button>
72
+ </div>
73
+ </div>
74
+
75
+ <div class="cpub-form-row">
76
+ <div class="cpub-form-field" style="flex: 2;">
77
+ <label :for="`stage-name-${index}`" class="cpub-form-label">Stage name</label>
78
+ <input
79
+ :id="`stage-name-${index}`"
80
+ :value="stage.name"
81
+ type="text"
82
+ class="cpub-form-input"
83
+ placeholder="e.g. Proposals Open"
84
+ @input="setField({ name: ($event.target as HTMLInputElement).value })"
85
+ />
86
+ </div>
87
+ <div class="cpub-form-field" style="flex: 1;">
88
+ <label :for="`stage-type-${index}`" class="cpub-form-label">Type</label>
89
+ <select
90
+ :id="`stage-type-${index}`"
91
+ :value="stage.kind"
92
+ class="cpub-form-input"
93
+ @change="setField({ kind: ($event.target as HTMLSelectElement).value as ContestStage['kind'] })"
94
+ >
95
+ <option v-for="k in KINDS" :key="k" :value="k">{{ STAGE_KIND_LABEL[k] }}</option>
96
+ </select>
97
+ </div>
98
+ </div>
99
+
100
+ <p class="cpub-stage-kind-help"><i class="fa-solid fa-circle-info"></i> {{ STAGE_KIND_HELP[stage.kind] }}</p>
101
+
102
+ <div class="cpub-form-row">
103
+ <CpubDateTimeField
104
+ label="Starts"
105
+ :model-value="stage.startsAt"
106
+ :max="stage.endsAt"
107
+ @update:model-value="setField({ startsAt: $event })"
108
+ />
109
+ <CpubDateTimeField
110
+ label="Ends (countdown target)"
111
+ :model-value="stage.endsAt"
112
+ :min="stage.startsAt"
113
+ @update:model-value="setField({ endsAt: $event })"
114
+ />
115
+ </div>
116
+
117
+ <div class="cpub-form-field">
118
+ <label :for="`stage-desc-${index}`" class="cpub-form-label">Description (optional)</label>
119
+ <input
120
+ :id="`stage-desc-${index}`"
121
+ :value="stage.description ?? ''"
122
+ type="text"
123
+ class="cpub-form-input"
124
+ placeholder="What happens, or what to submit/refine, this stage"
125
+ @input="setField({ description: ($event.target as HTMLInputElement).value || undefined })"
126
+ />
127
+ </div>
128
+
129
+ <!-- Per-round config (review stages): how many advance + the rubric -->
130
+ <div v-if="stage.kind === 'review'" class="cpub-stage-criteria">
131
+ <div class="cpub-form-field" style="margin-bottom: 10px;">
132
+ <label :for="`stage-advance-${index}`" class="cpub-form-label">Advance the top N to the next stage</label>
133
+ <input :id="`stage-advance-${index}`" :value="stage.advanceCount ?? ''" type="number" min="1" class="cpub-form-input cpub-stage-advn" placeholder="e.g. 50, leave blank to decide at advance time" @input="advanceCountInput($event)" />
134
+ </div>
135
+ <p class="cpub-form-hint" style="margin: 4px 0;">Optional, leave empty to use the contest’s default criteria. Set per-round criteria for multi-round contests (e.g. judge proposals on Feasibility, prototypes on Deployment readiness).</p>
136
+ <ContestCriteriaEditor
137
+ :model-value="(stage.criteria ?? [])"
138
+ label="Judging criteria, this round"
139
+ :show-total="false"
140
+ @update:model-value="setField({ criteria: ($event as ContestStage['criteria']) })"
141
+ />
142
+ </div>
143
+
144
+ <!-- Submission mode (Phase 4): attach an existing project, or collect a
145
+ form-first proposal that seeds a draft placeholder project. -->
146
+ <div v-if="stage.kind === 'submission' && proposalsEnabled" class="cpub-form-field">
147
+ <label :for="`stage-mode-${index}`" class="cpub-form-label">How entrants submit</label>
148
+ <select
149
+ :id="`stage-mode-${index}`"
150
+ :value="stage.submissionMode ?? 'attach'"
151
+ class="cpub-form-input"
152
+ @change="setField({ submissionMode: (($event.target as HTMLSelectElement).value as 'attach' | 'proposal') })"
153
+ >
154
+ <option value="attach">Attach an existing published project</option>
155
+ <option value="proposal">Proposal form (creates a draft project)</option>
156
+ </select>
157
+ <p class="cpub-form-hint" style="margin: 4px 0;">Proposal mode lets entrants apply with just this form. The server creates a draft project they develop for later rounds.</p>
158
+ </div>
159
+
160
+ <!-- Per-stage submission template (submission stages): the artifact fields
161
+ entrants fill for THIS stage (proposal vs prototype). -->
162
+ <ContestStageTemplateEditor
163
+ v-if="stage.kind === 'submission' && templatesEnabled"
164
+ :template="stage.submissionTemplate ?? []"
165
+ :instructions="(stage.instructionsBlocks as BlockTuple[] | undefined)"
166
+ @update:template="onTemplateUpdate"
167
+ @update:instructions="onInstructionsUpdate"
168
+ />
169
+
170
+ <div v-if="stage.kind === 'event'" class="cpub-form-row">
171
+ <div class="cpub-form-field">
172
+ <label :for="`stage-location-${index}`" class="cpub-form-label">Location</label>
173
+ <input :id="`stage-location-${index}`" :value="stage.location ?? ''" type="text" class="cpub-form-input" placeholder="e.g. Washington, D.C." @input="setField({ location: ($event.target as HTMLInputElement).value || undefined })" />
174
+ </div>
175
+ <div class="cpub-form-field">
176
+ <label :for="`stage-url-${index}`" class="cpub-form-label">Link</label>
177
+ <input :id="`stage-url-${index}`" :value="stage.url ?? ''" type="url" class="cpub-form-input" placeholder="https://…" @input="setField({ url: ($event.target as HTMLInputElement).value || undefined })" />
178
+ </div>
179
+ </div>
180
+ </li>
181
+ </template>
182
+
183
+ <style scoped>
184
+ /* Form controls + stage-row styles travel with this extracted markup (scoped CSS
185
+ doesn't cross component boundaries; the global theme only ships
186
+ .cpub-form-label/.cpub-form-hint/.cpub-btn). */
187
+ .cpub-form-field { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-3); }
188
+ .cpub-form-field:last-child { margin-bottom: 0; }
189
+ .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); }
190
+ .cpub-form-input:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
191
+ .cpub-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-3); }
192
+
193
+ .cpub-stage-row { border: var(--border-width-default) solid var(--border); background: var(--surface2); padding: 12px; }
194
+ .cpub-stage-row-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
195
+ .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); }
196
+ .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; }
197
+ .cpub-stage-current input { width: 13px; height: 13px; }
198
+ .cpub-stage-row-actions { margin-left: auto; display: flex; gap: 4px; }
199
+ .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; }
200
+ .cpub-stage-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
201
+ .cpub-stage-iconbtn:disabled { opacity: .4; cursor: not-allowed; }
202
+ .cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
203
+ .cpub-stage-kind-help { font-size: 11px; color: var(--text-faint); line-height: 1.5; margin: 0 0 4px; display: flex; gap: 6px; }
204
+ .cpub-stage-kind-help i { color: var(--accent); margin-top: 2px; flex-shrink: 0; }
205
+ .cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
206
+ .cpub-stage-advn { max-width: 320px; }
207
+ </style>
@@ -28,6 +28,7 @@ const toast = useToast();
28
28
  const { extract: extractError } = useApiError();
29
29
 
30
30
  const template = computed(() => props.stage.submissionTemplate ?? []);
31
+ const instructions = computed(() => (props.stage.instructionsBlocks ?? []) as [string, Record<string, unknown>][]);
31
32
  // Eliminated entries are out of later rounds; don't offer the form for them.
32
33
  const eligibleEntries = computed(() => props.entries.filter((e) => !e.eliminated));
33
34
  const selectedEntryId = ref<string>('');
@@ -94,6 +95,7 @@ function submittedAtLabel(iso: string): string {
94
95
  </span>
95
96
  </div>
96
97
  <p v-if="stage.description" class="cpub-stagesub-desc">{{ stage.description }}</p>
98
+ <BlocksBlockContentRenderer v-if="instructions.length" :blocks="instructions" class="cpub-prose cpub-md cpub-stagesub-intro" />
97
99
 
98
100
  <div v-if="eligibleEntries.length > 1" class="cpub-stagesub-field">
99
101
  <label class="cpub-stagesub-label" for="cpub-stagesub-entry">Entry</label>
@@ -134,6 +136,7 @@ function submittedAtLabel(iso: string): string {
134
136
  .cpub-stagesub-done { color: var(--green); border-color: var(--green-border); background: var(--green-bg); }
135
137
  .cpub-stagesub-todo { color: var(--text-dim); border-color: var(--border2); background: var(--surface2); }
136
138
  .cpub-stagesub-desc { font-size: 12px; color: var(--text-dim); margin: 0 0 12px; line-height: 1.6; }
139
+ .cpub-stagesub-intro { margin: 0 0 12px; }
137
140
  .cpub-stagesub-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
138
141
  .cpub-stagesub-label { font-size: 11px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-dim); }
139
142
  .cpub-stagesub-req { color: var(--red); }