From b160e5513525c0429fd71684532496b869bdfa4b Mon Sep 17 00:00:00 2001 From: Atriiy Date: Fri, 20 Mar 2026 12:02:24 +0800 Subject: [PATCH 01/23] feat: build the basic share card flow --- app/components/OgImage/ShareCard.vue | 325 ++++++++++++++++++++++++ app/components/Package/Header.vue | 17 ++ app/components/Package/ShareModal.vue | 137 ++++++++++ app/pages/share-card/[[org]]/[name].vue | 55 ++++ server/api/card/[...pkg].get.ts | 30 +++ server/utils/image-proxy.ts | 1 + 6 files changed, 565 insertions(+) create mode 100644 app/components/OgImage/ShareCard.vue create mode 100644 app/components/Package/ShareModal.vue create mode 100644 app/pages/share-card/[[org]]/[name].vue create mode 100644 server/api/card/[...pkg].get.ts diff --git a/app/components/OgImage/ShareCard.vue b/app/components/OgImage/ShareCard.vue new file mode 100644 index 0000000000..ea2b6418ec --- /dev/null +++ b/app/components/OgImage/ShareCard.vue @@ -0,0 +1,325 @@ + + + diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index 76a756b4dc..a0771942ec 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -183,6 +183,7 @@ onKeyStroke( const { user } = useAtproto() const authModal = useModal('auth-modal') +const shareModal = useModal('share-modal') const { data: likesData, status: likeStatus } = useFetch( () => `/api/social/likes/${packageName.value}`, @@ -312,9 +313,25 @@ const likeAction = async () => { + + + share + + +
+const props = defineProps<{ + packageName: string + resolvedVersion: string + isLatest: boolean + license?: string +}>() + +const { origin } = useRequestURL() +const colorMode = useColorMode() +const theme = computed(() => (colorMode.value === 'dark' ? 'dark' : 'light')) +const { selectedAccentColor } = useAccentColor() +const colorParam = computed(() => selectedAccentColor.value ? `&color=${selectedAccentColor.value}` : '') + +const cardUrl = computed(() => `/api/card/${props.packageName}.png?theme=${theme.value}${colorParam.value}`) +const absoluteCardUrl = computed(() => `${origin}/api/card/${props.packageName}.png?theme=${theme.value}${colorParam.value}`) + +// Downloads for alt text +const { data: downloadsData } = usePackageDownloads( + computed(() => props.packageName), + 'last-week', +) + +const altText = computed(() => { + const tag = props.isLatest ? 'latest' : props.resolvedVersion + const parts: string[] = [`${props.packageName} ${props.resolvedVersion} (${tag})`] + const dl = downloadsData.value?.downloads + if (dl && dl > 0) { + parts.push( + `${Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(dl)} weekly downloads`, + ) + } + if (props.license) parts.push(`${props.license} license`) + parts.push('via npmx.dev') + return parts.join(' — ') +}) + +// Copy link button +const { copied: linkCopied, copy: copyLink } = useClipboard({ + source: absoluteCardUrl, + copiedDuring: 1500, +}) + +// Copy alt text button +const { copied: altCopied, copy: copyAlt } = useClipboard({ + source: altText, + copiedDuring: 1500, +}) + +// Image load state — dialog is display:none when closed so loading begins on open +const imgLoaded = ref(false) +const imgError = ref(false) + +watch(cardUrl, () => { + imgLoaded.value = false + imgError.value = false +}) + +async function downloadCard() { + const a = document.createElement('a') + a.href = cardUrl.value + a.download = `${props.packageName.replace('/', '-')}-card.png` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) +} + + + diff --git a/app/pages/share-card/[[org]]/[name].vue b/app/pages/share-card/[[org]]/[name].vue new file mode 100644 index 0000000000..ed84b34ffe --- /dev/null +++ b/app/pages/share-card/[[org]]/[name].vue @@ -0,0 +1,55 @@ + + + diff --git a/server/api/card/[...pkg].get.ts b/server/api/card/[...pkg].get.ts new file mode 100644 index 0000000000..8cfc55c9a6 --- /dev/null +++ b/server/api/card/[...pkg].get.ts @@ -0,0 +1,30 @@ +import { createError, getQuery, getRouterParam, sendRedirect } from 'h3' +import { assertValidPackageName } from '#shared/utils/npm' + +export default defineEventHandler(async event => { + const segments = getRouterParam(event, 'pkg')?.split('/') ?? [] + + // Strip .png extension from the final segment (e.g. /api/card/nuxt.png) + if (segments.length > 0) { + const last = segments[segments.length - 1]! + if (last.endsWith('.png')) segments[segments.length - 1] = last.slice(0, -4) + } + + const packageName = segments.join('/') + + if (!packageName) { + throw createError({ statusCode: 404, message: 'Package name is required.' }) + } + + assertValidPackageName(packageName) + + const query = getQuery(event) + const theme = query.theme === 'light' ? 'light' : 'dark' + const color = typeof query.color === 'string' ? `&color=${encodeURIComponent(query.color)}` : '' + + return sendRedirect( + event, + `/__og-image__/image/share-card/${packageName}/og.png?theme=${theme}${color}`, + 302, + ) +}) diff --git a/server/utils/image-proxy.ts b/server/utils/image-proxy.ts index ae590a9d0d..927a1884fe 100644 --- a/server/utils/image-proxy.ts +++ b/server/utils/image-proxy.ts @@ -76,6 +76,7 @@ const TRUSTED_IMAGE_DOMAINS = [ 'travis-ci.org', 'secure.travis-ci.org', 'img.badgesize.io', + 'api.securityscorecards.dev', ] /** From 2c22d83c91c8753a71c2989defaf64b67086615e Mon Sep 17 00:00:00 2001 From: Atriiy Date: Fri, 20 Mar 2026 13:21:38 +0800 Subject: [PATCH 02/23] fix: tweaks share card UI --- app/components/OgImage/ShareCard.vue | 322 ++++++++++++++++++------ app/components/Package/ShareModal.vue | 24 +- app/pages/share-card/[[org]]/[name].vue | 2 +- 3 files changed, 255 insertions(+), 93 deletions(-) diff --git a/app/components/OgImage/ShareCard.vue b/app/components/OgImage/ShareCard.vue index ea2b6418ec..ab539af0dc 100644 --- a/app/components/OgImage/ShareCard.vue +++ b/app/components/OgImage/ShareCard.vue @@ -35,7 +35,13 @@ const primaryColor = computed(() => props.primaryColor || '#5bc8e8') // Blend primaryColor with alpha for satori (works for both hex and oklch) function withAlpha(color: string, alpha: number): string { if (color.startsWith('oklch(')) return color.replace(')', ` / ${alpha})`) - if (color.startsWith('#')) return color + Math.round(alpha * 255).toString(16).padStart(2, '0') + if (color.startsWith('#')) + return ( + color + + Math.round(alpha * 255) + .toString(16) + .padStart(2, '0') + ) return color } @@ -52,7 +58,11 @@ function formatBytes(bytes: number) { } function formatDate(iso: string) { - return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) } function truncate(s: string, n: number) { @@ -60,7 +70,10 @@ function truncate(s: string, n: number) { } // Data -const { data: resolvedVersion } = await useResolvedVersion(computed(() => props.name), null) +const { data: resolvedVersion } = await useResolvedVersion( + computed(() => props.name), + null, +) const { data: pkg, refresh: refreshPkg } = usePackage( computed(() => props.name), () => resolvedVersion.value ?? null, @@ -71,7 +84,7 @@ const { data: downloads, refresh: refreshDownloads } = usePackageDownloads( ) const displayVersion = computed(() => pkg.value?.requestedVersion ?? null) const { repositoryUrl } = useRepositoryUrl(displayVersion) -const { stars, repoRef, refresh: refreshRepoMeta } = useRepoMeta(repositoryUrl) +const { stars, forks, repoRef, refresh: refreshRepoMeta } = useRepoMeta(repositoryUrl) try { await refreshPkg() @@ -80,23 +93,26 @@ try { console.warn('[share-card] Failed to load data server-side:', err) } -// Per-day sparkline +// Weekly sparkline (52 weeks, matching the package page chart) let sparklineValues: number[] = [] try { + const endDate = new Date() + endDate.setDate(endDate.getDate() - 1) + const startDate = new Date(endDate) + startDate.setDate(startDate.getDate() - (52 * 7 - 1)) + const fmtDate = (d: Date) => d.toISOString().slice(0, 10) const result = await $fetch<{ downloads: Array<{ downloads: number }> }>( - `https://api.npmjs.org/downloads/range/last-month/${encodeURIComponent(props.name)}`, + `https://api.npmjs.org/downloads/range/${fmtDate(startDate)}:${fmtDate(endDate)}/${encodeURIComponent(props.name)}`, ) - sparklineValues = result.downloads?.map(d => d.downloads) ?? [] -} catch { /* decorative — omit on failure */ } + const daily = result.downloads ?? [] + // Aggregate daily → weekly buckets (sum each 7-day chunk) + for (let i = 0; i < daily.length; i += 7) { + sparklineValues.push(daily.slice(i, i + 7).reduce((sum, d) => sum + d.downloads, 0)) + } +} catch { + /* decorative — omit on failure */ +} -// Dependents -let dependents = 0 -try { - const result = await $fetch<{ total: number }>( - `https://registry.npmjs.org/-/v1/search?text=dependencies%3A${encodeURIComponent(props.name)}&size=0`, - ) - dependents = result.total ?? 0 -} catch { /* show nothing on failure */ } // Derived const version = computed(() => resolvedVersion.value ?? pkg.value?.['dist-tags']?.latest ?? '') @@ -128,10 +144,12 @@ const weekRange = computed(() => { return `${fmt(start)} – ${fmt(end)}` }) -// Sparkline — right panel is 400px, 36px padding each side → 328px content, 80px tall +// Sparkline — right panel is 400px, 24px padding each side → 352px content, 74px tall const sparklinePoints = computed(() => { if (sparklineValues.length < 2) return '' - const W = 328, H = 80, P = 4 + const W = 352, + H = 74, + P = 3 const max = Math.max(...sparklineValues) const min = Math.min(...sparklineValues) const range = max - min || 1 @@ -147,111 +165,196 @@ const sparklinePoints = computed(() => { // Area fill: close the polyline at the bottom corners const sparklineAreaPoints = computed(() => { if (!sparklinePoints.value) return '' - const W = 328, H = 80, P = 4 + const W = 352, + H = 74, + P = 3 return `${sparklinePoints.value} ${(W - P).toFixed(1)},${H} ${P},${H}` }) diff --git a/app/components/Package/ShareModal.vue b/app/components/Package/ShareModal.vue index fa754c047e..152834eafe 100644 --- a/app/components/Package/ShareModal.vue +++ b/app/components/Package/ShareModal.vue @@ -10,10 +10,16 @@ const { origin } = useRequestURL() const colorMode = useColorMode() const theme = computed(() => (colorMode.value === 'dark' ? 'dark' : 'light')) const { selectedAccentColor } = useAccentColor() -const colorParam = computed(() => selectedAccentColor.value ? `&color=${selectedAccentColor.value}` : '') +const colorParam = computed(() => + selectedAccentColor.value ? `&color=${selectedAccentColor.value}` : '', +) -const cardUrl = computed(() => `/api/card/${props.packageName}.png?theme=${theme.value}${colorParam.value}`) -const absoluteCardUrl = computed(() => `${origin}/api/card/${props.packageName}.png?theme=${theme.value}${colorParam.value}`) +const cardUrl = computed( + () => `/api/card/${props.packageName}.png?theme=${theme.value}${colorParam.value}`, +) +const absoluteCardUrl = computed( + () => `${origin}/api/card/${props.packageName}.png?theme=${theme.value}${colorParam.value}`, +) // Downloads for alt text const { data: downloadsData } = usePackageDownloads( @@ -67,11 +73,7 @@ async function downloadCard() {