Skip to content

Commit de10674

Browse files
Adebesin-Cellclaude
andcommitted
feat: add tiered OG image layouts for compare page (up to 10 packages)
- Full tier (1-4): name + downloads + version badge + bar - Compact tier (5-6): name + downloads + thinner bar, no version - Grid tier (7-12): side-by-side grid layout (4×2 or 5×2) - Summary tier (13+): top 3 names + "+N more" fallback - Extended accent colors to 12 for larger comparisons - Skip version fetch for non-full tiers (faster rendering) - Extract magic numbers into named constants Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0274948 commit de10674

File tree

1 file changed

+175
-42
lines changed

1 file changed

+175
-42
lines changed

app/components/OgImage/Compare.vue

Lines changed: 175 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,36 @@ const props = withDefaults(
1515
},
1616
)
1717
18-
const ACCENT_COLORS = ['#60a5fa', '#f472b6', '#34d399', '#fbbf24']
18+
// OG image is 1200×600. With 80px horizontal padding, content width is 1040px.
19+
const OG_PADDING_X = 80
20+
const CONTENT_WIDTH = 1200 - OG_PADDING_X * 2
21+
22+
const ACCENT_COLORS = [
23+
'#60a5fa', '#f472b6', '#34d399', '#fbbf24',
24+
'#a78bfa', '#fb923c', '#22d3ee', '#e879f9',
25+
'#4ade80', '#f87171', '#38bdf8', '#facc15',
26+
]
27+
28+
// Tier thresholds
29+
const FULL_MAX = 4
30+
const COMPACT_MAX = 6
31+
const GRID_MAX = 12
32+
const SUMMARY_TOP_COUNT = 3
1933
2034
const displayPackages = computed(() => {
2135
const raw = props.packages
22-
const list = (typeof raw === 'string' ? raw.split(',') : raw).map(p => p.trim()).filter(Boolean)
23-
return list.slice(0, 4)
36+
return (typeof raw === 'string' ? raw.split(',') : raw)
37+
.map(p => p.trim())
38+
.filter(Boolean)
39+
})
40+
41+
type LayoutTier = 'full' | 'compact' | 'grid' | 'summary'
42+
const layoutTier = computed<LayoutTier>(() => {
43+
const count = displayPackages.value.length
44+
if (count <= FULL_MAX) return 'full'
45+
if (count <= COMPACT_MAX) return 'compact'
46+
if (count <= GRID_MAX) return 'grid'
47+
return 'summary'
2448
})
2549
2650
interface PkgStats {
@@ -34,36 +58,41 @@ const stats = ref<PkgStats[]>([])
3458
3559
const FETCH_TIMEOUT_MS = 2500
3660
37-
try {
38-
const results = await Promise.all(
39-
displayPackages.value.map(async (name, index) => {
40-
const encoded = encodePackageName(name)
41-
const [dlData, pkgData] = await Promise.all([
42-
$fetch<{ downloads: number }>(
43-
`https://api.npmjs.org/downloads/point/last-week/${encoded}`,
44-
{ timeout: FETCH_TIMEOUT_MS },
45-
).catch(() => null),
46-
$fetch<{ 'dist-tags'?: { latest?: string } }>(`https://registry.npmjs.org/${encoded}`, {
47-
timeout: FETCH_TIMEOUT_MS,
48-
headers: { Accept: 'application/vnd.npm.install-v1+json' },
49-
}).catch(() => null),
50-
])
51-
return {
52-
name,
53-
downloads: dlData?.downloads ?? 0,
54-
version: pkgData?.['dist-tags']?.latest ?? '',
55-
color: ACCENT_COLORS[index % ACCENT_COLORS.length]!,
56-
}
57-
}),
58-
)
59-
stats.value = results
60-
} catch {
61-
stats.value = displayPackages.value.map((name, index) => ({
62-
name,
63-
downloads: 0,
64-
version: '',
65-
color: ACCENT_COLORS[index % ACCENT_COLORS.length]!,
66-
}))
61+
if (layoutTier.value !== 'summary') {
62+
try {
63+
const results = await Promise.all(
64+
displayPackages.value.map(async (name, index) => {
65+
const encoded = encodePackageName(name)
66+
const needsVersion = layoutTier.value === 'full'
67+
const [dlData, pkgData] = await Promise.all([
68+
$fetch<{ downloads: number }>(
69+
`https://api.npmjs.org/downloads/point/last-week/${encoded}`,
70+
{ timeout: FETCH_TIMEOUT_MS },
71+
).catch(() => null),
72+
needsVersion
73+
? $fetch<{ 'dist-tags'?: { latest?: string } }>(`https://registry.npmjs.org/${encoded}`, {
74+
timeout: FETCH_TIMEOUT_MS,
75+
headers: { Accept: 'application/vnd.npm.install-v1+json' },
76+
}).catch(() => null)
77+
: Promise.resolve(null),
78+
])
79+
return {
80+
name,
81+
downloads: dlData?.downloads ?? 0,
82+
version: pkgData?.['dist-tags']?.latest ?? '',
83+
color: ACCENT_COLORS[index % ACCENT_COLORS.length]!,
84+
}
85+
}),
86+
)
87+
stats.value = results
88+
} catch {
89+
stats.value = displayPackages.value.map((name, index) => ({
90+
name,
91+
downloads: 0,
92+
version: '',
93+
color: ACCENT_COLORS[index % ACCENT_COLORS.length]!,
94+
}))
95+
}
6796
}
6897
6998
const maxDownloads = computed(() => Math.max(...stats.value.map(s => s.downloads), 1))
@@ -76,17 +105,44 @@ function formatDownloads(n: number): string {
76105
}).format(n)
77106
}
78107
79-
// Bar width as percentage string (max 100%)
108+
const BAR_MIN_PCT = 5
109+
const BAR_MAX_PCT = 100
110+
80111
function barPct(downloads: number): string {
81112
if (downloads <= 0) return '0%'
82113
const pct = (downloads / maxDownloads.value) * 100
83-
return `${Math.min(100, Math.max(pct, 5))}%`
114+
return `${Math.min(BAR_MAX_PCT, Math.max(pct, BAR_MIN_PCT))}%`
84115
}
116+
117+
// Grid layout: aim for 2 balanced rows
118+
const GRID_COLS_SMALL = 4 // for up to 8 packages (2 rows of 4)
119+
const GRID_COLS_LARGE = 5 // for 9+ packages (2 rows of 5)
120+
121+
const gridColumns = computed(() => {
122+
const count = stats.value.length
123+
if (count <= GRID_COLS_SMALL * 2) return GRID_COLS_SMALL
124+
return GRID_COLS_LARGE
125+
})
126+
127+
const GRID_ITEM_GAP = 10
128+
const gridItemWidth = computed(() => `${Math.floor(CONTENT_WIDTH / gridColumns.value) - GRID_ITEM_GAP}px`)
129+
130+
const gridRows = computed(() => {
131+
const cols = gridColumns.value
132+
const rows: PkgStats[][] = []
133+
for (let i = 0; i < stats.value.length; i += cols) {
134+
rows.push(stats.value.slice(i, i + cols))
135+
}
136+
return rows
137+
})
138+
139+
const summaryTopNames = computed(() => displayPackages.value.slice(0, SUMMARY_TOP_COUNT))
140+
const summaryRemainder = computed(() => Math.max(0, displayPackages.value.length - SUMMARY_TOP_COUNT))
85141
</script>
86142

