Compare commits

...

2 Commits

Author SHA1 Message Date
6a9296f33d Migrate to HeroUI v3 and Tailwind CSS v4
- Upgrade from Tailwind CSS v3 to v4 with CSS-first configuration
- Install HeroUI v3 beta packages (@heroui/react, @heroui/styles)
- Migrate PostCSS config to new @tailwindcss/postcss plugin
- Convert tailwind.config.cjs to CSS @theme directive in globals.css
- Replace @tailwindcss/typography with custom prose styles

Component migrations:
- theme-toggle, back-to-top: HeroUI Button with onPress
- post-card, post-list-item: HeroUI Card compound components
- right-sidebar: HeroUI Card, Avatar, Chip
- search-modal: HeroUI Modal with compound structure
- nav-menu: HeroUI Button for mobile controls
- post-list-with-controls: HeroUI Button for sorting/pagination

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:47:36 +08:00
1cd9106ad0 Add CLAUDE.md for Claude Code session context
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:19:20 +08:00
15 changed files with 3457 additions and 1370 deletions

20
CLAUDE.md Normal file
View File

@@ -0,0 +1,20 @@
# blog-nextjs
Personal blog built with Next.js 16 (App Router), Contentlayer2, and Tailwind CSS.
## Commands
- `npm run dev` - Start dev server (Turbopack + Contentlayer2)
- `npm run build` - Full build (sync-assets → contentlayer2 → next build → pagefind)
- `npm run sync-assets` - Sync content assets to public/
## Architecture
- `app/` - Next.js App Router pages
- `content/` - Git submodule with MDX posts and pages
- `components/` - React components
- Contentlayer2 processes MDX from `content/` directory
- Pagefind provides client-side search
## Conventions
- TypeScript strict mode
- Tailwind for styling
- MDX for blog content

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button } from '@heroui/react';
export function BackToTop() { export function BackToTop() {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
@@ -22,16 +23,16 @@ export function BackToTop() {
if (!visible) return null; if (!visible) return null;
return ( return (
<button <Button
type="button" isIconOnly
onClick={() => { variant="secondary"
className="fixed bottom-6 right-4 z-40 h-9 w-9 rounded-full bg-slate-900 text-slate-50 shadow-md ring-1 ring-slate-800/70 transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:ring-slate-300/70 dark:hover:bg-slate-300"
onPress={() => {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}} }}
aria-label="回到頁面頂部" aria-label="回到頁面頂部"
className="fixed bottom-6 right-4 z-40 inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-slate-50 shadow-md ring-1 ring-slate-800/70 transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:ring-slate-300/70 dark:hover:bg-slate-300"
> >
<span className="text-lg leading-none"></span> <span className="text-lg leading-none"></span>
</button> </Button>
); );
} }

View File

@@ -21,6 +21,7 @@ import {
} from 'react-icons/fi'; } from 'react-icons/fi';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Button } from '@heroui/react';
export type IconKey = export type IconKey =
| 'home' | 'home'
@@ -35,7 +36,7 @@ export type IconKey =
| 'device' | 'device'
| 'menu'; | 'menu';
const ICON_MAP: Record<IconKey, any> = { const ICON_MAP: Record<IconKey, React.ComponentType<{ className?: string }>> = {
home: FiHome, home: FiHome,
blog: FiFileText, blog: FiFileText,
file: FiFile, file: FiFile,
@@ -128,7 +129,7 @@ export function NavMenu({ items }: NavMenuProps) {
<Link <Link
key={item.key} key={item.key}
href={item.href} href={item.href}
className="motion-link inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800" className="motion-link inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-[var(--color-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)]/40 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={close} onClick={close}
> >
<Icon className="h-4 w-4 text-slate-400" /> <Icon className="h-4 w-4 text-slate-400" />
@@ -145,8 +146,9 @@ export function NavMenu({ items }: NavMenuProps) {
if (hasChildren) { if (hasChildren) {
return ( return (
<div key={item.key} className="flex flex-col"> <div key={item.key} className="flex flex-col">
<button <Button
onClick={() => toggleMobileItem(item.key)} variant="ghost"
onPress={() => toggleMobileItem(item.key)}
className="flex w-full items-center justify-between rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800" className="flex w-full items-center justify-between rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -156,7 +158,7 @@ export function NavMenu({ items }: NavMenuProps) {
<FiChevronRight <FiChevronRight
className={`h-5 w-5 text-slate-400 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} className={`h-5 w-5 text-slate-400 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
/> />
</button> </Button>
<div <div
className={`grid transition-all duration-200 ease-in-out ${isExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0' className={`grid transition-all duration-200 ease-in-out ${isExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`} }`}
@@ -187,48 +189,50 @@ export function NavMenu({ items }: NavMenuProps) {
return ( return (
<> <>
{/* Mobile Menu Trigger */} {/* Mobile Menu Trigger */}
<button <Button
type="button" isIconOnly
className="relative z-50 inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 sm:hidden" variant="ghost"
className="relative z-50 h-10 w-10 rounded-full text-slate-600 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800 sm:hidden"
aria-label={open ? '關閉選單' : '開啟選單'} aria-label={open ? '關閉選單' : '開啟選單'}
aria-expanded={open} aria-expanded={open}
onClick={toggle} onPress={toggle}
> >
<div className="relative h-5 w-5"> <div className="relative h-5 w-5">
<span <span
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? 'rotate-45' : '-translate-y-1.5' className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ${open ? 'rotate-45' : '-translate-y-1.5'
}`} }`}
/> />
<span <span
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? 'opacity-0' : 'opacity-100' className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ${open ? 'opacity-0' : 'opacity-100'
}`} }`}
/> />
<span <span
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ease-snappy ${open ? '-rotate-45' : 'translate-y-1.5' className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ${open ? '-rotate-45' : 'translate-y-1.5'
}`} }`}
/> />
</div> </div>
</button> </Button>
{/* Mobile Menu Overlay - Portaled */} {/* Mobile Menu Overlay - Portaled */}
{mounted && createPortal( {mounted && createPortal(
<div <div
className={`fixed inset-0 z-[100] flex flex-col bg-white/95 backdrop-blur-xl transition-all duration-300 ease-snappy dark:bg-gray-950/95 sm:hidden ${open ? 'visible opacity-100' : 'invisible opacity-0 pointer-events-none' className={`fixed inset-0 z-[100] flex flex-col bg-white/95 backdrop-blur-xl transition-all duration-300 dark:bg-gray-950/95 sm:hidden ${open ? 'visible opacity-100' : 'invisible opacity-0 pointer-events-none'
}`} }`}
> >
{/* Close button area */} {/* Close button area */}
<div className="flex items-center justify-end px-4 py-3"> <div className="flex items-center justify-end px-4 py-3">
<button <Button
type="button" isIconOnly
className="inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800" variant="ghost"
onClick={close} className="h-10 w-10 rounded-full text-slate-600 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
onPress={close}
aria-label="Close menu" aria-label="Close menu"
> >
<div className="relative h-5 w-5"> <div className="relative h-5 w-5">
<span className="absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 rotate-45 bg-current" /> <span className="absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 rotate-45 bg-current" />
<span className="absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 -rotate-45 bg-current" /> <span className="absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 -rotate-45 bg-current" />
</div> </div>
</button> </Button>
</div> </div>
<div className="container mx-auto flex flex-1 flex-col px-4 pb-8"> <div className="container mx-auto flex flex-1 flex-col px-4 pb-8">
@@ -261,17 +265,17 @@ export function NavMenu({ items }: NavMenuProps) {
> >
<button <button
type="button" type="button"
className="motion-link type-nav inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200" className="motion-link type-nav inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-[var(--color-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)]/40 dark:text-slate-200"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={isOpen} aria-expanded={isOpen}
> >
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" /> <Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-[var(--color-accent)]" />
<span>{item.label}</span> <span>{item.label}</span>
<FiChevronDown className="h-3 w-3 text-slate-400 transition group-hover:text-accent" /> <FiChevronDown className="h-3 w-3 text-slate-400 transition group-hover:text-[var(--color-accent)]" />
</button> </button>
<div <div
className={`absolute left-0 top-full z-50 hidden min-w-[12rem] rounded-2xl border border-slate-200 bg-white p-2 shadow-lg transition duration-200 ease-snappy dark:border-slate-800 dark:bg-slate-900 sm:block ${isOpen ? 'pointer-events-auto translate-y-2 opacity-100' : 'pointer-events-none translate-y-1 opacity-0' className={`absolute left-0 top-full z-50 hidden min-w-[12rem] rounded-2xl border border-slate-200 bg-white p-2 shadow-lg transition duration-200 dark:border-slate-800 dark:bg-slate-900 sm:block ${isOpen ? 'pointer-events-auto translate-y-2 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'
}`} }`}
role="menu" role="menu"
aria-label={item.label} aria-label={item.label}
@@ -290,12 +294,12 @@ export function NavMenu({ items }: NavMenuProps) {
<Link <Link
key={item.key} key={item.key}
href={item.href} href={item.href}
className="motion-link type-nav group relative inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200" 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-[var(--color-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)]/40 dark:text-slate-200"
onClick={close} onClick={close}
> >
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" /> <Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-[var(--color-accent)]" />
<span>{item.label}</span> <span>{item.label}</span>
<span className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-accent transition duration-180 ease-snappy group-hover:scale-x-100" aria-hidden="true" /> <span className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-[var(--color-accent)] transition duration-180 group-hover:scale-x-100" aria-hidden="true" />
</Link> </Link>
) : null; ) : null;
})} })}

View File

@@ -4,6 +4,7 @@ import type { Post } from 'contentlayer2/generated';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { FiCalendar, FiTag } from 'react-icons/fi'; import { FiCalendar, FiTag } from 'react-icons/fi';
import { MetaItem } from './meta-item'; import { MetaItem } from './meta-item';
import { Card } from '@heroui/react';
interface PostCardProps { interface PostCardProps {
post: Post; post: Post;
@@ -17,7 +18,8 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
: undefined; : undefined;
return ( return (
<article className="motion-card group relative overflow-hidden rounded-xl border bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"> <article>
<Card className="motion-card group relative overflow-hidden rounded-xl border dark:border-slate-800">
<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="relative w-full bg-slate-100 dark:bg-slate-800"> <div className="relative w-full bg-slate-100 dark:bg-slate-800">
@@ -32,7 +34,7 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
/> />
</div> </div>
)} )}
<div className="space-y-3 px-4 py-4"> <Card.Content className="space-y-3 px-4 py-4">
<div className="flex flex-wrap items-center gap-3 text-xs"> <div className="flex flex-wrap items-center gap-3 text-xs">
{post.published_at && ( {post.published_at && (
<MetaItem icon={FiCalendar}> <MetaItem icon={FiCalendar}>
@@ -47,20 +49,23 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
</MetaItem> </MetaItem>
)} )}
</div> </div>
<h2 className="text-lg font-semibold leading-snug"> <Card.Header className="p-0">
<Link <Card.Title className="text-lg font-semibold leading-snug">
href={post.url} <Link
className="hover:text-blue-600 dark:hover:text-blue-400" href={post.url}
> className="hover:text-blue-600 dark:hover:text-blue-400"
{post.title} >
</Link> {post.title}
</h2> </Link>
</Card.Title>
</Card.Header>
{post.description && ( {post.description && (
<p className="line-clamp-3 text-sm text-slate-700 dark:text-slate-100"> <Card.Description className="line-clamp-3 text-sm text-slate-700 dark:text-slate-100">
{post.description} {post.description}
</p> </Card.Description>
)} )}
</div> </Card.Content>
</Card>
</article> </article>
); );
} }

View File

@@ -4,6 +4,7 @@ import { Post } from 'contentlayer2/generated';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { FiCalendar, FiTag } from 'react-icons/fi'; import { FiCalendar, FiTag } from 'react-icons/fi';
import { MetaItem } from './meta-item'; import { MetaItem } from './meta-item';
import { Card } from '@heroui/react';
interface Props { interface Props {
post: Post; post: Post;
@@ -19,7 +20,11 @@ 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 (
<article className="motion-card group relative flex gap-4 rounded-2xl border border-white/40 bg-white/60 p-5 shadow-lg backdrop-blur-md transition-all hover:scale-[1.01] hover:shadow-xl dark:border-white/10 dark:bg-slate-900/60"> <article>
<Card
variant="transparent"
className="motion-card group relative flex gap-4 rounded-2xl border border-white/40 bg-white/60 p-5 shadow-lg backdrop-blur-md transition-all hover:scale-[1.01] hover:shadow-xl dark:border-white/10 dark:bg-slate-900/60"
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" /> <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="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">
@@ -34,7 +39,7 @@ export function PostListItem({ post }: Props) {
/> />
</div> </div>
)} )}
<div className="flex-1 space-y-1.5"> <Card.Content className="flex-1 space-y-1.5 p-0">
<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={FiCalendar}> <MetaItem icon={FiCalendar}>
@@ -49,15 +54,18 @@ export function PostListItem({ post }: Props) {
</MetaItem> </MetaItem>
)} )}
</div> </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"> <Card.Header className="p-0">
<Link href={post.url}>{post.title}</Link> <Card.Title className="text-base font-semibold leading-snug text-slate-900 hover:text-[var(--color-accent)] sm:text-lg dark:text-slate-50 dark:hover:text-[var(--color-accent)]">
</h2> <Link href={post.url}>{post.title}</Link>
</Card.Title>
</Card.Header>
{excerpt && ( {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"> <Card.Description 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} {excerpt}
</p> </Card.Description>
)} )}
</div> </Card.Content>
</Card>
</article> </article>
); );
} }

