@commonpub/layer 0.84.0 → 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.
@@ -0,0 +1,121 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ContestBannerAdjust — non-destructive framing control for a contest banner or
4
+ * cover (P4). A live preview at the target aspect ratio + a zoom slider (0 = fit/
5
+ * contain) + drag-to-reposition. Writes a `ContestImageMeta` ({ zoom, x, y })
6
+ * v-model; the original image is never re-cropped. Render parity with the public
7
+ * hero is guaranteed by sharing `imageFramingStyle` (utils/contestImage.ts).
8
+ */
9
+ import type { ContestImageMeta } from '@commonpub/schema';
10
+ import { imageFramingStyle, defaultImageMeta } from '../../utils/contestImage';
11
+
12
+ defineProps<{
13
+ imageUrl: string;
14
+ /** CSS aspect-ratio for the preview box, e.g. '4 / 1' (banner), '4 / 3' (cover). */
15
+ aspect?: string;
16
+ label?: string;
17
+ }>();
18
+ const meta = defineModel<ContestImageMeta | null>({ default: null });
19
+
20
+ const ZOOM_MAX = 1.5;
21
+ const current = computed<ContestImageMeta>(() => meta.value ?? defaultImageMeta());
22
+ const framing = computed(() => imageFramingStyle(meta.value));
23
+
24
+ function patch(p: Partial<ContestImageMeta>): void {
25
+ meta.value = { ...current.value, ...p };
26
+ }
27
+ function onZoom(e: Event): void {
28
+ patch({ zoom: Number((e.target as HTMLInputElement).value) });
29
+ }
30
+ function reset(): void {
31
+ meta.value = null;
32
+ }
33
+
34
+ // ─── Drag to reposition (sets x/y as percent) ───
35
+ const boxRef = ref<HTMLElement | null>(null);
36
+ const dragging = ref(false);
37
+ let startX = 0;
38
+ let startY = 0;
39
+ let startPosX = 50;
40
+ let startPosY = 50;
41
+
42
+ function onPointerDown(e: PointerEvent): void {
43
+ if (!boxRef.value) return;
44
+ dragging.value = true;
45
+ startX = e.clientX;
46
+ startY = e.clientY;
47
+ startPosX = current.value.x;
48
+ startPosY = current.value.y;
49
+ document.addEventListener('pointermove', onPointerMove);
50
+ document.addEventListener('pointerup', onPointerUp);
51
+ }
52
+ function onPointerMove(e: PointerEvent): void {
53
+ if (!dragging.value || !boxRef.value) return;
54
+ const rect = boxRef.value.getBoundingClientRect();
55
+ // Drag right reveals the LEFT of the image (object-position decreases), so invert.
56
+ const dx = ((e.clientX - startX) / Math.max(1, rect.width)) * 100;
57
+ const dy = ((e.clientY - startY) / Math.max(1, rect.height)) * 100;
58
+ patch({ x: clamp(startPosX - dx), y: clamp(startPosY - dy) });
59
+ }
60
+ function onPointerUp(): void {
61
+ dragging.value = false;
62
+ document.removeEventListener('pointermove', onPointerMove);
63
+ document.removeEventListener('pointerup', onPointerUp);
64
+ }
65
+ function clamp(n: number): number {
66
+ return Math.max(0, Math.min(100, Math.round(n)));
67
+ }
68
+ onUnmounted(() => {
69
+ document.removeEventListener('pointermove', onPointerMove);
70
+ document.removeEventListener('pointerup', onPointerUp);
71
+ });
72
+
73
+ const zoomLabel = computed(() => (current.value.zoom <= 0 ? 'Fit' : `${Math.round(current.value.zoom * 100)}%`));
74
+ </script>
75
+
76
+ <template>
77
+ <div class="cpub-ba">
78
+ <div
79
+ ref="boxRef"
80
+ class="cpub-ba-box"
81
+ :class="{ 'cpub-ba-drag': dragging }"
82
+ :style="{ aspectRatio: aspect ?? '4 / 1' }"
83
+ @pointerdown="onPointerDown"
84
+ >
85
+ <img :src="imageUrl" :alt="label ?? 'Image preview'" class="cpub-ba-img" :style="framing" draggable="false" />
86
+ <span class="cpub-ba-hint"><i class="fa-solid fa-up-down-left-right"></i> Drag to reposition</span>
87
+ </div>
88
+ <div class="cpub-ba-controls">
89
+ <label class="cpub-ba-zoom">
90
+ <span class="cpub-ba-zoom-label">Zoom <strong>{{ zoomLabel }}</strong></span>
91
+ <input
92
+ type="range"
93
+ min="0"
94
+ :max="ZOOM_MAX"
95
+ step="0.05"
96
+ :value="current.zoom"
97
+ :aria-label="`${label ?? 'Image'} zoom (0 is fit)`"
98
+ @input="onZoom"
99
+ />
100
+ </label>
101
+ <button type="button" class="cpub-btn cpub-btn-sm cpub-ba-reset" :disabled="!meta" @click="reset">
102
+ <i class="fa-solid fa-rotate-left"></i> Reset
103
+ </button>
104
+ </div>
105
+ <p class="cpub-ba-help">Zoom 0 fits the whole image. Increase to fill and crop; drag the preview to choose what shows.</p>
106
+ </div>
107
+ </template>
108
+
109
+ <style scoped>
110
+ .cpub-ba { display: flex; flex-direction: column; gap: 8px; }
111
+ .cpub-ba-box { position: relative; width: 100%; overflow: hidden; border: var(--border-width-default) solid var(--border); background: var(--surface2); cursor: grab; touch-action: none; }
112
+ .cpub-ba-drag { cursor: grabbing; }
113
+ .cpub-ba-img { display: block; width: 100%; height: 100%; user-select: none; -webkit-user-drag: none; }
114
+ .cpub-ba-hint { position: absolute; left: 8px; bottom: 8px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text); background: var(--surface); border: var(--border-width-default) solid var(--border); padding: 3px 7px; display: inline-flex; align-items: center; gap: 5px; opacity: .85; pointer-events: none; }
115
+ .cpub-ba-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
116
+ .cpub-ba-zoom { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 180px; }
117
+ .cpub-ba-zoom-label { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-dim); white-space: nowrap; }
118
+ .cpub-ba-zoom input[type='range'] { flex: 1; accent-color: var(--accent); }
119
+ .cpub-ba-reset { flex-shrink: 0; }
120
+ .cpub-ba-help { margin: 0; font-size: 11px; color: var(--text-faint); line-height: 1.5; }
121
+ </style>
@@ -64,7 +64,7 @@ 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,
@@ -105,6 +105,20 @@ const scheduleRoadmap = computed(() => roadmapFromSchedule(stages.value, { start
105
105
  provide(CONTEST_SCHEDULE_KEY, scheduleRoadmap);
106
106
  const { uploadFile } = useFileUpload();
107
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
+ });
108
122
 
109
123
  // Editor -> model write-back: each body's blocks flow into the composable's
110
124
  // descriptionBlocks/rulesBlocks/prizesBlocks refs (read by buildPayload).
@@ -242,6 +256,12 @@ function uploadMedia(event: Event, target: Ref<string>, purpose: string): void {
242
256
  function onBannerUpload(e: Event): void { uploadMedia(e, bannerUrl, 'banner'); }
243
257
  function onCoverUpload(e: Event): void { uploadMedia(e, coverImageUrl, 'cover'); }
244
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));
245
265
 