87143
<template>
88144
<div
89-
class="h-full w-full flex flex-col justify-center px-20 bg-[#050505] text-[#fafafa] relative overflow-hidden"
145+
class="h-full w-full flex flex-col justify-center relative overflow-hidden bg-[#050505] text-[#fafafa] px-20"
90146
style="font-family: 'Geist Mono', sans-serif"
91147
>
92148
<div class="relative z-10 flex flex-col gap-5">
@@ -125,17 +181,16 @@ function barPct(downloads: number): string {
125181

126182
<!-- Empty state -->
127183
<div
128-
v-if="stats.length === 0"
184+
v-if="displayPackages.length === 0"
129185
class="text-4xl text-[#a3a3a3]"
130186
style="font-family: 'Geist', sans-serif"
131187
>
132188
{{ emptyDescription }}
133189
</div>
134190

135-
<!-- Bar chart rows -->
136-
<div v-else class="flex flex-col gap-2">
191+
<!-- FULL layout (1-4 packages): name + downloads + version badge + bar -->
192+
<div v-else-if="layoutTier === 'full'" class="flex flex-col gap-2">
137193
<div v-for="pkg in stats" :key="pkg.name" class="flex flex-col gap-1">
138-
<!-- Label row: name + downloads + version -->
139194
<div class="flex items-center gap-3" style="font-family: 'Geist', sans-serif">
140195
<span
141196
class="text-2xl font-semibold tracking-tight truncate max-w-[400px]"
@@ -158,8 +213,6 @@ function barPct(downloads: number): string {
158213
{{ pkg.version }}
159214
</span>
160215
</div>
161-
162-
<!-- Bar -->
163216
<div
164217
class="h-6 rounded-md"
165218
:style="{
@@ -169,6 +222,86 @@ function barPct(downloads: number): string {
169222
/>
170223
</div>
171224
</div>
225+
226+
<!-- COMPACT layout (5-6 packages): name + downloads + thinner bar, no version -->
227+
<div v-else-if="layoutTier === 'compact'" class="flex flex-col gap-2">
228+
<div v-for="pkg in stats" :key="pkg.name" class="flex flex-col gap-0.5">
229+
<div class="flex items-center gap-2" style="font-family: 'Geist', sans-serif">
230+
<span
231+
class="text-xl font-semibold tracking-tight truncate max-w-[300px]"
232+
:style="{ color: pkg.color }"
233+
>
234+
{{ pkg.name }}
235+
</span>
236+
<span class="text-xl font-bold text-[#fafafa]">
237+
{{ formatDownloads(pkg.downloads) }}/wk
238+
</span>
239+
</div>
240+
<div
241+
class="h-3 rounded-sm"
242+
:style="{
243+
width: barPct(pkg.downloads),
244+
background: `linear-gradient(90deg, ${pkg.color}50, ${pkg.color}20)`,
245+
}"
246+
/>
247+
</div>
248+
</div>
249+
250+
<!-- GRID layout (7-12 packages): packages in a side-by-side grid -->
251+
<div v-else-if="layoutTier === 'grid'" class="flex flex-col gap-6" style="font-family: 'Geist', sans-serif">
252+
<div
253+
v-for="(row, ri) in gridRows"
254+
:key="ri"
255+
class="flex items-start"
256+
>
257+
<!-- Using <span> as grid items because Satori treats <div> as full-width flex columns -->
258+
<span
259+
v-for="pkg in row"
260+
:key="pkg.name"
261+
:style="{
262+
display: 'flex',
263+
flexDirection: 'column',
264+
gap: '2px',
265+
width: gridItemWidth,
266+
}"
267+
>
268+
<span
269+
class="font-semibold tracking-tight"
270+
:style="{
271+
fontSize: '18px',
272+
overflow: 'hidden',
273+
textOverflow: 'ellipsis',
274+
whiteSpace: 'nowrap',
275+
color: pkg.color,
276+
}"
277+
>{{ pkg.name }}</span>
278+
<span class="flex items-baseline gap-0.5">
279+
<span class="text-2xl font-bold text-[#e5e5e5]">{{ formatDownloads(pkg.downloads) }}</span>
280+
<span class="text-sm font-medium text-[#737373]">/wk</span>
281+
</span>
282+
</span>
283+
</div>
284+
</div>
285+
286+
<!-- SUMMARY layout (13+ packages): top names + remainder count -->
287+
<div v-else class="flex flex-col gap-4" style="font-family: 'Geist', sans-serif">
288+
<div class="text-5xl font-bold tracking-tight">
289+
Comparing {{ displayPackages.length }} packages
290+
</div>
291+
<div class="flex items-center gap-2 text-2xl text-[#a3a3a3]">
292+
<span
293+
v-for="(name, i) in summaryTopNames"
294+
:key="name"
295+
class="font-semibold"
296+
:style="{ color: ACCENT_COLORS[i % ACCENT_COLORS.length] }"
297+
>
298+
{{ name }}<span v-if="i < summaryTopNames.length - 1" class="text-[#525252]">,</span>
299+
</span>
300+
<span v-if="summaryRemainder > 0" class="text-[#737373]">
301+
+{{ summaryRemainder }} more
302+
</span>
303+
</div>
304+
</div>
172305
</div>
173306

174307
<div

0 commit comments

Comments
 (0)