@commonpub/layer 0.47.0 → 0.49.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.
@@ -61,91 +61,102 @@ const isEnded = computed(() => c.value?.status === 'completed' || c.value?.statu
61
61
  const tagline = computed<string>(() => {
62
62
  const sub = (c.value?.subheading ?? '').trim();
63
63
  if (sub) return sub;
64
- return markdownToExcerpt(c.value?.description) || 'No description available.';
64
+ return markdownToExcerpt(c.value?.description) || '';
65
+ });
66
+
67
+ const dateRange = computed<string>(() => {
68
+ const fmt = (d: string, withYear = false) =>
69
+ new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(withYear ? { year: 'numeric' } : {}) });
70
+ const start = c.value?.startDate ? fmt(c.value.startDate) : '';
71
+ const end = c.value?.endDate ? fmt(c.value.endDate, true) : '';
72
+ if (start && end) return `${start} — ${end}`;
73
+ return start || end;
65
74
  });
66
75
  </script>
67
76
 
68
77
  <template>
69
78
  <div class="cpub-hero">
70
- <div class="cpub-hero-pattern">
71
- <div class="cpub-hero-dots"></div>
72
- <div class="cpub-hero-lines"></div>
79
+ <!-- Banner band — full-width image at the top, the same way other content
80
+ pages render their hero banner (clean band, never overlaid by text). -->
81
+ <div v-if="c?.bannerUrl" class="cpub-hero-banner">
82
+ <img :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" />
73
83
  </div>
74
84
 
75
- <div class="cpub-hero-inner">
76
- <!-- Banner image: a clean band at the top of the hero. Title/tagline sit
77
- BELOW it (never overlaid) so text stays legible regardless of image. -->
78
- <img v-if="c?.bannerUrl" :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" class="cpub-hero-banner" />
79
-
80
- <div v-if="c?.status === 'cancelled'" class="cpub-cancelled-banner">
81
- <i class="fa-solid fa-ban"></i> This contest has been cancelled.
85
+ <!-- Hero body — the contest's dark, patterned section. Two columns:
86
+ title + details on the left, the countdown on the right. -->
87
+ <div class="cpub-hero-body">
88
+ <div class="cpub-hero-pattern" aria-hidden="true">
89
+ <div class="cpub-hero-dots"></div>
90
+ <div class="cpub-hero-lines"></div>
82
91
  </div>
83
92
 
84
- <div class="cpub-hero-eyebrow">
85
- <span class="cpub-contest-badge"><i class="fa fa-trophy"></i> Contest</span>
86
- </div>
93
+ <div class="cpub-hero-inner">
94
+ <div v-if="c?.status === 'cancelled'" class="cpub-cancelled-banner">
95
+ <i class="fa-solid fa-ban"></i> This contest has been cancelled.
96
+ </div>
87
97
 
88
- <div class="cpub-hero-title">{{ c?.title || 'Contest' }}</div>
89
- <div class="cpub-hero-tagline">{{ tagline }}</div>
98
+ <div class="cpub-hero-grid">
99
+ <!-- LEFT: title + details + actions -->
100
+ <div class="cpub-hero-main">
101
+ <div class="cpub-hero-eyebrow">
102
+ <span class="cpub-contest-badge"><i class="fa fa-trophy"></i> Contest</span>
103
+ <span class="cpub-status-pill" :data-status="c?.status || 'upcoming'">{{ c?.status || 'upcoming' }}</span>
104
+ </div>
90
105
 