View File

@@ -1,11 +1,12 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Post, Page } from 'contentlayer2/generated'; import { Post } from 'contentlayer2/generated';
import { FiArrowDown, FiArrowUp, FiSearch, FiList } from 'react-icons/fi'; import { FiArrowDown, FiArrowUp, FiSearch, FiList } from 'react-icons/fi';
import { siteConfig } from '@/lib/config'; import { siteConfig } from '@/lib/config';
import { PostListItem } from './post-list-item'; import { PostListItem } from './post-list-item';
import { TimelineWrapper } from './timeline-wrapper'; import { TimelineWrapper } from './timeline-wrapper';
import { Button, Input } from '@heroui/react';
interface Props { interface Props {
posts: Post[]; posts: Post[];
@@ -79,28 +80,30 @@ export function PostListWithControls({ posts, pageSize }: Props) {
<div className="inline-flex items-center gap-2 rounded-full bg-slate-100/70 px-2 py-1 text-slate-600 dark:bg-slate-800/70 dark:text-slate-300"> <div className="inline-flex items-center gap-2 rounded-full bg-slate-100/70 px-2 py-1 text-slate-600 dark:bg-slate-800/70 dark:text-slate-300">
<FiList className="h-3.5 w-3.5" /> <FiList className="h-3.5 w-3.5" />
<span></span> <span></span>
<button <Button
type="button" size="sm"
onClick={() => handleChangeSort('new')} variant={sortOrder === 'new' ? 'primary' : 'ghost'}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 transition duration-180 ease-snappy ${sortOrder === 'new' onPress={() => handleChangeSort('new')}
className={`h-auto rounded-full px-2 py-0.5 text-xs ${sortOrder === 'new'
? 'bg-blue-600 text-white dark:bg-blue-500' ? 'bg-blue-600 text-white dark:bg-blue-500'
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700' : 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
}`} }`}
> >
<FiArrowDown className="h-3 w-3" /> <FiArrowDown className="h-3 w-3" />
</button> </Button>
<button <Button
type="button" size="sm"
onClick={() => handleChangeSort('old')} variant={sortOrder === 'old' ? 'primary' : 'ghost'}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 transition duration-180 ease-snappy ${sortOrder === 'old' onPress={() => handleChangeSort('old')}
className={`h-auto rounded-full px-2 py-0.5 text-xs ${sortOrder === 'old'
? 'bg-blue-600 text-white dark:bg-blue-500' ? 'bg-blue-600 text-white dark:bg-blue-500'
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700' : 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
}`} }`}
> >
<FiArrowUp className="h-3 w-3" /> <FiArrowUp className="h-3 w-3" />
</button> </Button>
</div> </div>
<div className="flex w-full items-center text-sm sm:w-auto"> <div className="flex w-full items-center text-sm sm:w-auto">
<label htmlFor="post-search" className="sr-only"> <label htmlFor="post-search" className="sr-only">
@@ -108,7 +111,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
</label> </label>
<div className="relative w-full sm:w-64"> <div className="relative w-full sm:w-64">
<FiSearch <FiSearch
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" className="pointer-events-none absolute left-3 top-1/2 z-10 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
/> />
<input <input
id="post-search" id="post-search"
@@ -116,7 +119,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
placeholder="標題、標籤、摘要關鍵字" placeholder="標題、標籤、摘要關鍵字"
value={searchTerm} value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)} onChange={(event) => setSearchTerm(event.target.value)}
className="w-full rounded-full border border-slate-200 bg-white py-1.5 pl-9 pr-3 text-sm text-slate-700 shadow-sm transition duration-180 ease-snappy focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:focus:ring-blue-500" className="w-full rounded-full border border-slate-200 bg-white py-1.5 pl-9 pr-3 text-sm text-slate-700 shadow-sm transition duration-180 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:focus:ring-blue-500"
/> />
</div> </div>
</div> </div>
@@ -128,13 +131,14 @@ export function PostListWithControls({ posts, pageSize }: Props) {
{normalizedQuery && `(搜尋「${searchTerm}」)`} {normalizedQuery && `(搜尋「${searchTerm}」)`}
</p> </p>
{normalizedQuery && sortedPosts.length === 0 && ( {normalizedQuery && sortedPosts.length === 0 && (
<button <Button
type="button" variant="ghost"
onClick={() => setSearchTerm('')} size="sm"
className="text-blue-600 underline-offset-2 hover:underline dark:text-blue-400" onPress={() => setSearchTerm('')}
className="h-auto p-0 text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
> >
</button> </Button>
)} )}
</div> </div>
@@ -152,41 +156,44 @@ export function PostListWithControls({ posts, pageSize }: Props) {
{totalPages > 1 && currentPosts.length > 0 && ( {totalPages > 1 && currentPosts.length > 0 && (
<nav className="flex items-center justify-center gap-3 text-xs text-slate-600 dark:text-slate-300"> <nav className="flex items-center justify-center gap-3 text-xs text-slate-600 dark:text-slate-300">
<button <Button
type="button" variant="secondary"
onClick={() => goToPage(currentPage - 1)} size="sm"
disabled={currentPage === 1} onPress={() => goToPage(currentPage - 1)}
className="rounded border border-slate-200 px-2 py-1 disabled:opacity-40 dark:border-slate-700" isDisabled={currentPage === 1}
className="h-auto rounded border border-slate-200 px-2 py-1 text-xs disabled:opacity-40 dark:border-slate-700"
> >
</button> </Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{Array.from({ length: totalPages }).map((_, i) => { {Array.from({ length: totalPages }).map((_, i) => {
const p = i + 1; const p = i + 1;
const isActive = p === currentPage; const isActive = p === currentPage;
return ( return (
<button <Button
key={p} key={p}
type="button" variant={isActive ? 'primary' : 'ghost'}
onClick={() => goToPage(p)} size="sm"
className={`h-7 w-7 rounded text-xs ${isActive onPress={() => goToPage(p)}
className={`h-7 w-7 min-w-0 rounded p-0 text-xs ${isActive
? 'bg-blue-600 text-white dark:bg-blue-500' ? 'bg-blue-600 text-white dark:bg-blue-500'
: 'hover:bg-slate-100 dark:hover:bg-slate-800' : 'hover:bg-slate-100 dark:hover:bg-slate-800'
}`} }`}
> >
{p} {p}
</button> </Button>
); );
})} })}
</div> </div>
<button <Button
type="button" variant="secondary"
onClick={() => goToPage(currentPage + 1)} size="sm"
disabled={currentPage === totalPages} onPress={() => goToPage(currentPage + 1)}
className="rounded border border-slate-200 px-2 py-1 disabled:opacity-40 dark:border-slate-700" isDisabled={currentPage === totalPages}
className="h-auto rounded border border-slate-200 px-2 py-1 text-xs disabled:opacity-40 dark:border-slate-700"
> >
</button> </Button>
</nav> </nav>
)} )}
</div> </div>

View File

@@ -1,3 +1,5 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { FaGithub, FaMastodon, FaLinkedin } from 'react-icons/fa'; import { FaGithub, FaMastodon, FaLinkedin } from 'react-icons/fa';
@@ -6,6 +8,7 @@ import { siteConfig } from '@/lib/config';
import { getAllTagsWithCount } from '@/lib/posts'; import { getAllTagsWithCount } from '@/lib/posts';
import { allPages } from 'contentlayer2/generated'; import { allPages } from 'contentlayer2/generated';
import { MastodonFeed } from './mastodon-feed'; import { MastodonFeed } from './mastodon-feed';
import { Card, Avatar, Chip } from '@heroui/react';
export function RightSidebar() { export function RightSidebar() {
const tags = getAllTagsWithCount().slice(0, 5); const tags = getAllTagsWithCount().slice(0, 5);
@@ -35,38 +38,41 @@ export function RightSidebar() {
icon: FaLinkedin, icon: FaLinkedin,
label: 'LinkedIn' label: 'LinkedIn'
} }
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[]; ].filter(Boolean) as { key: string; href: string; icon: React.ComponentType<{ className?: string }>; label: string }[];
return ( return (
<aside className="hidden 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"> <Card className="motion-card group relative overflow-hidden rounded-xl border px-4 py-4 dark:border-slate-800">
<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" />
<div className="pointer-events-none absolute -bottom-12 right-[-2.5rem] h-28 w-28 rounded-full bg-indigo-300/30 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/20" /> <div className="pointer-events-none absolute -bottom-12 right-[-2.5rem] h-28 w-28 rounded-full bg-indigo-300/30 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/20" />
<div className="relative flex flex-col items-center"> <Card.Content className="relative flex flex-col items-center p-0">
<Link <Link
href={aboutPage?.url || '/pages/關於作者'} href={aboutPage?.url || '/pages/關於作者'}
aria-label="關於作者" aria-label="關於作者"
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 ? ( <Avatar className="h-24 w-24 border border-slate-200 shadow-sm transition-transform duration-300 ease-out group-hover:scale-105 dark:border-slate-700">
<Image {avatarSrc ? (
src={avatarSrc} <Avatar.Image asChild>
alt={siteConfig.name} <Image
width={96} src={avatarSrc}
height={96} alt={siteConfig.name}
unoptimized width={96}
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" height={96}
/> unoptimized
) : ( className="h-full w-full object-cover"
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-slate-900 text-lg font-semibold text-slate-50 shadow-sm transition-transform duration-300 ease-out group-hover:scale-105 dark:bg-slate-100 dark:text-slate-900"> />
</Avatar.Image>
) : null}
<Avatar.Fallback className="text-lg font-semibold">
{siteConfig.name.charAt(0).toUpperCase()} {siteConfig.name.charAt(0).toUpperCase()}
</div> </Avatar.Fallback>
)} </Avatar>
</Link> </Link>
{socialItems.length > 0 && ( {socialItems.length > 0 && (
<div className="mt-2 flex items-center gap-3 text-lg text-accent-textLight dark:text-accent-textDark"> <div className="mt-2 flex items-center gap-3 text-lg text-[var(--color-accent-text-light)] dark:text-[var(--color-accent-text-dark)]">
{socialItems.map((item) => ( {socialItems.map((item) => (
<a <a
key={item.key} key={item.key}
@@ -74,7 +80,7 @@ export function RightSidebar() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label={item.label} aria-label={item.label}
className="motion-link inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent dark:bg-slate-800 dark:text-slate-200" className="motion-link inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:-translate-y-0.5 hover:bg-[var(--color-accent-soft)] hover:text-[var(--color-accent)] dark:bg-slate-800 dark:text-slate-200"
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
</a> </a>
@@ -88,48 +94,55 @@ export function RightSidebar() {
))} ))}
</div> </div>
)} )}
</div> </Card.Content>
</section> </Card>
{/* Mastodon Feed */} {/* Mastodon Feed */}
<MastodonFeed /> <MastodonFeed />
{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"> <Card className="motion-card rounded-xl border px-4 py-3 dark:border-slate-800">
<h2 className="type-small flex items-center gap-2 font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400"> <Card.Header className="p-0">
<FiTrendingUp className="h-3 w-3 text-orange-400" /> <Card.Title className="type-small flex items-center gap-2 font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
<FiTrendingUp className="h-3 w-3 text-orange-400" />
</h2>
<div className="mt-2 flex flex-wrap gap-2 text-base"> </Card.Title>
{tags.map(({ tag, slug, count }) => { </Card.Header>
let sizeClass = ''; <Card.Content className="p-0">
if (count >= 5) sizeClass = 'font-semibold'; <div className="mt-2 flex flex-wrap gap-2 text-base">
else if (count >= 3) sizeClass = 'font-medium'; {tags.map(({ tag, slug, count }) => {
let fontWeight = 'font-normal';
if (count >= 5) fontWeight = 'font-semibold';
else if (count >= 3) fontWeight = 'font-medium';
return ( return (
<Link <Link key={tag} href={`/tags/${slug}`}>
key={tag} <Chip
href={`/tags/${slug}`} color="accent"
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`} variant="soft"
> size="sm"
{tag} className={`${fontWeight} tag-chip cursor-pointer rounded-full bg-[var(--color-accent-soft)] px-2 py-0.5 text-[var(--color-accent-text-light)] transition hover:bg-[var(--color-accent)] hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`}
</Link> >
); {tag}
})} </Chip>
</div> </Link>
<div className="mt-3 flex items-center justify-between type-small text-slate-500 dark:text-slate-400"> );
})}
</div>
</Card.Content>
<Card.Footer className="mt-3 flex items-center justify-between p-0 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">
<FiArrowRight className="h-3 w-3" /> <FiArrowRight className="h-3 w-3" />
</span> </span>
<Link <Link
href="/tags" href="/tags"
className="motion-link text-accent-textLight hover:text-accent dark:text-accent-textDark" className="motion-link text-[var(--color-accent-text-light)] hover:text-[var(--color-accent)] dark:text-[var(--color-accent-text-dark)]"
> >
</Link> </Link>
</div> </Card.Footer>
</section> </Card>
)} )}
</div> </div>
</aside> </aside>

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { FiSearch, FiX } from 'react-icons/fi'; import { FiSearch, FiX } from 'react-icons/fi';
import { Modal, Button, Spinner } from '@heroui/react';
interface SearchModalProps { interface SearchModalProps {
isOpen: boolean; isOpen: boolean;
@@ -95,87 +95,58 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
}; };
}, [isOpen]); }, [isOpen]);
useEffect(() => { return (
const handleEscape = (e: KeyboardEvent) => { <Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()}>
if (e.key === 'Escape' && isOpen) { <Modal.Backdrop variant="blur">
onClose(); <Modal.Container placement="top" size="lg" className="pt-20">
} <Modal.Dialog className="w-full max-w-3xl rounded-2xl border border-white/40 bg-white/95 shadow-2xl backdrop-blur-md dark:border-white/10 dark:bg-slate-900/95">
}; {/* Header */}
<Modal.Header className="flex items-center justify-between border-b border-slate-200 px-6 py-4 dark:border-slate-700">
document.addEventListener('keydown', handleEscape); <div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
return () => document.removeEventListener('keydown', handleEscape); <FiSearch className="h-5 w-5" />
}, [isOpen, onClose]); <Modal.Heading className="text-sm font-medium"></Modal.Heading>
useEffect(() => {
// Prevent body scroll when modal is open
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
// Use portal to render modal at document body level to avoid z-index stacking context issues
if (typeof window === 'undefined') return null;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-start justify-center bg-black/50 backdrop-blur-sm pt-20 px-4"
onClick={onClose}
>
<div
className="w-full max-w-3xl rounded-2xl border border-white/40 bg-white/95 shadow-2xl backdrop-blur-md dark:border-white/10 dark:bg-slate-900/95"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4 dark:border-slate-700">
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<FiSearch className="h-5 w-5" />
<span className="text-sm font-medium"></span>
</div>
<button
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
aria-label="關閉搜尋"
>
<FiX className="h-5 w-5" />
</button>
</div>
{/* Search Container */}
<div className="max-h-[60vh] overflow-y-auto p-6">
<div
ref={searchContainerRef}
className="pagefind-search"
data-pagefind-ui
/>
{!isLoaded && (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-500 border-r-transparent"></div>
<p className="mt-4 text-sm text-slate-500 dark:text-slate-400">
...
</p>
</div> </div>
</div> <Button
)} isIconOnly
</div> variant="ghost"
onPress={onClose}
className="h-8 w-8 rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
aria-label="關閉搜尋"
>
<FiX className="h-5 w-5" />
</Button>
</Modal.Header>
{/* Footer */} {/* Search Container */}
<div className="border-t border-slate-200 px-6 py-3 text-xs text-slate-500 dark:border-slate-700 dark:text-slate-400"> <Modal.Body className="max-h-[60vh] overflow-y-auto p-6">
<div className="flex items-center justify-between"> <div
<span> ESC </span> ref={searchContainerRef}
<span className="text-right"></span> className="pagefind-search"
</div> data-pagefind-ui
</div> />
</div> {!isLoaded && (
</div>, <div className="flex items-center justify-center py-12">
document.body <div className="text-center">
<Spinner size="lg" color="accent" />
<p className="mt-4 text-sm text-slate-500 dark:text-slate-400">
...
</p>
</div>
</div>
)}
</Modal.Body>
{/* Footer */}
<Modal.Footer className="border-t border-slate-200 px-6 py-3 text-xs text-slate-500 dark:border-slate-700 dark:text-slate-400">
<div className="flex w-full items-center justify-between">
<span> ESC </span>
<span className="text-right"></span>
</div>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
); );
} }
@@ -193,9 +164,10 @@ export function SearchButton({ onClick }: { onClick: () => void }) {
}, [onClick]); }, [onClick]);
return ( return (
<button <Button
onClick={onClick} variant="ghost"
className="motion-link inline-flex h-9 items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700" onPress={onClick}
className="motion-link h-9 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
aria-label="搜尋 (Cmd+K)" aria-label="搜尋 (Cmd+K)"
> >
<FiSearch className="h-3.5 w-3.5" /> <FiSearch className="h-3.5 w-3.5" />
@@ -203,6 +175,6 @@ export function SearchButton({ onClick }: { onClick: () => void }) {
<kbd className="hidden rounded bg-white px-1.5 py-0.5 text-xs font-semibold text-slate-500 shadow-sm dark:bg-slate-900 dark:text-slate-400 sm:inline-block"> <kbd className="hidden rounded bg-white px-1.5 py-0.5 text-xs font-semibold text-slate-500 shadow-sm dark:bg-slate-900 dark:text-slate-400 sm:inline-block">
K K
</kbd> </kbd>
</button> </Button>
); );
} }

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { FiMoon, FiSun } from 'react-icons/fi'; import { FiMoon, FiSun } from 'react-icons/fi';
import { Button } from '@heroui/react';
export function ThemeToggle() { export function ThemeToggle() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -20,17 +21,18 @@ export function ThemeToggle() {
const isDark = theme === 'dark'; const isDark = theme === 'dark';
return ( return (
<button <Button
type="button" isIconOnly
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-accent-textLight transition duration-180 ease-snappy hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-accent-textDark dark:hover:bg-slate-800 dark:hover:text-accent" variant="ghost"
onClick={() => setTheme(next)} className="h-9 w-9 rounded-full text-[var(--color-accent-text-light)] transition duration-180 hover:-translate-y-0.5 hover:bg-[var(--color-accent-soft)] hover:text-[var(--color-accent)] active:scale-95 dark:text-[var(--color-accent-text-dark)] dark:hover:bg-slate-800 dark:hover:text-[var(--color-accent)]"
onPress={() => setTheme(next)}
aria-label={theme === 'dark' ? '切換為淺色主題' : '切換為深色主題'} aria-label={theme === 'dark' ? '切換為淺色主題' : '切換為深色主題'}
> >
{isDark ? ( {isDark ? (
<FiSun className="h-4 w-4 rotate-0 text-amber-400 transition-transform duration-260 ease-snappy" /> <FiSun className="h-4 w-4 rotate-0 text-amber-400 transition-transform duration-260" />
) : ( ) : (
<FiMoon className="h-4 w-4 rotate-180 text-blue-500 transition-transform duration-260 ease-snappy" /> <FiMoon className="h-4 w-4 rotate-180 text-blue-500 transition-transform duration-260" />
)} )}
</button> </Button>
); );
} }

3685
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,9 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@emotion/is-prop-valid": "^1.4.0", "@emotion/is-prop-valid": "^1.4.0",
"@heroui/react": "^3.0.0-beta.5",
"@heroui/styles": "^3.0.0-beta.5",
"@tailwindcss/postcss": "^4.1.18",
"@vercel/og": "^0.8.5", "@vercel/og": "^0.8.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"contentlayer2": "^0.5.8", "contentlayer2": "^0.5.8",
@@ -35,20 +38,19 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shiki": "^3.15.0", "shiki": "^3.15.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"unist-util-visit": "^5.0.0" "unist-util-visit": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.22",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.3", "eslint-config-next": "^16.0.3",
"pagefind": "^1.4.0", "pagefind": "^1.4.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.18", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -1,8 +1,50 @@
@tailwind base; /* Tailwind CSS v4 - Must be first */
@tailwind components; @import "tailwindcss";
@tailwind utilities;
/* HeroUI v3 styles - Must be after Tailwind */
@import "@heroui/styles";
/* Tailwind v4 CSS-first theme configuration */
@theme {
/* Custom colors */
--color-accent: var(--color-accent);
--color-accent-soft: var(--color-accent-soft);
--color-accent-text-light: var(--color-accent-text-light);
--color-accent-text-dark: var(--color-accent-text-dark);
/* Custom font families */
--font-family-serif-eng: var(--font-serif-eng), serif;
--font-family-serif-cn: "Songti SC", "Noto Serif TC", "SimSun", serif;
/* Custom transition timing */
--ease-snappy: cubic-bezier(0.32, 0.72, 0, 1);
/* Custom transition durations */
--duration-180: 180ms;
--duration-260: 260ms;
/* Custom box shadows */
--shadow-lifted: 0 12px 30px -14px rgba(15, 23, 42, 0.25);
--shadow-outline: 0 0 0 1px rgba(59, 130, 246, 0.25);
/* Custom keyframes */
--animate-fade-in-up: fade-in-up 0.6s ease-out both;
--animate-float-soft: float-soft 12s ease-in-out infinite;
}
@keyframes fade-in-up {
0% { opacity: 0; transform: translateY(8px) scale(0.98); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes float-soft {
0% { transform: translate3d(0,0,0) scale(1); }
50% { transform: translate3d(4px,-6px,0) scale(1.03); }
100% { transform: translate3d(0,0,0) scale(1); }
}
:root { :root {
/* Motion & layout variables */
--motion-duration-short: 180ms; --motion-duration-short: 180ms;
--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);
@@ -13,22 +55,33 @@
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-system-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'PingFang TC', 'PingFang SC', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft JhengHei', 'Microsoft YaHei', 'Noto Sans TC', 'Noto Sans SC', 'Noto Sans CJK TC', 'Noto Sans CJK SC', 'Source Han Sans TC', 'Source Han Sans SC', 'Roboto', 'Ubuntu', 'Cantarell', 'Inter', 'Helvetica Neue', Arial, sans-serif; --font-system-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'PingFang TC', 'PingFang SC', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft JhengHei', 'Microsoft YaHei', 'Noto Sans TC', 'Noto Sans SC', 'Noto Sans CJK TC', 'Noto Sans CJK SC', 'Source Han Sans TC', 'Source Han Sans SC', 'Roboto', 'Ubuntu', 'Cantarell', 'Inter', 'Helvetica Neue', Arial, sans-serif;
/* Ink + accent palette */ /* Ink + accent palette - Light mode */
--color-ink-strong: #0f172a; --color-ink-strong: #0f172a;
--color-ink-body: #1f2937; --color-ink-body: #1f2937;
--color-ink-muted: #475569; --color-ink-muted: #475569;
--color-accent: #7c3aed; --color-accent: #7c3aed;
--color-accent-soft: #f4f0ff; --color-accent-soft: #f4f0ff;
--color-accent-text-light: #7c3aed;
--color-accent-text-dark: #a78bfa;
/* Override HeroUI accent with site purple (oklch conversion) */
--accent: oklch(0.55 0.23 290); /* #7c3aed equivalent */
--accent-foreground: white;
font-size: clamp(15px, 0.65vw + 11px, 19px); font-size: clamp(15px, 0.65vw + 11px, 19px);
} }
.dark { .dark, [data-theme="dark"] {
--color-ink-strong: #e2e8f0; --color-ink-strong: #e2e8f0;
--color-ink-body: #cbd5e1; --color-ink-body: #cbd5e1;
--color-ink-muted: #94a3b8; --color-ink-muted: #94a3b8;
--color-accent: #a78bfa; --color-accent: #a78bfa;
--color-accent-soft: #1f1a3d; --color-accent-soft: #1f1a3d;
--color-accent-text-light: #a78bfa;
--color-accent-text-dark: #a78bfa;
/* Override HeroUI accent for dark mode */
--accent: oklch(0.72 0.16 290); /* #a78bfa equivalent */
} }
@media (min-width: 2560px) { @media (min-width: 2560px) {
@@ -38,13 +91,22 @@
} }
body { body {
@apply bg-white text-gray-900 transition-colors duration-200 ease-snappy dark:bg-gray-950 dark:text-gray-100; background-color: white;
color: #111827;
transition-property: color, background-color;
transition-duration: 200ms;
transition-timing-function: var(--motion-ease-snappy);
font-size: 1rem; font-size: 1rem;
line-height: var(--line-height-body); line-height: var(--line-height-body);
font-family: var(--font-system-sans); font-family: var(--font-system-sans);
color: var(--color-ink-body); color: var(--color-ink-body);
} }
.dark body {
background-color: #030712;
color: #f3f4f6;
}
@keyframes timeline-scroll { @keyframes timeline-scroll {
0% { 0% {
transform: translate(-50%, -10%); transform: translate(-50%, -10%);
@@ -104,49 +166,16 @@ body {
.toc-target-highlight { .toc-target-highlight {
@apply bg-yellow-50/60 dark:bg-yellow-900/40 transition-colors duration-500; background-color: rgb(254 252 232 / 0.6);
transition-property: color, background-color;
transition-duration: 500ms;
} }
/* Subtle hover for article elements */ .dark .toc-target-highlight {
.prose blockquote { background-color: rgb(113 63 18 / 0.4);
@apply transition-transform transition-shadow duration-180 ease-snappy;
border-left: 4px solid var(--color-accent, #2563eb);
background: linear-gradient(135deg, rgba(124, 58, 237, 0.06), rgba(124, 58, 237, 0.1));
padding: 1.2rem 1.5rem;
font-style: italic;
color: rgba(15, 23, 42, 0.78);
position: relative;
}
.dark .prose blockquote {
background: linear-gradient(135deg, rgba(167, 139, 250, 0.12), rgba(124, 58, 237, 0.08));
color: rgba(226, 232, 240, 0.85);
border-left-color: rgba(167, 139, 250, 0.9);
}
.prose blockquote:hover {
@apply -translate-y-0.5 shadow-sm;
}
.prose blockquote::before {
content: '“';
position: absolute;
top: 0.5rem;
left: 0.8rem;
font-size: 3rem;
font-family: 'Times New Roman', 'Noto Serif TC', serif;
color: rgba(37, 99, 235, 0.25);
pointer-events: none;
}
.prose pre {
@apply transition-transform transition-shadow duration-180 ease-snappy;
}
.prose pre:hover {
@apply -translate-y-0.5 shadow-md;
} }
/* Prose typography styles (replaces @tailwindcss/typography) */
.prose { .prose {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.2rem); font-size: clamp(1rem, 0.2vw + 0.9rem, 1.2rem);
line-height: var(--line-height-body); line-height: var(--line-height-body);
@@ -157,18 +186,40 @@ body {
font-size: clamp(2.2rem, 1.4rem + 2.2vw, 3.4rem); font-size: clamp(2.2rem, 1.4rem + 2.2vw, 3.4rem);
line-height: 1.25; line-height: 1.25;
color: var(--color-ink-strong); color: var(--color-ink-strong);
font-weight: 700;
letter-spacing: -0.03em;
font-family: var(--font-serif-eng), "Songti SC", serif;
margin-top: 0;
margin-bottom: 0.8em;
} }
.prose h2 { .prose h2 {
font-size: clamp(1.8rem, 1.1rem + 1.6vw, 2.8rem); font-size: clamp(1.8rem, 1.1rem + 1.6vw, 2.8rem);
line-height: 1.3; line-height: 1.3;
color: var(--color-ink-strong); color: var(--color-ink-strong);
font-weight: 600;
letter-spacing: -0.02em;
font-family: var(--font-serif-eng), "Songti SC", serif;
margin-top: 1.5em;
margin-bottom: 0.5em;
} }
.prose h3 { .prose h3 {
font-size: clamp(1.4rem, 0.9rem + 1vw, 2rem); font-size: clamp(1.4rem, 0.9rem + 1vw, 2rem);
line-height: 1.35; line-height: 1.35;
color: var(--color-ink-strong); color: var(--color-ink-strong);
font-weight: 600;
font-family: var(--font-serif-eng), "Songti SC", serif;
margin-top: 1.3em;
margin-bottom: 0.4em;
}
.prose h4, .prose h5, .prose h6 {
color: var(--color-ink-strong);
font-weight: 600;
font-family: var(--font-serif-eng), "Songti SC", serif;
margin-top: 1.2em;
margin-bottom: 0.4em;
} }
.prose p, .prose p,
@@ -176,6 +227,58 @@ body {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem); font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem);
line-height: var(--line-height-body); line-height: var(--line-height-body);
color: var(--color-ink-body); color: var(--color-ink-body);
margin-top: 0;
margin-bottom: 1em;
}
.prose ul, .prose ol {
padding-left: 1.5em;
margin-top: 1em;
margin-bottom: 1em;
}
.prose li {
margin-bottom: 0.5em;
}
.prose a {
color: var(--color-accent-text-light);
text-decoration: underline;
text-underline-offset: 2px;
transition: color var(--motion-duration-short) var(--motion-ease-snappy);
}
.prose a:hover {
color: var(--color-accent);
}
.dark .prose a {
color: var(--color-accent-text-dark);
}
.dark .prose a:hover {
color: var(--color-accent);
}
.prose strong, .prose b {
font-weight: 700;
color: var(--color-ink-strong);
}
.prose em {
font-style: italic;
}
.dark .prose {
color: #e2e8f0;
}
.dark .prose strong, .dark .prose b {
color: #f8fafc;
}
.dark .prose em {
color: #f1f5f9;
} }
.prose small, .prose small,
@@ -193,6 +296,205 @@ body {
color: inherit !important; color: inherit !important;
} }
/* Blockquote styles */
.prose blockquote {
transition-property: transform, box-shadow;
transition-duration: 180ms;
transition-timing-function: var(--motion-ease-snappy);
border-left: 4px solid var(--color-accent, #2563eb);
background: linear-gradient(135deg, rgba(124, 58, 237, 0.06), rgba(124, 58, 237, 0.1));
padding: 1.2rem 1.5rem;
font-style: italic;
color: rgba(15, 23, 42, 0.78);
position: relative;
margin: 1.5em 0;
border-radius: 0 0.5rem 0.5rem 0;
}
.dark .prose blockquote {
background: linear-gradient(135deg, rgba(167, 139, 250, 0.12), rgba(124, 58, 237, 0.08));
color: rgba(226, 232, 240, 0.85);
border-left-color: rgba(167, 139, 250, 0.9);
}
.prose blockquote:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.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 blockquote p {
margin: 0;
}
/* Pre/code block styles */
.prose pre {
transition-property: transform, box-shadow;
transition-duration: 180ms;
transition-timing-function: var(--motion-ease-snappy);
overflow-x: auto;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
padding: 1rem 1.2rem;
margin: 1.5rem 0;
background-color: #f8fafc;
}
.dark .prose pre {
border-color: #334155;
background-color: #0f172a;
}
.prose pre:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.prose pre > code {
display: grid;
counter-reset: line;
font-size: 0.9em;
line-height: 1.7;
}
.prose pre > code > [data-line] {
padding: 0 1rem;
border-left: 2px solid transparent;
}
.prose pre > code > [data-line]::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 1.5rem;
margin-right: 1.5rem;
text-align: right;
color: #94a3b8;
user-select: none;
}
.dark .prose pre > code > [data-line]::before {
color: #475569;
}
/* Highlighted lines */
.prose pre > code > [data-highlighted-line] {
background-color: rgba(59, 130, 246, 0.1);
border-left-color: rgb(59, 130, 246);
}
.dark .prose pre > code > [data-highlighted-line] {
background-color: rgba(96, 165, 250, 0.15);
border-left-color: rgb(96, 165, 250);
}
/* Inline code */
.prose :not(pre) > code {
border-radius: 0.25rem;
background-color: #f1f5f9;
padding: 0.125rem 0.375rem;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
}
.dark .prose :not(pre) > code {
background-color: #1e293b;
color: #e2e8f0;
}
/* Code title (if specified in markdown: ```js title="example.js") */
.prose [data-rehype-pretty-code-title] {
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
border: 1px solid #e2e8f0;
border-bottom: 0;
background-color: #f1f5f9;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: #475569;
margin-bottom: 0;
}
.dark .prose [data-rehype-pretty-code-title] {
border-color: #334155;
background-color: #1e293b;
color: #cbd5e1;
}
.prose [data-rehype-pretty-code-title] + pre {
margin-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* Horizontal rule */
.prose hr {
border: 0;
border-top: 1px solid #e2e8f0;
margin: 2em 0;
}
.dark .prose hr {
border-top-color: #334155;
}
/* Tables */
.prose table {
width: 100%;
border-collapse: collapse;
margin: 1.5em 0;
}
.prose th, .prose td {
border: 1px solid #e2e8f0;
padding: 0.75em 1em;
text-align: left;
}
.prose th {
background-color: #f8fafc;
font-weight: 600;
}
.dark .prose th, .dark .prose td {
border-color: #334155;
}
.dark .prose th {
background-color: #1e293b;
}
/* Images */
.prose img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1.5em 0;
}
.prose figure {
margin: 1.5em 0;
}
.prose figcaption {
text-align: center;
color: var(--color-ink-muted);
margin-top: 0.5em;
}
.hero-title { .hero-title {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@@ -436,112 +738,69 @@ body {
/* Additional custom styling for highlights */ /* Additional custom styling for highlights */
.pagefind-ui__result-excerpt mark { .pagefind-ui__result-excerpt mark {
@apply bg-yellow-200 font-semibold text-slate-900 dark:bg-yellow-600 dark:text-slate-100; background-color: #fef08a;
font-weight: 600;
color: #0f172a;
padding: 0.125rem 0.25rem; padding: 0.125rem 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.dark .pagefind-ui__result-excerpt mark {
background-color: #ca8a04;
color: #f1f5f9;
}
.pagefind-ui__search-input:focus { .pagefind-ui__search-input:focus {
@apply ring-2 ring-blue-500 dark:ring-blue-400; outline: none;
ring: 2px;
ring-color: #3b82f6;
} }
/* Code Syntax Highlighting Styles (rehype-pretty-code) */ .dark .pagefind-ui__search-input:focus {
.prose pre { ring-color: #60a5fa;
@apply overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700;
padding: 1rem 1.2rem;
margin: 1.5rem 0;
background-color: #f8fafc;
}
.dark .prose pre {
background-color: #0f172a;
}
.prose pre > code {
@apply grid;
counter-reset: line;
font-size: 0.9em;
line-height: 1.7;
}
.prose pre > code > [data-line] {
padding: 0 1rem;
border-left: 2px solid transparent;
}
.prose pre > code > [data-line]::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 1.5rem;
margin-right: 1.5rem;
text-align: right;
color: #94a3b8;
user-select: none;
}
.dark .prose pre > code > [data-line]::before {
color: #475569;
}
/* Highlighted lines */
.prose pre > code > [data-highlighted-line] {
background-color: rgba(59, 130, 246, 0.1);
border-left-color: rgb(59, 130, 246);
}
.dark .prose pre > code > [data-highlighted-line] {
background-color: rgba(96, 165, 250, 0.15);
border-left-color: rgb(96, 165, 250);
}
/* Inline code */
.prose :not(pre) > code {
@apply rounded bg-slate-100 px-1.5 py-0.5 text-sm font-semibold text-slate-800 dark:bg-slate-800 dark:text-slate-200;
white-space: nowrap;
}
/* Code title (if specified in markdown: ```js title="example.js") */
.prose [data-rehype-pretty-code-title] {
@apply rounded-t-lg border border-b-0 border-slate-200 bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300;
margin-bottom: 0;
}
.prose [data-rehype-pretty-code-title] + pre {
@apply mt-0 rounded-t-none;
} }
/* GitHub-style Callouts/Alerts */ /* GitHub-style Callouts/Alerts */
.prose .callout { .prose .callout {
@apply my-6 rounded-lg border-l-4 p-4 shadow-sm; margin: 1.5rem 0;
border-radius: 0.5rem;
border-left-width: 4px;
padding: 1rem;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
background: linear-gradient(135deg, var(--callout-bg-start), var(--callout-bg-end)); background: linear-gradient(135deg, var(--callout-bg-start), var(--callout-bg-end));
} }
.prose .callout-header { .prose .callout-header {
@apply mb-3 flex items-center gap-2; margin-bottom: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
} }
.prose .callout-icon { .prose .callout-icon {
@apply text-2xl; font-size: 1.5rem;
line-height: 1; line-height: 1;
} }
.prose .callout-title { .prose .callout-title {
@apply text-sm font-bold uppercase tracking-wider; font-size: 0.875rem;
color: var(--callout-title-color); font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--callout-title-color);
} }
.prose .callout-content { .prose .callout-content {
@apply text-sm leading-relaxed; font-size: 0.875rem;
line-height: 1.625;
} }
.prose .callout-content > *:first-child { .prose .callout-content > *:first-child {
@apply mt-0; margin-top: 0;
} }
.prose .callout-content > *:last-child { .prose .callout-content > *:last-child {
@apply mb-0; margin-bottom: 0;
} }
/* NOTE - Blue */ /* NOTE - Blue */
@@ -549,14 +808,14 @@ body {
--callout-bg-start: rgba(59, 130, 246, 0.08); --callout-bg-start: rgba(59, 130, 246, 0.08);
--callout-bg-end: rgba(59, 130, 246, 0.04); --callout-bg-end: rgba(59, 130, 246, 0.04);
--callout-title-color: #2563eb; --callout-title-color: #2563eb;
@apply border-blue-500; border-left-color: #3b82f6;
} }
.dark .prose .callout-note { .dark .prose .callout-note {
--callout-bg-start: rgba(96, 165, 250, 0.12); --callout-bg-start: rgba(96, 165, 250, 0.12);
--callout-bg-end: rgba(96, 165, 250, 0.06); --callout-bg-end: rgba(96, 165, 250, 0.06);
--callout-title-color: #93c5fd; --callout-title-color: #93c5fd;
@apply border-blue-400; border-left-color: #60a5fa;
} }
/* TIP - Green */ /* TIP - Green */
@@ -564,14 +823,14 @@ body {
--callout-bg-start: rgba(34, 197, 94, 0.08); --callout-bg-start: rgba(34, 197, 94, 0.08);
--callout-bg-end: rgba(34, 197, 94, 0.04); --callout-bg-end: rgba(34, 197, 94, 0.04);
--callout-title-color: #16a34a; --callout-title-color: #16a34a;
@apply border-green-500; border-left-color: #22c55e;
} }
.dark .prose .callout-tip { .dark .prose .callout-tip {
--callout-bg-start: rgba(74, 222, 128, 0.12); --callout-bg-start: rgba(74, 222, 128, 0.12);
--callout-bg-end: rgba(74, 222, 128, 0.06); --callout-bg-end: rgba(74, 222, 128, 0.06);
--callout-title-color: #86efac; --callout-title-color: #86efac;
@apply border-green-400; border-left-color: #4ade80;
} }
/* IMPORTANT - Purple */ /* IMPORTANT - Purple */
@@ -579,14 +838,14 @@ body {
--callout-bg-start: rgba(168, 85, 247, 0.08); --callout-bg-start: rgba(168, 85, 247, 0.08);
--callout-bg-end: rgba(168, 85, 247, 0.04); --callout-bg-end: rgba(168, 85, 247, 0.04);
--callout-title-color: #9333ea; --callout-title-color: #9333ea;
@apply border-purple-500; border-left-color: #a855f7;
} }
.dark .prose .callout-important { .dark .prose .callout-important {
--callout-bg-start: rgba(192, 132, 252, 0.12); --callout-bg-start: rgba(192, 132, 252, 0.12);
--callout-bg-end: rgba(192, 132, 252, 0.06); --callout-bg-end: rgba(192, 132, 252, 0.06);
--callout-title-color: #c084fc; --callout-title-color: #c084fc;
@apply border-purple-400; border-left-color: #c084fc;
} }
/* WARNING - Orange/Yellow */ /* WARNING - Orange/Yellow */
@@ -594,14 +853,14 @@ body {
--callout-bg-start: rgba(251, 191, 36, 0.08); --callout-bg-start: rgba(251, 191, 36, 0.08);
--callout-bg-end: rgba(251, 191, 36, 0.04); --callout-bg-end: rgba(251, 191, 36, 0.04);
--callout-title-color: #d97706; --callout-title-color: #d97706;
@apply border-yellow-500; border-left-color: #f59e0b;
} }
.dark .prose .callout-warning { .dark .prose .callout-warning {
--callout-bg-start: rgba(253, 224, 71, 0.12); --callout-bg-start: rgba(253, 224, 71, 0.12);
--callout-bg-end: rgba(253, 224, 71, 0.06); --callout-bg-end: rgba(253, 224, 71, 0.06);
--callout-title-color: #fde047; --callout-title-color: #fde047;
@apply border-yellow-400; border-left-color: #fde047;
} }
/* CAUTION - Red */ /* CAUTION - Red */
@@ -609,12 +868,12 @@ body {
--callout-bg-start: rgba(239, 68, 68, 0.08); --callout-bg-start: rgba(239, 68, 68, 0.08);
--callout-bg-end: rgba(239, 68, 68, 0.04); --callout-bg-end: rgba(239, 68, 68, 0.04);
--callout-title-color: #dc2626; --callout-title-color: #dc2626;
@apply border-red-500; border-left-color: #ef4444;
} }
.dark .prose .callout-caution { .dark .prose .callout-caution {
--callout-bg-start: rgba(248, 113, 113, 0.12); --callout-bg-start: rgba(248, 113, 113, 0.12);
--callout-bg-end: rgba(248, 113, 113, 0.06); --callout-bg-end: rgba(248, 113, 113, 0.06);
--callout-title-color: #fca5a5; --callout-title-color: #fca5a5;
@apply border-red-400; border-left-color: #f87171;
} }

View File

@@ -1,126 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./content/**/*.{md,mdx}"
],
theme: {
extend: {
colors: {
accent: {
DEFAULT: 'var(--color-accent)',
soft: 'var(--color-accent-soft)',
textLight: 'var(--color-accent-text-light)',
textDark: 'var(--color-accent-text-dark)'
}
},
fontFamily: {
'serif-eng': ['var(--font-serif-eng)', 'serif'],
'serif-cn': ['"Songti SC"', '"Noto Serif TC"', '"SimSun"', 'serif'],
},
transitionTimingFunction: {
snappy: 'cubic-bezier(0.32, 0.72, 0, 1)'
},
transitionDuration: {
180: '180ms',
260: '260ms'
},
boxShadow: {
lifted: '0 12px 30px -14px rgba(15, 23, 42, 0.25)',
outline: '0 0 0 1px rgba(59, 130, 246, 0.25)'
},
keyframes: {
'fade-in-up': {
'0%': { opacity: '0', transform: 'translateY(8px) scale(0.98)' },
'100%': { opacity: '1', transform: 'translateY(0) scale(1)' }
},
'float-soft': {
'0%': { transform: 'translate3d(0,0,0) scale(1)' },
'50%': { transform: 'translate3d(4px,-6px,0) scale(1.03)' },
'100%': { transform: 'translate3d(0,0,0) scale(1)' }
}
},
animation: {
'fade-in-up': 'fade-in-up 0.6s ease-out both',
'float-soft': 'float-soft 12s ease-in-out infinite'
},
typography: (theme) => ({
DEFAULT: {
css: {
color: theme('colors.slate.700'),
a: {
color: 'var(--color-accent-text-light)',
'&:hover': {
color: 'var(--color-accent)'
}
},
h1: {
fontWeight: '700',
letterSpacing: '-0.03em',
fontFamily: 'var(--font-serif-eng), "Songti SC", serif',
},
h2: {
fontWeight: '600',
letterSpacing: '-0.02em',
fontFamily: 'var(--font-serif-eng), "Songti SC", serif',
},
blockquote: {
fontStyle: 'normal',
borderLeftColor: 'var(--color-accent-soft)',
color: theme('colors.slate.700'),
backgroundColor: theme('colors.slate.50')
},
code: {
backgroundColor: theme('colors.slate.100'),
padding: '0.15rem 0.35rem',
borderRadius: '0.25rem'
}
}
},
dark: {
css: {
// Slightly softer than pure white for body text
color: theme('colors.slate.200'),
a: {
color: 'var(--color-accent-text-dark)',
'&:hover': {
color: 'var(--color-accent)'
}
},
strong: {
color: theme('colors.slate.50'),
fontWeight: '700'
},
b: {
color: theme('colors.slate.50'),
fontWeight: '700'
},
em: {
color: theme('colors.slate.100')
},
h1: {
color: theme('colors.slate.50')
},
h2: {
color: theme('colors.slate.50')
},
blockquote: {
borderLeftColor: 'var(--color-accent)',
backgroundColor: theme('colors.slate.800'),
color: theme('colors.slate.200'),
p: {
color: theme('colors.slate.200')
}
},
code: {
backgroundColor: theme('colors.slate.800')
}
}
}
})
},
},
plugins: [require('@tailwindcss/typography')],
};