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 ? (
) : 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 ;
}