|
| 1 | +<script setup lang="ts"> |
| 2 | +const props = withDefaults( |
| 3 | + defineProps<{ |
| 4 | + name: string |
| 5 | + theme?: 'light' | 'dark' |
| 6 | + primaryColor?: string |
| 7 | + }>(), |
| 8 | + { theme: 'dark', primaryColor: '#5bc8e8' }, |
| 9 | +) |
| 10 | +
|
| 11 | +const THEMES = { |
| 12 | + dark: { |
| 13 | + bg: '#0d0d0d', |
| 14 | + border: '#252525', |
| 15 | + divider: '#1a1a1a', |
| 16 | + text: '#f0f0f0', |
| 17 | + textMuted: '#9a9a9a', |
| 18 | + textSubtle: '#565656', |
| 19 | + footerBg: '#111111', |
| 20 | + }, |
| 21 | + light: { |
| 22 | + bg: '#fafaf9', |
| 23 | + border: '#e0ddd8', |
| 24 | + divider: '#ebebea', |
| 25 | + text: '#1a1a1a', |
| 26 | + textMuted: '#606060', |
| 27 | + textSubtle: '#9a9898', |
| 28 | + footerBg: '#f0efed', |
| 29 | + }, |
| 30 | +} as const |
| 31 | +
|
| 32 | +const t = computed(() => THEMES[props.theme]) |
| 33 | +const primaryColor = computed(() => props.primaryColor || '#5bc8e8') |
| 34 | +
|
| 35 | +// Blend primaryColor with alpha for satori (works for both hex and oklch) |
| 36 | +function withAlpha(color: string, alpha: number): string { |
| 37 | + if (color.startsWith('oklch(')) return color.replace(')', ` / ${alpha})`) |
| 38 | + if (color.startsWith('#')) return color + Math.round(alpha * 255).toString(16).padStart(2, '0') |
| 39 | + return color |
| 40 | +} |
| 41 | +
|
| 42 | +function formatNum(n: number) { |
| 43 | + return Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(n) |
| 44 | +} |
| 45 | +
|
| 46 | +function formatBytes(bytes: number) { |
| 47 | + if (!+bytes) return '—' |
| 48 | + const k = 1024 |
| 49 | + const sizes = ['B', 'KB', 'MB', 'GB'] as const |
| 50 | + const i = Math.floor(Math.log(bytes) / Math.log(k)) |
| 51 | + return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}` |
| 52 | +} |
| 53 | +
|
| 54 | +function formatDate(iso: string) { |
| 55 | + return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) |
| 56 | +} |
| 57 | +
|
| 58 | +function truncate(s: string, n: number) { |
| 59 | + return s.length > n ? s.slice(0, n - 1) + '…' : s |
| 60 | +} |
| 61 | +
|
| 62 | +// Data |
| 63 | +const { data: resolvedVersion } = await useResolvedVersion(computed(() => props.name), null) |
| 64 | +const { data: pkg, refresh: refreshPkg } = usePackage( |
| 65 | + computed(() => props.name), |
| 66 | + () => resolvedVersion.value ?? null, |
| 67 | +) |
| 68 | +const { data: downloads, refresh: refreshDownloads } = usePackageDownloads( |
| 69 | + computed(() => props.name), |
| 70 | + 'last-week', |
| 71 | +) |
| 72 | +const displayVersion = computed(() => pkg.value?.requestedVersion ?? null) |
| 73 | +const { repositoryUrl } = useRepositoryUrl(displayVersion) |
| 74 | +const { stars, repoRef, refresh: refreshRepoMeta } = useRepoMeta(repositoryUrl) |
| 75 | +
|
| 76 | +try { |
| 77 | + await refreshPkg() |
| 78 | + await Promise.all([refreshRepoMeta(), refreshDownloads()]) |
| 79 | +} catch (err) { |
| 80 | + console.warn('[share-card] Failed to load data server-side:', err) |
| 81 | +} |
| 82 | +
|
| 83 | +// Per-day sparkline |
| 84 | +let sparklineValues: number[] = [] |
| 85 | +try { |
| 86 | + const result = await $fetch<{ downloads: Array<{ downloads: number }> }>( |
| 87 | + `https://api.npmjs.org/downloads/range/last-month/${encodeURIComponent(props.name)}`, |
| 88 | + ) |
| 89 | + sparklineValues = result.downloads?.map(d => d.downloads) ?? [] |
| 90 | +} catch { /* decorative — omit on failure */ } |
| 91 | +
|
| 92 | +// Dependents |
| 93 | +let dependents = 0 |
| 94 | +try { |
| 95 | + const result = await $fetch<{ total: number }>( |
| 96 | + `https://registry.npmjs.org/-/v1/search?text=dependencies%3A${encodeURIComponent(props.name)}&size=0`, |
| 97 | + ) |
| 98 | + dependents = result.total ?? 0 |
| 99 | +} catch { /* show nothing on failure */ } |
| 100 | +
|
| 101 | +// Derived |
| 102 | +const version = computed(() => resolvedVersion.value ?? pkg.value?.['dist-tags']?.latest ?? '') |
| 103 | +const isLatest = computed(() => pkg.value?.['dist-tags']?.latest === version.value) |
| 104 | +const description = computed(() => pkg.value?.description ?? '') |
| 105 | +const license = computed(() => (pkg.value?.license as string | undefined) ?? '') |
| 106 | +const hasTypes = computed(() => !!(displayVersion.value?.types || displayVersion.value?.typings)) |
| 107 | +const moduleFormat = computed(() => (displayVersion.value?.type === 'module' ? 'ESM' : 'CJS')) |
| 108 | +const depsCount = computed(() => Object.keys(displayVersion.value?.dependencies ?? {}).length) |
| 109 | +// eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 110 | +const unpackedSize = computed(() => (displayVersion.value?.dist as any)?.unpackedSize ?? 0) |
| 111 | +const publishedAt = computed( |
| 112 | + () => pkg.value?.time?.[version.value] ?? pkg.value?.time?.modified ?? '', |
| 113 | +) |
| 114 | +const weeklyDownloads = computed(() => downloads.value?.downloads ?? 0) |
| 115 | +const repoSlug = computed(() => { |
| 116 | + const ref = repoRef.value |
| 117 | + if (!ref) return '' |
| 118 | + return truncate(`${ref.owner}/${ref.repo}`, 26) |
| 119 | +}) |
| 120 | +
|
| 121 | +// Last-week date range label |
| 122 | +const weekRange = computed(() => { |
| 123 | + const end = new Date() |
| 124 | + end.setDate(end.getDate() - 1) |
| 125 | + const start = new Date(end) |
| 126 | + start.setDate(start.getDate() - 6) |
| 127 | + const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) |
| 128 | + return `${fmt(start)} – ${fmt(end)}` |
| 129 | +}) |
| 130 | +
|
| 131 | +// Sparkline — right panel is 400px, 36px padding each side → 328px content, 80px tall |
| 132 | +const sparklinePoints = computed(() => { |
| 133 | + if (sparklineValues.length < 2) return '' |
| 134 | + const W = 328, H = 80, P = 4 |
| 135 | + const max = Math.max(...sparklineValues) |
| 136 | + const min = Math.min(...sparklineValues) |
| 137 | + const range = max - min || 1 |
| 138 | + return sparklineValues |
| 139 | + .map((v, i) => { |
| 140 | + const x = P + (i / (sparklineValues.length - 1)) * (W - P * 2) |
| 141 | + const y = H - P - ((v - min) / range) * (H - P * 2) |
| 142 | + return `${x.toFixed(1)},${y.toFixed(1)}` |
| 143 | + }) |
| 144 | + .join(' ') |
| 145 | +}) |
| 146 | +
|
| 147 | +// Area fill: close the polyline at the bottom corners |
| 148 | +const sparklineAreaPoints = computed(() => { |
| 149 | + if (!sparklinePoints.value) return '' |
| 150 | + const W = 328, H = 80, P = 4 |
| 151 | + return `${sparklinePoints.value} ${(W - P).toFixed(1)},${H} ${P},${H}` |
| 152 | +}) |
| 153 | +</script> |
| 154 | + |
| 155 | +<template> |
| 156 | + <!-- |
| 157 | + Rendered at 2400×840 (2× HiDPI). Tailwind for layout; inline :style for |
| 158 | + dynamic theme colours / pixel values only. |
| 159 | + --> |
| 160 | + <div |
| 161 | + class="h-full w-full flex flex-col" |
| 162 | + :style="{ backgroundColor: t.bg, color: t.text, fontFamily: '\'Geist Mono\', ui-monospace, monospace' }" |
| 163 | + > |
| 164 | + <!-- ── Main row ─────────────────────────────────────────────────── --> |
| 165 | + <div class="flex flex-row flex-1"> |
| 166 | + |
| 167 | + <!-- 3px accent bar (6px at 2×) --> |
| 168 | + <div class="flex-shrink-0" :style="{ width: '6px', backgroundColor: primaryColor }" /> |
| 169 | + |
| 170 | + <!-- Left panel — justify-between pushes meta stats to bottom --> |
| 171 | + <div class="flex flex-col flex-1 justify-between relative" style="padding: 60px 64px 52px 60px"> |
| 172 | + |
| 173 | + <!-- Glow blob (decorative, top-right of left panel) --> |
| 174 | + <div |
| 175 | + class="absolute" |
| 176 | + :style="{ |
| 177 | + top: '-160px', |
| 178 | + right: '-80px', |
| 179 | + width: '520px', |
| 180 | + height: '520px', |
| 181 | + borderRadius: '50%', |
| 182 | + backgroundColor: withAlpha(primaryColor, 0.1), |
| 183 | + filter: 'blur(80px)', |
| 184 | + }" |
| 185 | + /> |
| 186 | + |
| 187 | + <!-- TOP GROUP: name + description + chips --> |
| 188 | + <div class="flex flex-col" style="gap: 0"> |
| 189 | + |
| 190 | + <!-- Name · version · latest badge --> |
| 191 | + <div class="flex flex-row items-center flex-wrap" style="gap: 20px; margin-bottom: 28px"> |
| 192 | + <span :style="{ fontSize: '68px', fontWeight: 700, lineHeight: '1' }"> |
| 193 | + <span :style="{ color: primaryColor, opacity: 0.75 }">./</span>{{ truncate(name, 32) }} |
| 194 | + </span> |
| 195 | + <span :style="{ fontSize: '30px', color: t.textMuted, lineHeight: '1' }">v{{ version }}</span> |
| 196 | + <span |
| 197 | + v-if="isLatest" |
| 198 | + class="flex items-center" |
| 199 | + :style="{ |
| 200 | + fontSize: '20px', |
| 201 | + fontWeight: 600, |
| 202 | + padding: '4px 18px', |
| 203 | + borderRadius: '6px', |
| 204 | + border: `1px solid ${withAlpha(primaryColor, 0.35)}`, |
| 205 | + color: primaryColor, |
| 206 | + lineHeight: '1.5', |
| 207 | + }" |
| 208 | + >latest</span> |
| 209 | + </div> |
| 210 | + |
| 211 | + <!-- Description --> |
| 212 | + <div :style="{ fontSize: '26px', color: t.textMuted, lineHeight: '1.55', marginBottom: '28px' }"> |
| 213 | + {{ truncate(description || 'No description.', 110) }} |
| 214 | + </div> |
| 215 | + |
| 216 | + <!-- Tag chips --> |
| 217 | + <div class="flex flex-row flex-wrap" style="gap: 10px"> |
| 218 | + <span |
| 219 | + v-if="hasTypes" |
| 220 | + class="flex items-center" |
| 221 | + :style="{ fontSize: '18px', padding: '4px 16px', borderRadius: '4px', border: `1px solid ${t.border}`, color: t.textMuted, lineHeight: '1.6' }" |
| 222 | + >✓ TypeScript</span> |
| 223 | + <span |
| 224 | + class="flex items-center" |
| 225 | + :style="{ fontSize: '18px', padding: '4px 16px', borderRadius: '4px', border: `1px solid ${t.border}`, color: t.textMuted, lineHeight: '1.6' }" |
| 226 | + >{{ moduleFormat }}</span> |
| 227 | + <span |
| 228 | + v-if="license" |
| 229 | + class="flex items-center" |
| 230 | + :style="{ fontSize: '18px', padding: '4px 16px', borderRadius: '4px', border: `1px solid ${t.border}`, color: t.textMuted, lineHeight: '1.6' }" |
| 231 | + >{{ license }}</span> |
| 232 | + <span |
| 233 | + v-if="repoSlug" |
| 234 | + class="flex items-center" |
| 235 | + :style="{ fontSize: '18px', padding: '4px 16px', borderRadius: '4px', border: `1px solid ${t.border}`, color: t.textSubtle, lineHeight: '1.6' }" |
| 236 | + >{{ repoSlug }}</span> |
| 237 | + </div> |
| 238 | + </div> |
| 239 | + |
| 240 | + <!-- BOTTOM GROUP: divider + meta stats --> |
| 241 | + <div class="flex flex-col"> |
| 242 | + <div class="w-full" :style="{ height: '1px', backgroundColor: t.divider, marginBottom: '32px' }" /> |
| 243 | + <div class="flex flex-row" style="gap: 56px"> |
| 244 | + <div class="flex flex-col" style="gap: 8px"> |
| 245 | + <span :style="{ fontSize: '14px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', color: t.textSubtle }">Install Size</span> |
| 246 | + <span :style="{ fontSize: '32px', fontWeight: 600, color: t.text }">{{ formatBytes(unpackedSize) }}</span> |
| 247 | + </div> |
| 248 | + <div class="flex flex-col" style="gap: 8px"> |
| 249 | + <span :style="{ fontSize: '14px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', color: t.textSubtle }">Dependencies</span> |
| 250 | + <span :style="{ fontSize: '32px', fontWeight: 600, color: t.text }">{{ depsCount }}</span> |
| 251 | + </div> |
| 252 | + <div v-if="publishedAt" class="flex flex-col" style="gap: 8px"> |
| 253 | + <span :style="{ fontSize: '14px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', color: t.textSubtle }">Published</span> |
| 254 | + <span :style="{ fontSize: '32px', fontWeight: 600, color: t.text }">{{ formatDate(publishedAt) }}</span> |
| 255 | + </div> |
| 256 | + </div> |
| 257 | + </div> |
| 258 | + </div> |
| 259 | + |
| 260 | + <!-- Vertical divider --> |
| 261 | + <div class="flex-shrink-0" :style="{ width: '1px', backgroundColor: t.border }" /> |
| 262 | + |
| 263 | + <!-- Right stats panel (400px) --> |
| 264 | + <div class="flex flex-col flex-shrink-0" style="width: 400px"> |
| 265 | + |
| 266 | + <!-- Weekly Downloads (takes all remaining space above) --> |
| 267 | + <div class="flex flex-col flex-1 justify-center" style="padding: 40px 36px 28px"> |
| 268 | + <span :style="{ fontSize: '14px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', color: t.textSubtle, marginBottom: '10px' }">Weekly DL</span> |
| 269 | + <span :style="{ fontSize: '76px', fontWeight: 700, color: t.text, lineHeight: '1', marginBottom: '8px' }">{{ formatNum(weeklyDownloads) }}</span> |
| 270 | + <span :style="{ fontSize: '18px', color: t.textSubtle, marginBottom: '18px' }">{{ weekRange }}</span> |
| 271 | + <!-- Sparkline with area fill --> |
| 272 | + <svg v-if="sparklinePoints" width="328" height="80" viewBox="0 0 328 80" style="display: block"> |
| 273 | + <polyline |
| 274 | + :points="sparklineAreaPoints" |
| 275 | + :fill="withAlpha(primaryColor, 0.1)" |
| 276 | + stroke="none" |
| 277 | + /> |
| 278 | + <polyline |
| 279 | + :points="sparklinePoints" |
| 280 | + fill="none" |
| 281 | + :stroke="primaryColor" |
| 282 | + stroke-width="2.5" |
| 283 | + stroke-linecap="round" |
| 284 | + stroke-linejoin="round" |
| 285 | + /> |
| 286 | + </svg> |
| 287 | + </div> |
| 288 | + |
| 289 | + <!-- Divider --> |
| 290 | + <div :style="{ height: '1px', backgroundColor: t.divider }" /> |
| 291 | + |
| 292 | + <!-- GitHub Stars + Dependents side by side (fixed height) --> |
| 293 | + <div class="flex flex-row flex-shrink-0" style="height: 260px"> |
| 294 | + |
| 295 | + <!-- Stars --> |
| 296 | + <div class="flex flex-col flex-1 justify-center" style="padding: 28px 28px"> |
| 297 | + <span :style="{ fontSize: '14px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', color: t.textSubtle, marginBottom: '10px' }">Stars</span> |
| 298 | + <span :style="{ fontSize: '54px', fontWeight: 700, color: t.text, lineHeight: '1' }">{{ stars > 0 ? formatNum(stars) : '—' }}</span> |
| 299 | + </div> |
| 300 | + |
| 301 | + <!-- Divider --> |
| 302 | + <div class="flex-shrink-0" :style="{ width: '1px', backgroundColor: t.divider }" /> |
| 303 | + |
| 304 | + <!-- Dependents --> |
| 305 | + <div class="flex flex-col flex-1 justify-center" style="padding: 28px 28px"> |
| 306 | + <span :style="{ fontSize: '14px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', color: t.textSubtle, marginBottom: '10px' }">Deps on</span> |
| 307 | + <span :style="{ fontSize: '54px', fontWeight: 700, color: t.text, lineHeight: '1' }">{{ dependents > 0 ? formatNum(dependents) : '—' }}</span> |
| 308 | + </div> |
| 309 | + </div> |
| 310 | + </div> |
| 311 | + </div> |
| 312 | + |
| 313 | + <!-- ── Footer ────────────────────────────────────────────────────── --> |
| 314 | + <div |
| 315 | + class="flex flex-row items-center justify-between flex-shrink-0" |
| 316 | + :style="{ padding: '20px 52px 20px 48px', borderTop: `1px solid ${t.border}`, backgroundColor: t.footerBg }" |
| 317 | + > |
| 318 | + <div class="flex flex-row items-center" :style="{ fontSize: '22px' }"> |
| 319 | + <span :style="{ color: primaryColor, fontWeight: 700 }">./npmx</span> |
| 320 | + <span :style="{ color: t.textSubtle, marginLeft: '10px' }">· npm package explorer</span> |
| 321 | + </div> |
| 322 | + <span :style="{ fontSize: '20px', color: t.textSubtle }">npmx.dev/package/{{ name }}</span> |
| 323 | + </div> |
| 324 | + </div> |
| 325 | +</template> |
0 commit comments