| 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 |