@commonpub/layer 0.84.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.
- package/components/blocks/BlockEmbedView.vue +14 -2
- package/components/blocks/BlockVideoView.vue +19 -2
- package/components/contest/ContestBannerAdjust.vue +157 -0
- package/components/contest/ContestEditor.vue +52 -6
- package/components/contest/ContestHero.vue +31 -3
- package/components/contest/ContestProposalForm.vue +3 -0
- package/components/contest/ContestStageCard.vue +7 -0
- package/components/contest/ContestStageSubmission.vue +3 -0
- package/components/contest/ContestStageTemplateEditor.vue +191 -8
- package/components/contest/blocks/JudgesShowcaseBlock.vue +113 -6
- package/composables/useContestEditor.ts +19 -3
- package/package.json +8 -8
- package/pages/contests/[slug]/index.vue +54 -30
- package/pages/contests/index.vue +1 -0
- package/utils/contestBlocks.ts +10 -0
- package/utils/contestBody.ts +3 -3
- package/utils/contestImage.ts +45 -0
- package/utils/contestSubmissionTemplates.ts +165 -0
- package/utils/contestTemplates.ts +3 -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;
|
|
@@ -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;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContestBannerAdjust — non-destructive framing control for a contest banner or
|
|
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`.
|
|
17
|
+
*/
|
|
18
|
+
import type { ContestImageMeta } from '@commonpub/schema';
|
|
19
|
+
import { imageFramingStyle } from '../../utils/contestImage';
|
|
20
|
+
|
|
21
|
+
defineProps<{
|
|
22
|
+
imageUrl: string;
|
|
23
|
+
/** CSS aspect-ratio for the preview box, e.g. '4 / 1' (banner), '4 / 3' (cover). */
|
|
24
|
+
aspect?: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
}>();
|
|
27
|
+
const meta = defineModel<ContestImageMeta | null>({ default: null });
|
|
28
|
+
|
|
29
|
+
const ZOOM_MIN = 0.1;
|
|
30
|
+
const ZOOM_MAX = 1.5;
|
|
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
|
+
});
|
|
38
|
+
const framing = computed(() => imageFramingStyle(meta.value));
|
|
39
|
+
const pos = computed(() => ({ x: meta.value?.x ?? 50, y: meta.value?.y ?? 50 }));
|
|
40
|
+
|
|
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 };
|
|
45
|
+
}
|
|
46
|
+
function onZoom(e: Event): void {
|
|
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 };
|
|
49
|
+
}
|
|
50
|
+
|
|
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) ───
|
|
59
|
+
const boxRef = ref<HTMLElement | null>(null);
|
|
60
|
+
const dragging = ref(false);
|
|
61
|
+
let startX = 0;
|
|
62
|
+
let startY = 0;
|
|
63
|
+
let startPosX = 50;
|
|
64
|
+
let startPosY = 50;
|
|
65
|
+
|
|
66
|
+
function onPointerDown(e: PointerEvent): void {
|
|
67
|
+
if (mode.value !== 'zoom' || !boxRef.value) return;
|
|
68
|
+
dragging.value = true;
|
|
69
|
+
startX = e.clientX;
|
|
70
|
+
startY = e.clientY;
|
|
71
|
+
startPosX = pos.value.x;
|
|
72
|
+
startPosY = pos.value.y;
|
|
73
|
+
document.addEventListener('pointermove', onPointerMove);
|
|
74
|
+
document.addEventListener('pointerup', onPointerUp);
|
|
75
|
+
}
|
|
76
|
+
function onPointerMove(e: PointerEvent): void {
|
|
77
|
+
if (!dragging.value || !boxRef.value || !meta.value) return;
|
|
78
|
+
const rect = boxRef.value.getBoundingClientRect();
|
|
79
|
+
const dx = ((e.clientX - startX) / Math.max(1, rect.width)) * 100;
|
|
80
|
+
const dy = ((e.clientY - startY) / Math.max(1, rect.height)) * 100;
|
|
81
|
+
meta.value = { zoom: meta.value.zoom, x: clamp(startPosX - dx), y: clamp(startPosY - dy) };
|
|
82
|
+
}
|
|
83
|
+
function onPointerUp(): void {
|
|
84
|
+
dragging.value = false;
|
|
85
|
+
document.removeEventListener('pointermove', onPointerMove);
|
|
86
|
+
document.removeEventListener('pointerup', onPointerUp);
|
|
87
|
+
}
|
|
88
|
+
function clamp(n: number): number {
|
|
89
|
+
return Math.max(0, Math.min(100, Math.round(n)));
|
|
90
|
+
}
|
|
91
|
+
onUnmounted(() => {
|
|
92
|
+
document.removeEventListener('pointermove', onPointerMove);
|
|
93
|
+
document.removeEventListener('pointerup', onPointerUp);
|
|
94
|
+
});
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<template>
|
|
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
|
+
|
|
113
|
+
<div
|
|
114
|
+
ref="boxRef"
|
|
115
|
+
class="cpub-ba-box"
|
|
116
|
+
:class="{ 'cpub-ba-drag': mode === 'zoom', 'cpub-ba-dragging': dragging }"
|
|
117
|
+
:style="{ aspectRatio: aspect ?? '4 / 1' }"
|
|
118
|
+
@pointerdown="onPointerDown"
|
|
119
|
+
>
|
|
120
|
+
<img :src="imageUrl" :alt="label ?? 'Image preview'" class="cpub-ba-img" :style="framing" draggable="false" />
|
|
121
|
+
<span v-if="mode === 'zoom'" class="cpub-ba-hint"><i class="fa-solid fa-up-down-left-right"></i> Drag to reposition</span>
|
|
122
|
+
</div>
|
|
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>
|
|
138
|
+
</div>
|
|
139
|
+
</template>
|
|
140
|
+
|
|
141
|
+
<style scoped>
|
|
142
|
+
.cpub-ba { display: flex; flex-direction: column; gap: 8px; }
|
|
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; }
|
|
151
|
+
.cpub-ba-img { display: block; width: 100%; height: 100%; user-select: none; -webkit-user-drag: none; }
|
|
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; }
|
|
153
|
+
.cpub-ba-zoom { display: flex; align-items: center; gap: 8px; }
|
|
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; }
|
|
155
|
+
.cpub-ba-zoom input[type='range'] { flex: 1; accent-color: var(--accent); }
|
|
156
|
+
.cpub-ba-help { margin: 0; font-size: 11px; color: var(--text-faint); line-height: 1.5; }
|
|
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, 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,
|
|
@@ -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,14 @@ 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));
|
|
265
|
+
const bannerPreviewWhole = computed(() => isWholeImage(bannerMeta.value));
|
|
266
|
+
const coverPreviewWhole = computed(() => isWholeImage(coverMeta.value));
|
|
245
267
|
|
|
246
268
|
// --- Right-rail collapsible sections ---
|
|
247
269
|
const openSections = ref<Record<string, boolean>>({
|
|
@@ -466,8 +488,8 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
|
|
|
466
488
|
<!-- Banner + cover render inline at the top of the Overview body only. -->
|
|
467
489
|
<template #overview-lead>
|
|
468
490
|
<div class="cpub-ce-media">
|
|
469
|
-
<div class="cpub-ce-banner" :class="{ 'has-image': !!bannerUrl }">
|
|
470
|
-
<img v-if="bannerUrl" :src="bannerUrl" alt="Contest banner" class="cpub-ce-banner-img" />
|
|
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" />
|
|
471
493
|
<div v-else class="cpub-ce-media-placeholder">
|
|
472
494
|
<i class="fa-regular fa-image"></i>
|
|
473
495
|
<span>Banner image</span>
|
|
@@ -478,12 +500,13 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
|
|
|
478
500
|
<input type="file" accept="image/*" class="cpub-sr-only" aria-label="Upload banner image" @change="onBannerUpload">
|
|
479
501
|
</label>
|
|
480
502
|
<button type="button" class="cpub-ce-media-btn" @click="onBannerUrl"><i class="fa-solid fa-link"></i> URL</button>
|
|
503
|
+
<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
504
|
<button v-if="bannerUrl" type="button" class="cpub-ce-media-btn" @click="bannerUrl = ''"><i class="fa-solid fa-trash"></i> Remove</button>
|
|
482
505
|
</div>
|
|
483
506
|
|
|
484
507
|
<!-- Cover thumbnail, inset over the banner's lower-left (mirrors the public hero). -->
|
|
485
|
-
<div class="cpub-ce-cover" :class="{ 'has-image': !!coverImageUrl }">
|
|
486
|
-
<img v-if="coverImageUrl" :src="coverImageUrl" alt="Contest cover" class="cpub-ce-cover-img" />
|
|
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" />
|
|
487
510
|
<div v-else class="cpub-ce-media-placeholder cpub-ce-media-placeholder-sm">
|
|
488
511
|
<i class="fa-regular fa-image"></i>
|
|
489
512
|
<span>Cover</span>
|
|
@@ -493,11 +516,26 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
|
|
|
493
516
|
<i class="fa-solid fa-arrow-up-from-bracket"></i>
|
|
494
517
|
<input type="file" accept="image/*" class="cpub-sr-only" aria-label="Upload cover image" @change="onCoverUpload">
|
|
495
518
|
</label>
|
|
519
|
+
<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
520
|
<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
521
|
</div>
|
|
498
522
|
</div>
|
|
499
523
|
</div>
|
|
500
|
-
|
|
524
|
+
|
|
525
|
+
<!-- Non-destructive framing panels (P4) -->
|
|
526
|
+
<ContestBannerAdjust v-if="bannerUrl && showBannerAdjust" v-model="bannerMeta" :image-url="bannerUrl" aspect="4 / 1" label="Banner" class="cpub-ce-adjust" />
|
|
527
|
+
<ContestBannerAdjust v-if="coverImageUrl && showCoverAdjust" v-model="coverMeta" :image-url="coverImageUrl" aspect="4 / 3" label="Cover" class="cpub-ce-adjust" />
|
|
528
|
+
|
|
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>
|
|
501
539
|
</div>
|
|
502
540
|
</template>
|
|
503
541
|
|
|
@@ -860,7 +898,15 @@ const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) =>
|
|
|
860
898
|
.cpub-ce-media-btn:hover { background: var(--surface2); }
|
|
861
899
|
.cpub-ce-media-btn.primary:hover { opacity: 0.9; background: var(--accent); }
|
|
862
900
|
.cpub-ce-media-btn-icon { padding: 5px 7px; }
|
|
901
|
+
.cpub-ce-media-btn.active { background: var(--accent-bg); color: var(--accent); border-color: var(--accent); }
|
|
863
902
|
.cpub-ce-media-hint { margin: 0; }
|
|
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; }
|
|
864
910
|
.cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
|
|
865
911
|
|
|
866
912
|
/* --- Responsive: stack the rail under the body on narrow viewports --- */
|
|
@@ -16,6 +16,19 @@ 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
|
+
// 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));
|
|
31
|
+
|
|
19
32
|
// Local wall-clock formatting (dates) and the live countdown are timezone- and
|
|
20
33
|
// clock-dependent, so they would mismatch between the server's TZ and the viewer's
|
|
21
34
|
// on hydration (and Vue won't rectify it in prod). Gate them on a client `mounted`
|
|
@@ -141,8 +154,8 @@ const entryCount = computed<number>(() => c.value?.entryCount ?? 0);
|
|
|
141
154
|
<template>
|
|
142
155
|
<div class="cpub-hero">
|
|
143
156
|
<!-- Slim banner band (constrained) — the hero image, not a tall block. -->
|
|
144
|
-
<div v-if="c?.bannerUrl" class="cpub-hero-banner">
|
|
145
|
-
<img :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" />
|
|
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" />
|
|
146
159
|
</div>
|
|
147
160
|
|
|
148
161
|
<!-- Compact bar — title + status + meta + actions in one tight, clean band
|
|
@@ -169,6 +182,14 @@ const entryCount = computed<number>(() => c.value?.entryCount ?? 0);
|
|
|
169
182
|
|
|
170
183
|
<h1 class="cpub-hero-title">{{ c?.title || 'Contest' }}</h1>
|
|
171
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
|
+
/>
|
|
172
193
|
|
|
173
194
|
<div class="cpub-hero-foot">
|
|
174
195
|
<div class="cpub-hero-meta">
|
|
@@ -209,6 +230,13 @@ const entryCount = computed<number>(() => c.value?.entryCount ?? 0);
|
|
|
209
230
|
overflow: hidden;
|
|
210
231
|
}
|
|
211
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; }
|
|
212
240
|
|
|
213
241
|
/* ── COMPACT BAR ── one tight, clean band on a surface background. */
|
|
214
242
|
.cpub-hero-bar { background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); }
|
|
@@ -236,7 +264,7 @@ const entryCount = computed<number>(() => c.value?.entryCount ?? 0);
|
|
|
236
264
|
.cpub-countdown-chip-muted i { color: var(--text-faint); }
|
|
237
265
|
|
|
238
266
|
.cpub-hero-title { font-size: 26px; font-weight: 800; letter-spacing: -.02em; line-height: 1.15; margin: 0 0 6px; color: var(--text); }
|
|
239
|
-
.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; }
|
|
240
268
|
|
|
241
269
|
.cpub-hero-foot { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
|
242
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); }
|
|
@@ -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); }
|