Compare commits

...

24 Commits

Author SHA1 Message Date
ee2eb4796e SECURITY: Update Next.js and React to patch critical RCE vulnerability
Addresses CVE-2025-55182 (React) and CVE-2025-66478 (Next.js)
- CVSS Score: 10.0 (Critical)
- Allows unauthenticated remote code execution via RSC payloads

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 21:23:10 +08:00
34 changed files with 973 additions and 458 deletions

View File

@@ -12,6 +12,7 @@ import { PostCard } from '@/components/post-card';
import { PostStorylineNav } from '@/components/post-storyline-nav'; import { PostStorylineNav } from '@/components/post-storyline-nav';
import { SectionDivider } from '@/components/section-divider'; import { SectionDivider } from '@/components/section-divider';
import { FooterCue } from '@/components/footer-cue'; import { FooterCue } from '@/components/footer-cue';
import { JsonLd } from '@/components/json-ld';
export function generateStaticParams() { export function generateStaticParams() {
return allPosts.map((post) => ({ return allPosts.map((post) => ({
@@ -76,8 +77,88 @@ export default async function BlogPostPage({ params }: Props) {
const hasToc = /<h[23]/.test(post.body.html); 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 ( return (
<> <>
<JsonLd data={blogPostingSchema} />
<JsonLd data={breadcrumbSchema} />
<ReadingProgress /> <ReadingProgress />
<PostLayout hasToc={hasToc} contentKey={slug}> <PostLayout hasToc={hasToc} contentKey={slug}>
<div className="space-y-8"> <div className="space-y-8">
@@ -117,7 +198,10 @@ export default async function BlogPostPage({ params }: Props) {
<SectionDivider> <SectionDivider>
<ScrollReveal> <ScrollReveal>
<article className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark"> <article
data-toc-content={slug}
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark"
>
{post.feature_image && ( {post.feature_image && (
<div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4"> <div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4">
<Image <Image

View File

@@ -1,8 +1,7 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FiAlertTriangle } from 'react-icons/fi';
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
export default function Error({ export default function Error({
error, error,
@@ -20,10 +19,7 @@ export default function Error({
<div className="flex min-h-screen items-center justify-center px-4"> <div className="flex min-h-screen items-center justify-center px-4">
<div className="max-w-md text-center"> <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"> <div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20">
<FontAwesomeIcon <FiAlertTriangle className="h-8 w-8 text-red-600 dark:text-red-400" />
icon={faTriangleExclamation}
className="h-8 w-8 text-red-600 dark:text-red-400"
/>
</div> </div>
<h2 className="mb-2 text-2xl font-semibold text-slate-900 dark:text-slate-100"> <h2 className="mb-2 text-2xl font-semibold text-slate-900 dark:text-slate-100">

View File

@@ -4,6 +4,7 @@ import { siteConfig } from '@/lib/config';
import { LayoutShell } from '@/components/layout-shell'; import { LayoutShell } from '@/components/layout-shell';
import { ThemeProvider } from 'next-themes'; import { ThemeProvider } from 'next-themes';
import { Playfair_Display } from 'next/font/google'; import { Playfair_Display } from 'next/font/google';
import { JsonLd } from '@/components/json-ld';
const playfair = Playfair_Display({ const playfair = Playfair_Display({
subsets: ['latin'], subsets: ['latin'],
@@ -49,9 +50,48 @@ export default function RootLayout({
}) { }) {
const theme = siteConfig.theme; 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 ( return (
<html lang={siteConfig.defaultLocale} suppressHydrationWarning className={playfair.variable}> <html lang={siteConfig.defaultLocale} suppressHydrationWarning className={playfair.variable}>
<body> <body>
<JsonLd data={websiteSchema} />
<JsonLd data={organizationSchema} />
<style <style
// Set CSS variables for accent colors (light + dark variants) // Set CSS variables for accent colors (light + dark variants)
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{

View File

@@ -4,12 +4,35 @@ import { siteConfig } from '@/lib/config';
import { PostListItem } from '@/components/post-list-item'; import { PostListItem } from '@/components/post-list-item';
import { TimelineWrapper } from '@/components/timeline-wrapper'; import { TimelineWrapper } from '@/components/timeline-wrapper';
import { SidebarLayout } from '@/components/sidebar-layout'; import { SidebarLayout } from '@/components/sidebar-layout';
import { JsonLd } from '@/components/json-ld';
export default function HomePage() { export default function HomePage() {
const posts = getAllPostsSorted().slice(0, siteConfig.postsPerPage); 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 ( return (
<section className="space-y-6"> <>
<JsonLd data={collectionPageSchema} />
<section className="space-y-6">
<SidebarLayout> <SidebarLayout>
<header className="space-y-1 text-center"> <header className="space-y-1 text-center">
<h1 className="type-title font-bold text-slate-900 dark:text-slate-50"> <h1 className="type-title font-bold text-slate-900 dark:text-slate-50">
@@ -40,5 +63,6 @@ export default function HomePage() {
</div> </div>
</SidebarLayout> </SidebarLayout>
</section> </section>
</>
); );
} }

View File

@@ -9,6 +9,7 @@ import { ReadingProgress } from '@/components/reading-progress';
import { PostLayout } from '@/components/post-layout'; import { PostLayout } from '@/components/post-layout';
import { ScrollReveal } from '@/components/scroll-reveal'; import { ScrollReveal } from '@/components/scroll-reveal';
import { SectionDivider } from '@/components/section-divider'; import { SectionDivider } from '@/components/section-divider';
import { JsonLd } from '@/components/json-ld';
export function generateStaticParams() { export function generateStaticParams() {
return allPages.map((page) => ({ return allPages.map((page) => ({
@@ -39,8 +40,39 @@ export default async function StaticPage({ params }: Props) {
const hasToc = /<h[23]/.test(page.body.html); 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 ( return (
<> <>
<JsonLd data={webPageSchema} />
<ReadingProgress /> <ReadingProgress />
<PostLayout hasToc={hasToc} contentKey={slug}> <PostLayout hasToc={hasToc} contentKey={slug}>
<div className="space-y-8"> <div className="space-y-8">
@@ -78,7 +110,10 @@ export default async function StaticPage({ params }: Props) {
<SectionDivider> <SectionDivider>
<ScrollReveal> <ScrollReveal>
<article className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark"> <article
data-toc-content={slug}
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark"
>
{page.feature_image && ( {page.feature_image && (
<div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4"> <div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4">
<Image <Image

View File

@@ -5,8 +5,7 @@ import { getTagSlug } from '@/lib/posts';
import { SidebarLayout } from '@/components/sidebar-layout'; import { SidebarLayout } from '@/components/sidebar-layout';
import { SectionDivider } from '@/components/section-divider'; import { SectionDivider } from '@/components/section-divider';
import { ScrollReveal } from '@/components/scroll-reveal'; import { ScrollReveal } from '@/components/scroll-reveal';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FiTag } from 'react-icons/fi';
import { faTag } from '@fortawesome/free-solid-svg-icons';
export function generateStaticParams() { export function generateStaticParams() {
const slugs = new Set<string>(); const slugs = new Set<string>();
@@ -57,7 +56,7 @@ export default async function TagPage({ params }: Props) {
<ScrollReveal> <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="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"> <div className="inline-flex items-center gap-2 text-accent">
<FontAwesomeIcon icon={faTag} className="h-5 w-5" /> <FiTag className="h-5 w-5" />
<span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400"> <span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
TAG ARCHIVE TAG ARCHIVE
</span> </span>

View File

@@ -1,7 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FiTag, FiTrendingUp } from 'react-icons/fi';
import { faTags, faFire } from '@fortawesome/free-solid-svg-icons';
import { getAllTagsWithCount } from '@/lib/posts'; import { getAllTagsWithCount } from '@/lib/posts';
import { SectionDivider } from '@/components/section-divider'; import { SectionDivider } from '@/components/section-divider';
import { ScrollReveal } from '@/components/scroll-reveal'; import { ScrollReveal } from '@/components/scroll-reveal';
@@ -30,7 +29,7 @@ export default function TagIndexPage() {
<ScrollReveal> <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="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"> <div className="inline-flex items-center gap-2 text-accent">
<FontAwesomeIcon icon={faTags} className="h-5 w-5" /> <FiTag className="h-5 w-5" />
<span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400"> <span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
</span> </span>
@@ -65,7 +64,7 @@ export default function TagIndexPage() {
</span> </span>
</div> </div>
<span className="mt-1 inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400"> <span className="mt-1 inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<FontAwesomeIcon icon={faFire} className="h-3 w-3 text-orange-400" /> <FiTrendingUp className="h-3 w-3 text-orange-400" />
#{index + 1} #{index + 1}
</span> </span>
</Link> </Link>

View File

@@ -1,13 +1,6 @@
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FaGithub, FaTwitter, FaMastodon, FaGit, FaLinkedin } from 'react-icons/fa';
import { import { FiMail, FiFeather } from 'react-icons/fi';
faGithub,
faTwitter,
faMastodon,
faGitAlt,
faLinkedin
} from '@fortawesome/free-brands-svg-icons';
import { faEnvelope, faPenNib } from '@fortawesome/free-solid-svg-icons';
import { MetaItem } from './meta-item'; import { MetaItem } from './meta-item';
export function Hero() { export function Hero() {
@@ -19,37 +12,37 @@ export function Hero() {
key: 'github', key: 'github',
href: social.github, href: social.github,
label: 'GitHub', label: 'GitHub',
icon: faGithub icon: FaGithub
}, },
social.twitter && { social.twitter && {
key: 'twitter', key: 'twitter',
href: `https://twitter.com/${social.twitter.replace('@', '')}`, href: `https://twitter.com/${social.twitter.replace('@', '')}`,
label: 'Twitter', label: 'Twitter',
icon: faTwitter icon: FaTwitter
}, },
social.mastodon && { social.mastodon && {
key: 'mastodon', key: 'mastodon',
href: social.mastodon, href: social.mastodon,
label: 'Mastodon', label: 'Mastodon',
icon: faMastodon icon: FaMastodon
}, },
social.gitea && { social.gitea && {
key: 'gitea', key: 'gitea',
href: social.gitea, href: social.gitea,
label: 'Gitea', label: 'Gitea',
icon: faGitAlt icon: FaGit
}, },
social.linkedin && { social.linkedin && {
key: 'linkedin', key: 'linkedin',
href: social.linkedin, href: social.linkedin,
label: 'LinkedIn', label: 'LinkedIn',
icon: faLinkedin icon: FaLinkedin
}, },
social.email && { social.email && {
key: 'email', key: 'email',
href: `mailto:${social.email}`, href: `mailto:${social.email}`,
label: 'Email', label: 'Email',
icon: faEnvelope icon: FiMail
} }
].filter(Boolean) as { ].filter(Boolean) as {
key: string; key: string;
@@ -73,7 +66,7 @@ export function Hero() {
{name} {name}
</h1> </h1>
<div className="mt-1"> <div className="mt-1">
<MetaItem icon={faPenNib}> <MetaItem icon={FiFeather}>
{tagline} {tagline}
</MetaItem> </MetaItem>
</div> </div>
@@ -87,7 +80,7 @@ export function Hero() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="motion-link flex items-center gap-2 rounded-full bg-white/80 px-3 py-1 text-slate-600 shadow-sm ring-1 ring-slate-200 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent hover:shadow-md dark:bg-slate-900/80 dark:text-slate-200 dark:ring-slate-700" 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> <span>{item.label}</span>
</a> </a>
))} ))}

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

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

View File

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

View File

@@ -1,9 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FaMastodon } from 'react-icons/fa';
import { faMastodon } from '@fortawesome/free-brands-svg-icons'; import { FiArrowRight } from 'react-icons/fi';
import { faArrowRight } from '@fortawesome/free-solid-svg-icons';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { import {
parseMastodonUrl, parseMastodonUrl,
@@ -76,10 +75,7 @@ export function MastodonFeed() {
<section className="motion-card group rounded-xl border bg-white px-4 py-3 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-900/90"> <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 */} {/* 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"> <div className="type-small mb-3 flex items-center gap-2 font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
<FontAwesomeIcon <FaMastodon className="h-4 w-4 text-purple-500 dark:text-purple-400" />
icon={faMastodon}
className="h-4 w-4 text-purple-500 dark:text-purple-400"
/>
</div> </div>
@@ -119,10 +115,7 @@ export function MastodonFeed() {
{/* Boost indicator */} {/* Boost indicator */}
{status.reblog && ( {status.reblog && (
<div className="type-small flex items-center gap-1 text-slate-400 dark:text-slate-500"> <div className="type-small flex items-center gap-1 text-slate-400 dark:text-slate-500">
<FontAwesomeIcon <FiArrowRight className="h-2.5 w-2.5 rotate-90" />
icon={faArrowRight}
className="h-2.5 w-2.5 rotate-90"
/>
<span></span> <span></span>
</div> </div>
)} )}
@@ -162,7 +155,7 @@ export function MastodonFeed() {
className="type-small mt-3 flex items-center justify-end gap-1.5 text-slate-500 transition-colors hover:text-accent-textLight dark:text-slate-400 dark:hover:text-accent-textDark" className="type-small mt-3 flex items-center justify-end gap-1.5 text-slate-500 transition-colors hover:text-accent-textLight dark:text-slate-400 dark:hover:text-accent-textDark"
> >
<FontAwesomeIcon icon={faArrowRight} className="h-3 w-3" /> <FiArrowRight className="h-3 w-3" />
</a> </a>
)} )}
</section> </section>

View File

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

View File

@@ -1,23 +1,26 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef, FocusEvent, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { createPortal } from 'react-dom';
import { import {
faBars, FiMenu,
faXmark, FiX,
faHouse, FiHome,
faNewspaper, FiFileText,
faFileLines, FiFile,
faUser, FiUser,
faEnvelope, FiMail,
faLocationDot, FiMapPin,
faPenNib, FiFeather,
faTags, FiTag,
faServer, FiServer,
faMicrochip, FiCpu,
faBarsStaggered FiList,
} from '@fortawesome/free-solid-svg-icons'; FiChevronDown,
FiChevronRight
} from 'react-icons/fi';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation';
export type IconKey = export type IconKey =
| 'home' | 'home'
@@ -33,24 +36,25 @@ export type IconKey =
| 'menu'; | 'menu';
const ICON_MAP: Record<IconKey, any> = { const ICON_MAP: Record<IconKey, any> = {
home: faHouse, home: FiHome,
blog: faNewspaper, blog: FiFileText,
file: faFileLines, file: FiFile,
user: faUser, user: FiUser,
contact: faEnvelope, contact: FiMail,
location: faLocationDot, location: FiMapPin,
pen: faPenNib, pen: FiFeather,
tags: faTags, tags: FiTag,
server: faServer, server: FiServer,
device: faMicrochip, device: FiCpu,
menu: faBarsStaggered menu: FiList
}; };
export interface NavLinkItem { export interface NavLinkItem {
key: string; key: string;
href: string; href?: string;
label?: string; label: string;
iconKey: IconKey; iconKey: IconKey;
children?: NavLinkItem[];
} }
interface NavMenuProps { interface NavMenuProps {
@@ -59,40 +63,243 @@ interface NavMenuProps {
export function NavMenu({ items }: NavMenuProps) { export function NavMenu({ items }: NavMenuProps) {
const [open, setOpen] = useState(false); 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 toggle = () => setOpen((val) => !val);
const close = () => setOpen(false); 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 ( return (
<div className="flex items-center gap-3"> <>
{/* Mobile Menu Trigger */}
<button <button
type="button" type="button"
className="sm:hidden inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition duration-180 ease-snappy hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800" className="relative z-50 inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 sm:hidden"
aria-label={open ? '關閉選單' : '開啟選單'} aria-label={open ? '關閉選單' : '開啟選單'}
aria-expanded={open} aria-expanded={open}
onClick={toggle} onClick={toggle}
> >
<FontAwesomeIcon icon={open ? faXmark : faBars} className="h-4 w-4" /> <div className="relative h-5 w-5">
<span
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? 'rotate-45' : '-translate-y-1.5'
}`}
/>
<span
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? 'opacity-0' : 'opacity-100'
}`}
/>
<span
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? '-rotate-45' : 'translate-y-1.5'
}`}
/>
</div>
</button> </button>
<nav
className={`${open ? 'flex' : 'hidden'} flex-col gap-2 sm:flex sm:flex-row sm:items-center sm:gap-3`} {/* Mobile Menu Overlay - Portaled */}
> {mounted && createPortal(
{items.map((item) => ( <div
<Link 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'
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" {/* Close button area */}
onClick={close} <div className="flex items-center justify-end px-4 py-3">
> <button
<FontAwesomeIcon type="button"
icon={ICON_MAP[item.iconKey] ?? faFileLines} 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"
className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" onClick={close}
/> aria-label="Close menu"
<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" /> <div className="relative h-5 w-5">
</Link> <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> </nav>
</div> </>
); );
} }

View File

@@ -2,7 +2,7 @@ import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import type { Post } from 'contentlayer2/generated'; import type { Post } from 'contentlayer2/generated';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons'; import { FiCalendar, FiTag } from 'react-icons/fi';
import { MetaItem } from './meta-item'; import { MetaItem } from './meta-item';
interface PostCardProps { interface PostCardProps {
@@ -35,14 +35,14 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
<div className="space-y-3 px-4 py-4"> <div className="space-y-3 px-4 py-4">
<div className="flex flex-wrap items-center gap-3 text-xs"> <div className="flex flex-wrap items-center gap-3 text-xs">
{post.published_at && ( {post.published_at && (
<MetaItem icon={faCalendarDays}> <MetaItem icon={FiCalendar}>
{new Date(post.published_at).toLocaleDateString( {new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale siteConfig.defaultLocale
)} )}
</MetaItem> </MetaItem>
)} )}
{showTags && post.tags && post.tags.length > 0 && ( {showTags && post.tags && post.tags.length > 0 && (
<MetaItem icon={faTags} tone="muted"> <MetaItem icon={FiTag} tone="muted">
{post.tags.slice(0, 3).join(', ')} {post.tags.slice(0, 3).join(', ')}
</MetaItem> </MetaItem>
)} )}

View File

@@ -1,9 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { createPortal } from 'react-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FiList, FiX } from 'react-icons/fi';
import { faListUl, faChevronRight, faChevronLeft } from '@fortawesome/free-solid-svg-icons';
import { PostToc } from './post-toc'; import { PostToc } from './post-toc';
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@@ -13,80 +12,137 @@ function cn(...inputs: ClassValue[]) {
} }
export function PostLayout({ children, hasToc = true, contentKey }: { children: React.ReactNode; hasToc?: boolean; contentKey?: string }) { export function PostLayout({ children, hasToc = true, contentKey }: { children: React.ReactNode; hasToc?: boolean; contentKey?: string }) {
const [isTocOpen, setIsTocOpen] = useState(hasToc); 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 ( return (
<div className="relative"> <div className="relative">
<div className={cn( <div className={cn(
"group grid gap-8 transition-all duration-500 ease-snappy", "group grid gap-8 transition-all duration-500 ease-snappy",
isTocOpen && hasToc ? "lg:grid-cols-[1fr_16rem] toc-open" : "lg:grid-cols-[1fr_0rem]" isDesktopTocOpen && hasToc ? "lg:grid-cols-[1fr_16rem] toc-open" : "lg:grid-cols-[1fr_0rem]"
)}> )}>
{/* Main Content Area */} {/* Main Content Area */}
<div className="min-w-0"> <div className="min-w-0">
<motion.div <div className={cn("mx-auto transition-all duration-500 ease-snappy", isDesktopTocOpen && hasToc ? "max-w-3xl" : "max-w-4xl")}>
layout
className={cn("mx-auto transition-all duration-500 ease-snappy", isTocOpen && hasToc ? "max-w-3xl" : "max-w-4xl")}
>
{children} {children}
</motion.div> </div>
</div> </div>
{/* Desktop Sidebar (TOC) */} {/* Desktop Sidebar (TOC) */}
<aside className="hidden lg:block"> <aside className="hidden lg:block">
<div className="sticky top-24 h-[calc(100vh-6rem)] overflow-hidden"> <div className="sticky top-24 h-[calc(100vh-6rem)] overflow-hidden">
<AnimatePresence mode="wait"> {isDesktopTocOpen && hasToc && (
{isTocOpen && hasToc && ( <div className="toc-sidebar h-full overflow-y-auto pr-2">
<motion.div <PostToc contentKey={contentKey} />
initial={{ opacity: 0, x: 20 }} </div>
animate={{ opacity: 1, x: 0 }} )}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3 }}
className="h-full overflow-y-auto pr-2"
>
<PostToc key={contentKey} />
</motion.div>
)}
</AnimatePresence>
</div> </div>
</aside> </aside>
</div> </div>
{/* Mobile TOC Overlay */} {/* Mobile TOC Overlay */}
<AnimatePresence> {mobileToc}
{isTocOpen && hasToc && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
className="fixed bottom-24 right-4 z-40 w-72 rounded-2xl border border-white/20 bg-white/90 p-6 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/90 lg:hidden"
>
<div className="max-h-[60vh] overflow-y-auto">
<PostToc key={contentKey} onLinkClick={() => setIsTocOpen(false)} />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Toggle Button (Glassmorphism Pill) */} {/* Toggle Buttons - Rendered via Portal */}
{hasToc && ( {mounted && createPortal(
<motion.button <>
layout {tocButton}
onClick={() => setIsTocOpen(!isTocOpen)} {desktopTocButton}
className={cn( </>,
"fixed bottom-8 right-8 z-50 flex items-center gap-2 rounded-full border border-white/20 bg-white/80 px-4 py-2.5 shadow-lg backdrop-blur-md transition-all hover:bg-white hover:scale-105 dark:border-white/10 dark:bg-slate-900/80 dark:hover:bg-slate-900", document.body
"text-sm font-medium text-slate-600 dark:text-slate-300",
"lg:right-20" // Adjust position for desktop
)}
whileTap={{ scale: 0.95 }}
aria-label="Toggle Table of Contents"
>
<FontAwesomeIcon
icon={isTocOpen ? faChevronRight : faListUl}
className="h-3.5 w-3.5"
/>
<span>{isTocOpen ? 'Hide' : 'Menu'}</span>
</motion.button>
)} )}
</div> </div>
); );

View File

@@ -2,7 +2,7 @@ import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { Post } from 'contentlayer2/generated'; import { Post } from 'contentlayer2/generated';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons'; import { FiCalendar, FiTag } from 'react-icons/fi';
import { MetaItem } from './meta-item'; import { MetaItem } from './meta-item';
interface Props { interface Props {
@@ -37,14 +37,14 @@ export function PostListItem({ post }: Props) {
<div className="flex-1 space-y-1.5"> <div className="flex-1 space-y-1.5">
<div className="flex flex-wrap gap-3 text-xs"> <div className="flex flex-wrap gap-3 text-xs">
{post.published_at && ( {post.published_at && (
<MetaItem icon={faCalendarDays}> <MetaItem icon={FiCalendar}>
{new Date(post.published_at).toLocaleDateString( {new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale siteConfig.defaultLocale
)} )}
</MetaItem> </MetaItem>
)} )}
{post.tags && post.tags.length > 0 && ( {post.tags && post.tags.length > 0 && (
<MetaItem icon={faTags} tone="muted"> <MetaItem icon={FiTag} tone="muted">
{post.tags.slice(0, 3).join(', ')} {post.tags.slice(0, 3).join(', ')}
</MetaItem> </MetaItem>
)} )}

View File

@@ -2,13 +2,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Post, Page } from 'contentlayer2/generated'; import { Post, Page } from 'contentlayer2/generated';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FiArrowDown, FiArrowUp, FiSearch, FiList } from 'react-icons/fi';
import {
faArrowDownWideShort,
faArrowUpWideShort,
faMagnifyingGlass,
faListUl
} from '@fortawesome/free-solid-svg-icons';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { PostListItem } from './post-list-item'; import { PostListItem } from './post-list-item';
import { TimelineWrapper } from './timeline-wrapper'; import { TimelineWrapper } from './timeline-wrapper';
@@ -83,7 +77,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-4 text-xs text-slate-500 dark:text-slate-400 sm:flex-row sm:items-center sm:justify-between"> <div className="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"> <div className="inline-flex items-center gap-2 rounded-full bg-slate-100/70 px-2 py-1 text-slate-600 dark:bg-slate-800/70 dark:text-slate-300">
<FontAwesomeIcon icon={faListUl} className="h-3.5 w-3.5" /> <FiList className="h-3.5 w-3.5" />
<span></span> <span></span>
<button <button
type="button" type="button"
@@ -93,7 +87,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700' : 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
}`} }`}
> >
<FontAwesomeIcon icon={faArrowDownWideShort} className="h-3 w-3" /> <FiArrowDown className="h-3 w-3" />
</button> </button>
<button <button
@@ -104,7 +98,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700' : 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
}`} }`}
> >
<FontAwesomeIcon icon={faArrowUpWideShort} className="h-3 w-3" /> <FiArrowUp className="h-3 w-3" />
</button> </button>
</div> </div>
@@ -113,8 +107,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
</label> </label>
<div className="relative w-full sm:w-64"> <div className="relative w-full sm:w-64">
<FontAwesomeIcon <FiSearch
icon={faMagnifyingGlass}
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
/> />
<input <input

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
export function ReadingProgress() { export function ReadingProgress() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -29,19 +30,21 @@ export function ReadingProgress() {
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, [mounted]); }, [mounted]);
if (!mounted) return null; if (!mounted) return null;
return ( return createPortal(
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-px bg-transparent"> <div className="pointer-events-none fixed inset-x-0 top-0 z-[1200] h-1.5 bg-transparent">
<div className="relative h-1 w-full overflow-visible"> <div className="relative h-1.5 w-full overflow-visible">
<div <div
className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-blue-500/70 via-sky-400/70 to-indigo-500/70 shadow-[0_0_8px_rgba(59,130,246,0.45)] transition-[transform,opacity] duration-300 ease-out dark:from-blue-400/70 dark:via-sky-300/70 dark:to-indigo-400/70" className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)] transition-[transform,opacity] duration-300 ease-out"
style={{ transform: `scaleX(${progress / 100})`, opacity: progress > 0 ? 1 : 0 }} style={{ transform: `scaleX(${progress / 100})`, opacity: progress > 0 ? 1 : 0 }}
> >
<span className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/70 blur-[1px] dark:bg-slate-900/70" aria-hidden="true" /> <span className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/80 blur-[1px] dark:bg-slate-900/80" aria-hidden="true" />
</div> </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" /> <span className="absolute inset-x-0 top-2 h-px bg-gradient-to-r from-transparent via-blue-200/40 to-transparent blur-sm dark:via-blue-900/30" aria-hidden="true" />
</div> </div>
</div> </div>,
document.body
); );
} }

View File

@@ -1,8 +1,7 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FaGithub, FaMastodon, FaLinkedin } from 'react-icons/fa';
import { faGithub, faMastodon, faLinkedin } from '@fortawesome/free-brands-svg-icons'; import { FiTrendingUp, FiArrowRight } from 'react-icons/fi';
import { faFire, faArrowRight } from '@fortawesome/free-solid-svg-icons';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { getAllTagsWithCount } from '@/lib/posts'; import { getAllTagsWithCount } from '@/lib/posts';
import { allPages } from 'contentlayer2/generated'; import { allPages } from 'contentlayer2/generated';
@@ -21,19 +20,19 @@ export function RightSidebar() {
siteConfig.social.github && { siteConfig.social.github && {
key: 'github', key: 'github',
href: siteConfig.social.github, href: siteConfig.social.github,
icon: faGithub, icon: FaGithub,
label: 'GitHub' label: 'GitHub'
}, },
siteConfig.social.mastodon && { siteConfig.social.mastodon && {
key: 'mastodon', key: 'mastodon',
href: siteConfig.social.mastodon, href: siteConfig.social.mastodon,
icon: faMastodon, icon: FaMastodon,
label: 'Mastodon' label: 'Mastodon'
}, },
siteConfig.social.linkedin && { siteConfig.social.linkedin && {
key: 'linkedin', key: 'linkedin',
href: siteConfig.social.linkedin, href: siteConfig.social.linkedin,
icon: faLinkedin, icon: FaLinkedin,
label: 'LinkedIn' label: 'LinkedIn'
} }
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[]; ].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
@@ -77,7 +76,7 @@ export function RightSidebar() {
aria-label={item.label} aria-label={item.label}
className="motion-link inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent dark:bg-slate-800 dark:text-slate-200" className="motion-link inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent dark:bg-slate-800 dark:text-slate-200"
> >
<FontAwesomeIcon icon={item.icon} className="h-4 w-4" /> <item.icon className="h-4 w-4" />
</a> </a>
))} ))}
</div> </div>
@@ -98,7 +97,7 @@ export function RightSidebar() {
{tags.length > 0 && ( {tags.length > 0 && (
<section className="motion-card rounded-xl border bg-white px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"> <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"> <h2 className="type-small flex items-center gap-2 font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
<FontAwesomeIcon icon={faFire} className="h-3 w-3 text-orange-400" /> <FiTrendingUp className="h-3 w-3 text-orange-400" />
</h2> </h2>
<div className="mt-2 flex flex-wrap gap-2 text-base"> <div className="mt-2 flex flex-wrap gap-2 text-base">
@@ -120,7 +119,7 @@ export function RightSidebar() {
</div> </div>
<div className="mt-3 flex items-center justify-between type-small text-slate-500 dark:text-slate-400"> <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"> <span className="inline-flex items-center gap-1">
<FontAwesomeIcon icon={faArrowRight} className="h-3 w-3" /> <FiArrowRight className="h-3 w-3" />
</span> </span>
<Link <Link

View File

@@ -2,8 +2,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FiSearch, FiX } from 'react-icons/fi';
import { faMagnifyingGlass, faXmark } from '@fortawesome/free-solid-svg-icons';
interface SearchModalProps { interface SearchModalProps {
isOpen: boolean; isOpen: boolean;
@@ -136,7 +135,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
{/* Header */} {/* 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 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"> <div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<FontAwesomeIcon icon={faMagnifyingGlass} className="h-5 w-5" /> <FiSearch className="h-5 w-5" />
<span className="text-sm font-medium"></span> <span className="text-sm font-medium"></span>
</div> </div>
<button <button
@@ -144,7 +143,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
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" 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="關閉搜尋" aria-label="關閉搜尋"
> >
<FontAwesomeIcon icon={faXmark} className="h-5 w-5" /> <FiX className="h-5 w-5" />
</button> </button>
</div> </div>
@@ -199,7 +198,7 @@ export function SearchButton({ onClick }: { onClick: () => void }) {
className="motion-link inline-flex h-9 items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700" className="motion-link inline-flex h-9 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)" aria-label="搜尋 (Cmd+K)"
> >
<FontAwesomeIcon icon={faMagnifyingGlass} className="h-3.5 w-3.5" /> <FiSearch className="h-3.5 w-3.5" />
<span className="hidden sm:inline"></span> <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"> <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 K

View File

@@ -1,13 +1,6 @@
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FaGithub, FaTwitter, FaMastodon, FaGit, FaLinkedin } from 'react-icons/fa';
import { import { FiMail } from 'react-icons/fi';
faGithub,
faTwitter,
faMastodon,
faGitAlt,
faLinkedin
} from '@fortawesome/free-brands-svg-icons';
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
// Calculate year at build time for PPR compatibility // Calculate year at build time for PPR compatibility
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -20,37 +13,37 @@ export function SiteFooter() {
key: 'github', key: 'github',
href: social.github, href: social.github,
label: 'GitHub', label: 'GitHub',
icon: faGithub icon: FaGithub
}, },
social.twitter && { social.twitter && {
key: 'twitter', key: 'twitter',
href: `https://twitter.com/${social.twitter.replace('@', '')}`, href: `https://twitter.com/${social.twitter.replace('@', '')}`,
label: 'Twitter', label: 'Twitter',
icon: faTwitter icon: FaTwitter
}, },
social.mastodon && { social.mastodon && {
key: 'mastodon', key: 'mastodon',
href: social.mastodon, href: social.mastodon,
label: 'Mastodon', label: 'Mastodon',
icon: faMastodon icon: FaMastodon
}, },
social.gitea && { social.gitea && {
key: 'gitea', key: 'gitea',
href: social.gitea, href: social.gitea,
label: 'Gitea', label: 'Gitea',
icon: faGitAlt icon: FaGit
}, },
social.linkedin && { social.linkedin && {
key: 'linkedin', key: 'linkedin',
href: social.linkedin, href: social.linkedin,
label: 'LinkedIn', label: 'LinkedIn',
icon: faLinkedin icon: FaLinkedin
}, },
social.email && { social.email && {
key: 'email', key: 'email',
href: `mailto:${social.email}`, href: `mailto:${social.email}`,
label: 'Email', label: 'Email',
icon: faEnvelope icon: FiMail
} }
].filter(Boolean) as { ].filter(Boolean) as {
key: string; key: string;
@@ -75,7 +68,7 @@ export function SiteFooter() {
aria-label={item.label} aria-label={item.label}
className="text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100" className="text-slate-500 hover:text-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> </a>
))} ))}
</div> </div>

View File

@@ -21,19 +21,60 @@ export function SiteHeader() {
.slice() .slice()
.sort((a, b) => (a.title || '').localeCompare(b.title || '')); .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[] = [ const navItems: NavLinkItem[] = [
{ key: 'home', href: '/', label: '首頁', iconKey: 'home' }, { key: 'home', href: '/', label: '首頁', iconKey: 'home' },
{ key: 'blog', href: '/blog', label: 'Blog', iconKey: 'blog' }, {
...pages.map((page) => ({ key: 'about',
key: page._id, href: aboutChildren[0]?.href,
href: page.url, label: '關於',
label: page.title, iconKey: 'user',
iconKey: getIconForPage(page.title, page.slug) children: aboutChildren
})) },
{
key: 'devices',
href: deviceChildren[0]?.href,
label: '裝置',
iconKey: 'device',
children: deviceChildren
}
]; ];
return ( return (
<header className="bg-white/80 backdrop-blur transition-colors duration-200 ease-snappy dark:bg-gray-950/80"> <header className="relative z-40 bg-white/80 backdrop-blur transition-colors duration-200 ease-snappy dark:bg-gray-950/80">
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-slate-900 dark:text-slate-100"> <div className="container mx-auto flex items-center justify-between px-4 py-3 text-slate-900 dark:text-slate-100">
<Link <Link
href="/" href="/"

View File

@@ -2,8 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FiMoon, FiSun } from 'react-icons/fi';
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
export function ThemeToggle() { export function ThemeToggle() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -27,12 +26,11 @@ export function ThemeToggle() {
onClick={() => setTheme(next)} onClick={() => setTheme(next)}
aria-label={theme === 'dark' ? '切換為淺色主題' : '切換為深色主題'} aria-label={theme === 'dark' ? '切換為淺色主題' : '切換為深色主題'}
> >
<FontAwesomeIcon {isDark ? (
icon={isDark ? faSun : faMoon} <FiSun className="h-4 w-4 rotate-0 text-amber-400 transition-transform duration-260 ease-snappy" />
className={`h-4 w-4 transition-transform duration-260 ease-snappy ${ ) : (
isDark ? 'rotate-0 text-amber-400' : 'rotate-180 text-blue-500' <FiMoon className="h-4 w-4 rotate-180 text-blue-500 transition-transform duration-260 ease-snappy" />
}`} )}
/>
</button> </button>
); );
} }

View File

@@ -9,20 +9,20 @@ interface TimelineWrapperProps {
export function TimelineWrapper({ children, className }: TimelineWrapperProps) { export function TimelineWrapper({ children, className }: TimelineWrapperProps) {
const items = Children.toArray(children); const items = Children.toArray(children);
return ( return (
<div className={clsx('relative pl-8', className)}> <div className={clsx('relative pl-6 md:pl-8', className)}>
<span <span
className="pointer-events-none absolute left-3 top-0 h-full w-[2px] rounded-full bg-blue-400 shadow-[0_0_10px_rgba(59,130,246,0.35)] dark:bg-cyan-300" className="pointer-events-none absolute left-2 top-0 h-full w-[2px] rounded-full bg-blue-400 shadow-[0_0_10px_rgba(59,130,246,0.35)] dark:bg-cyan-300 md:left-3"
aria-hidden="true" aria-hidden="true"
/> />
<span <span
className="pointer-events-none absolute left-3 top-0 h-full w-[8px] rounded-full bg-blue-500/15 blur-[14px]" className="pointer-events-none absolute left-2 top-0 h-full w-[8px] rounded-full bg-blue-500/15 blur-[14px] md:left-3"
aria-hidden="true" aria-hidden="true"
/> />
<div className="space-y-4"> <div className="space-y-4">
{items.map((child, index) => ( {items.map((child, index) => (
<div key={index} className="relative pl-6 sm:pl-8"> <div key={index} className="relative pl-5 sm:pl-8">
<span className="pointer-events-none absolute left-0 top-1/2 h-px w-8 -translate-x-full -translate-y-1/2 bg-gradient-to-r from-transparent via-blue-300/80 to-transparent dark:via-cyan-200/80" aria-hidden="true" /> <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} {child}
</div> </div>
))} ))}

Submodule content updated: d976bb08e2...3f72ccb628

32
env Normal file
View File

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

View File

@@ -42,7 +42,7 @@ export const siteConfig = {
}, },
slugs: {} slugs: {}
}, },
ogImage: process.env.NEXT_PUBLIC_OG_DEFAULT_IMAGE || '/assets/og-default.jpg', ogImage: process.env.NEXT_PUBLIC_OG_DEFAULT_IMAGE || '/assets/og-default.png',
twitterCard: twitterCard:
(process.env.NEXT_PUBLIC_TWITTER_CARD_TYPE as (process.env.NEXT_PUBLIC_TWITTER_CARD_TYPE as
| 'summary' | 'summary'

2
next-env.d.ts vendored
View File

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

215
package-lock.json generated
View File

@@ -10,21 +10,17 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@emotion/is-prop-valid": "^1.4.0", "@emotion/is-prop-valid": "^1.4.0",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@vercel/og": "^0.8.5", "@vercel/og": "^0.8.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"contentlayer2": "^0.5.8", "contentlayer2": "^0.5.8",
"framer-motion": "^12.23.24",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"markdown-wasm": "^1.2.0", "markdown-wasm": "^1.2.0",
"next": "^16.0.3", "next": "^16.0.7",
"next-contentlayer2": "^0.5.8", "next-contentlayer2": "^0.5.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "react": "^19.2.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.1",
"react-icons": "^5.5.0",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.1", "rehype-pretty-code": "^0.14.1",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
@@ -534,7 +530,6 @@
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@emotion/memoize": "^0.9.0" "@emotion/memoize": "^0.9.0"
} }
@@ -1126,65 +1121,6 @@
"integrity": "sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==", "integrity": "sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.1.0.tgz",
"integrity": "sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
"integrity": "sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.1.0.tgz",
"integrity": "sha512-5OUQH9aDH/xHJwnpD4J7oEdGvFGJgYnGe0UebaPIdMW9UxYC/f5jv2VjVEgnikdJN0HL8yQxp9Nq+7gqGZpIIA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~6 || ~7",
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@grpc/grpc-js": { "node_modules/@grpc/grpc-js": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.1.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.1.tgz",
@@ -2010,9 +1946,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
"integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==", "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
@@ -2043,9 +1979,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
"integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==", "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2059,9 +1995,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
"integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==", "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2075,9 +2011,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
"integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==", "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2091,9 +2027,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
"integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==", "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2107,9 +2043,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
"integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==", "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2123,9 +2059,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
"integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==", "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2139,9 +2075,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
"integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==", "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2155,9 +2091,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
"integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==", "integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -5756,33 +5692,6 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "12.23.24",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -8555,21 +8464,6 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8630,12 +8524,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.0.3", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
"integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==", "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.0.3", "@next/env": "16.0.7",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
@@ -8648,14 +8542,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.0.3", "@next/swc-darwin-arm64": "16.0.7",
"@next/swc-darwin-x64": "16.0.3", "@next/swc-darwin-x64": "16.0.7",
"@next/swc-linux-arm64-gnu": "16.0.3", "@next/swc-linux-arm64-gnu": "16.0.7",
"@next/swc-linux-arm64-musl": "16.0.3", "@next/swc-linux-arm64-musl": "16.0.7",
"@next/swc-linux-x64-gnu": "16.0.3", "@next/swc-linux-x64-gnu": "16.0.7",
"@next/swc-linux-x64-musl": "16.0.3", "@next/swc-linux-x64-musl": "16.0.7",
"@next/swc-win32-arm64-msvc": "16.0.3", "@next/swc-win32-arm64-msvc": "16.0.7",
"@next/swc-win32-x64-msvc": "16.0.3", "@next/swc-win32-x64-msvc": "16.0.7",
"sharp": "^0.34.4" "sharp": "^0.34.4"
}, },
"peerDependencies": { "peerDependencies": {
@@ -9467,9 +9361,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/react": { "node_modules/react": {
"version": "19.2.0", "version": "19.2.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
@@ -9477,16 +9371,25 @@
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.2.0", "version": "19.2.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.2.0" "react": "^19.2.1"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
} }
}, },
"node_modules/react-is": { "node_modules/react-is": {

View File

@@ -17,21 +17,17 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@emotion/is-prop-valid": "^1.4.0", "@emotion/is-prop-valid": "^1.4.0",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@vercel/og": "^0.8.5", "@vercel/og": "^0.8.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"contentlayer2": "^0.5.8", "contentlayer2": "^0.5.8",
"framer-motion": "^12.23.24",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"markdown-wasm": "^1.2.0", "markdown-wasm": "^1.2.0",
"next": "^16.0.3", "next": "^16.0.7",
"next-contentlayer2": "^0.5.8", "next-contentlayer2": "^0.5.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "react": "^19.2.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.1",
"react-icons": "^5.5.0",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.1", "rehype-pretty-code": "^0.14.1",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 485 KiB

View File

@@ -13,9 +13,24 @@
--font-weight-semibold: 600; --font-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; --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); 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) { @media (min-width: 2560px) {
:root { :root {
font-size: 20px; font-size: 20px;
@@ -27,6 +42,7 @@ body {
font-size: 1rem; font-size: 1rem;
line-height: var(--line-height-body); line-height: var(--line-height-body);
font-family: var(--font-system-sans); font-family: var(--font-system-sans);
color: var(--color-ink-body);
} }
@keyframes timeline-scroll { @keyframes timeline-scroll {
@@ -95,17 +111,17 @@ body {
.prose blockquote { .prose blockquote {
@apply transition-transform transition-shadow duration-180 ease-snappy; @apply transition-transform transition-shadow duration-180 ease-snappy;
border-left: 4px solid var(--color-accent, #2563eb); border-left: 4px solid var(--color-accent, #2563eb);
background: linear-gradient(135deg, rgba(37, 99, 235, 0.04), rgba(37, 99, 235, 0.08)); background: linear-gradient(135deg, rgba(124, 58, 237, 0.06), rgba(124, 58, 237, 0.1));
padding: 1.2rem 1.5rem; padding: 1.2rem 1.5rem;
font-style: italic; font-style: italic;
color: rgba(15, 23, 42, 0.75); color: rgba(15, 23, 42, 0.78);
position: relative; position: relative;
} }
.dark .prose blockquote { .dark .prose blockquote {
background: linear-gradient(135deg, rgba(96, 165, 250, 0.12), rgba(96, 165, 250, 0.06)); background: linear-gradient(135deg, rgba(167, 139, 250, 0.12), rgba(124, 58, 237, 0.08));
color: rgba(226, 232, 240, 0.8); color: rgba(226, 232, 240, 0.85);
border-left-color: rgba(96, 165, 250, 0.9); border-left-color: rgba(167, 139, 250, 0.9);
} }
.prose blockquote:hover { .prose blockquote:hover {
@@ -134,27 +150,32 @@ body {
.prose { .prose {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.2rem); font-size: clamp(1rem, 0.2vw + 0.9rem, 1.2rem);
line-height: var(--line-height-body); line-height: var(--line-height-body);
color: var(--color-ink-body);
} }
.prose h1 { .prose h1 {
font-size: clamp(2.2rem, 1.4rem + 2.2vw, 3.4rem); font-size: clamp(2.2rem, 1.4rem + 2.2vw, 3.4rem);
line-height: 1.25; line-height: 1.25;
color: var(--color-ink-strong);
} }
.prose h2 { .prose h2 {
font-size: clamp(1.8rem, 1.1rem + 1.6vw, 2.8rem); font-size: clamp(1.8rem, 1.1rem + 1.6vw, 2.8rem);
line-height: 1.3; line-height: 1.3;
color: var(--color-ink-strong);
} }
.prose h3 { .prose h3 {
font-size: clamp(1.4rem, 0.9rem + 1vw, 2rem); font-size: clamp(1.4rem, 0.9rem + 1vw, 2rem);
line-height: 1.35; line-height: 1.35;
color: var(--color-ink-strong);
} }
.prose p, .prose p,
.prose li { .prose li {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem); font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem);
line-height: var(--line-height-body); line-height: var(--line-height-body);
color: var(--color-ink-body);
} }
.prose small, .prose small,
@@ -233,6 +254,69 @@ body {
left: 0; 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 { @layer components {
.type-display { .type-display {
font-size: clamp(2.2rem, 1.6rem + 2.4vw, 3.5rem); font-size: clamp(2.2rem, 1.6rem + 2.4vw, 3.5rem);
@@ -533,4 +617,4 @@ body {
--callout-bg-end: rgba(248, 113, 113, 0.06); --callout-bg-end: rgba(248, 113, 113, 0.06);
--callout-title-color: #fca5a5; --callout-title-color: #fca5a5;
@apply border-red-400; @apply border-red-400;
} }