@commonpub/layer 0.85.0 → 0.86.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.
@@ -2,10 +2,16 @@
2
2
  const props = defineProps<{ content: Record<string, unknown> }>();
3
3
 
4
4
  const embedUrl = computed(() => toEmbedUrl(props.content.url as string));
5
+
6
+ type EmbedSize = 's' | 'm' | 'l' | 'full';
7
+ const size = computed<EmbedSize>(() => {
8
+ const v = props.content.size;
9
+ return v === 's' || v === 'm' || v === 'l' || v === 'full' ? v : 'l';
10
+ });
5
11
  </script>
6
12
 
7
13
  <template>
8
- <div v-if="embedUrl" class="cpub-block-embed">
14
+ <div v-if="embedUrl" class="cpub-block-embed" :class="`cpub-embed-size-${size}`">
9
15
  <div class="cpub-embed-label">
10
16
  <i class="fa-solid fa-globe"></i> Embed
11
17
  </div>
@@ -17,11 +23,17 @@ const embedUrl = computed(() => toEmbedUrl(props.content.url as string));
17
23
 
18
24
  <style scoped>
19
25
  .cpub-block-embed {
20
- margin: 24px 0;
26
+ /* width:100% so max-width caps + margin:auto centers in the flex-column renderer. */
27
+ width: 100%;
28
+ margin: 24px auto;
21
29
  border: var(--border-width-default) solid var(--border);
22
30
  overflow: hidden;
23
31
  box-shadow: var(--shadow-md);
24
32
  }
33
+ .cpub-embed-size-s { max-width: 320px; }
34
+ .cpub-embed-size-m { max-width: 540px; }
35
+ .cpub-embed-size-l { max-width: 760px; }
36
+ .cpub-embed-size-full { max-width: 100%; }
25
37
 
