Compare commits

..

50 Commits

Author SHA1 Message Date
8ade752448 oops 2025-11-19 02:23:32 +08:00
e04a03097f Convert favicon to actual PNG 2025-11-19 02:16:15 +08:00
a8ee8d83af Update favicon to gravatar 2025-11-19 02:15:02 +08:00
261cb1d91e Add favicon 2025-11-19 02:13:23 +08:00
f32206d390 Use next/image for hero/sidebar/markdown images 2025-11-19 02:07:35 +08:00
ce43491e2e Switch card images to next/image 2025-11-19 02:03:49 +08:00
68ababe8c8 checkpoint before image optimizations 2025-11-19 02:02:34 +08:00
985caa2a4d Cleanup stray text in timeline wrapper 2025-11-19 01:56:59 +08:00
77bd180d97 Remove animated timeline dot 2025-11-19 01:55:56 +08:00
3425098006 Refine timeline rail/tick aesthetics 2025-11-19 01:49:40 +08:00
eefc38d562 Add glow and animation to timeline 2025-11-19 01:46:37 +08:00
48ce66a3e6 Remove timeline arrow 2025-11-19 01:43:29 +08:00
22120595a6 Use only card-aligned timeline ticks 2025-11-19 01:41:12 +08:00
eab80bd17a Render timeline ticks per card 2025-11-19 01:36:54 +08:00
5b99486a68 Style timeline rail with ticks and arrow 2025-11-19 01:31:02 +08:00
5fdd72302e Reduce gutter between timeline rail and cards 2025-11-19 01:25:19 +08:00
66cd9b8608 Remove timeline markers entirely 2025-11-19 01:24:12 +08:00
2e80b7ac59 Center timeline markers vertically 2025-11-19 01:22:49 +08:00
be5d942c79 Align node ring visually with timeline rail 2025-11-19 01:17:33 +08:00
3018a25578 Turn timeline nodes into hollow rings 2025-11-19 01:14:06 +08:00
04182ec754 Align timeline nodes with brighter rail 2025-11-19 01:11:45 +08:00
9b2d754a2f Tighten timeline spacing and add white node 2025-11-19 01:07:25 +08:00
1a7ae8a269 Remove timeline node dot 2025-11-19 01:05:22 +08:00
9a7eb6cfe3 Center timeline nodes directly on rail 2025-11-19 01:04:08 +08:00
246646f176 Brighten timeline rail and align nodes 2025-11-19 01:00:18 +08:00
287c0d72a8 Refine timeline visuals and apply to blog list 2025-11-19 00:58:13 +08:00
fe191752da Add aesthetic timeline to post lists 2025-11-19 00:54:58 +08:00
10e4e7e21e Give tags index a hero summary and card grid 2025-11-19 00:51:00 +08:00
82a459bede Restyle blockquotes with academic flair 2025-11-19 00:47:28 +08:00
af0d2e3a6c Formalize font weight hierarchy 2025-11-19 00:42:53 +08:00
9235ab291b Expand system font stack for multilingual UI 2025-11-19 00:37:12 +08:00
79578252df Apply new type scale to TOC 2025-11-19 00:33:11 +08:00
a225d57e06 Fix progress bar width 2025-11-19 00:30:35 +08:00
b416c9eb7d Soften reading progress indicator 2025-11-19 00:28:09 +08:00
61d5092136 Let sidebar about text honor line breaks 2025-11-19 00:26:37 +08:00
a582ef9cb5 Scale right sidebar typography 2025-11-19 00:24:16 +08:00
dc5ca97fee Add fluid typography scale and responsive headings 2025-11-19 00:22:09 +08:00
b4ee8b122f Remove dot icon from TOC items 2025-11-18 23:57:40 +08:00
cd95a7bb79 Render TOC items as divs to eliminate bullets 2025-11-18 23:51:43 +08:00
f34221b567 Exclude TOC from prose styling 2025-11-18 23:50:03 +08:00
3509b43643 Inline TOC list style overrides 2025-11-18 23:49:08 +08:00
6ca024b0ba Force TOC list styles with !important 2025-11-18 23:47:47 +08:00
9d86cd4663 Force TOC markers to be hidden 2025-11-18 23:46:20 +08:00
31f1c6979d Ensure TOC items have no default bullets 2025-11-18 23:44:24 +08:00
b69755c2d6 Force TOC list to remove default bullets 2025-11-18 23:41:37 +08:00
dadb5dce5c Remove default list bullets from TOC 2025-11-18 23:36:27 +08:00
7a6cd55c42 Polish TOC bullets 2025-11-18 23:34:38 +08:00
1e39647ab6 Fix duplicate imports in post TOC 2025-11-18 23:31:52 +08:00
e73f37da76 Hide next station when no newer posts 2025-11-18 23:10:15 +08:00
351a1a2f70 Loosen scroll reveal trigger for mobile 2025-11-18 23:04:02 +08:00
23 changed files with 606 additions and 192 deletions

BIN
Line.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

View File