91
- <div class="cpub-hero-meta">
92
- <span v-if="c?.startDate || c?.endDate" class="cpub-hero-meta-item">
93
- <i class="fa fa-calendar"></i>
94
- {{ c?.startDate ? new Date(c.startDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '' }}{{ c?.startDate && c?.endDate ? ' — ' : '' }}{{ c?.endDate ? new Date(c.endDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '' }}
95
- </span>
96
- <span v-if="c?.startDate || c?.endDate" class="cpub-hero-meta-sep">|</span>
97
- <span class="cpub-hero-meta-item"><i class="fa fa-folder-open"></i> {{ c?.entryCount ?? 0 }} entries</span>
98
- </div>
106
+ <h1 class="cpub-hero-title">{{ c?.title || 'Contest' }}</h1>
107
+ <p v-if="tagline" class="cpub-hero-tagline">{{ tagline }}</p>
99
108
 
100
- <!-- COUNTDOWN -->
101
- <div v-if="!isEnded" class="cpub-countdown-section">
102
- <div class="cpub-countdown-label"><i class="fa fa-clock"></i> {{ countdownLabel }}</div>
103
- <div class="cpub-countdown-row">
104
- <div class="cpub-countdown-block">
105
- <div class="cpub-countdown-val">{{ countdown.days }}</div>
106
- <div class="cpub-countdown-unit">Days</div>
107
- </div>
108
- <div class="cpub-countdown-sep">:</div>
109
- <div class="cpub-countdown-block">
110
- <div class="cpub-countdown-val">{{ countdown.hours }}</div>
111
- <div class="cpub-countdown-unit">Hours</div>
112
- </div>
113
- <div class="cpub-countdown-sep">:</div>
114
- <div class="cpub-countdown-block">
115
- <div class="cpub-countdown-val">{{ countdown.mins }}</div>
116
- <div class="cpub-countdown-unit">Minutes</div>
117
- </div>
118
- <div class="cpub-countdown-sep">:</div>
119
- <div class="cpub-countdown-block">
120
- <div class="cpub-countdown-val">{{ countdown.secs }}</div>
121
- <div class="cpub-countdown-unit">Seconds</div>
122
- </div>
123
- </div>
124
- </div>
109
+ <div class="cpub-hero-meta">
110
+ <span v-if="dateRange" class="cpub-hero-meta-item"><i class="fa fa-calendar"></i> {{ dateRange }}</span>
111
+ <span class="cpub-hero-meta-item"><i class="fa fa-folder-open"></i> {{ c?.entryCount ?? 0 }} {{ (c?.entryCount ?? 0) === 1 ? 'entry' : 'entries' }}</span>
112
+ </div>
125
113
 
126
- <div class="cpub-hero-cta">
127
- <button v-if="isAuthenticated && c?.status === 'active'" class="cpub-btn cpub-btn-primary cpub-btn-lg" @click="emit('submit-entry')"><i class="fa fa-upload"></i> Submit Entry</button>
128
- <button class="cpub-btn cpub-btn-lg cpub-btn-dark" @click="emit('copy-link')"><i class="fa fa-link"></i> Share</button>
129
- </div>
114
+ <div class="cpub-hero-cta">
115
+ <button v-if="isAuthenticated && c?.status === 'active'" class="cpub-btn cpub-btn-primary cpub-btn-lg" @click="emit('submit-entry')"><i class="fa fa-upload"></i> Submit Entry</button>
116
+ <button class="cpub-btn cpub-btn-lg cpub-btn-dark" @click="emit('copy-link')"><i class="fa fa-link"></i> Share</button>
117
+ </div>
130
118
 
131
- <!-- Admin controls -->
132
- <div v-if="isAdmin && c" class="cpub-admin-controls">
133
- <span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i> Admin</span>
134
- <button v-if="c.status === 'upcoming'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'active')"><i class="fa-solid fa-play"></i> Activate</button>
135
- <button v-if="c.status === 'active'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'judging')"><i class="fa-solid fa-gavel"></i> Start Judging</button>
136
- <button v-if="c.status === 'judging'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'completed')"><i class="fa-solid fa-check"></i> Complete</button>
137
- <button v-if="c.status !== 'completed' && c.status !== 'cancelled'" class="cpub-btn cpub-btn-sm cpub-btn-cancel" :disabled="transitioning" @click="emit('transition', 'cancelled')"><i class="fa-solid fa-ban"></i> Cancel</button>
138
- <span class="cpub-admin-status">Status: <strong>{{ c.status }}</strong></span>
139
- </div>
119
+ <!-- Admin controls -->
120
+ <div v-if="isAdmin && c" class="cpub-admin-controls">
121
+ <span class="cpub-admin-controls-label"><i class="fa-solid fa-shield-halved"></i> Admin</span>
122
+ <button v-if="c.status === 'upcoming'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'active')"><i class="fa-solid fa-play"></i> Activate</button>
123
+ <button v-if="c.status === 'active'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'judging')"><i class="fa-solid fa-gavel"></i> Start Judging</button>
124
+ <button v-if="c.status === 'judging'" class="cpub-btn cpub-btn-sm" :disabled="transitioning" @click="emit('transition', 'completed')"><i class="fa-solid fa-check"></i> Complete</button>
125
+ <button v-if="c.status !== 'completed' && c.status !== 'cancelled'" class="cpub-btn cpub-btn-sm cpub-btn-cancel" :disabled="transitioning" @click="emit('transition', 'cancelled')"><i class="fa-solid fa-ban"></i> Cancel</button>
126
+ </div>
127
+ </div>
140
128
 
141
- <div class="cpub-hero-stats">
142
- <div class="cpub-hero-stat">
143
- <div class="cpub-hero-stat-val">{{ c?.entryCount ?? 0 }}</div>
144
- <div class="cpub-hero-stat-label">Entries</div>
145
- </div>
146
- <div class="cpub-hero-stat">
147
- <div class="cpub-hero-stat-val">{{ c?.status ?? 'upcoming' }}</div>
148
- <div class="cpub-hero-stat-label">Status</div>
129
+ <!-- RIGHT: countdown -->
130
+ <aside class="cpub-hero-side">
131
+ <div v-if="!isEnded" class="cpub-countdown-section">
132
+ <div class="cpub-countdown-label"><i class="fa fa-clock"></i> {{ countdownLabel }}</div>
133
+ <div class="cpub-countdown-row">
134
+ <div class="cpub-countdown-block">
135
+ <div class="cpub-countdown-val">{{ countdown.days }}</div>
136
+ <div class="cpub-countdown-unit">Days</div>
137
+ </div>
138
+ <div class="cpub-countdown-sep">:</div>
139
+ <div class="cpub-countdown-block">
140
+ <div class="cpub-countdown-val">{{ countdown.hours }}</div>
141
+ <div class="cpub-countdown-unit">Hours</div>
142
+ </div>
143
+ <div class="cpub-countdown-sep">:</div>
144
+ <div class="cpub-countdown-block">
145
+ <div class="cpub-countdown-val">{{ countdown.mins }}</div>
146
+ <div class="cpub-countdown-unit">Minutes</div>
147
+ </div>
148
+ <div class="cpub-countdown-sep">:</div>
149
+ <div class="cpub-countdown-block">
150
+ <div class="cpub-countdown-val">{{ countdown.secs }}</div>
151
+ <div class="cpub-countdown-unit">Seconds</div>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ <div v-else class="cpub-countdown-ended">
156
+ <i class="fa-solid fa-flag-checkered"></i>
157
+ <span>{{ countdownLabel }}</span>
158
+ </div>
159
+ </aside>
149
160
  </div>
150
161
  </div>
151
162
  </div>
@@ -157,37 +168,59 @@ const tagline = computed<string>(() => {
157
168
  --hero-bg: var(--text);
158
169
  --hero-text: var(--color-text-inverse);
159
170
  --hero-text-dim: var(--text-faint);
160
- /* Alpha of the hero foreground so the structure lines/surfaces track
161
- the inverted hero in both themes (white-on-dark in light mode,
162
- dark-on-light in dark mode) instead of vanishing white-on-white. */
171
+ /* Alpha of the hero foreground so the structure lines/surfaces track the
172
+ inverted hero in both themes (white-on-dark in light mode, dark-on-light
173
+ in dark mode) instead of vanishing white-on-white. */
163
174
  --hero-border: color-mix(in srgb, var(--hero-text) 18%, transparent);
164
175
  --hero-surface: color-mix(in srgb, var(--hero-text) 7%, transparent);
165
- position: relative; overflow: hidden; background: var(--hero-bg); padding: 56px 0 48px;
176
+ }
177
+
178
+ /* ── BANNER BAND ── full-width, clean, like other content pages' hero banner. */
179
+ .cpub-hero-banner {
180
+ width: 100%;
181
+ background: var(--surface2);
182
+ border-bottom: var(--border-width-default) solid var(--border);
183
+ overflow: hidden;
184
+ }
185
+ .cpub-hero-banner img {
186
+ display: block;
187
+ width: 100%;
188
+ max-height: 300px;
189
+ object-fit: cover;
190
+ margin: 0 auto;
191
+ }
192
+
193
+ /* ── HERO BODY ── the contest's dark, patterned section. */
194
+ .cpub-hero-body {
195
+ position: relative;
196
+ overflow: hidden;
197
+ background: var(--hero-bg);
198
+ padding: 44px 0;
166
199
  }
167
200
  .cpub-hero-pattern { position: absolute; inset: 0; }
168
201
  .cpub-hero-dots { position: absolute; inset: 0; background-image: radial-gradient(var(--accent-border) 1.5px, transparent 1.5px); background-size: 28px 28px; opacity: .3; }
169
202
  .cpub-hero-lines { position: absolute; inset: 0; background-image: linear-gradient(var(--accent-bg) 1px, transparent 1px), linear-gradient(90deg, var(--accent-bg) 1px, transparent 1px); background-size: 56px 56px; }
170
203
  .cpub-hero-inner { max-width: 1100px; margin: 0 auto; padding: 0 32px; position: relative; z-index: 1; }
171
- .cpub-hero-banner { display: block; width: 100%; max-height: 195px; object-fit: cover; margin-bottom: 28px; border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); }
172
- .cpub-hero-eyebrow { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
204
+
205
+ .cpub-cancelled-banner { background: var(--red-bg); border: var(--border-width-default) solid var(--red-border); color: var(--red); padding: 10px 14px; font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 8px; margin-bottom: 20px; }
206
+
207
+ /* 2-column: details (flex) + countdown (auto width). */
208
+ .cpub-hero-grid { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 48px; align-items: start; }
209
+ .cpub-hero-main { min-width: 0; }
210
+
211
+ .cpub-hero-eyebrow { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
173
212
  .cpub-contest-badge { font-size: 9px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; font-family: var(--font-mono); color: var(--accent); background: var(--accent-bg); border: var(--border-width-default) solid var(--accent); padding: 3px 10px; border-radius: var(--radius); display: inline-flex; align-items: center; gap: 5px; }
174
213
  .cpub-contest-badge i { font-size: 8px; }
175
- .cpub-hero-title { font-size: 36px; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin-bottom: 10px; color: var(--hero-text); }
176
- .cpub-hero-tagline { font-size: 14px; color: var(--hero-text-dim); line-height: 1.55; max-width: 580px; margin-bottom: 28px; display: -webkit-box; -webkit-line-clamp: 4; line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
177
- .cpub-hero-meta { display: flex; align-items: center; gap: 20px; font-size: 11px; color: var(--hero-text-dim); font-family: var(--font-mono); margin-bottom: 28px; }
178
- .cpub-hero-meta-item { display: flex; align-items: center; gap: 5px; }
179
- .cpub-hero-meta-sep { color: var(--hero-border); }
180
-
181
- .cpub-countdown-section { margin-bottom: 28px; }
182
- .cpub-countdown-label { font-size: 10px; font-family: var(--font-mono); color: var(--hero-text-dim); letter-spacing: .1em; text-transform: uppercase; margin-bottom: 10px; display: flex; align-items: center; gap: 4px; }
183
- .cpub-countdown-label i { color: var(--accent); }
184
- .cpub-countdown-row { display: flex; align-items: center; gap: 8px; }
185
- .cpub-countdown-block { display: flex; flex-direction: column; align-items: center; background: var(--hero-surface); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 10px 16px; min-width: 60px; box-shadow: 4px 4px 0 var(--hero-surface); }
186
- .cpub-countdown-val { font-size: 26px; font-weight: 700; font-family: var(--font-mono); color: var(--hero-text); line-height: 1; margin-bottom: 4px; }
187
- .cpub-countdown-unit { font-size: 9px; text-transform: uppercase; letter-spacing: .1em; color: var(--hero-text-dim); font-family: var(--font-mono); }
188
- .cpub-countdown-sep { font-size: 20px; font-weight: 700; color: var(--hero-border); margin-top: -8px; font-family: var(--font-mono); }
214
+ .cpub-status-pill { font-size: 9px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; font-family: var(--font-mono); padding: 3px 10px; border-radius: var(--radius); border: var(--border-width-default) solid var(--hero-border); color: var(--hero-text-dim); }
215
+ .cpub-status-pill[data-status="active"] { color: var(--green); border-color: var(--green); background: color-mix(in srgb, var(--green) 14%, transparent); }
216
+ .cpub-status-pill[data-status="judging"] { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
217
+ .cpub-status-pill[data-status="upcoming"] { color: var(--yellow); border-color: var(--yellow); }
218
+ .cpub-status-pill[data-status="completed"], .cpub-status-pill[data-status="cancelled"] { color: var(--red); border-color: var(--red-border); }
189
219
 
190
- .cpub-cancelled-banner { background: var(--red-bg); border: var(--border-width-default) solid var(--red-border); color: var(--red); padding: 10px 14px; font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
220
+ .cpub-hero-title { font-size: 34px; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin: 0 0 10px; color: var(--hero-text); }
221
+ .cpub-hero-tagline { font-size: 14px; color: var(--hero-text-dim); line-height: 1.55; max-width: 600px; margin: 0 0 20px; display: -webkit-box; -webkit-line-clamp: 4; line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
222
+ .cpub-hero-meta { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; font-size: 11px; color: var(--hero-text-dim); font-family: var(--font-mono); margin-bottom: 24px; }
223
+ .cpub-hero-meta-item { display: flex; align-items: center; gap: 6px; }
191
224
 
192
225
  .cpub-hero-cta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
193
226
  .cpub-btn-lg { padding: 10px 22px; font-size: 13px; }
@@ -196,31 +229,40 @@ const tagline = computed<string>(() => {
196
229
  .cpub-btn-cancel { color: var(--red); border-color: var(--red-border); }
197
230
  .cpub-btn-cancel:hover { background: var(--red-bg); }
198
231
 
199
- .cpub-hero-stats { display: flex; gap: 24px; margin-top: 28px; padding-top: 24px; border-top: var(--border-width-default) solid var(--hero-border); }
200
- .cpub-hero-stat { display: flex; flex-direction: column; }
201
- .cpub-hero-stat-val { font-size: 20px; font-weight: 700; font-family: var(--font-mono); color: var(--hero-text); }
202
- .cpub-hero-stat-label { font-size: 10px; color: var(--hero-text-dim); text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono); }
203
-
204
- .cpub-admin-controls { display: flex; align-items: center; gap: 8px; margin-top: 16px; padding: 10px 14px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
232
+ .cpub-admin-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 18px; padding: 10px 14px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
205
233
  .cpub-admin-controls-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); margin-right: 4px; font-family: var(--font-mono); }
206
- .cpub-admin-status { font-size: 11px; color: var(--text-dim); margin-left: auto; font-family: var(--font-mono); }
207
- .cpub-admin-status strong { color: var(--accent); text-transform: capitalize; }
208
234
 
235
+ /* ── COUNTDOWN (right column) ── */
236
+ .cpub-hero-side { display: flex; flex-direction: column; }
237
+ .cpub-countdown-section { background: var(--hero-surface); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 16px 18px; }
238
+ .cpub-countdown-label { font-size: 10px; font-family: var(--font-mono); color: var(--hero-text-dim); letter-spacing: .1em; text-transform: uppercase; margin-bottom: 12px; display: flex; align-items: center; gap: 5px; white-space: nowrap; }
239
+ .cpub-countdown-label i { color: var(--accent); }
240
+ .cpub-countdown-row { display: flex; align-items: flex-start; gap: 8px; }
241
+ .cpub-countdown-block { display: flex; flex-direction: column; align-items: center; background: var(--hero-bg); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 10px 14px; min-width: 56px; }
242
+ .cpub-countdown-val { font-size: 24px; font-weight: 700; font-family: var(--font-mono); color: var(--hero-text); line-height: 1; margin-bottom: 4px; }
243
+ .cpub-countdown-unit { font-size: 8px; text-transform: uppercase; letter-spacing: .1em; color: var(--hero-text-dim); font-family: var(--font-mono); }
244
+ .cpub-countdown-sep { font-size: 20px; font-weight: 700; color: var(--hero-border); font-family: var(--font-mono); padding-top: 10px; }
245
+ .cpub-countdown-ended { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .08em; color: var(--hero-text-dim); background: var(--hero-surface); border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); padding: 14px 18px; }
246
+ .cpub-countdown-ended i { color: var(--accent); }
247
+
248
+ /* ── RESPONSIVE ── stack the countdown below the details. */
249
+ @media (max-width: 900px) {
250
+ .cpub-hero-grid { grid-template-columns: 1fr; gap: 28px; }
251
+ .cpub-hero-side { align-items: flex-start; }
252
+ }
209
253
  @media (max-width: 768px) {
210
- .cpub-hero { padding: 32px 0 28px; }
254
+ .cpub-hero-body { padding: 32px 0; }
211
255
  .cpub-hero-inner { padding: 0 16px; }
256
+ .cpub-hero-banner img { max-height: 200px; }
212
257
  .cpub-hero-title { font-size: 24px; }
213
- .cpub-hero-tagline { font-size: 13px; }
214
- .cpub-hero-meta { flex-wrap: wrap; gap: 10px; }
215
- .cpub-countdown-block { padding: 8px 12px; min-width: 48px; }
216
- .cpub-countdown-val { font-size: 20px; }
258
+ .cpub-hero-meta { gap: 10px; }
217
259
  }
218
260
  @media (max-width: 480px) {
219
261
  .cpub-hero-title { font-size: 20px; }
220
262
  .cpub-hero-tagline { font-size: 12px; margin-bottom: 16px; }
221
- .cpub-hero-stats { flex-wrap: wrap; gap: 16px; }
222
- .cpub-hero-stat-val { font-size: 16px; }
223
263
  .cpub-hero-cta { flex-direction: column; align-items: stretch; }
224
264
  .cpub-countdown-row { flex-wrap: wrap; justify-content: center; }
265
+ .cpub-countdown-block { min-width: 48px; padding: 8px 12px; }
266
+ .cpub-countdown-val { font-size: 20px; }
225
267
  }
226
268
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -55,14 +55,14 @@
55
55
  "zod": "^4.3.6",
56
56
  "@commonpub/auth": "0.8.0",
57
57
  "@commonpub/config": "0.18.0",
58
- "@commonpub/editor": "0.7.11",
59
- "@commonpub/docs": "0.6.3",
60
- "@commonpub/explainer": "0.7.15",
61
58
  "@commonpub/learning": "0.5.2",
59
+ "@commonpub/explainer": "0.7.15",
60
+ "@commonpub/docs": "0.6.3",
61
+ "@commonpub/editor": "0.7.11",
62
62
  "@commonpub/protocol": "0.13.0",
63
- "@commonpub/schema": "0.26.0",
64
- "@commonpub/server": "2.73.0",
65
- "@commonpub/ui": "0.9.2"
63
+ "@commonpub/ui": "0.9.2",
64
+ "@commonpub/server": "2.74.0",
65
+ "@commonpub/schema": "0.27.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -17,6 +17,7 @@ const subheading = ref('');
17
17
  const description = ref('');
18
18
  const rules = ref('');
19
19
  const bannerUrl = ref('');
20
+ const coverImageUrl = ref('');
20
21
  const startDate = ref('');
21
22
  const endDate = ref('');
22
23
  const judgingEndDate = ref('');
@@ -56,6 +57,7 @@ watch(contest, (c) => {
56
57
  description.value = c.description ?? '';
57
58
  rules.value = c.rules ?? '';
58
59
  bannerUrl.value = c.bannerUrl ?? '';
60
+ coverImageUrl.value = c.coverImageUrl ?? '';
59
61
  startDate.value = c.startDate ? new Date(c.startDate).toISOString().slice(0, 16) : '';
60
62
  endDate.value = c.endDate ? new Date(c.endDate).toISOString().slice(0, 16) : '';
61
63
  judgingEndDate.value = c.judgingEndDate ? new Date(c.judgingEndDate).toISOString().slice(0, 16) : '';
@@ -144,6 +146,7 @@ async function handleSave(): Promise<void> {
144
146
  description: description.value || undefined,
145
147
  rules: rules.value || undefined,
146
148
  bannerUrl: bannerUrl.value || undefined,
149
+ coverImageUrl: coverImageUrl.value || undefined,
147
150
  startDate: startDate.value ? new Date(startDate.value).toISOString() : undefined,
148
151
  endDate: endDate.value ? new Date(endDate.value).toISOString() : undefined,
149
152
  judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
@@ -231,7 +234,10 @@ async function transitionStatus(newStatus: string): Promise<void> {
231
234
  <p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
232
235
  </div>
233
236
  <div class="cpub-form-field">
234
- <ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide image shown across the top of the contest page (~4:1)." />
237
+ <ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide hero image across the top of the contest page (~4:1)." />
238
+ </div>
239
+ <div class="cpub-form-field">
240
+ <ImageUpload v-model="coverImageUrl" purpose="cover" label="Cover Image (optional)" hint="Card/thumbnail image shown in listings (~4:3). Falls back to the banner if unset." />
235
241
  </div>
236
242
  </section>
237
243
 
@@ -12,6 +12,7 @@ const subheading = ref('');
12
12
  const description = ref('');
13
13
  const rules = ref('');
14
14
  const bannerUrl = ref('');
15
+ const coverImageUrl = ref('');
15
16
  const startDate = ref('');
16
17
  const endDate = ref('');
17
18
  const judgingEndDate = ref('');
@@ -48,11 +49,10 @@ interface Prize {
48
49
  }
49
50
 
50
51
  const prizesDescription = ref('');
51
- const prizes = ref<Prize[]>([
52
- { place: 1, category: '', title: '1st Place', description: '', value: '' },
53
- { place: 2, category: '', title: '2nd Place', description: '', value: '' },
54
- { place: 3, category: '', title: '3rd Place', description: '', value: '' },
55
- ]);
52
+ // Prizes are entirely optional — start empty so a contest has NO prizes unless
53
+ // the operator explicitly adds them (the old 3 pre-filled rows forced prizes
54
+ // onto every contest, since their non-empty titles survived the submit filter).
55
+ const prizes = ref<Prize[]>([]);
56
56
 
57
57
  function addPrize(): void {
58
58
  prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
@@ -96,6 +96,7 @@ async function handleCreate(): Promise<void> {
96
96
  description: description.value || undefined,
97
97
  rules: rules.value || undefined,
98
98
  bannerUrl: bannerUrl.value || undefined,
99
+ coverImageUrl: coverImageUrl.value || undefined,
99
100
  startDate: new Date(startDate.value).toISOString(),
100
101
  endDate: new Date(endDate.value).toISOString(),
101
102
  judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
@@ -172,7 +173,10 @@ function prizeLabel(prize: Prize): string {
172
173
  <p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
173
174
  </div>
174
175
  <div class="cpub-form-field">
175
- <ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide image shown across the top of the contest page (~4:1)." />
176
+ <ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide hero image across the top of the contest page (~4:1)." />
177
+ </div>
178
+ <div class="cpub-form-field">
179
+ <ImageUpload v-model="coverImageUrl" purpose="cover" label="Cover Image (optional)" hint="Card/thumbnail image shown in listings (~4:3). Falls back to the banner if unset." />
176
180
  </div>
177
181
  </section>
178
182
 
@@ -281,13 +285,13 @@ function prizeLabel(prize: Prize): string {
281
285
  <!-- Prizes -->
282
286
  <section class="cpub-form-section">
283
287
  <div class="cpub-form-section-header">
284
- <h2 class="cpub-form-section-title">Prizes</h2>
288
+ <h2 class="cpub-form-section-title">Prizes <span style="color: var(--text-faint); font-weight: 400; font-size: 0.75em; font-family: var(--font-mono);">— optional</span></h2>
285
289
  <button type="button" class="cpub-btn cpub-btn-sm" @click="addPrize">
286
290
  <i class="fa-solid fa-plus"></i> Add Prize
287
291
  </button>
288
292
  </div>
289
293
 
290
- <p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes (1st/2nd/3rd), a <strong>category</strong> for themed awards (e.g. "Best in Show"), or just a <strong>description</strong>. Cash value is optional.</p>
294
+ <p class="cpub-form-hint">Contests don't need prizes — leave this empty to skip them entirely. If you do add prizes, every field is optional: use <strong>place</strong> for ranked prizes (1st/2nd/3rd), a <strong>category</strong> for themed awards (e.g. "Best in Show"), or just a <strong>description</strong>. Cash value is optional.</p>
291
295
  <div class="cpub-form-field">
292
296
  <label for="prizes-desc" class="cpub-form-label">Prizes overview (optional)</label>
293
297
  <textarea id="prizes-desc" v-model="prizesDescription" class="cpub-form-textarea" rows="3" placeholder="Intro shown above the prize cards. Supports Markdown." />
@@ -13,6 +13,20 @@ function cardBlurb(c: { subheading?: string | null; description?: string | null
13
13
  }
14
14
 
15
15
  const config = useRuntimeConfig();
16
+
17
+ // Contest banner thumbnail — proxy cross-origin images through our server
18
+ // (same pattern as ContentCard) for caching + faster loads.
19
+ function coverFor(url: string | null | undefined): string | null {
20
+ if (!url) return null;
21
+ const siteDomain = (config.public?.domain as string) || '';
22
+ try {
23
+ if (siteDomain && !url.includes(siteDomain)) {
24
+ return `/api/image-proxy?url=${encodeURIComponent(url)}&w=600`;
25
+ }
26
+ } catch { /* invalid URL — use as-is */ }
27
+ return url;
28
+ }
29
+
16
30
  const contestCreation = config.public.contestCreation as string || 'admin';
17
31
  const canCreateContest = computed(() => {
18
32
  if (!isAuthenticated.value) return false;
@@ -31,30 +45,47 @@ const canCreateContest = computed(() => {
31
45
  </NuxtLink>
32
46
  </div>
33
47
  <div v-if="contests?.items?.length" class="cpub-grid-3">
34
- <div v-for="contest in contests.items" :key="contest.id" class="cpub-card">
35
- <div class="cpub-card-body">
36
- <span class="cpub-badge" :class="{
48
+ <NuxtLink
49
+ v-for="contest in contests.items"
50
+ :key="contest.id"
51
+ :to="`/contests/${contest.slug}`"
52
+ class="cpub-card cpub-contest-card"
53
+ >
54
+ <!-- Card image: coverImageUrl (cover-cropped) → bannerUrl (contained, so a
55
+ wide hero/logo isn't crop-mangled) → trophy fallback. Status badge overlaid. -->
56
+ <div class="cpub-contest-thumb">
57
+ <img
58
+ v-if="coverFor(contest.coverImageUrl ?? contest.bannerUrl)"
59
+ :src="coverFor(contest.coverImageUrl ?? contest.bannerUrl)!"
60
+ :alt="contest.title"
61
+ class="cpub-contest-cover"
62
+ :class="{ 'cpub-contest-cover--contain': !contest.coverImageUrl && !!contest.bannerUrl }"
63
+ loading="lazy"
64
+ />
65
+ <template v-else>
66
+ <div class="cpub-contest-thumb-grid" />
67
+ <i class="fa-solid fa-trophy cpub-contest-thumb-icon" />
68
+ </template>
69
+ <span class="cpub-badge cpub-contest-thumb-badge" :class="{
37
70
  'cpub-badge-green': contest.status === 'active',
38
71
  'cpub-badge-yellow': contest.status === 'upcoming',
39
72
  'cpub-badge-accent': contest.status === 'judging',
40
73
  'cpub-badge-red': contest.status === 'completed' || contest.status === 'cancelled',
41
74
  }">{{ contest.status }}</span>
42
- <h3 style="font-size: 15px; font-weight: 600; margin: 8px 0">
43
- <NuxtLink :to="`/contests/${contest.slug}`" style="color: var(--text); text-decoration: none">
44
- {{ contest.title }}
45
- </NuxtLink>
46
- </h3>
47
- <p v-if="cardBlurb(contest)" class="cpub-contest-card-blurb" style="font-size: 12px; color: var(--text-dim); margin-bottom: 12px">
75
+ </div>
76
+ <div class="cpub-card-body">
77
+ <h3 class="cpub-contest-card-title">{{ contest.title }}</h3>
78
+ <p v-if="cardBlurb(contest)" class="cpub-contest-card-blurb">
48
79
  {{ cardBlurb(contest) }}
49
80
  </p>
50
81
  <div v-if="contest.endDate" style="margin-top: 8px">
51
82
  <CountdownTimer :target-date="contest.endDate" />
52
83
  </div>
53
- <div style="display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 11px; color: var(--text-faint); font-family: var(--font-mono)">
84
+ <div class="cpub-contest-card-meta">
54
85
  <span><i class="fa-solid fa-users"></i> {{ contest.entryCount }} entries</span>
55
86
  </div>
56
87
  </div>
57
- </div>
88
+ </NuxtLink>
58
89
  </div>
59
90
  <div v-else class="cpub-empty-state">
60
91
  <div class="cpub-empty-state-icon"><i class="fa-solid fa-trophy"></i></div>
@@ -71,7 +102,44 @@ const canCreateContest = computed(() => {
71
102
  .cpub-card:hover { box-shadow: var(--shadow-lg); transform: translate(-1px, -1px); }
72
103
  .cpub-card-body { padding: 16px; }
73
104
 
105
+ /* Whole card is a link */
106
+ .cpub-contest-card { display: block; text-decoration: none; color: inherit; }
107
+
108
+ /* Banner thumbnail — wide (banner-shaped), cover-cropped, with a grid+trophy
109
+ fallback when a contest has no bannerUrl. */
110
+ .cpub-contest-thumb {
111
+ position: relative;
112
+ aspect-ratio: 16 / 9;
113
+ background: var(--surface2);
114
+ border-bottom: var(--border-width-default) solid var(--border);
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ overflow: hidden;
119
+ }
120
+ .cpub-contest-cover { width: 100%; height: 100%; object-fit: cover; display: block; }
121
+ /* Banner-as-fallback: contain (+ breathing room) so a wide hero/logo shows whole, not crop-mangled. */
122
+ .cpub-contest-cover--contain { object-fit: contain; padding: 12px; background: var(--surface2); }
123
+ .cpub-contest-thumb-grid {
124
+ position: absolute;
125
+ inset: 0;
126
+ background-image:
127
+ linear-gradient(var(--border2) 1px, transparent 1px),
128
+ linear-gradient(90deg, var(--border2) 1px, transparent 1px);
129
+ background-size: 20px 20px;
130
+ opacity: 0.25;
131
+ }
132
+ .cpub-contest-thumb-icon { position: relative; z-index: 1; font-size: 36px; color: var(--accent); opacity: 0.45; }
133
+ .cpub-contest-thumb-badge { position: absolute; top: 10px; left: 10px; z-index: 2; box-shadow: var(--shadow-sm); }
134
+ .cpub-contest-card:hover .cpub-contest-cover { opacity: 0.92; }
135
+
136
+ .cpub-contest-card-title { font-size: 15px; font-weight: 600; margin: 0 0 6px; color: var(--text); }
137
+ .cpub-contest-card-meta { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 11px; color: var(--text-faint); font-family: var(--font-mono); }
138
+
74
139
  .cpub-contest-card-blurb {
140
+ font-size: 12px;
141
+ color: var(--text-dim);
142
+ margin-bottom: 12px;
75
143
  display: -webkit-box;
76
144
  -webkit-line-clamp: 3;
77
145
  line-clamp: 3;