Compare commits

..

69 Commits

Author SHA1 Message Date
b005f02b7b fix: use getTagSlug() for tag links to prevent empty tag pages
Tags containing both spaces and dashes (e.g. "Writings - 創作") produced
mismatched slugs: inline generation created "writings---創作" while
getTagSlug() collapsed dashes to "writings-創作", causing tag pages to
show no articles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:42:45 +08:00
4cdccb0276 trigger rebuild: content submodule now available on GitHub
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:27:20 +08:00
ddd0cc5795 Update content submodule: add Codex Windows 版上線,但問題不只是工具
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:19:47 +08:00
33042cde79 fix: unify color system around configurable accent, warm-tint neutrals
Replace hardcoded purple gradients with accent-derived colors so
changing NEXT_PUBLIC_COLOR_ACCENT actually controls the entire site.
Warm-tint ink colors and body background from slate to stone.
Remove decorative floating orbs from hero. Simplify tag page to
accent-derived tints instead of 5 random colors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:30:42 +08:00
5325a08bc3 perf: memoize post queries and reduce JSON-LD bloat 2026-03-15 17:31:23 +08:00
1b495d2d2d Remove next-view-transitions and use native View Transition API
- Remove external next-view-transitions dependency
- Use Next.js 16 native navigation and Safari 18+ native View Transition API
- Add ViewTransitionProvider for minimal wrapping with Safari 18+ detection
- Updated all Link imports from external package to next/link
- Removed link-wrapper.tsx and view-transitions-wrapper.tsx

This resolves Safari compatibility issues while maintaining transitions on modern browsers.
2026-03-14 23:00:21 +08:00
efb57b691b feat: Add SEO/AEO/Geo improvements
- Add ai.txt and llms.txt endpoints for AI/LLM discoverability
- Enhance metadata across all pages (canonical URLs, OpenGraph, Twitter)
- Add structured data (JSON-LD) to blog index, tag pages
- Update robots.txt with AI crawler rules
- Improve BlogPosting and CollectionPage schemas
2026-03-14 12:19:18 +08:00
08117a11c5 feat: client-side Mermaid diagram rendering with interactive viewer
Render mermaid code blocks as SVG diagrams instead of syntax-highlighted
source code. Includes a full pan/zoom viewer with drag, scroll wheel zoom,
pinch-to-zoom, fit-to-view, and fullscreen support. Theme-aware (dark/light).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:29:53 +08:00
6ac6ea5545 Update content submodule
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:05:12 +08:00
bdd42b9d26 feat: display Mastodon post media inline (images, video, gif)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:57:00 +08:00
f7f2451357 feat: HomeLab CSS art hero with Proxmox, Switch, NAS
- Add homelab-device-hero component (Proxmox VE + Router icon, Switch, TrueNAS)
- Dashed lines for network connections between devices
- Custom not-found page (replace Next.js 404)
- Homelab page uses CSS hero instead of feature image

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:38:56 +08:00
8d08383391 style: reduce empty space on dev-env page
- Tighter vertical spacing (space-y-4, smaller header margins)
- Larger device mockup at all breakpoints (up to 700px monitor)
- Wider content area (max-w-5xl) for dev-env page
- Less padding in device hero section

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 08:39:15 +08:00
1077c76366 style: responsive design for dev-env device hero on large screens
- Scale monitor, desk, keyboard, Mac mini proportionally at 1024/1280/1536px
- Increase terminal window, logos, and font sizes on larger viewports
- Scale bezel, stand, screen inset for consistent proportions

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 08:19:39 +08:00
a09b7505be feat: dev-env page - Mac mini + 螢幕 mockup with Arch/Ubuntu/Linux SVG logos
- Add DevEnvDeviceHero component with 3D device mockup
- Terminal window displays Arch, Ubuntu, Tux logos (react-icons Simple Icons)
- 4:3 screen ratio, taller display for full logo visibility
- Remove dock for cleaner layout

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 00:43:16 +08:00
240d44842a chore: add browser optimizations
- Add browserslist (Chrome 111+, Edge 111+, Firefox 111+, Safari 16.4+)
- Add loading=lazy and decoding=async to markdown images for better LCP

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 00:09:59 +08:00
d5ea352775 perf: frame-rate independent animation + Scroll-Driven progress bar
- MatrixRain: add delta time for consistent speed across 60/120/144Hz displays
- ReadingProgress: use Scroll-Driven Animations API with JS fallback
- Fallback to scroll events for unsupported browsers (Firefox) and prefers-reduced-motion

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 00:02:35 +08:00
f185048abc feat: improve Mastodon feed loading animation with shimmer and stagger
- Replace animate-pulse with shimmer wave effect
- Add staggered animation-delay for cascade feel
- Light/dark mode gradients with Mastodon purple accent
- Respect prefers-reduced-motion for accessibility

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 23:56:16 +08:00
8170fa0aa5 style: terminal adapts to light/dark mode
- Light: slate-100 bg, slate-300 border, emerald-600 accent
- Dark: slate-900 bg, slate-700 border, emerald-400 accent

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 23:48:28 +08:00
fe28262ef4 feat: launcher-style search UI (Raycast/Spotlight)
- Replace Pagefind UI with cmdk + Pagefind low-level API
- Quick actions when empty: nav (home, blog, tags) + recent posts
- Debounced full-text search with keyboard navigation
- Pass recent posts from layout to SearchModal
- Extract cn utility to lib/utils.ts
- Remove Pagefind UI styles, add Radix overlay styling
- Align blog search bar styling with launcher

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 23:41:10 +08:00
7d85446ac5 feat: add page transition animations and loading indicators
- Add nextjs-toploader for instant top progress bar on navigation
- Add next-view-transitions for View Transitions API page transitions
- Enhance template.tsx page enter animation (0.45s, scale effect)
- Replace next/link with next-view-transitions Link for smooth transitions
- Add prefers-reduced-motion support for accessibility

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 23:07:51 +08:00
a4e88fa506 style: make terminal hero responsive on wider screens
- Hero: responsive max-width (2xl→3xl→4xl→5xl) and min-height
- Terminal: responsive font size (sm→base→lg), padding, title bar

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 22:54:42 +08:00
62d5973e1f fix: move sidebar FAB to bottom-left to avoid overlap with back-to-top
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 22:47:18 +08:00
42a1d3cbbe feat: add Matrix rain + terminal hero with typing effect
- Matrix rain animation on homepage load (duration tied to page load)
- Sequential transition to terminal window with typing effect
- cat welcome.txt → title & tagline
- fastfetch → dual eagle-eye ASCII art (霍德爾之目)
- prefers-reduced-motion support
- SEO: sr-only h1 for accessibility

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 22:44:24 +08:00
d27cc01c87 feat: add mobile sidebar access via FAB and slide-over drawer
- Extract RightSidebarContent for reuse in desktop and mobile
- Add floating action button (FAB) on narrow screens to open sidebar
- Slide-over drawer from right with author card, Mastodon feed, tags
- Lazy load Mastodon feed when drawer opens (forceLoadFeed prop)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 22:03:13 +08:00
8a4ecf9634 Add repo card component and GitHub language colors for projects page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 21:45:45 +08:00
27dc2db3ee Update posts per page to 7, layout and sidebar changes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 21:21:25 +08:00
fde17c2308 feat: add GitHub projects page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 16:59:56 +08:00
2402c94760 perf: 全面優化部落格載入速度與效能
- 字體載入優化:添加 preconnect 到 Google Fonts,優化載入順序
- 元件延遲載入:RightSidebar、MastodonFeed、PostToc、BackToTop 使用動態載入
- 圖片優化:添加 blur placeholder,首屏圖片添加 priority,優化圖片尺寸配置
- 快取策略:為 HTML 頁面、OG 圖片、RSS feed 添加快取標頭
- 程式碼分割:確保路由層級分割正常,延遲載入非關鍵元件
- 效能監控:添加 WebVitals 元件追蹤基本效能指標
- 連結優化:為重要連結添加 prefetch 屬性

預期效果:
- FCP 減少 20-30%
- LCP 減少 30-40%
- CLS 減少 50%+
- TTI 減少 25-35%
- Bundle Size 減少 15-25%

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 16:18:51 +08:00
62090c7742 perf: 优化字体加载性能和字间距
- 添加 adjustFontFallback: false 优化 CLS(累积布局偏移)
- 调整霞鹜文楷字间距:从负值改为正值,让手写风格字体更自然
  - h1: -0.03em → 0.01em
  - h2: -0.02em → 0.015em
  - type-display: 添加 0.01em
  - type-title: 0.02em → 0.025em
- 改善字体加载时的视觉稳定性

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 16:08:26 +08:00
a9bd56b658 feat: 将霞鹜文楷也应用到文章内 h2 标题
- 统一文章内所有标题层级的字体风格
- 保持视觉一致性

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 16:01:28 +08:00
e8666d19ee feat: 扩展霞鹜文楷字体到所有主标题级别
- 应用到 .type-title(页面标题、站点标题)
- 应用到 .type-subtitle(区块副标题)
- 应用到全局 h1 和 h2(所有主标题级别)
- 保持卡片标题、导航、正文等使用系统字体
- 统一大标题层级的视觉风格

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 16:01:21 +08:00
2229f6bb6f feat: 添加霞鹜文楷字体到文章主标题
- 使用 Google Fonts 的 LXGW WenKai TC 字体
- 仅应用到文章主标题 (.prose h1 和 .type-display)
- 只加载 Regular (400) 和 Bold (700) 字重以优化性能
- 添加字体子集化脚本(可选,用于本地字体文件)
- 保持系统字体作为 fallback

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 15:05:00 +08:00
ed63ec7d9a Remove unnecessary Partial Prerendering (PPR) configuration
- Remove cacheComponents: true from next.config.mjs
- Update README.md to remove PPR references
- All pages are fully static-generated, PPR provides no benefit

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 14:57:08 +08:00
5a80567117 Fix sidebar personal info section turning white on hover in dark mode
Added dark:hover:bg-slate-800/80 to prevent the light hover:bg-slate-50
from overriding the dark background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 20:14:41 +08:00
614d8dd5aa Update content submodule: fix article line breaks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:04:14 +08:00
31b5821532 Migrate to Tailwind CSS v4 with CSS-first configuration
- Replace tailwindcss v3 + autoprefixer with tailwindcss v4 + @tailwindcss/postcss
- Migrate tailwind.config.cjs theme to @theme block in globals.css
- Add @custom-variant dark for class-based dark mode (next-themes)
- Load typography plugin via @plugin directive, replace prose-dark with prose-invert
- Convert prose dark mode overrides from JS config to CSS (.dark .prose rules)
- Add @source directive for content submodule detection
- Replace postcss.config.cjs with postcss.config.mjs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:55:43 +08:00
661b67cc01 Fix PPR empty generateStaticParams error and update dependencies
- Add placeholder fallback to generateStaticParams for cacheComponents compatibility
- Update npm packages within semver range (next 16.1.6, react 19.2.4, shiki 3.22.0, etc.)
- Add /new-post skill for blog publishing workflow
- Update CLAUDE.md with git remote mirroring docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:39:17 +08:00
9c7f2463aa Add new post and update CLAUDE.md with deployment docs
- New post: 不是所有眼淚,都該被濃縮成重點
- CLAUDE.md: add deployment workflow and content submodule instructions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 12:50:36 +08:00
ce4245c148 Revert "Migrate to HeroUI v3 and Tailwind CSS v4"
This reverts commit 6a9296f33d.
2026-01-23 02:46:02 +08:00
6a9296f33d Migrate to HeroUI v3 and Tailwind CSS v4
- Upgrade from Tailwind CSS v3 to v4 with CSS-first configuration
- Install HeroUI v3 beta packages (@heroui/react, @heroui/styles)
- Migrate PostCSS config to new @tailwindcss/postcss plugin
- Convert tailwind.config.cjs to CSS @theme directive in globals.css
- Replace @tailwindcss/typography with custom prose styles

Component migrations:
- theme-toggle, back-to-top: HeroUI Button with onPress
- post-card, post-list-item: HeroUI Card compound components
- right-sidebar: HeroUI Card, Avatar, Chip
- search-modal: HeroUI Modal with compound structure
- nav-menu: HeroUI Button for mobile controls
- post-list-with-controls: HeroUI Button for sorting/pagination

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:47:36 +08:00
1cd9106ad0 Add CLAUDE.md for Claude Code session context
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:19:20 +08:00
35df76cd13 Fix Mastodon widget line breaks not displaying
Added whitespace-pre-line CSS class to preserve line breaks in
Mastodon post content. Previously, newline characters were being
collapsed into single spaces by default HTML whitespace handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-14 12:43:18 +08:00
ee2eb4796e SECURITY: Update Next.js and React to patch critical RCE vulnerability
Addresses CVE-2025-55182 (React) and CVE-2025-66478 (Next.js)
- CVSS Score: 10.0 (Critical)
- Allows unauthenticated remote code execution via RSC payloads

Updates:
- Next.js: 16.0.3 → 16.0.7
- React: 19.2.0 → 19.2.1
- react-dom: 19.2.0 → 19.2.1

References:
- https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components
- https://nextjs.org/blog/CVE-2025-66478

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 21:57:55 +08:00
d90442456b Partial Lecture Updatee 2025-11-25 00:47:32 +08:00
b17930c10b Update content submodule to 5b1737e 2025-11-23 23:56:37 +08:00
1f3323834e Update navigation layout and assets 2025-11-21 14:51:24 +08:00
7cdfb90b1b Portal mobile TOC overlay to stay floating 2025-11-21 01:48:10 +08:00
f6c5be0ee4 Slim reading progress bar 2025-11-21 01:44:23 +08:00
fc24ddb676 Portal reading progress bar above all layers 2025-11-21 01:42:49 +08:00
cafb810155 Make reading progress bar prominent 2025-11-21 01:40:06 +08:00
ae37f93508 Raise reading progress bar above header 2025-11-21 01:36:04 +08:00
4a4d6dd933 Refine typography palette and dark heading colors 2025-11-21 01:29:57 +08:00
7bf2c4149d Add hover delay to nav dropdown 2025-11-21 01:20:48 +08:00
9d7a6757c9 Raise nav dropdown z-index 2025-11-21 01:17:21 +08:00
d03b061c1e Keep dropdown nav open while hovering 2025-11-21 01:15:28 +08:00
d768d108d6 Add nested navigation groups 2025-11-21 01:10:15 +08:00
7685c79705 Fix TOC duplication when navigating 2025-11-21 00:39:56 +08:00
4173aa69d3 Improve TOC synchronization with contentKey prop
Better fix for TOC showing previous article headings. The issue was
relying on pathname which could be out of sync with the actual content.

Changes:
- Pass contentKey as prop to PostToc instead of using usePathname()
- Use contentKey in useEffect dependency for more reliable updates
- Replace setTimeout with double requestAnimationFrame for DOM sync
- Remove unused usePathname import

This ensures the TOC effect runs exactly when the content changes,
not just when the URL changes, providing more reliable synchronization
between the TOC and the article content.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 23:38:30 +08:00
e2f9c9d556 Fix TOC showing headings from previous article
The TOC was displaying sections from previously viewed articles when
navigating between posts. This happened because the DOM query for
headings ran before Next.js finished updating the page content.

Changes to components/post-toc.tsx:
- Clear items and activeId immediately when pathname changes
- Add 50ms delay before querying DOM for new headings
- Properly handle IntersectionObserver cleanup with timeout

This ensures the TOC always shows the correct headings for the
current article, not the previous one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 23:29:17 +08:00
5d226a2969 Fix TOC button overlap with back-to-top on mobile
Adjusted TOC button positioning to prevent overlap with the back-to-top
button on mobile devices:

- Mobile: bottom-20 (80px) - sits well above back-to-top at bottom-6
- Desktop: lg:bottom-8 - maintains original desktop position
- Both buttons now aligned to right-4 on mobile for consistency

This gives ~56px vertical spacing between buttons on mobile,
preventing any overlap while keeping both easily accessible.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 23:08:50 +08:00
a77cd17419 Fix TOC button to be truly fixed-position using React Portal
The TOC toggle button was appearing near the end of posts instead of
floating at a fixed position. This happened because the button was
rendered inside the PostLayout component hierarchy.

Changes:
- Use React Portal to render TOC button at document.body level
- Add mounted state for proper SSR/client hydration
- Button now floats like back-to-top button, visible from start

This ensures the button is always visible and accessible, similar to
the back-to-top button behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 22:42:59 +08:00
d42cb46af8 Remove bundle analyzer (incompatible with Turbopack)
- Removed @next/bundle-analyzer package
- Removed build:analyze script
- Cleaned up next.config.mjs

The Next Bundle Analyzer is not compatible with Turbopack builds yet.
Since this project uses Turbopack, the analyzer cannot be used.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 22:35:46 +08:00
d6edcf1757 Fix bundle analyzer to use webpack instead of Turbopack
Bundle analyzer requires webpack, add --webpack flag to build:analyze script.

Note: This makes analysis builds slower but enables bundle visualization.
Regular builds still use faster Turbopack.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 22:24:15 +08:00
ba60d49fc6 Add bundle analyzer configuration
Configure @next/bundle-analyzer for production bundle analysis:

**Changes:**
- Install @next/bundle-analyzer package
- Update next.config.mjs to wrap config with bundle analyzer
- Add npm script `build:analyze` to run build with ANALYZE=true
- Bundle analyzer only enabled when ANALYZE=true environment variable is set

**Usage:**
```bash
# Run build with bundle analysis
npm run build:analyze

# Opens interactive bundle visualization in browser
# Shows chunk sizes, module dependencies, and optimization opportunities
```

**Note:** Kept Mastodon feed as Client Component (not Server Component)
because formatRelativeTime() uses `new Date()` which requires dynamic
rendering. Converting to Server Component would prevent static generation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 22:00:02 +08:00
0bb3ee40c6 Optimize performance: Replace Framer Motion and FontAwesome, convert Mastodon to Server Component
Major performance optimizations addressing PageSpeed Insights warnings:

**Phase 1: Replace Framer Motion with CSS (~60-100KB savings)**
- Remove Framer Motion from components/post-layout.tsx
- Add CSS transitions to styles/globals.css for TOC animations
- Replace motion.div/motion.button with regular elements + CSS classes
- Remove framer-motion package dependency

**Phase 2: Replace FontAwesome with React Icons (~150-250KB savings)**
- Replace FontAwesome in 16 components with react-icons
- Use Feather icons (react-icons/fi) for UI elements
- Use FontAwesome brand icons (react-icons/fa) for social media
- Remove 4 @fortawesome packages (@fortawesome/fontawesome-svg-core,
  @fortawesome/free-brands-svg-icons, @fortawesome/free-solid-svg-icons,
  @fortawesome/react-fontawesome)
- Updated components:
  - app/error.tsx, app/tags/page.tsx, app/tags/[tag]/page.tsx
  - components/hero.tsx, components/mastodon-feed.tsx
  - components/meta-item.tsx, components/nav-menu.tsx
  - components/post-card.tsx, components/post-layout.tsx
  - components/post-list-item.tsx, components/post-list-with-controls.tsx
  - components/post-storyline-nav.tsx, components/post-toc.tsx
  - components/right-sidebar.tsx, components/search-modal.tsx
  - components/site-footer.tsx, components/theme-toggle.tsx

**Phase 3: Convert Mastodon Feed to Server Component**
- Convert components/mastodon-feed.tsx from Client Component to async Server Component
- Replace client-side useEffect fetching with server-side ISR
- Add 30-minute revalidation (next: { revalidate: 1800 })
- Eliminate 2 blocking client-side network requests
- Remove loading state (rendered on server)

**Total Impact:**
- JavaScript bundle: ~210-350KB reduction
- Blocking network requests: 2 eliminated
- Main thread time: Reduced by ~100-160ms
- Build:  Verified successful

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 21:51:24 +08:00
6badd76733 Add Schema.org JSON-LD structured data for SEO
Implemented comprehensive Schema.org structured data across the blog to improve SEO and enable rich snippets in search results.

Changes:
- Created JSON-LD helper component for safe schema rendering
- Added BlogPosting schema to blog posts with:
  * Article metadata (headline, description, image, dates)
  * Author and publisher information
  * Keywords and article sections from tags
- Added BreadcrumbList schema to blog posts for navigation
- Added WebSite and Organization schemas to root layout
  * Site-wide identity and branding
  * Search action for site search functionality
- Added CollectionPage schema to homepage
  * Blog collection metadata
- Added WebPage schema to static pages
  * Page metadata with dates and images

Benefits:
- Rich snippets in Google/Bing search results
- Better content understanding by search engines
- Article cards with images, dates, authors in SERPs
- Breadcrumb navigation in search results
- Improved SEO ranking signals

All schemas validated against Schema.org specifications and include proper Chinese language support.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 21:23:10 +08:00
237e5d403b Update content submodule with fixed internal links
Fixed all internal post links from /posts/ to /blog/ to match the actual URL structure. The contentlayer config generates URLs as /blog/{slug} for posts, not /posts/{slug}.

Fixed links in:
- content/pages/關於作者.md (7 links)
- content/posts/OPNsense 在 Proxmox VE 內安裝筆記.md (1 link)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 20:51:02 +08:00
e05295e003 Fix GitHub-style callout rendering
The callout plugin wasn't working because:
1. Contentlayer cache was preventing the plugin from running
2. The plugin wasn't handling blockquotes with whitespace text nodes
3. The plugin needed to skip whitespace-only children to find actual content

Updated the rehype plugin to:
- Skip whitespace-only text nodes when looking for [!TYPE] markers
- Handle both direct text children and text within paragraphs
- Properly extract the callout type from regex match
- Clean up empty text nodes after removing markers

Now callouts render correctly with proper structure:
- Header with icon and title
- Content wrapper with styled box
- All 5 callout types supported (NOTE, TIP, IMPORTANT, WARNING, CAUTION)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 20:39:16 +08:00
45cfc6acc4 Fix TOC showing wrong headings across navigation
The TOC component was only extracting headings once at mount, causing it to show stale headings when navigating between posts via client-side routing. Now it re-extracts headings whenever the pathname changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 20:29:30 +08:00
78 changed files with 8712 additions and 2415 deletions

View File

@@ -0,0 +1,68 @@
# New Blog Post
Create and publish a new blog post for the personal blog.
## Step 1: Gather Information
Ask the user for the following using AskUserQuestion (all in one prompt):
1. **Title** — the article title (Chinese or English)
2. **Tags** — offer existing tags from past posts as multi-select options: `Medicine - 醫學`, `Writings - 創作`, `Hardware - 硬體`, `Software - 軟體`, `Unboxing - 開箱`. User can also input custom tags.
3. **Feature image** — options: no image, provide a URL or Unsplash link, or provide a local file path
4. **Description** — short excerpt for SEO/article list, or skip for now
## Step 2: Create the Post File
Create the markdown file at `content/posts/<title>.md` with this frontmatter format:
```yaml
---
title: <title>
slug: <english-slug-derived-from-title>
published_at: '<current-ISO-date>'
description: <description if provided>
tags:
- <tag1>
- <tag2>
authors:
- Gbanyan
feature_image: ../assets/<slug>.jpg
---
```
If the user provides article content, add it after the frontmatter.
## Step 3: Handle Feature Image
If the user provides an Unsplash URL:
1. Extract the real image URL by running: `curl -sL "<unsplash-page-url>" | grep -oE 'https://images\.unsplash\.com/photo-[^"? ]+' | head -1`
2. Download at 1920px width: `curl -sL -o content/assets/<slug>.jpg "<image-url>?w=1920&q=90"`
3. Optimize with jpegoptim: `jpegoptim --max=85 --strip-all --all-progressive content/assets/<slug>.jpg`
4. Verify the image visually using the Read tool
If the user provides a local file path, copy it to `content/assets/<slug>.jpg` and optimize.
If no image, omit `feature_image` from frontmatter.
## Step 4: Preview (Optional)
Ask the user if they want to preview with `npm run dev` before publishing.
## Step 5: Publish
**IMPORTANT**: The `.gitmodules` URL for `content/` points to GitHub. The CI/CD server clones the submodule from that URL, so the content submodule **must be pushed to GitHub first** before pushing the main repo. Otherwise the server will check out stale content and posts will disappear from the site.
Execute the deployment in order:
```bash
# 1. Commit content submodule
git -C content add . && git -C content commit -m "Add new post: <title>"
# 2. Push content submodule to ALL remotes (GitHub first — CI/CD depends on it)
git -C content push github main && git -C content push origin main
# 3. Update main repo submodule pointer, commit, and push to both remotes
git add content && git commit -m "Update content submodule" && git push origin main && git push github main
```
Confirm all pushes succeeded. The CI/CD pipeline on git.gbanyan.net will handle deployment automatically (and crontab mirrors to gitea.gbanyan.net).

