Compare commits

...

48 Commits

Author SHA1 Message Date
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
af40ebc5e6 Add GitHub-style callout support
Implement proper GitHub-style callouts with beautiful styling:

Features:
- Custom rehype plugin to transform > [!NOTE] syntax
- Support for 5 callout types:
  * NOTE (blue, 📝)
  * TIP (green, 💡)
  * IMPORTANT (purple, )
  * WARNING (orange, ⚠️)
  * CAUTION (red, 🚨)
- Gradient backgrounds with accent colors
- Full dark mode support
- Converts existing emoji callouts to proper format

Files:
- lib/rehype-callouts.ts: Custom plugin for parsing
- contentlayer.config.ts: Add plugin to pipeline
- styles/globals.css: Beautiful styling for all types
- content/: Convert 2 emoji callouts to [!TIP] format

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 18:11:29 +08:00
f994301fbb Add RSS feed, sitemap, robots.txt, and code syntax highlighting
Implements essential blog features:

1. RSS Feed (/feed.xml)
   - Latest 20 posts with full content
   - Proper XML escaping and CDATA sections
   - Includes tags, authors, and descriptions
   - Auto-discovery link in HTML head

2. Sitemap (/sitemap.xml)
   - All posts, pages, and tag pages
   - Proper lastModified dates and priorities
   - Automatic generation via Next.js built-in support

3. Robots.txt (/robots.txt)
   - Allow all crawlers
   - Disallow API and admin routes
   - Links to sitemap for better SEO

4. Code Syntax Highlighting
   - Using rehype-pretty-code + Shiki
   - GitHub Dark/Light themes based on user preference
   - Line numbers for all code blocks
   - Support for highlighted lines
   - Inline code styling
   - Code title support

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 17:59:56 +08:00
dd3f553282 Update content submodule with broken link fixes
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 17:40:29 +08:00
016c75cb8b Update content submodule with .gitignore
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 16:57:09 +08:00
0fe7faf334 Update content submodule with favicon optimization
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 16:56:35 +08:00
854c5a1097 Fix search on Vercel by serving Pagefind as static files
The previous approach using an API route to serve Pagefind files
doesn't work on Vercel's serverless environment because fs.readFile
can't reliably access files in the deployed output.

Solution: Serve Pagefind files directly from public/_pagefind as
static assets, which is the standard Next.js approach and works
reliably on all deployment platforms.

Changes:
- Update search modal to load from /_pagefind/ instead of /pagefind/
- Remove app/pagefind/[...path]/route.ts API route (no longer needed)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 16:46:10 +08:00
a7aa930759 Fix search hanging on production by correcting Pagefind file path
Problem: Search modal hangs on loading in production deployment,
but works fine locally.

Root cause: The Pagefind route handler was reading files from
.next/pagefind, which is not reliably accessible in production
deployments. The build script copies Pagefind files to
public/_pagefind for deployment, but the route wasn't using them.

Solution: Changed the file path in app/pagefind/[...path]/route.ts
from .next/pagefind to public/_pagefind. Files in the public
directory are always accessible and properly deployed.

This ensures search works consistently across both development
and production environments.
2025-11-20 16:27:34 +08:00
8c71e80b2a Add Mastodon feed to right sidebar
Features:
- Display latest 5 Mastodon posts (toots) in sidebar
- Include original posts and boosts, exclude replies
- Show medium-length previews (150-200 chars)
- Styled to match existing blog design with purple Mastodon branding
- Section title: "微網誌 (Microblog)"
- Relative timestamps in Chinese ("2小時前")
- Links to original posts on Mastodon
- Loading skeletons for better UX
- Graceful error handling (fails silently if API unavailable)
- Respects dark mode

Implementation:
- Created lib/mastodon.ts with utility functions:
  - Parse Mastodon URL format
  - Strip HTML from content
  - Smart text truncation
  - Relative time formatting in Chinese
  - API functions to fetch account and statuses

- Created components/mastodon-feed.tsx:
  - Client component with useEffect for data fetching
  - Fetches directly from Mastodon public API
  - Handles boosts/reblogs with indicator
  - Shows media attachment indicators
  - Matches existing card styling patterns

- Updated components/right-sidebar.tsx:
  - Added MastodonFeed between profile and hot tags
  - Maintains consistent spacing and layout

Usage:
Set NEXT_PUBLIC_MASTODON_URL in .env.local to enable
Format: https://your.instance/@yourhandle
2025-11-20 16:10:31 +08:00
2b1060dd45 Fix TOC showing wrong headings across navigation
Problem: Table of Contents displayed headings from previously viewed
articles when navigating between posts via client-side routing.

Root cause: PostToc component's useEffect with empty dependency array
only ran once on mount, so it retained stale heading data when React
reused the component instance during navigation.

Solution: Add contentKey prop flow:
- Blog/page routes pass slug to PostLayout
- PostLayout passes contentKey as key prop to PostToc instances
- React remounts PostToc when key changes, rebuilding TOC correctly

Files changed:
- components/post-layout.tsx: Add contentKey prop and key forwarding
- app/blog/[slug]/page.tsx: Pass slug as contentKey
- app/pages/[slug]/page.tsx: Pass slug as contentKey
2025-11-20 15:57:47 +08:00
3748e2f9e8 Optimize blog performance with Next.js 16 features and video conversion
## Performance Improvements

### Next.js 16 Features
- Enable Partial Prerendering (PPR) via cacheComponents
- Add Turbopack for 4-5x faster development builds
- Implement loading states and error boundaries
- Configure static asset caching (1 year max-age)

### Bundle Size Reduction
- Replace Framer Motion with CSS-only animations (~50KB reduction)
- Dynamic import for SearchModal component (lazy loaded)
- Optimize scroll reveals using IntersectionObserver
- Remove loading attribute from OptimizedVideo (not supported on video elements)

### Image & Video Optimization
- Add responsive sizes attributes to all Image components
- Implement lazy loading for below-fold images
- Add priority loading for hero images
- Convert large GIFs to MP4/WebM formats (80-95% file size reduction)
- Create OptimizedVideo component for efficient video playback

### Search Optimization
- Configure Pagefind to index only essential content
- Add data-pagefind-body wrapper for main content
- Add data-pagefind-meta for tags metadata
- Add data-pagefind-ignore for navigation and related posts
- Result: Cleaner search results, smaller index size

### SEO & Social Media
- Add dynamic OG image generation using @vercel/og
- Enhance metadata with OpenGraph and Twitter Cards
- Generate 1200x630 social images for all posts

### Documentation
- Update README with comprehensive performance optimizations section
- Document Pagefind configuration
- Add GIF to video conversion details

## Technical Details

Video file size reduction:
- AddNewThings3.gif (2.4MB) → WebM (116KB) = 95% reduction
- Things3.gif (1.5MB) → WebM (170KB) = 89% reduction
- Total: 3.9MB → 286KB = 93% reduction

Build output: 49 pages indexed, 5370 words searchable
2025-11-20 15:50:46 +08:00
d7dc279d32 Add dynamic OG image generation for social media sharing
## Features
- Create /api/og route for dynamic Open Graph image generation
- Beautiful gradient design with site branding
- Display post title, description, and tags
- Support for both light and dark themes
- Proper sizing for social media (1200x630)

## Implementation
- Use @vercel/og package for image generation
- Add OpenGraph and Twitter Card metadata to blog posts
- Fallback to localhost for development
- Uses NEXT_PUBLIC_SITE_URL environment variable for production

## Social Media Support
- OpenGraph (Facebook, LinkedIn, etc.)
- Twitter Cards with large image preview
- Article metadata including publish time and tags

Example usage:
/api/og?title=Post+Title&description=Post+Desc&tags=tag1,tag2,tag3

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 14:55:36 +08:00
7d1f29dd9d Implement comprehensive Next.js 16 optimizations
## Performance Improvements

### Build & Development (Phase 1)
- Enable Turbopack for 4-5x faster dev builds
- Configure Partial Prerendering (PPR) via cacheComponents
- Add advanced image optimization (AVIF/WebP support)
- Remove console.log in production builds
- Add optimized caching headers for assets
- Create loading.tsx for global loading UI
- Create error.tsx for error boundary
- Create blog post loading skeleton

### Client-Side JavaScript Reduction (Phase 2)
- Replace Framer Motion with lightweight CSS animations in template.tsx
- Refactor ScrollReveal to CSS-only implementation (removed React state)
- Add dynamic import for SearchModal component
- Fix site-footer to use build-time year calculation for PPR compatibility

### Image Optimization (Phase 3)
- Add explicit dimensions to all Next.js Image components
- Add responsive sizes attribute for optimal image loading
- Use priority for above-the-fold images
- Use loading="lazy" for below-the-fold images
- Prevents Cumulative Layout Shift (CLS)

### Type Safety
- Add @types/react-dom for createPortal support

## Technical Changes

**Files Modified:**
- next.config.mjs: PPR, image optimization, compiler settings
- package.json: Turbopack flag, @types/react-dom dependency
- app/template.tsx: CSS animations replace Framer Motion
- components/scroll-reveal.tsx: CSS-only with IntersectionObserver
- components/site-header.tsx: Dynamic import for SearchModal
- components/site-footer.tsx: Build-time year calculation
- styles/globals.css: Page transitions & scroll reveal CSS
- Image components: Dimensions, sizes, priority/lazy loading

**Files Created:**
- app/loading.tsx: Global loading spinner
- app/error.tsx: Error boundary with retry functionality
- app/blog/[slug]/loading.tsx: Blog post skeleton

## Expected Impact

