返回 CodeWhale
page.tsx
根目录 / web / app / [locale] / feed / page.tsx
1 import Link from "next/link";
2 import { Icon, type IconName } from "@/components/icon";
3 import { PageHeader } from "@/components/page-header";
4 import { FeedCard } from "@/components/feed-card";
5 import { FeedRetry } from "@/components/feed-retry";
6 import { EmptyState, ErrorState, UnavailableState } from "@/components/surface-state";
7 import { loadFeed, type FeedLoadStatus } from "@/lib/github";
8 import { getEnv } from "@/lib/kv";
9 import { fill, getFeed, getStates, splitToken } from "@/lib/i18n/dictionaries";
10 import { buildPageMetadata } from "@/lib/page-meta";
11 import type { FeedItem } from "@/lib/types";
12
13 export const revalidate = 600;
14
15 export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
16 const { locale } = await params;
17 const t = getFeed(locale);
18 return buildPageMetadata({
19 path: "/feed",
20 locale,
21 title: t.metaTitle,
22 description: t.metaDescription,
23 });
24 }
25
26 export default async function FeedPage({ params }: { params: Promise<{ locale: string }> }) {
27 const { locale } = await params;
28 const t = getFeed(locale);
29
30 const env = await getEnv();
31 let feed: FeedItem[] = [];
32 // Four honest answers for an empty column: GitHub answered and had nothing
33 // (`ok` → empty), GitHub was not asked or refused (`skipped` / `unavailable`
34 // → not loaded, retry), or the fetch itself threw (`failed` → error, retry).
35 // Each column answers for itself: one refused endpoint must not tell the
36 // other column that its source did not answer.
37 let issuesStatus: FeedLoadStatus | "failed" = "ok";
38 let pullsStatus: FeedLoadStatus | "failed" = "ok";
39 try {
40 const load = await loadFeed(env.GITHUB_TOKEN, 50);
41 feed = load.items;
42 issuesStatus = load.issuesStatus;
43 pullsStatus = load.pullsStatus;
44 } catch (e) {
45 issuesStatus = "failed";
46 pullsStatus = "failed";
47 console.error("feed fetch failed", e);
48 }
49
50 const issues = feed.filter((f) => f.kind === "issue");
51 const pulls = feed.filter((f) => f.kind === "pull");
52 const states = getStates(locale);
53 // FeedRetry first busts this route's ISR entry, because a bare server
54 // re-render would serve the same cached `skipped`/`unavailable` record for
55 // up to ten minutes.
56 const retry = <FeedRetry label={states.retry} />;
57 const columnState = (status: FeedLoadStatus | "failed") =>
58 status === "failed" ? (
59 <ErrorState locale={locale} compact action={retry} />
60 ) : status === "ok" ? (
61 <EmptyState locale={locale} compact />
62 ) : (
63 <UnavailableState locale={locale} compact action={retry} />
64 );
65
66 // The repository link is typeset between the lede's halves, so a locale
67 // may place the {repo} token anywhere.
68 const ledeParts = splitToken(t.lede, "repo");
69 const lede = (
70 <>
71 {ledeParts[0]}
72 <Link href="https://github.com/codewhale-hq/CodeWhale" className="link">codewhale-hq/CodeWhale</Link>
73 {ledeParts[1]}
74 </>
75 );
76 const actionLinks: { href: string; icon: IconName; label: string }[] = [
77 { href: "https://github.com/codewhale-hq/CodeWhale/issues/new/choose", icon: "alert", label: t.openIssue },
78 { href: "https://github.com/codewhale-hq/CodeWhale/compare", icon: "git-pull-request", label: t.openPull },
79 { href: "https://github.com/codewhale-hq/CodeWhale/discussions/new", icon: "message", label: t.startDiscussion },
80 ];
81 const columns = [
82 { id: "feed-pulls", title: t.pulls, items: pulls, status: pullsStatus },
83 { id: "feed-issues", title: t.issues, items: issues, status: issuesStatus },
84 ];
85
86 return (
87 <>
88 <PageHeader
89 seal="动"
90 title={t.title}
91 titleAside={t.titleAside}
92 titleAsideLang={t.titleAsideLang}
93 lede={lede}
94 pose="browse"
95 />
96 <div className="page-body">
97 <div className="grid-2 feed-columns">
98 {columns.map((column) => (
99 <section key={column.id} className="feed-column" aria-labelledby={column.id}>
100 <div className="feed-column-head">
101 <h2 id={column.id}>{column.title}</h2>
102 <span className="page-meta tabular">{fill(t.shownCount, { count: column.items.length })}</span>
103 </div>
104 {column.items.length > 0 ? (
105 <ul className="feed-items" role="list">
106 {column.items.map((item) => (
107 <li key={item.url}><FeedCard item={item} /></li>
108 ))}
109 </ul>
110 ) : (
111 <div className="feed-empty">{columnState(column.status)}</div>
112 )}
113 </section>
114 ))}
115 </div>
116
117 <section className="page-section">
118 <ul className="grid-3" role="list">
119 {actionLinks.map((link) => (
120 <li key={link.href}>
121 <Link href={link.href} className="dir-row feed-action">
122 <span className="dir-mark" aria-hidden="true"><Icon name={link.icon} /></span>
123 <span className="dir-text"><span className="dir-title">{link.label}</span></span>
124 <span className="dir-action" aria-hidden="true"><Icon name="external" /></span>
125 </Link>
126 </li>
127 ))}
128 </ul>
129 </section>
130 </div>
131 </>
132 );
133 }
134
134 lines Plain Text