9e45780ba0
First commit of the Next.js community site that powers deepseek-tui.com, deployed via Cloudflare Workers / OpenNext. This commit lands the scaffold and applies the visual + correctness pass requested by community feedback: - Palette: drop the cream/Anthropic-feel paper (#F4F1E8) for a DeepSeek-aligned cool white + soft gray (#FFFFFF / #F4F6FB), with indigo accents kept. Soften default hairlines so a pure-white background reads clean instead of harsh. - Mobile: add a hamburger menu (mobile-menu.tsx) so phones can reach Install / Docs / Activity / Roadmap / Contribute — previously the link list was hidden on phones with no replacement. Tighter hero, flexible button row, viewport-safe code blocks, columnar grids collapse cleanly under 768px, and the printed-almanac center rule is desktop-only now (it sliced through narrow viewports). - "How it works" diagram: replace the hand-rolled ASCII art (which misaligned under CJK monospace because Han characters take 2 columns vs Latin's 1, per dhh's note in WeChat) with a real mermaid diagram rendered client-side via dynamic import. Uses the mermaid.live standard syntax 庄表伟 recommended. - Issue #1104: the docs listed a `deepseek-cn` provider that the v0.8.16 binary doesn't accept (`ProviderArg` in crates/cli only has 9 variants; the 10th lives only in the legacy tui/config.rs). derive-facts.mjs now omits `deepseek-cn` until that variant is wired through the shared ProviderKind, and the install page's China-network recipe uses `base_url` / `DEEPSEEK_BASE_URL` (which actually works on v0.8.16) instead of the unsupported provider. Auto-deploys via .github/workflows/deploy-web.yml on push to main. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
import type { FeedItem, RepoStats } from "./types";
|
|
|
|
const REPO = process.env.GITHUB_REPO ?? "Hmbown/deepseek-tui";
|
|
const GH = "https://api.github.com";
|
|
|
|
function headers(token?: string): HeadersInit {
|
|
const h: Record<string, string> = {
|
|
Accept: "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"User-Agent": "deepseek-tui-web",
|
|
};
|
|
if (token) h.Authorization = `Bearer ${token}`;
|
|
return h;
|
|
}
|
|
|
|
export async function fetchRepoStats(token?: string): Promise<RepoStats> {
|
|
const [repoRes, contribRes, releaseRes] = await Promise.all([
|
|
fetch(`${GH}/repos/${REPO}`, { headers: headers(token), next: { revalidate: 1800 } }),
|
|
fetch(`${GH}/repos/${REPO}/contributors?per_page=1&anon=true`, {
|
|
headers: headers(token),
|
|
next: { revalidate: 3600 },
|
|
}),
|
|
fetch(`${GH}/repos/${REPO}/releases/latest`, { headers: headers(token), next: { revalidate: 3600 } }),
|
|
]);
|
|
|
|
const repo = (await repoRes.json()) as {
|
|
stargazers_count: number;
|
|
forks_count: number;
|
|
open_issues_count: number;
|
|
};
|
|
|
|
// Contributor count from Link header (anon=true). Fallback to 1.
|
|
let contributors = 1;
|
|
const link = contribRes.headers.get("link");
|
|
if (link) {
|
|
const m = link.match(/&page=(\d+)>; rel="last"/);
|
|
if (m) contributors = parseInt(m[1], 10);
|
|
}
|
|
|
|
// Open PRs: cheapest path is the search API.
|
|
const prRes = await fetch(
|
|
`${GH}/search/issues?q=${encodeURIComponent(`repo:${REPO} is:pr is:open`)}&per_page=1`,
|
|
{ headers: headers(token), next: { revalidate: 1800 } }
|
|
);
|
|
const prJson = (await prRes.json()) as { total_count?: number };
|
|
const openPulls = prJson.total_count ?? 0;
|
|
const openIssues = Math.max(0, repo.open_issues_count - openPulls);
|
|
|
|
let latestRelease: RepoStats["latestRelease"];
|
|
if (releaseRes.ok) {
|
|
const r = (await releaseRes.json()) as { tag_name: string; published_at: string; html_url: string };
|
|
latestRelease = { tag: r.tag_name, publishedAt: r.published_at, url: r.html_url };
|
|
}
|
|
|
|
return {
|
|
stars: repo.stargazers_count,
|
|
forks: repo.forks_count,
|
|
openIssues,
|
|
openPulls,
|
|
contributors,
|
|
latestRelease,
|
|
fetchedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
interface RawIssue {
|
|
number: number;
|
|
title: string;
|
|
html_url: string;
|
|
state: "open" | "closed";
|
|
user: { login: string; avatar_url: string };
|
|
created_at: string;
|
|
updated_at: string;
|
|
comments: number;
|
|
labels: { name: string; color: string }[];
|
|
pull_request?: unknown;
|
|
draft?: boolean;
|
|
body?: string | null;
|
|
}
|
|
|
|
export async function fetchFeed(token?: string, limit = 30): Promise<FeedItem[]> {
|
|
const [issuesRes, pullsRes] = await Promise.all([
|
|
fetch(
|
|
`${GH}/repos/${REPO}/issues?state=all&per_page=${limit}&sort=updated&direction=desc`,
|
|
{ headers: headers(token), next: { revalidate: 600 } }
|
|
),
|
|
fetch(
|
|
`${GH}/repos/${REPO}/pulls?state=all&per_page=${limit}&sort=updated&direction=desc`,
|
|
{ headers: headers(token), next: { revalidate: 600 } }
|
|
),
|
|
]);
|
|
|
|
const issues = (await issuesRes.json()) as RawIssue[];
|
|
const pulls = (await pullsRes.json()) as (RawIssue & { merged_at?: string | null })[];
|
|
|
|
const items: FeedItem[] = [];
|
|
|
|
for (const it of issues) {
|
|
if (it.pull_request) continue; // GH issues endpoint returns PRs too
|
|
items.push({
|
|
kind: "issue",
|
|
number: it.number,
|
|
title: it.title,
|
|
url: it.html_url,
|
|
state: it.state,
|
|
author: it.user.login,
|
|
authorAvatar: it.user.avatar_url,
|
|
createdAt: it.created_at,
|
|
updatedAt: it.updated_at,
|
|
comments: it.comments,
|
|
labels: it.labels?.map((l) => ({ name: l.name, color: l.color })) ?? [],
|
|
body: it.body ?? undefined,
|
|
});
|
|
}
|
|
|
|
for (const pr of pulls) {
|
|
let state: FeedItem["state"] = pr.state;
|
|
if (pr.merged_at) state = "merged";
|
|
else if (pr.draft) state = "draft";
|
|
items.push({
|
|
kind: "pull",
|
|
number: pr.number,
|
|
title: pr.title,
|
|
url: pr.html_url,
|
|
state,
|
|
author: pr.user.login,
|
|
authorAvatar: pr.user.avatar_url,
|
|
createdAt: pr.created_at,
|
|
updatedAt: pr.updated_at,
|
|
comments: pr.comments,
|
|
labels: pr.labels?.map((l) => ({ name: l.name, color: l.color })) ?? [],
|
|
body: pr.body ?? undefined,
|
|
});
|
|
}
|
|
|
|
return items
|
|
.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt))
|
|
.slice(0, limit);
|
|
}
|
|
|
|
export function relativeTime(iso: string): string {
|
|
const diff = Date.now() - +new Date(iso);
|
|
const mins = Math.round(diff / 60000);
|
|
if (mins < 1) return "just now";
|
|
if (mins < 60) return `${mins}m`;
|
|
const hrs = Math.round(mins / 60);
|
|
if (hrs < 24) return `${hrs}h`;
|
|
const days = Math.round(hrs / 24);
|
|
if (days < 30) return `${days}d`;
|
|
const months = Math.round(days / 30);
|
|
if (months < 12) return `${months}mo`;
|
|
return `${Math.round(months / 12)}y`;
|
|
}
|