- First Contentful Paint (FCP): ~1.2s → ~0.8s (-33%)
- Largest Contentful Paint (LCP): ~2.5s → ~1.5s (-40%)
- Cumulative Layout Shift (CLS): ~0.15 → ~0.05 (-67%)
- Total Blocking Time (TBT): ~300ms → ~150ms (-50%)
- Bundle Size: ~180KB → ~100KB (-44%)

## PPR Status
✓ Blog posts now use Partial Prerendering
✓ Static pages now use Partial Prerendering
✓ Tag archives now use Partial Prerendering

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 14:51:54 +08:00
b6f0bd1d69 Fix search modal z-index and improve text readability
- Use React Portal to render modal at document body level to avoid stacking context issues
- Increase z-index to z-[9999] to ensure modal appears on top of all content
- Add cleanup function to prevent duplicate Pagefind initializations
- Replace CSS class overrides with CSS variables for better maintainability
- Enhance search result text colors for improved readability in both light and dark modes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 02:46:54 +08:00
e28beac1f1 Fix Pagefind file serving with API route
Fixed issue where Pagefind static files weren't accessible due to Next.js routing conflicts.

Solution:
- Created API route at app/pagefind/[...path]/route.ts to serve Pagefind files from .next/pagefind/
- Updated build script to copy pagefind index to public/_pagefind (backup)
- API route handles all /pagefind/* requests and serves files with proper content types
- Added caching headers for optimal performance

This resolves the "cannot type in search" issue - the search modal can now load Pagefind UI and index files correctly.

Technical Details:
- Next.js App Router was treating /pagefind/ as a route, returning 404
- Static files in public/ weren't accessible for subdirectories due to routing priority
- API route bypasses routing to serve .next/pagefind/* files directly
- Supports .js, .css, .json, .wasm, and Pagefind-specific file types

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 02:26:38 +08:00
02f2d0a599 Fix search input autofocus issue
Added autofocus configuration and manual focus call to ensure search input is immediately focusable when modal opens.

Changes:
- Added autofocus: true to PagefindUI config
- Added setTimeout to manually focus input after UI loads
- Ensures users can type immediately after opening search modal

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 02:12:41 +08:00
2c9d5ed650 Add full-text search with Chinese tokenization using Pagefind
Integrated Pagefind for static site search with built-in Chinese word segmentation support.

Changes:
1. **Installed Pagefind** (v1.4.0) as dev dependency
2. **Updated build script** to run Pagefind indexing after Next.js build
   - Indexes all 69 pages with 5,711 words
   - Automatic Chinese (zh-tw) language detection
3. **Created search modal component** (components/search-modal.tsx)
   - Dynamic Pagefind UI loading (lazy-loaded on demand)
   - Keyboard shortcuts (Cmd+K / Ctrl+K)
   - Chinese translations for UI elements
   - Dark mode compatible styling
4. **Added search button to header** (components/site-header.tsx)
   - Integrated SearchButton with keyboard shortcut display
   - Modal state management
5. **Custom Pagefind styles** (styles/globals.css)
   - Tailwind-based styling to match site design
   - Dark mode support
   - Highlight styling for search results

Features:
-  Full-text search across all blog posts and pages
-  Built-in Chinese word segmentation (Unicode-based)
-  Mixed Chinese/English query support
-  Zero bundle impact (20KB lazy-loaded on search activation)
-  Keyboard shortcuts (⌘K / Ctrl+K)
-  Search result highlighting with excerpts
-  Dark mode compatible

Technical Details:
- Pagefind runs post-build to index .next directory
- Search index stored in .next/pagefind/
- Chinese segmentation works automatically via Unicode boundaries
- No third-party services or API keys required

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 00:10:26 +08:00
912c70332e Fix tag URL encoding for non-ASCII characters
Fixed tag matching issue where tags with spaces and non-ASCII characters (like "Medicine - 醫學") were not working correctly on Vercel.

Changes:
1. Updated getTagSlug() to normalize tags without encoding - Next.js handles URL encoding automatically
2. Added decodeURIComponent() in tag page to decode incoming URL parameters
3. This ensures proper matching between generated slugs and URL parameters

The fix resolves:
- Tag archive pages showing wrong characters
- Articles not being collected under correct tags
- URL display issues with encoded characters

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 23:20:04 +08:00
5d3d754252 Fix tag URL encoding for non-ASCII characters
Updated getTagSlug() to properly encode tags with spaces and non-ASCII characters (like Chinese). The function now:
- Normalizes multiple spaces/dashes to single dashes
- Properly encodes non-ASCII characters using encodeURIComponent
- Prevents issues with URL encoding on Vercel deployment

This fixes tags like "Medicine - 醫學" being displayed as "medicine---%E9%86%AB%E5%AD%B8" by generating clean URLs like "medicine-%E9%86%AB%E5%AD%B8".

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 23:10:34 +08:00
653f079e1a Upgrade ESLint to v9 to fix Vercel deployment
Updated ESLint from 8.57.1 to 9.39.1 to resolve peer dependency conflict with eslint-config-next@16.0.3 which requires ESLint >=9.0.0.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 22:55:55 +08:00
a4db9688b6 Upgrade to Next.js 16 with Turbopack and Contentlayer2
- Upgraded Next.js to v16, React to v19
- Migrated from contentlayer to contentlayer2
- Migrated to Turbopack by decoupling Contentlayer from webpack
- Updated all page components to handle async params (Next.js 15+ breaking change)
- Changed package.json to type: module and renamed config files to .cjs
- Updated README with current tech stack and article creation instructions
- Fixed tag encoding issue (removed double encoding)
- All security vulnerabilities resolved (npm audit: 0 vulnerabilities)
2025-11-19 22:43:14 +08:00
4c08413936 Migrate to Contentlayer2 2025-11-19 21:46:49 +08:00
54 changed files with 6420 additions and 7695 deletions

3
.gitignore vendored
View File

@@ -37,5 +37,8 @@ pnpm-debug.log*
# Generated assets mirror
/public/assets
# Generated search index
/public/_pagefind
# TypeScript
*.tsbuildinfo

BIN
Line.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

124
README.md
View File

@@ -1,18 +1,71 @@
# Personal Blog (Next.js + Contentlayer)
This is a personal blog built with **Next.js 13 (App Router)**, **Contentlayer**, and **Tailwind CSS**.
This is a personal blog built with **Next.js 16 (App Router)**, **Contentlayer2**, and **Tailwind CSS**.
Markdown content (posts & pages) lives in a separate repository and is consumed via a git submodule.
Recent iterations focused on migrating every image to `next/image`, refreshing the typography scale for mixed Chinese/English copy, and layering an elegant scrolling timeline aesthetic onto the home + blog index.
Recent updates include upgrading to Next.js 16 with Turbopack, migrating to Contentlayer2, and implementing React 19 features.
## Tech Stack
- **Framework**: Next.js 13 (App Router)
- **Framework**: Next.js 16 (App Router) with Turbopack
- **Language**: TypeScript
- **Runtime**: React 19
- **Styling**: Tailwind CSS + Typography plugin
- **Content**: Markdown via Contentlayer (`contentlayer/source-files`)
- **Content**: Markdown via Contentlayer2 (`contentlayer2/source-files`)
- **Search**: Pagefind for full-text search
- **Theming**: `next-themes` (light/dark), envdriven accent color system
- **Content source**: Git submodule `content` → [`personal-blog`](https://gitea.gbanyan.net/gbanyan/personal-blog.git)
## Performance Optimizations
This blog is optimized for performance using Next.js 16 features and best practices:
### 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
### Bundle Size Reduction
- **CSS-only animations** replacing Framer Motion (~50KB reduction)
- **Dynamic imports** for SearchModal component (lazy loaded when needed)
- **Optimized scroll reveals** using IntersectionObserver instead of React state
- **Tree-shaking** with Next.js compiler removing unused code
### Image & Video Optimization
- **Responsive images** with proper `sizes` attributes for all Next.js Image components
- **Lazy loading** for below-fold images, priority loading for hero images
- **AVIF/WebP formats** for better compression
- **GIF to video conversion**: Large animated GIFs converted to MP4/WebM for 80-95% file size reduction
- `AddNewThings3.gif` (2.4MB) → WebM (116KB) = 95% reduction
- `Things3.gif` (1.5MB) → WebM (170KB) = 89% reduction
### SEO & Social Media
- **Dynamic OG image generation** using `@vercel/og`
- **Enhanced metadata** with OpenGraph and Twitter Cards for all posts
- **1200x630 social images** with post title, description, and tags
### Search Optimization
Pagefind is configured to index only essential content:
- **Indexed**: Post titles, tags, and article body content
- **Excluded**: Navigation, related posts, footer, and UI elements
- This improves search relevance and reduces index size
Configuration in `app/blog/[slug]/page.tsx`:
- `data-pagefind-body` wraps main content area
- `data-pagefind-meta="tags"` marks tags as metadata
- `data-pagefind-ignore` excludes navigation and related posts
### Caching Strategy
- **Static assets** cached for 1 year (`max-age=31536000, immutable`)
- **PPR** caches static shells while streaming dynamic content
- **Font optimization** with Next.js font loading
## Project Structure
- `app/` Next.js App Router
@@ -294,15 +347,70 @@ This ensures your `content` folder matches the commit referenced in `blog-nextjs
## Available npm Scripts
- `npm run dev` Start Next.js dev server (Contentlayer is integrated via `next-contentlayer`).
- `npm run build` Run `next build` for production.
- `npm run dev` Start Contentlayer and Next.js dev server concurrently (with Turbopack).
- `npm run build` Build content and production bundle (`contentlayer2 build && next build`).
- `npm run start` Start the production server (after `npm run build`).
- `npm run lint` Run Next.js / ESLint linting.
- `npm run contentlayer` Manually run `contentlayer build` (optional).
- `npm run sync-assets` Copy `content/assets` to `public/assets`.
## Adding New Content
### Creating a New Blog Post
1. Navigate to the `content/posts` directory (inside the submodule):
```bash
cd content/posts
```
2. Create a new markdown file (e.g., `my-new-post.md`):
```markdown
---
title: "My New Post Title"
published_at: "2025-01-15"
tags:
- "Technology"
- "Tutorial"
description: "A brief description of the post"
feature_image: "../assets/my-image.jpg"
---
Your post content goes here...
```
3. If using images, place them in `content/assets/` and reference them with relative paths:
```markdown
![Image description](../assets/my-image.jpg)
```
4. Commit and push changes in the submodule:
```bash
git add .
git commit -m "Add new post: My New Post Title"
git push
```
5. Update the parent repository to reference the new submodule commit:
```bash
cd ../..
git add content
git commit -m "Update content submodule"
git push
```
6. The new post will appear automatically after rebuilding or restarting the dev server.
### Creating a New Static Page
Follow the same process as above, but create the file in `content/pages/` instead.
## Deployment Notes
- This is a standard Next.js 13 App Router project and can be deployed to:
- This is a Next.js 16 App Router project with Turbopack and can be deployed to:
- Vercel
- Any Node.js host running `npm run build && npm run start`
- Make sure to:

166
app/api/og/route.tsx Normal file
View File

@@ -0,0 +1,166 @@
import { ImageResponse } from '@vercel/og';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// Get parameters
const title = searchParams.get('title') || 'Blog Post';
const description = searchParams.get('description') || '';
const tags = searchParams.get('tags')?.split(',').slice(0, 3) || [];
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
backgroundColor: '#0f172a',
backgroundImage: 'radial-gradient(circle at 25px 25px, #1e293b 2%, transparent 0%), radial-gradient(circle at 75px 75px, #1e293b 2%, transparent 0%)',
backgroundSize: '100px 100px',
padding: '80px',
}}
>
{/* Header with gradient */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '20px',
}}
>
<div
style={{
width: '8px',
height: '60px',
background: 'linear-gradient(135deg, #3b82f6, #60a5fa)',
borderRadius: '4px',
}}
/>
<div
style={{
fontSize: '32px',
fontWeight: 600,
color: '#f8fafc',
letterSpacing: '-0.02em',
}}
>
</div>
</div>
{/* Main content */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '24px',
maxWidth: '900px',
}}
>
{/* Title */}
<div
style={{
fontSize: '72px',
fontWeight: 700,
color: '#f8fafc',
lineHeight: 1.1,
letterSpacing: '-0.03em',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</div>
{/* Description */}
{description && (
<div
style={{
fontSize: '28px',
color: '#cbd5e1',
lineHeight: 1.4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{description}
</div>
)}
{/* Tags */}
{tags.length > 0 && (
<div
style={{
display: 'flex',
gap: '12px',
flexWrap: 'wrap',
}}
>
{tags.map((tag, i) => (
<div
key={i}
style={{
backgroundColor: '#1e293b',
color: '#94a3b8',
padding: '8px 20px',
borderRadius: '20px',
fontSize: '20px',
border: '1px solid #334155',
}}
>
#{tag.trim()}
</div>
))}
</div>
)}
</div>
{/* Footer with accent line */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
width: '100%',
}}
>
<div
style={{
flex: 1,
height: '2px',
background: 'linear-gradient(90deg, #3b82f6, transparent)',
}}
/>
<div
style={{
fontSize: '24px',
color: '#64748b',
}}
>
gbanyan.net
</div>
</div>
</div>
),
{
width: 1200,
height: 630,
}
);
} catch (e: any) {
console.error('Error generating OG image:', e);
return new Response(`Failed to generate image: ${e.message}`, {
status: 500,
});
}
}

