Compare commits
112 Commits
0df0a85579
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ee2eb4796e | |||
| d90442456b | |||
| b17930c10b | |||
| 1f3323834e | |||
| 7cdfb90b1b | |||
| f6c5be0ee4 | |||
| fc24ddb676 | |||
| cafb810155 | |||
| ae37f93508 | |||
| 4a4d6dd933 | |||
| 7bf2c4149d | |||
| 9d7a6757c9 | |||
| d03b061c1e | |||
| d768d108d6 | |||
| 7685c79705 | |||
| 4173aa69d3 | |||
| e2f9c9d556 | |||
| 5d226a2969 | |||
| a77cd17419 | |||
| d42cb46af8 | |||
| d6edcf1757 | |||
| ba60d49fc6 | |||
| 0bb3ee40c6 | |||
| 6badd76733 | |||
| 237e5d403b | |||
| e05295e003 | |||
| 45cfc6acc4 | |||
| af40ebc5e6 | |||
| f994301fbb | |||
| dd3f553282 | |||
| 016c75cb8b | |||
| 0fe7faf334 | |||
| 854c5a1097 | |||
| a7aa930759 | |||
| 8c71e80b2a | |||
| 2b1060dd45 | |||
| 3748e2f9e8 | |||
| d7dc279d32 | |||
| 7d1f29dd9d | |||
| b6f0bd1d69 | |||
| e28beac1f1 | |||
| 02f2d0a599 | |||
| 2c9d5ed650 | |||
| 912c70332e | |||
| 5d3d754252 | |||
| 653f079e1a | |||
| a4db9688b6 | |||
| 4c08413936 | |||
| a249a120a5 | |||
| 7ca7655e40 | |||
| 0d5fc93b30 | |||
| 937203b4e9 | |||
| 8ade752448 | |||
| e04a03097f | |||
| a8ee8d83af | |||
| 261cb1d91e | |||
| f32206d390 | |||
| ce43491e2e | |||
| 68ababe8c8 | |||
| 985caa2a4d | |||
| 77bd180d97 | |||
| 3425098006 | |||
| eefc38d562 | |||
| 48ce66a3e6 | |||
| 22120595a6 | |||
| eab80bd17a | |||
| 5b99486a68 | |||
| 5fdd72302e | |||
| 66cd9b8608 | |||
| 2e80b7ac59 | |||
| be5d942c79 | |||
| 3018a25578 | |||
| 04182ec754 | |||
| 9b2d754a2f | |||
| 1a7ae8a269 | |||
| 9a7eb6cfe3 | |||
| 246646f176 | |||
| 287c0d72a8 | |||
| fe191752da | |||
| 10e4e7e21e | |||
| 82a459bede | |||
| af0d2e3a6c | |||
| 9235ab291b | |||
| 79578252df | |||
| a225d57e06 | |||
| b416c9eb7d | |||
| 61d5092136 | |||
| a582ef9cb5 | |||
| dc5ca97fee | |||
| b4ee8b122f | |||
| cd95a7bb79 | |||
| f34221b567 | |||
| 3509b43643 | |||
| 6ca024b0ba | |||
| 9d86cd4663 | |||
| 31f1c6979d | |||
| b69755c2d6 | |||
| dadb5dce5c | |||
| 7a6cd55c42 | |||
| 1e39647ab6 | |||
| e73f37da76 | |||
| 351a1a2f70 | |||
| 80d0b236c5 | |||
| c404be0822 | |||
| 71680252a4 | |||
| 3d3090c4e2 | |||
| 904434774b | |||
| 91dec52db6 | |||
| df10c8b751 | |||
| 009f4bf41e | |||
| 96ebca37d6 | |||
| 4b3329d66f |
5
.eslintrc.json
Normal file
5
.eslintrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/eslintrc",
|
||||
"extends": ["next/core-web-vitals"],
|
||||
"rules": {}
|
||||
}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -34,5 +34,11 @@ pnpm-debug.log*
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Generated assets mirror
|
||||
/public/assets
|
||||
|
||||
# Generated search index
|
||||
/public/_pagefind
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
||||
[submodule "content"]
|
||||
path = content
|
||||
url = https://gitea.gbanyan.net/gbanyan/personal-blog.git
|
||||
url = https://github.com/gbanyan/personal-blog.git
|
||||
|
||||
203
README.md
203
README.md
@@ -1,17 +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 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), env‑driven 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
|
||||
@@ -38,7 +92,7 @@ Markdown content (posts & pages) lives in a separate repository and is consumed
|
||||
- `posts/` – Blog posts (`.md`)
|
||||
- `pages/` – Static pages (`.md`)
|
||||
- `assets/` – Images referenced from markdown
|
||||
- `public/assets` – Symlink to `content/assets` for serving images at `/assets/...`
|
||||
- `public/assets` – Copy of `content/assets` that is refreshed via `npm run sync-assets` (and automatically before `npm run build`) so Next.js can serve `/assets/...` without relying on symlinks.
|
||||
- `contentlayer.config.ts` – Contentlayer document types and markdown pipeline
|
||||
|
||||
## UI Overview
|
||||
@@ -50,33 +104,74 @@ Markdown content (posts & pages) lives in a separate repository and is consumed
|
||||
|
||||
- **Home page** (`/`)
|
||||
- Centered hero heading: `SITE_NAME 的最新動態` + tagline.
|
||||
- "最新文章" section listing latest posts as cards with thumbnail, date, title, tags, and excerpt.
|
||||
- Timeline-inspired "最新文章" rail: a slim gradient spine with evenly spaced ticks aligned to each article card plus a downward-pointing finial at the bottom.
|
||||
- Posts remain card-based (thumbnail + excerpt) but inherit the new responsive typography scale + weight strategy.
|
||||
|
||||
- **Blog index** (`/blog`)
|
||||
- Uses `PostListWithControls`:
|
||||
- Uses `PostListWithControls` with the same vertical timeline rail visually tying the list together.
|
||||
- Keyword search filters posts by title, tags, and excerpt with instant feedback.
|
||||
- Sort order: new→old or old→new.
|
||||
- Pagination using `siteConfig.postsPerPage`.
|
||||
|
||||
- **Single post / page** (`/blog/[slug]`, `/pages/[slug]`)
|
||||
- Left: sticky TOC that highlights the current section as you scroll.
|
||||
- Top: published date, large title, colored tags.
|
||||
- Body: `prose` typography with tuned light/dark colors, images, blockquotes, code.
|
||||
- Top bar: reading progress indicator.
|
||||
- Wrapped in `PostLayout`, which pairs `ReadingProgress` with a motion-aware grid; `hasToc` only enables the sidebar when `h2`/`h3` headings exist and the floating glass pill toggle lets readers hide/show the TOC on large screens.
|
||||
- The sticky TOC (`components/post-toc.tsx`) layers a dot indicator beside the active heading, smooth-scrolls anchors, temporarily highlights the target section via `toc-target-highlight`, and drops list bullets for an academic rhythm.
|
||||
- Header keeps the date, title, and centered tag chips with refined spacing while feature images now flow edge-to-edge inside a rounded `next/image` card.
|
||||
- Body text leans on the tuned `prose` palette, Chinese-friendly leading, and accent blockquotes, with the slim progress bar staying above.
|
||||
- Navigation stays focused on 上一章 / 下一章 bars, and the related section uses airy `PostCard` grids with minimal chrome to preserve the reading flow.
|
||||
|
||||
- **Right sidebar** (on large screens)
|
||||
- Top hero:
|
||||
- Gravatar avatar (from env) linking to an "about" page.
|
||||
- Row of icon‑only service links (GitHub, Mastodon, LinkedIn).
|
||||
- Short "about me" sentence from env.
|
||||
- Hot tags: top 5 tags in a small tag cloud using the accent palette and neutral dark‑mode backgrounds.
|
||||
- Gravatar avatar (from env) rendered with `next/image` and shared rounded-mask styling.
|
||||
- Row of icon-only service links with manual overrides (HomeLab → server, 開發工作環境 → device, 關於本站 → menu, etc.).
|
||||
- Short "about me" sentence honoring `\n` line breaks and no leading icon for cleaner typography.
|
||||
- Hot tags: top 5 tags sized via the responsive scale and accent glows for consistency.
|
||||
|
||||
- **Tags**
|
||||
- Each tag chips in lists, post headers, and sidebar link to `/tags/[slug]`.
|
||||
- `/tags` shows all tags and their counts.
|
||||
- `/tags` now uses a masonry-like layout with pill consistency, subtle shadows, and accent outlines so the page no longer feels empty.
|
||||
|
||||
- **Misc**
|
||||
- Floating "back to top" button on long pages.
|
||||
|
||||
## Typography & Motion Guidelines
|
||||
|
||||
### Typography scale & font weights
|
||||
|
||||
- **Base scale**: global root font-size uses `clamp(15px, 0.65vw + 11px, 19px)` and `--line-height-body: clamp(1.5, 0.15vw + 1.45, 1.65)` so Chinese + English copy stays legible from phones through 4K displays.
|
||||
- **Prose headings**: `h1`/`h2`/`h3` are sized via clamps (≈2.2‑3.4, 1.8‑2.8, 1.4‑2.0rem) with tighter line heights (1.25‑1.35) and subtle letter-spacing tweaks that pair with the serif accent.
|
||||
- **Paragraphs & lists**: `prose p`, `li`, and `figcaption` settle between 1rem and 1.15rem while `small` descends to 0.8‑0.95rem so captions stay subordinate without losing legibility.
|
||||
- **Navigation/TOC**: tag chips, TOC anchors, and the floating toggle sit around 0.9‑1rem at 500 weight; `toc-target-highlight` plus the accent marker keep the active heading visible without bullet clutter.
|
||||
- **Blockquotes & code**: blockquotes lean into accent-gradient sides, oversized quotes, and hover elevation, while `pre`/`code` blocks gain padded, light backgrounds for clearer inline emphasis.
|
||||
- **Serif accent for English headings**: `app/layout.tsx` now loads `Playfair_Display` into `--font-serif-eng`, and `styles/globals.css` applies that serif stack to `.type-display`, `.type-title`, `.type-subtitle`, and the global `h1`/`h2` selectors (with slight letter-spacing) so Latin headings stay elegant without disrupting the CJK fallback.
|
||||
- **Font stack**: `Inter var`, `Noto Sans TC`, `PingFang TC`, `Microsoft JhengHei`, `Helvetica Neue`, `system-ui`, `sans-serif`.
|
||||
|
||||
### Motion & interaction
|
||||
|
||||
- Keep motion subtle and purposeful:
|
||||
- Use small translations (±2–4px) and short durations (200–400ms, `ease-out`).
|
||||
- Prefer fade/slide-in over large bounces or rotations.
|
||||
- Respect user preferences:
|
||||
- Animations that run on their own are wrapped with `motion-safe:` so they are disabled when `prefers-reduced-motion` is enabled.
|
||||
- Reading experience first:
|
||||
- Scroll-based reveals are used sparingly (e.g. post header and article body), not on every small element.
|
||||
- TOC and reading progress bar emphasize orientation, not decoration.
|
||||
- Hover & focus:
|
||||
- Use light elevation (shadow + tiny translateY) and accent color changes to indicate interactivity.
|
||||
- Focus states remain visible and are not replaced by motion-only cues.
|
||||
|
||||
### Implemented Visual Touches
|
||||
|
||||
- Site-wide `next/image` usage (cards, feature media, sidebar avatar, related posts) to boost LCP without layout shifts.
|
||||
- Reading progress bar slimmed down with a softer gradient glow.
|
||||
- Scroll reveal for post header + article body (`ScrollReveal` component).
|
||||
- Single post layout now wraps the article and optional TOC in `PostLayout`, animating column widths and exposing the floating glass pill toggle.
|
||||
- Elegant vertical timeline rail on home/blog pages with aligned milestone ticks.
|
||||
- Hover elevation + gradient accents for post cards, sidebar tiles, and tag chips.
|
||||
- Smooth theme toggle with icon rotation and global `transition-colors`.
|
||||
- TOC smooth scrolling with a dot indicator, temporary `toc-target-highlight`, and bullet-less list styling.
|
||||
- Academic blockquotes featuring accent-side rules and caption text.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js **18+**
|
||||
@@ -157,9 +252,17 @@ Markdown content (posts & pages) lives in a separate repository and is consumed
|
||||
echo -n 'your-email@example.com' | md5 # macOS
|
||||
# or
|
||||
echo -n 'your-email@example.com' | md5sum | cut -d' ' -f1 # Linux
|
||||
```
|
||||
|
||||
5. **Mirror markdown assets**
|
||||
|
||||
```bash
|
||||
npm run sync-assets
|
||||
```
|
||||
|
||||
5. **Run the development server**
|
||||
This copies `content/assets` into `public/assets` so `/assets/...` continues to work; the build script already runs it before `next build`, but running it locally keeps your previews in sync.
|
||||
|
||||
6. **Run the development server**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
@@ -200,8 +303,9 @@ Contentlayer is configured in `contentlayer.config.ts` to read from the `content
|
||||
```
|
||||
|
||||
- At build time, a rehype plugin rewrites these to `/assets/my-image.jpg`.
|
||||
- `public/assets` is a symlink to `content/assets`, so Next.js serves them as static files.
|
||||
- `feature_image` fields are also mapped from `../assets/...` → `/assets/...` and rendered above the article content.
|
||||
- `public/assets` is populated from `content/assets` before each build (and via `npm run sync-assets`) so `/assets/...` stays available without symlinks.
|
||||
- `feature_image` fields are also mapped from `../assets/...` → `/assets/...` and rendered above the article content via `next/image`.
|
||||
- All component-level imagery (list thumbnails, related posts, sidebar avatar, about page hero, etc.) now uses `next/image` for responsive sizing, blur placeholders, and better LCP.
|
||||
|
||||
## Updating Content from the Submodule
|
||||
|
||||
@@ -243,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
|
||||

|
||||
```
|
||||
|
||||
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
166
app/api/og/route.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
41
app/blog/[slug]/loading.tsx
Normal file
41
app/blog/[slug]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
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 { getPostBySlug } from '@/lib/posts';
|
||||
import { allPosts } from 'contentlayer2/generated';
|
||||
import { getPostBySlug, getRelatedPosts, getPostNeighbors } from '@/lib/posts';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { ReadingProgress } from '@/components/reading-progress';
|
||||
import { PostToc } from '@/components/post-toc';
|
||||
import { ScrollReveal } from '@/components/scroll-reveal';
|
||||
import { PostLayout } from '@/components/post-layout';
|
||||
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) => ({
|
||||
@@ -14,54 +21,171 @@ 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();
|
||||
|
||||
const relatedPosts = getRelatedPosts(post, 3);
|
||||
const neighbors = getPostNeighbors(post);
|
||||
|
||||
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 />
|
||||
<div className="flex gap-6 pt-4">
|
||||
<aside className="hidden shrink-0 lg:block lg:w-44">
|
||||
<PostToc />
|
||||
</aside>
|
||||
<div className="flex-1">
|
||||
<header className="mb-6 space-y-2">
|
||||
<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">
|
||||
{post.published_at && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-500">
|
||||
<p className="type-small text-slate-500 dark:text-slate-500">
|
||||
{new Date(post.published_at).toLocaleDateString(
|
||||
siteConfig.defaultLocale
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold leading-tight text-slate-900 sm:text-3xl dark:text-slate-50">
|
||||
<h1 className="type-display font-bold leading-tight text-slate-900 dark:text-slate-50">
|
||||
{post.title}
|
||||
</h1>
|
||||
{post.tags && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-2" data-pagefind-meta="tags">
|
||||
{post.tags.map((t) => (
|
||||
<Link
|
||||
key={t}
|
||||
href={`/tags/${encodeURIComponent(
|
||||
t.toLowerCase().replace(/\s+/g, '-')
|
||||
)}`}
|
||||
className="rounded-full bg-accent-soft px-2 py-0.5 text-xs text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
|
||||
className="tag-chip rounded-full bg-accent-soft px-3 py-1 text-sm text-accent-textLight dark:bg-slate-800 dark:text-slate-100"
|
||||
>
|
||||
#{t}
|
||||
</Link>
|
||||
@@ -69,20 +193,72 @@ export default function BlogPostPage({ params }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<article
|
||||
data-toc-content={slug}
|
||||
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark"
|
||||
>
|
||||
{post.feature_image && (
|
||||
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
<div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4">
|
||||
<Image
|
||||
src={post.feature_image.replace('../assets', '/assets')}
|
||||
alt={post.title}
|
||||
className="my-4 rounded"
|
||||
width={1200}
|
||||
height={600}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px"
|
||||
priority
|
||||
className="w-full rounded-xl shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
|
||||
</article>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
</div>
|
||||
|
||||
<FooterCue />
|
||||
|
||||
{/* Exclude navigation and related posts from search indexing */}
|
||||
<div data-pagefind-ignore>
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<PostStorylineNav
|
||||
current={post}
|
||||
newer={neighbors.newer}
|
||||
older={neighbors.older}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
|
||||
{relatedPosts.length > 0 && (
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<section className="space-y-6 rounded-2xl border border-slate-200/60 bg-slate-50/50 p-8 dark:border-slate-800 dark:bg-slate-900/30">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="type-subtitle font-semibold text-slate-900 dark:text-slate-50">
|
||||
相關文章
|
||||
</h2>
|
||||
<p className="type-small text-slate-500 dark:text-slate-400">
|
||||
為你挑選相似主題
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{relatedPosts.map((related) => (
|
||||
<PostCard key={related._id} post={related} showTags={false} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PostLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getAllPostsSorted } from '@/lib/posts';
|
||||
import { PostListWithControls } from '@/components/post-list-with-controls';
|
||||
import { TimelineWrapper } from '@/components/timeline-wrapper';
|
||||
import { SidebarLayout } from '@/components/sidebar-layout';
|
||||
|
||||
export const metadata = {
|
||||
title: '所有文章'
|
||||
@@ -10,10 +12,17 @@ export default function BlogIndexPage() {
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
<SidebarLayout>
|
||||
<header className="space-y-1">
|
||||
<h1 className="type-title font-semibold text-slate-900 dark:text-slate-50">
|
||||
所有文章
|
||||
</h1>
|
||||
<p className="type-small text-slate-500 dark:text-slate-400">
|
||||
繼續往下滑,慢慢逛逛。
|
||||
</p>
|
||||
</header>
|
||||
<PostListWithControls posts={posts} />
|
||||
</SidebarLayout>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
57
app/error.tsx
Normal file
57
app/error.tsx
Normal 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
63
app/feed.xml/route.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -3,6 +3,14 @@ import type { Metadata } from 'next';
|
||||
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'],
|
||||
variable: '--font-serif-eng',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -24,6 +32,14 @@ export const metadata: Metadata = {
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
images: [siteConfig.ogImage]
|
||||
},
|
||||
icons: {
|
||||
icon: '/favicon.png'
|
||||
},
|
||||
alternates: {
|
||||
types: {
|
||||
'application/rss+xml': `${siteConfig.url}/feed.xml`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,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>
|
||||
<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
12
app/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
app/page.tsx
38
app/page.tsx
@@ -2,24 +2,50 @@ import Link from 'next/link';
|
||||
import { getAllPostsSorted } from '@/lib/posts';
|
||||
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">
|
||||
<h1 className="text-2xl font-bold text-slate-900 sm:text-3xl dark:text-slate-50">
|
||||
<h1 className="type-title font-bold text-slate-900 dark:text-slate-50">
|
||||
{siteConfig.name} 的最新動態
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<p className="type-small text-slate-600 dark:text-slate-300">
|
||||
{siteConfig.tagline}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
<h2 className="type-small font-semibold uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
|
||||
最新文章
|
||||
</h2>
|
||||
<Link
|
||||
@@ -29,12 +55,14 @@ export default function HomePage() {
|
||||
所有文章 →
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
<TimelineWrapper>
|
||||
{posts.map((post) => (
|
||||
<PostListItem key={post._id} post={post} />
|
||||
))}
|
||||
</ul>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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 { PostToc } from '@/components/post-toc';
|
||||
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) => ({
|
||||
@@ -14,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 {};
|
||||
|
||||
@@ -28,40 +32,72 @@ 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 />
|
||||
<div className="flex gap-6 pt-4">
|
||||
<aside className="hidden shrink-0 lg:block lg:w-44">
|
||||
<PostToc />
|
||||
</aside>
|
||||
<div className="flex-1">
|
||||
<header className="mb-6 space-y-2">
|
||||
<PostLayout hasToc={hasToc} contentKey={slug}>
|
||||
<div className="space-y-8">
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<header className="mb-6 space-y-4 text-center">
|
||||
{page.published_at && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-500">
|
||||
<p className="type-small text-slate-500 dark:text-slate-500">
|
||||
{new Date(page.published_at).toLocaleDateString(
|
||||
siteConfig.defaultLocale
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold leading-tight text-slate-900 sm:text-3xl dark:text-slate-50">
|
||||
<h1 className="type-display font-bold leading-tight text-slate-900 dark:text-slate-50">
|
||||
{page.title}
|
||||
</h1>
|
||||
{page.tags && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-2">
|
||||
{page.tags.map((t) => (
|
||||
<Link
|
||||
key={t}
|
||||
href={`/tags/${encodeURIComponent(
|
||||
t.toLowerCase().replace(/\s+/g, '-')
|
||||
)}`}
|
||||
className="rounded-full bg-accent-soft px-2 py-0.5 text-xs text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
|
||||
className="tag-chip rounded-full bg-accent-soft px-3 py-1 text-sm text-accent-textLight dark:bg-slate-800 dark:text-slate-100"
|
||||
>
|
||||
#{t}
|
||||
</Link>
|
||||
@@ -69,20 +105,34 @@ export default function StaticPage({ params }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<article
|
||||
data-toc-content={slug}
|
||||
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark"
|
||||
>
|
||||
{page.feature_image && (
|
||||
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
<div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4">
|
||||
<Image
|
||||
src={page.feature_image.replace('../assets', '/assets')}
|
||||
alt={page.title}
|
||||
className="my-4 rounded"
|
||||
width={1200}
|
||||
height={600}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px"
|
||||
priority
|
||||
className="w-full rounded-xl shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: page.body.html }} />
|
||||
</article>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
</div>
|
||||
</div>
|
||||
</PostLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
14
app/robots.ts
Normal file
14
app/robots.ts
Normal 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
68
app/sitemap.ts
Normal 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];
|
||||
}
|
||||
@@ -1,7 +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 { FiTag } from 'react-icons/fi';
|
||||
|
||||
export function generateStaticParams() {
|
||||
const slugs = new Set<string>();
|
||||
@@ -17,37 +21,56 @@ 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 (
|
||||
<section className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
標籤:{tagLabel}
|
||||
<SidebarLayout>
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<div className="motion-card mb-8 rounded-2xl border border-white/40 bg-white/60 p-8 text-center shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-slate-900/60">
|
||||
<div className="inline-flex items-center gap-2 text-accent">
|
||||
<FiTag className="h-5 w-5" />
|
||||
<span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
|
||||
TAG ARCHIVE
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="type-title mt-2 font-semibold text-slate-900 dark:text-slate-50">
|
||||
{tagLabel}
|
||||
</h1>
|
||||
<p className="type-small mt-2 text-slate-600 dark:text-slate-300">
|
||||
收錄 {posts.length} 篇文章
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
<PostListWithControls posts={posts} />
|
||||
</section>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { FiTag, FiTrendingUp } from 'react-icons/fi';
|
||||
import { getAllTagsWithCount } from '@/lib/posts';
|
||||
import { SectionDivider } from '@/components/section-divider';
|
||||
import { ScrollReveal } from '@/components/scroll-reveal';
|
||||
import { SidebarLayout } from '@/components/sidebar-layout';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '標籤索引'
|
||||
@@ -8,39 +12,66 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function TagIndexPage() {
|
||||
const tags = getAllTagsWithCount();
|
||||
const topTags = tags.slice(0, 3);
|
||||
|
||||
const colorClasses = [
|
||||
'bg-rose-100 text-rose-700 dark:bg-rose-900/60 dark:text-rose-200',
|
||||
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/60 dark:text-emerald-200',
|
||||
'bg-sky-100 text-sky-700 dark:bg-sky-900/60 dark:text-sky-200',
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-900/60 dark:text-amber-200',
|
||||
'bg-violet-100 text-violet-700 dark:bg-violet-900/60 dark:text-violet-200'
|
||||
'from-rose-400/70 to-rose-200/40',
|
||||
'from-emerald-400/70 to-emerald-200/40',
|
||||
'from-sky-400/70 to-sky-200/40',
|
||||
'from-amber-400/70 to-amber-200/40',
|
||||
'from-violet-400/70 to-violet-200/40'
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
<section className="space-y-6">
|
||||
<SidebarLayout>
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<div className="motion-card rounded-2xl border border-white/40 bg-white/60 p-8 text-center shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-slate-900/60">
|
||||
<div className="inline-flex items-center gap-2 text-accent">
|
||||
<FiTag className="h-5 w-5" />
|
||||
<span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
|
||||
標籤索引
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="type-title mt-2 font-semibold text-slate-900 dark:text-slate-50">
|
||||
共 {tags.length} 組主題,任你探索
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
目前共有 {tags.length} 個標籤。
|
||||
<p className="type-small mt-2 text-slate-600 dark:text-slate-300">
|
||||
熱度最高的標籤:
|
||||
{topTags.map((t) => t.tag).join('、')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{tags.map(({ tag, slug, count }, index) => {
|
||||
const color = colorClasses[index % colorClasses.length];
|
||||
return (
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/tags/${slug}`}
|
||||
className={`rounded-full px-3 py-1 transition ${color}`}
|
||||
className="motion-card group flex flex-col rounded-2xl border border-white/40 bg-white/60 p-5 shadow-sm backdrop-blur-md transition-all hover:-translate-y-1 hover:shadow-xl dark:border-white/10 dark:bg-slate-900/60"
|
||||
>
|
||||
<span className="mr-1">{tag}</span>
|
||||
<span className="opacity-70">({count})</span>
|
||||
<span className={`mb-3 block h-1.5 w-16 rounded-full bg-gradient-to-r ${color}`} aria-hidden="true" />
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="type-subtitle font-semibold text-slate-900 group-hover:text-blue-600 dark:text-slate-50 dark:group-hover:text-blue-400">
|
||||
{tag}
|
||||
</h2>
|
||||
<span className="type-small text-slate-600 dark:text-slate-300">
|
||||
{count} 篇
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-1 inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<FiTrendingUp className="h-3 w-3 text-orange-400" />
|
||||
熱度 #{index + 1}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
24
app/template.tsx
Normal file
24
app/template.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
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 (
|
||||
<div ref={containerRef} className="page-transition">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
components/footer-cue.tsx
Normal file
43
components/footer-cue.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function FooterCue() {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
setActive(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActive(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.2 }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex flex-col items-center gap-2 py-4 text-[11px] uppercase tracking-[0.3em] text-slate-400 dark:text-slate-500">
|
||||
<span className="text-xs">即將展開</span>
|
||||
<span
|
||||
className={`h-10 w-px overflow-hidden rounded-full bg-gradient-to-b from-transparent via-accent to-transparent transition-[height,opacity] duration-500 ease-snappy ${
|
||||
active ? 'opacity-80' : 'h-4 opacity-30'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
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, FiFeather } from 'react-icons/fi';
|
||||
import { MetaItem } from './meta-item';
|
||||
|
||||
export function Hero() {
|
||||
const { name, tagline, social } = siteConfig;
|
||||
@@ -18,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;
|
||||
@@ -58,18 +52,24 @@ export function Hero() {
|
||||
}[];
|
||||
|
||||
return (
|
||||
<section className="mb-8 rounded-xl border bg-gradient-to-r from-sky-50 via-indigo-50 to-slate-50 px-6 py-6 shadow-sm dark:border-slate-800 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-2xl font-semibold text-slate-50 dark:bg-slate-100 dark:text-slate-900">
|
||||
<section className="motion-card group relative mb-8 overflow-hidden rounded-xl border bg-gradient-to-r from-sky-50 via-indigo-50 to-slate-50 px-6 py-6 shadow-sm dark:border-slate-800 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900">
|
||||
<div className="pointer-events-none absolute -left-16 -top-16 h-40 w-40 rounded-full bg-sky-300/40 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-sky-500/25" />
|
||||
<div className="pointer-events-none absolute -bottom-20 right-[-3rem] h-44 w-44 rounded-full bg-indigo-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/25" />
|
||||
|
||||
<div className="relative flex items-center gap-4 motion-safe:animate-fade-in-up">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-xl font-semibold text-slate-50 shadow-md transition-transform duration-300 ease-out group-hover:scale-105 dark:bg-slate-100 dark:text-slate-900">
|
||||
{initial}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
<h1 className="hero-title type-display font-bold tracking-tight">
|
||||
<span className="hero-title__sweep" aria-hidden="true" />
|
||||
{name}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-slate-700 dark:text-slate-100">
|
||||
<div className="mt-1">
|
||||
<MetaItem icon={FiFeather}>
|
||||
{tagline}
|
||||
</p>
|
||||
</MetaItem>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||
{items.map((item) => (
|
||||
@@ -78,9 +78,9 @@ export function Hero() {
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-full bg-white/80 px-3 py-1 shadow-sm ring-1 ring-slate-200 hover:bg-accent-soft dark:bg-slate-900/80 dark:ring-slate-700"
|
||||
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
12
components/json-ld.tsx
Normal 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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SiteHeader } from './site-header';
|
||||
import { SiteFooter } from './site-footer';
|
||||
import { RightSidebar } from './right-sidebar';
|
||||
import { BackToTop } from './back-to-top';
|
||||
|
||||
export function LayoutShell({ children }: { children: React.ReactNode }) {
|
||||
@@ -8,10 +7,7 @@ export function LayoutShell({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<main className="flex-1 container mx-auto px-4 py-6">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,1.4fr)]">
|
||||
<div>{children}</div>
|
||||
<RightSidebar />
|
||||
</div>
|
||||
{children}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<BackToTop />
|
||||
|
||||
163
components/mastodon-feed.tsx
Normal file
163
components/mastodon-feed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
components/meta-item.tsx
Normal file
25
components/meta-item.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { IconType } from 'react-icons';
|
||||
|
||||
interface MetaItemProps {
|
||||
icon: IconType;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
tone?: 'default' | 'muted';
|
||||
}
|
||||
|
||||
export function MetaItem({ icon: Icon, children, className, tone = 'default' }: MetaItemProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 text-xs transition-colors duration-180 ease-snappy',
|
||||
tone === 'muted' ? 'text-slate-500 dark:text-slate-400' : 'text-slate-600 dark:text-slate-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400 dark:text-slate-500" />
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
305
components/nav-menu.tsx
Normal file
305
components/nav-menu.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, FocusEvent, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
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'
|
||||
| 'blog'
|
||||
| 'file'
|
||||
| 'user'
|
||||
| 'contact'
|
||||
| 'location'
|
||||
| 'pen'
|
||||
| 'tags'
|
||||
| 'server'
|
||||
| 'device'
|
||||
| 'menu';
|
||||
|
||||
const ICON_MAP: Record<IconKey, any> = {
|
||||
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;
|
||||
iconKey: IconKey;
|
||||
children?: NavLinkItem[];
|
||||
}
|
||||
|
||||
interface NavMenuProps {
|
||||
items: NavLinkItem[];
|
||||
}
|
||||
|
||||
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="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}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Mobile Menu Overlay - Portaled */}
|
||||
{mounted && createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 z-[100] flex flex-col bg-white/95 backdrop-blur-xl transition-all duration-300 ease-snappy dark:bg-gray-950/95 sm:hidden ${open ? 'visible opacity-100' : 'invisible opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{/* Close button area */}
|
||||
<div className="flex items-center justify-end px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 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}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
components/optimized-video.tsx
Normal file
68
components/optimized-video.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,52 @@
|
||||
import Link from 'next/link';
|
||||
import type { Post } from 'contentlayer/generated';
|
||||
import Image from 'next/image';
|
||||
import type { Post } from 'contentlayer2/generated';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { FiCalendar, FiTag } from 'react-icons/fi';
|
||||
import { MetaItem } from './meta-item';
|
||||
|
||||
interface PostCardProps {
|
||||
post: Post;
|
||||
showTags?: boolean;
|
||||
}
|
||||
|
||||
export function PostCard({ post }: PostCardProps) {
|
||||
export function PostCard({ post, showTags = true }: PostCardProps) {
|
||||
const cover =
|
||||
post.feature_image && post.feature_image.startsWith('../assets')
|
||||
? post.feature_image.replace('../assets', '/assets')
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-xl border bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900">
|
||||
<article className="motion-card group relative overflow-hidden rounded-xl border bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
|
||||
{cover && (
|
||||
<div className="w-full bg-slate-100 dark:bg-slate-800">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<div className="relative w-full bg-slate-100 dark:bg-slate-800">
|
||||
<Image
|
||||
src={cover}
|
||||
alt={post.title}
|
||||
className="mx-auto max-h-60 w-full object-contain"
|
||||
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>
|
||||
)}
|
||||
<div className="space-y-2 px-4 py-4">
|
||||
<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={FiCalendar}>
|
||||
{new Date(post.published_at).toLocaleDateString(
|
||||
siteConfig.defaultLocale
|
||||
)}
|
||||
</MetaItem>
|
||||
)}
|
||||
{showTags && post.tags && post.tags.length > 0 && (
|
||||
<MetaItem icon={FiTag} tone="muted">
|
||||
{post.tags.slice(0, 3).join(', ')}
|
||||
</MetaItem>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold leading-snug">
|
||||
<Link
|
||||
href={post.url}
|
||||
@@ -33,27 +55,6 @@ export function PostCard({ post }: PostCardProps) {
|
||||
{post.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
{post.published_at && (
|
||||
<span>
|
||||
{new Date(post.published_at).toLocaleDateString(
|
||||
siteConfig.defaultLocale
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{post.tags.slice(0, 3).map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] dark:bg-slate-800"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{post.description && (
|
||||
<p className="line-clamp-3 text-sm text-slate-700 dark:text-slate-100">
|
||||
{post.description}
|
||||
|
||||
149
components/post-layout.tsx
Normal file
149
components/post-layout.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
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",
|
||||
isDesktopTocOpen && hasToc ? "lg:grid-cols-[1fr_16rem] toc-open" : "lg:grid-cols-[1fr_0rem]"
|
||||
)}>
|
||||
{/* Main Content Area */}
|
||||
<div className="min-w-0">
|
||||
<div className={cn("mx-auto transition-all duration-500 ease-snappy", isDesktopTocOpen && hasToc ? "max-w-3xl" : "max-w-4xl")}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Sidebar (TOC) */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-24 h-[calc(100vh-6rem)] overflow-hidden">
|
||||
{isDesktopTocOpen && hasToc && (
|
||||
<div className="toc-sidebar h-full overflow-y-auto pr-2">
|
||||
<PostToc contentKey={contentKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Mobile TOC Overlay */}
|
||||
{mobileToc}
|
||||
|
||||
{/* Toggle Buttons - Rendered via Portal */}
|
||||
{mounted && createPortal(
|
||||
<>
|
||||
{tocButton}
|
||||
{desktopTocButton}
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import Link from 'next/link';
|
||||
import type { Post } from 'contentlayer/generated';
|
||||
import Image from 'next/image';
|
||||
import { Post } from 'contentlayer2/generated';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { FiCalendar, FiTag } from 'react-icons/fi';
|
||||
import { MetaItem } from './meta-item';
|
||||
|
||||
interface Props {
|
||||
post: Post;
|
||||
@@ -16,44 +19,39 @@ export function PostListItem({ post }: Props) {
|
||||
post.description || post.custom_excerpt || post.body?.raw?.slice(0, 120);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<article className="group flex gap-4 rounded-lg border border-slate-200/70 bg-white/80 p-4 transition hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900/80 dark:hover:bg-slate-900">
|
||||
<article className="motion-card group relative flex gap-4 rounded-2xl border border-white/40 bg-white/60 p-5 shadow-lg backdrop-blur-md transition-all hover:scale-[1.01] hover:shadow-xl dark:border-white/10 dark:bg-slate-900/60">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
|
||||
{cover && (
|
||||
<div className="hidden flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:block sm:w-40">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<div className="relative flex h-24 w-24 flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:h-auto sm:w-40">
|
||||
<Image
|
||||
src={cover}
|
||||
alt={post.title}
|
||||
className="h-full w-full object-contain"
|
||||
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>
|
||||
)}
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{post.published_at && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-500">
|
||||
<MetaItem icon={FiCalendar}>
|
||||
{new Date(post.published_at).toLocaleDateString(
|
||||
siteConfig.defaultLocale
|
||||
)}
|
||||
</p>
|
||||
</MetaItem>
|
||||
)}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<MetaItem icon={FiTag} tone="muted">
|
||||
{post.tags.slice(0, 3).join(', ')}
|
||||
</MetaItem>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-base font-semibold leading-snug text-slate-900 hover:text-accent sm:text-lg dark:text-slate-50 dark:hover:text-accent">
|
||||
<Link href={post.url}>{post.title}</Link>
|
||||
</h2>
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-0.5">
|
||||
{post.tags.slice(0, 4).map((t) => (
|
||||
<Link
|
||||
key={t}
|
||||
href={`/tags/${encodeURIComponent(
|
||||
t.toLowerCase().replace(/\s+/g, '-')
|
||||
)}`}
|
||||
className="rounded-full bg-accent-soft px-2 py-0.5 text-[11px] text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
#{t}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{excerpt && (
|
||||
<p className="line-clamp-2 text-sm text-slate-600 group-hover:text-slate-800 dark:text-slate-300 dark:group-hover:text-slate-100">
|
||||
{excerpt}
|
||||
@@ -61,6 +59,5 @@ export function PostListItem({ post }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Post } from 'contentlayer/generated';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
posts: Post[];
|
||||
@@ -15,11 +17,31 @@ type SortOrder = 'new' | 'old';
|
||||
export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('new');
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const size = pageSize ?? siteConfig.postsPerPage ?? 5;
|
||||
|
||||
const normalizedQuery = searchTerm.trim().toLowerCase();
|
||||
|
||||
const filteredPosts = useMemo(() => {
|
||||
if (!normalizedQuery) return posts;
|
||||
|
||||
return posts.filter((post) => {
|
||||
const haystack = [
|
||||
post.title,
|
||||
post.description,
|
||||
post.custom_excerpt,
|
||||
post.tags?.join(' ')
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
});
|
||||
}, [posts, normalizedQuery]);
|
||||
|
||||
const sortedPosts = useMemo(() => {
|
||||
const arr = [...posts];
|
||||
const arr = [...filteredPosts];
|
||||
arr.sort((a, b) => {
|
||||
const aDate = a.published_at
|
||||
? new Date(a.published_at).getTime()
|
||||
@@ -30,13 +52,17 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
return sortOrder === 'new' ? bDate - aDate : aDate - bDate;
|
||||
});
|
||||
return arr;
|
||||
}, [posts, sortOrder]);
|
||||
}, [filteredPosts, sortOrder]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sortedPosts.length / size));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const start = (currentPage - 1) * size;
|
||||
const currentPosts = sortedPosts.slice(start, start + size);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [normalizedQuery]);
|
||||
|
||||
const handleChangeSort = (order: SortOrder) => {
|
||||
setSortOrder(order);
|
||||
setPage(1);
|
||||
@@ -49,44 +75,82 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4 text-xs text-slate-500 dark:text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>排序:</span>
|
||||
<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">
|
||||
<FiList className="h-3.5 w-3.5" />
|
||||
<span>排序</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeSort('new')}
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
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-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'
|
||||
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<FiArrowDown className="h-3 w-3" />
|
||||
新到舊
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeSort('old')}
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
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-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'
|
||||
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<FiArrowUp className="h-3 w-3" />
|
||||
舊到新
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
第 {currentPage} / {totalPages} 頁
|
||||
<div className="flex w-full items-center text-sm sm:w-auto">
|
||||
<label htmlFor="post-search" className="sr-only">
|
||||
搜尋文章
|
||||
</label>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<FiSearch
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
|
||||
/>
|
||||
<input
|
||||
id="post-search"
|
||||
type="search"
|
||||
placeholder="標題、標籤、摘要關鍵字"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className="w-full rounded-full border border-slate-200 bg-white py-1.5 pl-9 pr-3 text-sm text-slate-700 shadow-sm transition duration-180 ease-snappy focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||
<p>
|
||||
第 {currentPage} / {totalPages} 頁 · 共 {sortedPosts.length} 篇
|
||||
{normalizedQuery && `(搜尋「${searchTerm}」)`}
|
||||
</p>
|
||||
{normalizedQuery && sortedPosts.length === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
|
||||
>
|
||||
清除搜尋
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentPosts.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
找不到符合關鍵字的文章,換個詞再試試?
|
||||
</div>
|
||||
) : (
|
||||
<TimelineWrapper className="space-y-3">
|
||||
{currentPosts.map((post) => (
|
||||
<PostListItem key={post._id} post={post} />
|
||||
))}
|
||||
</ul>
|
||||
</TimelineWrapper>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
{totalPages > 1 && currentPosts.length > 0 && (
|
||||
<nav className="flex items-center justify-center gap-3 text-xs text-slate-600 dark:text-slate-300">
|
||||
<button
|
||||
type="button"
|
||||
@@ -105,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'
|
||||
}`}
|
||||
@@ -129,4 +192,3 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
102
components/post-storyline-nav.tsx
Normal file
102
components/post-storyline-nav.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import Link from 'next/link';
|
||||
import { Post } from 'contentlayer2/generated';
|
||||
import { FiArrowLeft, FiArrowRight } from 'react-icons/fi';
|
||||
|
||||
interface Props {
|
||||
current: Post;
|
||||
newer?: Post;
|
||||
older?: Post;
|
||||
}
|
||||
|
||||
interface StationConfig {
|
||||
key: 'older' | 'newer';
|
||||
label: string;
|
||||
post?: Post;
|
||||
rel?: 'prev' | 'next';
|
||||
subtitle: string;
|
||||
align: 'start' | 'end';
|
||||
}
|
||||
|
||||
export function PostStorylineNav({ current, newer, older }: Props) {
|
||||
const stations: StationConfig[] = [
|
||||
{
|
||||
key: 'older',
|
||||
label: '上一站',
|
||||
post: older,
|
||||
subtitle: older ? '回顧這篇' : '到達起點',
|
||||
rel: 'prev',
|
||||
align: 'end'
|
||||
},
|
||||
{
|
||||
key: 'newer',
|
||||
label: '下一站',
|
||||
post: newer,
|
||||
subtitle: newer ? '繼續前往' : '尚無新章',
|
||||
rel: 'next',
|
||||
align: 'start'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<nav aria-label="文章導覽" className="relative mt-10">
|
||||
<div className="relative overflow-hidden rounded-[32px] border border-slate-200/70 bg-gradient-to-r from-white via-slate-50 to-white px-6 py-8 shadow-lg dark:border-slate-800/70 dark:from-slate-900 dark:via-slate-900/80 dark:to-slate-900">
|
||||
<div className="pointer-events-none absolute inset-x-12 top-1/2 hidden md:block">
|
||||
<div className="relative flex items-center text-slate-200 dark:text-slate-700">
|
||||
<span className="h-0 w-0 -translate-x-3 border-y-[7px] border-y-transparent border-r-[14px] border-r-current" />
|
||||
<span className="flex-1 border-t border-dashed border-current" />
|
||||
<span className="h-0 w-0 translate-x-3 rotate-180 border-y-[7px] border-y-transparent border-r-[14px] border-r-current" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative grid gap-6 md:grid-cols-[1fr_auto_1fr] md:items-center">
|
||||
<Station station={stations[0]} />
|
||||
<div className="hidden flex-col items-center gap-2 text-center text-xs uppercase tracking-[0.4em] text-slate-400 md:flex">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-500" aria-hidden="true" />
|
||||
<span>你在這裡</span>
|
||||
</div>
|
||||
<Station station={stations[1]} />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Station({ station }: { station: StationConfig }) {
|
||||
const { post, label, subtitle, rel, align } = station;
|
||||
const alignClass = align === 'end' ? 'items-end text-right' : 'items-start text-left';
|
||||
|
||||
if (!post) {
|
||||
if (align === 'start') {
|
||||
return <div className="hidden" aria-hidden="true" />;
|
||||
}
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 text-slate-400 ${alignClass}`}>
|
||||
<p className="text-[11px] uppercase tracking-[0.4em]">{label}</p>
|
||||
<p className="text-base font-semibold">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={post.url}
|
||||
rel={rel}
|
||||
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">
|
||||
{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'
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FiList } from 'react-icons/fi';
|
||||
|
||||
interface TocItem {
|
||||
id: string;
|
||||
@@ -8,13 +9,48 @@ 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);
|
||||
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
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)
|
||||
@@ -25,7 +61,7 @@ export function PostToc() {
|
||||
}));
|
||||
setItems(mapped);
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
@@ -43,34 +79,105 @@ 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) {
|
||||
setIndicator({ top: 0, opacity: 0 });
|
||||
return;
|
||||
}
|
||||
const activeEl = itemRefs.current[activeId];
|
||||
if (!activeEl) return;
|
||||
const listTop = listRef.current.getBoundingClientRect().top;
|
||||
const { top, height } = activeEl.getBoundingClientRect();
|
||||
setIndicator({ top: top - listTop + height / 2, opacity: 1 });
|
||||
}, [activeId, items.length]);
|
||||
|
||||
const handleClick = (id: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
|
||||
// Temporary highlight
|
||||
el.classList.add('toc-target-highlight');
|
||||
setTimeout(() => {
|
||||
el.classList.remove('toc-target-highlight');
|
||||
}, 700);
|
||||
|
||||
// Update hash without instant jump
|
||||
if (history.replaceState) {
|
||||
const url = new URL(window.location.href);
|
||||
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="sticky top-20 text-xs text-slate-500 dark:text-slate-400">
|
||||
<div className="mb-2 font-semibold text-slate-700 dark:text-slate-200">
|
||||
<nav className={`not-prose text-slate-500 dark:text-slate-400 ${className || 'sticky top-20'}`}>
|
||||
{showTitle && (
|
||||
<div className="mb-2 inline-flex items-center gap-2 font-semibold text-slate-700 dark:text-slate-200">
|
||||
<FiList className="h-4 w-4 text-slate-400" />
|
||||
目錄
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
)}
|
||||
<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
|
||||
className="absolute left-0 h-3 w-3 -translate-y-1/2 rounded-full bg-accent transition-all duration-200 ease-snappy"
|
||||
style={{ top: `${indicator.top}px`, opacity: indicator.opacity }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={listRef}
|
||||
className="space-y-1 text-[0.95rem]"
|
||||
role="list"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className={item.depth === 3 ? 'pl-3' : ''}>
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
itemRefs.current[item.id] = el;
|
||||
}}
|
||||
role="listitem"
|
||||
className={`relative ${item.depth === 3 ? 'pl-3' : 'pl-0'}`}
|
||||
>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
className={`line-clamp-2 hover:text-blue-600 dark:hover:text-blue-400 ${
|
||||
item.id === activeId
|
||||
onClick={handleClick(item.id)}
|
||||
className={`line-clamp-2 inline-flex items-center py-1 pl-2 hover:text-blue-600 dark:hover:text-blue-400 ${item.id === activeId
|
||||
? 'text-blue-600 dark:text-blue-400 font-semibold'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,15 +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-1 bg-slate-200/60 dark:bg-slate-900/80">
|
||||
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="h-full origin-left bg-blue-500 transition-transform dark:bg-blue-400"
|
||||
style={{ transform: `scaleX(${progress / 100})` }}
|
||||
/>
|
||||
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/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>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Link from 'next/link';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faGithub, faMastodon, faLinkedin } from '@fortawesome/free-brands-svg-icons';
|
||||
import Image from 'next/image';
|
||||
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);
|
||||
@@ -18,48 +20,53 @@ export function RightSidebar() {
|
||||
siteConfig.social.github && {
|
||||
key: 'github',
|
||||
href: siteConfig.social.github,
|
||||
icon: faGithub,
|
||||
icon: FaGithub,
|
||||
label: 'GitHub'
|
||||
},
|
||||
siteConfig.social.mastodon && {
|
||||
key: 'mastodon',
|
||||
href: siteConfig.social.mastodon,
|
||||
icon: faMastodon,
|
||||
icon: FaMastodon,
|
||||
label: 'Mastodon'
|
||||
},
|
||||
siteConfig.social.linkedin && {
|
||||
key: 'linkedin',
|
||||
href: siteConfig.social.linkedin,
|
||||
icon: faLinkedin,
|
||||
icon: FaLinkedin,
|
||||
label: 'LinkedIn'
|
||||
}
|
||||
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block text-sm">
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-20 flex flex-col gap-4">
|
||||
<section className="rounded-xl border bg-white px-4 py-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
|
||||
<div className="flex flex-col items-center">
|
||||
<section className="motion-card group relative overflow-hidden rounded-xl border bg-white px-4 py-4 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
|
||||
<div className="pointer-events-none absolute -left-10 -top-10 h-24 w-24 rounded-full bg-sky-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-sky-500/25" />
|
||||
<div className="pointer-events-none absolute -bottom-12 right-[-2.5rem] h-28 w-28 rounded-full bg-indigo-300/30 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/20" />
|
||||
|
||||
<div className="relative flex flex-col items-center">
|
||||
<Link
|
||||
href={aboutPage?.url || '/pages/關於作者'}
|
||||
aria-label="關於作者"
|
||||
className="mb-2 inline-block"
|
||||
className="mb-2 inline-block transition-transform duration-300 ease-out group-hover:-translate-y-0.5"
|
||||
>
|
||||
{avatarSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
alt={siteConfig.name}
|
||||
className="h-24 w-24 rounded-full border border-slate-200 object-cover dark:border-slate-700"
|
||||
width={96}
|
||||
height={96}
|
||||
unoptimized
|
||||
className="h-24 w-24 rounded-full border border-slate-200 object-cover shadow-sm transition-transform duration-300 ease-out group-hover:scale-105 dark:border-slate-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-slate-900 text-lg font-semibold text-slate-50 dark:bg-slate-100 dark:text-slate-900">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-slate-900 text-lg font-semibold text-slate-50 shadow-sm transition-transform duration-300 ease-out group-hover:scale-105 dark:bg-slate-100 dark:text-slate-900">
|
||||
{siteConfig.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
{socialItems.length > 0 && (
|
||||
<div className="flex items-center gap-3 text-base text-accent-textLight dark:text-accent-textDark">
|
||||
<div className="mt-2 flex items-center gap-3 text-lg text-accent-textLight dark:text-accent-textDark">
|
||||
{socialItems.map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
@@ -67,27 +74,33 @@ export function RightSidebar() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={item.label}
|
||||
className="transition hover:text-accent"
|
||||
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>
|
||||
)}
|
||||
{siteConfig.aboutShort && (
|
||||
<p className="mt-2 max-w-[11rem] text-center text-[13px] text-slate-600 dark:text-slate-200">
|
||||
{siteConfig.aboutShort}
|
||||
</p>
|
||||
<div className="type-body mt-3 space-y-1 text-center text-slate-600 dark:text-slate-200">
|
||||
{siteConfig.aboutShort.split(/\n+/).map((line, index) => (
|
||||
<p key={`${line}-${index}`}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mastodon Feed */}
|
||||
<MastodonFeed />
|
||||
|
||||
{tags.length > 0 && (
|
||||
<section className="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="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
<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">
|
||||
<FiTrendingUp className="h-3 w-3 text-orange-400" />
|
||||
熱門標籤
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[13px]">
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-base">
|
||||
{tags.map(({ tag, slug, count }) => {
|
||||
let sizeClass = '';
|
||||
if (count >= 5) sizeClass = 'font-semibold';
|
||||
@@ -97,19 +110,23 @@ export function RightSidebar() {
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/tags/${slug}`}
|
||||
className={`${sizeClass} rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`}
|
||||
className={`${sizeClass} tag-chip rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`}
|
||||
>
|
||||
{tag}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 text-right text-[11px]">
|
||||
<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">
|
||||
<FiArrowRight className="h-3 w-3" />
|
||||
一覽所有標籤
|
||||
</span>
|
||||
<Link
|
||||
href="/tags"
|
||||
className="text-slate-500 hover:text-accent dark:text-slate-400 dark:hover:text-accent"
|
||||
className="motion-link text-accent-textLight hover:text-accent dark:text-accent-textDark"
|
||||
>
|
||||
查看全部標籤 →
|
||||
前往
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
67
components/scroll-reveal.tsx
Normal file
67
components/scroll-reveal.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface ScrollRevealProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
export function ScrollReveal({
|
||||
children,
|
||||
className,
|
||||
once = true
|
||||
}: ScrollRevealProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
// Fallback for browsers without IntersectionObserver
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
el.classList.add('is-visible');
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-visible');
|
||||
if (once) observer.unobserve(entry.target);
|
||||
} else if (!once) {
|
||||
entry.target.classList.remove('is-visible');
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold: 0.05,
|
||||
rootMargin: '0px 0px -20% 0px'
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
// Fallback timeout for slow connections
|
||||
const fallback = window.setTimeout(() => {
|
||||
el.classList.add('is-visible');
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.clearTimeout(fallback);
|
||||
};
|
||||
}, [once]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx('scroll-reveal', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
components/search-modal.tsx
Normal file
208
components/search-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
components/section-divider.tsx
Normal file
53
components/section-divider.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface SectionDividerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionDivider({ children, className }: SectionDividerProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.15, rootMargin: '0px 0px -20% 0px' }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx('space-y-4', className)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'block h-[2px] w-full origin-left rounded-full bg-gradient-to-r from-slate-200 via-accent-soft to-slate-200 transition-transform duration-500 ease-snappy dark:from-slate-800 dark:to-slate-800',
|
||||
visible ? 'scale-x-100 opacity-100' : 'scale-x-50 opacity-30'
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
components/sidebar-layout.tsx
Normal file
10
components/sidebar-layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { RightSidebar } from './right-sidebar';
|
||||
|
||||
export function SidebarLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,1.4fr)]">
|
||||
<div>{children}</div>
|
||||
<RightSidebar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,41 +1,139 @@
|
||||
'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 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,
|
||||
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 dark:bg-gray-950/80">
|
||||
<header className="relative z-40 bg-white/80 backdrop-blur transition-colors duration-200 ease-snappy dark:bg-gray-950/80">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-slate-900 dark:text-slate-100">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-semibold transition hover:text-accent-textDark focus-visible:outline-none focus-visible:text-accent-textDark"
|
||||
className="motion-link group relative type-title text-slate-900 hover:text-accent focus-visible:outline-none focus-visible:text-accent dark:text-slate-100"
|
||||
>
|
||||
<span className="absolute -bottom-0.5 left-0 h-[2px] w-0 bg-accent transition-all duration-180 ease-snappy group-hover:w-full" aria-hidden="true" />
|
||||
{siteConfig.title}
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4 text-base sm:text-lg">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="transition hover:text-accent-textDark focus-visible:outline-none focus-visible:text-accent-textDark"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
{pages.map((page) => (
|
||||
<Link
|
||||
key={page._id}
|
||||
href={page.url}
|
||||
className="transition hover:text-accent-textDark focus-visible:outline-none focus-visible:text-accent-textDark"
|
||||
>
|
||||
{page.title}
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<NavMenu items={navItems} />
|
||||
<SearchButton onClick={() => setIsSearchOpen(true)} />
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</div>
|
||||
<SearchModal
|
||||
isOpen={isSearchOpen}
|
||||
onClose={() => setIsSearchOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const titleOverrides = Object.fromEntries(
|
||||
Object.entries(siteConfig.navIconOverrides?.titles ?? {}).map(([key, value]) => [
|
||||
key.trim().toLowerCase(),
|
||||
value as IconKey
|
||||
])
|
||||
);
|
||||
|
||||
const slugOverrides = Object.fromEntries(
|
||||
Object.entries(siteConfig.navIconOverrides?.slugs ?? {}).map(([key, value]) => [
|
||||
key.trim().toLowerCase(),
|
||||
value as IconKey
|
||||
])
|
||||
);
|
||||
|
||||
function getIconForPage(title?: string, slug?: string): IconKey {
|
||||
const normalizedTitle = title?.trim().toLowerCase();
|
||||
if (normalizedTitle && titleOverrides[normalizedTitle]) {
|
||||
return titleOverrides[normalizedTitle];
|
||||
}
|
||||
|
||||
const normalizedSlug = slug?.trim().toLowerCase();
|
||||
if (normalizedSlug && slugOverrides[normalizedSlug]) {
|
||||
return slugOverrides[normalizedSlug];
|
||||
}
|
||||
|
||||
if (!title) return 'file';
|
||||
const lower = title.toLowerCase();
|
||||
if (lower.includes('關於本站')) return 'menu';
|
||||
if (lower.includes('關於') || lower.includes('about')) return 'user';
|
||||
if (lower.includes('聯絡') || lower.includes('contact')) return 'contact';
|
||||
if (lower.includes('位置') || lower.includes('map')) return 'location';
|
||||
if (lower.includes('作品') || lower.includes('portfolio')) return 'pen';
|
||||
if (lower.includes('標籤') || lower.includes('tags')) return 'tags';
|
||||
if (lower.includes('homelab')) return 'server';
|
||||
if (lower.includes('server') || lower.includes('伺服') || lower.includes('infrastructure')) return 'server';
|
||||
if (lower.includes('開發工作環境')) return 'device';
|
||||
if (lower.includes('device') || lower.includes('設備') || lower.includes('硬體') || lower.includes('hardware')) return 'device';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { FiMoon, FiSun } from 'react-icons/fi';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -16,17 +17,20 @@ export function ThemeToggle() {
|
||||
}
|
||||
|
||||
const next = theme === 'dark' ? 'light' : 'dark';
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-accent-textLight transition hover:bg-accent-soft hover:text-accent dark:text-accent-textDark dark:hover:bg-slate-800 dark:hover:text-accent"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-accent-textLight transition duration-180 ease-snappy hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-accent-textDark dark:hover:bg-slate-800 dark:hover:text-accent"
|
||||
onClick={() => setTheme(next)}
|
||||
aria-label={theme === 'dark' ? '切換為淺色主題' : '切換為深色主題'}
|
||||
>
|
||||
<span className="text-lg leading-none">
|
||||
{theme === 'dark' ? '☀' : '☾'}
|
||||
</span>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
32
components/timeline-wrapper.tsx
Normal file
32
components/timeline-wrapper.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Children, ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface TimelineWrapperProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TimelineWrapper({ children, className }: TimelineWrapperProps) {
|
||||
const items = Children.toArray(children);
|
||||
return (
|
||||
<div className={clsx('relative pl-6 md:pl-8', className)}>
|
||||
<span
|
||||
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-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-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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
content
2
content
Submodule content updated: c728118ba1...3f72ccb628
@@ -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
32
env
Normal 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=""
|
||||
@@ -34,7 +34,15 @@ export const siteConfig = {
|
||||
accentTextDark:
|
||||
process.env.NEXT_PUBLIC_COLOR_ACCENT_TEXT_DARK || '#93c5fd'
|
||||
},
|
||||
ogImage: process.env.NEXT_PUBLIC_OG_DEFAULT_IMAGE || '/assets/og-default.jpg',
|
||||
navIconOverrides: {
|
||||
titles: {
|
||||
homelab: 'server',
|
||||
'開發工作環境': 'device',
|
||||
'關於本站': 'menu'
|
||||
},
|
||||
slugs: {}
|
||||
},
|
||||
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
158
lib/mastodon.ts
Normal 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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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 [];
|
||||
}
|
||||
}
|
||||
66
lib/posts.ts
66
lib/posts.ts
@@ -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 }[] {
|
||||
@@ -47,3 +54,58 @@ export function getAllTagsWithCount(): { tag: string; slug: string; count: numbe
|
||||
return b.count - a.count;
|
||||
});
|
||||
}
|
||||
|
||||
export function getRelatedPosts(target: Post, limit = 3): Post[] {
|
||||
const targetTags = new Set(target.tags?.map((tag) => tag.toLowerCase()) ?? []);
|
||||
const candidates = getAllPostsSorted().filter((post) => post._id !== target._id);
|
||||
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const scored = candidates
|
||||
.map((post) => {
|
||||
const sharedTags = (post.tags ?? []).reduce((acc, tag) => {
|
||||
return acc + (targetTags.has(tag.toLowerCase()) ? 1 : 0);
|
||||
}, 0);
|
||||
return { post, score: sharedTags };
|
||||
})
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.score === a.score) {
|
||||
const aDate = a.post.published_at
|
||||
? new Date(a.post.published_at).getTime()
|
||||
: 0;
|
||||
const bDate = b.post.published_at
|
||||
? new Date(b.post.published_at).getTime()
|
||||
: 0;
|
||||
return bDate - aDate;
|
||||
}
|
||||
return b.score - a.score;
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map((entry) => entry.post);
|
||||
|
||||
if (scored.length >= limit) {
|
||||
return scored;
|
||||
}
|
||||
|
||||
const fallback = candidates.filter(
|
||||
(post) => !scored.some((existing) => existing._id === post._id)
|
||||
);
|
||||
|
||||
return [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit);
|
||||
}
|
||||
|
||||
export function getPostNeighbors(target: Post): {
|
||||
newer?: Post;
|
||||
older?: Post;
|
||||
} {
|
||||
const sorted = getAllPostsSorted();
|
||||
const index = sorted.findIndex((post) => post._id === target._id);
|
||||
|
||||
if (index === -1) return {};
|
||||
|
||||
return {
|
||||
newer: index > 0 ? sorted[index - 1] : undefined,
|
||||
older: index < sorted.length - 1 ? sorted[index + 1] : undefined
|
||||
};
|
||||
}
|
||||
|
||||
111
lib/rehype-callouts.ts
Normal file
111
lib/rehype-callouts.ts
Normal 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
3
next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
13693
package-lock.json
generated
13693
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -4,8 +4,9 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"dev": "concurrently \"contentlayer2 dev\" \"next dev --turbo\"",
|
||||
"sync-assets": "node scripts/sync-assets.mjs",
|
||||
"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"
|
||||
@@ -13,30 +14,39 @@
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.3",
|
||||
"pagefind": "^1.4.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../content/assets
|
||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 485 KiB |
27
scripts/sync-assets.mjs
Normal file
27
scripts/sync-assets.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import { cp, mkdir, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
async function syncAssets() {
|
||||
const root = process.cwd();
|
||||
const sourceDir = path.join(root, 'content', 'assets');
|
||||
const targetDir = path.join(root, 'public', 'assets');
|
||||
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await stat(sourceDir);
|
||||
} catch {
|
||||
// Nothing to copy yet; leave the empty directory in place.
|
||||
console.info('sync-assets: no content/assets directory found yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
await cp(sourceDir, targetDir, { recursive: true, force: true });
|
||||
console.info('sync-assets: copied content/assets → public/assets.');
|
||||
}
|
||||
|
||||
syncAssets().catch((error) => {
|
||||
console.error('sync-assets: failed to mirror assets.', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -2,6 +2,619 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100 text-[15px] sm:text-base;
|
||||
:root {
|
||||
--motion-duration-short: 180ms;
|
||||
--motion-duration-medium: 260ms;
|
||||
--motion-ease-snappy: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--card-translate-y: -6px;
|
||||
--line-height-body: clamp(1.5, 0.15vw + 1.45, 1.65);
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--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;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-gray-900 transition-colors duration-200 ease-snappy dark:bg-gray-950 dark:text-gray-100;
|
||||
font-size: 1rem;
|
||||
line-height: var(--line-height-body);
|
||||
font-family: var(--font-system-sans);
|
||||
color: var(--color-ink-body);
|
||||
}
|
||||
|
||||
@keyframes timeline-scroll {
|
||||
0% {
|
||||
transform: translate(-50%, -10%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
85% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, 110%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
/* Subtle hover for article elements */
|
||||
.prose blockquote {
|
||||
@apply transition-transform transition-shadow duration-180 ease-snappy;
|
||||
border-left: 4px solid var(--color-accent, #2563eb);
|
||||
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.78);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dark .prose blockquote {
|
||||
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 {
|
||||
@apply -translate-y-0.5 shadow-sm;
|
||||
}
|
||||
|
||||
.prose blockquote::before {
|
||||
content: '“';
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.8rem;
|
||||
font-size: 3rem;
|
||||
font-family: 'Times New Roman', 'Noto Serif TC', serif;
|
||||
color: rgba(37, 99, 235, 0.25);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
@apply transition-transform transition-shadow duration-180 ease-snappy;
|
||||
}
|
||||
|
||||
.prose pre:hover {
|
||||
@apply -translate-y-0.5 shadow-md;
|
||||
}
|
||||
|
||||
.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,
|
||||
.prose figcaption {
|
||||
font-size: clamp(0.85rem, 0.2vw + 0.8rem, 0.95rem);
|
||||
}
|
||||
|
||||
.prose h1>a,
|
||||
.prose h2>a,
|
||||
.prose h3>a,
|
||||
.prose h4>a,
|
||||
.prose h5>a,
|
||||
.prose h6>a {
|
||||
text-decoration: none !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-title__sweep {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(120deg, transparent 10%, rgba(59, 130, 246, 0.35) 45%, transparent 90%);
|
||||
transform: translateX(-120%);
|
||||
animation: hero-sweep 4s var(--motion-ease-snappy) infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@keyframes hero-sweep {
|
||||
0% {
|
||||
transform: translateX(-120%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
30% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translateX(120%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(120%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: color var(--motion-duration-short) var(--motion-ease-snappy), background-color var(--motion-duration-short) var(--motion-ease-snappy);
|
||||
}
|
||||
|
||||
.tag-chip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 4px;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
opacity: 0.5;
|
||||
transition: width var(--motion-duration-short) var(--motion-ease-snappy), left var(--motion-duration-short) var(--motion-ease-snappy);
|
||||
}
|
||||
|
||||
.tag-chip:hover::after,
|
||||
.tag-chip:focus-visible::after {
|
||||
width: 100%;
|
||||
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);
|
||||
line-height: 1.2;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-family: var(--font-serif-eng), "Songti SC", serif;
|
||||
}
|
||||
|
||||
.type-title {
|
||||
font-size: clamp(1.6rem, 1.1rem + 1.4vw, 2.6rem);
|
||||
line-height: 1.3;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-family: var(--font-serif-eng), "Songti SC", serif;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.type-subtitle {
|
||||
font-size: clamp(1.25rem, 0.9rem + 1vw, 1.9rem);
|
||||
line-height: 1.35;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-family: var(--font-serif-eng), "Songti SC", serif;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.type-body {
|
||||
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem);
|
||||
line-height: var(--line-height-body);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.type-small {
|
||||
font-size: clamp(0.85rem, 0.2vw + 0.8rem, 0.95rem);
|
||||
line-height: 1.4;
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
font-family: var(--font-serif-eng), "Songti SC", serif;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
font-family: var(--font-serif-eng), "Songti SC", serif;
|
||||
}
|
||||
|
||||
.type-nav {
|
||||
font-size: clamp(0.95rem, 0.2vw + 0.85rem, 1.05rem);
|
||||
font-weight: var(--font-weight-medium);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.motion-card {
|
||||
transition: transform var(--motion-duration-medium) var(--motion-ease-snappy),
|
||||
box-shadow var(--motion-duration-medium) var(--motion-ease-snappy),
|
||||
background-color var(--motion-duration-medium) var(--motion-ease-snappy),
|
||||
border-color var(--motion-duration-medium) var(--motion-ease-snappy);
|
||||
}
|
||||
|
||||
.motion-card:hover {
|
||||
transform: translateY(var(--card-translate-y));
|
||||
}
|
||||
|
||||
.motion-link {
|
||||
transition: color var(--motion-duration-short) var(--motion-ease-snappy),
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,36 @@ module.exports = {
|
||||
textDark: 'var(--color-accent-text-dark)'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
'serif-eng': ['var(--font-serif-eng)', 'serif'],
|
||||
'serif-cn': ['"Songti SC"', '"Noto Serif TC"', '"SimSun"', 'serif'],
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
snappy: 'cubic-bezier(0.32, 0.72, 0, 1)'
|
||||
},
|
||||
transitionDuration: {
|
||||
180: '180ms',
|
||||
260: '260ms'
|
||||
},
|
||||
boxShadow: {
|
||||
lifted: '0 12px 30px -14px rgba(15, 23, 42, 0.25)',
|
||||
outline: '0 0 0 1px rgba(59, 130, 246, 0.25)'
|
||||
},
|
||||
keyframes: {
|
||||
'fade-in-up': {
|
||||
'0%': { opacity: '0', transform: 'translateY(8px) scale(0.98)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0) scale(1)' }
|
||||
},
|
||||
'float-soft': {
|
||||
'0%': { transform: 'translate3d(0,0,0) scale(1)' },
|
||||
'50%': { transform: 'translate3d(4px,-6px,0) scale(1.03)' },
|
||||
'100%': { transform: 'translate3d(0,0,0) scale(1)' }
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'fade-in-up': 'fade-in-up 0.6s ease-out both',
|
||||
'float-soft': 'float-soft 12s ease-in-out infinite'
|
||||
},
|
||||
typography: (theme) => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
@@ -28,11 +58,13 @@ module.exports = {
|
||||
},
|
||||
h1: {
|
||||
fontWeight: '700',
|
||||
letterSpacing: '-0.03em'
|
||||
letterSpacing: '-0.03em',
|
||||
fontFamily: 'var(--font-serif-eng), "Songti SC", serif',
|
||||
},
|
||||
h2: {
|
||||
fontWeight: '600',
|
||||
letterSpacing: '-0.02em'
|
||||
letterSpacing: '-0.02em',
|
||||
fontFamily: 'var(--font-serif-eng), "Songti SC", serif',
|
||||
},
|
||||
blockquote: {
|
||||
fontStyle: 'normal',
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user