mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
wip(web): live GitHub fallback in the browser and credited-contributor faces (CW-7933)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e527942064
commit
e4208b5486
3 files changed
+188
-24
No files matched your search
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const API = "https://api.github.com";
|
||||
const REPO = "codewhale-hq/CodeWhale";
|
||||
const CACHE_KEY = "codewhale:github-live:v1";
|
||||
const CACHE_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface LiveMerge {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
author: string;
|
||||
avatar: string;
|
||||
mergedAt: string;
|
||||
firstTime: boolean;
|
||||
}
|
||||
|
||||
export interface LiveRepo {
|
||||
stars: number;
|
||||
openPulls: number;
|
||||
openIssues: number;
|
||||
merged: LiveMerge[];
|
||||
}
|
||||
|
||||
let pending: Promise<LiveRepo | null> | null = null;
|
||||
|
||||
async function json(url: string): Promise<unknown> {
|
||||
const res = await fetch(url, { headers: { Accept: "application/vnd.github+json" } });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchLive(): Promise<LiveRepo | null> {
|
||||
try {
|
||||
const cached = JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "null") as { at: number; data: LiveRepo } | null;
|
||||
if (cached && Date.now() - cached.at < CACHE_MS) return cached.data;
|
||||
} catch {}
|
||||
try {
|
||||
const [repo, prs, pulls] = await Promise.all([
|
||||
json(`${API}/repos/${REPO}`) as Promise<{ stargazers_count?: number; open_issues_count?: number }>,
|
||||
json(`${API}/search/issues?q=${encodeURIComponent(`repo:${REPO} is:pr is:open`)}&per_page=1`) as Promise<{ total_count?: number }>,
|
||||
json(`${API}/repos/${REPO}/pulls?state=closed&sort=updated&direction=desc&per_page=40`) as Promise<Array<{
|
||||
number: number; title: string; html_url: string; merged_at: string | null; author_association?: string;
|
||||
user: { login: string; avatar_url: string; type?: string };
|
||||
}>>,
|
||||
]);
|
||||
const stars = repo.stargazers_count ?? 0;
|
||||
if (stars <= 0) return null;
|
||||
const openPulls = prs.total_count ?? 0;
|
||||
const merged = pulls
|
||||
.filter((pr) => pr.merged_at && pr.user.type !== "Bot" && !pr.user.login.endsWith("[bot]"))
|
||||
.sort((a, b) => +new Date(b.merged_at!) - +new Date(a.merged_at!))
|
||||
.map((pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: pr.html_url,
|
||||
author: pr.user.login,
|
||||
avatar: pr.user.avatar_url,
|
||||
mergedAt: pr.merged_at!,
|
||||
firstTime: pr.author_association === "FIRST_TIME_CONTRIBUTOR",
|
||||
}));
|
||||
const data = { stars, openPulls, openIssues: Math.max(0, (repo.open_issues_count ?? 0) - openPulls), merged };
|
||||
try { sessionStorage.setItem(CACHE_KEY, JSON.stringify({ at: Date.now(), data })); } catch {}
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useLiveRepo(enabled: boolean): LiveRepo | null {
|
||||
const [data, setData] = useState<LiveRepo | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let alive = true;
|
||||
pending ??= fetchLive();
|
||||
pending.then((value) => { if (alive) setData(value); });
|
||||
return () => { alive = false; };
|
||||
}, [enabled]);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function LiveStarCount({ initial, numberLocale }: { initial?: number; numberLocale: string }) {
|
||||
const live = useLiveRepo(initial === undefined);
|
||||
const stars = initial ?? live?.stars;
|
||||
if (stars === undefined) return null;
|
||||
return (
|
||||
<span className="github-star-count tabular" data-live={initial === undefined ? "client" : "server"}>
|
||||
{new Intl.NumberFormat(numberLocale).format(stars)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PulseRow { key: "stars" | "contributors" | "pulls" | "issues"; label: string; href: string; value?: string }
|
||||
|
||||
export function LivePulseStats({ rows, numberLocale }: { rows: PulseRow[]; numberLocale: string }) {
|
||||
const missing = rows.some((row) => row.value === undefined);
|
||||
const live = useLiveRepo(missing);
|
||||
const fmt = new Intl.NumberFormat(numberLocale);
|
||||
const fill = (row: PulseRow) => {
|
||||
if (row.value !== undefined || !live) return row.value;
|
||||
if (row.key === "stars") return fmt.format(live.stars);
|
||||
if (row.key === "pulls") return fmt.format(live.openPulls);
|
||||
if (row.key === "issues") return fmt.format(live.openIssues);
|
||||
return undefined;
|
||||
};
|
||||
return (
|
||||
<dl className="pulse-stats">
|
||||
{rows.map((row) => {
|
||||
const value = fill(row);
|
||||
return (
|
||||
<div key={row.key} data-pending={value === undefined ? "" : undefined}>
|
||||
<dt>{row.label}</dt>
|
||||
<dd><a href={row.href} className="tabular">{value ?? "—"}</a></dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MergedCopy { heading: string; mergedAge: string; firstContribution: string; dateLocale: string }
|
||||
|
||||
function ago(iso: string, dateLocale: string): string {
|
||||
const mins = Math.round((Date.now() - +new Date(iso)) / 60000);
|
||||
const fmt = new Intl.RelativeTimeFormat(dateLocale, { numeric: "auto" });
|
||||
if (mins < 60) return fmt.format(-Math.max(mins, 0), "minute");
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return fmt.format(-hrs, "hour");
|
||||
const days = Math.round(hrs / 24);
|
||||
if (days < 30) return fmt.format(-days, "day");
|
||||
return fmt.format(-Math.round(days / 30), "month");
|
||||
}
|
||||
|
||||
export function LiveMergedList({ limit, copy }: { limit: number; copy: MergedCopy }) {
|
||||
const live = useLiveRepo(true);
|
||||
const items = live?.merged.slice(0, limit) ?? [];
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="pulse-main">
|
||||
<h3 className="pulse-heading"><span className="status status-live"><span className="status-dot" aria-hidden="true" />{copy.heading}</span></h3>
|
||||
<ol className="merged-list" role="list">
|
||||
{items.map((item) => (
|
||||
<li key={item.number}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={`${item.avatar}${item.avatar.includes("?") ? "&" : "?"}s=80`} alt="" width={40} height={40} loading="lazy" decoding="async" className="face" />
|
||||
<div className="merged-text">
|
||||
<a href={item.url} className="merged-title">{item.title}</a>
|
||||
<p className="merged-meta">
|
||||
<a href={`https://github.com/${item.author}`}>@{item.author}</a>
|
||||
<span>{copy.mergedAge.replace("{age}", ago(item.mergedAt, copy.dateLocale))}</span>
|
||||
{item.firstTime ? <span className="merged-first">{copy.firstContribution}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Icon } from "./icon";
|
||||
import { LiveMergedList, LivePulseStats, LiveStarCount, type PulseRow } from "./github-live";
|
||||
import { GITHUB_STAR_COPY } from "@/lib/content/github-star";
|
||||
import { PROJECT_COPY } from "@/lib/content/project";
|
||||
import { formatStars, relativeAge, type Face } from "@/lib/github";
|
||||
@@ -14,30 +15,29 @@ function age(iso: string, locale: string): string {
|
||||
}
|
||||
|
||||
export function PulseStats({ stats, contributors, locale }: { stats: RepoStats | null; contributors: number; locale: string }) {
|
||||
const rows: { value: string; label: string; href: string }[] = [
|
||||
{ value: String(contributors), label: pickText(PROJECT_COPY.statContributors, locale), href: `${REPO_URL}/blob/main/docs/CONTRIBUTORS.md` },
|
||||
const rows: PulseRow[] = [
|
||||
{ key: "stars", label: pickText(PROJECT_COPY.statStars, locale), href: REPO_URL, value: stats ? formatStars(stats.stars) : undefined },
|
||||
{ key: "contributors", label: pickText(PROJECT_COPY.statContributors, locale), href: `${REPO_URL}/blob/main/docs/CONTRIBUTORS.md`, value: String(contributors) },
|
||||
{ key: "pulls", label: pickText(PROJECT_COPY.statPulls, locale), href: `${REPO_URL}/pulls`, value: stats ? String(stats.openPulls) : undefined },
|
||||
{ key: "issues", label: pickText(PROJECT_COPY.statIssues, locale), href: `${REPO_URL}/issues`, value: stats ? String(stats.openIssues) : undefined },
|
||||
];
|
||||
if (stats) {
|
||||
rows.unshift({ value: formatStars(stats.stars), label: pickText(PROJECT_COPY.statStars, locale), href: REPO_URL });
|
||||
rows.push(
|
||||
{ value: String(stats.openPulls), label: pickText(PROJECT_COPY.statPulls, locale), href: `${REPO_URL}/pulls` },
|
||||
{ value: String(stats.openIssues), label: pickText(PROJECT_COPY.statIssues, locale), href: `${REPO_URL}/issues` },
|
||||
);
|
||||
}
|
||||
return (
|
||||
<dl className="pulse-stats">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label}>
|
||||
<dt>{row.label}</dt>
|
||||
<dd><a href={row.href} className="tabular">{row.value}</a></dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
return <LivePulseStats rows={rows} numberLocale={getChrome(locale).dateLocale} />;
|
||||
}
|
||||
|
||||
export function MergedList({ items, locale }: { items: FeedItem[]; locale: string }) {
|
||||
if (items.length === 0) return null;
|
||||
export function MergedList({ items, locale, limit = 5 }: { items: FeedItem[]; locale: string; limit?: number }) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<LiveMergedList
|
||||
limit={limit}
|
||||
copy={{
|
||||
heading: pickText(PROJECT_COPY.mergedHeading, locale),
|
||||
mergedAge: pickText(PROJECT_COPY.mergedAge, locale),
|
||||
firstContribution: pickText(PROJECT_COPY.firstContribution, locale),
|
||||
dateLocale: getChrome(locale).dateLocale,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ol className="merged-list" role="list">
|
||||
{items.map((item) => (
|
||||
@@ -82,12 +82,11 @@ export function FaceWall({ faces, total, locale }: { faces: Face[]; total: numbe
|
||||
|
||||
export function GitHubStar({ stars, locale }: { stars?: number; locale: string }) {
|
||||
const label = pickText(GITHUB_STAR_COPY, locale);
|
||||
const count = stars === undefined ? null : new Intl.NumberFormat(getChrome(locale).dateLocale).format(stars);
|
||||
return (
|
||||
<a href={REPO_URL} className="btn btn-secondary github-star" data-funnel="star_exit">
|
||||
<span aria-hidden="true">☆</span>
|
||||
<span>{label}</span>
|
||||
{count !== null && <span className="github-star-count tabular">{count}</span>}
|
||||
<LiveStarCount initial={stars} numberLocale={getChrome(locale).dateLocale} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetchFaces, fetchRepoStats, loadFeed, type Face } from "./github";
|
||||
import { getEnv } from "./kv";
|
||||
import { RELEASE_CONTRIBUTORS, RELEASE_HELPERS, UNRELEASED_CONTRIBUTORS } from "./release-credits";
|
||||
import type { FeedItem, RepoStats } from "./types";
|
||||
|
||||
export interface ProjectPulse {
|
||||
@@ -26,7 +27,10 @@ export async function loadProjectPulse(mergedLimit = 6, faceLimit = 60): Promise
|
||||
const seen = new Set<string>();
|
||||
const faces: Face[] = [];
|
||||
const recent = merged.map((item) => ({ login: item.author, avatar: item.authorAvatar }));
|
||||
for (const face of [...recent, ...(people.status === "fulfilled" ? people.value : [])]) {
|
||||
const credited = [...UNRELEASED_CONTRIBUTORS, ...RELEASE_CONTRIBUTORS, ...RELEASE_HELPERS]
|
||||
.map((handle) => handle.replace(/^@/, ""))
|
||||
.map((login) => ({ login, avatar: `https://avatars.githubusercontent.com/${login}` }));
|
||||
for (const face of [...recent, ...(people.status === "fulfilled" ? people.value : []), ...credited]) {
|
||||
if (!face.login || !face.avatar || seen.has(face.login)) continue;
|
||||
seen.add(face.login);
|
||||
faces.push(face);
|
||||
|
||||
Reference in new issue
Block a user