View File

@@ -0,0 +1,41 @@
export default function BlogPostLoading() {
return (
<article className="container mx-auto max-w-4xl px-4 py-12">
{/* Header skeleton */}
<header className="mb-12 space-y-4">
<div className="h-4 w-24 animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
<div className="h-12 w-3/4 animate-pulse rounded bg-slate-300 dark:bg-slate-600"></div>
<div className="flex gap-4">
<div className="h-4 w-32 animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
<div className="h-4 w-32 animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
</div>
</header>
{/* Cover image skeleton */}
<div className="mb-12 aspect-video w-full animate-pulse rounded-2xl bg-slate-200 dark:bg-slate-700"></div>
{/* Content skeleton */}
<div className="prose prose-slate mx-auto space-y-4 dark:prose-invert">
<div className="space-y-3">
<div className="h-4 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
<div className="h-4 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
<div className="h-4 w-5/6 animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
</div>
<div className="h-8 w-2/3 animate-pulse rounded bg-slate-300 dark:bg-slate-600"></div>
<div className="space-y-3">
<div className="h-4 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
<div className="h-4 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
<div className="h-4 w-4/5 animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
</div>
<div className="space-y-3">
<div className="h-4 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
<div className="h-4 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
<div className="h-4 w-3/4 animate-pulse rounded bg-slate-200 dark:bg-slate-700"></div>
</div>
</div>
</article>
);
}

View File