26
38
  .cpub-embed-label {
27
39
  padding: 6px 12px;
@@ -4,6 +4,14 @@ const props = defineProps<{ content: Record<string, unknown> }>();
4
4
  const url = computed(() => (props.content.url as string) || '');
5
5
  const embedUrl = computed(() => toEmbedUrl(url.value));
6
6
 
7
+ // Rendered-width preset (mirrors images). Missing ⇒ 'l' (760px, reading width),
8
+ // so a video doesn't stretch full-bleed and dominate the column.
9
+ type VideoSize = 's' | 'm' | 'l' | 'full';
10
+ const size = computed<VideoSize>(() => {
11
+ const v = props.content.size;
12
+ return v === 's' || v === 'm' || v === 'l' || v === 'full' ? v : 'l';
13
+ });
14
+
7
15
  const platform = computed(() => {
8
16
  const u = url.value;
9
17
  if (u.includes('youtube') || u.includes('youtu.be')) return 'YouTube';
@@ -13,7 +21,7 @@ const platform = computed(() => {
13
21
  </script>
14
22
 
15
23
  <template>
16
- <div v-if="embedUrl" class="cpub-block-video">
24
+ <div v-if="embedUrl" class="cpub-block-video" :class="`cpub-video-size-${size}`">
17
25
  <div class="cpub-video-label">
18
26
  <i class="fa-solid fa-film"></i> {{ platform }}
19
27
  </div>
@@ -33,11 +41,20 @@ const platform = computed(() => {
33
41
 
34
42
  <style scoped>
35
43
  .cpub-block-video {
36
- margin: 24px 0;
44
+ /* width:100% is required so max-width caps + margin:auto centers even inside the
45
+ flex-column block renderer (without it, an auto-margin flex item with no
46
+ intrinsic width shrinks to its label). */
47
+ width: 100%;
48
+ margin: 24px auto;
37
49
  border: var(--border-width-default) solid var(--border);
38
50
  overflow: hidden;
39
51
  box-shadow: var(--shadow-md);
40
52
  }
53
+ /* Width presets (centered via margin:auto). Match the image-block caps. */
54
+ .cpub-video-size-s { max-width: 320px; }
55
+ .cpub-video-size-m { max-width: 540px; }
56
+ .cpub-video-size-l { max-width: 760px; }
57
+ .cpub-video-size-full { max-width: 100%; }
41
58
 
42
59
  .cpub-video-label {
43
60
  padding: 6px 12px;
@@ -1,13 +1,22 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
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).
4
+ * cover (P4). Three explicit modes so the choice (and what persists) is never
5
+ * ambiguous:
6
+ * Fill (default) → meta = null → object-fit: cover (fills the band, crops).
7
+ * Fit (whole) → meta = {zoom:0} the WHOLE image shows (no crop). On the
8
+ * public hero the band grows to the image; here it previews
9
+ * contained.
10
+ * • Zoom → meta = {zoom>0, x, y} → cover + scale + drag-to-reposition.
11
+ *
12
+ * The earlier single slider defaulted a null banner to the "Fit" position without
13
+ * persisting it (moving the slider to where it already sat emitted nothing), so a
14
+ * banner that looked "set to Fit" stayed null → cover → cropped. Explicit mode
15
+ * buttons each emit a concrete value, which fixes that. Render parity with the
16
+ * public hero is via the shared `imageFramingStyle` / `isWholeImage`.
8
17
  */
9
18
  import type { ContestImageMeta } from '@commonpub/schema';
10
- import { imageFramingStyle, defaultImageMeta } from '../../utils/contestImage';
19
+ import { imageFramingStyle } from '../../utils/contestImage';
11
20
 
12
21
  defineProps<{
13
22
  imageUrl: string;
@@ -17,21 +26,36 @@ defineProps<{
17
26
  }>();
18
27
  const meta = defineModel<ContestImageMeta | null>({ default: null });
19
28
 
29
+ const ZOOM_MIN = 0.1;
20
30
  const ZOOM_MAX = 1.5;
21
- const current = computed<ContestImageMeta>(() => meta.value ?? defaultImageMeta());
31
+ const ZOOM_DEFAULT = 0.3;
32
+
33
+ type Mode = 'fill' | 'fit' | 'zoom';
34
+ const mode = computed<Mode>(() => {
35
+ if (!meta.value) return 'fill';
36
+ return meta.value.zoom <= 0 ? 'fit' : 'zoom';
37
+ });
22
38
  const framing = computed(() => imageFramingStyle(meta.value));
39
+ const pos = computed(() => ({ x: meta.value?.x ?? 50, y: meta.value?.y ?? 50 }));
23
40
 
24
- function patch(p: Partial<ContestImageMeta>): void {
25
- meta.value = { ...current.value, ...p };
41
+ function setMode(next: Mode): void {
42
+ if (next === 'fill') meta.value = null;
43
+ else if (next === 'fit') meta.value = { zoom: 0, x: pos.value.x, y: pos.value.y };
44
+ else meta.value = { zoom: meta.value && meta.value.zoom > 0 ? meta.value.zoom : ZOOM_DEFAULT, x: pos.value.x, y: pos.value.y };
26
45
  }
27
46
  function onZoom(e: Event): void {
28
- patch({ zoom: Number((e.target as HTMLInputElement).value) });
29
- }
30
- function reset(): void {
31
- meta.value = null;
47
+ const zoom = Math.max(ZOOM_MIN, Number((e.target as HTMLInputElement).value));
48
+ meta.value = { zoom, x: pos.value.x, y: pos.value.y };
32
49
  }
33
50
 
34
- // ─── Drag to reposition (sets x/y as percent) ───
51
+ const MODES: { key: Mode; label: string; icon: string; hint: string }[] = [
52
+ { key: 'fill', label: 'Fill', icon: 'fa-expand', hint: 'Fills the banner and crops the edges.' },
53
+ { key: 'fit', label: 'Fit', icon: 'fa-compress', hint: 'Shows the whole image, never cropped.' },
54
+ { key: 'zoom', label: 'Zoom', icon: 'fa-magnifying-glass-plus', hint: 'Zoom in and drag to choose what shows.' },
55
+ ];
56
+ const activeHint = computed(() => MODES.find((m) => m.key === mode.value)?.hint ?? '');
57
+
58
+ // ─── Drag to reposition (Zoom mode only) ───
35
59
  const boxRef = ref<HTMLElement | null>(null);
36
60
  const dragging = ref(false);
37
61
  let startX = 0;
@@ -40,22 +64,21 @@ let startPosX = 50;
40
64
  let startPosY = 50;
41
65
 
42
66
  function onPointerDown(e: PointerEvent): void {
43
- if (!boxRef.value) return;
67
+ if (mode.value !== 'zoom' || !boxRef.value) return;
44
68
  dragging.value = true;
45
69
  startX = e.clientX;
46
70
  startY = e.clientY;
47
- startPosX = current.value.x;
48
- startPosY = current.value.y;
71
+ startPosX = pos.value.x;
72
+ startPosY = pos.value.y;
49
73
  document.addEventListener('pointermove', onPointerMove);
50
74
  document.addEventListener('pointerup', onPointerUp);
51
75
  }
52
76
  function onPointerMove(e: PointerEvent): void {
53
- if (!dragging.value || !boxRef.value) return;
77
+ if (!dragging.value || !boxRef.value || !meta.value) return;
54
78
  const rect = boxRef.value.getBoundingClientRect();
55
- // Drag right reveals the LEFT of the image (object-position decreases), so invert.
56
79
  const dx = ((e.clientX - startX) / Math.max(1, rect.width)) * 100;
57
80
  const dy = ((e.clientY - startY) / Math.max(1, rect.height)) * 100;
58
- patch({ x: clamp(startPosX - dx), y: clamp(startPosY - dy) });
81
+ meta.value = { zoom: meta.value.zoom, x: clamp(startPosX - dx), y: clamp(startPosY - dy) };
59
82
  }
60
83
  function onPointerUp(): void {
61
84
  dragging.value = false;
@@ -69,53 +92,66 @@ onUnmounted(() => {
69
92
  document.removeEventListener('pointermove', onPointerMove);
70
93
  document.removeEventListener('pointerup', onPointerUp);
71
94
  });
72
-
73
- const zoomLabel = computed(() => (current.value.zoom <= 0 ? 'Fit' : `${Math.round(current.value.zoom * 100)}%`));
74
95
  </script>
75
96
 
76
97
  <template>
77
98
  <div class="cpub-ba">
99
+ <div class="cpub-ba-modes" role="group" :aria-label="`${label ?? 'Image'} framing`">
100
+ <button
101
+ v-for="m in MODES"
102
+ :key="m.key"
103
+ type="button"
104
+ class="cpub-ba-mode"
105
+ :class="{ 'cpub-ba-mode-active': mode === m.key }"
106
+ :aria-pressed="mode === m.key"
107
+ @click="setMode(m.key)"
108
+ >
109
+ <i class="fa-solid" :class="m.icon"></i> {{ m.label }}
110
+ </button>
111
+ </div>
112
+
78
113
  <div
79
114
  ref="boxRef"
80
115
  class="cpub-ba-box"
81
- :class="{ 'cpub-ba-drag': dragging }"
116
+ :class="{ 'cpub-ba-drag': mode === 'zoom', 'cpub-ba-dragging': dragging }"
82
117
  :style="{ aspectRatio: aspect ?? '4 / 1' }"
83
118
  @pointerdown="onPointerDown"
84
119
  >
85
120
  <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>
121
+ <span v-if="mode === 'zoom'" class="cpub-ba-hint"><i class="fa-solid fa-up-down-left-right"></i> Drag to reposition</span>
104
122
  </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>
123
+
124
+ <label v-if="mode === 'zoom'" class="cpub-ba-zoom">
125
+ <span class="cpub-ba-zoom-label">Zoom</span>
126
+ <input
127
+ type="range"
128
+ :min="ZOOM_MIN"
129
+ :max="ZOOM_MAX"
130
+ step="0.05"
131
+ :value="meta?.zoom ?? ZOOM_DEFAULT"
132
+ :aria-label="`${label ?? 'Image'} zoom level`"
133
+ @input="onZoom"
134
+ />
135
+ </label>
136
+
137
+ <p class="cpub-ba-help">{{ activeHint }}</p>
106
138
  </div>
107
139
  </template>
108
140
 
109
141
  <style scoped>
110
142
  .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; }
143
+ .cpub-ba-modes { display: inline-flex; border: var(--border-width-default) solid var(--border); align-self: flex-start; }
144
+ .cpub-ba-mode { display: inline-flex; align-items: center; gap: 5px; padding: 5px 12px; background: transparent; border: none; border-right: var(--border-width-default) solid var(--border); cursor: pointer; font-size: var(--text-xs); font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .04em; color: var(--text-faint); }
145
+ .cpub-ba-mode:last-child { border-right: none; }
146
+ .cpub-ba-mode:hover { background: var(--surface2); color: var(--text-dim); }
147
+ .cpub-ba-mode-active { background: var(--accent-bg); color: var(--accent); }
148
+ .cpub-ba-box { position: relative; width: 100%; overflow: hidden; border: var(--border-width-default) solid var(--border); background: var(--surface2); touch-action: none; }
149
+ .cpub-ba-drag { cursor: grab; }
150
+ .cpub-ba-dragging { cursor: grabbing; }
113
151
  .cpub-ba-img { display: block; width: 100%; height: 100%; user-select: none; -webkit-user-drag: none; }
114
152
  .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; }
153
+ .cpub-ba-zoom { display: flex; align-items: center; gap: 8px; }
117
154
  .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
155
  .cpub-ba-zoom input[type='range'] { flex: 1; accent-color: var(--accent); }
119
- .cpub-ba-reset { flex-shrink: 0; }
120
156
  .cpub-ba-help { margin: 0; font-size: 11px; color: var(--text-faint); line-height: 1.5; }
121
157
  </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, bannerMeta, coverMeta, startDate, endDate, judgingEndDate, communityVotingEnabled,
67
+ bannerUrl, coverImageUrl, bannerMeta, coverMeta, coverPlacement, 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,
@@ -262,6 +262,8 @@ const showBannerAdjust = ref(false);
262
262
  const showCoverAdjust = ref(false);
263
263
  const bannerPreviewStyle = computed(() => imageFramingStyle(bannerMeta.value));
264
264
  const coverPreviewStyle = computed(() => imageFramingStyle(coverMeta.value));
265
+ const bannerPreviewWhole = computed(() => isWholeImage(bannerMeta.value));
266
+ const coverPreviewWhole = computed(() => isWholeImage(coverMeta.value));
265
267
 
266
268
  // --- Right-rail collapsible sections ---
267
269
  const openSections = ref<Record<string, boolean>>({
@@ -486,8 +488,8 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
486
488
  <!-- Banner + cover render inline at the top of the Overview body only. -->
487
489
  <template #overview-lead>
488
490
  <div class="cpub-ce-media">
489
- <div class="cpub-ce-banner" :class="{ 'has-image': !!bannerUrl }">
490
- <img v-if="bannerUrl" :src="bannerUrl" alt="Contest banner" class="cpub-ce-banner-img" :style="bannerPreviewStyle" />
491
+ <div class="cpub-ce-banner" :class="{ 'has-image': !!bannerUrl, 'cpub-ce-banner--whole': bannerPreviewWhole }">
492
+ <img v-if="bannerUrl" :src="bannerUrl" alt="Contest banner" class="cpub-ce-banner-img" :style="bannerPreviewWhole ? undefined : bannerPreviewStyle" />
491
493
  <div v-else class="cpub-ce-media-placeholder">
492
494
  <i class="fa-regular fa-image"></i>
493
495
  <span>Banner image</span>
@@ -503,8 +505,8 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
503
505
  </div>
504
506
 
505
507
  <!-- Cover thumbnail, inset over the banner's lower-left (mirrors the public hero). -->
506
- <div class="cpub-ce-cover" :class="{ 'has-image': !!coverImageUrl }">
507
- <img v-if="coverImageUrl" :src="coverImageUrl" alt="Contest cover" class="cpub-ce-cover-img" :style="coverPreviewStyle" />
508
+ <div class="cpub-ce-cover" :class="{ 'has-image': !!coverImageUrl, 'cpub-ce-cover--whole': coverPreviewWhole }">
509
+ <img v-if="coverImageUrl" :src="coverImageUrl" alt="Contest cover" class="cpub-ce-cover-img" :style="coverPreviewWhole ? undefined : coverPreviewStyle" />
508
510
  <div v-else class="cpub-ce-media-placeholder cpub-ce-media-placeholder-sm">
509
511
  <i class="fa-regular fa-image"></i>
510
512
  <span>Cover</span>
@@ -524,7 +526,16 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
524
526
  <ContestBannerAdjust v-if="bannerUrl && showBannerAdjust" v-model="bannerMeta" :image-url="bannerUrl" aspect="4 / 1" label="Banner" class="cpub-ce-adjust" />
525
527
  <ContestBannerAdjust v-if="coverImageUrl && showCoverAdjust" v-model="coverMeta" :image-url="coverImageUrl" aspect="4 / 3" label="Cover" class="cpub-ce-adjust" />
526
528
 
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>
529
+ <!-- Where the cover image shows on the public page. -->
530
+ <label v-if="coverImageUrl" class="cpub-ce-cover-place">
531
+ <span class="cpub-form-label" style="margin: 0;">Show cover</span>
532
+ <select :value="coverPlacement ?? 'about'" class="cpub-form-input" @change="coverPlacement = (($event.target as HTMLSelectElement).value as 'about' | 'hero')">
533
+ <option value="about">In the Overview "About" section</option>
534
+ <option value="hero">In the hero, under the subheading</option>
535
+ </select>
536
+ </label>
537
+
538
+ <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 set Fill, Fit (whole image), or Zoom without re-cropping.</p>
528
539
  </div>
529
540
  </template>
530
541
 
@@ -890,6 +901,12 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
890
901
  .cpub-ce-media-btn.active { background: var(--accent-bg); color: var(--accent); border-color: var(--accent); }
891
902
  .cpub-ce-media-hint { margin: 0; }
892
903
  .cpub-ce-adjust { margin-top: 8px; padding: 10px; border: var(--border-width-default) solid var(--border); background: var(--surface2); }
904
+ .cpub-ce-cover-place { display: flex; align-items: center; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
905
+ .cpub-ce-cover-place .cpub-form-input { width: auto; flex: 1; min-width: 220px; 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); }
906
+ /* Fit (whole image) previews: let the box grow to the image, no crop. */
907
+ .cpub-ce-banner--whole { aspect-ratio: auto; max-height: 300px; }
908
+ .cpub-ce-banner--whole .cpub-ce-banner-img { height: auto; max-height: 300px; object-fit: contain; }
909
+ .cpub-ce-cover--whole .cpub-ce-cover-img { object-fit: contain; }
893
910
  .cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
894
911
 
895
912
  /* --- Responsive: stack the rail under the body on narrow viewports --- */
@@ -19,6 +19,15 @@ const c = computed(() => props.contest);
19
19
  // Non-destructive banner framing (P4). null/absent ⇒ the legacy cover fit, so
20
20
  // existing contests look identical until an organiser adjusts framing.
21
21
  const bannerStyle = computed(() => imageFramingStyle(c.value?.bannerMeta ?? null));
22
+ // Fit (zoom 0) shows the WHOLE banner: the band grows to the image (no crop, no
23
+ // letterbox bars). Cover/zoom keep the slim fixed band.
24
+ const bannerWhole = computed(() => isWholeImage(c.value?.bannerMeta ?? null));
25
+
26
+ // Cover placement: `hero` renders the cover under the subheading here; otherwise it
27
+ // stays in the Overview "About" section on the public page (default).
28
+ const coverInHero = computed(() => c.value?.coverPlacement === 'hero' && !!c.value?.coverImageUrl);
29
+ const coverStyle = computed(() => imageFramingStyle(c.value?.coverMeta ?? null));
30
+ const coverWhole = computed(() => isWholeImage(c.value?.coverMeta ?? null));
22
31
 
23
32
  // Local wall-clock formatting (dates) and the live countdown are timezone- and
24
33
  // clock-dependent, so they would mismatch between the server's TZ and the viewer's
@@ -145,8 +154,8 @@ const entryCount = computed<number>(() => c.value?.entryCount ?? 0);
145
154
  <template>
146
155
  <div class="cpub-hero">
147
156
  <!-- Slim banner band (constrained) — the hero image, not a tall block. -->
148
- <div v-if="c?.bannerUrl" class="cpub-hero-banner">
149
- <img :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" :style="bannerStyle" />
157
+ <div v-if="c?.bannerUrl" class="cpub-hero-banner" :class="{ 'cpub-hero-banner--whole': bannerWhole }">
158
+ <img :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" :style="bannerWhole ? undefined : bannerStyle" />
150
159
  </div>
151
160
 
152
161
  <!-- Compact bar — title + status + meta + actions in one tight, clean band
@@ -173,6 +182,14 @@ const entryCount = computed<number>(() => c.value?.entryCount ?? 0);
173
182
 
174
183
  <h1 class="cpub-hero-title">{{ c?.title || 'Contest' }}</h1>
175
184
  <p v-if="tagline" class="cpub-hero-tagline">{{ tagline }}</p>
185
+ <img
186
+ v-if="coverInHero"
187
+ :src="c!.coverImageUrl!"
188
+ :alt="`${c?.title || 'Contest'} cover`"
189
+ class="cpub-hero-cover"
190
+ :class="{ 'cpub-hero-cover--whole': coverWhole }"
191
+ :style="coverWhole ? undefined : coverStyle"
192
+ />
176
193
 
177
194
  <div class="cpub-hero-foot">
178
195
  <div class="cpub-hero-meta">
@@ -213,6 +230,13 @@ const entryCount = computed<number>(() => c.value?.entryCount ?? 0);
213
230
  overflow: hidden;
214
231
  }
215
232
  .cpub-hero-banner img { display: block; width: 100%; height: 100%; object-fit: cover; }
233
+ /* Fit (whole image): let the band grow to the image so nothing is cropped and there
234
+ are no letterbox bars (full width, natural height, capped for very tall images). */
235
+ .cpub-hero-banner--whole { aspect-ratio: auto; max-height: none; background: transparent; }
236
+ .cpub-hero-banner--whole img { height: auto; max-height: 70vh; object-fit: contain; }
237
+ /* Cover image placed in the hero (under the subheading) when coverPlacement=hero. */
238
+ .cpub-hero-cover { display: block; width: 100%; max-width: 680px; max-height: 320px; object-fit: cover; border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); margin: 0 0 14px; }
239
+ .cpub-hero-cover--whole { height: auto; max-height: 420px; object-fit: contain; }
216
240
 
217
241
  /* ── COMPACT BAR ── one tight, clean band on a surface background. */
218
242
  .cpub-hero-bar { background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); }
@@ -240,7 +264,7 @@ const entryCount = computed<number>(() => c.value?.entryCount ?? 0);
240
264
  .cpub-countdown-chip-muted i { color: var(--text-faint); }
241
265
 
242
266
  .cpub-hero-title { font-size: 26px; font-weight: 800; letter-spacing: -.02em; line-height: 1.15; margin: 0 0 6px; color: var(--text); }
243
- .cpub-hero-tagline { font-size: 13px; color: var(--text-dim); line-height: 1.55; max-width: 680px; margin: 0 0 14px; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
267
+ .cpub-hero-tagline { font-size: 14px; color: var(--text-dim); line-height: 1.6; max-width: 760px; margin: 0 0 14px; }
244
268
 
245
269
  .cpub-hero-foot { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
246
270
  .cpub-hero-meta { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; font-size: 11px; color: var(--text-faint); font-family: var(--font-mono); }
@@ -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, ContestImageMeta } from '@commonpub/schema';
17
+ import type { ContestStage, ContestImageMeta, ContestCoverPlacement } from '@commonpub/schema';
18
18
  import type { ContestTemplateSeed } from '../utils/contestTemplates';
19
19
 
20
20
  export type ContestFormat = 'markdown' | 'html';
@@ -54,6 +54,7 @@ export interface ContestEditorSource {
54
54
  coverImageUrl?: string | null;
55
55
  bannerMeta?: ContestImageMeta | null;
56
56
  coverMeta?: ContestImageMeta | null;
57
+ coverPlacement?: ContestCoverPlacement | null;
57
58
  startDate?: string | null;
58
59
  endDate?: string | null;
59
60
  judgingEndDate?: string | null;
@@ -109,6 +110,7 @@ export interface UseContestEditor {
109
110
  coverImageUrl: Ref<string>;
110
111
  bannerMeta: Ref<ContestImageMeta | null>;
111
112
  coverMeta: Ref<ContestImageMeta | null>;
113
+ coverPlacement: Ref<ContestCoverPlacement | null>;
112
114
  startDate: Ref<string>;
113
115
  endDate: Ref<string>;
114
116
  judgingEndDate: Ref<string>;
@@ -178,6 +180,7 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
178
180
  const coverImageUrl = ref('');
179
181
  const bannerMeta = ref<ContestImageMeta | null>(null);
180
182
  const coverMeta = ref<ContestImageMeta | null>(null);
183
+ const coverPlacement = ref<ContestCoverPlacement | null>(null);
181
184
  const startDate = ref('');
182
185
  const endDate = ref('');
183
186
  const judgingEndDate = ref('');
@@ -260,6 +263,7 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
260
263
  coverImageUrl.value = c.coverImageUrl ?? '';
261
264
  bannerMeta.value = c.bannerMeta ?? null;
262
265
  coverMeta.value = c.coverMeta ?? null;
266
+ coverPlacement.value = c.coverPlacement ?? null;
263
267
  // ISO instants stored verbatim; CpubDateTimeField renders them in local time.
264
268
  startDate.value = c.startDate ?? '';
265
269
  endDate.value = c.endDate ?? '';
@@ -346,6 +350,7 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
346
350
  // Clear the framing when the image is removed; else send it (or leave as-is).
347
351
  bannerMeta: bannerUrl.value ? (bannerMeta.value ?? undefined) : null,
348
352
  coverMeta: coverImageUrl.value ? (coverMeta.value ?? undefined) : null,
353
+ coverPlacement: coverImageUrl.value ? (coverPlacement.value ?? undefined) : null,
349
354
  startDate: startDate.value || undefined,
350
355
  endDate: endDate.value || undefined,
351
356
  judgingEndDate: judgingEndDate.value || undefined,
@@ -400,7 +405,7 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
400
405
  // Any post-hydration edit flips the dirty flag (drives the topbar "unsaved" cue).
401
406
  watch(
402
407
  [title, slugInput, subheading, description, descriptionBlocks, rulesBlocks, prizesBlocks, rules,
403
- descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, bannerMeta, coverMeta, startDate, endDate,
408
+ descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, bannerMeta, coverMeta, coverPlacement, startDate, endDate,
404
409
  judgingEndDate, communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser,
405
410
  visibility, visibleToRoles, showPrizes, prizesDescription, prizes, criteria, stages, currentStageId],
406
411
  () => { if (!hydrating) formDirty.value = true; },
@@ -414,7 +419,7 @@ export function useContestEditor(opts: UseContestEditorOptions): UseContestEdito
414
419
 
415
420
  return {
416
421
  title, slugInput, slugTouched, subheading, description, descriptionBlocks, rulesBlocks, prizesBlocks,
417
- rules, descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, bannerMeta, coverMeta, startDate,
422
+ rules, descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, bannerMeta, coverMeta, coverPlacement, startDate,
418
423
  endDate, judgingEndDate, communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser,
419
424
  visibility, visibleToRoles, showPrizes, prizesDescription, prizes, criteria, stages, currentStageId,
420
425
  saving, formDirty, dateError, canSubmit,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.85.0",
3
+ "version": "0.86.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -54,17 +54,17 @@
54
54
  "vue-advanced-cropper": "^2.8.9",
55
55
  "vue-router": "^4.3.0",
56
56
  "zod": "^4.3.6",
57
- "@commonpub/config": "0.23.0",
58
- "@commonpub/editor": "0.8.0",
59
57
  "@commonpub/auth": "0.8.0",
60
- "@commonpub/docs": "0.6.3",
61
- "@commonpub/theme-studio": "0.6.1",
62
- "@commonpub/protocol": "0.14.0",
63
- "@commonpub/schema": "0.47.0",
64
58
  "@commonpub/learning": "0.5.2",
65
- "@commonpub/server": "2.91.0",
66
59
  "@commonpub/explainer": "0.8.0",
67
- "@commonpub/ui": "0.13.1"
60
+ "@commonpub/schema": "0.48.0",
61
+ "@commonpub/server": "2.92.0",
62
+ "@commonpub/protocol": "0.14.0",
63
+ "@commonpub/theme-studio": "0.6.1",
64
+ "@commonpub/config": "0.23.0",
65
+ "@commonpub/ui": "0.13.1",
66
+ "@commonpub/editor": "0.9.0",
67
+ "@commonpub/docs": "0.6.3"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@testing-library/jest-dom": "^6.9.1",
@@ -267,6 +267,30 @@ async function withdrawEntry(entryId: string): Promise<void> {
267
267
  @copy-link="copyLink"
268
268
  />
269
269
 
270
+ <!-- Section tabs — a prominent centered band directly under the hero. -->
271
+ <nav class="cpub-ctabs" aria-label="Contest sections">
272
+ <div class="cpub-ctabs-inner" role="tablist">
273
+ <button
274
+ v-for="tab in tabs"
275
+ :id="`cpub-tab-${tab.key}`"
276
+ :key="tab.key"
277
+ role="tab"
278
+ type="button"
279
+ class="cpub-ctab"
280
+ :class="{ 'cpub-ctab-active': activeTab === tab.key }"
281
+ :aria-selected="activeTab === tab.key"
282
+ :aria-controls="`cpub-panel-${tab.key}`"
283
+ :tabindex="activeTab === tab.key ? 0 : -1"
284
+ @click="activeTab = tab.key"
285
+ @keydown="onTabKey($event, tab.key)"
286
+ >
287
+ <i class="fa-solid" :class="tab.icon"></i>
288
+ <span class="cpub-ctab-label">{{ tab.label }}</span>
289
+ <span v-if="tab.count != null" class="cpub-ctab-count">{{ tab.count }}</span>
290
+ </button>
291
+ </div>
292
+ </nav>
293
+
270
294
  <!-- SUBMIT ENTRY DIALOG -->
271
295
  <div v-if="showSubmitDialog" class="cpub-submit-overlay" @click.self="showSubmitDialog = false">
272
296
  <div ref="submitDialogRef" class="cpub-submit-dialog" role="dialog" aria-modal="true" aria-label="Submit entry">
@@ -340,32 +364,19 @@ async function withdrawEntry(entryId: string): Promise<void> {
340
364
  <span>{{ visibilityNote.text }}</span>
341
365
  </div>
342
366
 
343
- <!-- Tab bar -->
344
- <div class="cpub-tabbar" role="tablist" aria-label="Contest sections">
345
- <button
346
- v-for="tab in tabs"
347
- :id="`cpub-tab-${tab.key}`"
348
- :key="tab.key"
349
- role="tab"
350
- type="button"
351
- class="cpub-tab"
352
- :class="{ 'cpub-tab-active': activeTab === tab.key }"
353
- :aria-selected="activeTab === tab.key"
354
- :aria-controls="`cpub-panel-${tab.key}`"
355
- :tabindex="activeTab === tab.key ? 0 : -1"
356
- @click="activeTab = tab.key"
357
- @keydown="onTabKey($event, tab.key)"
358
- >
359
- <i class="fa-solid" :class="tab.icon"></i> {{ tab.label }}
360
- <span v-if="tab.count != null" class="cpub-tab-count">{{ tab.count }}</span>
361
- </button>
362
- </div>
363
367
 
364
368
  <!-- OVERVIEW -->
365
369
  <div v-show="activeTab === 'overview'" id="cpub-panel-overview" role="tabpanel" aria-labelledby="cpub-tab-overview" tabindex="0">
366
370
  <div class="cpub-about-section">
367
371
  <div class="cpub-sec-head"><h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2></div>
368
- <img v-if="c?.coverImageUrl" :src="c.coverImageUrl" :alt="`${c?.title || 'Contest'} cover`" class="cpub-about-cover" />
372
+ <img
373
+ v-if="c?.coverImageUrl && c?.coverPlacement !== 'hero'"
374
+ :src="c.coverImageUrl"
375
+ :alt="`${c?.title || 'Contest'} cover`"
376
+ class="cpub-about-cover"
377
+ :class="{ 'cpub-about-cover--whole': isWholeImage(c?.coverMeta) }"
378
+ :style="isWholeImage(c?.coverMeta) ? undefined : imageFramingStyle(c?.coverMeta)"
379
+ />
369
380
  <div class="cpub-about-card">
370
381
  <BlocksBlockContentRenderer
371
382
  v-if="c?.descriptionBlocks?.length"
@@ -525,14 +536,23 @@ async function withdrawEntry(entryId: string): Promise<void> {
525
536
  .cpub-invite-text i { color: var(--accent); }
526
537
 
527
538
  /* TABS */
528
- .cpub-tabbar { display: flex; gap: 2px; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; border-bottom: var(--border-width-default) solid var(--border); margin-bottom: 20px; }
529
- .cpub-tabbar::-webkit-scrollbar { display: none; }
530
- .cpub-tab { display: inline-flex; align-items: center; gap: 6px; padding: 9px 14px; min-height: 40px; flex-shrink: 0; white-space: nowrap; background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; font-family: var(--font-mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-faint); cursor: pointer; }
531
- .cpub-tab:hover { color: var(--text-dim); }
532
- .cpub-tab-active { color: var(--accent); border-bottom-color: var(--accent); }
533
- .cpub-tab i { font-size: 11px; }
534
- .cpub-tab-count { font-size: 9px; padding: 1px 6px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); }
535
- .cpub-tab-active .cpub-tab-count { background: var(--accent-bg); border-color: var(--accent-border); color: var(--accent); }
539
+ /* Section tabs a prominent centered nav band directly under the hero. Larger
540
+ bold mono labels; the active tab gets an accent tint + thick accent underline so
541
+ it clearly reads as selected even when there are only a couple of tabs. */
542
+ .cpub-ctabs { background: var(--surface); border-bottom: 2px solid var(--border); box-shadow: var(--shadow-sm); }
543
+ .cpub-ctabs-inner { max-width: 1100px; margin: 0 auto; padding: 0 24px; display: flex; justify-content: center; flex-wrap: wrap; }
544
+ .cpub-ctab { display: inline-flex; align-items: center; gap: 9px; padding: 17px 28px; min-height: 58px; background: none; border: none; border-bottom: 3px solid transparent; margin-bottom: -2px; font-family: var(--font-mono); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--text-faint); cursor: pointer; transition: color .12s, background .12s; }
545
+ .cpub-ctab:hover { color: var(--text); background: var(--surface2); }
546
+ .cpub-ctab-active { color: var(--accent); border-bottom-color: var(--accent); background: var(--accent-bg); }
547
+ .cpub-ctab i { font-size: 14px; }
548
+ .cpub-ctab-count { font-size: 10px; font-weight: 700; padding: 2px 8px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); }
549
+ .cpub-ctab-active .cpub-ctab-count { background: var(--surface); border-color: var(--accent-border); color: var(--accent); }
550
+ @media (max-width: 640px) {
551
+ .cpub-ctabs-inner { justify-content: flex-start; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
552
+ .cpub-ctabs-inner::-webkit-scrollbar { display: none; }
553
+ .cpub-ctab { flex-shrink: 0; padding: 14px 18px; min-height: 50px; font-size: 12px; }
554
+ .cpub-ctab-label { white-space: nowrap; }
555
+ }
536
556
 
537
557
  [role="tabpanel"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
538
558
 
@@ -559,6 +579,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
559
579
  /* ABOUT */
560
580
  .cpub-about-section { margin-bottom: 20px; }
561
581
  .cpub-about-cover { width: 100%; max-height: 380px; object-fit: cover; display: block; border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); margin-bottom: 16px; }
582
+ .cpub-about-cover--whole { height: auto; max-height: 480px; object-fit: contain; }
562
583
  .cpub-about-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-md); font-size: 12px; color: var(--text-dim); line-height: 1.7; }
563
584
  .cpub-about-card p { margin: 0; white-space: pre-line; }
564
585
 
@@ -33,3 +33,13 @@ function clampPct(n: number): number {
33
33
  export function defaultImageMeta(): ContestImageMeta {
34
34
  return { zoom: 0, x: 50, y: 50 };
35
35
  }
36
+
37
+ /**
38
+ * True when the framing means "show the WHOLE image" (Fit, zoom 0). Surfaces that
39
+ * can grow (the hero banner band, the editor preview) render this as a natural-ratio
40
+ * image (no crop, no letterbox bars) rather than `contain` inside a fixed band.
41
+ * null/absent = the legacy cover fit, so this is false (existing banners unchanged).
42
+ */
43
+ export function isWholeImage(meta: ContestImageMeta | null | undefined): boolean {
44
+ return !!meta && meta.zoom <= 0;
45
+ }