@commonpub/layer 0.30.0 → 0.32.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.
@@ -84,6 +84,10 @@ const tagline = computed<string>(() => {
84
84
  </div>
85
85
 
86
86
  <div class="cpub-hero-inner">
87
+ <!-- Banner image: a clean band at the top of the hero. Title/tagline sit
88
+ BELOW it (never overlaid) so text stays legible regardless of image. -->
89
+ <img v-if="c?.bannerUrl" :src="c.bannerUrl" :alt="`${c?.title || 'Contest'} banner`" class="cpub-hero-banner" />
90
+
87
91
  <div v-if="c?.status === 'cancelled'" class="cpub-cancelled-banner">
88
92
  <i class="fa-solid fa-ban"></i> This contest has been cancelled.
89
93
  </div>
@@ -175,6 +179,7 @@ const tagline = computed<string>(() => {
175
179
  .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; }
176
180
  .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; }
177
181
  .cpub-hero-inner { max-width: 1100px; margin: 0 auto; padding: 0 32px; position: relative; z-index: 1; }
182
+ .cpub-hero-banner { display: block; width: 100%; max-height: 260px; object-fit: cover; margin-bottom: 28px; border: var(--border-width-default) solid var(--hero-border); border-radius: var(--radius); }
178
183
  .cpub-hero-eyebrow { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
179
184
  .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; }
180
185
  .cpub-contest-badge i { font-size: 8px; }
@@ -2,6 +2,8 @@
2
2
  interface Prize { place?: number; category?: string; title?: string; description?: string; value?: string }
3
3
  defineProps<{
4
4
  prizes: Prize[];
5
+ /** Optional Markdown intro shown above the prize cards (section-level, not per-prize). */
6
+ description?: string | null;
5
7
  }>();
6
8
 
7
9
  function prizeLabel(prize: Prize): string {
@@ -35,7 +37,8 @@ function prizeIcon(prize: Prize): string {
35
37
  <div class="cpub-sec-head">
36
38
  <h2><i class="fa fa-trophy" style="color: var(--yellow);"></i> Prizes</h2>
37
39
  </div>
38
- <div class="cpub-prize-grid">
40
+ <CpubMarkdown v-if="description" :source="description" class="cpub-prizes-intro" />
41
+ <div v-if="prizes.length" class="cpub-prize-grid">
39
42
  <div
40
43
  v-for="(prize, i) in prizes"
41
44
  :key="i"
@@ -58,6 +61,7 @@ function prizeIcon(prize: Prize): string {
58
61
  .cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
59
62
  .cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
60
63
 
64
+ .cpub-prizes-intro { margin-bottom: 16px; }
61
65
  .cpub-prize-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 20px; }
62
66
  .cpub-prize-card { border-radius: var(--radius); padding: 20px; text-align: center; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); }
63
67
  .cpub-prize-gold { box-shadow: var(--shadow-accent); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -57,12 +57,12 @@
57
57
  "@commonpub/docs": "0.6.3",
58
58
  "@commonpub/config": "0.16.0",
59
59
  "@commonpub/learning": "0.5.2",
60
- "@commonpub/explainer": "0.7.15",
61
60
  "@commonpub/editor": "0.7.11",
62
- "@commonpub/schema": "0.23.0",
61
+ "@commonpub/explainer": "0.7.15",
62
+ "@commonpub/schema": "0.24.0",
63
+ "@commonpub/server": "2.66.0",
63
64
  "@commonpub/ui": "0.9.1",
64
- "@commonpub/protocol": "0.12.0",
65
- "@commonpub/server": "2.64.0"
65
+ "@commonpub/protocol": "0.12.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -41,6 +41,7 @@ function toggleRole(r: string): void {
41
41
  else visibleToRoles.value.push(r);
42
42
  }
43
43
 
44
+ const prizesDescription = ref('');
44
45
  interface Prize { place: number | null; category: string; title: string; description: string; value: string }
45
46
  const prizes = ref<Prize[]>([]);
46
47
 
@@ -64,6 +65,7 @@ watch(contest, (c) => {
64
65
  maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
65
66
  visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
66
67
  visibleToRoles.value = [...(c.visibleToRoles ?? [])];
68
+ prizesDescription.value = c.prizesDescription ?? '';
67
69
  prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
68
70
  place: p.place ?? null,
69
71
  category: p.category ?? '',
@@ -151,6 +153,7 @@ async function handleSave(): Promise<void> {
151
153
  maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
152
154
  visibility: visibility.value,
153
155
  visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
156
+ prizesDescription: prizesDescription.value || undefined,
154
157
  prizes: prizeData,
155
158
  judgingCriteria: criteriaData,
156
159
  },
@@ -272,6 +275,11 @@ async function transitionStatus(newStatus: string): Promise<void> {
272
275
  <section class="cpub-form-section">
273
276
  <h2 class="cpub-form-section-title">Prizes</h2>
274
277
  <p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes, a <strong>category</strong> for themed awards, or just a <strong>description</strong> — whatever fits. Cash value is optional.</p>
278
+ <div class="cpub-form-field">
279
+ <label class="cpub-form-label">Prizes overview (optional)</label>
280
+ <textarea v-model="prizesDescription" class="cpub-form-textarea" rows="3" placeholder="Intro shown above the prize cards. Supports Markdown." />
281
+ <p class="cpub-form-hint">Markdown intro displayed on the Prizes tab, above the individual prizes.</p>
282
+ </div>
275
283
  <div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
276
284
  <div class="cpub-prize-header">
277
285
  <span class="cpub-prize-label">{{ prizeLabel(prize) }}</span>
@@ -51,7 +51,7 @@ interface Tab { key: string; label: string; icon: string; count?: number }
51
51
  const tabs = computed<Tab[]>(() => {
52
52
  const t: Tab[] = [{ key: 'overview', label: 'Overview', icon: 'fa-circle-info' }];
53
53
  if (c.value?.rules) t.push({ key: 'rules', label: 'Rules', icon: 'fa-file-lines' });
54
- if (c.value?.prizes?.length) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
54
+ if (c.value?.prizes?.length || c.value?.prizesDescription) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
55
55
  t.push({ key: 'entries', label: 'Entries', icon: 'fa-box-open', count: c.value?.entryCount ?? entries.value.length });
56
56
  if (participants.value.length) t.push({ key: 'participants', label: 'Participants', icon: 'fa-users', count: participants.value.length });
57
57
  if (judges.value.length || isOwner.value) t.push({ key: 'judges', label: 'Judges', icon: 'fa-gavel', count: judges.value.length || undefined });
@@ -273,7 +273,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
273
273
 
274
274
  <!-- PRIZES -->
275
275
  <div v-show="activeTab === 'prizes'" id="cpub-panel-prizes" role="tabpanel" aria-labelledby="cpub-tab-prizes" tabindex="0">
276
- <ContestPrizes v-if="c?.prizes?.length" :prizes="c.prizes" />
276
+ <ContestPrizes v-if="c?.prizes?.length || c?.prizesDescription" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" />
277
277
  </div>
278
278
 
279
279
  <!-- ENTRIES -->
@@ -47,6 +47,7 @@ interface Prize {
47
47
  value: string;
48
48
  }
49
49
 
50
+ const prizesDescription = ref('');
50
51
  const prizes = ref<Prize[]>([
51
52
  { place: 1, category: '', title: '1st Place', description: '', value: '' },
52
53
  { place: 2, category: '', title: '2nd Place', description: '', value: '' },
@@ -104,6 +105,7 @@ async function handleCreate(): Promise<void> {
104
105
  visibleToRoles: visibility.value === 'private' && visibleToRoles.value.length ? visibleToRoles.value : undefined,
105
106
  eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
106
107
  maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
108
+ prizesDescription: prizesDescription.value || undefined,
107
109
  prizes: prizes.value
108
110
  .filter(p => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
109
111
  .map(p => ({
@@ -286,6 +288,11 @@ function prizeLabel(prize: Prize): string {
286
288
  </div>
287
289
 
288
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>
291
+ <div class="cpub-form-field">
292
+ <label for="prizes-desc" class="cpub-form-label">Prizes overview (optional)</label>
293
+ <textarea id="prizes-desc" v-model="prizesDescription" class="cpub-form-textarea" rows="3" placeholder="Intro shown above the prize cards. Supports Markdown." />
294
+ <p class="cpub-form-hint">Markdown intro displayed on the Prizes tab, above the individual prizes.</p>
295
+ </div>
289
296
  <div v-for="(prize, idx) in prizes" :key="idx" class="cpub-prize-card">
290
297
  <div class="cpub-prize-header">
291
298
  <span class="cpub-prize-place">
@@ -4,6 +4,25 @@ useSeoMeta({ title: `Contests — ${useSiteName()}` });
4
4
  const { data: contests } = await useFetch('/api/contests');
5
5
  const { isAuthenticated, isAdmin, user } = useAuth();
6
6
 
7
+ // Card blurb: prefer the short subheading; otherwise a plain-text, markdown-
8
+ // stripped excerpt of the (possibly long Markdown) description — so listing
9
+ // cards never dump a raw `## ...` wall.
10
+ function cardBlurb(c: { subheading?: string | null; description?: string | null }): string {
11
+ if (c.subheading?.trim()) return c.subheading.trim();
12
+ const d = (c.description ?? '').trim();
13
+ if (!d) return '';
14
+ return d
15
+ .replace(/```[\s\S]*?```/g, ' ')
16
+ .replace(/`([^`]*)`/g, '$1')
17
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
18
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
19
+ .replace(/^#{1,6}\s+/gm, '')
20
+ .replace(/^\s*[-*+>]\s+/gm, '')
21
+ .replace(/(\*\*|__|~~|\*|_)/g, '')
22
+ .replace(/\s+/g, ' ')
23
+ .trim();
24
+ }
25
+
7
26
  const config = useRuntimeConfig();
8
27
  const contestCreation = config.public.contestCreation as string || 'admin';
9
28
  const canCreateContest = computed(() => {
@@ -36,8 +55,8 @@ const canCreateContest = computed(() => {
36
55
  {{ contest.title }}
37
56
  </NuxtLink>
38
57
  </h3>
39
- <p v-if="contest.description" style="font-size: 12px; color: var(--text-dim); margin-bottom: 12px">
40
- {{ contest.description }}
58
+ <p v-if="cardBlurb(contest)" class="cpub-contest-card-blurb" style="font-size: 12px; color: var(--text-dim); margin-bottom: 12px">
59
+ {{ cardBlurb(contest) }}
41
60
  </p>
42
61
  <div v-if="contest.endDate" style="margin-top: 8px">
43
62
  <CountdownTimer :target-date="contest.endDate" />
@@ -63,6 +82,15 @@ const canCreateContest = computed(() => {
63
82
  .cpub-card:hover { box-shadow: var(--shadow-lg); transform: translate(-1px, -1px); }
64
83
  .cpub-card-body { padding: 16px; }
65
84
 
85
+ .cpub-contest-card-blurb {
86
+ display: -webkit-box;
87
+ -webkit-line-clamp: 3;
88
+ line-clamp: 3;
89
+ -webkit-box-orient: vertical;
90
+ overflow: hidden;
91
+ line-height: 1.5;
92
+ }
93
+
66
94
  @media (max-width: 768px) {
67
95
  .cpub-contests-page { padding: 16px; }
68
96
  .cpub-grid-3 { grid-template-columns: 1fr; gap: 14px; }