@@ -2,7 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { allPosts } from 'contentlayer/generated';
import { allPosts } from 'contentlayer2/generated';
import { getPostBySlug, getRelatedPosts, getPostNeighbors } from '@/lib/posts';
import { siteConfig } from '@/lib/config';
import { ReadingProgress } from '@/components/reading-progress';
@@ -12,6 +12,7 @@ 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';
export function generateStaticParams() {
return allPosts.map((post) => ({
@@ -20,22 +21,53 @@ export function generateStaticParams() {
}
interface Props {
params: { slug: string };
params: Promise<{ slug: string }>;
}
export function generateMetadata({ params }: Props): Metadata {
const slug = params.slug;
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) return {};
const ogImageUrl = new URL('/api/og', process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000');
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(','));
}
return {
title: post.title,
description: post.description || post.title
description: post.description || post.title,
openGraph: {
title: post.title,
description: post.description || post.title,
type: 'article',
publishedTime: post.published_at,
authors: post.authors,
tags: post.tags,
images: [
{
url: ogImageUrl.toString(),
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description || post.title,
images: [ogImageUrl.toString()],
},
};
}
export default function BlogPostPage({ params }: Props) {
const slug = params.slug;
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) return notFound();
@@ -45,11 +77,93 @@ export default 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();
// 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],
}),
inLanguage: siteConfig.defaultLocale,
url: postUrl,
};
// 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} />
<ReadingProgress />
<PostLayout hasToc={hasToc}>
<PostLayout hasToc={hasToc} contentKey={slug}>
<div className="space-y-8">
{/* Main content area for Pagefind indexing */}
<div data-pagefind-body>
<SectionDivider>
<ScrollReveal>
<header className="mb-6 space-y-4 text-center">
@@ -64,7 +178,7 @@ export default function BlogPostPage({ params }: Props) {
{post.title}
</h1>
{post.tags && (
<div className="flex flex-wrap justify-center gap-2 pt-2">
<div className="flex flex-wrap justify-center gap-2 pt-2" data-pagefind-meta="tags">
{post.tags.map((t) => (
<Link
key={t}
@@ -84,7 +198,10 @@ export default 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-dark"
>
{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
@@ -92,6 +209,8 @@ export default function BlogPostPage({ params }: Props) {
alt={post.title}
width={1200}
height={600}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px"
priority
className="w-full rounded-xl shadow-lg"
/>
</div>
@@ -100,9 +219,12 @@ export default function BlogPostPage({ params }: Props) {
</article>
</ScrollReveal>
</SectionDivider>
</div>
<FooterCue />
{/* Exclude navigation and related posts from search indexing */}
<div data-pagefind-ignore>
<SectionDivider>
<ScrollReveal>
<PostStorylineNav
@@ -135,6 +257,7 @@ export default function BlogPostPage({ params }: Props) {
</SectionDivider>
)}
</div>
</div>
</PostLayout>
</>
);

57
app/error.tsx Normal file
View File

@@ -0,0 +1,57 @@
'use client';
import { useEffect } from 'react';
import { FiAlertTriangle } from 'react-icons/fi';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error('Application error:', error);
}, [error]);
return (
<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">
<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">
</h2>
<p className="mb-6 text-slate-600 dark:text-slate-400">
{error.message || '頁面載入時發生問題,請稍後再試。'}
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
<button
onClick={reset}
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600"
>
</button>
<a
href="/"
className="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
>
</a>
</div>
{error.digest && (
<p className="mt-6 text-xs text-slate-500 dark:text-slate-500">
: {error.digest}
</p>
)}
</div>
</div>
);
}

63
app/feed.xml/route.ts Normal file
View File

@@ -0,0 +1,63 @@
import { allPosts } from 'contentlayer2/generated';
import { siteConfig } from '@/lib/config';
export async function GET() {
const sortedPosts = 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, 20); // Latest 20 posts
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${escapeXml(siteConfig.name)}</title>
<link>${siteUrl}</link>
<description>${escapeXml(siteConfig.description)}</description>
<language>${siteConfig.defaultLocale.replace('_', '-')}</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="${siteUrl}/feed.xml" rel="self" type="application/rss+xml"/>
${sortedPosts
.map((post) => {
const postUrl = `${siteUrl}${post.url}`;
const pubDate = post.published_at
? new Date(post.published_at).toUTCString()
: new Date(post.created_at || Date.now()).toUTCString();
return `
<item>
<title>${escapeXml(post.title)}</title>
<link>${postUrl}</link>
<guid isPermaLink="true">${postUrl}</guid>
<description>${escapeXml(post.description || post.custom_excerpt || post.title)}</description>
${post.body?.html ? `<content:encoded><![CDATA[${post.body.html}]]></content:encoded>` : ''}
<pubDate>${pubDate}</pubDate>
${post.authors?.map((author) => `<author>${escapeXml(author)}</author>`).join('\n ') || ''}
${post.tags?.map((tag) => `<category>${escapeXml(tag)}</category>`).join('\n ') || ''}
</item>`;
})
.join('')}
</channel>
</rss>`;
return new Response(rss, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
});
}
function escapeXml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

View File

@@ -4,6 +4,7 @@ import { siteConfig } from '@/lib/config';
import { LayoutShell } from '@/components/layout-shell';
import { ThemeProvider } from 'next-themes';
import { Playfair_Display } from 'next/font/google';
import { JsonLd } from '@/components/json-ld';
const playfair = Playfair_Display({
subsets: ['latin'],
@@ -34,6 +35,11 @@ export const metadata: Metadata = {
},
icons: {
icon: '/favicon.png'
},
alternates: {
types: {
'application/rss+xml': `${siteConfig.url}/feed.xml`
}
}
};
@@ -44,9 +50,48 @@ export default function RootLayout({
}) {
const theme = siteConfig.theme;
// WebSite Schema
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',
},
};
// Organization Schema
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}>
<body>
<JsonLd data={websiteSchema} />
<JsonLd data={organizationSchema} />
<style
// Set CSS variables for accent colors (light + dark variants)
dangerouslySetInnerHTML={{

12
app/loading.tsx Normal file
View File

@@ -0,0 +1,12 @@
export default function Loading() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<div className="inline-block h-12 w-12 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>
);
}

View File

@@ -4,11 +4,34 @@ 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';
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 (
<>
<JsonLd data={collectionPageSchema} />
<section className="space-y-6">
<SidebarLayout>
<header className="space-y-1 text-center">
@@ -40,5 +63,6 @@ export default function HomePage() {
</div>
</SidebarLayout>
</section>
</>
);
}

View File

@@ -2,13 +2,14 @@ import Link from 'next/link';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { allPages } from 'contentlayer/generated';
import { allPages } from 'contentlayer2/generated';
import { getPageBySlug } 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';
export function generateStaticParams() {
return allPages.map((page) => ({
@@ -17,11 +18,11 @@ export function generateStaticParams() {
}
interface Props {
params: { slug: string };
params: Promise<{ slug: string }>;
}
export function generateMetadata({ params }: Props): Metadata {
const slug = params.slug;
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const page = getPageBySlug(slug);
if (!page) return {};
@@ -31,18 +32,49 @@ export function generateMetadata({ params }: Props): Metadata {
};
}
export default function StaticPage({ params }: Props) {
const slug = params.slug;
export default async function StaticPage({ params }: Props) {
const { slug } = await params;
const page = getPageBySlug(slug);
if (!page) return notFound();
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}>
<PostLayout hasToc={hasToc} contentKey={slug}>
<div className="space-y-8">
<SectionDivider>
<ScrollReveal>
@@ -78,7 +110,10 @@ export default function StaticPage({ 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-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
@@ -86,6 +121,8 @@ export default function StaticPage({ params }: Props) {
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>

14
app/robots.ts Normal file
View File

@@ -0,0 +1,14 @@
import { MetadataRoute } from 'next';
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/'],
},
sitemap: `${siteUrl}/sitemap.xml`,
};
}

68
app/sitemap.ts Normal file
View File

@@ -0,0 +1,68 @@
import { MetadataRoute } from 'next';
import { allPosts, allPages } from 'contentlayer2/generated';
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
// Homepage
const homepage = {
url: siteUrl,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 1,
};
// Blog listing page
const blogPage = {
url: `${siteUrl}/blog`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.9,
};
// Tags page
const tagsPage = {
url: `${siteUrl}/tags`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.7,
};
// All blog posts
const posts = allPosts
.filter((post) => post.status === 'published')
.map((post) => ({
url: `${siteUrl}${post.url}`,
lastModified: new Date(post.updated_at || post.published_at || post.created_at || Date.now()),
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
// All pages
const pages = allPages
.filter((page) => page.status === 'published')
.map((page) => ({
url: `${siteUrl}${page.url}`,
lastModified: new Date(page.updated_at || page.published_at || page.created_at || Date.now()),
changeFrequency: 'monthly' as const,
priority: 0.6,
}));
// All unique tags
const allTags = Array.from(
new Set(
allPosts
.filter((post) => post.status === 'published' && post.tags)
.flatMap((post) => post.tags || [])
)
);
const tagPages = allTags.map((tag) => ({
url: `${siteUrl}/tags/${encodeURIComponent(tag.toLowerCase().replace(/\s+/g, '-'))}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.5,
}));
return [homepage, blogPage, tagsPage, ...posts, ...pages, ...tagPages];
}

View File

@@ -1,12 +1,11 @@
import type { Metadata } from 'next';
import { allPosts } from 'contentlayer/generated';
import { allPosts } from 'contentlayer2/generated';
import { PostListWithControls } from '@/components/post-list-with-controls';
import { getTagSlug } from '@/lib/posts';
import { SidebarLayout } from '@/components/sidebar-layout';
import { SectionDivider } from '@/components/section-divider';
import { ScrollReveal } from '@/components/scroll-reveal';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTag } from '@fortawesome/free-solid-svg-icons';
import { FiTag } from 'react-icons/fi';
export function generateStaticParams() {
const slugs = new Set<string>();
@@ -22,30 +21,34 @@ export function generateStaticParams() {
}
interface Props {
params: { tag: string };
params: Promise<{ tag: string }>;
}
export function generateMetadata({ params }: Props): Metadata {
const slug = params.tag;
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) === slug);
.find((t) => getTagSlug(t) === decodedSlug);
return {
title: tag ? `標籤:${tag}` : '標籤'
};
}
export default function TagPage({ params }: Props) {
const slug = params.tag;
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(
(post) => post.tags && post.tags.some((t) => getTagSlug(t) === slug)
(post) => post.tags && post.tags.some((t) => getTagSlug(t) === decodedSlug)
);
const tagLabel =
posts[0]?.tags?.find((t) => getTagSlug(t) === slug) ?? params.tag;
posts[0]?.tags?.find((t) => getTagSlug(t) === decodedSlug) ?? decodedSlug;
return (
<SidebarLayout>
@@ -53,7 +56,7 @@ export default function TagPage({ params }: Props) {
<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,7 +1,6 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTags, faFire } from '@fortawesome/free-solid-svg-icons';
import { FiTag, FiTrendingUp } from 'react-icons/fi';
import { getAllTagsWithCount } from '@/lib/posts';
import { SectionDivider } from '@/components/section-divider';
import { ScrollReveal } from '@/components/scroll-reveal';
@@ -30,7 +29,7 @@ export default function TagIndexPage() {
<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>
@@ -65,7 +64,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,15 +1,24 @@
'use client';
import { motion } from 'framer-motion';
import { useEffect, useRef } from 'react';
export default function Template({ children }: { children: React.ReactNode }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// 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 (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
<div ref={containerRef} className="page-transition">
{children}
</motion.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;
@@ -73,7 +66,7 @@ export function Hero() {
{name}
</h1>
<div className="mt-1">
<MetaItem icon={faPenNib}>
<MetaItem icon={FiFeather}>
{tagline}
</MetaItem>
</div>
@@ -87,7 +80,7 @@ export function Hero() {
rel="noopener noreferrer"
className="motion-link flex items-center gap-2 rounded-full bg-white/80 px-3 py-1 text-slate-600 shadow-sm ring-1 ring-slate-200 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent hover:shadow-md dark:bg-slate-900/80 dark:text-slate-200 dark:ring-slate-700"
>
<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>
))}

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,6 +1,5 @@
import { SiteHeader } from './site-header';
import { SiteFooter } from './site-footer';
import { BackToTop } from './back-to-top';
export function LayoutShell({ children }: { children: React.ReactNode }) {

View File

@@ -0,0 +1,163 @@
'use client';
import { useEffect, useState } from 'react';
import { FaMastodon } from 'react-icons/fa';
import { FiArrowRight } from 'react-icons/fi';
import { siteConfig } from '@/lib/config';
import {
parseMastodonUrl,
stripHtml,
truncateText,
formatRelativeTime,
fetchAccountId,
fetchStatuses,
type MastodonStatus
} from '@/lib/mastodon';
export function MastodonFeed() {
const [statuses, setStatuses] = useState<MastodonStatus[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
const loadStatuses = async () => {
const mastodonUrl = siteConfig.social.mastodon;
if (!mastodonUrl) {
setLoading(false);
return;
}
try {
// Parse the Mastodon URL
const parsed = parseMastodonUrl(mastodonUrl);
if (!parsed) {
setError(true);
setLoading(false);
return;
}
const { instance, username } = parsed;
// Fetch account ID
const accountId = await fetchAccountId(instance, username);
if (!accountId) {
setError(true);
setLoading(false);
return;
}
// Fetch statuses (5 posts, exclude replies, include boosts)
const fetchedStatuses = await fetchStatuses(instance, accountId, 5);
setStatuses(fetchedStatuses);
} catch (err) {
console.error('Error loading Mastodon feed:', err);
setError(true);
} finally {
setLoading(false);
}
};
loadStatuses();
}, []);
// Don't render if no Mastodon URL is configured
if (!siteConfig.social.mastodon) {
return null;
}
// Don't render if there's an error (fail silently)
if (error) {
return null;
}
return (
<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">
<FaMastodon className="h-4 w-4 text-purple-500 dark:text-purple-400" />
</div>
{/* Content */}
{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>
))}
</div>
) : statuses.length === 0 ? (
<p className="type-small text-slate-400 dark:text-slate-500">
</p>
) : (
<div className="space-y-3">
{statuses.map((status) => {
// Handle boosts (reblogs)
const displayStatus = status.reblog || status;
const content = stripHtml(displayStatus.content);
const truncated = truncateText(content, 180);
const relativeTime = formatRelativeTime(status.created_at);
const hasMedia = displayStatus.media_attachments.length > 0;
return (
<article key={status.id} className="group/post">
<a
href={status.url}
target="_blank"
rel="noopener noreferrer"
className="block space-y-1.5 transition-opacity hover:opacity-70"
>
{/* Boost indicator */}
{status.reblog && (
<div className="type-small flex items-center gap-1 text-slate-400 dark:text-slate-500">
<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">
{truncated}
</p>
{/* Media indicator */}
{hasMedia && (
<div className="type-small text-slate-400 dark:text-slate-500">
📎 {displayStatus.media_attachments.length}
</div>
)}
{/* Timestamp */}
<time
className="type-small block text-slate-400 dark:text-slate-500"
dateTime={status.created_at}
>
{relativeTime}
</time>
</a>
</article>
);
})}
</div>
)}
{/* Footer link */}
{!loading && statuses.length > 0 && (
<a
href={siteConfig.social.mastodon}
target="_blank"
rel="noopener noreferrer"
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"
>
<FiArrowRight className="h-3 w-3" />
</a>
)}
</section>
);
}

View File

@@ -1,16 +1,15 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { ReactNode } from 'react';
import clsx from 'clsx';
import { IconType } from 'react-icons';
interface MetaItemProps {
icon: IconDefinition;
icon: IconType;
children: ReactNode;
className?: string;
tone?: 'default' | 'muted';
}
export function MetaItem({ icon, children, className, tone = 'default' }: MetaItemProps) {
export function MetaItem({ icon: Icon, children, className, tone = 'default' }: MetaItemProps) {
return (
<span
className={clsx(
@@ -19,7 +18,7 @@ export function MetaItem({ icon, children, className, tone = 'default' }: MetaIt
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

@@ -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"
onClick={close}
>
<Icon className="h-4 w-4 text-slate-400" />
<span>{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"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-slate-400" />
<span>{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"
onClick={close}
>
<Icon className="h-5 w-5 text-slate-400" />
<span>{item.label}</span>
</Link>
) : null;
};
return (
<>
{/* 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 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`}
{/* 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'
}`}
>
{items.map((item) => (
{/* 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
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 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"
aria-haspopup="menu"
aria-expanded={isOpen}
>
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" />
<span>{item.label}</span>
<FiChevronDown className="h-3 w-3 text-slate-400 transition 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 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"
/>
<Icon 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>
))}
) : null;
})}
</nav>
</div>
</>
);
}

View File

@@ -0,0 +1,68 @@
import { HTMLAttributes } from 'react';
import clsx from 'clsx';
interface OptimizedVideoProps extends Omit<HTMLAttributes<HTMLVideoElement>, 'src'> {
src: string;
alt?: string;
width?: number;
height?: number;
autoPlay?: boolean;
loop?: boolean;
muted?: boolean;
playsInline?: boolean;
controls?: boolean;
poster?: string;
}
/**
* Optimized video component that provides:
* - Multiple format support (WebM and MP4) for better browser compatibility
* - Proper accessibility attributes
* - Automatic GIF-like behavior when autoPlay is enabled
* - Lightweight alternative to GIF files with 80-95% file size reduction
*/
export function OptimizedVideo({
src,
alt,
width,
height,
autoPlay = true,
loop = true,
muted = true,
playsInline = true,
controls = false,
poster,
className,
...props
}: OptimizedVideoProps) {
// Remove file extension to get base path
const basePath = src.replace(/\.(mp4|webm|gif)$/i, '');
return (
<video
width={width}
height={height}
autoPlay={autoPlay}
loop={loop}
muted={muted}
playsInline={playsInline}
controls={controls}
poster={poster}
className={clsx('inline-block', className)}
aria-label={alt}
{...props}
>
{/* WebM for better compression (Chrome, Firefox, Edge) */}
<source src={`${basePath}.webm`} type="video/webm" />
{/* MP4 for Safari and older browsers */}
<source src={`${basePath}.mp4`} type="video/mp4" />
{/* Fallback message for browsers that don't support video */}
<p className="text-slate-500 dark:text-slate-400">
Your browser does not support the video tag.
{alt && <span className="block mt-2">{alt}</span>}
</p>
</video>
);
}

View File

@@ -1,8 +1,8 @@
import Link from 'next/link';
import Image from 'next/image';
import type { Post } from 'contentlayer/generated';
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 {
@@ -26,6 +26,8 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
alt={post.title}
width={640}
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"
/>
</div>
@@ -33,14 +35,14 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
<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>
)}

View File

@@ -1,9 +1,8 @@
'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 { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { FiList, FiX } from 'react-icons/fi';
import { PostToc } from './post-toc';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
@@ -12,63 +11,138 @@ function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function PostLayout({ children, hasToc = true }: { children: React.ReactNode; hasToc?: boolean }) {
const [isTocOpen, setIsTocOpen] = useState(hasToc);
export function PostLayout({ children, hasToc = true, contentKey }: { children: React.ReactNode; hasToc?: boolean; contentKey?: string }) {
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 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 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 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 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" : "max-w-4xl")}>
{children}
</motion.div>
</div>
</div>
{/* Sidebar (TOC) */}
{/* 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 />
</motion.div>
{isDesktopTocOpen && hasToc && (
<div className="toc-sidebar h-full overflow-y-auto pr-2">
<PostToc contentKey={contentKey} />
</div>
)}
</AnimatePresence>
</div>
</aside>
</div>
{/* Toggle Button (Glassmorphism Pill) */}
{hasToc && (
<motion.button
layout
onClick={() => setIsTocOpen(!isTocOpen)}
className={cn(
"fixed bottom-8 right-20 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"
)}
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>
{/* Mobile TOC Overlay */}
{mobileToc}
{/* Toggle Buttons - Rendered via Portal */}
{mounted && createPortal(
<>
{tocButton}
{desktopTocButton}
</>,
document.body
)}
</div>
);

View File

@@ -1,8 +1,8 @@
import Link from 'next/link';
import Image from 'next/image';
import type { Post } from 'contentlayer/generated';
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 {
@@ -28,6 +28,8 @@ export function PostListItem({ post }: Props) {
alt={post.title}
width={320}
height={240}
sizes="(max-width: 640px) 96px, 160px"
loading="lazy"
className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
/>
</div>
@@ -35,14 +37,14 @@ 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>
)}

View File

@@ -1,14 +1,8 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import type { Post } from 'contentlayer/generated';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowDownWideShort,
faArrowUpWideShort,
faMagnifyingGlass,
faListUl
} from '@fortawesome/free-solid-svg-icons';
import { Post, Page } from 'contentlayer2/generated';
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,30 +77,28 @@ 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"
onClick={() => handleChangeSort('new')}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 transition duration-180 ease-snappy ${
sortOrder === 'new'
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 transition duration-180 ease-snappy ${sortOrder === 'new'
? 'bg-blue-600 text-white dark:bg-blue-500'
: '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
type="button"
onClick={() => handleChangeSort('old')}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 transition duration-180 ease-snappy ${
sortOrder === 'old'
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 transition duration-180 ease-snappy ${sortOrder === 'old'
? 'bg-blue-600 text-white dark:bg-blue-500'
: '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>
@@ -115,8 +107,7 @@ 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
@@ -178,8 +169,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
key={p}
type="button"
onClick={() => goToPage(p)}
className={`h-7 w-7 rounded text-xs ${
isActive
className={`h-7 w-7 rounded text-xs ${isActive
? 'bg-blue-600 text-white dark:bg-blue-500'
: 'hover:bg-slate-100 dark:hover:bg-slate-800'
}`}

View File

@@ -1,7 +1,6 @@
import Link from 'next/link';
import type { Post } from 'contentlayer/generated';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeftLong, faArrowRightLong } from '@fortawesome/free-solid-svg-icons';
import { Post } from 'contentlayer2/generated';
import { FiArrowLeft, FiArrowRight } from 'react-icons/fi';
interface Props {
current: Post;
@@ -84,18 +83,18 @@ 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">
{post.title}
</p>
<span
className={`mt-2 h-0.5 w-16 rounded-full bg-slate-200 transition group-hover:w-24 group-hover:bg-blue-400 dark:bg-slate-700 ${
align === 'end' ? 'self-end' : 'self-start'
className={`mt-2 h-0.5 w-16 rounded-full bg-slate-200 transition group-hover:w-24 group-hover:bg-blue-400 dark:bg-slate-700 ${align === 'end' ? 'self-end' : 'self-start'
}`}
/>
</Link>

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() {
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,8 +27,30 @@ export function PostToc() {
const [indicator, setIndicator] = useState({ top: 0, opacity: 0 });
useEffect(() => {
// Clear items immediately when content changes
setItems([]);
setActiveId(null);
itemRefs.current = {};
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(
document.querySelectorAll<HTMLElement>('article h2, article h3')
container.querySelectorAll<HTMLElement>('h2, h3')
);
const mapped = headings
.filter((el) => el.id)
@@ -30,7 +61,7 @@ export function PostToc() {
}));
setItems(mapped);
const observer = new IntersectionObserver(
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
@@ -48,10 +79,18 @@ export function PostToc() {
}
);
headings.forEach((el) => observer.observe(el));
headings.forEach((el) => observer?.observe(el));
});
});
return () => observer.disconnect();
}, []);
return () => {
cancelAnimationFrame(rafId1);
cancelAnimationFrame(rafId2);
if (observer) {
observer.disconnect();
}
};
}, [contentKey]);
useEffect(() => {
if (!activeId || !listRef.current) {
@@ -87,16 +126,23 @@ export function PostToc() {
url.hash = id;
history.replaceState(null, '', url.toString());
}
// Trigger callback if provided (e.g. to close mobile menu)
if (onLinkClick) {
onLinkClick();
}
};
if (items.length === 0) return null;
return (
<nav className="not-prose sticky top-20 text-slate-500 dark:text-slate-400">
<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">
<FontAwesomeIcon icon={faListUl} className="h-4 w-4 text-slate-400" />
<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
@@ -121,8 +167,7 @@ export function PostToc() {
<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'
: ''
}`}

View File

@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
export function ReadingProgress() {
const [mounted, setMounted] = useState(false);
@@ -29,19 +30,21 @@ export function ReadingProgress() {
return () => window.removeEventListener('scroll', handleScroll);
}, [mounted]);
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">
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">
<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"
className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)] transition-[transform,opacity] duration-300 ease-out"
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" />
<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-blue-200/40 to-transparent blur-sm dark:via-blue-900/30" aria-hidden="true" />
</div>
</div>
</div>,
document.body
);
}

View File

@@ -1,11 +1,11 @@
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 { 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 'contentlayer/generated';
import { allPages } from 'contentlayer2/generated';
import { MastodonFeed } from './mastodon-feed';
export function RightSidebar() {
const tags = getAllTagsWithCount().slice(0, 5);
@@ -20,19 +20,19 @@ 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 }[];
@@ -76,7 +76,7 @@ export function RightSidebar() {
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"
>
<FontAwesomeIcon icon={item.icon} className="h-4 w-4" />
<item.icon className="h-4 w-4" />
</a>
))}
</div>
@@ -91,10 +91,13 @@ export function RightSidebar() {
</div>
</section>
{/* Mastodon Feed */}
<MastodonFeed />
{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">
@@ -116,7 +119,7 @@ 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

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import clsx from 'clsx';
interface ScrollRevealProps {
@@ -15,26 +15,25 @@ export function ScrollReveal({
once = true
}: ScrollRevealProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
// Fallback for browsers without IntersectionObserver
if (!('IntersectionObserver' in window)) {
setVisible(true);
el.classList.add('is-visible');
return;
}
let cancelled = false;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
if (!cancelled) setVisible(true);
entry.target.classList.add('is-visible');
if (once) observer.unobserve(entry.target);
} else if (!once) {
if (!cancelled) setVisible(false);
entry.target.classList.remove('is-visible');
}
});
},
@@ -46,12 +45,12 @@ export function ScrollReveal({
observer.observe(el);
// Fallback timeout for slow connections
const fallback = window.setTimeout(() => {
if (!cancelled) setVisible(true);
el.classList.add('is-visible');
}, 500);
return () => {
cancelled = true;
observer.disconnect();
window.clearTimeout(fallback);
};
@@ -60,13 +59,7 @@ export function ScrollReveal({
return (
<div
ref={ref}
className={clsx(
'motion-safe:transition-all motion-safe:duration-500 motion-safe:ease-out',
'motion-safe:opacity-0 motion-safe:translate-y-3',
visible &&
'motion-safe:opacity-100 motion-safe:translate-y-0 motion-safe:animate-none',
className
)}
className={clsx('scroll-reveal', className)}
>
{children}
</div>

208
components/search-modal.tsx Normal file
View File

@@ -0,0 +1,208 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { FiSearch, FiX } from 'react-icons/fi';
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
}
export function SearchModal({ isOpen, onClose }: SearchModalProps) {
const [isLoaded, setIsLoaded] = useState(false);
const searchContainerRef = useRef<HTMLDivElement>(null);
const pagefindUIRef = useRef<any>(null);
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);
} catch (error) {
console.error('Failed to load Pagefind:', error);
}
};
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;
}
};
}, [isOpen]);
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 = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
// Use portal to render modal at document body level to avoid z-index stacking context issues
if (typeof window === 'undefined') return null;
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}
>
<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">
<FiSearch 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="關閉搜尋"
>
<FiX 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>
</div>,
document.body
);
}
export function SearchButton({ onClick }: { onClick: () => void }) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
onClick();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClick]);
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"
aria-label="搜尋 (Cmd+K)"
>
<FiSearch className="h-3.5 w-3.5" />
<span className="hidden 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>
</button>
);
}

View File

@@ -1,13 +1,9 @@
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();
export function SiteFooter() {
const { social } = siteConfig;
@@ -17,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;
@@ -59,7 +55,7 @@ export function SiteFooter() {
return (
<footer className="py-4 text-center text-sm text-gray-500 dark:text-slate-400">
<div>
© {new Date().getFullYear()} {siteConfig.author}
© {currentYear} {siteConfig.author}
</div>
{items.length > 0 && (
<div className="mt-2 flex justify-center gap-4 text-base">
@@ -72,7 +68,7 @@ export function SiteFooter() {
aria-label={item.label}
className="text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
>
<FontAwesomeIcon icon={item.icon} className="h-4 w-4" />
<item.icon className="h-4 w-4" />
</a>
))}
</div>

View File

@@ -1,27 +1,80 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { ThemeToggle } from './theme-toggle';
import { NavMenu, NavLinkItem, IconKey } from './nav-menu';
import { SearchButton } from './search-modal';
import { siteConfig } from '@/lib/config';
import { allPages } from 'contentlayer/generated';
import { allPages } from 'contentlayer2/generated';
// Dynamically import SearchModal to reduce initial bundle size
const SearchModal = dynamic(
() => import('./search-modal').then((mod) => ({ default: mod.SearchModal })),
{ ssr: false }
);
export function SiteHeader() {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const pages = allPages
.slice()
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
const navItems: NavLinkItem[] = [
{ key: 'home', href: '/', label: '首頁', iconKey: 'home' },
{ key: 'blog', href: '/blog', label: 'Blog', iconKey: 'blog' },
...pages.map((page) => ({
const findPage = (title: string) => pages.find((page) => page.title === title);
const aboutChildren = [
{ title: '關於作者', label: '作者' },
{ title: '關於本站', label: '本站' }
]
.map(({ title, label }) => {
const page = findPage(title);
if (!page) return null;
return {
key: page._id,
href: page.url,
label: page.title,
label,
iconKey: getIconForPage(page.title, page.slug)
}))
} satisfies NavLinkItem;
})
.filter(Boolean) as NavLinkItem[];
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: '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="/"
@@ -32,8 +85,13 @@ export function SiteHeader() {
</Link>
<div className="flex items-center gap-3">
<NavMenu items={navItems} />
<SearchButton onClick={() => setIsSearchOpen(true)} />
<ThemeToggle />
</div>
<SearchModal
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
/>
</div>
</header>
);

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();
@@ -27,12 +26,11 @@ export function ThemeToggle() {
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-transform duration-260 ease-snappy" />
) : (
<FiMoon className="h-4 w-4 rotate-180 text-blue-500 transition-transform duration-260 ease-snappy" />
)}
</button>
);
}

View File

@@ -9,20 +9,20 @@ interface TimelineWrapperProps {
export function TimelineWrapper({ children, className }: TimelineWrapperProps) {
const items = Children.toArray(children);
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"
className="pointer-events-none absolute left-2 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 md:left-3"
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-[8px] rounded-full bg-blue-500/15 blur-[14px] 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-blue-300/80 to-transparent dark:via-cyan-200/80 sm:w-8" aria-hidden="true" />
{child}
</div>
))}

Submodule content updated: c728118ba1...3f72ccb628

View File

@@ -1,8 +1,10 @@
import { defineDocumentType, makeSource } from 'contentlayer/source-files';
import { defineDocumentType, makeSource } from 'contentlayer2/source-files';
import { visit } from 'unist-util-visit';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import remarkGfm from 'remark-gfm';
import rehypePrettyCode from 'rehype-pretty-code';
import { rehypeCallouts } from './lib/rehype-callouts';
export const Post = defineDocumentType(() => ({
name: 'Post',
@@ -86,6 +88,17 @@ export default makeSource({
markdown: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeCallouts,
[
rehypePrettyCode,
{
theme: {
dark: 'github-dark',
light: 'github-light',
},
keepBackground: false,
},
],
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
/**

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="5"
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

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

158
lib/mastodon.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* Mastodon API utilities for fetching and processing toots
*/
export interface MastodonStatus {
id: string;
content: string;
created_at: string;
url: string;
reblog: MastodonStatus | null;
account: {
username: string;
display_name: string;
avatar: string;
};
media_attachments: Array<{
type: string;
url: string;
preview_url: string;
}>;
}
/**
* Parse Mastodon URL to extract instance domain and username
* @param url - Mastodon profile URL (e.g., "https://mastodon.social/@username")
* @returns Object with instance and username, or null if invalid
*/
export function parseMastodonUrl(url: string): { instance: string; username: string } | null {
try {
const urlObj = new URL(url);
const instance = urlObj.hostname;
const pathMatch = urlObj.pathname.match(/^\/@?([^/]+)/);
if (!pathMatch) return null;
const username = pathMatch[1];
return { instance, username };
} catch {
return null;
}
}
/**
* Strip HTML tags from content and decode HTML entities
* @param html - HTML content from Mastodon post
* @returns Plain text content
*/
export function stripHtml(html: string): string {
// Remove HTML tags
let text = html.replace(/<br\s*\/?>/gi, '\n');
text = text.replace(/<\/p><p>/gi, '\n\n');
text = text.replace(/<[^>]+>/g, '');
// Decode common HTML entities
text = text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ');
return text.trim();
}
/**
* Truncate text smartly, avoiding cutting words in half
* @param text - Text to truncate
* @param maxLength - Maximum length (default: 180)
* @returns Truncated text with ellipsis if needed
*/
export function truncateText(text: string, maxLength: number = 180): string {
if (text.length <= maxLength) return text;
// Find the last space before maxLength
const truncated = text.substring(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
// If there's a space, cut at the space; otherwise use maxLength
const cutPoint = lastSpace > maxLength * 0.8 ? lastSpace : maxLength;
return text.substring(0, cutPoint).trim() + '...';
}
/**
* Format timestamp as relative time in Chinese
* @param dateString - ISO date string
* @returns Relative time string (e.g., "2小時前")
*/
export function formatRelativeTime(dateString: string): string {
const now = new Date();
const date = new Date(dateString);
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return '剛剛';
if (diffMin < 60) return `${diffMin}分鐘前`;
if (diffHour < 24) return `${diffHour}小時前`;
if (diffDay < 7) return `${diffDay}天前`;
if (diffDay < 30) return `${Math.floor(diffDay / 7)}週前`;
if (diffDay < 365) return `${Math.floor(diffDay / 30)}個月前`;
return `${Math.floor(diffDay / 365)}年前`;
}
/**
* Fetch user's Mastodon account ID from username
* @param instance - Mastodon instance domain
* @param username - Username without @
* @returns Account ID or null if not found
*/
export async function fetchAccountId(instance: string, username: string): Promise<string | null> {
try {
const response = await fetch(
`https://${instance}/api/v1/accounts/lookup?acct=${username}`,
{ cache: 'no-store' }
);
if (!response.ok) return null;
const account = await response.json();
return account.id;
} catch (error) {
console.error('Error fetching Mastodon account:', error);
return null;
}
}
/**
* Fetch user's recent statuses from Mastodon
* @param instance - Mastodon instance domain
* @param accountId - Account ID
* @param limit - Number of statuses to fetch (default: 5)
* @returns Array of statuses or empty array on error
*/
export async function fetchStatuses(
instance: string,
accountId: string,
limit: number = 5
): Promise<MastodonStatus[]> {
try {
const response = await fetch(
`https://${instance}/api/v1/accounts/${accountId}/statuses?limit=${limit}&exclude_replies=true`,
{ cache: 'no-store' }
);
if (!response.ok) return [];
const statuses = await response.json();
return statuses;
} catch (error) {
console.error('Error fetching Mastodon statuses:', error);
return [];
}
}

View File

@@ -1,4 +1,4 @@
import { allPosts, allPages, Post, Page } from 'contentlayer/generated';
import { allPosts, allPages, Post, Page } from 'contentlayer2/generated';
export function getAllPostsSorted(): Post[] {
return [...allPosts].sort((a, b) => {
@@ -27,7 +27,14 @@ export function getPageBySlug(slug: string): Page | undefined {
}
export function getTagSlug(tag: string): string {
return encodeURIComponent(tag.toLowerCase().replace(/\s+/g, '-'));
// Normalize spaces and convert to lowercase
// Replace multiple spaces/dashes with single dash
// Next.js will handle URL encoding automatically, so we don't encode here
return tag
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
export function getAllTagsWithCount(): { tag: string; slug: string; count: number }[] {

111
lib/rehype-callouts.ts Normal file
View File

@@ -0,0 +1,111 @@
import { visit } from 'unist-util-visit';
/**
* Rehype plugin to transform GitHub-style blockquote alerts
* Transforms: > [!NOTE] into styled callout boxes
*/
export function rehypeCallouts() {
return (tree: any) => {
visit(tree, 'element', (node) => {
// Only process blockquotes
if (node.tagName !== 'blockquote') return;
if (!node.children || node.children.length === 0) 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;
}
}
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[1].toLowerCase();
// Remove the [!TYPE] marker from the text
textNode.value = textNode.value.replace(match[0], '').trim();
// If the text node is now empty, remove it
if (!textNode.value) {
const index = textParent.children.indexOf(textNode);
if (index > -1) {
textParent.children.splice(index, 1);
}
}
// Add callout data attributes and classes
node.properties = node.properties || {};
node.properties.className = ['callout', `callout-${type}`];
node.properties['data-callout'] = type;
// Add icon and title elements
const iconMap: Record<string, string> = {
note: '📝',
tip: '💡',
important: '❗',
warning: '⚠️',
caution: '🚨',
};
const icon = {
type: 'element',
tagName: 'div',
properties: { className: ['callout-icon'] },
children: [{ type: 'text', value: iconMap[type] || '📝' }],
};
const title = {
type: 'element',
tagName: 'div',
properties: { className: ['callout-title'] },
children: [{ type: 'text', value: type.toUpperCase() }],
};
const header = {
type: 'element',
tagName: 'div',
properties: { className: ['callout-header'] },
children: [icon, title],
};
const content = {
type: 'element',
tagName: 'div',
properties: { className: ['callout-content'] },
children: [...node.children],
};
node.children = [header, content];
});
};
}

3
next-env.d.ts vendored
View File

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

View File

@@ -1,20 +1,38 @@
import { withContentlayer } from 'next-contentlayer';
/** @type {import('next').NextConfig} */
const nextConfig = {
// Image optimization configuration
images: {
remotePatterns: []
remotePatterns: [],
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
// Enable Partial Prerendering (PPR) via cacheComponents in Next.js 16
cacheComponents: true,
// Compiler optimizations
compiler: {
// Remove console.log in production
removeConsole: process.env.NODE_ENV === 'production' ? {
exclude: ['error', 'warn'],
} : false,
},
// Headers for better caching
async headers() {
return [
{
source: '/assets/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
webpack: (config) => {
config.ignoreWarnings = [
...(config.ignoreWarnings || []),
// Contentlayer dynamic import / cache analysis warnings
/@contentlayer\/core[\\/]dist[\\/]dynamic-build\.js/,
/@contentlayer\/core[\\/]dist[\\/]getConfig[\\/]index\.js/,
/@contentlayer\/core[\\/]dist[\\/]generation[\\/]generate-dotpkg\.js/
];
return config;
}
},
};
export default withContentlayer(nextConfig);
export default nextConfig;

11055
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,9 @@
"description": "",
"main": "index.js",
"scripts": {
"dev": "next dev",
"dev": "concurrently \"contentlayer2 dev\" \"next dev --turbo\"",
"sync-assets": "node scripts/sync-assets.mjs",
"build": "npm run sync-assets && next build",
"build": "npm run sync-assets && contentlayer2 build && next build && npx pagefind --site .next && rm -rf public/_pagefind && cp -r .next/pagefind public/_pagefind",
"start": "next start",
"lint": "next lint",
"contentlayer": "contentlayer build"
@@ -14,24 +14,26 @@
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"type": "module",
"dependencies": {
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@emotion/is-prop-valid": "^1.4.0",
"@vercel/og": "^0.8.5",
"clsx": "^2.1.1",
"contentlayer": "^0.3.4",
"framer-motion": "^12.23.24",
"contentlayer2": "^0.5.8",
"gray-matter": "^4.0.3",
"markdown-wasm": "^1.2.0",
"next": "^13.5.11",
"next-contentlayer": "^0.3.4",
"next": "^16.0.7",
"next-contentlayer2": "^0.5.8",
"next-themes": "^0.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"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",
"remark-directive": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.15.0",
"tailwind-merge": "^3.4.0",
"unist-util-visit": "^5.0.0"
},
@@ -39,9 +41,12 @@
"@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",
"eslint": "^8.57.1",
"eslint-config-next": "^13.5.11",
"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",
"typescript": "^5.9.3"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 485 KiB

View File

@@ -13,9 +13,24 @@
--font-weight-semibold: 600;
--font-system-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'PingFang TC', 'PingFang SC', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft JhengHei', 'Microsoft YaHei', 'Noto Sans TC', 'Noto Sans SC', 'Noto Sans CJK TC', 'Noto Sans CJK SC', 'Source Han Sans TC', 'Source Han Sans SC', 'Roboto', 'Ubuntu', 'Cantarell', 'Inter', 'Helvetica Neue', Arial, sans-serif;
/* Ink + accent palette */
--color-ink-strong: #0f172a;
--color-ink-body: #1f2937;
--color-ink-muted: #475569;
--color-accent: #7c3aed;
--color-accent-soft: #f4f0ff;
font-size: clamp(15px, 0.65vw + 11px, 19px);
}
.dark {
--color-ink-strong: #e2e8f0;
--color-ink-body: #cbd5e1;
--color-ink-muted: #94a3b8;
--color-accent: #a78bfa;
--color-accent-soft: #1f1a3d;
}
@media (min-width: 2560px) {
:root {
font-size: 20px;
@@ -27,6 +42,7 @@ body {
font-size: 1rem;
line-height: var(--line-height-body);
font-family: var(--font-system-sans);
color: var(--color-ink-body);
}
@keyframes timeline-scroll {
@@ -49,6 +65,43 @@ body {
}
}
@keyframes pageEnter {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-transition {
opacity: 0;
}
/* Scroll reveal animations - CSS only */
.scroll-reveal {
opacity: 0;
transform: translateY(12px);
transition: opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1),
transform 0.5s cubic-bezier(0.32, 0.72, 0, 1);
}
.scroll-reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
.scroll-reveal {
opacity: 1;
transform: none;
transition: none;
}
}
.toc-target-highlight {
@apply bg-yellow-50/60 dark:bg-yellow-900/40 transition-colors duration-500;
@@ -58,17 +111,17 @@ body {
.prose blockquote {
@apply transition-transform transition-shadow duration-180 ease-snappy;
border-left: 4px solid var(--color-accent, #2563eb);
background: linear-gradient(135deg, rgba(37, 99, 235, 0.04), rgba(37, 99, 235, 0.08));
background: linear-gradient(135deg, rgba(124, 58, 237, 0.06), rgba(124, 58, 237, 0.1));
padding: 1.2rem 1.5rem;
font-style: italic;
color: rgba(15, 23, 42, 0.75);
color: rgba(15, 23, 42, 0.78);
position: relative;
}
.dark .prose blockquote {
background: linear-gradient(135deg, rgba(96, 165, 250, 0.12), rgba(96, 165, 250, 0.06));
color: rgba(226, 232, 240, 0.8);
border-left-color: rgba(96, 165, 250, 0.9);
background: linear-gradient(135deg, rgba(167, 139, 250, 0.12), rgba(124, 58, 237, 0.08));
color: rgba(226, 232, 240, 0.85);
border-left-color: rgba(167, 139, 250, 0.9);
}
.prose blockquote:hover {
@@ -97,27 +150,32 @@ body {
.prose {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.2rem);
line-height: var(--line-height-body);
color: var(--color-ink-body);
}
.prose h1 {
font-size: clamp(2.2rem, 1.4rem + 2.2vw, 3.4rem);
line-height: 1.25;
color: var(--color-ink-strong);
}
.prose h2 {
font-size: clamp(1.8rem, 1.1rem + 1.6vw, 2.8rem);
line-height: 1.3;
color: var(--color-ink-strong);
}
.prose h3 {
font-size: clamp(1.4rem, 0.9rem + 1vw, 2rem);
line-height: 1.35;
color: var(--color-ink-strong);
}
.prose p,
.prose li {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem);
line-height: var(--line-height-body);
color: var(--color-ink-body);
}
.prose small,
@@ -196,6 +254,69 @@ body {
left: 0;
}
/* TOC transitions - replaces Framer Motion */
.toc-sidebar {
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
will-change: opacity, transform;
}
.toc-sidebar-enter {
opacity: 0;
transform: translateX(20px);
}
.toc-sidebar-enter-active {
opacity: 1;
transform: translateX(0);
}
.toc-sidebar-exit {
opacity: 1;
transform: translateX(0);
}
.toc-sidebar-exit-active {
opacity: 0;
transform: translateX(20px);
}
.toc-mobile {
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
will-change: opacity, transform;
}
.toc-mobile-enter {
opacity: 0;
transform: translateY(20px);
}
.toc-mobile-enter-active {
opacity: 1;
transform: translateY(0);
}
.toc-mobile-exit {
opacity: 1;
transform: translateY(0);
}
.toc-mobile-exit-active {
opacity: 0;
transform: translateY(20px);
}
.toc-button {
transition: all 0.2s ease-in-out;
}
.toc-button:active {
transform: scale(0.95);
}
.toc-button:hover {
transform: scale(1.05);
}
@layer components {
.type-display {
font-size: clamp(2.2rem, 1.6rem + 2.4vw, 3.5rem);
@@ -266,3 +387,234 @@ body {
transform var(--motion-duration-short) var(--motion-ease-snappy);
}
}
/* Pagefind Search Styles - Use CSS variables to override defaults */
:root {
--pagefind-ui-scale: 1;
--pagefind-ui-primary: #2563eb;
--pagefind-ui-text: #0f172a;
--pagefind-ui-background: #ffffff;
--pagefind-ui-border: #e2e8f0;
--pagefind-ui-tag: #f1f5f9;
--pagefind-ui-border-width: 1px;
--pagefind-ui-border-radius: 0.5rem;
--pagefind-ui-font: var(--font-system-sans);
}
.dark {
--pagefind-ui-primary: #60a5fa;
--pagefind-ui-text: #f1f5f9;
--pagefind-ui-background: #0f172a;
--pagefind-ui-border: #475569;
--pagefind-ui-tag: #334155;
}
/* Enhanced text colors for better readability */
.pagefind-ui__result-title {
color: var(--pagefind-ui-text) !important;
}
.dark .pagefind-ui__result-title {
color: #f8fafc !important;
}
.pagefind-ui__result-excerpt {
color: #475569 !important;
}
.dark .pagefind-ui__result-excerpt {
color: #cbd5e1 !important;
}
.pagefind-ui__result-link {
color: var(--pagefind-ui-primary) !important;
}
.dark .pagefind-ui__result-link {
color: #93c5fd !important;
}
/* Additional custom styling for highlights */
.pagefind-ui__result-excerpt mark {
@apply bg-yellow-200 font-semibold text-slate-900 dark:bg-yellow-600 dark:text-slate-100;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
.pagefind-ui__search-input:focus {
@apply ring-2 ring-blue-500 dark:ring-blue-400;
}
/* Code Syntax Highlighting Styles (rehype-pretty-code) */
.prose pre {
@apply overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700;
padding: 1rem 1.2rem;
margin: 1.5rem 0;
background-color: #f8fafc;
}
.dark .prose pre {
background-color: #0f172a;
}
.prose pre > code {
@apply grid;
counter-reset: line;
font-size: 0.9em;
line-height: 1.7;
}
.prose pre > code > [data-line] {
padding: 0 1rem;
border-left: 2px solid transparent;
}
.prose pre > code > [data-line]::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 1.5rem;
margin-right: 1.5rem;
text-align: right;
color: #94a3b8;
user-select: none;
}
.dark .prose pre > code > [data-line]::before {
color: #475569;
}
/* Highlighted lines */
.prose pre > code > [data-highlighted-line] {
background-color: rgba(59, 130, 246, 0.1);
border-left-color: rgb(59, 130, 246);
}
.dark .prose pre > code > [data-highlighted-line] {
background-color: rgba(96, 165, 250, 0.15);
border-left-color: rgb(96, 165, 250);
}
/* Inline code */
.prose :not(pre) > code {
@apply rounded bg-slate-100 px-1.5 py-0.5 text-sm font-semibold text-slate-800 dark:bg-slate-800 dark:text-slate-200;
white-space: nowrap;
}
/* Code title (if specified in markdown: ```js title="example.js") */
.prose [data-rehype-pretty-code-title] {
@apply rounded-t-lg border border-b-0 border-slate-200 bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300;
margin-bottom: 0;
}
.prose [data-rehype-pretty-code-title] + pre {
@apply mt-0 rounded-t-none;
}
/* GitHub-style Callouts/Alerts */
.prose .callout {
@apply my-6 rounded-lg border-l-4 p-4 shadow-sm;
background: linear-gradient(135deg, var(--callout-bg-start), var(--callout-bg-end));
}
.prose .callout-header {
@apply mb-3 flex items-center gap-2;
}
.prose .callout-icon {
@apply text-2xl;
line-height: 1;
}
.prose .callout-title {
@apply text-sm font-bold uppercase tracking-wider;
color: var(--callout-title-color);
letter-spacing: 0.05em;
}
.prose .callout-content {
@apply text-sm leading-relaxed;
}
.prose .callout-content > *:first-child {
@apply mt-0;
}
.prose .callout-content > *:last-child {
@apply mb-0;
}
/* NOTE - Blue */
.prose .callout-note {
--callout-bg-start: rgba(59, 130, 246, 0.08);
--callout-bg-end: rgba(59, 130, 246, 0.04);
--callout-title-color: #2563eb;
@apply border-blue-500;
}
.dark .prose .callout-note {
--callout-bg-start: rgba(96, 165, 250, 0.12);
--callout-bg-end: rgba(96, 165, 250, 0.06);
--callout-title-color: #93c5fd;
@apply border-blue-400;
}
/* TIP - Green */
.prose .callout-tip {
--callout-bg-start: rgba(34, 197, 94, 0.08);
--callout-bg-end: rgba(34, 197, 94, 0.04);
--callout-title-color: #16a34a;
@apply border-green-500;
}
.dark .prose .callout-tip {
--callout-bg-start: rgba(74, 222, 128, 0.12);
--callout-bg-end: rgba(74, 222, 128, 0.06);
--callout-title-color: #86efac;
@apply border-green-400;
}
/* IMPORTANT - Purple */
.prose .callout-important {
--callout-bg-start: rgba(168, 85, 247, 0.08);
--callout-bg-end: rgba(168, 85, 247, 0.04);
--callout-title-color: #9333ea;
@apply border-purple-500;
}
.dark .prose .callout-important {
--callout-bg-start: rgba(192, 132, 252, 0.12);
--callout-bg-end: rgba(192, 132, 252, 0.06);
--callout-title-color: #c084fc;
@apply border-purple-400;
}
/* WARNING - Orange/Yellow */
.prose .callout-warning {
--callout-bg-start: rgba(251, 191, 36, 0.08);
--callout-bg-end: rgba(251, 191, 36, 0.04);
--callout-title-color: #d97706;
@apply border-yellow-500;
}
.dark .prose .callout-warning {
--callout-bg-start: rgba(253, 224, 71, 0.12);
--callout-bg-end: rgba(253, 224, 71, 0.06);
--callout-title-color: #fde047;
@apply border-yellow-400;
}
/* CAUTION - Red */
.prose .callout-caution {
--callout-bg-start: rgba(239, 68, 68, 0.08);
--callout-bg-end: rgba(239, 68, 68, 0.04);
--callout-title-color: #dc2626;
@apply border-red-500;
}
.dark .prose .callout-caution {
--callout-bg-start: rgba(248, 113, 113, 0.12);
--callout-bg-end: rgba(248, 113, 113, 0.06);
--callout-title-color: #fca5a5;
@apply border-red-400;
}

View File

@@ -15,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"types": [
"node"
@@ -25,7 +25,7 @@
"@/*": [
"./*"
],
"contentlayer/generated": [
"contentlayer2/generated": [
"./.contentlayer/generated"
]
},
@@ -40,7 +40,8 @@
"**/*.ts",
"**/*.tsx",
".contentlayer/generated",
".next/types/**/*.ts"
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"