@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.
- package/components/contest/ContestAdvancementPanel.vue +138 -0
- package/components/contest/ContestBannerAdjust.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +23 -14
- package/components/contest/ContestEditor.vue +94 -132
- package/components/contest/ContestHero.vue +5 -1
- package/components/contest/ContestProposalForm.vue +3 -0
- package/components/contest/ContestStageCard.vue +207 -0
- package/components/contest/ContestStageSubmission.vue +3 -0
- package/components/contest/ContestStageTemplateEditor.vue +374 -0
- package/components/contest/ContestStagesEditor.vue +25 -325
- package/components/contest/blocks/JudgesShowcaseBlock.vue +113 -6
- package/composables/useContestEditor.ts +40 -4
- package/package.json +9 -9
- package/pages/contests/[slug]/index.vue +4 -1
- package/pages/contests/index.vue +1 -0
- package/utils/contestBlocks.ts +10 -0
- package/utils/contestBody.ts +3 -3
- package/utils/contestImage.ts +35 -0
- package/utils/contestStages.ts +80 -51
- package/utils/contestSubmissionTemplates.ts +165 -0
- package/utils/contestTemplates.ts +119 -0
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
339
|
-
// editable `stages` ref, since
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/*
|
|
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; }
|
|
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); }
|