diff --git a/app/error.tsx b/app/error.tsx index cf9f1db..28331d0 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -1,8 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; +import { FiAlertTriangle } from 'react-icons/fi'; export default function Error({ error, @@ -20,10 +19,7 @@ export default function Error({
- +

diff --git a/app/tags/[tag]/page.tsx b/app/tags/[tag]/page.tsx index 1b7c4e5..9ea6ca3 100644 --- a/app/tags/[tag]/page.tsx +++ b/app/tags/[tag]/page.tsx @@ -5,8 +5,7 @@ import { getTagSlug } from '@/lib/posts'; import { SidebarLayout } from '@/components/sidebar-layout'; import { SectionDivider } from '@/components/section-divider'; import { ScrollReveal } from '@/components/scroll-reveal'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faTag } from '@fortawesome/free-solid-svg-icons'; +import { FiTag } from 'react-icons/fi'; export function generateStaticParams() { const slugs = new Set(); @@ -57,7 +56,7 @@ export default async function TagPage({ params }: Props) {
- + TAG ARCHIVE diff --git a/app/tags/page.tsx b/app/tags/page.tsx index 1d30f1b..92600ef 100644 --- a/app/tags/page.tsx +++ b/app/tags/page.tsx @@ -1,7 +1,6 @@ import Link from 'next/link'; import type { Metadata } from 'next'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faTags, faFire } from '@fortawesome/free-solid-svg-icons'; +import { FiTag, FiTrendingUp } from 'react-icons/fi'; import { getAllTagsWithCount } from '@/lib/posts'; import { SectionDivider } from '@/components/section-divider'; import { ScrollReveal } from '@/components/scroll-reveal'; @@ -30,7 +29,7 @@ export default function TagIndexPage() {
- + 標籤索引 @@ -65,7 +64,7 @@ export default function TagIndexPage() {
- + 熱度 #{index + 1} diff --git a/components/hero.tsx b/components/hero.tsx index 6421223..3270dac 100644 --- a/components/hero.tsx +++ b/components/hero.tsx @@ -1,13 +1,6 @@ import { siteConfig } from '@/lib/config'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - faGithub, - faTwitter, - faMastodon, - faGitAlt, - faLinkedin -} from '@fortawesome/free-brands-svg-icons'; -import { faEnvelope, faPenNib } from '@fortawesome/free-solid-svg-icons'; +import { FaGithub, FaTwitter, FaMastodon, FaGit, FaLinkedin } from 'react-icons/fa'; +import { FiMail, FiFeather } from 'react-icons/fi'; import { MetaItem } from './meta-item'; export function Hero() { @@ -19,37 +12,37 @@ export function Hero() { key: 'github', href: social.github, label: 'GitHub', - icon: faGithub + icon: FaGithub }, social.twitter && { key: 'twitter', href: `https://twitter.com/${social.twitter.replace('@', '')}`, label: 'Twitter', - icon: faTwitter + icon: FaTwitter }, social.mastodon && { key: 'mastodon', href: social.mastodon, label: 'Mastodon', - icon: faMastodon + icon: FaMastodon }, social.gitea && { key: 'gitea', href: social.gitea, label: 'Gitea', - icon: faGitAlt + icon: FaGit }, social.linkedin && { key: 'linkedin', href: social.linkedin, label: 'LinkedIn', - icon: faLinkedin + icon: FaLinkedin }, social.email && { key: 'email', href: `mailto:${social.email}`, label: 'Email', - icon: faEnvelope + icon: FiMail } ].filter(Boolean) as { key: string; @@ -73,7 +66,7 @@ export function Hero() { {name}

- + {tagline}
@@ -87,7 +80,7 @@ export function Hero() { rel="noopener noreferrer" className="motion-link flex items-center gap-2 rounded-full bg-white/80 px-3 py-1 text-slate-600 shadow-sm ring-1 ring-slate-200 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent hover:shadow-md dark:bg-slate-900/80 dark:text-slate-200 dark:ring-slate-700" > - + {item.label} ))} diff --git a/components/mastodon-feed.tsx b/components/mastodon-feed.tsx index 2f5ecf4..13642f4 100644 --- a/components/mastodon-feed.tsx +++ b/components/mastodon-feed.tsx @@ -1,74 +1,100 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faMastodon } from '@fortawesome/free-brands-svg-icons'; -import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; +import { FaMastodon } from 'react-icons/fa'; +import { FiArrowRight } from 'react-icons/fi'; import { siteConfig } from '@/lib/config'; import { parseMastodonUrl, stripHtml, truncateText, formatRelativeTime, - fetchAccountId, - fetchStatuses, type MastodonStatus } from '@/lib/mastodon'; -export function MastodonFeed() { - const [statuses, setStatuses] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - - useEffect(() => { - const loadStatuses = async () => { - const mastodonUrl = siteConfig.social.mastodon; - - if (!mastodonUrl) { - setLoading(false); - return; +/** + * Fetch user's Mastodon account ID from username with ISR + */ +async function fetchAccountId(instance: string, username: string): Promise { + try { + const response = await fetch( + `https://${instance}/api/v1/accounts/lookup?acct=${username}`, + { + next: { revalidate: 1800 } // Revalidate every 30 minutes } + ); - try { - // Parse the Mastodon URL - const parsed = parseMastodonUrl(mastodonUrl); - if (!parsed) { - setError(true); - setLoading(false); - return; - } + if (!response.ok) return null; - const { instance, username } = parsed; + const account = await response.json(); + return account.id; + } catch (error) { + console.error('Error fetching Mastodon account:', error); + return null; + } +} - // Fetch account ID - const accountId = await fetchAccountId(instance, username); - if (!accountId) { - setError(true); - setLoading(false); - return; - } - - // Fetch statuses (5 posts, exclude replies, include boosts) - const fetchedStatuses = await fetchStatuses(instance, accountId, 5); - setStatuses(fetchedStatuses); - } catch (err) { - console.error('Error loading Mastodon feed:', err); - setError(true); - } finally { - setLoading(false); +/** + * Fetch user's recent statuses from Mastodon with ISR + */ +async function fetchStatuses( + instance: string, + accountId: string, + limit: number = 5 +): Promise { + try { + const response = await fetch( + `https://${instance}/api/v1/accounts/${accountId}/statuses?limit=${limit}&exclude_replies=true`, + { + next: { revalidate: 1800 } // Revalidate every 30 minutes } - }; + ); - loadStatuses(); - }, []); + if (!response.ok) return []; + + const statuses = await response.json(); + return statuses; + } catch (error) { + console.error('Error fetching Mastodon statuses:', error); + return []; + } +} + +/** + * Server Component for Mastodon feed with ISR + */ +export async function MastodonFeed() { + const mastodonUrl = siteConfig.social.mastodon; // Don't render if no Mastodon URL is configured - if (!siteConfig.social.mastodon) { + if (!mastodonUrl) { return null; } - // Don't render if there's an error (fail silently) - if (error) { + let statuses: MastodonStatus[] = []; + + try { + // Parse the Mastodon URL + const parsed = parseMastodonUrl(mastodonUrl); + if (!parsed) { + return null; + } + + const { instance, username } = parsed; + + // Fetch account ID + const accountId = await fetchAccountId(instance, username); + if (!accountId) { + return null; + } + + // Fetch statuses (5 posts, exclude replies, include boosts) + statuses = await fetchStatuses(instance, accountId, 5); + } catch (err) { + console.error('Error loading Mastodon feed:', err); + // Fail silently - don't render component on error + return null; + } + + // Don't render if no statuses + if (statuses.length === 0) { return null; } @@ -76,95 +102,71 @@ export function MastodonFeed() {
{/* Header */}
- + 微網誌
{/* Content */} - {loading ? ( -
- {[...Array(3)].map((_, i) => ( -
-
-
-
-
- ))} -
- ) : statuses.length === 0 ? ( -

- 暫無動態 -

- ) : ( -
- {statuses.map((status) => { - // Handle boosts (reblogs) - const displayStatus = status.reblog || status; - const content = stripHtml(displayStatus.content); - const truncated = truncateText(content, 180); - const relativeTime = formatRelativeTime(status.created_at); - const hasMedia = displayStatus.media_attachments.length > 0; +
+ {statuses.map((status) => { + // Handle boosts (reblogs) + const displayStatus = status.reblog || status; + const content = stripHtml(displayStatus.content); + const truncated = truncateText(content, 180); + const relativeTime = formatRelativeTime(status.created_at); + const hasMedia = displayStatus.media_attachments.length > 0; - return ( -
- + + {/* Boost indicator */} + {status.reblog && ( +
+ + 轉推了 +
+ )} + + {/* Content */} +

+ {truncated} +

+ + {/* Media indicator */} + {hasMedia && ( +
+ 📎 包含 {displayStatus.media_attachments.length} 個媒體 +
+ )} + + {/* Timestamp */} +
-
- ); - })} -
- )} + {relativeTime} + + + + ); + })} +
{/* Footer link */} - {!loading && statuses.length > 0 && ( - - 查看更多 - - - )} + + 查看更多 + +
); } diff --git a/components/meta-item.tsx b/components/meta-item.tsx index c99f996..31ec4d5 100644 --- a/components/meta-item.tsx +++ b/components/meta-item.tsx @@ -1,16 +1,15 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { ReactNode } from 'react'; import clsx from 'clsx'; +import { IconType } from 'react-icons'; interface MetaItemProps { - icon: IconDefinition; + icon: IconType; children: ReactNode; className?: string; tone?: 'default' | 'muted'; } -export function MetaItem({ icon, children, className, tone = 'default' }: MetaItemProps) { +export function MetaItem({ icon: Icon, children, className, tone = 'default' }: MetaItemProps) { return ( - + {children} ); diff --git a/components/nav-menu.tsx b/components/nav-menu.tsx index 168eac3..380245d 100644 --- a/components/nav-menu.tsx +++ b/components/nav-menu.tsx @@ -1,22 +1,21 @@ 'use client'; import { useState } from 'react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { - faBars, - faXmark, - faHouse, - faNewspaper, - faFileLines, - faUser, - faEnvelope, - faLocationDot, - faPenNib, - faTags, - faServer, - faMicrochip, - faBarsStaggered -} from '@fortawesome/free-solid-svg-icons'; + FiMenu, + FiX, + FiHome, + FiFileText, + FiFile, + FiUser, + FiMail, + FiMapPin, + FiFeather, + FiTag, + FiServer, + FiCpu, + FiList +} from 'react-icons/fi'; import Link from 'next/link'; export type IconKey = @@ -33,17 +32,17 @@ export type IconKey = | 'menu'; const ICON_MAP: Record = { - home: faHouse, - blog: faNewspaper, - file: faFileLines, - user: faUser, - contact: faEnvelope, - location: faLocationDot, - pen: faPenNib, - tags: faTags, - server: faServer, - device: faMicrochip, - menu: faBarsStaggered + home: FiHome, + blog: FiFileText, + file: FiFile, + user: FiUser, + contact: FiMail, + location: FiMapPin, + pen: FiFeather, + tags: FiTag, + server: FiServer, + device: FiCpu, + menu: FiList }; export interface NavLinkItem { @@ -72,7 +71,7 @@ export function NavMenu({ items }: NavMenuProps) { aria-expanded={open} onClick={toggle} > - + {open ? : }