Compare commits
50 Commits
80d0b236c5
...
8ade752448
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ade752448 | |||
| e04a03097f | |||
| a8ee8d83af | |||
| 261cb1d91e | |||
| f32206d390 | |||
| ce43491e2e | |||
| 68ababe8c8 | |||
| 985caa2a4d | |||
| 77bd180d97 | |||
| 3425098006 | |||
| eefc38d562 | |||
| 48ce66a3e6 | |||
| 22120595a6 | |||
| eab80bd17a | |||
| 5b99486a68 | |||
| 5fdd72302e | |||
| 66cd9b8608 | |||
| 2e80b7ac59 | |||
| be5d942c79 | |||
| 3018a25578 | |||
| 04182ec754 | |||
| 9b2d754a2f | |||
| 1a7ae8a269 | |||
| 9a7eb6cfe3 | |||
| 246646f176 | |||
| 287c0d72a8 | |||
| fe191752da | |||
| 10e4e7e21e | |||
| 82a459bede | |||
| af0d2e3a6c | |||
| 9235ab291b | |||
| 79578252df | |||
| a225d57e06 | |||
| b416c9eb7d | |||
| 61d5092136 | |||
| a582ef9cb5 | |||
| dc5ca97fee | |||
| b4ee8b122f | |||
| cd95a7bb79 | |||
| f34221b567 | |||
| 3509b43643 | |||
| 6ca024b0ba | |||
| 9d86cd4663 | |||
| 31f1c6979d | |||
| b69755c2d6 | |||
| dadb5dce5c | |||
| 7a6cd55c42 | |||
| 1e39647ab6 | |||
| e73f37da76 | |||
| 351a1a2f70 |
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { allPosts } from 'contentlayer/generated';
|
||||
@@ -9,6 +10,8 @@ import { PostToc } from '@/components/post-toc';
|
||||
import { ScrollReveal } from '@/components/scroll-reveal';
|
||||
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';
|
||||
|
||||
export function generateStaticParams() {
|
||||
return allPosts.map((post) => ({
|
||||
@@ -48,16 +51,17 @@ export default function BlogPostPage({ params }: Props) {
|
||||
<PostToc />
|
||||
</aside>
|
||||
<div className="flex-1 space-y-6">
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<header className="mb-2 space-y-2">
|
||||
{post.published_at && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-500">
|
||||
<p className="type-small text-slate-500 dark:text-slate-500">
|
||||
{new Date(post.published_at).toLocaleDateString(
|
||||
siteConfig.defaultLocale
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold leading-tight text-slate-900 sm:text-3xl dark:text-slate-50">
|
||||
<h1 className="type-display font-bold leading-tight text-slate-900 dark:text-slate-50">
|
||||
{post.title}
|
||||
</h1>
|
||||
{post.tags && (
|
||||
@@ -68,7 +72,7 @@ export default function BlogPostPage({ params }: Props) {
|
||||
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>
|
||||
@@ -77,22 +81,28 @@ export default function BlogPostPage({ params }: Props) {
|
||||
)}
|
||||
</header>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
|
||||
{post.feature_image && (
|
||||
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
<Image
|
||||
src={post.feature_image.replace('../assets', '/assets')}
|
||||
alt={post.title}
|
||||
width={1200}
|
||||
height={600}
|
||||
className="my-4 rounded"
|
||||
/>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
|
||||
</article>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
|
||||
<FooterCue />
|
||||
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<PostStorylineNav
|
||||
current={post}
|
||||
@@ -100,15 +110,17 @@ export default function BlogPostPage({ params }: Props) {
|
||||
older={neighbors.older}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
|
||||
{relatedPosts.length > 0 && (
|
||||
<SectionDivider>
|
||||
<ScrollReveal>
|
||||
<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">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-50">
|
||||
<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">
|
||||
<p className="type-small text-slate-500 dark:text-slate-400">
|
||||
為你挑選相似主題
|
||||
</p>
|
||||
</div>
|
||||
@@ -119,6 +131,7 @@ export default function BlogPostPage({ params }: Props) {
|
||||
</div>
|
||||
</section>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getAllPostsSorted } from '@/lib/posts';
|
||||
import { PostListWithControls } from '@/components/post-list-with-controls';
|
||||
import { TimelineWrapper } from '@/components/timeline-wrapper';
|
||||
|
||||
export const metadata = {
|
||||
title: '所有文章'
|
||||
@@ -11,10 +12,10 @@ export default function BlogIndexPage() {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<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>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
<p className="type-small text-slate-500 dark:text-slate-400">
|
||||
繼續往下滑,慢慢逛逛。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -24,6 +24,9 @@ export const metadata: Metadata = {
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
images: [siteConfig.ogImage]
|
||||
},
|
||||
icons: {
|
||||
icon: '/favicon.png'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
11
app/page.tsx
11
app/page.tsx
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
||||
import { getAllPostsSorted } from '@/lib/posts';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { PostListItem } from '@/components/post-list-item';
|
||||
import { TimelineWrapper } from '@/components/timeline-wrapper';
|
||||
|
||||
export default function HomePage() {
|
||||
const posts = getAllPostsSorted().slice(0, siteConfig.postsPerPage);
|
||||
@@ -9,17 +10,17 @@ export default function HomePage() {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-1 text-center">
|
||||
<h1 className="text-2xl font-bold text-slate-900 sm:text-3xl dark:text-slate-50">
|
||||
<h1 className="type-title font-bold text-slate-900 dark:text-slate-50">
|
||||
{siteConfig.name} 的最新動態
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
<p className="type-small text-slate-600 dark:text-slate-300">
|
||||
{siteConfig.tagline}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
<h2 className="type-small font-semibold uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
|
||||
最新文章
|
||||
</h2>
|
||||
<Link
|
||||
@@ -29,11 +30,11 @@ export default function HomePage() {
|
||||
所有文章 →
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
<TimelineWrapper>
|
||||
{posts.map((post) => (
|
||||
<PostListItem key={post._id} post={post} />
|
||||
))}
|
||||
</ul>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { allPages } from 'contentlayer/generated';
|
||||
@@ -71,11 +72,11 @@ export default function StaticPage({ params }: Props) {
|
||||
</header>
|
||||
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
|
||||
{page.feature_image && (
|
||||
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
<Image
|
||||
src={page.feature_image.replace('../assets', '/assets')}
|
||||
alt={page.title}
|
||||
width={1200}
|
||||
height={600}
|
||||
className="my-4 rounded"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
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 { SectionDivider } from '@/components/section-divider';
|
||||
import { ScrollReveal } from '@/components/scroll-reveal';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '標籤索引'
|
||||
@@ -10,35 +12,60 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function TagIndexPage() {
|
||||
const tags = getAllTagsWithCount();
|
||||
const topTags = tags.slice(0, 3);
|
||||
|
||||
const colorClasses = [
|
||||
'bg-rose-100 text-rose-700 dark:bg-rose-900/60 dark:text-rose-200',
|
||||
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/60 dark:text-emerald-200',
|
||||
'bg-sky-100 text-sky-700 dark:bg-sky-900/60 dark:text-sky-200',
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-900/60 dark:text-amber-200',
|
||||
'bg-violet-100 text-violet-700 dark:bg-violet-900/60 dark:text-violet-200'
|
||||
'from-rose-400/70 to-rose-200/40',
|
||||
'from-emerald-400/70 to-emerald-200/40',
|
||||
'from-sky-400/70 to-sky-200/40',
|
||||
'from-amber-400/70 to-amber-200/40',
|
||||
'from-violet-400/70 to-violet-200/40'
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h1 className="flex items-center gap-2 text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
<FontAwesomeIcon icon={faTags} className="h-5 w-5 text-slate-400" />
|
||||
<section className="space-y-6">
|
||||
<SectionDivider>
|
||||
<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">
|
||||
<div className="inline-flex items-center gap-2 text-accent">
|
||||
<FontAwesomeIcon icon={faTags} className="h-5 w-5" />
|
||||
<span className="type-small uppercase tracking-[0.4em] text-slate-500 dark:text-slate-400">
|
||||
標籤索引
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="type-title mt-2 font-semibold text-slate-900 dark:text-slate-50">
|
||||
共 {tags.length} 組主題,任你探索
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
目前共有 {tags.length} 個標籤。
|
||||
<p className="type-small mt-2 text-slate-600 dark:text-slate-300">
|
||||
熱度最高的標籤:
|
||||
{topTags.map((t) => t.tag).join('、')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</SectionDivider>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{tags.map(({ tag, slug, count }, index) => {
|
||||
const color = colorClasses[index % colorClasses.length];
|
||||
return (
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/tags/${slug}`}
|
||||
className={`rounded-full px-3 py-1 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="opacity-70">({count})</span>
|
||||
<span className={`mb-3 block h-1.5 w-16 rounded-full bg-gradient-to-r ${color}`} aria-hidden="true" />
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="type-subtitle font-semibold text-slate-900 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>
|
||||
);
|
||||
})}
|
||||
|
||||
43
components/footer-cue.tsx
Normal file
43
components/footer-cue.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function FooterCue() {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
setActive(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActive(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.2 }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex flex-col items-center gap-2 py-4 text-[11px] uppercase tracking-[0.3em] text-slate-400 dark:text-slate-500">
|
||||
<span className="text-xs">即將展開</span>
|
||||
<span
|
||||
className={`h-10 w-px overflow-hidden rounded-full bg-gradient-to-b from-transparent via-accent to-transparent transition-[height,opacity] duration-500 ease-snappy ${
|
||||
active ? 'opacity-80' : 'h-4 opacity-30'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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="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}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
<h1 className="hero-title type-display font-bold tracking-tight">
|
||||
<span className="hero-title__sweep" aria-hidden="true" />
|
||||
{name}
|
||||
</h1>
|
||||
<div className="mt-1">
|
||||
|
||||
@@ -75,13 +75,13 @@ export function NavMenu({ items }: NavMenuProps) {
|
||||
<FontAwesomeIcon icon={open ? faXmark : faBars} className="h-4 w-4" />
|
||||
</button>
|
||||
<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) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
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}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Post } from 'contentlayer/generated';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
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">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
|
||||
{cover && (
|
||||
<div className="w-full bg-slate-100 dark:bg-slate-800">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<div className="relative w-full bg-slate-100 dark:bg-slate-800">
|
||||
<Image
|
||||
src={cover}
|
||||
alt={post.title}
|
||||
width={640}
|
||||
height={360}
|
||||
className="mx-auto max-h-60 w-full object-contain transition-transform duration-300 ease-out group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Post } from 'contentlayer/generated';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||
@@ -18,15 +19,15 @@ export function PostListItem({ post }: Props) {
|
||||
post.description || post.custom_excerpt || post.body?.raw?.slice(0, 120);
|
||||
|
||||
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">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
|
||||
{cover && (
|
||||
<div className="flex h-24 w-24 flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:h-auto sm:w-40">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<div className="relative flex h-24 w-24 flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:h-auto sm:w-40">
|
||||
<Image
|
||||
src={cover}
|
||||
alt={post.title}
|
||||
width={320}
|
||||
height={240}
|
||||
className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
@@ -56,6 +57,5 @@ export function PostListItem({ post }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { PostListItem } from './post-list-item';
|
||||
import { TimelineWrapper } from './timeline-wrapper';
|
||||
|
||||
interface Props {
|
||||
posts: Post[];
|
||||
@@ -151,11 +152,11 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
找不到符合關鍵字的文章,換個詞再試試?
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
<TimelineWrapper className="space-y-3">
|
||||
{currentPosts.map((post) => (
|
||||
<PostListItem key={post._id} post={post} />
|
||||
))}
|
||||
</ul>
|
||||
</TimelineWrapper>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && currentPosts.length > 0 && (
|
||||
|
||||
@@ -66,6 +66,9 @@ function Station({ station }: { station: StationConfig }) {
|
||||
const alignClass = align === 'end' ? 'items-end text-right' : 'items-start text-left';
|
||||
|
||||
if (!post) {
|
||||
if (align === 'start') {
|
||||
return <div className="hidden" aria-hidden="true" />;
|
||||
}
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 text-slate-400 ${alignClass}`}>
|
||||
<p className="text-[11px] uppercase tracking-[0.4em]">{label}</p>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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 {
|
||||
id: string;
|
||||
@@ -13,6 +13,9 @@ interface TocItem {
|
||||
export function PostToc() {
|
||||
const [items, setItems] = useState<TocItem[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const [indicator, setIndicator] = useState({ top: 0, opacity: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const headings = Array.from(
|
||||
@@ -50,6 +53,18 @@ export function PostToc() {
|
||||
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>) => {
|
||||
event.preventDefault();
|
||||
const el = document.getElementById(id);
|
||||
@@ -77,29 +92,47 @@ export function PostToc() {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
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">
|
||||
<FontAwesomeIcon icon={faListUl} className="h-3 w-3 text-slate-400" />
|
||||
<FontAwesomeIcon icon={faListUl} className="h-4 w-4 text-slate-400" />
|
||||
目錄
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
<div className="relative pl-4">
|
||||
<span className="absolute left-1 top-0 h-full w-px bg-slate-200 dark:bg-slate-800" aria-hidden="true" />
|
||||
<span
|
||||
className="absolute left-0 h-3 w-3 -translate-y-1/2 rounded-full bg-accent transition-all duration-200 ease-snappy"
|
||||
style={{ top: `${indicator.top}px`, opacity: indicator.opacity }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={listRef}
|
||||
className="space-y-1 text-[0.95rem]"
|
||||
role="list"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className={item.depth === 3 ? 'pl-3' : ''}>
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
itemRefs.current[item.id] = el;
|
||||
}}
|
||||
role="listitem"
|
||||
className={`relative ${item.depth === 3 ? 'pl-3' : 'pl-0'}`}
|
||||
>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
onClick={handleClick(item.id)}
|
||||
className={`line-clamp-2 inline-flex items-center gap-2 hover:text-blue-600 dark:hover:text-blue-400 ${
|
||||
className={`line-clamp-2 inline-flex items-center pl-2 hover:text-blue-600 dark:hover:text-blue-400 ${
|
||||
item.id === activeId
|
||||
? 'text-blue-600 dark:text-blue-400 font-semibold'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCircle} className="h-1.5 w-1.5 text-slate-300" />
|
||||
{item.text}
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBookOpen } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export function ReadingProgress() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -34,14 +32,15 @@ export function ReadingProgress() {
|
||||
if (!mounted) return null;
|
||||
|
||||
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 className="relative h-1 w-full overflow-visible">
|
||||
<div
|
||||
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"
|
||||
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>
|
||||
<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" />
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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, faIdCard, faArrowRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faFire, faArrowRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { getAllTagsWithCount } from '@/lib/posts';
|
||||
import { allPages } from 'contentlayer/generated';
|
||||
@@ -37,7 +38,7 @@ export function RightSidebar() {
|
||||
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
|
||||
|
||||
return (
|
||||
<aside className="hidden text-sm lg:block">
|
||||
<aside className="hidden lg:block">
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
{avatarSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
@@ -63,7 +66,7 @@ export function RightSidebar() {
|
||||
)}
|
||||
</Link>
|
||||
{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) => (
|
||||
<a
|
||||
key={item.key}
|
||||
@@ -79,21 +82,22 @@ export function RightSidebar() {
|
||||
</div>
|
||||
)}
|
||||
{siteConfig.aboutShort && (
|
||||
<p className="mt-3 flex items-center gap-2 text-[13px] text-slate-600 dark:text-slate-200">
|
||||
<FontAwesomeIcon icon={faIdCard} className="h-3 w-3 text-slate-400" />
|
||||
<span>{siteConfig.aboutShort}</span>
|
||||
</p>
|
||||
<div className="type-body mt-3 space-y-1 text-center text-slate-600 dark:text-slate-200">
|
||||
{siteConfig.aboutShort.split(/\n+/).map((line, index) => (
|
||||
<p key={`${line}-${index}`}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{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="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" />
|
||||
熱門標籤
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[13px]">
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-base">
|
||||
{tags.map(({ tag, slug, count }) => {
|
||||
let sizeClass = '';
|
||||
if (count >= 5) sizeClass = 'font-semibold';
|
||||
@@ -103,14 +107,14 @@ export function RightSidebar() {
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/tags/${slug}`}
|
||||
className={`${sizeClass} rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`}
|
||||
className={`${sizeClass} tag-chip rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`}
|
||||
>
|
||||
{tag}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-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">
|
||||
<FontAwesomeIcon icon={faArrowRight} className="h-3 w-3" />
|
||||
一覽所有標籤
|
||||
|
||||
@@ -21,24 +21,40 @@ export function ScrollReveal({
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
if (!cancelled) setVisible(true);
|
||||
if (once) observer.unobserve(entry.target);
|
||||
} else if (!once) {
|
||||
setVisible(false);
|
||||
if (!cancelled) setVisible(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold: 0.15
|
||||
threshold: 0.05,
|
||||
rootMargin: '0px 0px -20% 0px'
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
|
||||
const fallback = window.setTimeout(() => {
|
||||
if (!cancelled) setVisible(true);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
observer.disconnect();
|
||||
window.clearTimeout(fallback);
|
||||
};
|
||||
}, [once]);
|
||||
|
||||
return (
|
||||
@@ -56,4 +72,3 @@ export function ScrollReveal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
53
components/section-divider.tsx
Normal file
53
components/section-divider.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface SectionDividerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionDivider({ children, className }: SectionDividerProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.15, rootMargin: '0px 0px -20% 0px' }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx('space-y-4', className)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'block h-[2px] w-full origin-left rounded-full bg-gradient-to-r from-slate-200 via-accent-soft to-slate-200 transition-transform duration-500 ease-snappy dark:from-slate-800 dark:to-slate-800',
|
||||
visible ? 'scale-x-100 opacity-100' : 'scale-x-50 opacity-30'
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<Link
|
||||
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" />
|
||||
{siteConfig.title}
|
||||
|
||||
32
components/timeline-wrapper.tsx
Normal file
32
components/timeline-wrapper.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Children, ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface TimelineWrapperProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TimelineWrapper({ children, className }: TimelineWrapperProps) {
|
||||
const items = Children.toArray(children);
|
||||
return (
|
||||
<div className={clsx('relative pl-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
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -7,12 +7,46 @@
|
||||
--motion-duration-medium: 260ms;
|
||||
--motion-ease-snappy: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--card-translate-y: -6px;
|
||||
--line-height-body: clamp(1.5, 0.15vw + 1.45, 1.65);
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-system-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'PingFang TC', 'PingFang SC', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft JhengHei', 'Microsoft YaHei', 'Noto Sans TC', 'Noto Sans SC', 'Noto Sans CJK TC', 'Noto Sans CJK SC', 'Source Han Sans TC', 'Source Han Sans SC', 'Roboto', 'Ubuntu', 'Cantarell', 'Inter', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
font-size: clamp(15px, 0.65vw + 11px, 19px);
|
||||
}
|
||||
|
||||
@media (min-width: 2560px) {
|
||||
:root {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-gray-900 transition-colors duration-200 ease-snappy dark:bg-gray-950 dark:text-gray-100 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 {
|
||||
@apply bg-yellow-50/60 dark:bg-yellow-900/40 transition-colors duration-500;
|
||||
}
|
||||
@@ -20,12 +54,35 @@ body {
|
||||
/* Subtle hover for article elements */
|
||||
.prose blockquote {
|
||||
@apply transition-transform transition-shadow duration-180 ease-snappy;
|
||||
border-left: 4px solid var(--color-accent, #2563eb);
|
||||
background: linear-gradient(135deg, rgba(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 {
|
||||
@apply -translate-y-0.5 shadow-sm;
|
||||
}
|
||||
|
||||
.prose blockquote::before {
|
||||
content: '“';
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.8rem;
|
||||
font-size: 3rem;
|
||||
font-family: 'Times New Roman', 'Noto Serif TC', serif;
|
||||
color: rgba(37, 99, 235, 0.25);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
@apply transition-transform transition-shadow duration-180 ease-snappy;
|
||||
}
|
||||
@@ -34,6 +91,37 @@ body {
|
||||
@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 h2 > a,
|
||||
.prose h3 > a,
|
||||
@@ -44,7 +132,101 @@ body {
|
||||
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 {
|
||||
.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 {
|
||||
transition: transform var(--motion-duration-medium) var(--motion-ease-snappy),
|
||||
box-shadow var(--motion-duration-medium) var(--motion-ease-snappy),
|
||||
|
||||
Reference in New Issue
Block a user