246
266
  // --- Right-rail collapsible sections ---
247
267
  const openSections = ref<Record<string, boolean>>({
@@ -467,7 +487,7 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
467
487
  <template #overview-lead>
468
488
  <div class="cpub-ce-media">
469
489
  <div class="cpub-ce-banner" :class="{ 'has-image': !!bannerUrl }">
470
- <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" />
471
491
  <div v-else class="cpub-ce-media-placeholder">
472
492
  <i class="fa-regular fa-image"></i>
473
493
  <span>Banner image</span>
@@ -478,12 +498,13 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
478
498
  <input type="file" accept="image/*" class="cpub-sr-only" aria-label="Upload banner image" @change="onBannerUpload">
479
499
  </label>
480
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>
481
502
  <button v-if="bannerUrl" type="button" class="cpub-ce-media-btn" @click="bannerUrl = ''"><i class="fa-solid fa-trash"></i> Remove</button>
482
503
  </div>
483
504
 
484
505
  <!-- Cover thumbnail, inset over the banner's lower-left (mirrors the public hero). -->
485
506
  <div class="cpub-ce-cover" :class="{ 'has-image': !!coverImageUrl }">
486
- <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" />
487
508
  <div v-else class="cpub-ce-media-placeholder cpub-ce-media-placeholder-sm">
488
509
  <i class="fa-regular fa-image"></i>
489
510
  <span>Cover</span>
@@ -493,11 +514,17 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
493
514
  <i class="fa-solid fa-arrow-up-from-bracket"></i>
494
515
  <input type="file" accept="image/*" class="cpub-sr-only" aria-label="Upload cover image" @change="onCoverUpload">
495
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>
496
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>
497
519
  </div>
498
520
  </div>
499
521
  </div>
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>
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>
501
528
  </div>
502
529
  </template>
503
530
 
@@ -860,7 +887,9 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
860
887
  .cpub-ce-media-btn:hover { background: var(--surface2); }
861
888
  .cpub-ce-media-btn.primary:hover { opacity: 0.9; background: var(--accent); }
862
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); }
863
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); }
864
893
  .cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
865
894
 
866
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>
@@ -8,6 +8,7 @@
8
8
  * submission-form builder) re-derive `useFeatures()` here rather than prop-drilling.
9
9
  */
10
10
  import type { ContestStage } from '@commonpub/schema';
11
+ import type { BlockTuple } from '@commonpub/editor';
11
12
  import ContestStageTemplateEditor from './ContestStageTemplateEditor.vue';
12
13
 
