diff --git a/app/api/badge/[login]/route.js b/app/api/badge/[login]/route.js new file mode 100644 index 0000000..7a38200 --- /dev/null +++ b/app/api/badge/[login]/route.js @@ -0,0 +1,45 @@ +import { BADGE_STATS, DEFAULT_BADGE_STAT, renderBadgeSvg, resolveBadgeStat } from '../../../../lib/badge.js'; +import { getBadgeDeveloper } from '../../../../lib/badge-lookup.js'; + +export const runtime = 'nodejs'; + +const LOGIN_PATTERN = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i; + +function svgResponse(svg, { cache = true } = {}) { + return new Response(svg, { + status: 200, + headers: { + 'Content-Type': 'image/svg+xml; charset=utf-8', + // Badges are meant to be embedded (README, personal sites) and read on + // every page view, so cache briefly at the edge instead of per-request. + 'Cache-Control': cache + ? 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400' + : 'no-store', + }, + }); +} + +export async function GET(request, { params }) { + const { login: rawLogin } = await params; + const login = rawLogin.replace(/\.svg$/i, ''); + + if (!LOGIN_PATTERN.test(login)) { + return svgResponse(renderBadgeSvg({ value: 'invalid login', unranked: true }), { cache: false }); + } + + const { searchParams } = new URL(request.url); + const statParam = searchParams.get('stat') || DEFAULT_BADGE_STAT; + if (!BADGE_STATS.includes(statParam)) { + return svgResponse(renderBadgeSvg({ value: 'invalid stat', unranked: true }), { cache: false }); + } + + try { + const developer = await getBadgeDeveloper(login); + const { value, unranked } = resolveBadgeStat(developer, statParam); + return svgResponse(renderBadgeSvg({ value, unranked })); + } catch (error) { + console.error('Badge render failed:', error.message); + // Degrade to an "unranked" badge rather than a broken image in READMEs. + return svgResponse(renderBadgeSvg({ value: 'unavailable', unranked: true }), { cache: false }); + } +} diff --git a/components/BadgeSnippet.jsx b/components/BadgeSnippet.jsx new file mode 100644 index 0000000..21c60c4 --- /dev/null +++ b/components/BadgeSnippet.jsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; + +const STAT_OPTIONS = [ + { value: 'globalRank', label: 'Global Rank' }, + { value: 'countryRank', label: 'Country Rank' }, + { value: 'cityRank', label: 'City Rank' }, + { value: 'score', label: 'Score' }, + { value: 'stars', label: 'Stars' }, +]; + +export default function BadgeSnippet({ login, siteUrl }) { + const [stat, setStat] = useState('globalRank'); + const [format, setFormat] = useState('markdown'); + const [copied, setCopied] = useState(false); + + const statQuery = stat === 'globalRank' ? '' : `?stat=${stat}`; + const badgeUrl = `${siteUrl}/api/badge/${encodeURIComponent(login)}.svg${statQuery}`; + const profileUrl = `${siteUrl}/share/${encodeURIComponent(login)}`; + + const snippets = { + markdown: `[![devglobe](${badgeUrl})](${profileUrl})`, + html: `devglobe badge`, + }; + + async function handleCopy() { + try { + await navigator.clipboard.writeText(snippets[format]); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard API can fail silently (permissions, insecure context); + // the snippet is still selectable text, so no further action needed. + } + } + + return ( +
+ Embeddable badge +

Get your badge

+

+ Embed a live-updating rank badge in your GitHub README or personal site. It refreshes automatically as your DevGlobe stats update. +

+ +
+ {STAT_OPTIONS.map(option => ( + + ))} +
+ +
+ {['markdown', 'html'].map(option => ( + + ))} +
+ +
+ {snippets[format]} + +
+
+ ); +} diff --git a/components/DetailPanel.jsx b/components/DetailPanel.jsx index ee698b5..3f5951d 100644 --- a/components/DetailPanel.jsx +++ b/components/DetailPanel.jsx @@ -133,7 +133,8 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi {merged.soUserId && ( StackOverflow ↗ )} -