@commonpub/layer 0.85.0 → 0.86.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/blocks/BlockEmbedView.vue +24 -9
- package/components/blocks/BlockVideoView.vue +31 -10
- package/components/contest/ContestBannerAdjust.vue +82 -46
- package/components/contest/ContestEditor.vue +23 -6
- package/components/contest/ContestHero.vue +27 -3
- package/composables/useContestEditor.ts +8 -3
- package/package.json +7 -7
- package/pages/contests/[slug]/index.vue +50 -29
- package/utils/contestImage.ts +10 -0
|
@@ -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:
|
|
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;
|
|
@@ -44,17 +56,20 @@ const embedUrl = computed(() => toEmbedUrl(props.content.url as string));
|
|
|
44
56
|
.cpub-embed-label i { color: var(--accent); }
|
|
45
57
|
|
|
46
58
|
.cpub-embed-wrap {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
height: 0;
|
|
59
|
+
display: block;
|
|
60
|
+
line-height: 0;
|
|
50
61
|
border-radius: 0;
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
/* 16:9 via aspect-ratio so a prose `iframe { height: auto }` rule cooperates with
|
|
65
|
+
the ratio rather than collapsing the iframe (see BlockVideoView). */
|
|
53
66
|
.cpub-embed-iframe {
|
|
54
|
-
|
|
55
|
-
top: 0;
|
|
56
|
-
left: 0;
|
|
67
|
+
display: block;
|
|
57
68
|
width: 100%;
|
|
58
|
-
|
|
69
|
+
aspect-ratio: 16 / 9;
|
|
70
|
+
height: auto;
|
|
71
|
+
border: 0;
|
|
72
|
+
background: var(--text);
|
|
73
|
+
border-radius: 0;
|
|
59
74
|
}
|
|
60
75
|
</style>
|
|
@@ -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:
|
|
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;
|
|
@@ -61,18 +78,22 @@ const platform = computed(() => {
|
|
|
61
78
|
.cpub-video-label i { color: var(--accent); }
|
|
62
79
|
|
|
63
80
|
.cpub-video-wrap {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
height: 0;
|
|
67
|
-
background: var(--text);
|
|
81
|
+
display: block;
|
|
82
|
+
line-height: 0;
|
|
68
83
|
border-radius: 0;
|
|
69
84
|
}
|
|
70
85
|
|
|
86
|
+
/* 16:9 via aspect-ratio on the iframe (not the legacy padding-bottom hack), so a
|
|
87
|
+
prose `iframe { height: auto }` rule (ProjectView/ArticleView/ExplainerView)
|
|
88
|
+
COOPERATES with the ratio instead of collapsing the iframe inside a 0-height
|
|
89
|
+
wrap and leaving black space below it. */
|
|
71
90
|
.cpub-video-iframe {
|
|
72
|
-
|
|
73
|
-
top: 0;
|
|
74
|
-
left: 0;
|
|
91
|
+
display: block;
|
|
75
92
|
width: 100%;
|
|
76
|
-
|
|
93
|
+
aspect-ratio: 16 / 9;
|
|
94
|
+
height: auto;
|
|
95
|
+
border: 0;
|
|
96
|
+
background: var(--text);
|
|
97
|
+
border-radius: 0;
|
|
77
98
|
}
|
|
78
99
|
</style>
|
|
@@ -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).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
48
|
-
startPosY =
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
112
|
-
.cpub-ba-
|
|
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-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
3
|
+
"version": "0.86.1",
|
|
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",
|
|
58
|
+
"@commonpub/config": "0.23.0",
|
|
60
59
|
"@commonpub/docs": "0.6.3",
|
|
61
|
-
"@commonpub/theme-studio": "0.6.1",
|
|
62
60
|
"@commonpub/protocol": "0.14.0",
|
|
63
|
-
"@commonpub/schema": "0.47.0",
|
|
64
61
|
"@commonpub/learning": "0.5.2",
|
|
65
|
-
"@commonpub/server": "2.91.0",
|
|
66
62
|
"@commonpub/explainer": "0.8.0",
|
|
67
|
-
"@commonpub/
|
|
63
|
+
"@commonpub/theme-studio": "0.6.1",
|
|
64
|
+
"@commonpub/server": "2.92.0",
|
|
65
|
+
"@commonpub/editor": "0.9.0",
|
|
66
|
+
"@commonpub/ui": "0.13.1",
|
|
67
|
+
"@commonpub/schema": "0.48.0"
|
|
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
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
.cpub-
|
|
532
|
-
.cpub-
|
|
533
|
-
.cpub-
|
|
534
|
-
.cpub-
|
|
535
|
-
.cpub-
|
|
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
|
|
package/utils/contestImage.ts
CHANGED
|
@@ -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
|
+
}
|