13
14
  const props = defineProps<{
@@ -44,6 +45,10 @@ function advanceCountInput(e: Event): void {
44
45
  function onTemplateUpdate(template: ContestStage['submissionTemplate']): void {
45
46
  setField({ submissionTemplate: template && template.length ? template : undefined });
46
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
+ }
47
52
  </script>
48
53
 
49
54
  <template>
@@ -157,7 +162,9 @@ function onTemplateUpdate(template: ContestStage['submissionTemplate']): void {
157
162
  <ContestStageTemplateEditor
158
163
  v-if="stage.kind === 'submission' && templatesEnabled"
159
164
  :template="stage.submissionTemplate ?? []"
165
+ :instructions="(stage.instructionsBlocks as BlockTuple[] | undefined)"
160
166
  @update:template="onTemplateUpdate"
167
+ @update:instructions="onInstructionsUpdate"
161
168
  />
162
169
 
163
170
  <div v-if="stage.kind === 'event'" class="cpub-form-row">
@@ -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); }
@@ -4,16 +4,38 @@
4
4
  * from ContestStagesEditor so the (heaviest, flag-gated) part of the stage card is
5
5
  * its own cohesive unit. Operates on ONE stage's `submissionTemplate` array and
6
6
  * emits the whole new array (`update:template`); the pure array ops live in
7
- * utils/contestStages.ts. The agreement/address field types + the per-field PII
8
- * toggle are gated behind `features.contestPii` (rule #2); PII *access* is always
9
- * gated server-side by the `contest.pii` permission regardless.
7
+ * utils/contestStages.ts. P2 added (a) one-click field PRESETS, (b) whole-form
8
+ * TEMPLATES, and (c) a block INTRO rendered above the fields on the public form.
9
+ *
10
+ * The intro is authored as markdown but STORED as `instructionsBlocks` (BlockTuple[]
11
+ * — the same shape the contest bodies use), so it renders through BlockContentRenderer
12
+ * identically in this preview and on the public submission form. Full drag-drop block
13
+ * editing of the intro is deferred (markdown + live preview is enough, same call the
14
+ * plan made for agreement terms); the storage already supports upgrading later.
15
+ *
16
+ * The agreement/address field types + the per-field PII toggle are gated behind
17
+ * `features.contestPii` (rule #2); PII *access* is always gated server-side by the
18
+ * `contest.pii` permission regardless.
10
19
  */
11
20
  import type { ContestSubmissionTemplateField } from '@commonpub/schema';
21
+ import { markdownToBlockTuples, blockTuplesToMarkdown, type BlockTuple } from '@commonpub/editor';
22
+ import {
23
+ availableFieldPresets,
24
+ availableFormTemplates,
25
+ templatePresetAdded,
26
+ type FieldPreset,
27
+ type SubmissionFormTemplate,
28
+ } from '../../utils/contestSubmissionTemplates';
12
29
 
13
30
  const props = defineProps<{
14
31
  template: ContestSubmissionTemplateField[];
32
+ /** This stage's block intro (rendered above the fields on the public form). */
33
+ instructions?: BlockTuple[];
34
+ }>();
35
+ const emit = defineEmits<{
36
+ 'update:template': [template: ContestSubmissionTemplateField[]];
37
+ 'update:instructions': [blocks: BlockTuple[]];
15
38
  }>();
16
- const emit = defineEmits<{ 'update:template': [template: ContestSubmissionTemplateField[]] }>();
17
39
 
18
40
  const { features } = useFeatures();
19
41
  const piiEnabled = computed(() => features.value.contestPii === true);
@@ -23,9 +45,47 @@ const FIELD_TYPES = computed<ContestSubmissionTemplateField['type'][]>(() => {
23
45
  return base;
24
46
  });
25
47
 
26
- function addField(): void {
27
- emit('update:template', templateFieldAdded(props.template));
48
+ const fieldPresets = computed(() => availableFieldPresets(piiEnabled.value));
49
+ const formTemplates = computed(() => availableFormTemplates(piiEnabled.value));
50
+
51
+ // ─── Two small dropdown menus (Add field · Use a template) ───
52
+ const menuWrap = ref<HTMLElement | null>(null);
53
+ const openMenu = ref<'add' | 'template' | null>(null);
54
+ function toggleMenu(which: 'add' | 'template'): void {
55
+ openMenu.value = openMenu.value === which ? null : which;
56
+ }
57
+ function closeMenu(): void {
58
+ openMenu.value = null;
59
+ }
60
+ function onDocPointer(e: PointerEvent): void {
61
+ if (openMenu.value && menuWrap.value && !menuWrap.value.contains(e.target as Node)) closeMenu();
28
62
  }
63
+ function onDocKey(e: KeyboardEvent): void {
64
+ if (e.key === 'Escape' && openMenu.value) closeMenu();
65
+ }
66
+ onMounted(() => {
67
+ document.addEventListener('pointerdown', onDocPointer);
68
+ document.addEventListener('keydown', onDocKey);
69
+ });
70
+ onUnmounted(() => {
71
+ document.removeEventListener('pointerdown', onDocPointer);
72
+ document.removeEventListener('keydown', onDocKey);
73
+ });
74
+
75
+ function addPreset(preset: FieldPreset): void {
76
+ emit('update:template', templatePresetAdded(props.template, preset));
77
+ closeMenu();
78
+ }
79
+ function applyFormTemplate(tpl: SubmissionFormTemplate): void {
80
+ closeMenu();
81
+ // Replacing a non-empty form is destructive — confirm before clobbering.
82
+ if (props.template.length && typeof window !== 'undefined' && !window.confirm(`Replace the current ${props.template.length} field(s) with the "${tpl.label}" template?`)) {
83
+ return;
84
+ }
85
+ emit('update:template', tpl.build({ pii: piiEnabled.value }));
86
+ }
87
+
88
+ // ─── Per-field edits (delegate to the pure array ops) ───
29
89
  function labelInput(fi: number, e: Event): void {
30
90
  emit('update:template', templateFieldLabelChanged(props.template, fi, (e.target as HTMLInputElement).value));
31
91
  }
@@ -47,15 +107,120 @@ function setOption(fi: number, oi: number, patch: Partial<{ value: string; label
47
107
  function removeOption(fi: number, oi: number): void {
48
108
  emit('update:template', templateOptionRemoved(props.template, fi, oi));
49
109
  }
110
+
111
+ // ─── Block intro (markdown ⇄ BlockTuple[]) ───
112
+ const showIntro = ref((props.instructions?.length ?? 0) > 0);
113
+ const introText = ref(blockTuplesToMarkdown(props.instructions ?? []));
114
+ const introPreview = computed<BlockTuple[]>(() => markdownToBlockTuples(introText.value));
115
+ // Re-sync only on a GENUINELY external change (a form-template reset, a reorder
116
+ // reusing this instance). Our own keystroke emits the same blocks straight back;
117
+ // re-deriving markdown from them isn't char-exact (round-trip normalisation), so a
118
+ // naive `md !== introText` resync would fight the caret. Compare BLOCKS instead:
119
+ // if the incoming blocks already match what our current text produces, it's our
120
+ // echo — skip.
121
+ watch(
122
+ () => props.instructions,
123
+ (b) => {
124
+ const incoming = JSON.stringify(b ?? []);
125
+ if (incoming === JSON.stringify(introPreview.value)) return; // our own echo
126
+ introText.value = blockTuplesToMarkdown(b ?? []);
127
+ if ((b?.length ?? 0) > 0) showIntro.value = true;
128
+ },
129
+ );
130
+ function onIntroInput(e: Event): void {
131
+ introText.value = (e.target as HTMLTextAreaElement).value;
132
+ emit('update:instructions', introText.value.trim() ? introPreview.value : []);
133
+ }
134
+ function toggleIntro(): void {
135
+ showIntro.value = !showIntro.value;
136
+ if (!showIntro.value && introText.value.trim()) {
137
+ introText.value = '';
138
+ emit('update:instructions', []);
139
+ }
140
+ }
50
141
  </script>
51
142
 
52
143
  <template>
53
144
  <div class="cpub-stage-criteria">
54
145
  <div class="cpub-stage-criteria-head">
55
146
  <span class="cpub-form-label" style="margin: 0;">Submission form, this stage</span>
56
- <button type="button" class="cpub-btn cpub-btn-sm" @click="addField"><i class="fa-solid fa-plus"></i> Add field</button>
147
+ <div ref="menuWrap" class="cpub-stf-menus">
148
+ <!-- Use a template -->
149
+ <div class="cpub-stf-menu">
150
+ <button
151
+ type="button"
152
+ class="cpub-btn cpub-btn-sm"
153
+ aria-haspopup="menu"
154
+ :aria-expanded="openMenu === 'template'"
155
+ @click="toggleMenu('template')"
156
+ >
157
+ <i class="fa-solid fa-wand-magic-sparkles"></i> Use a template <i class="fa-solid fa-chevron-down"></i>
158
+ </button>
159
+ <div v-if="openMenu === 'template'" class="cpub-stf-dropdown" role="menu" aria-label="Submission form templates">
160
+ <button
161
+ v-for="tpl in formTemplates"
162
+ :key="tpl.id"
163
+ type="button"
164
+ role="menuitem"
165
+ class="cpub-stf-item"
166
+ @click="applyFormTemplate(tpl)"
167
+ >
168
+ <span class="cpub-stf-item-label">{{ tpl.label }}</span>
169
+ <span class="cpub-stf-item-desc">{{ tpl.description }}</span>
170
+ </button>
171
+ </div>
172
+ </div>
173
+ <!-- Add a field (presets) -->
174
+ <div class="cpub-stf-menu">
175
+ <button
176
+ type="button"
177
+ class="cpub-btn cpub-btn-sm"
178
+ aria-haspopup="menu"
179
+ :aria-expanded="openMenu === 'add'"
180
+ @click="toggleMenu('add')"
181
+ >
182
+ <i class="fa-solid fa-plus"></i> Add field <i class="fa-solid fa-chevron-down"></i>
183
+ </button>
184
+ <div v-if="openMenu === 'add'" class="cpub-stf-dropdown" role="menu" aria-label="Field presets">
185
+ <button
186
+ v-for="preset in fieldPresets"
187
+ :key="preset.id"
188
+ type="button"
189
+ role="menuitem"
190
+ class="cpub-stf-item cpub-stf-item-row"
191
+ @click="addPreset(preset)"
192
+ >
193
+ <i class="fa-solid cpub-stf-item-icon" :class="preset.icon"></i>
194
+ <span class="cpub-stf-item-label">{{ preset.label }}</span>
195
+ </button>
196
+ </div>
197
+ </div>
198
+ </div>
57
199
  </div>
58
200
  <p class="cpub-form-hint" style="margin: 4px 0;">Optional. Add fields entrants must fill for this stage (e.g. a proposal summary, or a repository link for a prototype round). Leave empty if entering a project is enough.</p>
201
+
202
+ <!-- Block intro: rich instructions shown above the fields on the public form. -->
203
+ <div class="cpub-stf-intro">
204
+ <label class="cpub-stage-tfield-req">
205
+ <input type="checkbox" :checked="showIntro" aria-label="Add instructions above the form" @change="toggleIntro" />
206
+ <span>Add instructions above the form</span>
207
+ </label>
208
+ <div v-if="showIntro" class="cpub-stf-intro-edit">
209
+ <textarea
210
+ :value="introText"
211
+ class="cpub-form-input cpub-form-textarea"
212
+ rows="3"
213
+ placeholder="Markdown instructions shown above the form (what to submit, tips, links)."
214
+ aria-label="Form instructions (markdown)"
215
+ @input="onIntroInput"
216
+ ></textarea>
217
+ <div v-if="introPreview.length" class="cpub-stf-intro-preview">
218
+ <span class="cpub-form-hint" style="margin: 0 0 4px;">Preview</span>
219
+ <BlocksBlockContentRenderer :blocks="introPreview" class="cpub-prose cpub-md" />
220
+ </div>
221
+ </div>
222
+ </div>
223
+
59
224
  <div v-for="(tf, fi) in template" :key="fi" class="cpub-stage-tfield">
60
225
  <div class="cpub-stage-tfield-main">
61
226
  <input
@@ -175,7 +340,25 @@ function removeOption(fi: number, oi: number): void {
175
340
  .cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
176
341
 
177
342
  .cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
178
- .cpub-stage-criteria-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
343
+ .cpub-stage-criteria-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
344
+
345
+ /* Add-field / template dropdown menus */
346
+ .cpub-stf-menus { display: flex; gap: 6px; }
347
+ .cpub-stf-menu { position: relative; }
348
+ .cpub-stf-dropdown { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; min-width: 220px; max-height: 320px; overflow-y: auto; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); display: flex; flex-direction: column; }
349
+ .cpub-stf-item { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; text-align: left; padding: 8px 10px; background: transparent; border: none; border-bottom: var(--border-width-default) solid var(--border2); cursor: pointer; color: var(--text); }
350
+ .cpub-stf-item:last-child { border-bottom: none; }
351
+ .cpub-stf-item:hover { background: var(--accent-bg); }
352
+ .cpub-stf-item-row { flex-direction: row; align-items: center; gap: 8px; }
353
+ .cpub-stf-item-icon { color: var(--accent); width: 16px; text-align: center; }
354
+ .cpub-stf-item-label { font-size: var(--text-sm); font-weight: 600; }
355
+ .cpub-stf-item-desc { font-size: var(--text-xs); color: var(--text-faint); line-height: 1.4; }
356
+
357
+ /* Block intro */
358
+ .cpub-stf-intro { margin: 8px 0; padding: 8px; border: var(--border-width-default) dashed var(--border2); background: var(--surface2); }
359
+ .cpub-stf-intro-edit { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
360
+ .cpub-stf-intro-preview { border-top: var(--border-width-default) dashed var(--border2); padding-top: 8px; }
361
+
179
362
  .cpub-stage-tfield { margin-top: 8px; padding-top: 8px; border-top: var(--border-width-default) dashed var(--border2); }
180
363
  .cpub-stage-tfield:first-of-type { border-top: 0; padding-top: 0; }
181
364
  .cpub-stage-tfield-main { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
@@ -2,14 +2,29 @@
2
2
  /**
3
3
  * Edit component for the `judgesShowcase` contest block (avatar + name + title +
4
4
  * bio cards). Provided to BlockCanvas via BLOCK_COMPONENTS_KEY by the contest
5
- * editor (2e). Follows the house block-edit contract: `content` in, `update` out,
6
- * immutable list ops. Avatars are URLs here; 2e can swap to <ImageUpload>.
5
+ * editor. Follows the house block-edit contract: `content` in, `update` out,
6
+ * immutable list ops.
7
+ *
8
+ * P6 de-friction: avatars now upload via the contest editor's UPLOAD_HANDLER_KEY
9
+ * (URL still accepted), rows reorder, and "Import panel judges" seeds rows from
10
+ * the real scoring panel (CONTEST_JUDGES_KEY) in one click. The Judges Showcase
11
+ * is the curated PUBLIC face (custom photos/titles); the scoring panel (People
12
+ * rail) is the real accounts who score — two distinct concepts, hence the note.
7
13
  */
14
+ import { inject, ref } from 'vue';
15
+ import { UPLOAD_HANDLER_KEY } from '@commonpub/editor/vue';
16
+ import { CONTEST_JUDGES_KEY } from '../../../utils/contestBlocks';
8
17
  import type { JudgeShowcaseEntry, JudgesShowcaseContent } from '../../../types/contestBlocks';
9
18
 
10
19
  const props = defineProps<{ content: Record<string, unknown> }>();
11
20
  const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
12
21
 
22
+ const uploadHandler = inject(UPLOAD_HANDLER_KEY, undefined);
23
+ const loadPanelJudges = inject(CONTEST_JUDGES_KEY, null);
24
+ const uploadingIndex = ref<number | null>(null);
25
+ const importing = ref(false);
26
+ const importNote = ref('');
27
+
13
28
  const heading = computed(() => (typeof props.content.heading === 'string' ? props.content.heading : ''));
14
29
  const judges = computed<JudgeShowcaseEntry[]>(() =>
15
30
  Array.isArray(props.content.judges) ? (props.content.judges as JudgeShowcaseEntry[]) : [],
@@ -30,6 +45,50 @@ function setJudge(i: number, field: keyof JudgeShowcaseEntry, v: string): void {
30
45
  function removeJudge(i: number): void {
31
46
  commit({ judges: judges.value.filter((_, idx) => idx !== i) });
32
47
  }
48
+ function moveJudge(i: number, dir: -1 | 1): void {
49
+ const j = i + dir;
50
+ if (j < 0 || j >= judges.value.length) return;
51
+ const next = [...judges.value];
52
+ [next[i], next[j]] = [next[j]!, next[i]!];
53
+ commit({ judges: next });
54
+ }
55
+
56
+ async function onFile(i: number, event: Event): Promise<void> {
57
+ const input = event.target as HTMLInputElement;
58
+ const file = input.files?.[0];
59
+ input.value = '';
60
+ if (!file || !uploadHandler) return;
61
+ uploadingIndex.value = i;
62
+ try {
63
+ const res = await uploadHandler(file);
64
+ setJudge(i, 'avatarUrl', res.url);
65
+ } finally {
66
+ uploadingIndex.value = null;
67
+ }
68
+ }
69
+
70
+ async function importPanelJudges(): Promise<void> {
71
+ if (!loadPanelJudges || importing.value) return;
72
+ importing.value = true;
73
+ importNote.value = '';
74
+ try {
75
+ const panel = await loadPanelJudges();
76
+ const have = new Set(judges.value.map((j) => (j.name ?? '').trim().toLowerCase()).filter(Boolean));
77
+ const additions: JudgeShowcaseEntry[] = panel
78
+ .filter((p) => p.name.trim() && !have.has(p.name.trim().toLowerCase()))
79
+ .map((p) => ({ name: p.name, avatarUrl: p.avatarUrl, title: p.title, link: p.link }));
80
+ if (!additions.length) {
81
+ importNote.value = panel.length ? 'All panel judges are already shown.' : 'No panel judges to import yet.';
82
+ return;
83
+ }
84
+ commit({ judges: [...judges.value, ...additions] });
85
+ importNote.value = `Imported ${additions.length} judge${additions.length === 1 ? '' : 's'}. Add photos and titles below.`;
86
+ } catch {
87
+ importNote.value = 'Could not load the judges panel.';
88
+ } finally {
89
+ importing.value = false;
90
+ }
91
+ }
33
92
  </script>
34
93
 
35
94
  <template>
@@ -38,10 +97,25 @@ function removeJudge(i: number): void {
38
97
  <div class="cpub-jedit-icon"><i class="fa-solid fa-user-group"></i></div>
39
98
  <span class="cpub-jedit-title">Judges Showcase</span>
40
99
  <span class="cpub-jedit-count">{{ judges.length }} {{ judges.length === 1 ? 'person' : 'people' }}</span>
100
+ <button
101
+ v-if="loadPanelJudges"
102
+ type="button"
103
+ class="cpub-jedit-add"
104
+ :disabled="importing"
105
+ @click="importPanelJudges"
106
+ >
107
+ <i class="fa-solid fa-user-plus"></i> {{ importing ? 'Importing...' : 'Import panel judges' }}
108
+ </button>
41
109
  <button type="button" class="cpub-jedit-add" @click="addJudge"><i class="fa-solid fa-plus"></i> Add person</button>
42
110
  </div>
43
111
 
44
112
  <div class="cpub-jedit-body">
113
+ <p class="cpub-jedit-explain">
114
+ <i class="fa-solid fa-circle-info"></i>
115
+ These are the curated public faces (custom photos and titles). The scoring panel, who actually
116
+ rate entries, is managed under People. Use Import panel judges to start from that list.
117
+ </p>
118
+
45
119
  <input
46
120
  class="cpub-jedit-input cpub-jedit-heading"
47
121
  type="text"
@@ -51,13 +125,30 @@ function removeJudge(i: number): void {
51
125
  @input="setHeading(($event.target as HTMLInputElement).value)"
52
126
  />
53
127
 
128
+ <p v-if="importNote" class="cpub-jedit-note" role="status">{{ importNote }}</p>
129
+
54
130
  <div v-for="(j, i) in judges" :key="i" class="cpub-jedit-row">
55
131
  <div class="cpub-jedit-row-main">
56
132
  <input class="cpub-jedit-input" type="text" :value="j.name" placeholder="Name" :aria-label="`Person ${i + 1} name`" @input="setJudge(i, 'name', ($event.target as HTMLInputElement).value)" />
57
133
  <input class="cpub-jedit-input" type="text" :value="j.title ?? ''" placeholder="Title / affiliation" :aria-label="`Person ${i + 1} title`" @input="setJudge(i, 'title', ($event.target as HTMLInputElement).value)" />
134
+ <button type="button" class="cpub-jedit-iconbtn" :disabled="i === 0" :aria-label="`Move person ${i + 1} up`" @click="moveJudge(i, -1)"><i class="fa-solid fa-arrow-up"></i></button>
135
+ <button type="button" class="cpub-jedit-iconbtn" :disabled="i === judges.length - 1" :aria-label="`Move person ${i + 1} down`" @click="moveJudge(i, 1)"><i class="fa-solid fa-arrow-down"></i></button>
58
136
  <button type="button" class="cpub-jedit-remove" :aria-label="`Remove person ${i + 1}`" @click="removeJudge(i)"><i class="fa-solid fa-xmark"></i></button>
59
137
  </div>
60
- <input class="cpub-jedit-input" type="url" :value="j.avatarUrl ?? ''" placeholder="Avatar image URL (https://…)" :aria-label="`Person ${i + 1} avatar URL`" @input="setJudge(i, 'avatarUrl', ($event.target as HTMLInputElement).value)" />
138
+
139
+ <div class="cpub-jedit-avatar-row">
140
+ <span class="cpub-jedit-avatar-prev">
141
+ <img v-if="j.avatarUrl" :src="j.avatarUrl" :alt="`${j.name || 'Judge'} photo`" />
142
+ <i v-else class="fa-solid fa-user"></i>
143
+ </span>
144
+ <input class="cpub-jedit-input" type="url" :value="j.avatarUrl ?? ''" placeholder="Photo URL (https://…)" :aria-label="`Person ${i + 1} photo URL`" @input="setJudge(i, 'avatarUrl', ($event.target as HTMLInputElement).value)" />
145
+ <label v-if="uploadHandler" class="cpub-jedit-upload" :class="{ 'cpub-jedit-upload-busy': uploadingIndex === i }">
146
+ <i class="fa-solid" :class="uploadingIndex === i ? 'fa-spinner fa-spin' : 'fa-arrow-up-from-bracket'"></i>
147
+ <span>{{ uploadingIndex === i ? 'Uploading' : 'Upload' }}</span>
148
+ <input type="file" accept="image/*" class="cpub-jedit-file" :aria-label="`Upload photo for person ${i + 1}`" @change="onFile(i, $event)" />
149
+ </label>
150
+ </div>
151
+
61
152
  <input class="cpub-jedit-input" type="url" :value="j.link ?? ''" placeholder="Profile / link (https://…, optional)" :aria-label="`Person ${i + 1} link`" @input="setJudge(i, 'link', ($event.target as HTMLInputElement).value)" />
62
153
  <textarea class="cpub-jedit-input cpub-jedit-bio" rows="2" :value="j.bio ?? ''" placeholder="Short bio (optional)" :aria-label="`Person ${i + 1} bio`" @input="setJudge(i, 'bio', ($event.target as HTMLTextAreaElement).value)" />
63
154
  </div>
@@ -71,14 +162,18 @@ function removeJudge(i: number): void {
71
162
 
72
163
  <style scoped>
73
164
  .cpub-jedit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
74
- .cpub-jedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
165
+ .cpub-jedit-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); flex-wrap: wrap; }
75
166
  .cpub-jedit-icon { font-size: 12px; color: var(--accent); }
76
167
  .cpub-jedit-title { font-size: 12px; font-weight: 600; }
77
168
  .cpub-jedit-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
78
169
  .cpub-jedit-add { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 4px; margin-left: 8px; }
79
- .cpub-jedit-add:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
170
+ .cpub-jedit-add:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
171
+ .cpub-jedit-add:disabled { opacity: .5; cursor: default; }
80
172
 
81
173
  .cpub-jedit-body { padding: 10px 14px; display: flex; flex-direction: column; gap: 10px; }
174
+ .cpub-jedit-explain { margin: 0; font-size: 11px; color: var(--text-faint); line-height: 1.5; display: flex; gap: 6px; }
175
+ .cpub-jedit-explain i { color: var(--accent); margin-top: 2px; flex-shrink: 0; }
176
+ .cpub-jedit-note { margin: 0; font-size: 11px; color: var(--accent); font-family: var(--font-mono); }
82
177
  .cpub-jedit-input { width: 100%; padding: 6px 8px; font-size: 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text); outline: none; }
83
178
  .cpub-jedit-input:focus { border-color: var(--accent); }
84
179
  .cpub-jedit-input::placeholder { color: var(--text-faint); }
@@ -86,11 +181,23 @@ function removeJudge(i: number): void {
86
181
  .cpub-jedit-bio { resize: vertical; font-family: inherit; }
87
182
 
88
183
  .cpub-jedit-row { border: var(--border-width-default) dashed var(--border2); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
89
- .cpub-jedit-row-main { display: flex; gap: 6px; }
184
+ .cpub-jedit-row-main { display: flex; gap: 6px; align-items: center; }
90
185
  .cpub-jedit-row-main .cpub-jedit-input { flex: 1; }
186
+ .cpub-jedit-iconbtn { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; width: 26px; height: 26px; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; }
187
+ .cpub-jedit-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
188
+ .cpub-jedit-iconbtn:disabled { opacity: .4; cursor: not-allowed; }
91
189
  .cpub-jedit-remove { background: none; border: var(--border-width-default) solid var(--border); color: var(--text-faint); cursor: pointer; font-size: 11px; padding: 0 8px; flex-shrink: 0; }
92
190
  .cpub-jedit-remove:hover { border-color: var(--red-border); color: var(--red); }
93
191
 
192
+ .cpub-jedit-avatar-row { display: flex; gap: 6px; align-items: center; }
193
+ .cpub-jedit-avatar-row .cpub-jedit-input { flex: 1; }
194
+ .cpub-jedit-avatar-prev { width: 30px; height: 30px; flex-shrink: 0; border: var(--border-width-default) solid var(--border); background: var(--surface2); display: inline-flex; align-items: center; justify-content: center; color: var(--text-faint); font-size: 12px; overflow: hidden; }
195
+ .cpub-jedit-avatar-prev img { width: 100%; height: 100%; object-fit: cover; }
196
+ .cpub-jedit-upload { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: .04em; padding: 6px 8px; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; flex-shrink: 0; white-space: nowrap; }
197
+ .cpub-jedit-upload:hover { border-color: var(--accent); color: var(--accent); }
198
+ .cpub-jedit-upload-busy { opacity: .7; cursor: default; }
199
+ .cpub-jedit-file { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
200
+
94
201
  .cpub-jedit-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; border: var(--border-width-default) dashed var(--border2); }
95
202
  .cpub-jedit-empty:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
96
203
  </style>
@@ -14,7 +14,7 @@
14
14
  * round-trip bug and no per-field re-conversion at save (the Phase 1 datetime fix).
15
15
  */
16
16
  import { ref, computed, watch, nextTick, type Ref, type ComputedRef } from 'vue';
17
- import type { ContestStage } from '@commonpub/schema';
17
+ import type { ContestStage, ContestImageMeta } from '@commonpub/schema';
18
18
  import type { ContestTemplateSeed } from '../utils/contestTemplates';
19
19
 
20
20
  export type ContestFormat = 'markdown' | 'html';
@@ -52,6 +52,8 @@ export interface ContestEditorSource {
52
52
  prizesDescriptionFormat?: string | null;
53
53
  bannerUrl?: string | null;
54
54
  coverImageUrl?: string | null;
55
+ bannerMeta?: ContestImageMeta | null;
56
+ coverMeta?: ContestImageMeta | null;
55
57
  startDate?: string | null;
56
58
  endDate?: string | null;
57
59
  judgingEndDate?: string | null;
@@ -105,6 +107,8 @@ export interface UseContestEditor {
105
107
  prizesDescriptionFormat: Ref<ContestFormat>;
106
108
  bannerUrl: Ref<string>;
107
109
  coverImageUrl: Ref<string>;
110
+ bannerMeta: Ref<ContestImageMeta | null>;
111
+ coverMeta: Ref<ContestImageMeta | null>;
108
112
  startDate: Ref<string>;
109
113
  endDate: Ref<string>;
110
114
  judgingEndDate: Ref<string>;
@@ -172,6 +176,8 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
172
176
  const prizesDescriptionFormat = ref<ContestFormat>('markdown');
173
177
  const bannerUrl = ref('');
174
178
  const coverImageUrl = ref('');
179
+ const bannerMeta = ref<ContestImageMeta | null>(null);
180
+ const coverMeta = ref<ContestImageMeta | null>(null);
175
181
  const startDate = ref('');
176
182
  const endDate = ref('');
177
183
  const judgingEndDate = ref('');
@@ -252,6 +258,8 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
252
258
  prizesDescriptionFormat.value = asFormat(c.prizesDescriptionFormat);
253
259
  bannerUrl.value = c.bannerUrl ?? '';
254
260
  coverImageUrl.value = c.coverImageUrl ?? '';
261
+ bannerMeta.value = c.bannerMeta ?? null;
262
+ coverMeta.value = c.coverMeta ?? null;
255
263
  // ISO instants stored verbatim; CpubDateTimeField renders them in local time.
256
264
  startDate.value = c.startDate ?? '';
257
265
  endDate.value = c.endDate ?? '';
@@ -335,6 +343,9 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
335
343
  prizesDescriptionFormat: prizesDescriptionFormat.value,
336
344
  bannerUrl: bannerUrl.value || undefined,
337
345
  coverImageUrl: coverImageUrl.value || undefined,
346
+ // Clear the framing when the image is removed; else send it (or leave as-is).
347
+ bannerMeta: bannerUrl.value ? (bannerMeta.value ?? undefined) : null,
348
+ coverMeta: coverImageUrl.value ? (coverMeta.value ?? undefined) : null,
338
349
  startDate: startDate.value || undefined,
339
350
  endDate: endDate.value || undefined,
340
351
  judgingEndDate: judgingEndDate.value || undefined,
@@ -389,7 +400,7 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
389
400
  // Any post-hydration edit flips the dirty flag (drives the topbar "unsaved" cue).
390
401
  watch(
391
402
  [title, slugInput, subheading, description, descriptionBlocks, rulesBlocks, prizesBlocks, rules,
392
- descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, startDate, endDate,
403
+ descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, bannerMeta, coverMeta, startDate, endDate,
393
404
  judgingEndDate, communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser,
394
405
  visibility, visibleToRoles, showPrizes, prizesDescription, prizes, criteria, stages, currentStageId],
395
406
  () => { if (!hydrating) formDirty.value = true; },
@@ -403,7 +414,7 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
403
414
 
404
415
  return {
405
416
  title, slugInput, slugTouched, subheading, description, descriptionBlocks, rulesBlocks, prizesBlocks,
406
- rules, descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, startDate,
417
+ rules, descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, bannerMeta, coverMeta, startDate,
407
418
  endDate, judgingEndDate, communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser,
408
419
  visibility, visibleToRoles, showPrizes, prizesDescription, prizes, criteria, stages, currentStageId,
409
420
  saving, formDirty, dateError, canSubmit,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.84.0",
3
+ "version": "0.85.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -56,15 +56,15 @@
56
56
  "zod": "^4.3.6",
57
57
  "@commonpub/config": "0.23.0",
58
58
  "@commonpub/editor": "0.8.0",
59
- "@commonpub/explainer": "0.8.0",
60
- "@commonpub/learning": "0.5.2",
61
- "@commonpub/protocol": "0.14.0",
62
- "@commonpub/server": "2.90.0",
59
+ "@commonpub/auth": "0.8.0",
63
60
  "@commonpub/docs": "0.6.3",
64
- "@commonpub/schema": "0.46.0",
65
61
  "@commonpub/theme-studio": "0.6.1",
66
- "@commonpub/ui": "0.13.1",
67
- "@commonpub/auth": "0.8.0"
62
+ "@commonpub/protocol": "0.14.0",
63
+ "@commonpub/schema": "0.47.0",
64
+ "@commonpub/learning": "0.5.2",
65
+ "@commonpub/server": "2.91.0",
66
+ "@commonpub/explainer": "0.8.0",
67
+ "@commonpub/ui": "0.13.1"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@testing-library/jest-dom": "^6.9.1",
@@ -506,7 +506,10 @@ async function withdrawEntry(entryId: string): Promise<void> {
506
506
  .cpub-submit-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: var(--border-width-default) solid var(--border); }
507
507
 
508
508
  /* LAYOUT */
509
- .cpub-contest-main { max-width: 1100px; margin: 0 auto; padding: 32px; }
509
+ /* Top padding intentionally tighter than the sides: the hero bar already sits
510
+ directly above with its own 20px bottom padding + border, so a full 32px here
511
+ stacked to a ~52px hero→tabs gap. ~18px lands the tabbar close under the hero. */
512
+ .cpub-contest-main { max-width: 1100px; margin: 0 auto; padding: 18px 32px 32px; }
510
513
 
511
514
  .cpub-entries-tools { display: flex; justify-content: flex-end; margin-bottom: 12px; }
512
515
  .cpub-entries-cta { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; margin-bottom: 18px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
@@ -65,6 +65,7 @@ const canCreateContest = computed(() => {
65
65
  :alt="contest.title"
66
66
  class="cpub-contest-cover"
67
67
  :class="{ 'cpub-contest-cover--contain': !contest.coverImageUrl && !!contest.bannerUrl }"
68
+ :style="contest.coverImageUrl ? imageFramingStyle(contest.coverMeta) : undefined"
68
69
  loading="lazy"
69
70
  />
70
71
  <template v-else>
@@ -19,6 +19,16 @@ export const CONTEST_RUBRIC_KEY: InjectionKey<Ref<ContestRubricCriterion[]>> = S
19
19
  * "pull from schedule" seed. Absent (null) outside the contest editor. */
20
20
  export const CONTEST_SCHEDULE_KEY: InjectionKey<Ref<RoadmapItem[]>> = Symbol('contestSchedule');
21
21
 
22
+ /** A panel judge as the Judges Showcase block cares about it (for "Import panel
23
+ * judges": seed showcase rows from the real scoring panel). */
24
+ export interface ContestPanelJudge { name: string; avatarUrl?: string; title?: string; link?: string }
25
+
26
+ /** ContestEditor `provide`s an async loader for the contest's scoring-panel
27
+ * judges under this key, so the Judges Showcase block can offer a one-click
28
+ * "import panel judges" seed (name + account avatar). Resolves to [] in create
29
+ * mode (no slug yet); absent (null) outside the contest editor. */
30
+ export const CONTEST_JUDGES_KEY: InjectionKey<() => Promise<ContestPanelJudge[]>> = Symbol('contestJudges');
31
+
22
32
  /** A stage as the roadmap cares about it (structural subset of ContestStage). */
23
33
  export interface RoadmapStageSource { name: string; kind?: string; startsAt?: string; endsAt?: string; description?: string }
24
34
  /** The three core schedule dates, when there are no custom stages. */
@@ -15,11 +15,11 @@ export function seedBodyBlocks(
15
15
  if (Array.isArray(blocks) && blocks.length) return blocks as BlockTuple[];
16
16
  const text = (legacy ?? '').trim();
17
17
  if (!text) return [];
18
- if (legacyFormat === 'html') return [['markdown', { content: text }]];
18
+ if (legacyFormat === 'html') return [['markdown', { source: text }]];
19
19
  try {
20
20
  const parsed = markdownToBlockTuples(text);
21
- return parsed.length ? parsed : [['markdown', { content: text }]];
21
+ return parsed.length ? parsed : [['markdown', { source: text }]];
22
22
  } catch {
23
- return [['markdown', { content: text }]];
23
+ return [['markdown', { source: text }]];
24
24
  }
25
25
  }
@@ -0,0 +1,35 @@
1
+ import type { CSSProperties } from 'vue';
2
+ import type { ContestImageMeta } from '@commonpub/schema';
3
+
4
+ /**
5
+ * Map a contest banner/cover `ContestImageMeta` to CSS for the <img> (P4). The
6
+ * framing is NON-DESTRUCTIVE — the original upload is never re-cropped; this only
7
+ * drives object-fit / transform / object-position. Shared by ContestHero (public
8
+ * render), the editor preview, and ContestBannerAdjust so all three agree.
9
+ *
10
+ * - `null`/absent ⇒ `cover` with NO transform — the legacy fit, so existing
11
+ * contests look identical until an organiser touches the framing (back-compat).
12
+ * - `zoom <= 0` ⇒ `contain` — perfect fit, the whole image visible (letterboxed).
13
+ * - `zoom > 0` ⇒ `cover` + `scale(1 + zoom)` + `object-position: x% y%`.
14
+ */
15
+ /** A `:style` object (a Vue CSSProperties subset) for an <img>. */
16
+ export type ImageFraming = CSSProperties;
17
+
18
+ export function imageFramingStyle(meta: ContestImageMeta | null | undefined): CSSProperties {
19
+ if (!meta) return { objectFit: 'cover' };
20
+ const x = clampPct(meta.x);
21
+ const y = clampPct(meta.y);
22
+ if (meta.zoom <= 0) return { objectFit: 'contain', objectPosition: `${x}% ${y}%` };
23
+ const zoom = Math.min(4, meta.zoom);
24
+ return { objectFit: 'cover', transform: `scale(${1 + zoom})`, objectPosition: `${x}% ${y}%` };
25
+ }
26
+
27
+ function clampPct(n: number): number {
28
+ if (!Number.isFinite(n)) return 50;
29
+ return Math.max(0, Math.min(100, Math.round(n)));
30
+ }
31
+
32
+ /** The default framing an organiser starts from when they first adjust an image. */
33
+ export function defaultImageMeta(): ContestImageMeta {
34
+ return { zoom: 0, x: 50, y: 50 };
35
+ }
@@ -0,0 +1,165 @@
1
+ import type { ContestSubmissionTemplateField } from '@commonpub/schema';
2
+ import { fieldKeyFromLabel } from './contestStages';
3
+
4
+ /**
5
+ * Field presets + whole-form templates for the submission-form builder (P2). Pure
6
+ * data + helpers so they unit-test in isolation; the builder UI
7
+ * (ContestStageTemplateEditor) appends a preset or replaces the whole form via the
8
+ * `templatePreset*`/template builders here. Keys are derived from the label and
9
+ * uniquified against the existing template so two "Email" fields don't collide
10
+ * (template field keys must be unique — `contestStageSchema`).
11
+ *
12
+ * Address/Agreement presets + the address/shipping templates are PII-gated
13
+ * (`features.contestPii`): the agreement/address field types are only offered in
14
+ * the builder when that flag is on, so the UI hides them otherwise. The pure
15
+ * builders take an explicit `{ pii }` so they degrade the same way in isolation.
16
+ */
17
+ type TemplateField = ContestSubmissionTemplateField;
18
+
19
+ /** Default terms an organiser can keep or edit; shared by the preset + template. */
20
+ export const RULES_AGREEMENT_TERMS =
21
+ 'By entering, I confirm this submission is my own original work and I agree to the contest rules and code of conduct.';
22
+
23
+ /** Make `base` unique within `taken` by appending `_2`, `_3`, … */
24
+ function uniqueKey(taken: Set<string>, base: string): string {
25
+ if (!taken.has(base)) return base;
26
+ let n = 2;
27
+ while (taken.has(`${base}_${n}`)) n += 1;
28
+ return `${base}_${n}`;
29
+ }
30
+
31
+ /** Stamp keys on a set of keyless fields, keeping them unique among themselves. */
32
+ function withKeys(fields: Array<Omit<TemplateField, 'key'> & { key?: string }>): TemplateField[] {
33
+ const taken = new Set<string>();
34
+ return fields.map((f) => {
35
+ const key = uniqueKey(taken, f.key || fieldKeyFromLabel(f.label));
36
+ taken.add(key);
37
+ return { ...f, key };
38
+ });
39
+ }
40
+
41
+ // ─── One-click field presets (the "Add field" menu) ───
42
+
43
+ export interface FieldPreset {
44
+ id: string;
45
+ /** Menu label. */
46
+ label: string;
47
+ /** FontAwesome icon (no `fa-solid` prefix). */
48
+ icon: string;
49
+ /** Requires `features.contestPii` (agreement/address field types). */
50
+ pii?: boolean;
51
+ /** The field this preset seeds (key derived from the label at add time). */
52
+ field: Omit<TemplateField, 'key'>;
53
+ }
54
+
55
+ export const FIELD_PRESETS: FieldPreset[] = [
56
+ { id: 'text', label: 'Short text', icon: 'fa-font', field: { label: 'Short answer', type: 'text', required: false } },
57
+ { id: 'textarea', label: 'Long text', icon: 'fa-align-left', field: { label: 'Details', type: 'textarea', required: false } },
58
+ { id: 'url', label: 'Link (URL)', icon: 'fa-link', field: { label: 'Link', type: 'url', required: false, help: 'Include the full https:// address.' } },
59
+ { id: 'email', label: 'Email', icon: 'fa-envelope', field: { label: 'Email address', type: 'email', required: false } },
60
+ { id: 'number', label: 'Number', icon: 'fa-hashtag', field: { label: 'Number', type: 'number', required: false } },
61
+ { id: 'select', label: 'Dropdown', icon: 'fa-list', field: { label: 'Choose one', type: 'select', required: false, options: [{ value: '', label: '' }] } },
62
+ { id: 'checkbox', label: 'Checkbox', icon: 'fa-square-check', field: { label: 'Confirm', type: 'checkbox', required: false } },
63
+ { id: 'date', label: 'Date', icon: 'fa-calendar', field: { label: 'Date', type: 'date', required: false } },
64
+ {
65
+ id: 'address',
66
+ label: 'Mailing address',
67
+ icon: 'fa-location-dot',
68
+ pii: true,
69
+ field: { label: 'Mailing address', type: 'address', required: false, pii: true, help: 'Stored privately. Only staff with PII access and the entrant can read it.' },
70
+ },
71
+ {
72
+ id: 'agreement',
73
+ label: 'Agreement',
74
+ icon: 'fa-file-signature',
75
+ pii: true,
76
+ field: { label: 'Agreement', type: 'agreement', required: true, mustAccept: true, terms: RULES_AGREEMENT_TERMS },
77
+ },
78
+ ];
79
+
80
+ /** Presets offered for the builder, gated by whether PII field types are enabled. */
81
+ export function availableFieldPresets(pii: boolean): FieldPreset[] {
82
+ return pii ? FIELD_PRESETS : FIELD_PRESETS.filter((p) => !p.pii);
83
+ }
84
+
85
+ /** Append a preset field, deriving a unique machine key from its label. */
86
+ export function templatePresetAdded(t: TemplateField[], preset: FieldPreset): TemplateField[] {
87
+ const taken = new Set(t.map((f) => f.key));
88
+ const key = uniqueKey(taken, fieldKeyFromLabel(preset.field.label));
89
+ return [...t, { ...preset.field, key }];
90
+ }
91
+
92
+ // ─── Whole-form templates (the "Start from template" picker) ───
93
+
94
+ export interface SubmissionFormTemplate {
95
+ id: string;
96
+ label: string;
97
+ description: string;
98
+ /** Requires `features.contestPii` to seed its address/agreement fields. */
99
+ pii?: boolean;
100
+ /** Build the field array; flag-adaptive so it degrades when PII is off. */
101
+ build(opts: { pii: boolean }): TemplateField[];
102
+ }
103
+
104
+ const SHIPPING_AGREEMENT_TERMS =
105
+ 'If selected, I agree to provide a valid shipping address and accept responsibility for any hardware sent to me.';
106
+
107
+ export const SUBMISSION_FORM_TEMPLATES: SubmissionFormTemplate[] = [
108
+ {
109
+ id: 'standard',
110
+ label: 'Standard proposal',
111
+ description: 'Name, summary, description, approach (and a rules agreement when PII is on).',
112
+ build({ pii }): TemplateField[] {
113
+ const fields: Array<Omit<TemplateField, 'key'>> = [
114
+ { label: 'Project name', type: 'text', required: true },
115
+ { label: 'One-line summary', type: 'text', required: true, help: 'A single sentence describing your idea.' },
116
+ { label: 'Description', type: 'textarea', required: true, help: 'What you are building and the problem it solves.' },
117
+ { label: 'Approach', type: 'textarea', required: false, help: 'How you plan to build it (optional).' },
118
+ ];
119
+ if (pii) fields.push({ label: 'Contest rules', type: 'agreement', required: true, terms: RULES_AGREEMENT_TERMS, mustAccept: true });
120
+ return withKeys(fields);
121
+ },
122
+ },
123
+ {
124
+ id: 'hardware',
125
+ label: 'Hardware / shipping',
126
+ description: 'Standard proposal plus a mailing address and a shipping agreement (PII).',
127
+ pii: true,
128
+ build({ pii }): TemplateField[] {
129
+ const fields: Array<Omit<TemplateField, 'key'>> = [
130
+ { label: 'Project name', type: 'text', required: true },
131
+ { label: 'One-line summary', type: 'text', required: true, help: 'A single sentence describing your idea.' },
132
+ { label: 'Description', type: 'textarea', required: true, help: 'What you are building and the problem it solves.' },
133
+ ];
134
+ if (pii) {
135
+ fields.push({ label: 'Mailing address', type: 'address', required: true, pii: true, help: 'Stored privately. Only staff with PII access and the entrant can read it.' });
136
+ fields.push({ label: 'Shipping agreement', type: 'agreement', required: true, terms: SHIPPING_AGREEMENT_TERMS, mustAccept: true });
137
+ }
138
+ return withKeys(fields);
139
+ },
140
+ },
141
+ {
142
+ id: 'minimal',
143
+ label: 'Minimal',
144
+ description: 'Just a project name and a link.',
145
+ build(): TemplateField[] {
146
+ return withKeys([
147
+ { label: 'Project name', type: 'text', required: true },
148
+ { label: 'Link', type: 'url', required: false, help: 'Include the full https:// address.' },
149
+ ]);
150
+ },
151
+ },
152
+ {
153
+ id: 'blank',
154
+ label: 'Blank',
155
+ description: 'Start with no fields.',
156
+ build(): TemplateField[] {
157
+ return [];
158
+ },
159
+ },
160
+ ];
161
+
162
+ /** Templates offered for the builder, gated by whether PII field types are enabled. */
163
+ export function availableFormTemplates(pii: boolean): SubmissionFormTemplate[] {
164
+ return pii ? SUBMISSION_FORM_TEMPLATES : SUBMISSION_FORM_TEMPLATES.filter((t) => !t.pii);
165
+ }
@@ -87,6 +87,9 @@ export function standardContestTemplate(opts: StandardTemplateOptions): ContestT
87
87
  description: 'Entrants submit a proposal for review.',
88
88
  submissionMode: opts.proposals ? 'proposal' : 'attach',
89
89
  submissionTemplate: standardSubmissionTemplate(opts),
90
+ instructionsBlocks: markdownToBlockTuples(
91
+ 'Tell us about your idea. Be concrete about what you will build and why it matters. You can edit your proposal until the round closes.',
92
+ ) as ContestStage['instructionsBlocks'],
90
93
  },
91
94
  {
92
95
  id: newStageId(),