@@ -1,4 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { allPosts } from 'contentlayer/generated'; import { allPosts } from 'contentlayer/generated';
@@ -9,6 +10,8 @@ import { PostToc } from '@/components/post-toc';
import { ScrollReveal } from '@/components/scroll-reveal'; import { ScrollReveal } from '@/components/scroll-reveal';
import { PostCard } from '@/components/post-card'; 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 { FooterCue } from '@/components/footer-cue';
export function generateStaticParams() { export function generateStaticParams() {
return allPosts.map((post) => ({ return allPosts.map((post) => ({
@@ -48,77 +51,87 @@ export default function BlogPostPage({ params }: Props) {
<PostToc /> <PostToc />
</aside> </aside>
<div className="flex-1 space-y-6"> <div className="flex-1 space-y-6">
<ScrollReveal> <SectionDivider>
<header className="mb-2 space-y-2"> <ScrollReveal>
{post.published_at && ( <header className="mb-2 space-y-2">
<p className="text-xs text-slate-500 dark:text-slate-500"> {post.published_at && (
{new Date(post.published_at).toLocaleDateString( <p className="type-small text-slate-500 dark:text-slate-500">
siteConfig.defaultLocale {new Date(post.published_at).toLocaleDateString(
)} siteConfig.defaultLocale
</p> )}
)} </p>
<h1 className="text-2xl font-bold leading-tight text-slate-900 sm:text-3xl dark:text-slate-50"> )}
{post.title} <h1 className="type-display font-bold leading-tight text-slate-900 dark:text-slate-50">
</h1> {post.title}
{post.tags && ( </h1>
<div className="flex flex-wrap gap-2 pt-1"> {post.tags && (
{post.tags.map((t) => ( <div className="flex flex-wrap gap-2 pt-1">
<Link {post.tags.map((t) => (
key={t} <Link
href={`/tags/${encodeURIComponent( key={t}
t.toLowerCase().replace(/\s+/g, '-') href={`/tags/${encodeURIComponent(
)}`} t.toLowerCase().replace(/\s+/g, '-')
className="rounded-full bg-accent-soft px-2 py-0.5 text-xs text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700" )}`}
> className="tag-chip rounded-full bg-accent-soft px-2 py-0.5 text-xs text-accent-textLight dark:bg-slate-800 dark:text-slate-100"
#{t} >
</Link> #{t}
))} </Link>
</div> ))}
)} </div>
</header> )}
</ScrollReveal> </header>
</ScrollReveal>
</SectionDivider>
<ScrollReveal> <SectionDivider>
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark"> <ScrollReveal>
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
{post.feature_image && ( {post.feature_image && (
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz" <Image
// eslint-disable-next-line @next/next/no-img-element
<img
src={post.feature_image.replace('../assets', '/assets')} src={post.feature_image.replace('../assets', '/assets')}
alt={post.title} alt={post.title}
width={1200}
height={600}
className="my-4 rounded" className="my-4 rounded"
/> />
)} )}
<div dangerouslySetInnerHTML={{ __html: post.body.html }} /> <div dangerouslySetInnerHTML={{ __html: post.body.html }} />
</article> </article>
</ScrollReveal> </ScrollReveal>
</SectionDivider>
<ScrollReveal> <FooterCue />
<PostStorylineNav
current={post} <SectionDivider>
newer={neighbors.newer} <ScrollReveal>
older={neighbors.older} <PostStorylineNav
/> current={post}
</ScrollReveal> newer={neighbors.newer}
older={neighbors.older}
/>
</ScrollReveal>
</SectionDivider>
{relatedPosts.length > 0 && ( {relatedPosts.length > 0 && (
<ScrollReveal> <SectionDivider>
<section className="space-y-4 rounded-xl border border-slate-200 bg-white/80 p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900/50"> <ScrollReveal>
<div className="flex items-center justify-between gap-2"> <section className="space-y-4 rounded-xl border border-slate-200 bg-white/80 p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900/50">
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-50"> <div className="flex items-center justify-between gap-2">
<h2 className="type-subtitle font-semibold text-slate-900 dark:text-slate-50">
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400"> </h2>
<p className="type-small text-slate-500 dark:text-slate-400">
</p>
</div> </p>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> </div>
{relatedPosts.map((related) => ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<PostCard key={related._id} post={related} showTags={false} /> {relatedPosts.map((related) => (
))} <PostCard key={related._id} post={related} showTags={false} />
</div> ))}
</section> </div>
</ScrollReveal> </section>
</ScrollReveal>
</SectionDivider>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { getAllPostsSorted } from '@/lib/posts'; import { getAllPostsSorted } from '@/lib/posts';
import { PostListWithControls } from '@/components/post-list-with-controls'; import { PostListWithControls } from '@/components/post-list-with-controls';
import { TimelineWrapper } from '@/components/timeline-wrapper';
export const metadata = { export const metadata = {
title: '所有文章' title: '所有文章'
@@ -11,10 +12,10 @@ export default function BlogIndexPage() {
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-50"> <h1 className="type-title font-semibold text-slate-900 dark:text-slate-50">
</h1> </h1>
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="type-small text-slate-500 dark:text-slate-400">
</p> </p>
</header> </header>

View File

@@ -24,6 +24,9 @@ export const metadata: Metadata = {
title: siteConfig.title, title: siteConfig.title,
description: siteConfig.description, description: siteConfig.description,
images: [siteConfig.ogImage] images: [siteConfig.ogImage]
},
icons: {
icon: '/favicon.png'
} }
}; };

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import { getAllPostsSorted } from '@/lib/posts'; import { getAllPostsSorted } from '@/lib/posts';
import { siteConfig } from '@/lib/config'; 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';
export default function HomePage() { export default function HomePage() {
const posts = getAllPostsSorted().slice(0, siteConfig.postsPerPage); const posts = getAllPostsSorted().slice(0, siteConfig.postsPerPage);
@@ -9,17 +10,17 @@ export default function HomePage() {
return ( return (
<section className="space-y-6"> <section className="space-y-6">
<header className="space-y-1 text-center"> <header className="space-y-1 text-center">
<h1 className="text-2xl font-bold text-slate-900 sm:text-3xl dark:text-slate-50"> <h1 className="type-title font-bold text-slate-900 dark:text-slate-50">
{siteConfig.name} {siteConfig.name}
</h1> </h1>
<p className="text-sm text-slate-600 dark:text-slate-300"> <p className="type-small text-slate-600 dark:text-slate-300">
{siteConfig.tagline} {siteConfig.tagline}
</p> </p>
</header> </header>
<div> <div>
<div className="mb-3 flex items-baseline justify-between"> <div className="mb-3 flex items-baseline justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"> <h2 className="type-small font-semibold uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
</h2> </h2>
<Link <Link
@@ -29,11 +30,11 @@ export default function HomePage() {
</Link> </Link>
</div> </div>
<ul className="space-y-3"> <TimelineWrapper>
{posts.map((post) => ( {posts.map((post) => (
<PostListItem key={post._id} post={post} /> <PostListItem key={post._id} post={post} />
))} ))}
</ul> </TimelineWrapper>
</div> </div>
</section> </section>
); );

View File

@@ -1,4 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { allPages } from 'contentlayer/generated'; import { allPages } from 'contentlayer/generated';
@@ -70,15 +71,15 @@ export default function StaticPage({ params }: Props) {
)} )}
</header> </header>
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark"> <article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
{page.feature_image && ( {page.feature_image && (
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz" <Image
// eslint-disable-next-line @next/next/no-img-element src={page.feature_image.replace('../assets', '/assets')}
<img alt={page.title}
src={page.feature_image.replace('../assets', '/assets')} width={1200}
alt={page.title} height={600}
className="my-4 rounded" className="my-4 rounded"
/> />
)} )}
<div dangerouslySetInnerHTML={{ __html: page.body.html }} /> <div dangerouslySetInnerHTML={{ __html: page.body.html }} />
</article> </article>
</div> </div>

View File

@@ -1,8 +1,10 @@
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTags } from '@fortawesome/free-solid-svg-icons'; 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 { ScrollReveal } from '@/components/scroll-reveal';
export const metadata: Metadata = { export const metadata: Metadata = {
title: '標籤索引' title: '標籤索引'
@@ -10,35 +12,60 @@ export const metadata: Metadata = {
export default function TagIndexPage() { export default function TagIndexPage() {
const tags = getAllTagsWithCount(); const tags = getAllTagsWithCount();
const topTags = tags.slice(0, 3);
const colorClasses = [ const colorClasses = [
'bg-rose-100 text-rose-700 dark:bg-rose-900/60 dark:text-rose-200', 'from-rose-400/70 to-rose-200/40',
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/60 dark:text-emerald-200', 'from-emerald-400/70 to-emerald-200/40',
'bg-sky-100 text-sky-700 dark:bg-sky-900/60 dark:text-sky-200', 'from-sky-400/70 to-sky-200/40',
'bg-amber-100 text-amber-700 dark:bg-amber-900/60 dark:text-amber-200', 'from-amber-400/70 to-amber-200/40',
'bg-violet-100 text-violet-700 dark:bg-violet-900/60 dark:text-violet-200' 'from-violet-400/70 to-violet-200/40'
]; ];
return ( return (
<section className="space-y-4"> <section className="space-y-6">
<h1 className="flex items-center gap-2 text-lg font-semibold text-slate-900 dark:text-slate-50"> <SectionDivider>
<FontAwesomeIcon icon={faTags} className="h-5 w-5 text-slate-400" /> <ScrollReveal>
<div className="motion-card rounded-2xl border bg-white/90 p-6 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
</h1> <div className="inline-flex items-center gap-2 text-accent">
<p className="text-xs text-slate-500 dark:text-slate-400"> <FontAwesomeIcon icon={faTags} className="h-5 w-5" />
{tags.length} <span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
</p>
<div className="flex flex-wrap gap-3 text-xs"> </span>
</div>
<h1 className="type-title mt-2 font-semibold text-slate-900 dark:text-slate-50">
{tags.length}
</h1>
<p className="type-small mt-2 text-slate-600 dark:text-slate-300">
{topTags.map((t) => t.tag).join('、')}
</p>
</div>
</ScrollReveal>
</SectionDivider>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{tags.map(({ tag, slug, count }, index) => { {tags.map(({ tag, slug, count }, index) => {
const color = colorClasses[index % colorClasses.length]; const color = colorClasses[index % colorClasses.length];
return ( return (
<Link <Link
key={tag} key={tag}
href={`/tags/${slug}`} href={`/tags/${slug}`}
className={`rounded-full px-3 py-1 shadow-sm transition-transform transition-shadow duration-200 ease-out hover:-translate-y-0.5 hover:shadow-md ${color}`} className="motion-card flex flex-col rounded-xl border bg-white/90 p-4 shadow-sm transition hover:-translate-y-1 dark:border-slate-800 dark:bg-slate-900"
> >
<span className="mr-1">{tag}</span> <span className={`mb-3 block h-1.5 w-16 rounded-full bg-gradient-to-r ${color}`} aria-hidden="true" />
<span className="opacity-70">({count})</span> <div className="flex items-center justify-between">
<h2 className="type-subtitle font-semibold text-slate-900 dark:text-slate-50">
{tag}
</h2>
<span className="type-small text-slate-600 dark:text-slate-300">
{count}
</span>
</div>
<span className="mt-1 inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<FontAwesomeIcon icon={faFire} className="h-3 w-3" />
#{index + 1}
</span>
</Link> </Link>
); );
})} })}

43
components/footer-cue.tsx Normal file
View File

@@ -0,0 +1,43 @@
'use client';
import { useEffect, useRef, useState } from 'react';
export function FooterCue() {
const ref = useRef<HTMLDivElement | null>(null);
const [active, setActive] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (!('IntersectionObserver' in window)) {
setActive(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActive(true);
}
});
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className="flex flex-col items-center gap-2 py-4 text-[11px] uppercase tracking-[0.3em] text-slate-400 dark:text-slate-500">
<span className="text-xs"></span>
<span
className={`h-10 w-px overflow-hidden rounded-full bg-gradient-to-b from-transparent via-accent to-transparent transition-[height,opacity] duration-500 ease-snappy ${
active ? 'opacity-80' : 'h-4 opacity-30'
}`}
/>
</div>
);
}

View File

@@ -64,11 +64,12 @@ export function Hero() {
<div className="pointer-events-none absolute -bottom-20 right-[-3rem] h-44 w-44 rounded-full bg-indigo-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/25" /> <div className="pointer-events-none absolute -bottom-20 right-[-3rem] h-44 w-44 rounded-full bg-indigo-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/25" />
<div className="relative flex items-center gap-4 motion-safe:animate-fade-in-up"> <div className="relative flex items-center gap-4 motion-safe:animate-fade-in-up">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-2xl font-semibold text-slate-50 shadow-md transition-transform duration-300 ease-out group-hover:scale-105 dark:bg-slate-100 dark:text-slate-900"> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-xl font-semibold text-slate-50 shadow-md transition-transform duration-300 ease-out group-hover:scale-105 dark:bg-slate-100 dark:text-slate-900">
{initial} {initial}
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl"> <h1 className="hero-title type-display font-bold tracking-tight">
<span className="hero-title__sweep" aria-hidden="true" />
{name} {name}
</h1> </h1>
<div className="mt-1"> <div className="mt-1">

View File

@@ -75,13 +75,13 @@ export function NavMenu({ items }: NavMenuProps) {
<FontAwesomeIcon icon={open ? faXmark : faBars} className="h-4 w-4" /> <FontAwesomeIcon icon={open ? faXmark : faBars} className="h-4 w-4" />
</button> </button>
<nav <nav
className={`${open ? 'flex' : 'hidden'} flex-col gap-2 text-base sm:flex sm:flex-row sm:items-center sm:gap-3 sm:text-sm`} className={`${open ? 'flex' : 'hidden'} flex-col gap-2 sm:flex sm:flex-row sm:items-center sm:gap-3`}
> >
{items.map((item) => ( {items.map((item) => (
<Link <Link
key={item.key} key={item.key}
href={item.href} href={item.href}
className="motion-link group relative inline-flex items-center gap-1.5 rounded-full px-3 py-1 font-medium text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200" 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} onClick={close}
> >
<FontAwesomeIcon <FontAwesomeIcon

View File

@@ -1,4 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import type { Post } from 'contentlayer/generated'; import type { Post } from 'contentlayer/generated';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons'; import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons';
@@ -19,11 +20,12 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
<article className="motion-card group relative overflow-hidden rounded-xl border bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"> <article className="motion-card group relative overflow-hidden rounded-xl border bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" /> <div className="pointer-events-none absolute inset-x-0 top-0 h-1 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
{cover && ( {cover && (
<div className="w-full bg-slate-100 dark:bg-slate-800"> <div className="relative w-full bg-slate-100 dark:bg-slate-800">
{/* eslint-disable-next-line @next/next/no-img-element */} <Image
<img
src={cover} src={cover}
alt={post.title} alt={post.title}
width={640}
height={360}
className="mx-auto max-h-60 w-full object-contain transition-transform duration-300 ease-out group-hover:scale-105" className="mx-auto max-h-60 w-full object-contain transition-transform duration-300 ease-out group-hover:scale-105"
/> />
</div> </div>

View File

@@ -1,4 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import type { Post } from 'contentlayer/generated'; import type { Post } from 'contentlayer/generated';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons'; import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons';
@@ -18,44 +19,43 @@ export function PostListItem({ post }: Props) {
post.description || post.custom_excerpt || post.body?.raw?.slice(0, 120); post.description || post.custom_excerpt || post.body?.raw?.slice(0, 120);
return ( return (
<li> <article className="motion-card group relative flex gap-4 rounded-lg border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900/80">
<article className="motion-card group relative flex gap-4 rounded-lg border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900/80"> <div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" /> {cover && (
{cover && ( <div className="relative flex h-24 w-24 flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:h-auto sm:w-40">
<div className="flex h-24 w-24 flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:h-auto sm:w-40"> <Image
{/* eslint-disable-next-line @next/next/no-img-element */} src={cover}
<img alt={post.title}
src={cover} width={320}
alt={post.title} height={240}
className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105" className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
/> />
</div> </div>
)} )}
<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={faCalendarDays}>
{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={faTags} tone="muted">
{post.tags.slice(0, 3).join(', ')} {post.tags.slice(0, 3).join(', ')}
</MetaItem> </MetaItem>
)}
</div>
<h2 className="text-base font-semibold leading-snug text-slate-900 hover:text-accent sm:text-lg dark:text-slate-50 dark:hover:text-accent">
<Link href={post.url}>{post.title}</Link>
</h2>
{excerpt && (
<p className="line-clamp-2 text-sm text-slate-600 group-hover:text-slate-800 dark:text-slate-300 dark:group-hover:text-slate-100">
{excerpt}
</p>
)} )}
</div> </div>
</article> <h2 className="text-base font-semibold leading-snug text-slate-900 hover:text-accent sm:text-lg dark:text-slate-50 dark:hover:text-accent">
</li> <Link href={post.url}>{post.title}</Link>
</h2>
{excerpt && (
<p className="line-clamp-2 text-sm text-slate-600 group-hover:text-slate-800 dark:text-slate-300 dark:group-hover:text-slate-100">
{excerpt}
</p>
)}
</div>
</article>
); );
} }

View File

@@ -11,6 +11,7 @@ import {
} from '@fortawesome/free-solid-svg-icons'; } 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';
interface Props { interface Props {
posts: Post[]; posts: Post[];
@@ -151,11 +152,11 @@ export function PostListWithControls({ posts, pageSize }: Props) {
</div> </div>
) : ( ) : (
<ul className="space-y-3"> <TimelineWrapper className="space-y-3">
{currentPosts.map((post) => ( {currentPosts.map((post) => (
<PostListItem key={post._id} post={post} /> <PostListItem key={post._id} post={post} />
))} ))}
</ul> </TimelineWrapper>
)} )}
{totalPages > 1 && currentPosts.length > 0 && ( {totalPages > 1 && currentPosts.length > 0 && (

View File

@@ -66,6 +66,9 @@ function Station({ station }: { station: StationConfig }) {
const alignClass = align === 'end' ? 'items-end text-right' : 'items-start text-left'; const alignClass = align === 'end' ? 'items-end text-right' : 'items-start text-left';
if (!post) { if (!post) {
if (align === 'start') {
return <div className="hidden" aria-hidden="true" />;
}
return ( return (
<div className={`flex flex-col gap-1 text-slate-400 ${alignClass}`}> <div className={`flex flex-col gap-1 text-slate-400 ${alignClass}`}>
<p className="text-[11px] uppercase tracking-[0.4em]">{label}</p> <p className="text-[11px] uppercase tracking-[0.4em]">{label}</p>

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faListUl, faCircle } from '@fortawesome/free-solid-svg-icons'; import { faListUl } from '@fortawesome/free-solid-svg-icons';
interface TocItem { interface TocItem {
id: string; id: string;
@@ -13,6 +13,9 @@ interface TocItem {
export function PostToc() { export function PostToc() {
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 itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [indicator, setIndicator] = useState({ top: 0, opacity: 0 });
useEffect(() => { useEffect(() => {
const headings = Array.from( const headings = Array.from(
@@ -50,6 +53,18 @@ export function PostToc() {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
useEffect(() => {
if (!activeId || !listRef.current) {
setIndicator({ top: 0, opacity: 0 });
return;
}
const activeEl = itemRefs.current[activeId];
if (!activeEl) return;
const listTop = listRef.current.getBoundingClientRect().top;
const { top, height } = activeEl.getBoundingClientRect();
setIndicator({ top: top - listTop + height / 2, opacity: 1 });
}, [activeId, items.length]);
const handleClick = (id: string) => (event: React.MouseEvent<HTMLAnchorElement>) => { const handleClick = (id: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault(); event.preventDefault();
const el = document.getElementById(id); const el = document.getElementById(id);
@@ -77,29 +92,47 @@ export function PostToc() {
if (items.length === 0) return null; if (items.length === 0) return null;
return ( return (
<nav className="sticky top-20 text-xs text-slate-500 dark:text-slate-400"> <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"> <div className="mb-2 inline-flex items-center gap-2 font-semibold text-slate-700 dark:text-slate-200">
<FontAwesomeIcon icon={faListUl} className="h-3 w-3 text-slate-400" /> <FontAwesomeIcon icon={faListUl} className="h-4 w-4 text-slate-400" />
</div> </div>
<ul className="space-y-1"> <div className="relative pl-4">
{items.map((item) => ( <span className="absolute left-1 top-0 h-full w-px bg-slate-200 dark:bg-slate-800" aria-hidden="true" />
<li key={item.id} className={item.depth === 3 ? 'pl-3' : ''}> <span
<a className="absolute left-0 h-3 w-3 -translate-y-1/2 rounded-full bg-accent transition-all duration-200 ease-snappy"
href={`#${item.id}`} style={{ top: `${indicator.top}px`, opacity: indicator.opacity }}
onClick={handleClick(item.id)} aria-hidden="true"
className={`line-clamp-2 inline-flex items-center gap-2 hover:text-blue-600 dark:hover:text-blue-400 ${ />
item.id === activeId <div
? 'text-blue-600 dark:text-blue-400 font-semibold' ref={listRef}
: '' className="space-y-1 text-[0.95rem]"
}`} role="list"
>
{items.map((item) => (
<div
key={item.id}
ref={(el) => {
itemRefs.current[item.id] = el;
}}
role="listitem"
className={`relative ${item.depth === 3 ? 'pl-3' : 'pl-0'}`}
> >
<FontAwesomeIcon icon={faCircle} className="h-1.5 w-1.5 text-slate-300" /> <a
{item.text} href={`#${item.id}`}
</a> onClick={handleClick(item.id)}
</li> className={`line-clamp-2 inline-flex items-center pl-2 hover:text-blue-600 dark:hover:text-blue-400 ${
))} item.id === activeId
</ul> ? 'text-blue-600 dark:text-blue-400 font-semibold'
: ''
}`}
>
{item.text}
</a>
</div>
))}
</div>
</div>
</nav> </nav>
); );
} }

View File

@@ -1,8 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBookOpen } from '@fortawesome/free-solid-svg-icons';
export function ReadingProgress() { export function ReadingProgress() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -34,14 +32,15 @@ export function ReadingProgress() {
if (!mounted) return null; if (!mounted) return null;
return ( return (
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-1.5 bg-slate-200/40 backdrop-blur-sm dark:bg-slate-900/70"> <div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-px bg-transparent">
<div <div className="relative h-1 w-full overflow-visible">
className="relative h-full origin-left bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 shadow-[0_0_12px_rgba(56,189,248,0.7)] transition-[transform,box-shadow] duration-200 ease-out dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" <div
style={{ transform: `scaleX(${progress / 100})` }} className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-blue-500/70 via-sky-400/70 to-indigo-500/70 shadow-[0_0_8px_rgba(59,130,246,0.45)] transition-[transform,opacity] duration-300 ease-out dark:from-blue-400/70 dark:via-sky-300/70 dark:to-indigo-400/70"
> style={{ transform: `scaleX(${progress / 100})`, opacity: progress > 0 ? 1 : 0 }}
<span className="absolute -right-3 -top-2.5 h-5 w-5 rounded-full bg-white/80 text-[10px] text-blue-600 shadow-md backdrop-blur dark:bg-slate-900/80" aria-hidden="true"> >
<FontAwesomeIcon icon={faBookOpen} className="h-full w-full p-1" /> <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> </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> </div>
); );

View File

@@ -1,7 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGithub, faMastodon, faLinkedin } from '@fortawesome/free-brands-svg-icons'; import { faGithub, faMastodon, faLinkedin } from '@fortawesome/free-brands-svg-icons';
import { faFire, faIdCard, faArrowRight } from '@fortawesome/free-solid-svg-icons'; 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 'contentlayer/generated'; import { allPages } from 'contentlayer/generated';
@@ -37,7 +38,7 @@ export function RightSidebar() {
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[]; ].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
return ( return (
<aside className="hidden text-sm lg:block"> <aside className="hidden lg:block">
<div className="sticky top-20 flex flex-col gap-4"> <div className="sticky top-20 flex flex-col gap-4">
<section className="motion-card group relative overflow-hidden rounded-xl border bg-white px-4 py-4 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"> <section className="motion-card group relative overflow-hidden rounded-xl border bg-white px-4 py-4 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
<div className="pointer-events-none absolute -left-10 -top-10 h-24 w-24 rounded-full bg-sky-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-sky-500/25" /> <div className="pointer-events-none absolute -left-10 -top-10 h-24 w-24 rounded-full bg-sky-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-sky-500/25" />
@@ -50,10 +51,12 @@ export function RightSidebar() {
className="mb-2 inline-block transition-transform duration-300 ease-out group-hover:-translate-y-0.5" className="mb-2 inline-block transition-transform duration-300 ease-out group-hover:-translate-y-0.5"
> >
{avatarSrc ? ( {avatarSrc ? (
// eslint-disable-next-line @next/next/no-img-element <Image
<img
src={avatarSrc} src={avatarSrc}
alt={siteConfig.name} alt={siteConfig.name}
width={96}
height={96}
unoptimized
className="h-24 w-24 rounded-full border border-slate-200 object-cover shadow-sm transition-transform duration-300 ease-out group-hover:scale-105 dark:border-slate-700" className="h-24 w-24 rounded-full border border-slate-200 object-cover shadow-sm transition-transform duration-300 ease-out group-hover:scale-105 dark:border-slate-700"
/> />
) : ( ) : (
@@ -63,7 +66,7 @@ export function RightSidebar() {
)} )}
</Link> </Link>
{socialItems.length > 0 && ( {socialItems.length > 0 && (
<div className="mt-2 flex items-center gap-3 text-base text-accent-textLight dark:text-accent-textDark"> <div className="mt-2 flex items-center gap-3 text-lg text-accent-textLight dark:text-accent-textDark">
{socialItems.map((item) => ( {socialItems.map((item) => (
<a <a
key={item.key} key={item.key}
@@ -79,21 +82,22 @@ export function RightSidebar() {
</div> </div>
)} )}
{siteConfig.aboutShort && ( {siteConfig.aboutShort && (
<p className="mt-3 flex items-center gap-2 text-[13px] text-slate-600 dark:text-slate-200"> <div className="type-body mt-3 space-y-1 text-center text-slate-600 dark:text-slate-200">
<FontAwesomeIcon icon={faIdCard} className="h-3 w-3 text-slate-400" /> {siteConfig.aboutShort.split(/\n+/).map((line, index) => (
<span>{siteConfig.aboutShort}</span> <p key={`${line}-${index}`}>{line}</p>
</p> ))}
</div>
)} )}
</div> </div>
</section> </section>
{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="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide 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" /> <FontAwesomeIcon icon={faFire} className="h-3 w-3 text-orange-400" />
</h2> </h2>
<div className="mt-2 flex flex-wrap gap-2 text-[13px]"> <div className="mt-2 flex flex-wrap gap-2 text-base">
{tags.map(({ tag, slug, count }) => { {tags.map(({ tag, slug, count }) => {
let sizeClass = ''; let sizeClass = '';
if (count >= 5) sizeClass = 'font-semibold'; if (count >= 5) sizeClass = 'font-semibold';
@@ -103,14 +107,14 @@ export function RightSidebar() {
<Link <Link
key={tag} key={tag}
href={`/tags/${slug}`} href={`/tags/${slug}`}
className={`${sizeClass} rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`} className={`${sizeClass} tag-chip rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`}
> >
{tag} {tag}
</Link> </Link>
); );
})} })}
</div> </div>
<div className="mt-3 flex items-center justify-between text-[11px] 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" /> <FontAwesomeIcon icon={faArrowRight} className="h-3 w-3" />

View File

@@ -21,24 +21,40 @@ export function ScrollReveal({
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
if (!('IntersectionObserver' in window)) {
setVisible(true);
return;
}
let cancelled = false;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
setVisible(true); if (!cancelled) setVisible(true);
if (once) observer.unobserve(entry.target); if (once) observer.unobserve(entry.target);
} else if (!once) { } else if (!once) {
setVisible(false); if (!cancelled) setVisible(false);
} }
}); });
}, },
{ {
threshold: 0.15 threshold: 0.05,
rootMargin: '0px 0px -20% 0px'
} }
); );
observer.observe(el); observer.observe(el);
return () => observer.disconnect();
const fallback = window.setTimeout(() => {
if (!cancelled) setVisible(true);
}, 500);
return () => {
cancelled = true;
observer.disconnect();
window.clearTimeout(fallback);
};
}, [once]); }, [once]);
return ( return (
@@ -56,4 +72,3 @@ export function ScrollReveal({
</div> </div>
); );
} }

View File

@@ -0,0 +1,53 @@
'use client';
import { useEffect, useRef, useState, ReactNode } from 'react';
import clsx from 'clsx';
interface SectionDividerProps {
children: ReactNode;
className?: string;
}
export function SectionDivider({ children, className }: SectionDividerProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (!('IntersectionObserver' in window)) {
setVisible(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setVisible(true);
}
});
},
{ threshold: 0.15, rootMargin: '0px 0px -20% 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={clsx('space-y-4', className)}
>
<span
className={clsx(
'block h-[2px] w-full origin-left rounded-full bg-gradient-to-r from-slate-200 via-accent-soft to-slate-200 transition-transform duration-500 ease-snappy dark:from-slate-800 dark:to-slate-800',
visible ? 'scale-x-100 opacity-100' : 'scale-x-50 opacity-30'
)}
/>
{children}
</div>
);
}

View File

@@ -25,7 +25,7 @@ export function SiteHeader() {
<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="/"
className="motion-link group relative font-semibold tracking-tight text-lg text-slate-900 hover:text-accent focus-visible:outline-none focus-visible:text-accent dark:text-slate-100" className="motion-link group relative type-title text-slate-900 hover:text-accent focus-visible:outline-none focus-visible:text-accent dark:text-slate-100"
> >
<span className="absolute -bottom-0.5 left-0 h-[2px] w-0 bg-accent transition-all duration-180 ease-snappy group-hover:w-full" aria-hidden="true" /> <span className="absolute -bottom-0.5 left-0 h-[2px] w-0 bg-accent transition-all duration-180 ease-snappy group-hover:w-full" aria-hidden="true" />
{siteConfig.title} {siteConfig.title}

View File

@@ -0,0 +1,32 @@
import { Children, ReactNode } from 'react';
import clsx from 'clsx';
interface TimelineWrapperProps {
children: ReactNode;
className?: string;
}
export function TimelineWrapper({ children, className }: TimelineWrapperProps) {
const items = Children.toArray(children);
return (
<div className={clsx('relative pl-8', className)}>
<span
className="pointer-events-none absolute left-3 top-0 h-full w-[2px] rounded-full bg-blue-400 shadow-[0_0_10px_rgba(59,130,246,0.35)] dark:bg-cyan-300"
aria-hidden="true"
/>
<span
className="pointer-events-none absolute left-3 top-0 h-full w-[8px] rounded-full bg-blue-500/15 blur-[14px]"
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" />
{child}
</div>
))}
</div>
</div>
);
}

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -7,12 +7,46 @@
--motion-duration-medium: 260ms; --motion-duration-medium: 260ms;
--motion-ease-snappy: cubic-bezier(0.32, 0.72, 0, 1); --motion-ease-snappy: cubic-bezier(0.32, 0.72, 0, 1);
--card-translate-y: -6px; --card-translate-y: -6px;
--line-height-body: clamp(1.5, 0.15vw + 1.45, 1.65);
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-system-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'PingFang TC', 'PingFang SC', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft JhengHei', 'Microsoft YaHei', 'Noto Sans TC', 'Noto Sans SC', 'Noto Sans CJK TC', 'Noto Sans CJK SC', 'Source Han Sans TC', 'Source Han Sans SC', 'Roboto', 'Ubuntu', 'Cantarell', 'Inter', 'Helvetica Neue', Arial, sans-serif;
font-size: clamp(15px, 0.65vw + 11px, 19px);
}
@media (min-width: 2560px) {
:root {
font-size: 20px;
}
} }
body { body {
@apply bg-white text-gray-900 transition-colors duration-200 ease-snappy dark:bg-gray-950 dark:text-gray-100 text-[15px] sm:text-base; @apply bg-white text-gray-900 transition-colors duration-200 ease-snappy dark:bg-gray-950 dark:text-gray-100;
font-size: 1rem;
line-height: var(--line-height-body);
font-family: var(--font-system-sans);
} }
@keyframes timeline-scroll {
0% {
transform: translate(-50%, -10%);
opacity: 0;
}
15% {
opacity: 1;
}
85% {
opacity: 1;
}
100% {
transform: translate(-50%, 110%);
opacity: 0;
}
}
.toc-target-highlight { .toc-target-highlight {
@apply bg-yellow-50/60 dark:bg-yellow-900/40 transition-colors duration-500; @apply bg-yellow-50/60 dark:bg-yellow-900/40 transition-colors duration-500;
} }
@@ -20,12 +54,35 @@ body {
/* Subtle hover for article elements */ /* Subtle hover for article elements */
.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);
background: linear-gradient(135deg, rgba(37, 99, 235, 0.04), rgba(37, 99, 235, 0.08));
padding: 1.2rem 1.5rem;
font-style: italic;
color: rgba(15, 23, 42, 0.75);
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);
} }
.prose blockquote:hover { .prose blockquote:hover {
@apply -translate-y-0.5 shadow-sm; @apply -translate-y-0.5 shadow-sm;
} }
.prose blockquote::before {
content: '“';
position: absolute;
top: 0.5rem;
left: 0.8rem;
font-size: 3rem;
font-family: 'Times New Roman', 'Noto Serif TC', serif;
color: rgba(37, 99, 235, 0.25);
pointer-events: none;
}
.prose pre { .prose pre {
@apply transition-transform transition-shadow duration-180 ease-snappy; @apply transition-transform transition-shadow duration-180 ease-snappy;
} }
@@ -34,6 +91,37 @@ body {
@apply -translate-y-0.5 shadow-md; @apply -translate-y-0.5 shadow-md;
} }
.prose {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.2rem);
line-height: var(--line-height-body);
}
.prose h1 {
font-size: clamp(2.2rem, 1.4rem + 2.2vw, 3.4rem);
line-height: 1.25;
}
.prose h2 {
font-size: clamp(1.8rem, 1.1rem + 1.6vw, 2.8rem);
line-height: 1.3;
}
.prose h3 {
font-size: clamp(1.4rem, 0.9rem + 1vw, 2rem);
line-height: 1.35;
}
.prose p,
.prose li {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem);
line-height: var(--line-height-body);
}
.prose small,
.prose figcaption {
font-size: clamp(0.85rem, 0.2vw + 0.8rem, 0.95rem);
}
.prose h1 > a, .prose h1 > a,
.prose h2 > a, .prose h2 > a,
.prose h3 > a, .prose h3 > a,
@@ -44,7 +132,101 @@ body {
color: inherit !important; color: inherit !important;
} }
.hero-title {
position: relative;
display: inline-flex;
overflow: hidden;
}
.hero-title__sweep {
position: absolute;
inset: 0;
background: linear-gradient(120deg, transparent 10%, rgba(59, 130, 246, 0.35) 45%, transparent 90%);
transform: translateX(-120%);
animation: hero-sweep 4s var(--motion-ease-snappy) infinite;
pointer-events: none;
}
@keyframes hero-sweep {
0% {
transform: translateX(-120%);
opacity: 0;
}
30% {
opacity: 0.4;
}
60% {
transform: translateX(120%);
opacity: 0;
}
100% {
transform: translateX(120%);
opacity: 0;
}
}
.tag-chip {
position: relative;
overflow: hidden;
transition: color var(--motion-duration-short) var(--motion-ease-snappy), background-color var(--motion-duration-short) var(--motion-ease-snappy);
}
.tag-chip::after {
content: '';
position: absolute;
left: 50%;
bottom: 4px;
width: 0;
height: 2px;
background: currentColor;
opacity: 0.5;
transition: width var(--motion-duration-short) var(--motion-ease-snappy), left var(--motion-duration-short) var(--motion-ease-snappy);
}
.tag-chip:hover::after,
.tag-chip:focus-visible::after {
width: 100%;
left: 0;
}
@layer components { @layer components {
.type-display {
font-size: clamp(2.2rem, 1.6rem + 2.4vw, 3.5rem);
line-height: 1.2;
font-weight: var(--font-weight-semibold);
}
.type-title {
font-size: clamp(1.6rem, 1.1rem + 1.4vw, 2.6rem);
line-height: 1.3;
font-weight: var(--font-weight-semibold);
}
.type-subtitle {
font-size: clamp(1.25rem, 0.9rem + 1vw, 1.9rem);
line-height: 1.35;
font-weight: var(--font-weight-medium);
}
.type-body {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem);
line-height: var(--line-height-body);
font-weight: var(--font-weight-regular);
}
.type-small {
font-size: clamp(0.85rem, 0.2vw + 0.8rem, 0.95rem);
line-height: 1.4;
font-weight: var(--font-weight-regular);
}
.type-nav {
font-size: clamp(0.95rem, 0.2vw + 0.85rem, 1.05rem);
font-weight: var(--font-weight-medium);
letter-spacing: 0.04em;
}
.motion-card { .motion-card {
transition: transform var(--motion-duration-medium) var(--motion-ease-snappy), transition: transform var(--motion-duration-medium) var(--motion-ease-snappy),
box-shadow var(--motion-duration-medium) var(--motion-ease-snappy), box-shadow var(--motion-duration-medium) var(--motion-ease-snappy),