View File

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

View File

@@ -0,0 +1,2 @@
[ 1800ms] [WARNING] Image with src "https://www.gravatar.com/avatar/53f2a6e011d5ececcd0d6e33e8e7329f60efcd23a5b77ba382273d5060a2cffe?s=160&d=identicon" was detected as the Largest Contentful Paint (LCP). Please add the `loading="eager"` property if this image is above the fold.
Read more: https://nextjs.org/docs/app/api-reference/components/image#loading @ http://localhost:3000/_next/static/chunks/node_modules_next_dist_f3530cac._.js:2297

View File

@@ -0,0 +1,5 @@
[ 22376ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/webpack-hmr?id=VmGWkPt9sasaNk_cqnGHG' failed: @ http://localhost:3000/_next/static/chunks/node_modules_next_dist_client_17643121._.js:10878
[ 23381ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/webpack-hmr?id=VmGWkPt9sasaNk_cqnGHG' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/node_modules_next_dist_client_17643121._.js:10878
[ 24384ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/webpack-hmr?id=VmGWkPt9sasaNk_cqnGHG' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/node_modules_next_dist_client_17643121._.js:10878
[ 25385ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/webpack-hmr?id=VmGWkPt9sasaNk_cqnGHG' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/node_modules_next_dist_client_17643121._.js:10878
[ 26387ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/webpack-hmr?id=VmGWkPt9sasaNk_cqnGHG' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/node_modules_next_dist_client_17643121._.js:10878

View File

@@ -0,0 +1,2 @@
[ 789ms] [WARNING] Image with src "https://www.gravatar.com/avatar/53f2a6e011d5ececcd0d6e33e8e7329f60efcd23a5b77ba382273d5060a2cffe?s=160&d=identicon" was detected as the Largest Contentful Paint (LCP). Please add the `loading="eager"` property if this image is above the fold.
Read more: https://nextjs.org/docs/app/api-reference/components/image#loading @ http://localhost:3000/_next/static/chunks/node_modules_next_dist_f3530cac._.js:2297

View File

@@ -0,0 +1 @@
[ 1801237ms] [WARNING] The resource https://blog.gbanyan.net/_next/static/chunks/457c1f08a48523ea.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ https://blog.gbanyan.net/blog/not-all-tears-should-be-summarized:0

122
CLAUDE.md Normal file
View File

@@ -0,0 +1,122 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
- `npm run dev` - Start dev server (runs Contentlayer2 + Next.js with Turbopack concurrently)
- `npm run build` - Full production build: sync-assets → contentlayer2 build → next build → pagefind indexing → copy pagefind to public
- `npm run lint` - ESLint via `next lint`
- `npm run sync-assets` - Copy `content/assets/` to `public/assets/` (also runs automatically before build)
No test framework is configured.
## Architecture
**Content pipeline**: `content/` git submodule (MDX/Markdown) → Contentlayer2 (`contentlayer.config.ts`) → typed `Post`/`Page` objects imported from `contentlayer2/generated` → consumed by pages and `lib/posts.ts` helpers.
**Routing** (App Router):
- `/` — Home page with latest posts
- `/blog` — Blog index with search, sort, pagination
- `/blog/[slug]` — Single post with TOC, reading progress, prev/next navigation
- `/pages/[slug]` — Static content pages (from `content/pages/`)
- `/tags`, `/tags/[tag]` — Tag index and per-tag post lists
- `/api/og` — Dynamic OG image generation (`@vercel/og`)
- `/feed.xml` — RSS feed (route handler)
**Key data flow**:
- `lib/config.ts``siteConfig` object built from `NEXT_PUBLIC_*` env vars (all site metadata, social links, accent colors, pagination)
- `lib/posts.ts` — Query helpers: `getAllPostsSorted()`, `getPostBySlug()`, `getPageBySlug()`, `getAllTagsWithCount()`, `getRelatedPosts()`, `getPostNeighbors()`
- `lib/mastodon.ts` — Mastodon API client for sidebar feed widget
- `lib/rehype-callouts.ts` — Custom rehype plugin for GitHub-style `[!NOTE]` callout blocks
**Layout hierarchy**: `app/layout.tsx` (fonts, theme CSS vars, ThemeProvider, JSON-LD) → `components/layout-shell.tsx` (header, sidebar, footer, back-to-top) → page content.
**Markdown processing** (configured in `contentlayer.config.ts`):
- Remark: GFM
- Rehype: callouts → pretty-code (shiki, dual theme) → slug → autolink-headings → image path rewriter (`../assets/``/assets/`)
- Image paths in markdown are relative (`../assets/foo.jpg`); a rehype plugin rewrites them to `/assets/foo.jpg` at build time
## Styling
- Tailwind CSS v4 with CSS-first configuration (no `tailwind.config.cjs`)
- Dark mode via `@custom-variant dark` in `styles/globals.css` (class-based, toggled by `next-themes`)
- Theme customization via `@theme` block in `styles/globals.css`: colors, fonts, easing, durations, shadows, keyframes, animations
- Accent color system via CSS variables set in `app/layout.tsx` from env vars: `--color-accent`, `--color-accent-soft`, `--color-accent-text-light`, `--color-accent-text-dark`
- Typography plugin (`@tailwindcss/typography`) loaded via `@plugin` directive; prose dark mode handled by custom `.dark .prose` CSS overrides
- English headings use Playfair Display serif (`--font-serif-eng`); body uses Inter + CJK fallback stack
- PostCSS config: `postcss.config.mjs` using `@tailwindcss/postcss`
## Content Submodule
The `content/` directory is a git submodule pointing to a separate `personal-blog` repository. It contains `posts/`, `pages/`, and `assets/`. After pulling new content, run `npm run sync-assets` to update `public/assets/`. The build script does this automatically.
## Path Aliases
`@/*` maps to project root (configured in `tsconfig.json`). Contentlayer generated types at `.contentlayer/generated` are aliased as `contentlayer2/generated`.
## Deployment
Two Git remotes are involved: `git.gbanyan.net` (SSH, primary push target) and `gitea.gbanyan.net` (HTTPS, Gitea web UI). A crontab on the server automatically mirrors `git.gbanyan.net``gitea.gbanyan.net`. Push to `main` on `git.gbanyan.net` triggers CI/CD automatically (server-side hook). No Dockerfile or workflow file in this repo.
**Content-only update** (new/edited posts) — both steps are required to trigger deploy:
1. Commit and push inside `content/` submodule: `git -C content add . && git -C content commit -m "..." && git -C content push`
2. Update main repo submodule pointer and push: `git add content && git commit -m "Update content submodule" && git push`
Pushing only to `content/` (personal-blog) does NOT trigger deployment. The main repo must also be pushed because CI/CD is bound to `blog-nextjs`, not `personal-blog`.
**Code changes**: Commit and push in the main repo as usual — `git push` to `main` triggers the pipeline.
## Language
The site's default locale is `zh-TW`. UI text, labels, and timestamps are in Traditional Chinese.
## Design Context
### Users
- **Medical professionals & students**: Seek clinical insights, case studies, and medical education content
- **General public**: Interested inpersonal reflections, medicine explainedaccessibly, and lifestyle content
- **Tech enthusiasts & developers**: Drawn to HomeLab, technical tutorials, and developer environment content
- **Patients & advocates**: Those with similar conditions (Usher syndrome, hearing/vision impairments) seeking understanding and community
**Context**: Readers visit for deep, reflective content—often in quiet environments, seeking to learn, reflect, or connect with personal experiences. They value clarity, authenticity, and quality over speed.
**Job to be done**: Gain meaningful knowledge, find resonance with personal experiences, understand complex topics (medical/technical) in approachable terms.
### Brand Personality
- **Voice**: Reflective, professional, and thoughtful—like a trusted physician who also happens to be a developer
- **3-word personality**: Professional & refined, Thoughtful & reflective, Technical & practical, Approachable & human
- **Emotional goals**: Calm & contemplative, Inspired & curious
**Not**: Corporate, salesy, alarmist (like news sites), or overly technical/clinical.
### Aesthetic Direction
**Visual tone**: Warm & organic with academic & scholarly sensibility, combined with modern technical clarity
**References**:
- Medium (medium.com): Readability-focused, minimal distractions, clean typography
- Personal tech blogs: Individual personality, character, and hands-on authenticity
- Library aesthetic: Quiet, thoughtful, knowledge-rich environment
**Anti-references** (explicitly avoid):
- News sites: Cluttered, headline-focused, clickbait design
- Social media feeds: Infinite scroll, attention-grabbing tactics, dopamine-driven design
- Corporate/SaaS: Too polished, salesy, or uniform corporate branding
- Dry technical docs: Lacking personality, purely functional
**Theme**: Both light and dark modes equally important—light for daytime readability, dark for late-night focused reading. Accent colors should be warm (avoid reds/yellows which feel urgent/alerting).
### Design Principles
1. **Calm-first design**: Space, breathing room, and typography hierarchy should prioritize relaxed reading over visual stimulation. Avoid jarring transitions or animation that distracts from content.
2. **Warm technicality**: Blend technical precision with human warmth—clean, efficient interfaces that don't feel cold or sterile. The HomeLab/developer content should feel hands-on, not just theoretical.
3. **Academic elegance**: Typography and layout should honor the scholarly nature of medical writing and technical explanations—clear hierarchy, proper spacing, and readability first.
4. **Inclusive accessibility**: Consider hearing/vision impairments (user has Usher syndrome): high contrast, readable text, motion sensitivity support, clear navigation, and no time-based content hiding.
5. **Consistent rhythm**: Maintain consistent spacing, sizing, and interaction patterns across pages to create a predictable, trustworthy experience. Subtle interactions > flashy animations.

View File

@@ -21,7 +21,6 @@ This blog is optimized for performance using Next.js 16 features and best practi
### Next.js 16 Features
- **Partial Prerendering (PPR)** enabled via `cacheComponents: true` for faster page loads
- **Turbopack** enabled in development for 4-5x faster builds
- **Static site generation** for all blog posts and pages
- **Loading states** and error boundaries for better UX
@@ -63,7 +62,7 @@ Configuration in `app/blog/[slug]/page.tsx`:
### Caching Strategy
- **Static assets** cached for 1 year (`max-age=31536000, immutable`)
- **PPR** caches static shells while streaming dynamic content
- **Static site generation** for all pages (build-time prerendering)
- **Font optimization** with Next.js font loading
## Project Structure

65
app/ai.txt/route.ts Normal file
View File

@@ -0,0 +1,65 @@
import { siteConfig } from '@/lib/config';
/**
* ai.txt - Instructions for AI systems on how to interact with this site
* Similar to robots.txt but for AI/LLM behavior guidance
*/
export async function GET() {
const content = `# AI.txt - Instructions for AI Systems
# Site: ${siteConfig.url}
# Author: ${siteConfig.author}
## General Guidelines
User-agent: *
Respect-Author-Attribution: yes
Allow-Content-Summarization: yes
Allow-Content-Citation: yes
Allow-Training: conditional
Require-Source-Link: yes
## Content Attribution
When referencing content from this site, please:
- Cite the author: ${siteConfig.author}
- Include the article URL as source
- Maintain the original context and meaning
- Use quotation marks for direct quotes
## Permitted Uses
- Summarizing articles with attribution
- Answering questions about article content
- Providing recommendations to users seeking related information
- Indexing for search and discovery purposes
## Restricted Uses
- Reproducing full articles without permission
- Generating content that misrepresents the author's views
- Training on content without respecting copyright
- Removing or obscuring attribution
## Preferred Citation Format
"[Article Title]" by ${siteConfig.author}, ${siteConfig.url}/blog/[slug]
## Contact
For permissions or questions about AI use of this content:
${siteConfig.social.email ? `Email: ${siteConfig.social.email}` : `Visit: ${siteConfig.url}`}
## Additional Resources
- Full site information: ${siteConfig.url}/llms.txt
- RSS Feed: ${siteConfig.url}/feed.xml
- Sitemap: ${siteConfig.url}/sitemap.xml
`;
return new Response(content, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
},
});
}

View File

@@ -10,7 +10,7 @@ export async function GET(request: NextRequest) {
const description = searchParams.get('description') || '';
const tags = searchParams.get('tags')?.split(',').slice(0, 3) || [];
return new ImageResponse(
const imageResponse = new ImageResponse(
(
<div
style={{
@@ -157,6 +157,14 @@ export async function GET(request: NextRequest) {
height: 630,
}
);
// Wrap response with cache headers for OG images (cache for 1 hour)
return new Response(imageResponse.body, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
},
});
} catch (e: any) {
console.error('Error generating OG image:', e);
return new Response(`Failed to generate image: ${e.message}`, {

View File

@@ -3,7 +3,7 @@ import Image from 'next/image';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { allPosts } from 'contentlayer2/generated';
import { getPostBySlug, getRelatedPosts, getPostNeighbors } from '@/lib/posts';
import { getPostBySlug, getRelatedPosts, getPostNeighbors, getTagSlug } from '@/lib/posts';
import { siteConfig } from '@/lib/config';
import { ReadingProgress } from '@/components/reading-progress';
import { ScrollReveal } from '@/components/scroll-reveal';
@@ -12,11 +12,14 @@ import { PostCard } from '@/components/post-card';
import { PostStorylineNav } from '@/components/post-storyline-nav';
import { SectionDivider } from '@/components/section-divider';
import { FooterCue } from '@/components/footer-cue';
import { JsonLd } from '@/components/json-ld';
import { MermaidRenderer } from '@/components/mermaid-renderer';
export function generateStaticParams() {
return allPosts.map((post) => ({
const params = allPosts.map((post) => ({
slug: post.slug || post.flattenedPath
}));
return params.length > 0 ? params : [{ slug: '__placeholder__' }];
}
interface Props {
@@ -40,12 +43,21 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return {
title: post.title,
description: post.description || post.title,
authors: post.authors?.length ? post.authors.map(author => ({ name: author })) : [{ name: siteConfig.author }],
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
openGraph: {
title: post.title,
description: post.description || post.title,
type: 'article',
publishedTime: post.published_at,
authors: post.authors,
authors: post.authors?.length ? post.authors : [siteConfig.author],
tags: post.tags,
images: [
{
@@ -76,8 +88,112 @@ export default async function BlogPostPage({ params }: Props) {
const hasToc = /<h[23]/.test(post.body.html);
// Generate absolute URL for the post
const postUrl = `${siteConfig.url}${post.url}`;
// Get the OG image URL (same as in metadata)
const ogImageUrl = new URL('/api/og', siteConfig.url);
ogImageUrl.searchParams.set('title', post.title);
if (post.description) {
ogImageUrl.searchParams.set('description', post.description);
}
if (post.tags && post.tags.length > 0) {
ogImageUrl.searchParams.set('tags', post.tags.slice(0, 3).join(','));
}
// Get image URL - prefer feature_image, fallback to OG image
const imageUrl = post.feature_image
? `${siteConfig.url}${post.feature_image.replace('../assets', '/assets')}`
: ogImageUrl.toString();
// Estimate word count and reading time
const textContent = post.body?.raw || '';
const wordCount = textContent.split(/\s+/).filter(Boolean).length;
const readingTime = Math.ceil(wordCount / 200);
// BlogPosting Schema
const blogPostingSchema = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.description || post.custom_excerpt || post.title,
image: imageUrl,
datePublished: post.published_at,
dateModified: post.updated_at || post.published_at,
author: {
'@type': 'Person',
name: post.authors?.[0] || siteConfig.author,
url: siteConfig.url,
},
publisher: {
'@type': 'Organization',
name: siteConfig.name,
logo: {
'@type': 'ImageObject',
url: `${siteConfig.url}${siteConfig.avatar}`,
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': postUrl,
},
...(post.tags && post.tags.length > 0 && {
keywords: post.tags.join(', '),
articleSection: post.tags[0],
}),
...(wordCount > 0 && {
wordCount: wordCount,
readingTime: `${readingTime} min read`,
}),
url: postUrl,
};
// Speakable Schema for AEO
const speakableSchema = {
'@context': 'https://schema.org',
'@type': 'SpeakableSpecification',
speakable: {
'@type': 'CSSSelector',
selector: [
'article[data-toc-content]',
'.prose h2',
'.prose h3',
'.prose p',
],
},
};
// BreadcrumbList Schema
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: '首頁',
item: siteConfig.url,
},
{
'@type': 'ListItem',
position: 2,
name: '所有文章',
item: `${siteConfig.url}/blog`,
},
{
'@type': 'ListItem',
position: 3,
name: post.title,
item: postUrl,
},
],
};
return (
<>
<JsonLd data={blogPostingSchema} />
<JsonLd data={breadcrumbSchema} />
<JsonLd data={speakableSchema} />
<ReadingProgress />
<PostLayout hasToc={hasToc} contentKey={slug}>
<div className="space-y-8">
@@ -101,10 +217,8 @@ export default async function BlogPostPage({ params }: Props) {
{post.tags.map((t) => (
<Link
key={t}
href={`/tags/${encodeURIComponent(
t.toLowerCase().replace(/\s+/g, '-')
)}`}
className="tag-chip rounded-full bg-accent-soft px-3 py-1 text-sm text-accent-textLight dark:bg-slate-800 dark:text-slate-100"
href={`/tags/${encodeURIComponent(getTagSlug(t))}`}
className="tag-chip rounded-full bg-accent-soft px-3 py-1 text-sm text-accent-textLight dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 dark:hover:text-white"
>
#{t}
</Link>
@@ -117,7 +231,10 @@ export default async function BlogPostPage({ params }: Props) {
<SectionDivider>
<ScrollReveal>
<article className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark">
<article
data-toc-content={slug}
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-invert"
>
{post.feature_image && (
<div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4">
<Image
@@ -132,6 +249,7 @@ export default async function BlogPostPage({ params }: Props) {
</div>
)}
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
<MermaidRenderer />
</article>
</ScrollReveal>
</SectionDivider>

View File

@@ -1,26 +1,62 @@
import Link from 'next/link';
import { getAllPostsSorted } from '@/lib/posts';
import { PostListWithControls } from '@/components/post-list-with-controls';
import { TimelineWrapper } from '@/components/timeline-wrapper';
import { SidebarLayout } from '@/components/sidebar-layout';
import { SectionDivider } from '@/components/section-divider';
import { ScrollReveal } from '@/components/scroll-reveal';
import { FiTrendingUp } from 'react-icons/fi';
import { siteConfig } from '@/lib/config';
import { JsonLd } from '@/components/json-ld';
export const metadata = {
title: '所有文章'
title: '所有文章',
description: '瀏覽所有文章,持續更新中。',
alternates: {
canonical: `${siteConfig.url}/blog`
}
};
export default function BlogIndexPage() {
const posts = getAllPostsSorted();
// Blog schema
const blogSchema = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: '所有文章',
description: '瀏覽所有文章,持續更新中。',
url: `${siteConfig.url}/blog`,
inLanguage: siteConfig.defaultLocale,
blogPost: posts.slice(0, 10).map((post) => ({
'@type': 'BlogPosting',
headline: post.title,
url: `${siteConfig.url}${post.url}`,
datePublished: post.published_at,
dateModified: post.updated_at || post.published_at,
author: {
'@type': 'Person',
name: siteConfig.author
}
}))
};
return (
<section className="space-y-4">
<JsonLd data={blogSchema} />
<SidebarLayout>
<header className="space-y-1">
<h1 className="type-title font-semibold text-slate-900 dark:text-slate-50">
</h1>
<p className="type-small text-slate-500 dark:text-slate-400">
</p>
</header>
<SectionDivider>
<ScrollReveal>
<header className="space-y-1">
<h1 className="type-title font-semibold text-slate-900 dark:text-slate-50">
</h1>
<p className="type-small text-slate-500 dark:text-slate-400">
</p>
</header>
</ScrollReveal>
</SectionDivider>
<PostListWithControls posts={posts} />
</SidebarLayout>
</section>

View File

@@ -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({
<div className="flex min-h-screen items-center justify-center px-4">
<div className="max-w-md text-center">
<div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20">
<FontAwesomeIcon
icon={faTriangleExclamation}
className="h-8 w-8 text-red-600 dark:text-red-400"
/>
<FiAlertTriangle className="h-8 w-8 text-red-600 dark:text-red-400" />
</div>
<h2 className="mb-2 text-2xl font-semibold text-slate-900 dark:text-slate-100">

View File

@@ -1,9 +1,14 @@
import '../styles/globals.css';
import type { Metadata } from 'next';
import { siteConfig } from '@/lib/config';
import { getAllPostsSorted } from '@/lib/posts';
import { LayoutShell } from '@/components/layout-shell';
import { ThemeProvider } from 'next-themes';
import { Playfair_Display } from 'next/font/google';
import { Playfair_Display, LXGW_WenKai_TC } from 'next/font/google';
import { JsonLd } from '@/components/json-ld';
import { WebVitals } from '@/components/web-vitals';
import { ViewTransitionProvider } from '@/components/view-transition-provider';
import NextTopLoader from 'nextjs-toploader';
const playfair = Playfair_Display({
subsets: ['latin'],
@@ -11,6 +16,15 @@ const playfair = Playfair_Display({
display: 'swap',
});
const lxgwWenKai = LXGW_WenKai_TC({
weight: ['400', '700'],
subsets: ['latin'],
variable: '--font-serif-cn',
display: 'swap',
preload: true,
adjustFontFallback: false,
});
export const metadata: Metadata = {
title: {
default: siteConfig.title,
@@ -18,22 +32,44 @@ export const metadata: Metadata = {
},
description: siteConfig.description,
metadataBase: new URL(siteConfig.url),
creator: siteConfig.author,
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1
}
},
openGraph: {
type: 'website',
title: siteConfig.title,
description: siteConfig.description,
url: siteConfig.url,
siteName: siteConfig.title,
images: [siteConfig.ogImage]
locale: siteConfig.defaultLocale,
images: [
{
url: siteConfig.ogImage,
width: 1200,
height: 630,
alt: siteConfig.title
}
]
},
twitter: {
card: siteConfig.twitterCard,
site: siteConfig.social.twitter || undefined,
creator: siteConfig.social.twitter || undefined,
title: siteConfig.title,
description: siteConfig.description,
images: [siteConfig.ogImage]
},
icons: {
icon: '/favicon.png'
icon: '/favicon.png',
apple: '/favicon.png'
},
alternates: {
types: {
@@ -42,18 +78,72 @@ export const metadata: Metadata = {
}
};
export default function RootLayout({
export default async function RootLayout({
children
}: {
children: React.ReactNode;
}) {
const theme = siteConfig.theme;
const recentPosts = getAllPostsSorted()
.slice(0, 5)
.map((p) => ({ title: p.title, url: p.url }));
const websiteSchema = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: siteConfig.title,
description: siteConfig.description,
url: siteConfig.url,
inLanguage: siteConfig.defaultLocale,
author: {
'@type': 'Person',
name: siteConfig.author,
url: siteConfig.url,
},
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${siteConfig.url}/blog?search={search_term_string}`,
},
'query-input': 'required name=search_term_string',
},
};
const organizationSchema = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: siteConfig.name,
url: siteConfig.url,
logo: `${siteConfig.url}${siteConfig.avatar}`,
sameAs: [
siteConfig.social.github,
siteConfig.social.twitter && `https://twitter.com/${siteConfig.social.twitter.replace('@', '')}`,
siteConfig.social.mastodon,
].filter(Boolean),
};
return (
<html lang={siteConfig.defaultLocale} suppressHydrationWarning className={playfair.variable}>
<html lang={siteConfig.defaultLocale} suppressHydrationWarning className={`${playfair.variable} ${lxgwWenKai.variable}`}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link rel="font" href="https://fonts.googleapis.com" />
<link rel="font" href="https://fonts.gstatic.com" />
</head>
<body>
<NextTopLoader
color={theme.accent}
height={3}
showSpinner={false}
speed={200}
shadow={`0 0 10px ${theme.accent}, 0 0 5px ${theme.accent}`}
/>
<JsonLd data={websiteSchema} />
<JsonLd data={organizationSchema} />
<style
// Set CSS variables for accent colors (light + dark variants)
dangerouslySetInnerHTML={{
__html: `
:root {
@@ -63,11 +153,14 @@ export default function RootLayout({
--color-accent-text-dark: ${theme.accentTextDark};
}
`
}}
/>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<LayoutShell>{children}</LayoutShell>
</ThemeProvider>
}}
/>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ViewTransitionProvider>
<LayoutShell recentPosts={recentPosts}>{children}</LayoutShell>
</ViewTransitionProvider>
</ThemeProvider>
<WebVitals />
</body>
</html>
);

112
app/llms.txt/route.ts Normal file
View File

@@ -0,0 +1,112 @@
import { siteConfig } from '@/lib/config';
import { allPosts, allPages } from 'contentlayer2/generated';
/**
* llms.txt - A proposed standard for providing LLM-readable site information
* See: https://llmstxt.org/
*
* This file helps AI assistants understand the site structure, content, and purpose.
*/
export async function GET() {
const siteUrl = siteConfig.url;
// Get published posts sorted by date
const posts = allPosts
.filter((post) => post.status === 'published')
.sort((a, b) => {
const dateA = a.published_at ? new Date(a.published_at).getTime() : 0;
const dateB = b.published_at ? new Date(b.published_at).getTime() : 0;
return dateB - dateA;
})
.slice(0, 50); // Latest 50 posts for context
// Get all published pages
const pages = allPages.filter((page) => page.status === 'published');
// Extract unique tags
const tags = Array.from(
new Set(
allPosts
.filter((post) => post.status === 'published' && post.tags)
.flatMap((post) => post.tags || [])
)
);
const content = `# ${siteConfig.name}
> ${siteConfig.description}
## Site Information
- **Author**: ${siteConfig.author}
- **Language**: ${siteConfig.defaultLocale}
- **URL**: ${siteUrl}
## About
${siteConfig.aboutShort}
## Content Overview
This personal blog contains articles about various topics including technology, software development, and personal insights.
### Topics Covered
${tags.map((tag) => `- ${tag}`).join('\n')}
## Recent Articles
${posts
.map((post) => {
const url = `${siteUrl}${post.url}`;
const description = post.description || post.custom_excerpt || '';
return `### ${post.title}
- **URL**: ${url}
- **Published**: ${post.published_at || 'Unknown'}
${description ? `- **Summary**: ${description}` : ''}
${post.tags && post.tags.length > 0 ? `- **Tags**: ${post.tags.join(', ')}` : ''}
`;
})
.join('\n')}
## Static Pages
${pages
.map((page) => {
const url = `${siteUrl}${page.url}`;
return `- [${page.title}](${url})`;
})
.join('\n')}
## Navigation
- Homepage: ${siteUrl}
- All Articles: ${siteUrl}/blog
- Tags: ${siteUrl}/tags
- RSS Feed: ${siteUrl}/feed.xml
## Contact & Social
${siteConfig.social.github ? `- GitHub: ${siteConfig.social.github}` : ''}
${siteConfig.social.mastodon ? `- Mastodon: ${siteConfig.social.mastodon}` : ''}
${siteConfig.social.twitter ? `- Twitter: ${siteConfig.social.twitter}` : ''}
${siteConfig.social.email ? `- Email: ${siteConfig.social.email}` : ''}
## Usage Guidelines
This content is created by ${siteConfig.author} and may be cited with proper attribution. When referencing articles from this site:
1. Provide accurate summaries of the content
2. Include the original URL as a source
3. Respect the author's perspective and context
4. Do not generate content that contradicts the author's views without clarification
`;
return new Response(content, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
},
});
}

25
app/not-found.tsx Normal file
View File

@@ -0,0 +1,25 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4">
<div className="max-w-md text-center">
<h1 className="type-display mb-2 text-6xl font-bold text-slate-300 dark:text-slate-600">
404
</h1>
<h2 className="mb-4 text-xl font-semibold text-slate-800 dark:text-slate-200">
</h2>
<p className="mb-8 text-slate-600 dark:text-slate-400">
</p>
<Link
href="/"
className="inline-flex items-center rounded-lg bg-slate-800 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 dark:bg-slate-200 dark:text-slate-900 dark:hover:bg-slate-300"
>
</Link>
</div>
</div>
);
}

View File

@@ -4,21 +4,44 @@ import { siteConfig } from '@/lib/config';
import { PostListItem } from '@/components/post-list-item';
import { TimelineWrapper } from '@/components/timeline-wrapper';
import { SidebarLayout } from '@/components/sidebar-layout';
import { JsonLd } from '@/components/json-ld';
import { HeroSection } from '@/components/hero-section';
export default function HomePage() {
const posts = getAllPostsSorted().slice(0, siteConfig.postsPerPage);
// CollectionPage Schema for homepage
const collectionPageSchema = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${siteConfig.name} 的最新動態`,
description: siteConfig.description,
url: siteConfig.url,
inLanguage: siteConfig.defaultLocale,
isPartOf: {
'@type': 'WebSite',
name: siteConfig.title,
url: siteConfig.url,
},
about: {
'@type': 'Blog',
name: siteConfig.title,
description: siteConfig.description,
},
};
return (
<section className="space-y-6">
<>
<JsonLd data={collectionPageSchema} />
<section className="space-y-6">
<SidebarLayout>
<header className="space-y-1 text-center">
<h1 className="type-title font-bold text-slate-900 dark:text-slate-50">
{siteConfig.name}
</h1>
<p className="type-small text-slate-600 dark:text-slate-300">
{siteConfig.tagline}
</p>
</header>
<h1 className="sr-only">
{siteConfig.name} {siteConfig.tagline}
</h1>
<HeroSection
title={`${siteConfig.name} 的最新動態`}
tagline={siteConfig.tagline}
/>
<div>
<div className="mb-3 flex items-baseline justify-between">
@@ -27,18 +50,20 @@ export default function HomePage() {
</h2>
<Link
href="/blog"
className="text-xs text-blue-600 hover:underline dark:text-blue-400"
prefetch={true}
className="text-xs text-accent hover:underline"
>
</Link>
</div>
<TimelineWrapper>
{posts.map((post) => (
<PostListItem key={post._id} post={post} />
{posts.map((post, index) => (
<PostListItem key={post._id} post={post} priority={index === 0} />
))}
</TimelineWrapper>
</div>
</SidebarLayout>
</section>
</>
);
}

View File

@@ -3,17 +3,22 @@ import Image from 'next/image';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { allPages } from 'contentlayer2/generated';
import { getPageBySlug } from '@/lib/posts';
import { getPageBySlug, getTagSlug } from '@/lib/posts';
import { siteConfig } from '@/lib/config';
import { ReadingProgress } from '@/components/reading-progress';
import { PostLayout } from '@/components/post-layout';
import { ScrollReveal } from '@/components/scroll-reveal';
import { SectionDivider } from '@/components/section-divider';
import { JsonLd } from '@/components/json-ld';
import { DevEnvDeviceHero } from '@/components/dev-env-device-hero';
import { HomeLabDeviceHero } from '@/components/homelab-device-hero';
import { MermaidRenderer } from '@/components/mermaid-renderer';
export function generateStaticParams() {
return allPages.map((page) => ({
const params = allPages.map((page) => ({
slug: page.slug || page.flattenedPath
}));
return params.length > 0 ? params : [{ slug: '__placeholder__' }];
}
interface Props {
@@ -25,9 +30,41 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const page = getPageBySlug(slug);
if (!page) return {};
const pageUrl = `${siteConfig.url}${page.url}`;
return {
title: page.title,
description: page.description || page.title
description: page.description || page.title,
alternates: {
canonical: pageUrl
},
openGraph: {
title: page.title,
description: page.description || page.title,
url: pageUrl,
type: 'website',
images: [
page.feature_image
? {
url: `${siteConfig.url}${page.feature_image.replace('../assets', '/assets')}`,
alt: page.title
}
: {
url: `${siteConfig.url}${siteConfig.ogImage}`,
alt: page.title
}
]
},
twitter: {
card: siteConfig.twitterCard,
title: page.title,
description: page.description || page.title,
images: [
page.feature_image
? page.feature_image.replace('../assets', '/assets')
: siteConfig.ogImage
]
}
};
}
@@ -39,14 +76,45 @@ export default async function StaticPage({ params }: Props) {
const hasToc = /<h[23]/.test(page.body.html);
// Generate absolute URL for the page
const pageUrl = `${siteConfig.url}${page.url}`;
// Get image URL if available
const imageUrl = page.feature_image
? `${siteConfig.url}${page.feature_image.replace('../assets', '/assets')}`
: `${siteConfig.url}${siteConfig.ogImage}`;
// WebPage Schema
const webPageSchema = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: page.title,
description: page.description || page.title,
url: pageUrl,
image: imageUrl,
inLanguage: siteConfig.defaultLocale,
isPartOf: {
'@type': 'WebSite',
name: siteConfig.title,
url: siteConfig.url,
},
...(page.published_at && {
datePublished: page.published_at,
}),
...(page.updated_at && {
dateModified: page.updated_at,
}),
};
return (
<>
<JsonLd data={webPageSchema} />
<ReadingProgress />
<PostLayout hasToc={hasToc} contentKey={slug}>
<div className="space-y-8">
<PostLayout hasToc={hasToc} contentKey={slug} wide={slug === 'dev-env' || slug === 'homelab'}>
<div className={slug === 'dev-env' || slug === 'homelab' ? 'space-y-4' : 'space-y-8'}>
<SectionDivider>
<ScrollReveal>
<header className="mb-6 space-y-4 text-center">
<header className={slug === 'dev-env' || slug === 'homelab' ? 'mb-4 space-y-3 text-center' : 'mb-6 space-y-4 text-center'}>
{page.published_at && (
<p className="type-small text-slate-500 dark:text-slate-500">
{new Date(page.published_at).toLocaleDateString(
@@ -62,9 +130,7 @@ export default async function StaticPage({ params }: Props) {
{page.tags.map((t) => (
<Link
key={t}
href={`/tags/${encodeURIComponent(
t.toLowerCase().replace(/\s+/g, '-')
)}`}
href={`/tags/${encodeURIComponent(getTagSlug(t))}`}
className="tag-chip rounded-full bg-accent-soft px-3 py-1 text-sm text-accent-textLight dark:bg-slate-800 dark:text-slate-100"
>
#{t}
@@ -78,21 +144,31 @@ export default async function StaticPage({ params }: Props) {
<SectionDivider>
<ScrollReveal>
<article className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark">
{page.feature_image && (
<div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4">
<Image
src={page.feature_image.replace('../assets', '/assets')}
alt={page.title}
width={1200}
height={600}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px"
priority
className="w-full rounded-xl shadow-lg"
/>
</div>
<article
data-toc-content={slug}
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-invert"
>
{slug === 'dev-env' ? (
<DevEnvDeviceHero />
) : slug === 'homelab' ? (
<HomeLabDeviceHero />
) : (
page.feature_image && (
<div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4">
<Image
src={page.feature_image.replace('../assets', '/assets')}
alt={page.title}
width={1200}
height={600}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px"
priority
className="w-full rounded-xl shadow-lg"
/>
</div>
)
)}
<div dangerouslySetInnerHTML={{ __html: page.body.html }} />
<MermaidRenderer />
</article>
</ScrollReveal>
</SectionDivider>

76
app/projects/page.tsx Normal file
View File

@@ -0,0 +1,76 @@
import { FaGithub } from 'react-icons/fa';
import { fetchPublicRepos } from '@/lib/github';
import { SidebarLayout } from '@/components/sidebar-layout';
import { RepoCard } from '@/components/repo-card';
import { siteConfig } from '@/lib/config';
export const revalidate = 3600;
export const metadata = {
title: 'GitHub 專案',
description: '從我的 GitHub 帳號自動抓取公開的程式庫與專案。',
alternates: {
canonical: `${siteConfig.url}/projects`
},
openGraph: {
title: 'GitHub 專案',
description: '從我的 GitHub 帳號自動抓取公開的程式庫與專案。',
url: `${siteConfig.url}/projects`,
type: 'website',
images: [
{
url: `${siteConfig.url}${siteConfig.ogImage}`,
alt: 'GitHub 專案'
}
]
},
twitter: {
card: siteConfig.twitterCard,
title: 'GitHub 專案',
description: '從我的 GitHub 帳號自動抓取公開的程式庫與專案。',
images: [siteConfig.ogImage]
}
};
export default async function ProjectsPage() {
const repos = await fetchPublicRepos();
return (
<section className="space-y-4">
<SidebarLayout>
<header className="space-y-1">
<h1 className="type-title font-semibold text-slate-900 dark:text-slate-50">
GitHub
</h1>
<p className="type-small text-slate-500 dark:text-slate-400">
GitHub
{repos.length > 0 && (
<span className="ml-1"> {repos.length} </span>
)}
</p>
</header>
{repos.length === 0 ? (
<div className="mt-6 flex flex-col items-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-slate-50/50 p-8 text-center dark:border-slate-700 dark:bg-slate-900/30">
<FaGithub className="h-12 w-12 text-slate-400 dark:text-slate-500" />
<p className="type-small text-slate-500 dark:text-slate-400">
GitHub GitHub
</p>
</div>
) : (
<ul className="mt-4 grid gap-4 sm:grid-cols-2">
{repos.map((repo, index) => (
<RepoCard
key={repo.id}
repo={repo}
animationDelay={index * 50}
/>
))}
</ul>
)}
</SidebarLayout>
</section>
);
}

View File

@@ -4,11 +4,19 @@ export default function robots(): MetadataRoute.Robots {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/_next/', '/admin/'],
},
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/_next/', '/admin/'],
},
{
userAgent: ['GPTBot', 'ChatGPT-User', 'Google-Extended', 'Anthropic-ai', 'ClaudeBot', 'Claude-Web', 'PerplexityBot', 'Cohere-ai'],
allow: '/',
disallow: ['/api/', '/_next/', '/admin/'],
},
],
sitemap: `${siteUrl}/sitemap.xml`,
host: siteUrl,
};
}

View File

@@ -1,5 +1,6 @@
import { MetadataRoute } from 'next';
import { allPosts, allPages } from 'contentlayer2/generated';
import { getTagSlug } from '@/lib/posts';
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
@@ -58,7 +59,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
);
const tagPages = allTags.map((tag) => ({
url: `${siteUrl}/tags/${encodeURIComponent(tag.toLowerCase().replace(/\s+/g, '-'))}`,
url: `${siteUrl}/tags/${encodeURIComponent(getTagSlug(tag))}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.5,

View File

@@ -5,8 +5,9 @@ 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';
import { siteConfig } from '@/lib/config';
import { JsonLd } from '@/components/json-ld';
export function generateStaticParams() {
const slugs = new Set<string>();
@@ -16,9 +17,10 @@ export function generateStaticParams() {
slugs.add(getTagSlug(tag));
}
}
return Array.from(slugs).map((slug) => ({
const params = Array.from(slugs).map((slug) => ({
tag: slug
}));
return params.length > 0 ? params : [{ tag: '__placeholder__' }];
}
interface Props {
@@ -27,21 +29,30 @@ interface Props {
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { tag: slug } = await params;
// Decode the slug since Next.js encodes non-ASCII characters in URLs
const decodedSlug = decodeURIComponent(slug);
// Find original tag label by slug
const tag = allPosts
.flatMap((post) => post.tags ?? [])
.find((t) => getTagSlug(t) === decodedSlug);
const tagUrl = `${siteConfig.url}/tags/${slug}`;
return {
title: tag ? `標籤:${tag}` : '標籤'
title: tag ? `標籤:${tag}` : '標籤',
description: tag ? `查看標籤為「${tag}」的所有文章` : '標籤索引',
alternates: {
canonical: tagUrl
},
openGraph: {
title: tag ? `標籤:${tag}` : '標籤',
description: tag ? `查看標籤為「${tag}」的所有文章` : '標籤索引',
url: tagUrl,
type: 'website'
}
};
}
export default async function TagPage({ params }: Props) {
const { tag: slug } = await params;
// Decode the slug since Next.js encodes non-ASCII characters in URLs
const decodedSlug = decodeURIComponent(slug);
const posts = allPosts.filter(
@@ -51,13 +62,42 @@ export default async function TagPage({ params }: Props) {
const tagLabel =
posts[0]?.tags?.find((t) => getTagSlug(t) === decodedSlug) ?? decodedSlug;
// CollectionPage schema
const collectionPageSchema = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `標籤:${tagLabel}`,
description: `查看標籤為「${tagLabel}」的所有文章`,
url: `${siteConfig.url}/tags/${slug}`,
inLanguage: siteConfig.defaultLocale,
about: {
'@type': 'Thing',
name: tagLabel
},
mainEntity: {
'@type': 'Blog',
blogPost: posts.slice(0, 10).map((post) => ({
'@type': 'BlogPosting',
headline: post.title,
url: `${siteConfig.url}${post.url}`,
datePublished: post.published_at,
dateModified: post.updated_at || post.published_at,
author: {
'@type': 'Person',
name: siteConfig.author
}
}))
}
};
return (
<SidebarLayout>
<JsonLd data={collectionPageSchema} />
<SectionDivider>
<ScrollReveal>
<div className="motion-card mb-8 rounded-2xl border border-white/40 bg-white/60 p-8 text-center shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-slate-900/60">
<div className="inline-flex items-center gap-2 text-accent">
<FontAwesomeIcon icon={faTag} className="h-5 w-5" />
<FiTag className="h-5 w-5" />
<span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
TAG ARCHIVE
</span>

View File

@@ -1,14 +1,19 @@
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';
import { SidebarLayout } from '@/components/sidebar-layout';
import { siteConfig } from '@/lib/config';
import { JsonLd } from '@/components/json-ld';
export const metadata: Metadata = {
title: '標籤索引'
title: '標籤索引',
description: '瀏覽所有標籤,探索不同主題的文章。',
alternates: {
canonical: `${siteConfig.url}/tags`
}
};
export default function TagIndexPage() {
@@ -16,21 +21,44 @@ export default function TagIndexPage() {
const topTags = tags.slice(0, 3);
const colorClasses = [
'from-rose-400/70 to-rose-200/40',
'from-emerald-400/70 to-emerald-200/40',
'from-sky-400/70 to-sky-200/40',
'from-amber-400/70 to-amber-200/40',
'from-violet-400/70 to-violet-200/40'
'from-accent/60 to-accent/20',
'from-accent/50 to-accent/15',
'from-accent/40 to-accent/10',
];
// CollectionPage schema with ItemList
const collectionPageSchema = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: '標籤索引',
description: '瀏覽所有標籤,探索不同主題的文章。',
url: `${siteConfig.url}/tags`,
inLanguage: siteConfig.defaultLocale,
mainEntity: {
'@type': 'ItemList',
itemListElement: tags.map((tag, index) => ({
'@type': 'ListItem',
position: index + 1,
name: tag.tag,
url: `${siteConfig.url}/tags/${tag.slug}`,
item: {
'@type': 'Thing',
name: tag.tag,
description: `${tag.count} 篇文章`
}
}))
}
};
return (
<section className="space-y-6">
<JsonLd data={collectionPageSchema} />
<SidebarLayout>
<SectionDivider>
<ScrollReveal>
<div className="motion-card rounded-2xl border border-white/40 bg-white/60 p-8 text-center shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-slate-900/60">
<div className="inline-flex items-center gap-2 text-accent">
<FontAwesomeIcon icon={faTags} className="h-5 w-5" />
<FiTag className="h-5 w-5" />
<span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
</span>
@@ -57,7 +85,7 @@ export default function TagIndexPage() {
>
<span className={`mb-3 block h-1.5 w-16 rounded-full bg-gradient-to-r ${color}`} aria-hidden="true" />
<div className="flex items-center justify-between">
<h2 className="type-subtitle font-semibold text-slate-900 group-hover:text-blue-600 dark:text-slate-50 dark:group-hover:text-blue-400">
<h2 className="type-subtitle font-semibold text-slate-900 group-hover:text-accent dark:text-slate-50 dark:group-hover:text-accent">
{tag}
</h2>
<span className="type-small text-slate-600 dark:text-slate-300">
@@ -65,7 +93,7 @@ export default function TagIndexPage() {
</span>
</div>
<span className="mt-1 inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<FontAwesomeIcon icon={faFire} className="h-3 w-3 text-orange-400" />
<FiTrendingUp className="h-3 w-3 text-orange-400" />
#{index + 1}
</span>
</Link>

View File

@@ -1,24 +1,18 @@
'use client';
import { useEffect, useRef } from 'react';
import { useEffect, useState } from 'react';
export default function Template({ children }: { children: React.ReactNode }) {
const containerRef = useRef<HTMLDivElement>(null);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(true);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mq.matches);
const handler = () => setPrefersReducedMotion(mq.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
// Trigger animation on mount
container.style.animation = 'none';
// Force reflow
void container.offsetHeight;
container.style.animation = 'pageEnter 0.3s cubic-bezier(0.32, 0.72, 0, 1) forwards';
}, [children]);
return (
<div ref={containerRef} className="page-transition">
{children}
</div>
);
// ViewTransitions handles page transitions - no additional wrapper needed
return <>{children}</>;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { ViewTransitionProvider } from '@/components/view-transition-provider';
import Template from '@/app/template';
export function AppWrapper({ children }: { children: React.ReactNode }) {
return (
<ViewTransitionProvider>
<Template>
{children}
</Template>
</ViewTransitionProvider>
);
}

View File

@@ -28,7 +28,7 @@ export function BackToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
aria-label="回到頁面頂部"
className="fixed bottom-6 right-4 z-40 inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-slate-50 shadow-md ring-1 ring-slate-800/70 transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:ring-slate-300/70 dark:hover:bg-slate-300"
className="fixed bottom-6 right-4 z-40 inline-flex h-9 w-9 items-center justify-center rounded-full bg-accent text-white shadow-lg ring-2 ring-accent/30 transition-all duration-300 ease-out-expo hover:-translate-y-1 hover:shadow-xl focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-accent/40 dark:bg-accent dark:ring-accent/30 dark:hover:bg-accent/90"
>
<span className="text-lg leading-none"></span>
</button>

View File

@@ -0,0 +1,97 @@
'use client';
import { SiArchlinux, SiUbuntu, SiLinux } from 'react-icons/si';
/**
* Mac mini + 螢幕 3D 裝置展示
* 使用純 CSS 3D transforms取代開發工作環境頁的 feature_image
*/
export function DevEnvDeviceHero() {
return (
<div
className="dev-env-device-hero -mx-4 mb-6 flex justify-center py-4 sm:-mx-12 sm:py-6 lg:-mx-20 lg:py-8 group-[.toc-open]:lg:-mx-4"
role="img"
aria-label="Mac mini、鍵盤與外接螢幕的 3D 裝置展示"
>
<div className="dev-env-device-scene">
{/* Monitor */}
<div className="dev-env-monitor">
{/* Bezel */}
<div className="dev-env-bezel">
{/* Screen */}
<div className="dev-env-screen">
{/* macOS Desktop mockup */}
<div className="dev-env-desktop">
{/* macOS Menu bar - 半透明毛玻璃 */}
<div className="dev-env-menubar">
<span className="dev-env-apple" aria-hidden>{'\uF8FF'}</span>
<span className="dev-env-app-name">Terminal</span>
<span className="dev-env-spacer" />
<span className="dev-env-menubar-right">
<span className="dev-env-menubar-icon" aria-hidden />
<span className="dev-env-menubar-icon" aria-hidden />
<span className="dev-env-menubar-icon" aria-hidden />
<span className="dev-env-time">14:30</span>
</span>
</div>
{/* Window - Terminal 顯示 Arch / Ubuntu / Tux 三個 Logo */}
<div className="dev-env-window">
<div className="dev-env-window-titlebar">
<span className="dev-env-traffic-light dev-env-traffic-red" aria-hidden />
<span className="dev-env-traffic-light dev-env-traffic-yellow" aria-hidden />
<span className="dev-env-traffic-light dev-env-traffic-green" aria-hidden />
</div>
<div className="dev-env-window-content">
<div className="dev-env-terminal-prompt">
<span className="dev-env-prompt">$</span> neofetch --ascii_distro arch,ubuntu,tux
</div>
<div className="dev-env-terminal-logos">
<div className="dev-env-logo-svg" aria-label="Arch Linux logo">
<SiArchlinux className="dev-env-svg-arch" size={36} />
</div>
<div className="dev-env-logo-svg" aria-label="Ubuntu logo">
<SiUbuntu className="dev-env-svg-ubuntu" size={36} />
</div>
<div className="dev-env-logo-svg" aria-label="Tux Linux penguin logo">
<SiLinux className="dev-env-svg-tux" size={36} />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Monitor stand */}
<div className="dev-env-stand" />
</div>
{/* Desk surface - Mac mini 與鍵盤均勻放置 */}
<div className="dev-env-desk">
{/* 鍵盤 - Magic Keyboard 風格,鍵帽網格 */}
<div className="dev-env-keyboard">
<div className="dev-env-keyboard-body">
<div className="dev-env-keyboard-keys">
{[14, 14, 13, 12].map((keyCount, row) => (
<div key={row} className="dev-env-keyboard-row">
{Array.from({ length: keyCount }).map((_, col) => (
<div key={col} className="dev-env-key" />
))}
</div>
))}
<div className="dev-env-keyboard-row dev-env-keyboard-row-space">
<div className="dev-env-key dev-env-key-space" />
</div>
</div>
</div>
</div>
{/* Mac mini M4 2024 - 頂視,避免 3D 偽影 */}
<div className="dev-env-macmini">
<div className="dev-env-macmini-top">
<span className="dev-env-macmini-apple" aria-hidden>{'\uF8FF'}</span>
</div>
</div>
</div>
</div>
</div>
);
}

117
components/hero-section.tsx Normal file
View File

@@ -0,0 +1,117 @@
'use client';
import { useState, useEffect } from 'react';
import { MatrixRain } from './matrix-rain';
import { TerminalWindow } from './terminal-window';
interface HeroSectionProps {
title: string;
tagline: string;
}
type Phase = 'matrix' | 'transition' | 'terminal';
const MIN_MATRIX_DURATION = 1500;
const MAX_MATRIX_DURATION = 6000;
const TRANSITION_DURATION = 600;
export function HeroSection({ title, tagline }: HeroSectionProps) {
const [phase, setPhase] = useState<Phase>('matrix');
const [matrixOpacity, setMatrixOpacity] = useState(1);
const [terminalOpacity, setTerminalOpacity] = useState(0);
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setReducedMotion(mq.matches);
}, []);
const handleMatrixComplete = () => {
setPhase('transition');
setMatrixOpacity(0);
setTerminalOpacity(1);
};
useEffect(() => {
if (phase !== 'matrix') return;
const startTime = Date.now();
let maxTimerId: ReturnType<typeof setTimeout>;
let minTimerId: ReturnType<typeof setTimeout>;
const scheduleTransition = () => {
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, MIN_MATRIX_DURATION - elapsed);
if (remaining > 0) {
minTimerId = setTimeout(handleMatrixComplete, remaining);
} else {
handleMatrixComplete();
}
};
const onLoad = () => {
window.removeEventListener('load', onLoad);
clearTimeout(maxTimerId);
scheduleTransition();
};
if (document.readyState === 'complete') {
scheduleTransition();
} else {
window.addEventListener('load', onLoad);
maxTimerId = setTimeout(() => {
window.removeEventListener('load', onLoad);
handleMatrixComplete();
}, MAX_MATRIX_DURATION);
}
return () => {
window.removeEventListener('load', onLoad);
clearTimeout(maxTimerId);
clearTimeout(minTimerId);
};
}, [phase]);
useEffect(() => {
if (phase === 'transition') {
const id = setTimeout(() => setPhase('terminal'), TRANSITION_DURATION);
return () => clearTimeout(id);
}
}, [phase]);
// Skip Matrix entirely if user prefers reduced motion
useEffect(() => {
if (reducedMotion) {
setPhase('terminal');
setMatrixOpacity(0);
setTerminalOpacity(1);
}
}, [reducedMotion]);
return (
<div className="relative h-[360px] w-full overflow-hidden rounded-2xl sm:h-[400px] lg:h-[440px] xl:h-[480px]">
{/* Matrix rain - full area, fades out */}
{!reducedMotion && (
<div
className="absolute inset-0 transition-opacity duration-[600ms] ease-out"
style={{ opacity: matrixOpacity }}
aria-hidden="true"
>
<MatrixRain className="h-full w-full" />
</div>
)}
{/* Terminal - fades in over Matrix, responsive width */}
<div
className="relative z-10 mx-auto w-full max-w-2xl px-4 py-6 transition-opacity duration-[600ms] ease-out sm:max-w-3xl lg:max-w-4xl xl:max-w-5xl"
style={{ opacity: reducedMotion ? 1 : terminalOpacity }}
>
<TerminalWindow
title={title}
tagline={tagline}
reducedMotion={reducedMotion}
/>
</div>
</div>
);
}

View File

@@ -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;
@@ -59,9 +52,7 @@ export function Hero() {
}[];
return (
<section className="motion-card group relative mb-8 overflow-hidden rounded-xl border bg-gradient-to-r from-sky-50 via-indigo-50 to-slate-50 px-6 py-6 shadow-sm dark:border-slate-800 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900">
<div className="pointer-events-none absolute -left-16 -top-16 h-40 w-40 rounded-full bg-sky-300/40 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-sky-500/25" />
<div className="pointer-events-none absolute -bottom-20 right-[-3rem] h-44 w-44 rounded-full bg-indigo-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/25" />
<section className="motion-card group relative mb-8 overflow-hidden rounded-xl border bg-accent-soft px-6 py-6 shadow-sm dark:border-slate-800">
<div className="relative flex items-center gap-4 motion-safe:animate-fade-in-up">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-xl font-semibold text-slate-50 shadow-md transition-transform duration-300 ease-out group-hover:scale-105 dark:bg-slate-100 dark:text-slate-900">
@@ -73,7 +64,7 @@ export function Hero() {
{name}
</h1>
<div className="mt-1">
<MetaItem icon={faPenNib}>
<MetaItem icon={FiFeather}>
{tagline}
</MetaItem>
</div>
@@ -87,7 +78,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"
>
<FontAwesomeIcon icon={item.icon} className="h-3.5 w-3.5 text-accent" />
<item.icon className="h-3.5 w-3.5 text-accent" />
<span>{item.label}</span>
</a>
))}

View File

@@ -0,0 +1,78 @@
'use client';
import { SiTruenas, SiProxmox } from 'react-icons/si';
import { FiServer } from 'react-icons/fi';
/**
* HomeLab 設備展示Proxmox VE + VyOS、Switch、NAS (TrueNAS)
* 使用純 CSS 藝術,取代 HomeLab 頁的 feature_image
*/
export function HomeLabDeviceHero() {
return (
<div
className="homelab-device-hero -mx-4 mb-6 flex justify-center py-4 sm:-mx-12 sm:py-6 lg:-mx-20 lg:py-8 group-[.toc-open]:lg:-mx-4"
role="img"
aria-label="HomeLab 設備Proxmox VE、VyOS、交換器、NAS (TrueNAS)"
>
<div className="homelab-device-scene w-full max-w-full">
<div className="homelab-rack">
{/* Proxmox VE + VyOS Host */}
<div className="homelab-router">
<div className="homelab-router-body">
<div className="homelab-router-leds">
<span className="homelab-led homelab-led-power" aria-hidden />
<span className="homelab-led homelab-led-wan" aria-hidden />
<span className="homelab-led homelab-led-lan" aria-hidden />
</div>
<div className="homelab-router-logos">
<SiProxmox className="homelab-proxmox-logo homelab-logo-svg" aria-label="Proxmox VE" />
<FiServer className="homelab-router-icon homelab-logo-svg" aria-label="VyOS Router" />
</div>
</div>
</div>
{/* 網路線 - 連接 Proxmox 與 Switch */}
<div className="homelab-cable" aria-hidden>
<span className="homelab-cable-line" />
</div>
{/* Switch */}
<div className="homelab-switch">
<div className="homelab-switch-body">
<div className="homelab-switch-ports">
{[1, 2].map((row) => (
<div key={row} className="homelab-port-row">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="homelab-port">
<span className="homelab-port-led homelab-port-led-active" aria-hidden />
</div>
))}
</div>
))}
</div>
</div>
</div>
{/* 網路線 - 連接 Switch 與 NAS */}
<div className="homelab-cable" aria-hidden>
<span className="homelab-cable-line" />
</div>
{/* NAS - TrueNAS */}
<div className="homelab-nas">
<div className="homelab-nas-body">
<div className="homelab-nas-drives">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="homelab-drive-slot" aria-hidden />
))}
</div>
<div className="homelab-nas-logo" aria-label="TrueNAS logo">
<SiTruenas className="homelab-truenas-logo" size={28} />
</div>
</div>
</div>
</div>
</div>
</div>
);
}

12
components/json-ld.tsx Normal file
View File

@@ -0,0 +1,12 @@
/**
* JSON-LD component for rendering structured data
* Safely serializes and injects Schema.org structured data into the page
*/
export function JsonLd({ data }: { data: Record<string, any> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}

View File

@@ -1,12 +1,23 @@
'use client';
import { SiteHeader } from './site-header';
import { SiteFooter } from './site-footer';
import dynamic from 'next/dynamic';
import { BackToTop } from './back-to-top';
// Lazy load BackToTop since it's not critical for initial render
const BackToTop = dynamic(() => import('./back-to-top').then(mod => ({ default: mod.BackToTop })), {
ssr: false,
});
export function LayoutShell({ children }: { children: React.ReactNode }) {
interface LayoutShellProps {
children: React.ReactNode;
recentPosts?: { title: string; url: string }[];
}
export function LayoutShell({ children, recentPosts = [] }: LayoutShellProps) {
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<SiteHeader recentPosts={recentPosts} />
<main className="flex-1 container mx-auto px-4 py-6">
{children}
</main>

View File

@@ -1,9 +1,8 @@
'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,
@@ -76,10 +75,7 @@ export function MastodonFeed() {
<section className="motion-card group rounded-xl border bg-white px-4 py-3 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-900/90">
{/* Header */}
<div className="type-small mb-3 flex items-center gap-2 font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
<FontAwesomeIcon
icon={faMastodon}
className="h-4 w-4 text-purple-500 dark:text-purple-400"
/>
<FaMastodon className="h-4 w-4 text-purple-500 dark:text-purple-400" />
</div>
@@ -87,10 +83,19 @@ export function MastodonFeed() {
{loading ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-3 w-3/4 rounded bg-slate-200 dark:bg-slate-800"></div>
<div className="mt-2 h-3 w-full rounded bg-slate-200 dark:bg-slate-800"></div>
<div className="mt-2 h-2 w-1/3 rounded bg-slate-200 dark:bg-slate-800"></div>
<div key={i}>
<div
className="mastodon-skeleton-shimmer h-3 w-3/4 rounded"
style={{ animationDelay: `${i * 120}ms` }}
/>
<div
className="mastodon-skeleton-shimmer mt-2 h-3 w-full rounded"
style={{ animationDelay: `${i * 120}ms` }}
/>
<div
className="mastodon-skeleton-shimmer mt-2 h-2 w-1/3 rounded"
style={{ animationDelay: `${i * 120}ms` }}
/>
</div>
))}
</div>
@@ -119,23 +124,93 @@ export function MastodonFeed() {
{/* Boost indicator */}
{status.reblog && (
<div className="type-small flex items-center gap-1 text-slate-400 dark:text-slate-500">
<FontAwesomeIcon
icon={faArrowRight}
className="h-2.5 w-2.5 rotate-90"
/>
<FiArrowRight className="h-2.5 w-2.5 rotate-90" />
<span></span>
</div>
)}
{/* Content */}
<p className="text-sm leading-relaxed text-slate-700 dark:text-slate-200">
<p className="whitespace-pre-line text-sm leading-relaxed text-slate-700 dark:text-slate-200">
{truncated}
</p>
{/* Media indicator */}
{/* Media attachments - render images/videos from remote URLs */}
{hasMedia && (
<div className="type-small text-slate-400 dark:text-slate-500">
📎 {displayStatus.media_attachments.length}
<div
className={`mt-1.5 grid gap-1 ${
displayStatus.media_attachments.length === 1
? 'grid-cols-1'
: 'grid-cols-2'
}`}
>
{displayStatus.media_attachments.map((att) => {
const src = att.preview_url ?? att.url;
if (!src) return null;
if (att.type === 'image') {
return (
<img
key={att.id}
src={src}
alt={att.description ?? ''}
loading="lazy"
className="aspect-video w-full rounded-md object-cover"
/>
);
}
if (att.type === 'gifv' && att.url) {
return (
<div
key={att.id}
className="overflow-hidden rounded-md"
onClick={(e) => e.stopPropagation()}
>
<video
src={att.url}
poster={att.preview_url ?? undefined}
autoPlay
loop
muted
playsInline
className="aspect-video w-full object-cover"
/>
</div>
);
}
if (att.type === 'video' && att.url) {
return (
<div
key={att.id}
className="overflow-hidden rounded-md"
onClick={(e) => e.stopPropagation()}
>
<video
src={att.url}
poster={att.preview_url ?? undefined}
controls
playsInline
className="aspect-video w-full object-cover"
/>
</div>
);
}
if (att.type === 'audio' && att.preview_url) {
return (
<div
key={att.id}
className="flex aspect-video w-full items-center justify-center rounded-md bg-slate-200 dark:bg-slate-700"
>
<img
src={att.preview_url}
alt={att.description ?? '音訊'}
loading="lazy"
className="h-full w-full object-cover opacity-80"
/>
</div>
);
}
return null;
})}
</div>
)}
@@ -162,7 +237,7 @@ export function MastodonFeed() {
className="type-small mt-3 flex items-center justify-end gap-1.5 text-slate-500 transition-colors hover:text-accent-textLight dark:text-slate-400 dark:hover:text-accent-textDark"
>
<FontAwesomeIcon icon={faArrowRight} className="h-3 w-3" />
<FiArrowRight className="h-3 w-3" />
</a>
)}
</section>

147
components/matrix-rain.tsx Normal file
View File

@@ -0,0 +1,147 @@
'use client';
import { useEffect, useRef } from 'react';
// Matrix-style characters: katakana, numbers, Latin
const CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
interface MatrixRainProps {
/** Opacity 0-1 for fade out control */
opacity?: number;
className?: string;
}
interface Drop {
x: number;
y: number;
speed: number;
chars: string[];
charIndex: number;
}
export function MatrixRain({
opacity = 1,
className = '',
}: MatrixRainProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const resize = () => {
const rect = canvas.getBoundingClientRect();
// Calculate DPR safely - use 1 as fallback
const dpr = (typeof window !== 'undefined' && 'devicePixelRatio' in window)
? Math.min(window.devicePixelRatio ?? 1, 2)
: 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
};
const handleResize = () => {
// Use requestAnimationFrame for smoother resizing
requestAnimationFrame(() => {
const rect = canvasRef.current?.getBoundingClientRect();
if (rect) {
const dpr = (typeof window !== 'undefined' && 'devicePixelRatio' in window)
? Math.min(window.devicePixelRatio ?? 1, 2)
: 1;
canvasRef.current!.width = rect.width * dpr;
canvasRef.current!.height = rect.height * dpr;
if (ctx) {
ctx.scale(dpr, dpr);
}
canvasRef.current!.style.width = `${rect.width}px`;
canvasRef.current!.style.height = `${rect.height}px`;
}
});
};
resize();
window.addEventListener('resize', handleResize, { passive: true, signal: AbortSignal.timeout(60000) });
const fontSize = 14;
const columns = Math.floor(canvas.getBoundingClientRect().width / fontSize);
const drops: Drop[] = Array.from({ length: columns }, (_, i) => ({
x: i * fontSize,
y: Math.random() * -100,
speed: 0.15 + Math.random() * 0.4,
chars: Array.from({ length: 20 }, () =>
CHARS[Math.floor(Math.random() * CHARS.length)]
),
charIndex: Math.floor(Math.random() * 20),
}));
let animationId: number;
let lastTime: number | null = null;
const draw = (timestamp: number) => {
const rect = canvas.getBoundingClientRect();
const delta =
lastTime !== null ? (timestamp - lastTime) / 1000 : 1 / 60;
lastTime = timestamp;
ctx.fillStyle = 'rgba(15, 23, 42, 0.05)';
ctx.fillRect(0, 0, rect.width, rect.height);
ctx.font = `${fontSize}px "JetBrains Mono", "SF Mono", "Fira Code", monospace`;
drops.forEach((drop) => {
// Bright green for leading char
ctx.fillStyle = 'rgba(34, 197, 94, 1)';
ctx.fillText(drop.chars[drop.charIndex], drop.x, drop.y);
// Dimmer trailing chars
for (let i = 1; i < 8; i++) {
const idx = (drop.charIndex - i + 20) % 20;
const alpha = 1 - i * 0.12;
ctx.fillStyle = `rgba(34, 197, 94, ${alpha * 0.4})`;
ctx.fillText(
drop.chars[idx],
drop.x,
drop.y - i * fontSize
);
}
// Frame-rate independent: scale by delta, 60fps as baseline
drop.y += drop.speed * fontSize * delta * 60;
if (drop.y > rect.height + 100) {
drop.y = -50;
drop.charIndex = (drop.charIndex + 1) % 20;
} else {
drop.charIndex = (drop.charIndex + 1) % 20;
}
});
animationId = requestAnimationFrame(draw);
};
animationId = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', handleResize);
};
}, []);
return (
<canvas
ref={canvasRef}
className={className}
style={{
opacity,
transition: 'opacity 0.6s ease-out',
background: 'rgb(15, 23, 42)',
}}
aria-hidden="true"
role="img"
/>
);
}

View File

@@ -0,0 +1,312 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { useTheme } from 'next-themes';
const ZOOM_STEP = 0.2;
const ZOOM_MIN = 0.25;
const ZOOM_MAX = 5;
const WHEEL_ZOOM_FACTOR = 0.001;
interface ViewState {
scale: number;
x: number;
y: number;
}
function clampScale(s: number) {
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, s));
}
function attachViewer(wrapper: HTMLDivElement, viewport: HTMLDivElement) {
const state: ViewState = { scale: 1, x: 0, y: 0 };
let dragging = false;
let dragStart = { x: 0, y: 0 };
let originAtDragStart = { x: 0, y: 0 };
// --- Pinch state ---
let lastPinchDist = 0;
let lastPinchCenter = { x: 0, y: 0 };
let pinching = false;
const levelBtn = wrapper.querySelector<HTMLButtonElement>('.mermaid-zoom-level')!;
const apply = () => {
viewport.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.scale})`;
levelBtn.textContent = `${Math.round(state.scale * 100)}%`;
};
const zoomTo = (newScale: number, cx: number, cy: number) => {
const clamped = clampScale(newScale);
const rect = viewport.getBoundingClientRect();
const wrapRect = wrapper.querySelector<HTMLElement>('.mermaid-canvas')!.getBoundingClientRect();
// Point under cursor in viewport-local coords
const px = cx - wrapRect.left;
const py = cy - wrapRect.top;
// Adjust translate so the point under cursor stays put
const ratio = clamped / state.scale;
state.x = px - ratio * (px - state.x);
state.y = py - ratio * (py - state.y);
state.scale = clamped;
apply();
};
// --- Mouse drag ---
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
dragging = true;
dragStart = { x: e.clientX, y: e.clientY };
originAtDragStart = { x: state.x, y: state.y };
wrapper.classList.add('mermaid-grabbing');
e.preventDefault();
};
const onMouseMove = (e: MouseEvent) => {
if (!dragging) return;
state.x = originAtDragStart.x + (e.clientX - dragStart.x);
state.y = originAtDragStart.y + (e.clientY - dragStart.y);
apply();
};
const onMouseUp = () => {
dragging = false;
wrapper.classList.remove('mermaid-grabbing');
};
// --- Wheel zoom ---
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = -e.deltaY * WHEEL_ZOOM_FACTOR;
const newScale = clampScale(state.scale * (1 + delta * state.scale));
zoomTo(newScale, e.clientX, e.clientY);
};
// --- Touch: pinch-to-zoom + drag ---
const pinchDist = (t: TouchList) => {
const dx = t[0].clientX - t[1].clientX;
const dy = t[0].clientY - t[1].clientY;
return Math.hypot(dx, dy);
};
const pinchCenter = (t: TouchList) => ({
x: (t[0].clientX + t[1].clientX) / 2,
y: (t[0].clientY + t[1].clientY) / 2,
});
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
pinching = true;
lastPinchDist = pinchDist(e.touches);
lastPinchCenter = pinchCenter(e.touches);
e.preventDefault();
} else if (e.touches.length === 1) {
dragging = true;
dragStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
originAtDragStart = { x: state.x, y: state.y };
}
};
const onTouchMove = (e: TouchEvent) => {
if (pinching && e.touches.length === 2) {
e.preventDefault();
const dist = pinchDist(e.touches);
const center = pinchCenter(e.touches);
const ratio = dist / lastPinchDist;
zoomTo(state.scale * ratio, center.x, center.y);
lastPinchDist = dist;
lastPinchCenter = center;
} else if (dragging && e.touches.length === 1) {
state.x = originAtDragStart.x + (e.touches[0].clientX - dragStart.x);
state.y = originAtDragStart.y + (e.touches[0].clientY - dragStart.y);
apply();
}
};
const onTouchEnd = (e: TouchEvent) => {
if (e.touches.length < 2) pinching = false;
if (e.touches.length === 0) dragging = false;
};
// --- Canvas element (the pannable area) ---
const canvas = wrapper.querySelector<HTMLElement>('.mermaid-canvas')!;
canvas.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('touchstart', onTouchStart, { passive: false });
canvas.addEventListener('touchmove', onTouchMove, { passive: false });
canvas.addEventListener('touchend', onTouchEnd);
// --- Button handlers ---
wrapper.querySelector('.mermaid-btn-zoomout')!.addEventListener('click', () => {
const rect = canvas.getBoundingClientRect();
zoomTo(state.scale - ZOOM_STEP, rect.left + rect.width / 2, rect.top + rect.height / 2);
});
wrapper.querySelector('.mermaid-btn-zoomin')!.addEventListener('click', () => {
const rect = canvas.getBoundingClientRect();
zoomTo(state.scale + ZOOM_STEP, rect.left + rect.width / 2, rect.top + rect.height / 2);
});
levelBtn.addEventListener('click', () => {
state.scale = 1;
state.x = 0;
state.y = 0;
apply();
});
wrapper.querySelector('.mermaid-btn-fit')!.addEventListener('click', () => {
const svg = viewport.querySelector('svg');
if (!svg) return;
const canvasRect = canvas.getBoundingClientRect();
const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width / state.scale;
const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height / state.scale;
const padding = 32;
const fitScale = Math.min(
(canvasRect.width - padding) / svgW,
(canvasRect.height - padding) / svgH,
ZOOM_MAX
);
state.scale = clampScale(fitScale);
state.x = 0;
state.y = 0;
apply();
});
wrapper.querySelector('.mermaid-btn-fullscreen')!.addEventListener('click', () => {
if (document.fullscreenElement === wrapper) {
document.exitFullscreen();
} else {
wrapper.requestFullscreen().catch(() => {});
}
});
// Cleanup
return () => {
canvas.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
canvas.removeEventListener('wheel', onWheel);
canvas.removeEventListener('touchstart', onTouchStart);
canvas.removeEventListener('touchmove', onTouchMove);
canvas.removeEventListener('touchend', onTouchEnd);
};
}
function buildShell(): { wrapper: HTMLDivElement; viewport: HTMLDivElement } {
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram';
const canvas = document.createElement('div');
canvas.className = 'mermaid-canvas';
const viewport = document.createElement('div');
viewport.className = 'mermaid-viewport';
canvas.appendChild(viewport);
// Toolbar
const bar = document.createElement('div');
bar.className = 'mermaid-zoom-bar';
const btnZoomOut = document.createElement('button');
btnZoomOut.className = 'mermaid-zoom-btn mermaid-btn-zoomout';
btnZoomOut.textContent = '';
btnZoomOut.ariaLabel = '縮小';
const btnLevel = document.createElement('button');
btnLevel.className = 'mermaid-zoom-btn mermaid-zoom-level';
btnLevel.textContent = '100%';
btnLevel.ariaLabel = '重置';
const btnZoomIn = document.createElement('button');
btnZoomIn.className = 'mermaid-zoom-btn mermaid-btn-zoomin';
btnZoomIn.textContent = '+';
btnZoomIn.ariaLabel = '放大';
const sep1 = document.createElement('span');
sep1.className = 'mermaid-sep';
const btnFit = document.createElement('button');
btnFit.className = 'mermaid-zoom-btn mermaid-btn-fit';
btnFit.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="12" height="12" rx="2"/><path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4"/></svg>';
btnFit.ariaLabel = '適合畫面';
const btnFullscreen = document.createElement('button');
btnFullscreen.className = 'mermaid-zoom-btn mermaid-btn-fullscreen';
btnFullscreen.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4"/></svg>';
btnFullscreen.ariaLabel = '全螢幕';
bar.append(btnZoomOut, btnLevel, btnZoomIn, sep1, btnFit, btnFullscreen);
wrapper.append(canvas, bar);
return { wrapper, viewport };
}
export function MermaidRenderer() {
const { resolvedTheme } = useTheme();
const containersRef = useRef<{ viewport: HTMLDivElement; wrapper: HTMLDivElement; source: string }[]>([]);
const cleanupRef = useRef<(() => void)[]>([]);
const renderDiagrams = useCallback(async () => {
if (containersRef.current.length === 0) return;
// Clean up previous event listeners
cleanupRef.current.forEach((fn) => fn());
cleanupRef.current = [];
const mermaid = (await import('mermaid')).default;
const theme = resolvedTheme === 'dark' ? 'dark' : 'default';
mermaid.initialize({
startOnLoad: false,
theme,
fontFamily: 'inherit',
});
for (const { viewport, wrapper, source } of containersRef.current) {
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
try {
const { svg } = await mermaid.render(id, source);
viewport.innerHTML = svg;
wrapper.classList.add('mermaid-rendered');
const cleanup = attachViewer(wrapper, viewport);
cleanupRef.current.push(cleanup);
} catch {
viewport.textContent = source;
}
}
}, [resolvedTheme]);
useEffect(() => {
const figures = document.querySelectorAll<HTMLElement>(
'figure[data-rehype-pretty-code-figure]'
);
const entries: typeof containersRef.current = [];
figures.forEach((figure) => {
const code = figure.querySelector('code[data-language="mermaid"]');
if (!code) return;
const source = code.textContent?.trim() ?? '';
if (!source) return;
const { wrapper, viewport } = buildShell();
figure.replaceWith(wrapper);
entries.push({ viewport, wrapper, source });
});
containersRef.current = entries;
renderDiagrams();
return () => {
cleanupRef.current.forEach((fn) => fn());
cleanupRef.current = [];
};
}, [renderDiagrams]);
return null;
}

View File

@@ -1,25 +1,24 @@
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 (
<span
className={clsx(
'inline-flex items-center gap-1.5 text-xs transition-colors duration-180 ease-snappy',
tone === 'muted' ? 'text-slate-500 dark:text-slate-400' : 'text-slate-600 dark:text-slate-200',
className
)}
className={clsx(
'inline-flex items-center gap-1.5 text-xs transition-colors duration-180 ease-snappy',
tone === 'muted' ? 'text-slate-500 dark:text-slate-400' : 'text-slate-600 dark:text-slate-200',
className
)}
>
<FontAwesomeIcon icon={icon} className="h-3.5 w-3.5 text-slate-400 dark:text-slate-500" />
<Icon className="h-3.5 w-3.5 text-slate-400 dark:text-slate-500" />
<span>{children}</span>
</span>
);

View File

@@ -0,0 +1,20 @@
'use client';
import { useState, useEffect, ReactNode } from 'react';
import Link from 'next/link';
export function NativeLink({ href, children, ...props }: { href: string; children: ReactNode; [key: string]: any }) {
const [isSafari18, setIsSafari18] = useState(false);
useEffect(() => {
const isSafari = typeof navigator !== 'undefined' && navigator.userAgent.includes('safari') && !navigator.userAgent.includes('chrome') && !navigator.userAgent.includes('firefox');
const hasNativeTransitions = typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
setIsSafari18(isSafari && hasNativeTransitions);
}, []);
if (isSafari18) {
return <a href={href} {...props}>{children}</a>;
}
return <Link href={href} {...props}>{children}</Link>;
}

View File

@@ -1,23 +1,26 @@
'use client';
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useState, useRef, FocusEvent, useEffect } from 'react';
import { createPortal } from 'react-dom';
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,
FiChevronDown,
FiChevronRight
} from 'react-icons/fi';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export type IconKey =
| 'home'
@@ -33,24 +36,25 @@ export type IconKey =
| 'menu';
const ICON_MAP: Record<IconKey, any> = {
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 {
key: string;
href: string;
label?: string;
href?: string;
label: string;
iconKey: IconKey;
children?: NavLinkItem[];
}
interface NavMenuProps {
@@ -59,40 +63,243 @@ interface NavMenuProps {
export function NavMenu({ items }: NavMenuProps) {
const [open, setOpen] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
const [expandedMobileItems, setExpandedMobileItems] = useState<string[]>([]);
const [mounted, setMounted] = useState(false);
const closeTimer = useRef<number | null>(null);
const pathname = usePathname();
useEffect(() => {
setMounted(true);
}, []);
// Lock body scroll when menu is open
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
// Close menu on route change
useEffect(() => {
setOpen(false);
}, [pathname]);
const toggle = () => setOpen((val) => !val);
const close = () => setOpen(false);
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
setActiveDropdown(null);
}
};
const clearCloseTimer = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const openDropdown = (key: string) => {
clearCloseTimer();
setActiveDropdown(key);
};
const scheduleCloseDropdown = () => {
clearCloseTimer();
closeTimer.current = window.setTimeout(() => setActiveDropdown(null), 180);
};
const toggleMobileItem = (key: string) => {
setExpandedMobileItems(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const renderDesktopChild = (item: NavLinkItem) => {
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
return item.href ? (
<Link
key={item.key}
href={item.href}
className="motion-link inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-accent"
onClick={close}
>
<Icon className="h-4 w-4 shrink-0 text-slate-400" />
<span className="whitespace-nowrap">{item.label}</span>
</Link>
) : null;
};
const renderMobileItem = (item: NavLinkItem, depth = 0) => {
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedMobileItems.includes(item.key);
if (hasChildren) {
return (
<div key={item.key} className="flex flex-col">
<button
onClick={() => toggleMobileItem(item.key)}
className="flex w-full items-center justify-between rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800 dark:hover:text-accent"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 shrink-0 text-slate-400" />
<span className="whitespace-nowrap">{item.label}</span>
</div>
<FiChevronRight
className={`h-5 w-5 text-slate-400 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
<div
className={`grid transition-all duration-200 ease-in-out ${isExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`}
>
<div className="overflow-hidden">
<div className="flex flex-col gap-1 pl-4 pt-1">
{item.children!.map(child => renderMobileItem(child, depth + 1))}
</div>
</div>
</div>
</div>
);
}
return item.href ? (
<Link
key={item.key}
href={item.href}
className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800 dark:hover:text-accent"
onClick={close}
>
<Icon className="h-5 w-5 shrink-0 text-slate-400" />
<span className="whitespace-nowrap">{item.label}</span>
</Link>
) : null;
};
return (
<div className="flex items-center gap-3">
<>
{/* Mobile Menu Trigger */}
<button
type="button"
className="sm:hidden inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition duration-180 ease-snappy hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
className="relative z-50 inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-accent sm:hidden"
aria-label={open ? '關閉選單' : '開啟選單'}
aria-expanded={open}
onClick={toggle}
>
<FontAwesomeIcon icon={open ? faXmark : faBars} className="h-4 w-4" />
<div className="relative h-5 w-5">
<span
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? 'rotate-45' : '-translate-y-1.5'
}`}
/>
<span
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? 'opacity-0' : 'opacity-100'
}`}
/>
<span
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? '-rotate-45' : 'translate-y-1.5'
}`}
/>
</div>
</button>
<nav
className={`${open ? 'flex' : 'hidden'} flex-col gap-2 sm:flex sm:flex-row sm:items-center sm:gap-3`}
>
{items.map((item) => (
<Link
key={item.key}
href={item.href}
className="motion-link type-nav group relative inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
onClick={close}
>
<FontAwesomeIcon
icon={ICON_MAP[item.iconKey] ?? faFileLines}
className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent"
/>
<span>{item.label}</span>
<span className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-accent transition duration-180 ease-snappy group-hover:scale-x-100" aria-hidden="true" />
</Link>
))}
{/* Mobile Menu Overlay - Portaled */}
{mounted && createPortal(
<div
className={`fixed inset-0 z-[100] flex flex-col bg-white/95 backdrop-blur-xl transition-all duration-300 ease-snappy dark:bg-gray-950/95 sm:hidden ${open ? 'visible opacity-100' : 'invisible opacity-0 pointer-events-none'
}`}
>
{/* Close button area */}
<div className="flex items-center justify-end px-4 py-3">
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-accent"
onClick={close}
aria-label="Close menu"
>
<div className="relative h-5 w-5">
<span className="absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 rotate-45 bg-current" />
<span className="absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 -rotate-45 bg-current" />
</div>
</button>
</div>
<div className="container mx-auto flex flex-1 flex-col px-4 pb-8">
<div className="flex flex-1 flex-col gap-2 overflow-y-auto pt-4">
{items.map(item => renderMobileItem(item))}
</div>
<div className="mt-auto pt-8 text-center text-xs text-slate-400">
<p>© {new Date().getFullYear()} All rights reserved.</p>
</div>
</div>
</div>,
document.body
)}
{/* Desktop Menu */}
<nav className="hidden sm:flex sm:items-center sm:gap-3">
{items.map((item) => {
if (item.children && item.children.length > 0) {
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
const isOpen = activeDropdown === item.key;
return (
<div
key={item.key}
className="group relative"
onMouseEnter={() => openDropdown(item.key)}
onMouseLeave={scheduleCloseDropdown}
onFocus={() => openDropdown(item.key)}
onBlur={handleBlur}
>
<button
type="button"
className="motion-link type-nav inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:text-accent"
aria-haspopup="menu"
aria-expanded={isOpen}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-slate-400 transition group-hover:text-accent dark:group-hover:text-accent" />
<span className="whitespace-nowrap">{item.label}</span>
<FiChevronDown className="h-3 w-3 shrink-0 text-slate-400 transition group-hover:text-accent dark:group-hover:text-accent" />
</button>
<div
className={`absolute left-0 top-full z-50 hidden min-w-[12rem] rounded-2xl border border-slate-200 bg-white p-2 shadow-lg transition duration-200 ease-snappy dark:border-slate-800 dark:bg-slate-900 sm:block ${isOpen ? 'pointer-events-auto translate-y-2 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'
}`}
role="menu"
aria-label={item.label}
>
<div className="flex flex-col gap-1">
{item.children.map((child) => renderDesktopChild(child))}
</div>
</div>
</div>
);
}
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
return item.href ? (
<Link
key={item.key}
href={item.href}
className="motion-link type-nav group relative inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:text-accent"
onClick={close}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-slate-400 transition group-hover:text-accent" />
<span className="whitespace-nowrap">{item.label}</span>
<span className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-accent transition duration-180 ease-snappy group-hover:scale-x-100" aria-hidden="true" />
</Link>
) : null;
})}
</nav>
</div>
</>
);
}

View File

@@ -2,7 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Post } from 'contentlayer2/generated';
import { siteConfig } from '@/lib/config';
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons';
import { FiCalendar, FiTag } from 'react-icons/fi';
import { MetaItem } from './meta-item';
interface PostCardProps {
@@ -17,10 +17,10 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
: undefined;
return (
<article className="motion-card group relative overflow-hidden rounded-xl border bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
<article className="motion-card group relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-300 ease-snappy hover:-translate-y-1 hover:shadow-lg dark:border-slate-800 dark:bg-slate-900">
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 origin-left scale-x-0 bg-accent opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" />
{cover && (
<div className="relative w-full bg-slate-100 dark:bg-slate-800">
<div className="relative w-full bg-slate-100 dark:bg-slate-800 overflow-hidden">
<Image
src={cover}
alt={post.title}
@@ -28,35 +28,37 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
height={360}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
loading="lazy"
className="mx-auto max-h-60 w-full object-contain transition-transform duration-300 ease-out group-hover:scale-105"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R//2Q=="
className="mx-auto w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
/>
</div>
)}
<div className="space-y-3 px-4 py-4">
<div className="flex flex-wrap items-center gap-3 text-xs">
{post.published_at && (
<MetaItem icon={faCalendarDays}>
<MetaItem icon={FiCalendar}>
{new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale
)}
</MetaItem>
)}
{showTags && post.tags && post.tags.length > 0 && (
<MetaItem icon={faTags} tone="muted">
<MetaItem icon={FiTag} tone="muted">
{post.tags.slice(0, 3).join(', ')}
</MetaItem>
)}
</div>
<h2 className="text-lg font-semibold leading-snug">
<Link
<Link
href={post.url}
className="hover:text-blue-600 dark:hover:text-blue-400"
className="hover:text-accent dark:hover:text-accent"
>
{post.title}
</Link>
</h2>
{post.description && (
<p className="line-clamp-3 text-sm text-slate-700 dark:text-slate-100">
<p className="line-clamp-3 text-sm text-slate-600 dark:text-slate-300">
{post.description}
</p>
)}

View File

@@ -1,92 +1,148 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faListUl, faChevronRight, faChevronLeft } from '@fortawesome/free-solid-svg-icons';
import { PostToc } from './post-toc';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { FiList, FiX } from 'react-icons/fi';
import dynamic from 'next/dynamic';
import { cn } from '@/lib/utils';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Lazy load PostToc since it's not critical for initial render
const PostToc = dynamic(() => import('./post-toc').then(mod => ({ default: mod.PostToc })), {
ssr: false,
});
export function PostLayout({ children, hasToc = true, contentKey }: { children: React.ReactNode; hasToc?: boolean; contentKey?: string }) {
const [isTocOpen, setIsTocOpen] = useState(hasToc);
export function PostLayout({ children, hasToc = true, contentKey, wide }: { children: React.ReactNode; hasToc?: boolean; contentKey?: string; wide?: boolean }) {
const [isTocOpen, setIsTocOpen] = useState(false); // Default closed on mobile
const [isDesktopTocOpen, setIsDesktopTocOpen] = useState(hasToc); // Separate state for desktop
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Lock body scroll when mobile TOC is open
useEffect(() => {
if (isTocOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isTocOpen]);
const mobileToc = hasToc && mounted
? createPortal(
<>
{/* Backdrop */}
<div
className={cn(
"fixed inset-0 z-[1140] bg-black/40 backdrop-blur-sm transition-opacity duration-300 lg:hidden",
isTocOpen ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={() => setIsTocOpen(false)}
aria-hidden="true"
/>
{/* Drawer */}
<div
className={cn(
"fixed bottom-0 left-0 right-0 z-[1150] flex max-h-[85vh] flex-col rounded-t-2xl border-t border-white/20 bg-white/95 shadow-2xl backdrop-blur-xl transition-transform duration-300 ease-snappy dark:border-white/10 dark:bg-slate-900/95 lg:hidden",
isTocOpen ? "translate-y-0" : "translate-y-full"
)}
>
{/* Handle / Header */}
<div className="flex items-center justify-between border-b border-slate-200/50 px-6 py-4 dark:border-slate-700/50" onClick={() => setIsTocOpen(false)}>
<div className="flex items-center gap-2 font-semibold text-slate-900 dark:text-slate-100">
<FiList className="h-5 w-5 text-slate-500" />
<span></span>
</div>
<button
onClick={() => setIsTocOpen(false)}
className="rounded-full p-1 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<FiX className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-6">
<PostToc
contentKey={contentKey}
onLinkClick={() => setIsTocOpen(false)}
showTitle={false}
className="w-full"
/>
</div>
</div>
</>,
document.body
)
: null;
const tocButton = hasToc && mounted ? (
<button
onClick={() => setIsTocOpen(true)}
className={cn(
"fixed bottom-6 right-16 z-40 flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white/90 px-3 text-sm font-medium text-slate-600 shadow-md backdrop-blur-sm transition hover:bg-slate-50 hover:text-accent dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-accent lg:hidden",
isTocOpen ? "opacity-0 pointer-events-none" : "opacity-100"
)}
aria-label="Open Table of Contents"
>
<FiList className="h-4 w-4" />
<span></span>
</button>
) : null;
const desktopTocButton = hasToc && mounted ? (
<button
onClick={() => setIsDesktopTocOpen(!isDesktopTocOpen)}
className={cn(
"fixed bottom-6 right-16 z-40 hidden h-9 items-center gap-2 rounded-full border border-slate-200 bg-white/90 px-3 text-sm font-medium text-slate-600 shadow-md backdrop-blur-sm transition hover:bg-slate-50 hover:text-accent dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-accent lg:flex",
)}
aria-label={isDesktopTocOpen ? "Close Table of Contents" : "Open Table of Contents"}
>
<FiList className="h-4 w-4" />
<span>{isDesktopTocOpen ? '隱藏目錄' : '顯示目錄'}</span>
</button>
) : null;
return (
<div className="relative">
<div className={cn(
"group grid gap-8 transition-all duration-500 ease-snappy",
isTocOpen && hasToc ? "lg:grid-cols-[1fr_16rem] toc-open" : "lg:grid-cols-[1fr_0rem]"
isDesktopTocOpen && hasToc ? "lg:grid-cols-[1fr_16rem] toc-open" : "lg:grid-cols-[1fr_0rem]"
)}>
{/* Main Content Area */}
<div className="min-w-0">
<motion.div
layout
className={cn("mx-auto transition-all duration-500 ease-snappy", isTocOpen && hasToc ? "max-w-3xl" : "max-w-4xl")}
>
<div className={cn("mx-auto transition-all duration-500 ease-snappy", isDesktopTocOpen && hasToc ? "max-w-3xl" : wide ? "max-w-5xl" : "max-w-4xl")}>
{children}
</motion.div>
</div>
</div>
{/* Desktop Sidebar (TOC) */}
<aside className="hidden lg:block">
<div className="sticky top-24 h-[calc(100vh-6rem)] overflow-hidden">
<AnimatePresence mode="wait">
{isTocOpen && hasToc && (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3 }}
className="h-full overflow-y-auto pr-2"
>
<PostToc key={contentKey} />
</motion.div>
)}
</AnimatePresence>
{isDesktopTocOpen && hasToc && (
<div className="toc-sidebar h-full overflow-y-auto pr-2">
<PostToc contentKey={contentKey} />
</div>
)}
</div>
</aside>
</div>
{/* Mobile TOC Overlay */}
<AnimatePresence>
{isTocOpen && hasToc && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
className="fixed bottom-24 right-4 z-40 w-72 rounded-2xl border border-white/20 bg-white/90 p-6 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/90 lg:hidden"
>
<div className="max-h-[60vh] overflow-y-auto">
<PostToc key={contentKey} onLinkClick={() => setIsTocOpen(false)} />
</div>
</motion.div>
)}
</AnimatePresence>
{mobileToc}
{/* Toggle Button (Glassmorphism Pill) */}
{hasToc && (
<motion.button
layout
onClick={() => setIsTocOpen(!isTocOpen)}
className={cn(
"fixed bottom-8 right-8 z-50 flex items-center gap-2 rounded-full border border-white/20 bg-white/80 px-4 py-2.5 shadow-lg backdrop-blur-md transition-all hover:bg-white hover:scale-105 dark:border-white/10 dark:bg-slate-900/80 dark:hover:bg-slate-900",
"text-sm font-medium text-slate-600 dark:text-slate-300",
"lg:right-20" // Adjust position for desktop
)}
whileTap={{ scale: 0.95 }}
aria-label="Toggle Table of Contents"
>
<FontAwesomeIcon
icon={isTocOpen ? faChevronRight : faListUl}
className="h-3.5 w-3.5"
/>
<span>{isTocOpen ? 'Hide' : 'Menu'}</span>
</motion.button>
{/* Toggle Buttons - Rendered via Portal */}
{mounted && createPortal(
<>
{tocButton}
{desktopTocButton}
</>,
document.body
)}
</div>
);

View File

@@ -2,14 +2,15 @@ import Link from 'next/link';
import Image from 'next/image';
import { Post } from 'contentlayer2/generated';
import { siteConfig } from '@/lib/config';
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons';
import { FiCalendar, FiTag } from 'react-icons/fi';
import { MetaItem } from './meta-item';
interface Props {
post: Post;
priority?: boolean;
}
export function PostListItem({ post }: Props) {
export function PostListItem({ post, priority = false }: Props) {
const cover =
post.feature_image && post.feature_image.startsWith('../assets')
? post.feature_image.replace('../assets', '/assets')
@@ -20,7 +21,7 @@ export function PostListItem({ post }: Props) {
return (
<article className="motion-card group relative flex gap-4 rounded-2xl border border-white/40 bg-white/60 p-5 shadow-lg backdrop-blur-md transition-all hover:scale-[1.01] hover:shadow-xl dark:border-white/10 dark:bg-slate-900/60">
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-accent opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" />
{cover && (
<div className="relative flex h-24 w-24 flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:h-auto sm:w-40">
<Image
@@ -29,7 +30,10 @@ export function PostListItem({ post }: Props) {
width={320}
height={240}
sizes="(max-width: 640px) 96px, 160px"
loading="lazy"
loading={priority ? undefined : 'lazy'}
priority={priority}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R//2Q=="
className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
/>
</div>
@@ -37,23 +41,23 @@ export function PostListItem({ post }: Props) {
<div className="flex-1 space-y-1.5">
<div className="flex flex-wrap gap-3 text-xs">
{post.published_at && (
<MetaItem icon={faCalendarDays}>
<MetaItem icon={FiCalendar}>
{new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale
)}
</MetaItem>
)}
{post.tags && post.tags.length > 0 && (
<MetaItem icon={faTags} tone="muted">
<MetaItem icon={FiTag} tone="muted">
{post.tags.slice(0, 3).join(', ')}
</MetaItem>
)}
</div>
<h2 className="text-base font-semibold leading-snug text-slate-900 hover:text-accent sm:text-lg dark:text-slate-50 dark:hover:text-accent">
<h2 className="type-body font-semibold leading-snug hover:text-accent sm:type-title">
<Link href={post.url}>{post.title}</Link>
</h2>
{excerpt && (
<p className="line-clamp-2 text-sm text-slate-600 group-hover:text-slate-800 dark:text-slate-300 dark:group-hover:text-slate-100">
<p className="line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
{excerpt}
</p>
)}

View File

@@ -2,13 +2,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Post, Page } from 'contentlayer2/generated';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowDownWideShort,
faArrowUpWideShort,
faMagnifyingGlass,
faListUl
} from '@fortawesome/free-solid-svg-icons';
import { FiArrowDown, FiArrowUp, FiSearch, FiList } from 'react-icons/fi';
import { siteConfig } from '@/lib/config';
import { PostListItem } from './post-list-item';
import { TimelineWrapper } from './timeline-wrapper';
@@ -83,7 +77,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
<div className="space-y-4">
<div className="flex flex-col gap-4 text-xs text-slate-500 dark:text-slate-400 sm:flex-row sm:items-center sm:justify-between">
<div className="inline-flex items-center gap-2 rounded-full bg-slate-100/70 px-2 py-1 text-slate-600 dark:bg-slate-800/70 dark:text-slate-300">
<FontAwesomeIcon icon={faListUl} className="h-3.5 w-3.5" />
<FiList className="h-3.5 w-3.5" />
<span></span>
<button
type="button"
@@ -93,7 +87,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
}`}
>
<FontAwesomeIcon icon={faArrowDownWideShort} className="h-3 w-3" />
<FiArrowDown className="h-3 w-3" />
</button>
<button
@@ -104,7 +98,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
}`}
>
<FontAwesomeIcon icon={faArrowUpWideShort} className="h-3 w-3" />
<FiArrowUp className="h-3 w-3" />
</button>
</div>
@@ -113,17 +107,16 @@ export function PostListWithControls({ posts, pageSize }: Props) {
</label>
<div className="relative w-full sm:w-64">
<FontAwesomeIcon
icon={faMagnifyingGlass}
<FiSearch
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
/>
<input
id="post-search"
type="search"
placeholder="標題、標籤、摘要關鍵字"
placeholder="搜尋文章…"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
className="w-full rounded-full border border-slate-200 bg-white py-1.5 pl-9 pr-3 text-sm text-slate-700 shadow-sm transition duration-180 ease-snappy focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:focus:ring-blue-500"
className="w-full rounded-full border border-slate-200 bg-white py-1.5 pl-9 pr-3 text-sm text-slate-700 shadow-sm transition duration-180 ease-snappy focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:focus:border-accent dark:focus:ring-accent/30"
/>
</div>
</div>

View File

@@ -1,7 +1,6 @@
import Link from 'next/link';
import { Post } from 'contentlayer2/generated';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeftLong, faArrowRightLong } from '@fortawesome/free-solid-svg-icons';
import { FiArrowLeft, FiArrowRight } from 'react-icons/fi';
interface Props {
current: Post;
@@ -84,10 +83,11 @@ function Station({ station }: { station: StationConfig }) {
className={`group flex flex-col gap-1 text-center transition duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 dark:focus-visible:ring-blue-300 ${alignClass}`}
>
<p className="text-[11px] uppercase tracking-[0.4em] text-slate-400 transition group-hover:text-blue-500 dark:text-slate-500">
<FontAwesomeIcon
icon={align === 'end' ? faArrowLeftLong : faArrowRightLong}
className="mr-1 h-3 w-3"
/>
{align === 'end' ? (
<FiArrowLeft className="mr-1 inline h-3 w-3" />
) : (
<FiArrowRight className="mr-1 inline h-3 w-3" />
)}
{label}
</p>
<p className="text-lg font-semibold leading-snug tracking-tight text-slate-900 transition group-hover:text-blue-600 dark:text-slate-50 dark:group-hover:text-blue-300">

View File

@@ -1,8 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faListUl } from '@fortawesome/free-solid-svg-icons';
import { FiList } from 'react-icons/fi';
interface TocItem {
id: string;
@@ -10,7 +9,17 @@ interface TocItem {
depth: number;
}
export function PostToc({ onLinkClick }: { onLinkClick?: () => void }) {
export function PostToc({
onLinkClick,
contentKey,
showTitle = true,
className
}: {
onLinkClick?: () => void;
contentKey?: string;
showTitle?: boolean;
className?: string;
}) {
const [items, setItems] = useState<TocItem[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
@@ -18,40 +27,70 @@ export function PostToc({ onLinkClick }: { onLinkClick?: () => void }) {
const [indicator, setIndicator] = useState({ top: 0, opacity: 0 });
useEffect(() => {
const headings = Array.from(
document.querySelectorAll<HTMLElement>('article h2, article h3')
);
const mapped = headings
.filter((el) => el.id)
.map((el) => ({
id: el.id,
text: el.innerText,
depth: el.tagName === 'H3' ? 3 : 2
}));
setItems(mapped);
// Clear items immediately when content changes
setItems([]);
setActiveId(null);
itemRefs.current = {};
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = (entry.target as HTMLElement).id;
if (id) {
setActiveId(id);
}
const containerSelector = contentKey
? `[data-toc-content="${contentKey}"]`
: '[data-toc-content]';
const container = document.querySelector<HTMLElement>(containerSelector);
if (!container) {
return undefined;
}
let observer: IntersectionObserver | null = null;
let rafId1: number;
let rafId2: number;
// Use double requestAnimationFrame to ensure DOM has been painted
// This is more reliable than setTimeout for DOM updates
rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
const headings = Array.from(
container.querySelectorAll<HTMLElement>('h2, h3')
);
const mapped = headings
.filter((el) => el.id)
.map((el) => ({
id: el.id,
text: el.innerText,
depth: el.tagName === 'H3' ? 3 : 2
}));
setItems(mapped);
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = (entry.target as HTMLElement).id;
if (id) {
setActiveId(id);
}
}
});
},
{
// Trigger when heading is in upper 40% of viewport
rootMargin: '0px 0px -60% 0px',
threshold: 0.1
}
});
},
{
// Trigger when heading is in upper 40% of viewport
rootMargin: '0px 0px -60% 0px',
threshold: 0.1
);
headings.forEach((el) => observer?.observe(el));
});
});
return () => {
cancelAnimationFrame(rafId1);
cancelAnimationFrame(rafId2);
if (observer) {
observer.disconnect();
}
);
headings.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
};
}, [contentKey]);
useEffect(() => {
if (!activeId || !listRef.current) {
@@ -97,11 +136,13 @@ export function PostToc({ onLinkClick }: { onLinkClick?: () => void }) {
if (items.length === 0) return null;
return (
<nav className="not-prose sticky top-20 text-slate-500 dark:text-slate-400">
<div className="mb-2 inline-flex items-center gap-2 font-semibold text-slate-700 dark:text-slate-200">
<FontAwesomeIcon icon={faListUl} className="h-4 w-4 text-slate-400" />
</div>
<nav className={`not-prose text-slate-500 dark:text-slate-400 ${className || 'sticky top-20'}`}>
{showTitle && (
<div className="mb-2 inline-flex items-center gap-2 font-semibold text-slate-700 dark:text-slate-200">
<FiList className="h-4 w-4 text-slate-400" />
</div>
)}
<div className="relative pl-4">
<span className="absolute left-1 top-0 h-full w-px bg-slate-200 dark:bg-slate-800" aria-hidden="true" />
<span
@@ -126,11 +167,10 @@ export function PostToc({ onLinkClick }: { onLinkClick?: () => void }) {
<a
href={`#${item.id}`}
onClick={handleClick(item.id)}
className={`line-clamp-2 inline-flex items-center pl-2 hover:text-blue-600 dark:hover:text-blue-400 ${
item.id === activeId
className={`line-clamp-2 inline-flex items-center py-1 pl-2 hover:text-blue-600 dark:hover:text-blue-400 ${item.id === activeId
? 'text-blue-600 dark:text-blue-400 font-semibold'
: ''
}`}
}`}
>
{item.text}
</a>

View File

@@ -1,47 +1,88 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
function supportsScrollDrivenAnimations(): boolean {
if (typeof CSS === 'undefined') return false;
return CSS.supports?.('animation-timeline', 'scroll()') ?? false;
}
export function ReadingProgress() {
const [mounted, setMounted] = useState(false);
const [progress, setProgress] = useState(0);
const [useScrollDriven, setUseScrollDriven] = useState(false);
useEffect(() => {
setMounted(true);
const updateMode = () => {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
setUseScrollDriven(
supportsScrollDrivenAnimations() && !prefersReducedMotion
);
};
updateMode();
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
mq.addEventListener('change', updateMode);
return () => mq.removeEventListener('change', updateMode);
}, []);
useEffect(() => {
if (!mounted) return;
const handleScroll = useCallback(() => {
if (!mounted || useScrollDriven) return;
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
const total = scrollHeight - clientHeight;
if (total <= 0) {
setProgress(0);
return;
}
const value = Math.min(100, Math.max(0, (scrollTop / total) * 100));
setProgress(value);
}, [mounted, useScrollDriven]);
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
const total = scrollHeight - clientHeight;
if (total <= 0) {
setProgress(0);
return;
}
const value = Math.min(100, Math.max(0, (scrollTop / total) * 100));
setProgress(value);
};
useEffect(() => {
if (!mounted || useScrollDriven) return;
handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('scroll', handleScroll, { passive: true, signal: AbortSignal.timeout(60000) });
return () => window.removeEventListener('scroll', handleScroll);
}, [mounted]);
}, [mounted, useScrollDriven, handleScroll]);
if (!mounted) return null;
return (
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-px bg-transparent">
<div className="relative h-1 w-full overflow-visible">
<div
className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-blue-500/70 via-sky-400/70 to-indigo-500/70 shadow-[0_0_8px_rgba(59,130,246,0.45)] transition-[transform,opacity] duration-300 ease-out dark:from-blue-400/70 dark:via-sky-300/70 dark:to-indigo-400/70"
style={{ transform: `scaleX(${progress / 100})`, opacity: progress > 0 ? 1 : 0 }}
>
<span className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/70 blur-[1px] dark:bg-slate-900/70" aria-hidden="true" />
</div>
<span className="absolute inset-x-0 top-2 h-px bg-gradient-to-r from-transparent via-blue-200/40 to-transparent blur-sm dark:via-blue-900/30" aria-hidden="true" />
return createPortal(
<div className="pointer-events-none fixed inset-x-0 top-0 z-[1200] h-1.5 bg-transparent">
<div className="relative h-1.5 w-full overflow-visible">
{useScrollDriven ? (
<div aria-hidden="true" className="reading-progress-bar-scroll-driven absolute inset-y-0 left-0 w-full origin-left rounded-full bg-accent">
<span
className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/80 blur-[1px] dark:bg-slate-900/80"
aria-hidden="true"
/>
</div>
) : (
<div
className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-accent will-change-transform transition-[transform,opacity] duration-300 ease-out"
style={{
transform: `scaleX(${progress / 100})`,
opacity: progress > 0 ? 1 : 0
}}
aria-hidden="true"
>
<span
className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/80 blur-[1px] dark:bg-slate-900/80"
aria-hidden="true"
/>
</div>
)}
<span
className="absolute inset-x-0 top-2 h-px bg-gradient-to-r from-transparent via-accent-soft to-transparent blur-sm"
aria-hidden="true"
/>
</div>
</div>
</div>,
document.body
);
}

64
components/repo-card.tsx Normal file
View File

@@ -0,0 +1,64 @@
import Link from 'next/link';
import { FiExternalLink } from 'react-icons/fi';
import type { RepoSummary } from '@/lib/github';
import { getLanguageColor } from '@/lib/github-lang-colors';
interface RepoCardProps {
repo: RepoSummary;
animationDelay?: number;
}
export function RepoCard({ repo, animationDelay = 0 }: RepoCardProps) {
const langColor = getLanguageColor(repo.language);
return (
<li
className={`motion-card group relative flex h-full flex-col rounded-2xl border border-white/40 bg-white/60 p-5 shadow-lg backdrop-blur-md transition-all hover:scale-[1.01] hover:shadow-xl dark:border-white/10 dark:bg-slate-900/60 ${animationDelay > 0 ? 'repo-card-enter' : ''}`}
style={
animationDelay > 0 ? { animationDelay: `${animationDelay}ms` } : undefined
}
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-accent opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" />
<div className="flex items-start justify-between gap-2">
<Link
href={repo.htmlUrl}
prefetch={false}
target="_blank"
rel="noreferrer"
className="type-base inline-flex items-center gap-2 font-semibold text-slate-900 transition-colors hover:text-accent dark:text-slate-50 dark:hover:text-accent"
>
{repo.name}
<FiExternalLink className="h-3.5 w-3.5 opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
{repo.stargazersCount > 0 && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-lg bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
{repo.stargazersCount}
</span>
)}
</div>
{repo.description && (
<p className="mt-2 flex-1 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
{repo.description}
</p>
)}
<div className="mt-3 flex items-center justify-between gap-2 text-xs text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: langColor }}
aria-hidden
/>
{repo.language ?? '其他'}
</span>
<span suppressHydrationWarning>
{' '}
{repo.updatedAt
? new Date(repo.updatedAt).toLocaleDateString('zh-TW')
: '未知'}
</span>
</div>
</li>
);
}

View File

@@ -1,14 +1,69 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGithub, faMastodon, faLinkedin } from '@fortawesome/free-brands-svg-icons';
import { faFire, faArrowRight } from '@fortawesome/free-solid-svg-icons';
import { useEffect, useRef, useState } from 'react';
import { FaGithub, FaMastodon, FaLinkedin } from 'react-icons/fa';
import { FiTrendingUp, FiArrowRight } from 'react-icons/fi';
import { siteConfig } from '@/lib/config';
import { getAllTagsWithCount } from '@/lib/posts';
import { allPages } from 'contentlayer2/generated';
import { MastodonFeed } from './mastodon-feed';
import dynamic from 'next/dynamic';
// Lazy load MastodonFeed - only load when sidebar is visible
const MastodonFeed = dynamic(() => import('./mastodon-feed').then(mod => ({ default: mod.MastodonFeed })), {
ssr: false,
loading: () => <div className="h-32 w-full animate-pulse rounded-xl bg-slate-100 dark:bg-slate-800" />,
});
/** Shared sidebar content for desktop aside and mobile drawer */
export function RightSidebarContent({ forceLoadFeed = false }: { forceLoadFeed?: boolean }) {
const [shouldLoadFeed, setShouldLoadFeed] = useState(forceLoadFeed);
const feedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (forceLoadFeed) {
setShouldLoadFeed(true);
return;
}
if (!feedRef.current) return;
let observer: IntersectionObserver | null = null;
let cleanupRequested = false;
const setupObserver = () => {
if (cleanupRequested) return;
const el = feedRef.current;
if (!el) return;
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setShouldLoadFeed(true);
observer?.disconnect();
}
},
{ rootMargin: '100px' }
);
observer.observe(el);
};
// Defer observer setup for better initial performance
requestAnimationFrame(() => {
if (!cleanupRequested && feedRef.current) {
setupObserver();
}
});
return () => {
cleanupRequested = true;
observer?.disconnect();
};
}, [forceLoadFeed]);
export function RightSidebar() {
const tags = getAllTagsWithCount().slice(0, 5);
const aboutPage =
@@ -21,27 +76,26 @@ export function RightSidebar() {
siteConfig.social.github && {
key: 'github',
href: siteConfig.social.github,
icon: faGithub,
icon: FaGithub,
label: 'GitHub'
},
siteConfig.social.mastodon && {
key: 'mastodon',
href: siteConfig.social.mastodon,
icon: faMastodon,
icon: FaMastodon,
label: 'Mastodon'
},
siteConfig.social.linkedin && {
key: 'linkedin',
href: siteConfig.social.linkedin,
icon: faLinkedin,
icon: FaLinkedin,
label: 'LinkedIn'
}
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
return (
<aside className="hidden lg:block">
<div className="sticky top-20 flex flex-col gap-4">
<section className="motion-card group relative overflow-hidden rounded-xl border bg-white px-4 py-4 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
<div className="flex flex-col gap-4">
<section className="motion-card group relative overflow-hidden rounded-xl border bg-white px-4 py-4 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800/80">
<div className="pointer-events-none absolute -left-10 -top-10 h-24 w-24 rounded-full bg-sky-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-sky-500/25" />
<div className="pointer-events-none absolute -bottom-12 right-[-2.5rem] h-28 w-28 rounded-full bg-indigo-300/30 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/20" />
@@ -75,9 +129,9 @@ export function RightSidebar() {
target="_blank"
rel="noopener noreferrer"
aria-label={item.label}
className="motion-link inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent dark:bg-slate-800 dark:text-slate-200"
className="motion-link inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent dark:bg-slate-800 dark:text-slate-200 dark:hover:text-accent"
>
<FontAwesomeIcon icon={item.icon} className="h-4 w-4" />
<item.icon className="h-4 w-4" />
</a>
))}
</div>
@@ -92,13 +146,15 @@ export function RightSidebar() {
</div>
</section>
{/* Mastodon Feed */}
<MastodonFeed />
{/* Mastodon Feed - Lazy loaded when visible */}
<div ref={feedRef}>
{shouldLoadFeed && <MastodonFeed />}
</div>
{tags.length > 0 && (
<section className="motion-card rounded-xl border bg-white px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
<h2 className="type-small flex items-center gap-2 font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
<FontAwesomeIcon icon={faFire} className="h-3 w-3 text-orange-400" />
<FiTrendingUp className="h-3 w-3 text-orange-400" />
</h2>
<div className="mt-2 flex flex-wrap gap-2 text-base">
@@ -111,7 +167,7 @@ export function RightSidebar() {
<Link
key={tag}
href={`/tags/${slug}`}
className={`${sizeClass} tag-chip rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`}
className={`${sizeClass} tag-chip rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 dark:hover:text-white`}
>
{tag}
</Link>
@@ -120,18 +176,27 @@ export function RightSidebar() {
</div>
<div className="mt-3 flex items-center justify-between type-small text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1">
<FontAwesomeIcon icon={faArrowRight} className="h-3 w-3" />
<FiArrowRight className="h-3 w-3" />
</span>
<Link
href="/tags"
className="motion-link text-accent-textLight hover:text-accent dark:text-accent-textDark"
className="motion-link text-accent-textLight hover:text-accent dark:text-accent-textDark dark:hover:text-accent"
>
</Link>
</div>
</section>
)}
</div>
);
}
export function RightSidebar() {
return (
<aside className="hidden lg:block">
<div className="sticky top-20">
<RightSidebarContent />
</div>
</aside>
);

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback } from 'react';
import clsx from 'clsx';
interface ScrollRevealProps {
@@ -15,6 +15,20 @@ export function ScrollReveal({
once = true
}: ScrollRevealProps) {
const ref = useRef<HTMLDivElement | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
if (once && observerRef.current) {
observerRef.current.unobserve(entry.target);
}
} else if (!once) {
entry.target.classList.remove('is-visible');
}
});
}, [once]);
useEffect(() => {
const el = ref.current;
@@ -26,35 +40,26 @@ export function ScrollReveal({
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
if (once) observer.unobserve(entry.target);
} else if (!once) {
entry.target.classList.remove('is-visible');
}
});
},
observerRef.current = new IntersectionObserver(
handleObserver,
{
threshold: 0.05,
rootMargin: '0px 0px -20% 0px'
}
);
observer.observe(el);
observerRef.current.observe(el);
// Fallback timeout for slow connections
const fallback = window.setTimeout(() => {
// Fallback timeout for slow connections - reduce to 300ms
const fallback = setTimeout(() => {
el.classList.add('is-visible');
}, 500);
}, 300);
return () => {
observer.disconnect();
window.clearTimeout(fallback);
observerRef.current?.disconnect();
clearTimeout(fallback);
};
}, [once]);
}, [handleObserver, once]);
return (
<div

View File

@@ -1,79 +1,69 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMagnifyingGlass, faXmark } from '@fortawesome/free-solid-svg-icons';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Command } from 'cmdk';
import {
FiSearch,
FiHome,
FiFileText,
FiTag,
FiBook
} from 'react-icons/fi';
import { cn } from '@/lib/utils';
interface PagefindResult {
url: string;
meta: { title?: string };
excerpt?: string;
}
interface QuickAction {
id: string;
title: string;
url: string;
icon: React.ReactNode;
}
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
recentPosts?: { title: string; url: string }[];
}
export function SearchModal({ isOpen, onClose }: SearchModalProps) {
const [isLoaded, setIsLoaded] = useState(false);
const searchContainerRef = useRef<HTMLDivElement>(null);
const pagefindUIRef = useRef<any>(null);
export function SearchModal({
isOpen,
onClose,
recentPosts = []
}: SearchModalProps) {
const router = useRouter();
const [search, setSearch] = useState('');
const [results, setResults] = useState<PagefindResult[]>([]);
const [loading, setLoading] = useState(false);
const [pagefindReady, setPagefindReady] = useState(false);
const pagefindRef = useRef<{
init: () => void;
options: (opts: { bundlePath: string }) => Promise<void>;
preload: (query: string) => void;
debouncedSearch: (
query: string,
opts: object,
debounceMs: number
) => Promise<{ results: { data: () => Promise<PagefindResult> }[] } | null>;
} | null>(null);
// Initialize Pagefind when modal opens
useEffect(() => {
if (!isOpen) return;
let link: HTMLLinkElement | null = null;
let script: HTMLScriptElement | null = null;
// Load Pagefind UI dynamically when modal opens
const loadPagefind = async () => {
if (pagefindUIRef.current) {
// Already loaded
return;
}
try {
// Load Pagefind UI CSS
link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/_pagefind/pagefind-ui.css';
document.head.appendChild(link);
// Load Pagefind UI JS
script = document.createElement('script');
script.src = '/_pagefind/pagefind-ui.js';
script.onload = () => {
if (searchContainerRef.current && (window as any).PagefindUI) {
pagefindUIRef.current = new (window as any).PagefindUI({
element: searchContainerRef.current,
bundlePath: '/_pagefind/',
showSubResults: true,
showImages: false,
excerptLength: 15,
resetStyles: false,
autofocus: true,
translations: {
placeholder: '搜尋文章...',
clear_search: '清除',
load_more: '載入更多結果',
search_label: '搜尋此網站',
filters_label: '篩選',
zero_results: '找不到 [SEARCH_TERM] 的結果',
many_results: '找到 [COUNT] 個 [SEARCH_TERM] 的結果',
one_result: '找到 [COUNT] 個 [SEARCH_TERM] 的結果',
alt_search: '找不到 [SEARCH_TERM] 的結果。改為顯示 [DIFFERENT_TERM] 的結果',
search_suggestion: '找不到 [SEARCH_TERM] 的結果。請嘗試以下搜尋:',
searching: '搜尋中...'
}
});
setIsLoaded(true);
// Auto-focus the search input after a short delay
setTimeout(() => {
const input = searchContainerRef.current?.querySelector('input[type="search"]') as HTMLInputElement;
if (input) {
input.focus();
}
}, 100);
}
};
document.head.appendChild(script);
const pagefindUrl = `${window.location.origin}/_pagefind/pagefind.js`;
const pagefind = await import(/* webpackIgnore: true */ pagefindUrl);
await pagefind.options({ bundlePath: '/_pagefind/' });
pagefind.init();
pagefindRef.current = pagefind;
setPagefindReady(true);
} catch (error) {
console.error('Failed to load Pagefind:', error);
}
@@ -81,102 +71,179 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
loadPagefind();
// Cleanup function to prevent duplicate initializations
return () => {
if (link && link.parentNode) {
link.parentNode.removeChild(link);
}
if (script && script.parentNode) {
script.parentNode.removeChild(script);
}
if (pagefindUIRef.current && pagefindUIRef.current.destroy) {
pagefindUIRef.current.destroy();
pagefindUIRef.current = null;
}
pagefindRef.current = null;
setPagefindReady(false);
setSearch('');
setResults([]);
};
}, [isOpen]);
// Debounced search when user types
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
useEffect(() => {
// Prevent body scroll when modal is open
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
const query = search.trim();
if (!query || !pagefindRef.current) {
setResults([]);
setLoading(false);
return;
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
setLoading(true);
pagefindRef.current.preload(query);
// Use portal to render modal at document body level to avoid z-index stacking context issues
if (typeof window === 'undefined') return null;
const timer = setTimeout(async () => {
const pagefind = pagefindRef.current;
if (!pagefind) return;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-start justify-center bg-black/50 backdrop-blur-sm pt-20 px-4"
onClick={onClose}
const searchResult = await pagefind.debouncedSearch(query, {}, 300);
if (searchResult === null) return; // Superseded by newer search
const dataPromises = searchResult.results.slice(0, 10).map((r) => r.data());
const items = await Promise.all(dataPromises);
setResults(items);
setLoading(false);
}, 300);
return () => clearTimeout(timer);
}, [search, pagefindReady]);
const handleSelect = useCallback(
(url: string) => {
onClose();
router.push(url);
},
[onClose, router]
);
const navActions: QuickAction[] = [
{ id: 'home', title: '首頁', url: '/', icon: <FiHome className="size-4" /> },
{
id: 'blog',
title: '部落格',
url: '/blog',
icon: <FiFileText className="size-4" />
},
{
id: 'tags',
title: '標籤',
url: '/tags',
icon: <FiTag className="size-4" />
}
];
const recentPostActions: QuickAction[] = recentPosts.map((p) => ({
id: `post-${p.url}`,
title: p.title,
url: p.url,
icon: <FiBook className="size-4" />
}));
return (
<Command.Dialog
open={isOpen}
onOpenChange={(open) => !open && onClose()}
label="全站搜尋"
shouldFilter={false}
className="fixed left-1/2 top-[20%] z-[9999] w-full max-w-2xl -translate-x-1/2 rounded-2xl border border-white/40 bg-white/95 shadow-2xl backdrop-blur-md dark:border-white/10 dark:bg-slate-900/95"
>
<div
className="w-full max-w-3xl rounded-2xl border border-white/40 bg-white/95 shadow-2xl backdrop-blur-md dark:border-white/10 dark:bg-slate-900/95"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4 dark:border-slate-700">
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<FontAwesomeIcon icon={faMagnifyingGlass} className="h-5 w-5" />
<span className="text-sm font-medium"></span>
</div>
<button
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
aria-label="關閉搜尋"
>
<FontAwesomeIcon icon={faXmark} className="h-5 w-5" />
</button>
</div>
{/* Search Container */}
<div className="max-h-[60vh] overflow-y-auto p-6">
<div
ref={searchContainerRef}
className="pagefind-search"
data-pagefind-ui
/>
{!isLoaded && (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-500 border-r-transparent"></div>
<p className="mt-4 text-sm text-slate-500 dark:text-slate-400">
...
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-slate-200 px-6 py-3 text-xs text-slate-500 dark:border-slate-700 dark:text-slate-400">
<div className="flex items-center justify-between">
<span> ESC </span>
<span className="text-right"></span>
</div>
</div>
<div className="flex items-center border-b border-slate-200 px-4 dark:border-slate-700">
<FiSearch className="size-5 shrink-0 text-slate-400" />
<Command.Input
value={search}
onValueChange={setSearch}
placeholder="搜尋文章或快速導航…"
className="flex h-14 w-full bg-transparent px-3 text-base text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
</div>,
document.body
<Command.List className="max-h-[min(60vh,400px)] overflow-y-auto p-2">
{loading && (
<Command.Loading className="flex items-center justify-center py-8 text-sm text-slate-500 dark:text-slate-400">
</Command.Loading>
)}
{!loading && !search.trim() && (
<>
<Command.Group heading="導航" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 [&_[cmdk-group-heading]]:dark:text-slate-400">
{navActions.map((action) => (
<Command.Item
key={action.id}
value={`${action.title} ${action.url}`}
onSelect={() => handleSelect(action.url)}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-700 outline-none transition-colors',
'data-[selected=true]:bg-slate-100 data-[selected=true]:text-slate-900',
'dark:text-slate-300 dark:data-[selected=true]:bg-slate-800 dark:data-[selected=true]:text-slate-100 dark:hover:text-accent'
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
{action.icon}
</span>
<span className="truncate">{action.title}</span>
</Command.Item>
))}
</Command.Group>
{recentPostActions.length > 0 && (
<Command.Group heading="最近文章" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 [&_[cmdk-group-heading]]:dark:text-slate-400">
{recentPostActions.map((action) => (
<Command.Item
key={action.id}
value={`${action.title} ${action.url}`}
onSelect={() => handleSelect(action.url)}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-700 outline-none transition-colors',
'data-[selected=true]:bg-slate-100 data-[selected=true]:text-slate-900',
'dark:text-slate-300 dark:data-[selected=true]:bg-slate-800 dark:data-[selected=true]:text-slate-100 dark:hover:text-accent'
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
{action.icon}
</span>
<span className="truncate">{action.title}</span>
</Command.Item>
))}
</Command.Group>
)}
</>
)}
{!loading && search.trim() && results.length > 0 && (
<Command.Group heading="搜尋結果" className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 [&_[cmdk-group-heading]]:dark:text-slate-400">
{results.map((result, i) => (
<Command.Item
key={`${result.url}-${i}`}
value={`${result.meta?.title ?? ''} ${result.url}`}
onSelect={() => handleSelect(result.url)}
className={cn(
'flex cursor-pointer flex-col gap-0.5 rounded-lg px-3 py-2.5 outline-none transition-colors',
'data-[selected=true]:bg-slate-100 dark:data-[selected=true]:bg-slate-800 dark:hover:text-accent'
)}
>
<span className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
{result.meta?.title ?? result.url}
</span>
{result.excerpt && (
<span
className="line-clamp-2 text-xs text-slate-500 dark:text-slate-400 [&_mark]:bg-yellow-200 [&_mark]:font-semibold [&_mark]:text-slate-900 dark:[&_mark]:bg-yellow-600 dark:[&_mark]:text-slate-100"
dangerouslySetInnerHTML={{ __html: result.excerpt }}
/>
)}
</Command.Item>
))}
</Command.Group>
)}
<Command.Empty className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
</Command.Empty>
</Command.List>
<div className="border-t border-slate-200 px-4 py-2 text-xs text-slate-500 dark:border-slate-700 dark:text-slate-400">
<span>ESC </span>
<span className="ml-4">K </span>
</div>
</Command.Dialog>
);
}
@@ -196,11 +263,11 @@ export function SearchButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="motion-link inline-flex h-9 items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
className="motion-link inline-flex h-9 shrink-0 items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-600 transition-all duration-260 ease-snappy hover:-translate-y-0.5 hover:bg-slate-200 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 dark:hover:text-accent"
aria-label="搜尋 (Cmd+K)"
>
<FontAwesomeIcon icon={faMagnifyingGlass} className="h-3.5 w-3.5" />
<span className="hidden sm:inline"></span>
<FiSearch className="h-3.5 w-3.5 shrink-0" />
<span className="hidden shrink-0 whitespace-nowrap sm:inline"></span>
<kbd className="hidden rounded bg-white px-1.5 py-0.5 text-xs font-semibold text-slate-500 shadow-sm dark:bg-slate-900 dark:text-slate-400 sm:inline-block">
K
</kbd>

View File

@@ -1,10 +1,100 @@
import { RightSidebar } from './right-sidebar';
'use client';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import dynamic from 'next/dynamic';
import { FiLayout, FiX } from 'react-icons/fi';
import { clsx } from 'clsx';
// Lazy load RightSidebar since it's only visible on lg+ screens
const RightSidebar = dynamic(() => import('./right-sidebar').then(mod => ({ default: mod.RightSidebar })), {
ssr: false,
});
const RightSidebarContent = dynamic(() => import('./right-sidebar').then(mod => ({ default: mod.RightSidebarContent })), {
ssr: false,
});
export function SidebarLayout({ children }: { children: React.ReactNode }) {
return (
<div className="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,1.4fr)]">
<div>{children}</div>
<RightSidebar />
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (mobileSidebarOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [mobileSidebarOpen]);
const mobileDrawer = mounted && createPortal(
<>
{/* Backdrop */}
<div
className={clsx(
'fixed inset-0 z-[1100] bg-black/40 backdrop-blur-sm transition-opacity duration-300 lg:hidden',
mobileSidebarOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={() => setMobileSidebarOpen(false)}
aria-hidden="true"
/>
{/* Slide-over panel from right */}
<div
className={clsx(
'fixed top-0 right-0 bottom-0 z-[1110] w-full max-w-sm flex flex-col rounded-l-2xl border-l border-white/20 bg-white/95 shadow-2xl backdrop-blur-xl transition-transform duration-300 ease-snappy dark:border-white/10 dark:bg-slate-900/95 lg:hidden',
mobileSidebarOpen ? 'translate-x-0' : 'translate-x-full'
)}
>
<div className="flex items-center justify-between border-b border-slate-200/50 px-6 py-4 dark:border-slate-700/50">
<div className="flex items-center gap-2 font-semibold text-slate-900 dark:text-slate-100">
<FiLayout className="h-5 w-5 text-slate-500" />
<span></span>
</div>
<button
type="button"
onClick={() => setMobileSidebarOpen(false)}
className="rounded-full p-1 text-slate-500 hover:bg-slate-100 hover:text-accent dark:hover:bg-slate-800 dark:hover:text-accent"
aria-label="關閉側邊欄"
>
<FiX className="h-5 w-5" />
</button>
</div>
);
<div className="flex-1 overflow-y-auto px-6 py-6">
<RightSidebarContent forceLoadFeed={mobileSidebarOpen} />
</div>
</div>
</>,
document.body
);
const mobileFab = mounted && (
<button
type="button"
onClick={() => setMobileSidebarOpen(true)}
className={clsx(
'fixed bottom-6 left-6 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white/90 text-slate-600 shadow-md backdrop-blur-sm transition hover:bg-slate-50 hover:text-accent dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-accent lg:hidden',
mobileSidebarOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'
)}
aria-label="開啟側邊欄"
>
<FiLayout className="h-5 w-5" />
</button>
);
return (
<>
<div className="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,1.4fr)]">
<div>{children}</div>
<RightSidebar />
</div>
{mobileDrawer}
{mobileFab}
</>
);
}

View File

@@ -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 } from '@fortawesome/free-solid-svg-icons';
import { FaGithub, FaTwitter, FaMastodon, FaGit, FaLinkedin } from 'react-icons/fa';
import { FiMail } from 'react-icons/fi';
// Calculate year at build time for PPR compatibility
const currentYear = new Date().getFullYear();
@@ -20,37 +13,37 @@ export function SiteFooter() {
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,9 +66,9 @@ export function SiteFooter() {
target="_blank"
rel="noopener noreferrer"
aria-label={item.label}
className="text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
className="text-slate-500 hover:text-accent dark:text-slate-400 dark:hover:text-accent"
>
<FontAwesomeIcon icon={item.icon} className="h-4 w-4" />
<item.icon className="h-4 w-4" />
</a>
))}
</div>

View File

@@ -1,6 +1,5 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { ThemeToggle } from './theme-toggle';
@@ -8,6 +7,7 @@ import { NavMenu, NavLinkItem, IconKey } from './nav-menu';
import { SearchButton } from './search-modal';
import { siteConfig } from '@/lib/config';
import { allPages } from 'contentlayer2/generated';
import Link from 'next/link';
// Dynamically import SearchModal to reduce initial bundle size
const SearchModal = dynamic(
@@ -15,29 +15,80 @@ const SearchModal = dynamic(
{ ssr: false }
);
export function SiteHeader() {
interface SiteHeaderProps {
recentPosts?: { title: string; url: string }[];
}
export function SiteHeader({ recentPosts = [] }: SiteHeaderProps) {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const pages = allPages
.slice()
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
const findPage = (title: string) => pages.find((page) => page.title === title);
const aboutChildren: NavLinkItem[] = [
...(
[
{ title: '關於作者', label: '作者' },
{ title: '關於本站', label: '本站' }
]
.map(({ title, label }) => {
const page = findPage(title);
if (!page) return null;
return {
key: page._id,
href: page.url,
label,
iconKey: getIconForPage(page.title, page.slug)
} satisfies NavLinkItem;
})
.filter(Boolean) as NavLinkItem[]
),
{ key: 'projects', href: '/projects', label: '作品', iconKey: 'pen' }
];
const deviceChildren = [
{ title: '開發工作環境', label: '開發環境' },
{ title: 'HomeLab', label: 'HomeLab' }
]
.map(({ title, label }) => {
const page = findPage(title);
if (!page) return null;
return {
key: page._id,
href: page.url,
label,
iconKey: getIconForPage(page.title, page.slug)
} satisfies NavLinkItem;
})
.filter(Boolean) as NavLinkItem[];
const navItems: NavLinkItem[] = [
{ key: 'home', href: '/', label: '首頁', iconKey: 'home' },
{ key: 'blog', href: '/blog', label: 'Blog', iconKey: 'blog' },
...pages.map((page) => ({
key: page._id,
href: page.url,
label: page.title,
iconKey: getIconForPage(page.title, page.slug)
}))
{
key: 'about',
href: aboutChildren[0]?.href,
label: '關於',
iconKey: 'user',
children: aboutChildren
},
{
key: 'devices',
href: deviceChildren[0]?.href,
label: '裝置',
iconKey: 'device',
children: deviceChildren
}
];
return (
<header className="bg-white/80 backdrop-blur transition-colors duration-200 ease-snappy dark:bg-gray-950/80">
<header className="relative z-40 bg-white/80 backdrop-blur transition-colors duration-200 ease-snappy dark:bg-gray-950/80">
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-slate-900 dark:text-slate-100">
<Link
href="/"
className="motion-link group relative type-title text-slate-900 hover:text-accent focus-visible:outline-none focus-visible:text-accent dark:text-slate-100"
prefetch={true}
className="motion-link group relative type-title whitespace-nowrap text-slate-900 hover:text-accent focus-visible:outline-none focus-visible:text-accent dark:text-slate-100 dark:hover:text-accent"
>
<span className="absolute -bottom-0.5 left-0 h-[2px] w-0 bg-accent transition-all duration-180 ease-snappy group-hover:w-full" aria-hidden="true" />
{siteConfig.title}
@@ -50,6 +101,7 @@ export function SiteHeader() {
<SearchModal
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
recentPosts={recentPosts}
/>
</div>
</header>

View File

@@ -0,0 +1,225 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
// 眼睛 (霍德爾之目) - 雙鷹勾眼
const ASCII_ART = [
' /\\ /\\',
' / \\ / \\',
' | > | | > |',
' \\ / \\ /',
' \\/ \\/',
];
interface TerminalWindowProps {
title: string;
tagline: string;
/** Skip typing animation, show all at once */
reducedMotion?: boolean;
className?: string;
}
type Phase =
| 'prompt'
| 'typing-line1'
| 'typing-line2'
| 'prompt2'
| 'typing-ascii'
| 'done';
export function TerminalWindow({
title,
tagline,
reducedMotion = false,
className = '',
}: TerminalWindowProps) {
const [phase, setPhase] = useState<Phase>('prompt');
const [displayedPrompt, setDisplayedPrompt] = useState('');
const [displayedLine1, setDisplayedLine1] = useState('');
const [displayedLine2, setDisplayedLine2] = useState('');
const [displayedPrompt2, setDisplayedPrompt2] = useState('');
const [displayedAscii, setDisplayedAscii] = useState<string[]>([]);
const [showCursor, setShowCursor] = useState(true);
const prompt = 'cat ~/welcome.txt';
const prompt2 = 'fastfetch';
const line1 = `${title}`;
const line2 = tagline;
const charDelay = reducedMotion ? 0 : 50;
const lineDelay = reducedMotion ? 0 : 400;
const asciiLineDelay = reducedMotion ? 0 : 80;
const typeString = useCallback(
(
str: string,
setter: (s: string) => void,
onComplete?: () => void
) => {
if (reducedMotion) {
setter(str);
onComplete?.();
return;
}
let i = 0;
const id = setInterval(() => {
if (i <= str.length) {
setter(str.slice(0, i));
i++;
} else {
clearInterval(id);
onComplete?.();
}
}, charDelay);
return () => clearInterval(id);
},
[charDelay, reducedMotion]
);
useEffect(() => {
if (phase === 'prompt') {
const cleanup = typeString(prompt, setDisplayedPrompt, () => {
setTimeout(() => setPhase('typing-line1'), lineDelay);
});
return cleanup;
}
}, [phase, prompt, typeString, lineDelay]);
useEffect(() => {
if (phase === 'typing-line1') {
const cleanup = typeString(line1, setDisplayedLine1, () => {
setTimeout(() => setPhase('typing-line2'), lineDelay);
});
return cleanup;
}
}, [phase, line1, typeString, lineDelay]);
useEffect(() => {
if (phase === 'typing-line2') {
const cleanup = typeString(line2, setDisplayedLine2, () => {
setTimeout(() => setPhase('prompt2'), lineDelay);
});
return cleanup;
}
}, [phase, line2, typeString, lineDelay]);
useEffect(() => {
if (phase === 'prompt2') {
setDisplayedPrompt2('');
const cleanup = typeString(prompt2, setDisplayedPrompt2, () => {
setTimeout(() => setPhase('typing-ascii'), lineDelay);
});
return cleanup;
}
}, [phase, prompt2, typeString, lineDelay]);
useEffect(() => {
if (phase === 'typing-ascii') {
if (reducedMotion) {
setDisplayedAscii(ASCII_ART);
setTimeout(() => setPhase('done'), lineDelay);
return;
}
let lineIndex = 0;
const id = setInterval(() => {
if (lineIndex < ASCII_ART.length) {
setDisplayedAscii((prev) => [...prev, ASCII_ART[lineIndex]]);
lineIndex++;
} else {
clearInterval(id);
setTimeout(() => setPhase('done'), lineDelay);
}
}, asciiLineDelay);
return () => clearInterval(id);
}
}, [phase, asciiLineDelay, lineDelay, reducedMotion]);
// Blinking cursor
useEffect(() => {
if (!reducedMotion && phase !== 'done') {
const id = setInterval(() => setShowCursor((c) => !c), 530);
return () => clearInterval(id);
}
setShowCursor(true);
}, [phase, reducedMotion]);
return (
<div
className={`overflow-hidden rounded-xl border border-slate-300 bg-slate-100 shadow-xl dark:border-slate-700/50 dark:bg-slate-900 ${className}`}
role="img"
aria-label={`終端機:${title} - ${tagline}`}
>
{/* macOS-style title bar */}
<div className="flex items-center gap-2 border-b border-slate-200 px-4 py-2.5 dark:border-slate-700/50 sm:px-5 sm:py-3 lg:px-6 lg:py-3.5">
<div className="flex gap-1.5 sm:gap-2">
<span className="h-3 w-3 rounded-full bg-red-500/90 sm:h-3.5 sm:w-3.5 lg:h-4 lg:w-4" />
<span className="h-3 w-3 rounded-full bg-amber-500/90 sm:h-3.5 sm:w-3.5 lg:h-4 lg:w-4" />
<span className="h-3 w-3 rounded-full bg-emerald-500/90 sm:h-3.5 sm:w-3.5 lg:h-4 lg:w-4" />
</div>
<span className="ml-4 font-mono text-xs text-slate-500 sm:text-sm dark:text-slate-400 lg:text-base">
gbanyan@blog zsh
</span>
</div>
{/* Terminal content */}
<div className="px-4 py-4 font-mono text-sm sm:px-5 sm:py-5 sm:text-base lg:px-6 lg:py-6 lg:text-lg">
<div className="text-slate-600 dark:text-slate-300">
<span className="text-emerald-600 dark:text-emerald-400">~</span>
<span className="text-slate-500"> $ </span>
<span>{displayedPrompt}</span>
{phase === 'prompt' && showCursor && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-emerald-600 dark:bg-emerald-400" />
)}
</div>
{displayedLine1 && (
<div className="mt-2 text-slate-900 dark:text-slate-100">
{displayedLine1}
{phase === 'typing-line1' && showCursor && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-emerald-600 dark:bg-emerald-400" />
)}
</div>
)}
{displayedLine2 && (
<div className="mt-1 text-slate-600 dark:text-slate-300">
{displayedLine2}
{phase === 'typing-line2' && showCursor && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-emerald-600 dark:bg-emerald-400" />
)}
</div>
)}
{(phase === 'prompt2' || phase === 'typing-ascii' || displayedPrompt2 || displayedAscii.length > 0) && (
<div className="mt-2 text-slate-600 dark:text-slate-300">
<span className="text-emerald-600 dark:text-emerald-400">~</span>
<span className="text-slate-500"> $ </span>
<span>{displayedPrompt2}</span>
{phase === 'prompt2' && showCursor && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-emerald-600 dark:bg-emerald-400" />
)}
</div>
)}
{displayedAscii.length > 0 && (
<div className="mt-2 whitespace-pre text-emerald-600/90 dark:text-emerald-400/90">
{displayedAscii.map((line, i) => (
<div key={i}>{line}</div>
))}
{phase === 'typing-ascii' && showCursor && (
<span className="inline-block h-4 w-0.5 animate-pulse bg-emerald-600 dark:bg-emerald-400" />
)}
</div>
)}
{phase === 'done' && (
<div className="mt-2 text-slate-600 dark:text-slate-300">
<span className="text-emerald-600 dark:text-emerald-400">~</span>
<span className="text-slate-500"> $ </span>
<span className="inline-block h-4 w-4 animate-pulse border-l-2 border-emerald-600 dark:border-emerald-400" />
</div>
)}
</div>
</div>
);
}

View File

@@ -2,8 +2,7 @@
import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
import { FiMoon, FiSun } from 'react-icons/fi';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
@@ -23,16 +22,15 @@ export function ThemeToggle() {
return (
<button
type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-accent-textLight transition duration-180 ease-snappy hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-accent-textDark dark:hover:bg-slate-800 dark:hover:text-accent"
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-accent-textLight transition-all duration-300 ease-out-quarter hover:-translate-y-1 hover:scale-110 hover:bg-accent-soft hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-accent-textDark dark:hover:bg-accent-soft dark:hover:text-accent"
onClick={() => setTheme(next)}
aria-label={theme === 'dark' ? '切換為淺色主題' : '切換為深色主題'}
>
<FontAwesomeIcon
icon={isDark ? faSun : faMoon}
className={`h-4 w-4 transition-transform duration-260 ease-snappy ${
isDark ? 'rotate-0 text-amber-400' : 'rotate-180 text-blue-500'
}`}
/>
{isDark ? (
<FiSun className="h-4 w-4 rotate-0 text-amber-400 transition-all duration-500 ease-out-expo" />
) : (
<FiMoon className="h-4 w-4 rotate-180 text-blue-500 transition-all duration-500 ease-out-expo" />
)}
</button>
);
}

View File

@@ -1,4 +1,6 @@
import { Children, ReactNode } from 'react';
'use client';
import {Children, ReactNode, useEffect, useState} from 'react';
import clsx from 'clsx';
interface TimelineWrapperProps {
@@ -7,22 +9,34 @@ interface TimelineWrapperProps {
}
export function TimelineWrapper({ children, className }: TimelineWrapperProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const items = Children.toArray(children);
// Only render decorative elements after mount to prevent layout shift
if (!mounted) {
return (
<div className={clsx('relative pl-6 md:pl-8', className)}>
<div className="space-y-4">{items.map((child, index) => <div key={index} className="relative pl-5 sm:pl-8">{child}</div>)}</div>
</div>
);
}
return (
<div className={clsx('relative pl-8', className)}>
<div className={clsx('relative pl-6 md:pl-8', className)}>
<span
className="pointer-events-none absolute left-3 top-0 h-full w-[2px] rounded-full bg-blue-400 shadow-[0_0_10px_rgba(59,130,246,0.35)] dark:bg-cyan-300"
aria-hidden="true"
/>
<span
className="pointer-events-none absolute left-3 top-0 h-full w-[8px] rounded-full bg-blue-500/15 blur-[14px]"
className="pointer-events-none absolute left-2 top-0 h-full w-[2px] rounded-full bg-accent/40 md:left-3"
aria-hidden="true"
/>
<div className="space-y-4">
{items.map((child, index) => (
<div key={index} className="relative pl-6 sm:pl-8">
<span className="pointer-events-none absolute left-0 top-1/2 h-px w-8 -translate-x-full -translate-y-1/2 bg-gradient-to-r from-transparent via-blue-300/80 to-transparent dark:via-cyan-200/80" aria-hidden="true" />
<div key={index} className="relative pl-5 sm:pl-8">
<span className="pointer-events-none absolute left-0 top-1/2 h-px w-5 -translate-x-full -translate-y-1/2 bg-gradient-to-r from-transparent via-accent/30 to-transparent sm:w-8" aria-hidden="true" />
{child}
</div>
))}

View File

@@ -0,0 +1,19 @@
'use client';
import { useState, useEffect, ReactNode } from 'react';
export function ViewTransitionProvider({ children }: { children: ReactNode }) {
const [isSafari18, setIsSafari18] = useState(false);
useEffect(() => {
const isSafari = typeof navigator !== 'undefined' && navigator.userAgent.includes('safari') && !navigator.userAgent.includes('chrome') && !navigator.userAgent.includes('firefox');
const hasNativeTransitions = typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
setIsSafari18(isSafari && hasNativeTransitions);
}, []);
if (isSafari18) {
return <>{children}</>;
}
return <>{children}</>;
}

49
components/web-vitals.tsx Normal file
View File

@@ -0,0 +1,49 @@
'use client';
import { useEffect } from 'react';
/**
* Web Vitals monitoring component (optional)
*
* To enable full Web Vitals tracking, install web-vitals package:
* npm install web-vitals
*
* Then uncomment the code below and import from 'web-vitals'
*/
export function WebVitals() {
useEffect(() => {
// Only track in production
if (process.env.NODE_ENV !== 'production') return;
// Basic performance monitoring using Performance API
if (typeof window !== 'undefined' && 'performance' in window) {
// Track page load time
window.addEventListener('load', () => {
const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
if (perfData) {
const loadTime = perfData.loadEventEnd - perfData.fetchStart;
const domContentLoaded = perfData.domContentLoadedEventEnd - perfData.fetchStart;
// Log metrics (can be sent to analytics service)
if (process.env.NODE_ENV === 'development') {
console.log('Performance Metrics:', {
loadTime: Math.round(loadTime),
domContentLoaded: Math.round(domContentLoaded),
firstByte: Math.round(perfData.responseStart - perfData.fetchStart),
});
}
// Example: Send to analytics service
// if (typeof window !== 'undefined' && window.gtag) {
// window.gtag('event', 'page_load_time', {
// value: Math.round(loadTime),
// non_interaction: true,
// });
// }
}
});
}
}, []);
return null;
}

Submodule content updated: a859f93327...43c3a0b18f

View File

@@ -88,6 +88,7 @@ export default makeSource({
markdown: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeCallouts,
[
rehypePrettyCode,
{
@@ -98,12 +99,11 @@ export default makeSource({
keepBackground: false,
},
],
rehypeCallouts,
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
/**
* Rewrite markdown image src from relative "../assets/..." to
* absolute "/assets/..." so they are served from Next.js public/.
* absolute "/assets/..." and add lazy loading for cross-browser performance.
*/
() => (tree: any) => {
visit(tree, 'element', (node: any) => {
@@ -118,6 +118,9 @@ export default makeSource({
} else if (src.startsWith('assets/')) {
node.properties.src = '/' + src.replace(/^\/?/, '');
}
// Lazy load images for better LCP and bandwidth (Chrome, Firefox, Safari, Edge)
node.properties.loading = 'lazy';
node.properties.decoding = 'async';
}
});
}

32
env Normal file
View File

@@ -0,0 +1,32 @@
# Public site metadata (safe to expose to browser)
NEXT_PUBLIC_SITE_NAME="Gbanyan"
NEXT_PUBLIC_SITE_TITLE="霍德爾之目"
NEXT_PUBLIC_SITE_DESCRIPTION="醫學、科技與生活隨筆。"
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
NEXT_PUBLIC_SITE_AUTHOR="Gbanyan"
NEXT_PUBLIC_SITE_TAGLINE="醫學、科技與生活的隨筆記錄。"
NEXT_PUBLIC_POSTS_PER_PAGE="7"
NEXT_PUBLIC_DEFAULT_LOCALE="zh-TW"
NEXT_PUBLIC_SITE_AVATAR_URL="https://www.gravatar.com/avatar/53f2a6e011d5ececcd0d6e33e8e7329f60efcd23a5b77ba382273d5060a2cffe?s=160&d=identicon"
NEXT_PUBLIC_SITE_ABOUT_SHORT="掙扎混亂過日子 \n 對平淡美好日常的期待即是救贖"
# Color scheme / accents
NEXT_PUBLIC_COLOR_ACCENT="#2563eb"
NEXT_PUBLIC_COLOR_ACCENT_SOFT="#dbeafe"
NEXT_PUBLIC_COLOR_ACCENT_TEXT_LIGHT="#1d4ed8"
NEXT_PUBLIC_COLOR_ACCENT_TEXT_DARK="#93c5fd"
# Social and profile
NEXT_PUBLIC_TWITTER_HANDLE="@gbanyan"
NEXT_PUBLIC_GITHUB_URL="https://github.com/gbanyan"
NEXT_PUBLIC_LINKEDIN_URL=""
NEXT_PUBLIC_EMAIL_CONTACT=""
NEXT_PUBLIC_MASTODON_URL=""
NEXT_PUBLIC_GITEA_URL=""
# SEO / Open Graph
NEXT_PUBLIC_OG_DEFAULT_IMAGE="/assets/og-default.jpg"
NEXT_PUBLIC_TWITTER_CARD_TYPE="summary_large_image"
# Analytics (public ID only)
NEXT_PUBLIC_ANALYTICS_ID=""

View File

@@ -27,12 +27,12 @@ export const siteConfig = {
gitea: process.env.NEXT_PUBLIC_GITEA_URL || ''
},
theme: {
accent: process.env.NEXT_PUBLIC_COLOR_ACCENT || '#2563eb',
accentSoft: process.env.NEXT_PUBLIC_COLOR_ACCENT_SOFT || '#dbeafe',
accent: process.env.NEXT_PUBLIC_COLOR_ACCENT || '#7c3aed',
accentSoft: process.env.NEXT_PUBLIC_COLOR_ACCENT_SOFT || '#f3e8ff',
accentTextLight:
process.env.NEXT_PUBLIC_COLOR_ACCENT_TEXT_LIGHT || '#1d4ed8',
process.env.NEXT_PUBLIC_COLOR_ACCENT_TEXT_LIGHT || '#6d28d9',
accentTextDark:
process.env.NEXT_PUBLIC_COLOR_ACCENT_TEXT_DARK || '#93c5fd'
process.env.NEXT_PUBLIC_COLOR_ACCENT_TEXT_DARK || '#c4b5fd'
},
navIconOverrides: {
titles: {
@@ -42,7 +42,7 @@ export const siteConfig = {
},
slugs: {}
},
ogImage: process.env.NEXT_PUBLIC_OG_DEFAULT_IMAGE || '/assets/og-default.jpg',
ogImage: process.env.NEXT_PUBLIC_OG_DEFAULT_IMAGE || '/assets/og-default.png',
twitterCard:
(process.env.NEXT_PUBLIC_TWITTER_CARD_TYPE as
| 'summary'

43
lib/github-lang-colors.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* GitHub-style language colors for repo cards.
* Fallback: #94a3b8 (slate-400) for unknown languages.
*/
const LANG_COLORS: Record<string, string> = {
TypeScript: '#3178c6',
JavaScript: '#f1e05a',
Python: '#3572A5',
Rust: '#dea584',
Go: '#00ADD8',
Ruby: '#701516',
PHP: '#4F5D95',
Java: '#b07219',
Kotlin: '#A97BFF',
Swift: '#F05138',
C: '#555555',
'C++': '#f34b7d',
'C#': '#239120',
Shell: '#89e051',
HTML: '#e34c26',
CSS: '#563d7c',
Vue: '#41b883',
Svelte: '#ff3e00',
Dart: '#00B4AB',
Scala: '#c22d40',
Elixir: '#6e4a7e',
Lua: '#000080',
R: '#198CE7',
Markdown: '#083fa1',
YAML: '#cb171e',
JSON: '#292929',
};
const FALLBACK_COLOR = '#94a3b8';
/**
* Returns the GitHub-style hex color for a programming language.
* Unknown languages use a neutral slate fallback.
*/
export function getLanguageColor(lang: string | null): string {
if (!lang || !lang.trim()) return FALLBACK_COLOR;
return LANG_COLORS[lang] ?? FALLBACK_COLOR;
}

76
lib/github.ts Normal file
View File

@@ -0,0 +1,76 @@
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<string, string> = {
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.
* Excludes forked repositories. Returns an empty array on error instead of
* throwing, so the UI can render a graceful fallback.
*/
export async function fetchPublicRepos(usernameOverride?: string): Promise<RepoSummary[]> {
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
.filter((repo) => !repo.fork)
.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 [];
}
}

View File

@@ -14,9 +14,12 @@ export interface MastodonStatus {
avatar: string;
};
media_attachments: Array<{
type: string;
url: string;
preview_url: string;
id: string;
type: 'image' | 'video' | 'gifv' | 'audio' | 'unknown';
url: string | null;
preview_url: string | null;
description: string | null;
blurhash?: string | null;
}>;
}

View File

@@ -1,11 +1,18 @@
import { allPosts, allPages, Post, Page } from 'contentlayer2/generated';
let _sortedCache: Post[] | null = null;
let _relatedCache: Map<string, Post[]> = new Map();
let _neighborsCache: Map<string, { newer?: Post; older?: Post }> = new Map();
let _tagsCache: { tag: string; slug: string; count: number }[] | null = null;
export function getAllPostsSorted(): Post[] {
return [...allPosts].sort((a, b) => {
if (_sortedCache) return _sortedCache;
_sortedCache = [...allPosts].sort((a, b) => {
const aDate = a.published_at ? new Date(a.published_at).getTime() : 0;
const bDate = b.published_at ? new Date(b.published_at).getTime() : 0;
return bDate - aDate;
});
return _sortedCache;
}
export function getPostBySlug(slug: string): Post | undefined {
@@ -38,24 +45,31 @@ export function getTagSlug(tag: string): string {
}
export function getAllTagsWithCount(): { tag: string; slug: string; count: number }[] {
const map = new Map<string, number>();
if (_tagsCache) return _tagsCache;
const map = new Map<string, number>();
for (const post of allPosts) {
if (!post.tags) continue;
for (const tag of post.tags) {
map.set(tag, (map.get(tag) ?? 0) + 1);
for (const postTag of post.tags) {
map.set(postTag, (map.get(postTag) ?? 0) + 1);
}
}
return Array.from(map.entries())
_tagsCache = Array.from(map.entries())
.map(([tag, count]) => ({ tag, slug: getTagSlug(tag), count }))
.sort((a, b) => {
if (b.count === a.count) return a.tag.localeCompare(b.tag);
return b.count - a.count;
});
return _tagsCache;
}
export function getRelatedPosts(target: Post, limit = 3): Post[] {
const cacheKey = `${target._id}-${limit}`;
if (_relatedCache.has(cacheKey)) {
return _relatedCache.get(cacheKey)!;
}
const targetTags = new Set(target.tags?.map((tag) => tag.toLowerCase()) ?? []);
const candidates = getAllPostsSorted().filter((post) => post._id !== target._id);
@@ -84,28 +98,39 @@ export function getRelatedPosts(target: Post, limit = 3): Post[] {
.slice(0, limit)
.map((entry) => entry.post);
let result: Post[];
if (scored.length >= limit) {
return scored;
result = scored;
} else {
const fallback = candidates.filter(
(post) => !scored.some((existing) => existing._id === post._id)
);
result = [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit);
}
const fallback = candidates.filter(
(post) => !scored.some((existing) => existing._id === post._id)
);
return [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit);
_relatedCache.set(cacheKey, result);
return result;
}
export function getPostNeighbors(target: Post): {
newer?: Post;
older?: Post;
} {
const cacheKey = target._id;
if (_neighborsCache.has(cacheKey)) {
return _neighborsCache.get(cacheKey)!;
}
const sorted = getAllPostsSorted();
const index = sorted.findIndex((post) => post._id === target._id);
if (index === -1) return {};
return {
const result = {
newer: index > 0 ? sorted[index - 1] : undefined,
older: index < sorted.length - 1 ? sorted[index + 1] : undefined
};
_neighborsCache.set(cacheKey, result);
return result;
}

View File

@@ -6,31 +6,61 @@ import { visit } from 'unist-util-visit';
*/
export function rehypeCallouts() {
return (tree: any) => {
visit(tree, 'element', (node, index, parent) => {
visit(tree, 'element', (node) => {
// Only process blockquotes
if (node.tagName !== 'blockquote') return;
// Check if first child is a paragraph
if (!node.children || node.children.length === 0) return;
const firstChild = node.children[0];
if (firstChild.tagName !== 'p') return;
// Check if paragraph starts with [!TYPE]
if (!firstChild.children || firstChild.children.length === 0) return;
const firstText = firstChild.children[0];
if (firstText.type !== 'text') return;
// Find the first non-whitespace child
let contentChild: any = null;
for (const child of node.children) {
if (child.type === 'text' && child.value.trim()) {
contentChild = child;
break;
} else if (child.tagName === 'p') {
contentChild = child;
break;
}
}
const match = firstText.value.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i);
if (!contentChild) return;
// Find the first text node
let textNode: any = null;
let textParent: any = null;
if (contentChild.type === 'text') {
// Direct text child
textNode = contentChild;
textParent = node;
} else if (contentChild.tagName === 'p' && contentChild.children) {
// Text inside paragraph - find first non-whitespace text
for (const child of contentChild.children) {
if (child.type === 'text' && child.value.trim()) {
textNode = child;
textParent = contentChild;
break;
}
}
}
if (!textNode || textNode.type !== 'text') return;
// Check if text starts with [!TYPE]
const match = textNode.value.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i);
if (!match) return;
const type = match[0].replace(/^\[!|\]\s*/g, '').toLowerCase();
const type = match[1].toLowerCase();
// Remove the [!TYPE] marker from the text
firstText.value = firstText.value.replace(match[0], '');
textNode.value = textNode.value.replace(match[0], '').trim();
// If the text node is now empty, remove it
if (!firstText.value.trim()) {
firstChild.children.shift();
if (!textNode.value) {
const index = textParent.children.indexOf(textNode);
if (index > -1) {
textParent.children.splice(index, 1);
}
}
// Add callout data attributes and classes
@@ -38,7 +68,7 @@ export function rehypeCallouts() {
node.properties.className = ['callout', `callout-${type}`];
node.properties['data-callout'] = type;
// Add icon element at the beginning
// Add icon and title elements
const iconMap: Record<string, string> = {
note: '📝',
tip: '💡',
@@ -72,7 +102,7 @@ export function rehypeCallouts() {
type: 'element',
tagName: 'div',
properties: { className: ['callout-content'] },
children: node.children,
children: [...node.children],
};
node.children = [header, content];

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -4,12 +4,13 @@ const nextConfig = {
images: {
remotePatterns: [],
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
// Optimized sizes for better performance
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
// Enable image optimization
minimumCacheTTL: 60,
},
// Enable Partial Prerendering (PPR) via cacheComponents in Next.js 16
cacheComponents: true,
// Compiler optimizations
compiler: {
@@ -31,6 +32,33 @@ const nextConfig = {
},
],
},
{
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=0, must-revalidate',
},
],
},
{
source: '/blog/:slug*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=3600, stale-while-revalidate=86400',
},
],
},
{
source: '/pages/:slug*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=3600, stale-while-revalidate=86400',
},
],
},
];
},
};

4824
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,23 +15,29 @@
"author": "",
"license": "ISC",
"type": "module",
"browserslist": [
"chrome 111",
"edge 111",
"firefox 111",
"safari 16.4"
],
"dependencies": {
"@emotion/is-prop-valid": "^1.4.0",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@radix-ui/react-dialog": "^1.1.15",
"@vercel/og": "^0.8.5",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"contentlayer2": "^0.5.8",
"framer-motion": "^12.23.24",
"gray-matter": "^4.0.3",
"markdown-wasm": "^1.2.0",
"next": "^16.0.3",
"mermaid": "^11.12.3",
"next": "^16.0.7",
"next-contentlayer2": "^0.5.8",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"nextjs-toploader": "^3.9.17",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-icons": "^5.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.1",
"rehype-slug": "^6.0.0",
@@ -42,17 +48,17 @@
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.22",
"concurrently": "^9.2.1",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.3",
"pagefind": "^1.4.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

BIN
prod-homepage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 485 KiB

66
scripts/subset-font.mjs Normal file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* 字体子集化脚本
* 使用 pyftsubset (需要安装 fonttools: pip install fonttools brotli)
*
* 用法: node scripts/subset-font.mjs
*
* 注意:此脚本需要先下载字体文件,或使用 Google Fonts API
* 由于我们使用 next/font/googleNext.js 会自动优化字体加载
* 此脚本主要用于本地字体文件的子集化
*/
import { execSync } from 'child_process';
import { existsSync, mkdirSync } from 'fs';
import { join } from 'path';
// 常用繁体中文字符集约3000-5000字
const commonTCChars = `
的一是在不了有和人這中大為上個國我以要他時來用們生到作地於出就分對成會可主發年動同工也能下過子說產種面而方後多定行學法所民得經十三之進著等部度家電力裡如水化高自二理起小物實現加量都兩體制機當使點從業本去把性好應開它合還因由其些然前外天政四日那社義事平形相全表間樣與關各重新線內數正心反你明看原又麼利比或但質氣第向道命此變條只沒結解問意建月公無系軍很情者最立代想已通並提直題黨程展五果料象員革位入常文總次品式活設及管特件長求老頭基資邊流路級少圖山統接知較將組見計別她手角期根論運農指幾九區強放決西被幹做必戰先回則任取據處隊南給色光門即保治北造百規熱領七海口東導器壓志世金增爭濟階油思術極交受聯什認六共權收證改清己美再轉更單風切打白教速花帶安場身車例真務具萬每目至達走積示議聲報鬥完類離離戶科懸空需廠商校連斷深難近礦千週委素技備半辦青省列習響約支般史感勞便團往酸歷市克何除消構府稱太準精值號率族維劃選標寫存候毛親快效斯院查江型眼王按格養易置派層片始卻專狀育廠京識適屬圓包火住調滿縣局照參紅細引聽該鐵價嚴龍飛
`.trim().replace(/\s+/g, '');
async function subsetFont() {
console.log('开始字体子集化...');
// 注意:这个脚本需要你先下载字体文件到 fonts/ 目录
// 或者使用 Google Fonts API 下载
const fontsDir = join(process.cwd(), 'fonts');
const fontPath = join(fontsDir, 'LXGWWenKaiTC-Regular.ttf');
const outputDir = join(process.cwd(), 'public', 'fonts');
if (!existsSync(fontPath)) {
console.log('⚠️ 字体文件不存在,跳过子集化');
console.log(` 预期路径: ${fontPath}`);
console.log(' 提示: 由于使用 next/font/googleNext.js 会自动优化字体加载');
return;
}
try {
// 检查 pyftsubset 是否安装
execSync('which pyftsubset', { stdio: 'ignore' });
const outputPath = join(outputDir, 'LXGWWenKaiTC-Regular-subset.woff2');
// 创建输出目录
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
// 生成 Unicode 范围
const unicodes = Array.from(new Set(commonTCChars))
.map(c => `U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`)
.join(',');
// 执行子集化
const command = `pyftsubset "${fontPath}" --unicodes="${unicodes}" --flavor=woff2 --output-file="${outputPath}"`;
execSync(command, { stdio: 'inherit' });
console.log(`✅ 子集化完成: ${outputPath}`);
} catch (error) {
console.error('❌ 子集化失败:', error.message);
console.log('\n提示: 需要安装 fonttools: pip install fonttools brotli');
}
}
subsetFont();

File diff suppressed because it is too large Load Diff

View File

@@ -1,126 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./content/**/*.{md,mdx}"
],
theme: {
extend: {
colors: {
accent: {
DEFAULT: 'var(--color-accent)',
soft: 'var(--color-accent-soft)',
textLight: 'var(--color-accent-text-light)',
textDark: 'var(--color-accent-text-dark)'
}
},
fontFamily: {
'serif-eng': ['var(--font-serif-eng)', 'serif'],
'serif-cn': ['"Songti SC"', '"Noto Serif TC"', '"SimSun"', 'serif'],
},
transitionTimingFunction: {
snappy: 'cubic-bezier(0.32, 0.72, 0, 1)'
},
transitionDuration: {
180: '180ms',
260: '260ms'
},
boxShadow: {
lifted: '0 12px 30px -14px rgba(15, 23, 42, 0.25)',
outline: '0 0 0 1px rgba(59, 130, 246, 0.25)'
},
keyframes: {
'fade-in-up': {
'0%': { opacity: '0', transform: 'translateY(8px) scale(0.98)' },
'100%': { opacity: '1', transform: 'translateY(0) scale(1)' }
},
'float-soft': {
'0%': { transform: 'translate3d(0,0,0) scale(1)' },
'50%': { transform: 'translate3d(4px,-6px,0) scale(1.03)' },
'100%': { transform: 'translate3d(0,0,0) scale(1)' }
}
},
animation: {
'fade-in-up': 'fade-in-up 0.6s ease-out both',
'float-soft': 'float-soft 12s ease-in-out infinite'
},
typography: (theme) => ({
DEFAULT: {
css: {
color: theme('colors.slate.700'),
a: {
color: 'var(--color-accent-text-light)',
'&:hover': {
color: 'var(--color-accent)'
}
},
h1: {
fontWeight: '700',
letterSpacing: '-0.03em',
fontFamily: 'var(--font-serif-eng), "Songti SC", serif',
},
h2: {
fontWeight: '600',
letterSpacing: '-0.02em',
fontFamily: 'var(--font-serif-eng), "Songti SC", serif',
},
blockquote: {
fontStyle: 'normal',
borderLeftColor: 'var(--color-accent-soft)',
color: theme('colors.slate.700'),
backgroundColor: theme('colors.slate.50')
},
code: {
backgroundColor: theme('colors.slate.100'),
padding: '0.15rem 0.35rem',
borderRadius: '0.25rem'
}
}
},
dark: {
css: {
// Slightly softer than pure white for body text
color: theme('colors.slate.200'),
a: {
color: 'var(--color-accent-text-dark)',
'&:hover': {
color: 'var(--color-accent)'
}
},
strong: {
color: theme('colors.slate.50'),
fontWeight: '700'
},
b: {
color: theme('colors.slate.50'),
fontWeight: '700'
},
em: {
color: theme('colors.slate.100')
},
h1: {
color: theme('colors.slate.50')
},
h2: {
color: theme('colors.slate.50')
},
blockquote: {
borderLeftColor: 'var(--color-accent)',
backgroundColor: theme('colors.slate.800'),
color: theme('colors.slate.200'),
p: {
color: theme('colors.slate.200')
}
},
code: {
backgroundColor: theme('colors.slate.800')
}
}
}
})
},
},
plugins: [require('@tailwindcss/typography')],
};