Skip to content

Commit b160e55

Browse files
committed
feat: build the basic share card flow
1 parent 8cd4074 commit b160e55

6 files changed

Lines changed: 565 additions & 0 deletions

File tree

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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>

app/components/Package/Header.vue

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ onKeyStroke(
183183
const { user } = useAtproto()
184184
185185
const authModal = useModal('auth-modal')
186+
const shareModal = useModal('share-modal')
186187
187188
const { data: likesData, status: likeStatus } = useFetch(
188189
() => `/api/social/likes/${packageName.value}`,
@@ -312,9 +313,25 @@ const likeAction = async () => {
312313
</span>
313314
</ButtonBase>
314315
</TooltipApp>
316+
<!-- Share card -->
317+
<ButtonBase
318+
classicon="i-lucide:share-2"
319+
aria-label="Share package card"
320+
@click="shareModal.open()"
321+
>
322+
<span class="max-sm:sr-only">share</span>
323+
</ButtonBase>
315324
</div>
316325
</div>
317326
</header>
327+
<!-- Share modal -->
328+
<PackageShareModal
329+
v-if="pkg"
330+
:package-name="packageName"
331+
:resolved-version="resolvedVersion ?? ''"
332+
:is-latest="resolvedVersion === pkg?.['dist-tags']?.latest"
333+
:license="(displayVersion as any)?.license"
334+
/>
318335
<div
319336
ref="header"
320337
class="w-full bg-bg sticky top-14 z-10 border-b border-border pt-2"

0 commit comments

Comments
 (0)