diff --git a/.env.local.example b/.env.local.example index 0c8e741..8a4260b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -33,3 +33,9 @@ NEXT_PUBLIC_TWITTER_CARD_TYPE="summary_large_image" # Analytics (public ID only) NEXT_PUBLIC_ANALYTICS_ID="" + +# Server-side only (NOT exposed to browser) +# Used to fetch GitHub repositories for the /projects page. +# Copy these into your local `.env.local` and fill in real values. +GITHUB_USERNAME="your-github-username" +GITHUB_TOKEN="your-github-token" diff --git a/app/projects/page.tsx b/app/projects/page.tsx new file mode 100644 index 0000000..df05305 --- /dev/null +++ b/app/projects/page.tsx @@ -0,0 +1,77 @@ +import Link from 'next/link'; +import { fetchPublicRepos } from '@/lib/github'; +import { SidebarLayout } from '@/components/sidebar-layout'; + +export const revalidate = 3600; + +export const metadata = { + title: 'GitHub 專案', +}; + +export default async function ProjectsPage() { + const repos = await fetchPublicRepos(); + + return ( +
+ +
+

+ GitHub 專案 +

+

+ 從我的 GitHub 帳號自動抓取公開的程式庫與專案。 +

+
+ + {repos.length === 0 ? ( +

+ 目前沒有可顯示的 GitHub 專案,或暫時無法連線到 GitHub。 +

+ ) : ( + + )} +
+
+ ); +} + diff --git a/components/site-header.tsx b/components/site-header.tsx index fb72f59..6995adf 100644 --- a/components/site-header.tsx +++ b/components/site-header.tsx @@ -57,6 +57,7 @@ export function SiteHeader() { const navItems: NavLinkItem[] = [ { key: 'home', href: '/', label: '首頁', iconKey: 'home' }, + { key: 'projects', href: '/projects', label: '作品', iconKey: 'pen' }, { key: 'about', href: aboutChildren[0]?.href, diff --git a/lib/github.ts b/lib/github.ts new file mode 100644 index 0000000..6d09450 --- /dev/null +++ b/lib/github.ts @@ -0,0 +1,74 @@ +export type RepoSummary = { + id: number; + name: string; + fullName: string; + htmlUrl: string; + description: string | null; + language: string | null; + stargazersCount: number; + updatedAt: string; +}; + +const GITHUB_API_BASE = 'https://api.github.com'; + +function getGithubHeaders() { + const headers: Record = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'blog-nextjs-app', + }; + + const token = process.env.GITHUB_TOKEN; + if (token && token.trim() !== '') { + headers.Authorization = `Bearer ${token}`; + } + + return headers; +} + +/** + * Fetch all public repositories for the configured GitHub user. + * Returns an empty array on error instead of throwing, so the UI + * can render a graceful fallback. + */ +export async function fetchPublicRepos(usernameOverride?: string): Promise { + const username = usernameOverride || process.env.GITHUB_USERNAME; + + if (!username) { + console.error('GITHUB_USERNAME is not set; cannot fetch GitHub repositories.'); + return []; + } + + const url = `${GITHUB_API_BASE}/users/${encodeURIComponent( + username + )}/repos?type=public&sort=updated`; + + try { + const res = await fetch(url, { + headers: getGithubHeaders(), + // Use Next.js App Router caching / ISR + next: { revalidate: 3600 }, + }); + + if (!res.ok) { + console.error('Failed to fetch GitHub repositories:', res.status, res.statusText); + return []; + } + + const data = (await res.json()) as any[]; + + return data.map((repo) => ({ + id: repo.id, + name: repo.name, + fullName: repo.full_name, + htmlUrl: repo.html_url, + description: repo.description, + language: repo.language, + stargazersCount: repo.stargazers_count, + updatedAt: repo.updated_at, + })); + } catch (error) { + console.error('Error while fetching GitHub repositories:', error); + return []; + } +} +