import type { ReactNode } from "react"; import type { AccountCardData, RepoCardData } from "~/lib/og"; import { OG_HEIGHT, OG_WIDTH } from "~/lib/og"; import { LOGO_SVG } from "./logo"; import { languageColor } from "~/lib/languages"; /** Mirrors src/styles/app.css: gray-950 page, gray-800 rules, the orange brand accent. */ const COLORS = { page: "#030712", rule: "#1f2937", text: "#f9fafb", body: "#d1d5db", muted: "#9ca3af", accent: "#f6821f", accentDeep: "#2a1404", accentSoft: "#ffb27a", } as const; const LOGO_URI = `data:image/svg+xml;base64,${btoa(LOGO_SVG)}`; const DESCRIPTION_CHARS = 170; /** Topics take a row, so the description gives up about one line. */ const DESCRIPTION_WITH_TOPICS_CHARS = 90; export function truncate(text: string, max: number): string { const clean = text.replace(/\s+/g, " ").trim(); if (clean.length <= max) return clean; const cut = clean.slice(0, max - 1); const space = cut.lastIndexOf(" "); return `${(space > max * 0.6 ? cut.slice(0, space) : cut).replace(/[\s.,;:]+$/, "")}…`; } const THOUSAND = 1000; const TEN_THOUSAND = 10_000; const MILLION = 1_000_000; export function compactCount(value: number): string { if (value < THOUSAND) return String(value); if (value < MILLION) return `${(value / THOUSAND).toFixed(value < TEN_THOUSAND ? 1 : 0).replace(/\.0$/, "")}k`; return `${(value / MILLION).toFixed(1).replace(/\.0$/, "")}m`; } function plural(value: number, one: string, many: string): string { return `${compactCount(value)} ${value === 1 ? one : many}`; } type Strip = ReadonlyArray<{ name: string; percent: number }>; /** The bottom strip: the brand accent, or the repository's languages in proportion (as GitHub's cards do). */ function BottomStrip({ languages }: { languages?: Strip }) { if (!languages?.length) { return
; } return (
{languages.map((language) => (
))}
); } function Frame({ children, footer, languages }: { children: ReactNode; footer: string; languages?: Strip }) { return (
{children}
gitfor
{footer}
); } function Avatar({ src, name, size }: { src: string | null; name: string; size: number }) { const radius = Math.round(size / 6); if (src) return ; return (
{name.slice(0, 1).toUpperCase()}
); } function Stats({ items, language }: { items: ReadonlyArray; language?: string }) { return (
{language ? (
{truncate(language, 20)}
) : null} {items.map((item, index) => (
{item}
))}
); } function Topics({ topics }: { topics: readonly string[] }) { if (topics.length === 0) return null; return (
{topics.map((topic) => (
{truncate(topic, 24)}
))}
); } export function RepoCard({ repo, avatar, host }: { repo: RepoCardData; avatar: string | null; host: string }) { const stats = [ plural(repo.stars, "star", "stars"), plural(repo.forks, "fork", "forks"), plural(repo.openIssues, "issue", "issues"), plural(repo.openPulls, "pull request", "pull requests"), ]; return (
{truncate(repo.owner, 38)}/
{truncate(repo.name, 26)}
{repo.description ? (
{truncate(repo.description, repo.topics.length ? DESCRIPTION_WITH_TOPICS_CHARS : DESCRIPTION_CHARS)}
) : null}
language.name !== "Other")?.name} />
); } export function AccountCard({ account, avatar, host, }: { account: AccountCardData; avatar: string | null; host: string; }) { const title = account.displayName?.trim() || account.name; const kind = account.kind === "org" ? "Organization" : "User"; return (
{truncate(title, 24)}
{title === account.name ? kind : `${truncate(account.name, 30)} · ${kind}`}
{account.bio ? (
{truncate(account.bio, 150)}
) : null}
); } function TextCard({ title, body, footer }: { title: string; body: string; footer: string }) { return (
{truncate(title, 60)}
{truncate(body, 190)}
); } export function SiteCard({ host }: { host: string }) { return ( ); } export function FeatureCard({ title, lede, slug, host }: { title: string; lede: string; slug: string; host: string }) { return ; }