@commonpub/layer 0.46.0 → 0.48.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.
@@ -303,6 +303,12 @@ function formatCount(n: number | undefined): string {
303
303
  .cpub-cc-av {
304
304
  width: 18px;
305
305
  height: 18px;
306
+ /* Hard-lock square so a portrait/non-square photo can't render as an oval
307
+ (intrinsic-ratio fallback if a dimension is dropped). See ArticleView.vue. */
308
+ min-width: 18px;
309
+ max-width: 18px;
310
+ min-height: 18px;
311
+ max-height: 18px;
306
312
  background: var(--accent-bg);
307
313
  border: var(--border-width-default) solid var(--accent-border);
308
314
  color: var(--accent);
@@ -435,8 +435,18 @@ useJsonLd({
435
435
  * Fix: scope display:flex centering to the div variant only.
436
436
  */
437
437
  .cpub-av {
438
- width: 28px;
439
- height: 28px;
438
+ --cpub-av-size: 28px;
439
+ width: var(--cpub-av-size);
440
+ height: var(--cpub-av-size);
441
+ /* Hard-lock to a square. Without min/max clamps, a global img reset or a
442
+ dropped dimension lets the <img> fall back to its intrinsic aspect ratio,
443
+ so a portrait photo renders as a tall oval (the deveco blog-avatar bug —
444
+ visible even on wide viewports, so it's not flex compression). min/max on
445
+ BOTH axes clamp the used size regardless of what sets width/height. */
446
+ min-width: var(--cpub-av-size);
447
+ max-width: var(--cpub-av-size);
448
+ min-height: var(--cpub-av-size);
449
+ max-height: var(--cpub-av-size);
440
450
  border-radius: 50%;
441
451
  background: var(--surface3);
442
452
  border: var(--border-width-default) solid var(--border);
@@ -459,8 +469,8 @@ img.cpub-av {
459
469
  object-fit: cover;
460
470
  }
461
471
 
462
- .cpub-av-lg { width: 44px; height: 44px; font-size: 14px; }
463
- .cpub-av-xl { width: 64px; height: 64px; font-size: 18px; }
472
+ .cpub-av-lg { --cpub-av-size: 44px; font-size: 14px; }
473
+ .cpub-av-xl { --cpub-av-size: 64px; font-size: 18px; }
464
474
 
465
475
  /* ── AUTHOR ROW ── */
466
476
  .cpub-author-row {
@@ -767,8 +767,16 @@ async function handleBuild(): Promise<void> {
767
767
  * to the div-variant only — stops img-variant from squishing portrait
768
768
  * avatars (object-fit:cover gets dropped on flex-set replaced elements). */
769
769
  .cpub-av {
770
- width: 28px;
771
- height: 28px;
770
+ --cpub-av-size: 28px;
771
+ width: var(--cpub-av-size);
772
+ height: var(--cpub-av-size);
773
+ /* Hard-lock to a square (min/max on both axes) so a portrait photo can't
774
+ render as an oval if a global reset or dropped dimension lets the <img>
775
+ take its intrinsic aspect ratio. See ArticleView.vue. */
776
+ min-width: var(--cpub-av-size);
777
+ max-width: var(--cpub-av-size);
778
+ min-height: var(--cpub-av-size);
779
+ max-height: var(--cpub-av-size);
772
780
  border-radius: 50%;
773
781
  background: var(--surface3);
774
782
  border: var(--border-width-default) solid var(--border);
@@ -789,7 +797,7 @@ img.cpub-av {
789
797
  object-fit: cover;
790
798
  }
791
799
 
792
- .cpub-av-lg { width: 36px; height: 36px; font-size: 12px; }
800
+ .cpub-av-lg { --cpub-av-size: 36px; font-size: 12px; }
793
801
 
794
802
  .cpub-author-name {
795
803
  font-size: 13px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -54,12 +54,12 @@
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
56
  "@commonpub/auth": "0.8.0",
57
- "@commonpub/docs": "0.6.3",
58
57
  "@commonpub/config": "0.18.0",
59
- "@commonpub/editor": "0.7.11",
58
+ "@commonpub/learning": "0.5.2",
59
+ "@commonpub/docs": "0.6.3",
60
60
  "@commonpub/explainer": "0.7.15",
61
+ "@commonpub/editor": "0.7.11",
61
62
  "@commonpub/protocol": "0.13.0",
62
- "@commonpub/learning": "0.5.2",
63
63
  "@commonpub/schema": "0.26.0",
64
64
  "@commonpub/server": "2.73.0",
65
65
  "@commonpub/ui": "0.9.2"
@@ -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,45 @@ 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
+ <!-- Banner thumbnail (contest.bannerUrl) with trophy fallback + status badge overlay -->
55
+ <div class="cpub-contest-thumb">
56
+ <img
57
+ v-if="coverFor(contest.bannerUrl)"
58
+ :src="coverFor(contest.bannerUrl)!"
59
+ :alt="contest.title"
60
+ class="cpub-contest-cover"
61
+ loading="lazy"
62
+ />
63
+ <template v-else>
64
+ <div class="cpub-contest-thumb-grid" />
65
+ <i class="fa-solid fa-trophy cpub-contest-thumb-icon" />
66
+ </template>
67
+ <span class="cpub-badge cpub-contest-thumb-badge" :class="{
37
68
  'cpub-badge-green': contest.status === 'active',
38
69
  'cpub-badge-yellow': contest.status === 'upcoming',
39
70
  'cpub-badge-accent': contest.status === 'judging',
40
71
  'cpub-badge-red': contest.status === 'completed' || contest.status === 'cancelled',
41
72
  }">{{ 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">
73
+ </div>
74
+ <div class="cpub-card-body">
75
+ <h3 class="cpub-contest-card-title">{{ contest.title }}</h3>
76
+ <p v-if="cardBlurb(contest)" class="cpub-contest-card-blurb">
48
77
  {{ cardBlurb(contest) }}
49
78
  </p>
50
79
  <div v-if="contest.endDate" style="margin-top: 8px">
51
80
  <CountdownTimer :target-date="contest.endDate" />
52
81
  </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)">
82
+ <div class="cpub-contest-card-meta">
54
83
  <span><i class="fa-solid fa-users"></i> {{ contest.entryCount }} entries</span>
55
84
  </div>
56
85
  </div>
57
- </div>
86
+ </NuxtLink>
58
87
  </div>
59
88
  <div v-else class="cpub-empty-state">
60
89
  <div class="cpub-empty-state-icon"><i class="fa-solid fa-trophy"></i></div>
@@ -71,7 +100,42 @@ const canCreateContest = computed(() => {
71
100
  .cpub-card:hover { box-shadow: var(--shadow-lg); transform: translate(-1px, -1px); }
72
101
  .cpub-card-body { padding: 16px; }
73
102
 
103
+ /* Whole card is a link */
104
+ .cpub-contest-card { display: block; text-decoration: none; color: inherit; }
105
+
106
+ /* Banner thumbnail — wide (banner-shaped), cover-cropped, with a grid+trophy
107
+ fallback when a contest has no bannerUrl. */
108
+ .cpub-contest-thumb {
109
+ position: relative;
110
+ aspect-ratio: 16 / 9;
111
+ background: var(--surface2);
112
+ border-bottom: var(--border-width-default) solid var(--border);
113
+ display: flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ overflow: hidden;
117
+ }
118
+ .cpub-contest-cover { width: 100%; height: 100%; object-fit: cover; display: block; }
119
+ .cpub-contest-thumb-grid {
120
+ position: absolute;
121
+ inset: 0;
122
+ background-image:
123
+ linear-gradient(var(--border2) 1px, transparent 1px),
124
+ linear-gradient(90deg, var(--border2) 1px, transparent 1px);
125
+ background-size: 20px 20px;
126
+ opacity: 0.25;
127
+ }
128
+ .cpub-contest-thumb-icon { position: relative; z-index: 1; font-size: 36px; color: var(--accent); opacity: 0.45; }
129
+ .cpub-contest-thumb-badge { position: absolute; top: 10px; left: 10px; z-index: 2; box-shadow: var(--shadow-sm); }
130
+ .cpub-contest-card:hover .cpub-contest-cover { opacity: 0.92; }
131
+
132
+ .cpub-contest-card-title { font-size: 15px; font-weight: 600; margin: 0 0 6px; color: var(--text); }
133
+ .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); }
134
+
74
135
  .cpub-contest-card-blurb {
136
+ font-size: 12px;
137
+ color: var(--text-dim);
138
+ margin-bottom: 12px;
75
139
  display: -webkit-box;
76
140
  -webkit-line-clamp: 3;
77
141
  line-clamp: 3;