Compare commits
24 Commits
237e5d403b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ee2eb4796e | |||
| d90442456b | |||
| b17930c10b | |||
| 1f3323834e | |||
| 7cdfb90b1b | |||
| f6c5be0ee4 | |||
| fc24ddb676 | |||
| cafb810155 | |||
| ae37f93508 | |||
| 4a4d6dd933 | |||
| 7bf2c4149d | |||
| 9d7a6757c9 | |||
| d03b061c1e | |||
| d768d108d6 | |||
| 7685c79705 | |||
| 4173aa69d3 | |||
| e2f9c9d556 | |||
| 5d226a2969 | |||
| a77cd17419 | |||
| d42cb46af8 | |||
| d6edcf1757 | |||
| ba60d49fc6 | |||
| 0bb3ee40c6 | |||
| 6badd76733 |
@@ -12,6 +12,7 @@ import { PostCard } from '@/components/post-card';
|
||||
import { PostStorylineNav } from '@/components/post-storyline-nav';
|
||||
import { SectionDivider } from '@/components/section-divider';
|
||||
import { FooterCue } from '@/components/footer-cue';
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
|
||||
export function generateStaticParams() {
|
||||
return allPosts.map((post) => ({
|
||||
@@ -76,8 +77,88 @@ export default async function BlogPostPage({ params }: Props) {
|
||||
|
||||
const hasToc = /<h[23]/.test(post.body.html);
|
||||
|
||||
// Generate absolute URL for the post
|
||||
const postUrl = `${siteConfig.url}${post.url}`;
|
||||
|
||||
// Get the OG image URL (same as in metadata)
|
||||
const ogImageUrl = new URL('/api/og', siteConfig.url);
|
||||
ogImageUrl.searchParams.set('title', post.title);
|
||||
if (post.description) {
|
||||
ogImageUrl.searchParams.set('description', post.description);
|
||||
}
|
||||
if (post.tags && post.tags.length > 0) {
|
||||
ogImageUrl.searchParams.set('tags', post.tags.slice(0, 3).join(','));
|
||||
}
|
||||
|
||||
// Get image URL - prefer feature_image, fallback to OG image
|
||||
const imageUrl = post.feature_image
|
||||
? `${siteConfig.url}${post.feature_image.replace('../assets', '/assets')}`
|
||||
: ogImageUrl.toString();
|
||||
|
||||
// BlogPosting Schema
|
||||
const blogPostingSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.title,
|
||||
description: post.description || post.custom_excerpt || post.title,
|
||||
image: imageUrl,
|
||||
datePublished: post.published_at,
|
||||
dateModified: post.updated_at || post.published_at,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: post.authors?.[0] || siteConfig.author,
|
||||
url: siteConfig.url,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: siteConfig.name,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteConfig.url}${siteConfig.avatar}`,
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': postUrl,
|
||||
},
|
||||
...(post.tags && post.tags.length > 0 && {
|
||||
keywords: post.tags.join(', '),
|
||||
articleSection: post.tags[0],
|
||||
}),
|
||||
inLanguage: siteConfig.defaultLocale,
|
||||
url: postUrl,
|
||||
};
|
||||
|
||||
// BreadcrumbList Schema
|
||||
const breadcrumbSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: '首頁',
|
||||
item: siteConfig.url,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: '所有文章',
|
||||
item: `${siteConfig.url}/blog`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: post.title,
|
||||
item: postUrl,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={blogPostingSchema} />
|
||||
<JsonLd data={breadcrumbSchema} />
|
||||
<ReadingProgress />
|
||||
<PostLayout hasToc={hasToc} contentKey={slug}>
|
||||
<div className="space-y-8">
|
||||
@@ -117,7 +198,10 @@ export default async function BlogPostPage({ params }: Props) {
|
||||
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<article className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark">
|
||||
<article
|
||||
data-toc-content={slug}
|
||||
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark"
|
||||
>
|
||||
{post.feature_image && (
|
||||
<div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4">
|
||||
<Image
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FiAlertTriangle } from 'react-icons/fi';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
@@ -20,10 +19,7 @@ export default function Error({
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20">
|
||||
<FontAwesomeIcon
|
||||
icon={faTriangleExclamation}
|
||||
className="h-8 w-8 text-red-600 dark:text-red-400"
|
||||
/>
|
||||
<FiAlertTriangle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 text-2xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { siteConfig } from '@/lib/config';
|
||||
import { LayoutShell } from '@/components/layout-shell';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { Playfair_Display } from 'next/font/google';
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
subsets: ['latin'],
|
||||
@@ -49,9 +50,48 @@ export default function RootLayout({
|
||||
}) {
|
||||
const theme = siteConfig.theme;
|
||||
|
||||
// WebSite Schema
|
||||
const websiteSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
url: siteConfig.url,
|
||||
inLanguage: siteConfig.defaultLocale,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: siteConfig.author,
|
||||
url: siteConfig.url,
|
||||
},
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: `${siteConfig.url}/blog?search={search_term_string}`,
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
};
|
||||
|
||||
// Organization Schema
|
||||
const organizationSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: siteConfig.name,
|
||||
url: siteConfig.url,
|
||||
logo: `${siteConfig.url}${siteConfig.avatar}`,
|
||||
sameAs: [
|
||||
siteConfig.social.github,
|
||||
siteConfig.social.twitter && `https://twitter.com/${siteConfig.social.twitter.replace('@', '')}`,
|
||||
siteConfig.social.mastodon,
|
||||
].filter(Boolean),
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang={siteConfig.defaultLocale} suppressHydrationWarning className={playfair.variable}>
|
||||
<body>
|
||||
<JsonLd data={websiteSchema} />
|
||||
<JsonLd data={organizationSchema} />
|
||||
<style
|
||||
// Set CSS variables for accent colors (light + dark variants)
|
||||
dangerouslySetInnerHTML={{
|
||||
|
||||
26
app/page.tsx
26
app/page.tsx
@@ -4,12 +4,35 @@ import { siteConfig } from '@/lib/config';
|
||||
import { PostListItem } from '@/components/post-list-item';
|
||||
import { TimelineWrapper } from '@/components/timeline-wrapper';
|
||||
import { SidebarLayout } from '@/components/sidebar-layout';
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
|
||||
export default function HomePage() {
|
||||
const posts = getAllPostsSorted().slice(0, siteConfig.postsPerPage);
|
||||
|
||||
// CollectionPage Schema for homepage
|
||||
const collectionPageSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: `${siteConfig.name} 的最新動態`,
|
||||
description: siteConfig.description,
|
||||
url: siteConfig.url,
|
||||
inLanguage: siteConfig.defaultLocale,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: siteConfig.title,
|
||||
url: siteConfig.url,
|
||||
},
|
||||
about: {
|
||||
'@type': 'Blog',
|
||||
name: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<>
|
||||
<JsonLd data={collectionPageSchema} />
|
||||
<section className="space-y-6">
|
||||
<SidebarLayout>
|
||||
<header className="space-y-1 text-center">
|
||||
<h1 className="type-title font-bold text-slate-900 dark:text-slate-50">
|
||||
@@ -40,5 +63,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ReadingProgress } from '@/components/reading-progress';
|
||||
import { PostLayout } from '@/components/post-layout';
|
||||
import { ScrollReveal } from '@/components/scroll-reveal';
|
||||
import { SectionDivider } from '@/components/section-divider';
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
|
||||
export function generateStaticParams() {
|
||||
return allPages.map((page) => ({
|
||||
@@ -39,8 +40,39 @@ export default async function StaticPage({ params }: Props) {
|
||||
|
||||
const hasToc = /<h[23]/.test(page.body.html);
|
||||
|
||||
// Generate absolute URL for the page
|
||||
const pageUrl = `${siteConfig.url}${page.url}`;
|
||||
|
||||
// Get image URL if available
|
||||
const imageUrl = page.feature_image
|
||||
? `${siteConfig.url}${page.feature_image.replace('../assets', '/assets')}`
|
||||
: `${siteConfig.url}${siteConfig.ogImage}`;
|
||||
|
||||
// WebPage Schema
|
||||
const webPageSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: page.title,
|
||||
description: page.description || page.title,
|
||||
url: pageUrl,
|
||||
image: imageUrl,
|
||||
inLanguage: siteConfig.defaultLocale,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: siteConfig.title,
|
||||
url: siteConfig.url,
|
||||
},
|
||||
...(page.published_at && {
|
||||
datePublished: page.published_at,
|
||||
}),
|
||||
...(page.updated_at && {
|
||||
dateModified: page.updated_at,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={webPageSchema} />
|
||||
<ReadingProgress />
|
||||
<PostLayout hasToc={hasToc} contentKey={slug}>
|
||||
<div className="space-y-8">
|
||||
@@ -78,7 +110,10 @@ export default async function StaticPage({ params }: Props) {
|
||||
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<article className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark">
|
||||
<article
|
||||
data-toc-content={slug}
|
||||
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark"
|
||||
>
|
||||
{page.feature_image && (
|
||||
<div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4">
|
||||
<Image
|
||||
|
||||
@@ -5,8 +5,7 @@ import { getTagSlug } from '@/lib/posts';
|
||||
import { SidebarLayout } from '@/components/sidebar-layout';
|
||||
import { SectionDivider } from '@/components/section-divider';
|
||||
import { ScrollReveal } from '@/components/scroll-reveal';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTag } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FiTag } from 'react-icons/fi';
|
||||
|
||||
export function generateStaticParams() {
|
||||
const slugs = new Set<string>();
|
||||
@@ -57,7 +56,7 @@ export default async function TagPage({ params }: Props) {
|
||||
<ScrollReveal>
|
||||
<div className="motion-card mb-8 rounded-2xl border border-white/40 bg-white/60 p-8 text-center shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-slate-900/60">
|
||||
<div className="inline-flex items-center gap-2 text-accent">
|
||||
<FontAwesomeIcon icon={faTag} className="h-5 w-5" />
|
||||
<FiTag className="h-5 w-5" />
|
||||
<span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
|
||||
TAG ARCHIVE
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTags, faFire } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FiTag, FiTrendingUp } from 'react-icons/fi';
|
||||
import { getAllTagsWithCount } from '@/lib/posts';
|
||||
import { SectionDivider } from '@/components/section-divider';
|
||||
import { ScrollReveal } from '@/components/scroll-reveal';
|
||||
@@ -30,7 +29,7 @@ export default function TagIndexPage() {
|
||||
<ScrollReveal>
|
||||
<div className="motion-card rounded-2xl border border-white/40 bg-white/60 p-8 text-center shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-slate-900/60">
|
||||
<div className="inline-flex items-center gap-2 text-accent">
|
||||
<FontAwesomeIcon icon={faTags} className="h-5 w-5" />
|
||||
<FiTag className="h-5 w-5" />
|
||||
<span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
|
||||
標籤索引
|
||||
</span>
|
||||
@@ -65,7 +64,7 @@ export default function TagIndexPage() {
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-1 inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<FontAwesomeIcon icon={faFire} className="h-3 w-3 text-orange-400" />
|
||||
<FiTrendingUp className="h-3 w-3 text-orange-400" />
|
||||
熱度 #{index + 1}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faGithub,
|
||||
faTwitter,
|
||||
faMastodon,
|
||||
faGitAlt,
|
||||
faLinkedin
|
||||
} from '@fortawesome/free-brands-svg-icons';
|
||||
import { faEnvelope, faPenNib } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FaGithub, FaTwitter, FaMastodon, FaGit, FaLinkedin } from 'react-icons/fa';
|
||||
import { FiMail, FiFeather } from 'react-icons/fi';
|
||||
import { MetaItem } from './meta-item';
|
||||
|
||||
export function Hero() {
|
||||
@@ -19,37 +12,37 @@ export function Hero() {
|
||||
key: 'github',
|
||||
href: social.github,
|
||||
label: 'GitHub',
|
||||
icon: faGithub
|
||||
icon: FaGithub
|
||||
},
|
||||
social.twitter && {
|
||||
key: 'twitter',
|
||||
href: `https://twitter.com/${social.twitter.replace('@', '')}`,
|
||||
label: 'Twitter',
|
||||
icon: faTwitter
|
||||
icon: FaTwitter
|
||||
},
|
||||
social.mastodon && {
|
||||
key: 'mastodon',
|
||||
href: social.mastodon,
|
||||
label: 'Mastodon',
|
||||
icon: faMastodon
|
||||
icon: FaMastodon
|
||||
},
|
||||
social.gitea && {
|
||||
key: 'gitea',
|
||||
href: social.gitea,
|
||||
label: 'Gitea',
|
||||
icon: faGitAlt
|
||||
icon: FaGit
|
||||
},
|
||||
social.linkedin && {
|
||||
key: 'linkedin',
|
||||
href: social.linkedin,
|
||||
label: 'LinkedIn',
|
||||
icon: faLinkedin
|
||||
icon: FaLinkedin
|
||||
},
|
||||
social.email && {
|
||||
key: 'email',
|
||||
href: `mailto:${social.email}`,
|
||||
label: 'Email',
|
||||
icon: faEnvelope
|
||||
icon: FiMail
|
||||
}
|
||||
].filter(Boolean) as {
|
||||
key: string;
|
||||
@@ -73,7 +66,7 @@ export function Hero() {
|
||||
{name}
|
||||
</h1>
|
||||
<div className="mt-1">
|
||||
<MetaItem icon={faPenNib}>
|
||||
<MetaItem icon={FiFeather}>
|
||||
{tagline}
|
||||
</MetaItem>
|
||||
</div>
|
||||
@@ -87,7 +80,7 @@ export function Hero() {
|
||||
rel="noopener noreferrer"
|
||||
className="motion-link flex items-center gap-2 rounded-full bg-white/80 px-3 py-1 text-slate-600 shadow-sm ring-1 ring-slate-200 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent hover:shadow-md dark:bg-slate-900/80 dark:text-slate-200 dark:ring-slate-700"
|
||||
>
|
||||
<FontAwesomeIcon icon={item.icon} className="h-3.5 w-3.5 text-accent" />
|
||||
<item.icon className="h-3.5 w-3.5 text-accent" />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
|
||||
12
components/json-ld.tsx
Normal file
12
components/json-ld.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* JSON-LD component for rendering structured data
|
||||
* Safely serializes and injects Schema.org structured data into the page
|
||||
*/
|
||||
export function JsonLd({ data }: { data: Record<string, any> }) {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SiteHeader } from './site-header';
|
||||
import { SiteFooter } from './site-footer';
|
||||
|
||||
import { BackToTop } from './back-to-top';
|
||||
|
||||
export function LayoutShell({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faMastodon } from '@fortawesome/free-brands-svg-icons';
|
||||
import { faArrowRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FaMastodon } from 'react-icons/fa';
|
||||
import { FiArrowRight } from 'react-icons/fi';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import {
|
||||
parseMastodonUrl,
|
||||
@@ -76,10 +75,7 @@ export function MastodonFeed() {
|
||||
<section className="motion-card group rounded-xl border bg-white px-4 py-3 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-900/90">
|
||||
{/* Header */}
|
||||
<div className="type-small mb-3 flex items-center gap-2 font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
|
||||
<FontAwesomeIcon
|
||||
icon={faMastodon}
|
||||
className="h-4 w-4 text-purple-500 dark:text-purple-400"
|
||||
/>
|
||||
<FaMastodon className="h-4 w-4 text-purple-500 dark:text-purple-400" />
|
||||
微網誌
|
||||
</div>
|
||||
|
||||
@@ -119,10 +115,7 @@ export function MastodonFeed() {
|
||||
{/* Boost indicator */}
|
||||
{status.reblog && (
|
||||
<div className="type-small flex items-center gap-1 text-slate-400 dark:text-slate-500">
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="h-2.5 w-2.5 rotate-90"
|
||||
/>
|
||||
<FiArrowRight className="h-2.5 w-2.5 rotate-90" />
|
||||
<span>轉推了</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -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"
|
||||
>
|
||||
查看更多
|
||||
<FontAwesomeIcon icon={faArrowRight} className="h-3 w-3" />
|
||||
<FiArrowRight className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { IconType } from 'react-icons';
|
||||
|
||||
interface MetaItemProps {
|
||||
icon: IconDefinition;
|
||||
icon: IconType;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
tone?: 'default' | 'muted';
|
||||
}
|
||||
|
||||
export function MetaItem({ icon, children, className, tone = 'default' }: MetaItemProps) {
|
||||
export function MetaItem({ icon: Icon, children, className, tone = 'default' }: MetaItemProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
@@ -19,7 +18,7 @@ export function MetaItem({ icon, children, className, tone = 'default' }: MetaIt
|
||||
className
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className="h-3.5 w-3.5 text-slate-400 dark:text-slate-500" />
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400 dark:text-slate-500" />
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useState, useRef, FocusEvent, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
faBars,
|
||||
faXmark,
|
||||
faHouse,
|
||||
faNewspaper,
|
||||
faFileLines,
|
||||
faUser,
|
||||
faEnvelope,
|
||||
faLocationDot,
|
||||
faPenNib,
|
||||
faTags,
|
||||
faServer,
|
||||
faMicrochip,
|
||||
faBarsStaggered
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
FiMenu,
|
||||
FiX,
|
||||
FiHome,
|
||||
FiFileText,
|
||||
FiFile,
|
||||
FiUser,
|
||||
FiMail,
|
||||
FiMapPin,
|
||||
FiFeather,
|
||||
FiTag,
|
||||
FiServer,
|
||||
FiCpu,
|
||||
FiList,
|
||||
FiChevronDown,
|
||||
FiChevronRight
|
||||
} from 'react-icons/fi';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export type IconKey =
|
||||
| 'home'
|
||||
@@ -33,24 +36,25 @@ export type IconKey =
|
||||
| 'menu';
|
||||
|
||||
const ICON_MAP: Record<IconKey, any> = {
|
||||
home: faHouse,
|
||||
blog: faNewspaper,
|
||||
file: faFileLines,
|
||||
user: faUser,
|
||||
contact: faEnvelope,
|
||||
location: faLocationDot,
|
||||
pen: faPenNib,
|
||||
tags: faTags,
|
||||
server: faServer,
|
||||
device: faMicrochip,
|
||||
menu: faBarsStaggered
|
||||
home: FiHome,
|
||||
blog: FiFileText,
|
||||
file: FiFile,
|
||||
user: FiUser,
|
||||
contact: FiMail,
|
||||
location: FiMapPin,
|
||||
pen: FiFeather,
|
||||
tags: FiTag,
|
||||
server: FiServer,
|
||||
device: FiCpu,
|
||||
menu: FiList
|
||||
};
|
||||
|
||||
export interface NavLinkItem {
|
||||
key: string;
|
||||
href: string;
|
||||
label?: string;
|
||||
href?: string;
|
||||
label: string;
|
||||
iconKey: IconKey;
|
||||
children?: NavLinkItem[];
|
||||
}
|
||||
|
||||
interface NavMenuProps {
|
||||
@@ -59,40 +63,243 @@ interface NavMenuProps {
|
||||
|
||||
export function NavMenu({ items }: NavMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
|
||||
const [expandedMobileItems, setExpandedMobileItems] = useState<string[]>([]);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const closeTimer = useRef<number | null>(null);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Lock body scroll when menu is open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Close menu on route change
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
const toggle = () => setOpen((val) => !val);
|
||||
const close = () => setOpen(false);
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
|
||||
setActiveDropdown(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openDropdown = (key: string) => {
|
||||
clearCloseTimer();
|
||||
setActiveDropdown(key);
|
||||
};
|
||||
|
||||
const scheduleCloseDropdown = () => {
|
||||
clearCloseTimer();
|
||||
closeTimer.current = window.setTimeout(() => setActiveDropdown(null), 180);
|
||||
};
|
||||
|
||||
const toggleMobileItem = (key: string) => {
|
||||
setExpandedMobileItems(prev =>
|
||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
const renderDesktopChild = (item: NavLinkItem) => {
|
||||
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
|
||||
return item.href ? (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="motion-link inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
onClick={close}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-slate-400" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const renderMobileItem = (item: NavLinkItem, depth = 0) => {
|
||||
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expandedMobileItems.includes(item.key);
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div key={item.key} className="flex flex-col">
|
||||
<button
|
||||
onClick={() => toggleMobileItem(item.key)}
|
||||
className="flex w-full items-center justify-between rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-5 w-5 text-slate-400" />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
<FiChevronRight
|
||||
className={`h-5 w-5 text-slate-400 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`grid transition-all duration-200 ease-in-out ${isExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex flex-col gap-1 pl-4 pt-1">
|
||||
{item.children!.map(child => renderMobileItem(child, depth + 1))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return item.href ? (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800"
|
||||
onClick={close}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-slate-400" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<>
|
||||
{/* Mobile Menu Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
className="sm:hidden inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition duration-180 ease-snappy hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
className="relative z-50 inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 sm:hidden"
|
||||
aria-label={open ? '關閉選單' : '開啟選單'}
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<FontAwesomeIcon icon={open ? faXmark : faBars} className="h-4 w-4" />
|
||||
<div className="relative h-5 w-5">
|
||||
<span
|
||||
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? 'rotate-45' : '-translate-y-1.5'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? '-rotate-45' : 'translate-y-1.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<nav
|
||||
className={`${open ? 'flex' : 'hidden'} flex-col gap-2 sm:flex sm:flex-row sm:items-center sm:gap-3`}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="motion-link type-nav group relative inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
|
||||
onClick={close}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={ICON_MAP[item.iconKey] ?? faFileLines}
|
||||
className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent"
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
<span className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-accent transition duration-180 ease-snappy group-hover:scale-x-100" aria-hidden="true" />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Mobile Menu Overlay - Portaled */}
|
||||
{mounted && createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 z-[100] flex flex-col bg-white/95 backdrop-blur-xl transition-all duration-300 ease-snappy dark:bg-gray-950/95 sm:hidden ${open ? 'visible opacity-100' : 'invisible opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{/* Close button area */}
|
||||
<div className="flex items-center justify-end px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
onClick={close}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<div className="relative h-5 w-5">
|
||||
<span className="absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 rotate-45 bg-current" />
|
||||
<span className="absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 -rotate-45 bg-current" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto flex flex-1 flex-col px-4 pb-8">
|
||||
<div className="flex flex-1 flex-col gap-2 overflow-y-auto pt-4">
|
||||
{items.map(item => renderMobileItem(item))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-8 text-center text-xs text-slate-400">
|
||||
<p>© {new Date().getFullYear()} All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<nav className="hidden sm:flex sm:items-center sm:gap-3">
|
||||
{items.map((item) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
|
||||
const isOpen = activeDropdown === item.key;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="group relative"
|
||||
onMouseEnter={() => openDropdown(item.key)}
|
||||
onMouseLeave={scheduleCloseDropdown}
|
||||
onFocus={() => openDropdown(item.key)}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="motion-link type-nav inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" />
|
||||
<span>{item.label}</span>
|
||||
<FiChevronDown className="h-3 w-3 text-slate-400 transition group-hover:text-accent" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`absolute left-0 top-full z-50 hidden min-w-[12rem] rounded-2xl border border-slate-200 bg-white p-2 shadow-lg transition duration-200 ease-snappy dark:border-slate-800 dark:bg-slate-900 sm:block ${isOpen ? 'pointer-events-auto translate-y-2 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'
|
||||
}`}
|
||||
role="menu"
|
||||
aria-label={item.label}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{item.children.map((child) => renderDesktopChild(child))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
|
||||
|
||||
return item.href ? (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="motion-link type-nav group relative inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
|
||||
onClick={close}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" />
|
||||
<span>{item.label}</span>
|
||||
<span className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-accent transition duration-180 ease-snappy group-hover:scale-x-100" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null;
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Post } from 'contentlayer2/generated';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FiCalendar, FiTag } from 'react-icons/fi';
|
||||
import { MetaItem } from './meta-item';
|
||||
|
||||
interface PostCardProps {
|
||||
@@ -35,14 +35,14 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
{post.published_at && (
|
||||
<MetaItem icon={faCalendarDays}>
|
||||
<MetaItem icon={FiCalendar}>
|
||||
{new Date(post.published_at).toLocaleDateString(
|
||||
siteConfig.defaultLocale
|
||||
)}
|
||||
</MetaItem>
|
||||
)}
|
||||
{showTags && post.tags && post.tags.length > 0 && (
|
||||
<MetaItem icon={faTags} tone="muted">
|
||||
<MetaItem icon={FiTag} tone="muted">
|
||||
{post.tags.slice(0, 3).join(', ')}
|
||||
</MetaItem>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faListUl, faChevronRight, faChevronLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FiList, FiX } from 'react-icons/fi';
|
||||
import { PostToc } from './post-toc';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -13,80 +12,137 @@ function cn(...inputs: ClassValue[]) {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="relative">
|
||||
<div className={cn(
|
||||
"group grid gap-8 transition-all duration-500 ease-snappy",
|
||||
isTocOpen && hasToc ? "lg:grid-cols-[1fr_16rem] toc-open" : "lg:grid-cols-[1fr_0rem]"
|
||||
isDesktopTocOpen && hasToc ? "lg:grid-cols-[1fr_16rem] toc-open" : "lg:grid-cols-[1fr_0rem]"
|
||||
)}>
|
||||
{/* Main Content Area */}
|
||||
<div className="min-w-0">
|
||||
<motion.div
|
||||
layout
|
||||
className={cn("mx-auto transition-all duration-500 ease-snappy", isTocOpen && hasToc ? "max-w-3xl" : "max-w-4xl")}
|
||||
>
|
||||
<div className={cn("mx-auto transition-all duration-500 ease-snappy", isDesktopTocOpen && hasToc ? "max-w-3xl" : "max-w-4xl")}>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Sidebar (TOC) */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-24 h-[calc(100vh-6rem)] overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{isTocOpen && hasToc && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full overflow-y-auto pr-2"
|
||||
>
|
||||
<PostToc key={contentKey} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{isDesktopTocOpen && hasToc && (
|
||||
<div className="toc-sidebar h-full overflow-y-auto pr-2">
|
||||
<PostToc contentKey={contentKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Mobile TOC Overlay */}
|
||||
<AnimatePresence>
|
||||
{isTocOpen && hasToc && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed bottom-24 right-4 z-40 w-72 rounded-2xl border border-white/20 bg-white/90 p-6 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/90 lg:hidden"
|
||||
>
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<PostToc key={contentKey} onLinkClick={() => setIsTocOpen(false)} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{mobileToc}
|
||||
|
||||
{/* Toggle Button (Glassmorphism Pill) */}
|
||||
{hasToc && (
|
||||
<motion.button
|
||||
layout
|
||||
onClick={() => setIsTocOpen(!isTocOpen)}
|
||||
className={cn(
|
||||
"fixed bottom-8 right-8 z-50 flex items-center gap-2 rounded-full border border-white/20 bg-white/80 px-4 py-2.5 shadow-lg backdrop-blur-md transition-all hover:bg-white hover:scale-105 dark:border-white/10 dark:bg-slate-900/80 dark:hover:bg-slate-900",
|
||||
"text-sm font-medium text-slate-600 dark:text-slate-300",
|
||||
"lg:right-20" // Adjust position for desktop
|
||||
)}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
aria-label="Toggle Table of Contents"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isTocOpen ? faChevronRight : faListUl}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>{isTocOpen ? 'Hide' : 'Menu'}</span>
|
||||
</motion.button>
|
||||
{/* Toggle Buttons - Rendered via Portal */}
|
||||
{mounted && createPortal(
|
||||
<>
|
||||
{tocButton}
|
||||
{desktopTocButton}
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Post } from 'contentlayer2/generated';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FiCalendar, FiTag } from 'react-icons/fi';
|
||||
import { MetaItem } from './meta-item';
|
||||
|
||||
interface Props {
|
||||
@@ -37,14 +37,14 @@ export function PostListItem({ post }: Props) {
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{post.published_at && (
|
||||
<MetaItem icon={faCalendarDays}>
|
||||
<MetaItem icon={FiCalendar}>
|
||||
{new Date(post.published_at).toLocaleDateString(
|
||||
siteConfig.defaultLocale
|
||||
)}
|
||||
</MetaItem>
|
||||
)}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<MetaItem icon={faTags} tone="muted">
|
||||
<MetaItem icon={FiTag} tone="muted">
|
||||
{post.tags.slice(0, 3).join(', ')}
|
||||
</MetaItem>
|
||||
)}
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Post, Page } from 'contentlayer2/generated';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faArrowDownWideShort,
|
||||
faArrowUpWideShort,
|
||||
faMagnifyingGlass,
|
||||
faListUl
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FiArrowDown, FiArrowUp, FiSearch, FiList } from 'react-icons/fi';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { PostListItem } from './post-list-item';
|
||||
import { TimelineWrapper } from './timeline-wrapper';
|
||||
@@ -83,7 +77,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 text-xs text-slate-500 dark:text-slate-400 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-slate-100/70 px-2 py-1 text-slate-600 dark:bg-slate-800/70 dark:text-slate-300">
|
||||
<FontAwesomeIcon icon={faListUl} className="h-3.5 w-3.5" />
|
||||
<FiList className="h-3.5 w-3.5" />
|
||||
<span>排序</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -93,7 +87,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowDownWideShort} className="h-3 w-3" />
|
||||
<FiArrowDown className="h-3 w-3" />
|
||||
新到舊
|
||||
</button>
|
||||
<button
|
||||
@@ -104,7 +98,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowUpWideShort} className="h-3 w-3" />
|
||||
<FiArrowUp className="h-3 w-3" />
|
||||
舊到新
|
||||
</button>
|
||||
</div>
|
||||
@@ -113,8 +107,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
搜尋文章
|
||||
</label>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
<FiSearch
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
|
||||
/>
|
||||
<input
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import { Post } from 'contentlayer2/generated';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowLeftLong, faArrowRightLong } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FiArrowLeft, FiArrowRight } from 'react-icons/fi';
|
||||
|
||||
interface Props {
|
||||
current: Post;
|
||||
@@ -84,10 +83,11 @@ function Station({ station }: { station: StationConfig }) {
|
||||
className={`group flex flex-col gap-1 text-center transition duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 dark:focus-visible:ring-blue-300 ${alignClass}`}
|
||||
>
|
||||
<p className="text-[11px] uppercase tracking-[0.4em] text-slate-400 transition group-hover:text-blue-500 dark:text-slate-500">
|
||||
<FontAwesomeIcon
|
||||
icon={align === 'end' ? faArrowLeftLong : faArrowRightLong}
|
||||
className="mr-1 h-3 w-3"
|
||||
/>
|
||||
{align === 'end' ? (
|
||||
<FiArrowLeft className="mr-1 inline h-3 w-3" />
|
||||
) : (
|
||||
<FiArrowRight className="mr-1 inline h-3 w-3" />
|
||||
)}
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-lg font-semibold leading-snug tracking-tight text-slate-900 transition group-hover:text-blue-600 dark:text-slate-50 dark:group-hover:text-blue-300">
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faListUl } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FiList } from 'react-icons/fi';
|
||||
|
||||
interface TocItem {
|
||||
id: string;
|
||||
@@ -11,49 +9,88 @@ interface TocItem {
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function PostToc({ onLinkClick }: { onLinkClick?: () => void }) {
|
||||
export function PostToc({
|
||||
onLinkClick,
|
||||
contentKey,
|
||||
showTitle = true,
|
||||
className
|
||||
}: {
|
||||
onLinkClick?: () => void;
|
||||
contentKey?: string;
|
||||
showTitle?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [items, setItems] = useState<TocItem[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const [indicator, setIndicator] = useState({ top: 0, opacity: 0 });
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const headings = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('article h2, article h3')
|
||||
);
|
||||
const mapped = headings
|
||||
.filter((el) => el.id)
|
||||
.map((el) => ({
|
||||
id: el.id,
|
||||
text: el.innerText,
|
||||
depth: el.tagName === 'H3' ? 3 : 2
|
||||
}));
|
||||
setItems(mapped);
|
||||
// Clear items immediately when content changes
|
||||
setItems([]);
|
||||
setActiveId(null);
|
||||
itemRefs.current = {};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const id = (entry.target as HTMLElement).id;
|
||||
if (id) {
|
||||
setActiveId(id);
|
||||
}
|
||||
const containerSelector = contentKey
|
||||
? `[data-toc-content="${contentKey}"]`
|
||||
: '[data-toc-content]';
|
||||
const container = document.querySelector<HTMLElement>(containerSelector);
|
||||
|
||||
if (!container) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let rafId1: number;
|
||||
let rafId2: number;
|
||||
|
||||
// Use double requestAnimationFrame to ensure DOM has been painted
|
||||
// This is more reliable than setTimeout for DOM updates
|
||||
rafId1 = requestAnimationFrame(() => {
|
||||
rafId2 = requestAnimationFrame(() => {
|
||||
const headings = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('h2, h3')
|
||||
);
|
||||
const mapped = headings
|
||||
.filter((el) => el.id)
|
||||
.map((el) => ({
|
||||
id: el.id,
|
||||
text: el.innerText,
|
||||
depth: el.tagName === 'H3' ? 3 : 2
|
||||
}));
|
||||
setItems(mapped);
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const id = (entry.target as HTMLElement).id;
|
||||
if (id) {
|
||||
setActiveId(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
// Trigger when heading is in upper 40% of viewport
|
||||
rootMargin: '0px 0px -60% 0px',
|
||||
threshold: 0.1
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
// Trigger when heading is in upper 40% of viewport
|
||||
rootMargin: '0px 0px -60% 0px',
|
||||
threshold: 0.1
|
||||
);
|
||||
|
||||
headings.forEach((el) => observer?.observe(el));
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId1);
|
||||
cancelAnimationFrame(rafId2);
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
);
|
||||
|
||||
headings.forEach((el) => observer.observe(el));
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [pathname]);
|
||||
};
|
||||
}, [contentKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId || !listRef.current) {
|
||||
@@ -99,11 +136,13 @@ export function PostToc({ onLinkClick }: { onLinkClick?: () => void }) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav className="not-prose sticky top-20 text-slate-500 dark:text-slate-400">
|
||||
<div className="mb-2 inline-flex items-center gap-2 font-semibold text-slate-700 dark:text-slate-200">
|
||||
<FontAwesomeIcon icon={faListUl} className="h-4 w-4 text-slate-400" />
|
||||
目錄
|
||||
</div>
|
||||
<nav className={`not-prose text-slate-500 dark:text-slate-400 ${className || 'sticky top-20'}`}>
|
||||
{showTitle && (
|
||||
<div className="mb-2 inline-flex items-center gap-2 font-semibold text-slate-700 dark:text-slate-200">
|
||||
<FiList className="h-4 w-4 text-slate-400" />
|
||||
目錄
|
||||
</div>
|
||||
)}
|
||||
<div className="relative pl-4">
|
||||
<span className="absolute left-1 top-0 h-full w-px bg-slate-200 dark:bg-slate-800" aria-hidden="true" />
|
||||
<span
|
||||
@@ -128,11 +167,10 @@ export function PostToc({ onLinkClick }: { onLinkClick?: () => void }) {
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
onClick={handleClick(item.id)}
|
||||
className={`line-clamp-2 inline-flex items-center pl-2 hover:text-blue-600 dark:hover:text-blue-400 ${
|
||||
item.id === activeId
|
||||
className={`line-clamp-2 inline-flex items-center py-1 pl-2 hover:text-blue-600 dark:hover:text-blue-400 ${item.id === activeId
|
||||
? 'text-blue-600 dark:text-blue-400 font-semibold'
|
||||
: ''
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export function ReadingProgress() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -29,19 +30,21 @@ export function ReadingProgress() {
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [mounted]);
|
||||
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-px bg-transparent">
|
||||
<div className="relative h-1 w-full overflow-visible">
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-[1200] h-1.5 bg-transparent">
|
||||
<div className="relative h-1.5 w-full overflow-visible">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-blue-500/70 via-sky-400/70 to-indigo-500/70 shadow-[0_0_8px_rgba(59,130,246,0.45)] transition-[transform,opacity] duration-300 ease-out dark:from-blue-400/70 dark:via-sky-300/70 dark:to-indigo-400/70"
|
||||
className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)] transition-[transform,opacity] duration-300 ease-out"
|
||||
style={{ transform: `scaleX(${progress / 100})`, opacity: progress > 0 ? 1 : 0 }}
|
||||
>
|
||||
<span className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/70 blur-[1px] dark:bg-slate-900/70" aria-hidden="true" />
|
||||
<span className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/80 blur-[1px] dark:bg-slate-900/80" aria-hidden="true" />
|
||||
</div>
|
||||
<span className="absolute inset-x-0 top-2 h-px bg-gradient-to-r from-transparent via-blue-200/40 to-transparent blur-sm dark:via-blue-900/30" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faGithub, faMastodon, faLinkedin } from '@fortawesome/free-brands-svg-icons';
|
||||
import { faFire, faArrowRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FaGithub, FaMastodon, FaLinkedin } from 'react-icons/fa';
|
||||
import { FiTrendingUp, FiArrowRight } from 'react-icons/fi';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { getAllTagsWithCount } from '@/lib/posts';
|
||||
import { allPages } from 'contentlayer2/generated';
|
||||
@@ -21,19 +20,19 @@ export function RightSidebar() {
|
||||
siteConfig.social.github && {
|
||||
key: 'github',
|
||||
href: siteConfig.social.github,
|
||||
icon: faGithub,
|
||||
icon: FaGithub,
|
||||
label: 'GitHub'
|
||||
},
|
||||
siteConfig.social.mastodon && {
|
||||
key: 'mastodon',
|
||||
href: siteConfig.social.mastodon,
|
||||
icon: faMastodon,
|
||||
icon: FaMastodon,
|
||||
label: 'Mastodon'
|
||||
},
|
||||
siteConfig.social.linkedin && {
|
||||
key: 'linkedin',
|
||||
href: siteConfig.social.linkedin,
|
||||
icon: faLinkedin,
|
||||
icon: FaLinkedin,
|
||||
label: 'LinkedIn'
|
||||
}
|
||||
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
|
||||
@@ -77,7 +76,7 @@ export function RightSidebar() {
|
||||
aria-label={item.label}
|
||||
className="motion-link inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent dark:bg-slate-800 dark:text-slate-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={item.icon} className="h-4 w-4" />
|
||||
<item.icon className="h-4 w-4" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -98,7 +97,7 @@ export function RightSidebar() {
|
||||
{tags.length > 0 && (
|
||||
<section className="motion-card rounded-xl border bg-white px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
|
||||
<h2 className="type-small flex items-center gap-2 font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
|
||||
<FontAwesomeIcon icon={faFire} className="h-3 w-3 text-orange-400" />
|
||||
<FiTrendingUp className="h-3 w-3 text-orange-400" />
|
||||
熱門標籤
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-base">
|
||||
@@ -120,7 +119,7 @@ export function RightSidebar() {
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between type-small text-slate-500 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FontAwesomeIcon icon={faArrowRight} className="h-3 w-3" />
|
||||
<FiArrowRight className="h-3 w-3" />
|
||||
一覽所有標籤
|
||||
</span>
|
||||
<Link
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faMagnifyingGlass, faXmark } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FiSearch, FiX } from 'react-icons/fi';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -136,7 +135,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
||||
<FontAwesomeIcon icon={faMagnifyingGlass} className="h-5 w-5" />
|
||||
<FiSearch className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">全站搜尋</span>
|
||||
</div>
|
||||
<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"
|
||||
aria-label="關閉搜尋"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="h-5 w-5" />
|
||||
<FiX className="h-5 w-5" />
|
||||
</button>
|
||||
</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"
|
||||
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>
|
||||
<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
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faGithub,
|
||||
faTwitter,
|
||||
faMastodon,
|
||||
faGitAlt,
|
||||
faLinkedin
|
||||
} from '@fortawesome/free-brands-svg-icons';
|
||||
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FaGithub, FaTwitter, FaMastodon, FaGit, FaLinkedin } from 'react-icons/fa';
|
||||
import { FiMail } from 'react-icons/fi';
|
||||
|
||||
// Calculate year at build time for PPR compatibility
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -20,37 +13,37 @@ export function SiteFooter() {
|
||||
key: 'github',
|
||||
href: social.github,
|
||||
label: 'GitHub',
|
||||
icon: faGithub
|
||||
icon: FaGithub
|
||||
},
|
||||
social.twitter && {
|
||||
key: 'twitter',
|
||||
href: `https://twitter.com/${social.twitter.replace('@', '')}`,
|
||||
label: 'Twitter',
|
||||
icon: faTwitter
|
||||
icon: FaTwitter
|
||||
},
|
||||
social.mastodon && {
|
||||
key: 'mastodon',
|
||||
href: social.mastodon,
|
||||
label: 'Mastodon',
|
||||
icon: faMastodon
|
||||
icon: FaMastodon
|
||||
},
|
||||
social.gitea && {
|
||||
key: 'gitea',
|
||||
href: social.gitea,
|
||||
label: 'Gitea',
|
||||
icon: faGitAlt
|
||||
icon: FaGit
|
||||
},
|
||||
social.linkedin && {
|
||||
key: 'linkedin',
|
||||
href: social.linkedin,
|
||||
label: 'LinkedIn',
|
||||
icon: faLinkedin
|
||||
icon: FaLinkedin
|
||||
},
|
||||
social.email && {
|
||||
key: 'email',
|
||||
href: `mailto:${social.email}`,
|
||||
label: 'Email',
|
||||
icon: faEnvelope
|
||||
icon: FiMail
|
||||
}
|
||||
].filter(Boolean) as {
|
||||
key: string;
|
||||
@@ -75,7 +68,7 @@ export function SiteFooter() {
|
||||
aria-label={item.label}
|
||||
className="text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
|
||||
>
|
||||
<FontAwesomeIcon icon={item.icon} className="h-4 w-4" />
|
||||
<item.icon className="h-4 w-4" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -21,19 +21,60 @@ export function SiteHeader() {
|
||||
.slice()
|
||||
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||
|
||||
const findPage = (title: string) => pages.find((page) => page.title === title);
|
||||
|
||||
const aboutChildren = [
|
||||
{ title: '關於作者', label: '作者' },
|
||||
{ title: '關於本站', label: '本站' }
|
||||
]
|
||||
.map(({ title, label }) => {
|
||||
const page = findPage(title);
|
||||
if (!page) return null;
|
||||
return {
|
||||
key: page._id,
|
||||
href: page.url,
|
||||
label,
|
||||
iconKey: getIconForPage(page.title, page.slug)
|
||||
} satisfies NavLinkItem;
|
||||
})
|
||||
.filter(Boolean) as NavLinkItem[];
|
||||
|
||||
const deviceChildren = [
|
||||
{ title: '開發工作環境', label: '開發環境' },
|
||||
{ title: 'HomeLab', label: 'HomeLab' }
|
||||
]
|
||||
.map(({ title, label }) => {
|
||||
const page = findPage(title);
|
||||
if (!page) return null;
|
||||
return {
|
||||
key: page._id,
|
||||
href: page.url,
|
||||
label,
|
||||
iconKey: getIconForPage(page.title, page.slug)
|
||||
} satisfies NavLinkItem;
|
||||
})
|
||||
.filter(Boolean) as NavLinkItem[];
|
||||
|
||||
const navItems: NavLinkItem[] = [
|
||||
{ key: 'home', href: '/', label: '首頁', iconKey: 'home' },
|
||||
{ key: 'blog', href: '/blog', label: 'Blog', iconKey: 'blog' },
|
||||
...pages.map((page) => ({
|
||||
key: page._id,
|
||||
href: page.url,
|
||||
label: page.title,
|
||||
iconKey: getIconForPage(page.title, page.slug)
|
||||
}))
|
||||
{
|
||||
key: 'about',
|
||||
href: aboutChildren[0]?.href,
|
||||
label: '關於',
|
||||
iconKey: 'user',
|
||||
children: aboutChildren
|
||||
},
|
||||
{
|
||||
key: 'devices',
|
||||
href: deviceChildren[0]?.href,
|
||||
label: '裝置',
|
||||
iconKey: 'device',
|
||||
children: deviceChildren
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="bg-white/80 backdrop-blur transition-colors duration-200 ease-snappy dark:bg-gray-950/80">
|
||||
<header className="relative z-40 bg-white/80 backdrop-blur transition-colors duration-200 ease-snappy dark:bg-gray-950/80">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-slate-900 dark:text-slate-100">
|
||||
<Link
|
||||
href="/"
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FiMoon, FiSun } from 'react-icons/fi';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -27,12 +26,11 @@ export function ThemeToggle() {
|
||||
onClick={() => setTheme(next)}
|
||||
aria-label={theme === 'dark' ? '切換為淺色主題' : '切換為深色主題'}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDark ? faSun : faMoon}
|
||||
className={`h-4 w-4 transition-transform duration-260 ease-snappy ${
|
||||
isDark ? 'rotate-0 text-amber-400' : 'rotate-180 text-blue-500'
|
||||
}`}
|
||||
/>
|
||||
{isDark ? (
|
||||
<FiSun className="h-4 w-4 rotate-0 text-amber-400 transition-transform duration-260 ease-snappy" />
|
||||
) : (
|
||||
<FiMoon className="h-4 w-4 rotate-180 text-blue-500 transition-transform duration-260 ease-snappy" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,20 +9,20 @@ interface TimelineWrapperProps {
|
||||
export function TimelineWrapper({ children, className }: TimelineWrapperProps) {
|
||||
const items = Children.toArray(children);
|
||||
return (
|
||||
<div className={clsx('relative pl-8', className)}>
|
||||
<div className={clsx('relative pl-6 md:pl-8', className)}>
|
||||
<span
|
||||
className="pointer-events-none absolute left-3 top-0 h-full w-[2px] rounded-full bg-blue-400 shadow-[0_0_10px_rgba(59,130,246,0.35)] dark:bg-cyan-300"
|
||||
className="pointer-events-none absolute left-2 top-0 h-full w-[2px] rounded-full bg-blue-400 shadow-[0_0_10px_rgba(59,130,246,0.35)] dark:bg-cyan-300 md:left-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute left-3 top-0 h-full w-[8px] rounded-full bg-blue-500/15 blur-[14px]"
|
||||
className="pointer-events-none absolute left-2 top-0 h-full w-[8px] rounded-full bg-blue-500/15 blur-[14px] md:left-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{items.map((child, index) => (
|
||||
<div key={index} className="relative pl-6 sm:pl-8">
|
||||
<span className="pointer-events-none absolute left-0 top-1/2 h-px w-8 -translate-x-full -translate-y-1/2 bg-gradient-to-r from-transparent via-blue-300/80 to-transparent dark:via-cyan-200/80" aria-hidden="true" />
|
||||
<div key={index} className="relative pl-5 sm:pl-8">
|
||||
<span className="pointer-events-none absolute left-0 top-1/2 h-px w-5 -translate-x-full -translate-y-1/2 bg-gradient-to-r from-transparent via-blue-300/80 to-transparent dark:via-cyan-200/80 sm:w-8" aria-hidden="true" />
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
|
||||
2
content
2
content
Submodule content updated: d976bb08e2...3f72ccb628
32
env
Normal file
32
env
Normal file
@@ -0,0 +1,32 @@
|
||||
# Public site metadata (safe to expose to browser)
|
||||
NEXT_PUBLIC_SITE_NAME="Gbanyan"
|
||||
NEXT_PUBLIC_SITE_TITLE="霍德爾之目"
|
||||
NEXT_PUBLIC_SITE_DESCRIPTION="醫學、科技與生活隨筆。"
|
||||
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_SITE_AUTHOR="Gbanyan"
|
||||
NEXT_PUBLIC_SITE_TAGLINE="醫學、科技與生活的隨筆記錄。"
|
||||
NEXT_PUBLIC_POSTS_PER_PAGE="5"
|
||||
NEXT_PUBLIC_DEFAULT_LOCALE="zh-TW"
|
||||
NEXT_PUBLIC_SITE_AVATAR_URL="https://www.gravatar.com/avatar/53f2a6e011d5ececcd0d6e33e8e7329f60efcd23a5b77ba382273d5060a2cffe?s=160&d=identicon"
|
||||
NEXT_PUBLIC_SITE_ABOUT_SHORT="掙扎混亂過日子 \n 對平淡美好日常的期待即是救贖"
|
||||
|
||||
# Color scheme / accents
|
||||
NEXT_PUBLIC_COLOR_ACCENT="#2563eb"
|
||||
NEXT_PUBLIC_COLOR_ACCENT_SOFT="#dbeafe"
|
||||
NEXT_PUBLIC_COLOR_ACCENT_TEXT_LIGHT="#1d4ed8"
|
||||
NEXT_PUBLIC_COLOR_ACCENT_TEXT_DARK="#93c5fd"
|
||||
|
||||
# Social and profile
|
||||
NEXT_PUBLIC_TWITTER_HANDLE="@gbanyan"
|
||||
NEXT_PUBLIC_GITHUB_URL="https://github.com/gbanyan"
|
||||
NEXT_PUBLIC_LINKEDIN_URL=""
|
||||
NEXT_PUBLIC_EMAIL_CONTACT=""
|
||||
NEXT_PUBLIC_MASTODON_URL=""
|
||||
NEXT_PUBLIC_GITEA_URL=""
|
||||
|
||||
# SEO / Open Graph
|
||||
NEXT_PUBLIC_OG_DEFAULT_IMAGE="/assets/og-default.jpg"
|
||||
NEXT_PUBLIC_TWITTER_CARD_TYPE="summary_large_image"
|
||||
|
||||
# Analytics (public ID only)
|
||||
NEXT_PUBLIC_ANALYTICS_ID=""
|
||||
@@ -42,7 +42,7 @@ export const siteConfig = {
|
||||
},
|
||||
slugs: {}
|
||||
},
|
||||
ogImage: process.env.NEXT_PUBLIC_OG_DEFAULT_IMAGE || '/assets/og-default.jpg',
|
||||
ogImage: process.env.NEXT_PUBLIC_OG_DEFAULT_IMAGE || '/assets/og-default.png',
|
||||
twitterCard:
|
||||
(process.env.NEXT_PUBLIC_TWITTER_CARD_TYPE as
|
||||
| 'summary'
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
215
package-lock.json
generated
215
package-lock.json
generated
@@ -10,21 +10,17 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||
"@vercel/og": "^0.8.5",
|
||||
"clsx": "^2.1.1",
|
||||
"contentlayer2": "^0.5.8",
|
||||
"framer-motion": "^12.23.24",
|
||||
"gray-matter": "^4.0.3",
|
||||
"markdown-wasm": "^1.2.0",
|
||||
"next": "^16.0.3",
|
||||
"next": "^16.0.7",
|
||||
"next-contentlayer2": "^0.5.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rehype-slug": "^6.0.0",
|
||||
@@ -534,7 +530,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
|
||||
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0"
|
||||
}
|
||||
@@ -1126,65 +1121,6 @@
|
||||
"integrity": "sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==",
|
||||
"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": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.1.tgz",
|
||||
@@ -2010,9 +1946,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz",
|
||||
"integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
|
||||
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -2043,9 +1979,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz",
|
||||
"integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
|
||||
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2059,9 +1995,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz",
|
||||
"integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
|
||||
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2075,9 +2011,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz",
|
||||
"integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2091,9 +2027,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz",
|
||||
"integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2107,9 +2043,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz",
|
||||
"integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2123,9 +2059,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz",
|
||||
"integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2139,9 +2075,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz",
|
||||
"integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2155,9 +2091,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz",
|
||||
"integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5756,33 +5692,6 @@
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -8555,21 +8464,6 @@
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -8630,12 +8524,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
|
||||
"integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
|
||||
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.0.3",
|
||||
"@next/env": "16.0.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -8648,14 +8542,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.0.3",
|
||||
"@next/swc-darwin-x64": "16.0.3",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.3",
|
||||
"@next/swc-linux-arm64-musl": "16.0.3",
|
||||
"@next/swc-linux-x64-gnu": "16.0.3",
|
||||
"@next/swc-linux-x64-musl": "16.0.3",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.3",
|
||||
"@next/swc-win32-x64-msvc": "16.0.3",
|
||||
"@next/swc-darwin-arm64": "16.0.7",
|
||||
"@next/swc-darwin-x64": "16.0.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.7",
|
||||
"@next/swc-linux-arm64-musl": "16.0.7",
|
||||
"@next/swc-linux-x64-gnu": "16.0.7",
|
||||
"@next/swc-linux-x64-musl": "16.0.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.7",
|
||||
"@next/swc-win32-x64-msvc": "16.0.7",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -9467,9 +9361,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -9477,16 +9371,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"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": {
|
||||
|
||||
12
package.json
12
package.json
@@ -17,21 +17,17 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||
"@vercel/og": "^0.8.5",
|
||||
"clsx": "^2.1.1",
|
||||
"contentlayer2": "^0.5.8",
|
||||
"framer-motion": "^12.23.24",
|
||||
"gray-matter": "^4.0.3",
|
||||
"markdown-wasm": "^1.2.0",
|
||||
"next": "^16.0.3",
|
||||
"next": "^16.0.7",
|
||||
"next-contentlayer2": "^0.5.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rehype-slug": "^6.0.0",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 485 KiB |
@@ -13,9 +13,24 @@
|
||||
--font-weight-semibold: 600;
|
||||
--font-system-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'PingFang TC', 'PingFang SC', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft JhengHei', 'Microsoft YaHei', 'Noto Sans TC', 'Noto Sans SC', 'Noto Sans CJK TC', 'Noto Sans CJK SC', 'Source Han Sans TC', 'Source Han Sans SC', 'Roboto', 'Ubuntu', 'Cantarell', 'Inter', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
/* Ink + accent palette */
|
||||
--color-ink-strong: #0f172a;
|
||||
--color-ink-body: #1f2937;
|
||||
--color-ink-muted: #475569;
|
||||
--color-accent: #7c3aed;
|
||||
--color-accent-soft: #f4f0ff;
|
||||
|
||||
font-size: clamp(15px, 0.65vw + 11px, 19px);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-ink-strong: #e2e8f0;
|
||||
--color-ink-body: #cbd5e1;
|
||||
--color-ink-muted: #94a3b8;
|
||||
--color-accent: #a78bfa;
|
||||
--color-accent-soft: #1f1a3d;
|
||||
}
|
||||
|
||||
@media (min-width: 2560px) {
|
||||
:root {
|
||||
font-size: 20px;
|
||||
@@ -27,6 +42,7 @@ body {
|
||||
font-size: 1rem;
|
||||
line-height: var(--line-height-body);
|
||||
font-family: var(--font-system-sans);
|
||||
color: var(--color-ink-body);
|
||||
}
|
||||
|
||||
@keyframes timeline-scroll {
|
||||
@@ -95,17 +111,17 @@ body {
|
||||
.prose blockquote {
|
||||
@apply transition-transform transition-shadow duration-180 ease-snappy;
|
||||
border-left: 4px solid var(--color-accent, #2563eb);
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.04), rgba(37, 99, 235, 0.08));
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.06), rgba(124, 58, 237, 0.1));
|
||||
padding: 1.2rem 1.5rem;
|
||||
font-style: italic;
|
||||
color: rgba(15, 23, 42, 0.75);
|
||||
color: rgba(15, 23, 42, 0.78);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dark .prose blockquote {
|
||||
background: linear-gradient(135deg, rgba(96, 165, 250, 0.12), rgba(96, 165, 250, 0.06));
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
border-left-color: rgba(96, 165, 250, 0.9);
|
||||
background: linear-gradient(135deg, rgba(167, 139, 250, 0.12), rgba(124, 58, 237, 0.08));
|
||||
color: rgba(226, 232, 240, 0.85);
|
||||
border-left-color: rgba(167, 139, 250, 0.9);
|
||||
}
|
||||
|
||||
.prose blockquote:hover {
|
||||
@@ -134,27 +150,32 @@ body {
|
||||
.prose {
|
||||
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.2rem);
|
||||
line-height: var(--line-height-body);
|
||||
color: var(--color-ink-body);
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-size: clamp(2.2rem, 1.4rem + 2.2vw, 3.4rem);
|
||||
line-height: 1.25;
|
||||
color: var(--color-ink-strong);
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: clamp(1.8rem, 1.1rem + 1.6vw, 2.8rem);
|
||||
line-height: 1.3;
|
||||
color: var(--color-ink-strong);
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: clamp(1.4rem, 0.9rem + 1vw, 2rem);
|
||||
line-height: 1.35;
|
||||
color: var(--color-ink-strong);
|
||||
}
|
||||
|
||||
.prose p,
|
||||
.prose li {
|
||||
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem);
|
||||
line-height: var(--line-height-body);
|
||||
color: var(--color-ink-body);
|
||||
}
|
||||
|
||||
.prose small,
|
||||
@@ -233,6 +254,69 @@ body {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* TOC transitions - replaces Framer Motion */
|
||||
.toc-sidebar {
|
||||
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.toc-sidebar-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.toc-sidebar-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toc-sidebar-exit {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toc-sidebar-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.toc-mobile {
|
||||
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.toc-mobile-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.toc-mobile-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toc-mobile-exit {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toc-mobile-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.toc-button {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.toc-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.toc-button:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.type-display {
|
||||
font-size: clamp(2.2rem, 1.6rem + 2.4vw, 3.5rem);
|
||||
|
||||
Reference in New Issue
Block a user