Compare commits
1 Commits
6a9296f33d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ce4245c148 |
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@heroui/react';
|
||||
|
||||
export function BackToTop() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -23,16 +22,16 @@ export function BackToTop() {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
isIconOnly
|
||||
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={() => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
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>
|
||||
</Button>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from 'react-icons/fi';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Button } from '@heroui/react';
|
||||
|
||||
export type IconKey =
|
||||
| 'home'
|
||||
@@ -36,7 +35,7 @@ export type IconKey =
|
||||
| 'device'
|
||||
| 'menu';
|
||||
|
||||
const ICON_MAP: Record<IconKey, React.ComponentType<{ className?: string }>> = {
|
||||
const ICON_MAP: Record<IconKey, any> = {
|
||||
home: FiHome,
|
||||
blog: FiFileText,
|
||||
file: FiFile,
|
||||
@@ -129,7 +128,7 @@ export function NavMenu({ items }: NavMenuProps) {
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="motion-link inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-[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"
|
||||
className="motion-link inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
onClick={close}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-slate-400" />
|
||||
@@ -146,9 +145,8 @@ export function NavMenu({ items }: NavMenuProps) {
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div key={item.key} className="flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={() => toggleMobileItem(item.key)}
|
||||
<button
|
||||
onClick={() => toggleMobileItem(item.key)}
|
||||
className="flex w-full items-center justify-between rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -158,7 +156,7 @@ export function NavMenu({ items }: NavMenuProps) {
|
||||
<FiChevronRight
|
||||
className={`h-5 w-5 text-slate-400 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</button>
|
||||
<div
|
||||
className={`grid transition-all duration-200 ease-in-out ${isExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
}`}
|
||||
@@ -189,50 +187,48 @@ export function NavMenu({ items }: NavMenuProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Menu Trigger */}
|
||||
<Button
|
||||
isIconOnly
|
||||
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"
|
||||
<button
|
||||
type="button"
|
||||
className="relative z-50 inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 sm:hidden"
|
||||
aria-label={open ? '關閉選單' : '開啟選單'}
|
||||
aria-expanded={open}
|
||||
onPress={toggle}
|
||||
onClick={toggle}
|
||||
>
|
||||
<div className="relative h-5 w-5">
|
||||
<span
|
||||
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ${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 ease-snappy ${open ? 'rotate-45' : '-translate-y-1.5'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ${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 ease-snappy ${open ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 bg-current transition-all duration-300 ${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 ease-snappy ${open ? '-rotate-45' : 'translate-y-1.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
{/* Mobile Menu Overlay - Portaled */}
|
||||
{mounted && createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 z-[100] flex flex-col bg-white/95 backdrop-blur-xl transition-all duration-300 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 ease-snappy dark:bg-gray-950/95 sm:hidden ${open ? 'visible opacity-100' : 'invisible opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{/* Close button area */}
|
||||
<div className="flex items-center justify-end px-4 py-3">
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="ghost"
|
||||
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}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
onClick={close}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<div className="relative h-5 w-5">
|
||||
<span className="absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 rotate-45 bg-current" />
|
||||
<span className="absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 -rotate-45 bg-current" />
|
||||
</div>
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto flex flex-1 flex-col px-4 pb-8">
|
||||
@@ -265,17 +261,17 @@ export function NavMenu({ items }: NavMenuProps) {
|
||||
>
|
||||
<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-[var(--color-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-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-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-[var(--color-accent)]" />
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" />
|
||||
<span>{item.label}</span>
|
||||
<FiChevronDown className="h-3 w-3 text-slate-400 transition group-hover:text-[var(--color-accent)]" />
|
||||
<FiChevronDown className="h-3 w-3 text-slate-400 transition group-hover:text-accent" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`absolute left-0 top-full z-50 hidden min-w-[12rem] rounded-2xl border border-slate-200 bg-white p-2 shadow-lg transition duration-200 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 ease-snappy dark:border-slate-800 dark:bg-slate-900 sm:block ${isOpen ? 'pointer-events-auto translate-y-2 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'
|
||||
}`}
|
||||
role="menu"
|
||||
aria-label={item.label}
|
||||
@@ -294,12 +290,12 @@ export function NavMenu({ items }: NavMenuProps) {
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="motion-link type-nav group relative inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-[var(--color-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-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}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-[var(--color-accent)]" />
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" />
|
||||
<span>{item.label}</span>
|
||||
<span className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-[var(--color-accent)] transition duration-180 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-accent transition duration-180 ease-snappy group-hover:scale-x-100" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null;
|
||||
})}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Post } from 'contentlayer2/generated';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { FiCalendar, FiTag } from 'react-icons/fi';
|
||||
import { MetaItem } from './meta-item';
|
||||
import { Card } from '@heroui/react';
|
||||
|
||||
interface PostCardProps {
|
||||
post: Post;
|
||||
@@ -18,8 +17,7 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<article>
|
||||
<Card className="motion-card group relative overflow-hidden rounded-xl border dark:border-slate-800">
|
||||
<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="relative w-full bg-slate-100 dark:bg-slate-800">
|
||||
@@ -34,7 +32,7 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Card.Content className="space-y-3 px-4 py-4">
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
{post.published_at && (
|
||||
<MetaItem icon={FiCalendar}>
|
||||
@@ -49,23 +47,20 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
|
||||
</MetaItem>
|
||||
)}
|
||||
</div>
|
||||
<Card.Header className="p-0">
|
||||
<Card.Title className="text-lg font-semibold leading-snug">
|
||||
<Link
|
||||
href={post.url}
|
||||
className="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<h2 className="text-lg font-semibold leading-snug">
|
||||
<Link
|
||||
href={post.url}
|
||||
className="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</h2>
|
||||
{post.description && (
|
||||
<Card.Description className="line-clamp-3 text-sm text-slate-700 dark:text-slate-100">
|
||||
<p className="line-clamp-3 text-sm text-slate-700 dark:text-slate-100">
|
||||
{post.description}
|
||||
</Card.Description>
|
||||
</p>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Post } from 'contentlayer2/generated';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { FiCalendar, FiTag } from 'react-icons/fi';
|
||||
import { MetaItem } from './meta-item';
|
||||
import { Card } from '@heroui/react';
|
||||
|
||||
interface Props {
|
||||
post: Post;
|
||||
@@ -20,11 +19,7 @@ export function PostListItem({ post }: Props) {
|
||||
post.description || post.custom_excerpt || post.body?.raw?.slice(0, 120);
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<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="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">
|
||||
@@ -39,7 +34,7 @@ export function PostListItem({ post }: Props) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Card.Content className="flex-1 space-y-1.5 p-0">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{post.published_at && (
|
||||
<MetaItem icon={FiCalendar}>
|
||||
@@ -54,18 +49,15 @@ export function PostListItem({ post }: Props) {
|
||||
</MetaItem>
|
||||
)}
|
||||
</div>
|
||||
<Card.Header className="p-0">
|
||||
<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)]">
|
||||
<Link href={post.url}>{post.title}</Link>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<h2 className="text-base font-semibold leading-snug text-slate-900 hover:text-accent sm:text-lg dark:text-slate-50 dark:hover:text-accent">
|
||||
<Link href={post.url}>{post.title}</Link>
|
||||
</h2>
|
||||
{excerpt && (
|
||||
<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">
|
||||
<p className="line-clamp-2 text-sm text-slate-600 group-hover:text-slate-800 dark:text-slate-300 dark:group-hover:text-slate-100">
|
||||
{excerpt}
|
||||
</Card.Description>
|
||||
</p>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Post } from 'contentlayer2/generated';
|
||||
import { Post, Page } from 'contentlayer2/generated';
|
||||
import { FiArrowDown, FiArrowUp, FiSearch, FiList } from 'react-icons/fi';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { PostListItem } from './post-list-item';
|
||||
import { TimelineWrapper } from './timeline-wrapper';
|
||||
import { Button, Input } from '@heroui/react';
|
||||
|
||||
interface Props {
|
||||
posts: Post[];
|
||||
@@ -80,30 +79,28 @@ 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">
|
||||
<FiList className="h-3.5 w-3.5" />
|
||||
<span>排序</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={sortOrder === 'new' ? 'primary' : 'ghost'}
|
||||
onPress={() => handleChangeSort('new')}
|
||||
className={`h-auto rounded-full px-2 py-0.5 text-xs ${sortOrder === 'new'
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeSort('new')}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 transition duration-180 ease-snappy ${sortOrder === 'new'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<FiArrowDown className="h-3 w-3" />
|
||||
新到舊
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={sortOrder === 'old' ? 'primary' : 'ghost'}
|
||||
onPress={() => handleChangeSort('old')}
|
||||
className={`h-auto rounded-full px-2 py-0.5 text-xs ${sortOrder === 'old'
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangeSort('old')}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 transition duration-180 ease-snappy ${sortOrder === 'old'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<FiArrowUp className="h-3 w-3" />
|
||||
舊到新
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex w-full items-center text-sm sm:w-auto">
|
||||
<label htmlFor="post-search" className="sr-only">
|
||||
@@ -111,7 +108,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
</label>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<FiSearch
|
||||
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"
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
|
||||
/>
|
||||
<input
|
||||
id="post-search"
|
||||
@@ -119,7 +116,7 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
placeholder="標題、標籤、摘要關鍵字"
|
||||
value={searchTerm}
|
||||
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 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 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,14 +128,13 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
{normalizedQuery && `(搜尋「${searchTerm}」)`}
|
||||
</p>
|
||||
{normalizedQuery && sortedPosts.length === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={() => setSearchTerm('')}
|
||||
className="h-auto p-0 text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
|
||||
>
|
||||
清除搜尋
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -156,44 +152,41 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
||||
|
||||
{totalPages > 1 && currentPosts.length > 0 && (
|
||||
<nav className="flex items-center justify-center gap-3 text-xs text-slate-600 dark:text-slate-300">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => goToPage(currentPage - 1)}
|
||||
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
|
||||
type="button"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded border border-slate-200 px-2 py-1 disabled:opacity-40 dark:border-slate-700"
|
||||
>
|
||||
上一頁
|
||||
</Button>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }).map((_, i) => {
|
||||
const p = i + 1;
|
||||
const isActive = p === currentPage;
|
||||
return (
|
||||
<Button
|
||||
<button
|
||||
key={p}
|
||||
variant={isActive ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onPress={() => goToPage(p)}
|
||||
className={`h-7 w-7 min-w-0 rounded p-0 text-xs ${isActive
|
||||
type="button"
|
||||
onClick={() => goToPage(p)}
|
||||
className={`h-7 w-7 rounded text-xs ${isActive
|
||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onPress={() => goToPage(currentPage + 1)}
|
||||
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
|
||||
type="button"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded border border-slate-200 px-2 py-1 disabled:opacity-40 dark:border-slate-700"
|
||||
>
|
||||
下一頁
|
||||
</Button>
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { FaGithub, FaMastodon, FaLinkedin } from 'react-icons/fa';
|
||||
@@ -8,7 +6,6 @@ import { siteConfig } from '@/lib/config';
|
||||
import { getAllTagsWithCount } from '@/lib/posts';
|
||||
import { allPages } from 'contentlayer2/generated';
|
||||
import { MastodonFeed } from './mastodon-feed';
|
||||
import { Card, Avatar, Chip } from '@heroui/react';
|
||||
|
||||
export function RightSidebar() {
|
||||
const tags = getAllTagsWithCount().slice(0, 5);
|
||||
@@ -38,41 +35,38 @@ export function RightSidebar() {
|
||||
icon: FaLinkedin,
|
||||
label: 'LinkedIn'
|
||||
}
|
||||
].filter(Boolean) as { key: string; href: string; icon: React.ComponentType<{ className?: string }>; label: string }[];
|
||||
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-20 flex flex-col gap-4">
|
||||
<Card className="motion-card group relative overflow-hidden rounded-xl border px-4 py-4 dark:border-slate-800">
|
||||
<section className="motion-card group relative overflow-hidden rounded-xl border bg-white px-4 py-4 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
|
||||
<div className="pointer-events-none absolute -left-10 -top-10 h-24 w-24 rounded-full bg-sky-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-sky-500/25" />
|
||||
<div className="pointer-events-none absolute -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" />
|
||||
|
||||
<Card.Content className="relative flex flex-col items-center p-0">
|
||||
<div className="relative flex flex-col items-center">
|
||||
<Link
|
||||
href={aboutPage?.url || '/pages/關於作者'}
|
||||
aria-label="關於作者"
|
||||
className="mb-2 inline-block transition-transform duration-300 ease-out group-hover:-translate-y-0.5"
|
||||
>
|
||||
<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">
|
||||
{avatarSrc ? (
|
||||
<Avatar.Image asChild>
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
alt={siteConfig.name}
|
||||
width={96}
|
||||
height={96}
|
||||
unoptimized
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</Avatar.Image>
|
||||
) : null}
|
||||
<Avatar.Fallback className="text-lg font-semibold">
|
||||
{avatarSrc ? (
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{siteConfig.name.charAt(0).toUpperCase()}
|
||||
</Avatar.Fallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
{socialItems.length > 0 && (
|
||||
<div className="mt-2 flex items-center gap-3 text-lg text-[var(--color-accent-text-light)] dark:text-[var(--color-accent-text-dark)]">
|
||||
<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}
|
||||
@@ -80,7 +74,7 @@ export function RightSidebar() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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-[var(--color-accent-soft)] hover:text-[var(--color-accent)] dark:bg-slate-800 dark:text-slate-200"
|
||||
className="motion-link inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent dark:bg-slate-800 dark:text-slate-200"
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
</a>
|
||||
@@ -94,55 +88,48 @@ export function RightSidebar() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mastodon Feed */}
|
||||
<MastodonFeed />
|
||||
|
||||
{tags.length > 0 && (
|
||||
<Card className="motion-card rounded-xl border px-4 py-3 dark:border-slate-800">
|
||||
<Card.Header className="p-0">
|
||||
<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" />
|
||||
熱門標籤
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content className="p-0">
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-base">
|
||||
{tags.map(({ tag, slug, count }) => {
|
||||
let fontWeight = 'font-normal';
|
||||
if (count >= 5) fontWeight = 'font-semibold';
|
||||
else if (count >= 3) fontWeight = 'font-medium';
|
||||
<section className="motion-card rounded-xl border bg-white px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
|
||||
<h2 className="type-small flex items-center gap-2 font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
|
||||
<FiTrendingUp className="h-3 w-3 text-orange-400" />
|
||||
熱門標籤
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-base">
|
||||
{tags.map(({ tag, slug, count }) => {
|
||||
let sizeClass = '';
|
||||
if (count >= 5) sizeClass = 'font-semibold';
|
||||
else if (count >= 3) sizeClass = 'font-medium';
|
||||
|
||||
return (
|
||||
<Link key={tag} href={`/tags/${slug}`}>
|
||||
<Chip
|
||||
color="accent"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
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`}
|
||||
>
|
||||
{tag}
|
||||
</Chip>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer className="mt-3 flex items-center justify-between p-0 type-small text-slate-500 dark:text-slate-400">
|
||||
return (
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/tags/${slug}`}
|
||||
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 type-small text-slate-500 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FiArrowRight className="h-3 w-3" />
|
||||
一覽所有標籤
|
||||
</span>
|
||||
<Link
|
||||
href="/tags"
|
||||
className="motion-link text-[var(--color-accent-text-light)] hover:text-[var(--color-accent)] dark:text-[var(--color-accent-text-dark)]"
|
||||
className="motion-link text-accent-textLight hover:text-accent dark:text-accent-textDark"
|
||||
>
|
||||
前往
|
||||
</Link>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FiSearch, FiX } from 'react-icons/fi';
|
||||
import { Modal, Button, Spinner } from '@heroui/react';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -95,58 +95,87 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Modal.Backdrop variant="blur">
|
||||
<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">
|
||||
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
||||
<FiSearch className="h-5 w-5" />
|
||||
<Modal.Heading className="text-sm font-medium">全站搜尋</Modal.Heading>
|
||||
</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
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>
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
{/* Search Container */}
|
||||
<Modal.Body 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">
|
||||
<Spinner size="lg" color="accent" />
|
||||
<p className="mt-4 text-sm text-slate-500 dark:text-slate-400">
|
||||
載入搜尋引擎...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal.Body>
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
{/* 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>
|
||||
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>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div 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 items-center justify-between">
|
||||
<span>按 ESC 關閉</span>
|
||||
<span className="text-right">支援中英文全文搜尋</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,10 +193,9 @@ export function SearchButton({ onClick }: { onClick: () => void }) {
|
||||
}, [onClick]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
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"
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="motion-link inline-flex h-9 items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
aria-label="搜尋 (Cmd+K)"
|
||||
>
|
||||
<FiSearch className="h-3.5 w-3.5" />
|
||||
@@ -175,6 +203,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">
|
||||
⌘K
|
||||
</kbd>
|
||||
</Button>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { FiMoon, FiSun } from 'react-icons/fi';
|
||||
import { Button } from '@heroui/react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -21,18 +20,17 @@ export function ThemeToggle() {
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="ghost"
|
||||
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)}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => setTheme(next)}
|
||||
aria-label={theme === 'dark' ? '切換為淺色主題' : '切換為深色主題'}
|
||||
>
|
||||
{isDark ? (
|
||||
<FiSun className="h-4 w-4 rotate-0 text-amber-400 transition-transform duration-260" />
|
||||
<FiSun className="h-4 w-4 rotate-0 text-amber-400 transition-transform duration-260 ease-snappy" />
|
||||
) : (
|
||||
<FiMoon className="h-4 w-4 rotate-180 text-blue-500 transition-transform duration-260" />
|
||||
<FiMoon className="h-4 w-4 rotate-180 text-blue-500 transition-transform duration-260 ease-snappy" />
|
||||
)}
|
||||
</Button>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
3656
package-lock.json
generated
3656
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,6 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"clsx": "^2.1.1",
|
||||
"contentlayer2": "^0.5.8",
|
||||
@@ -38,19 +35,20 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.15.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.3",
|
||||
"pagefind": "^1.4.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
6
postcss.config.cjs
Normal file
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -1,50 +1,8 @@
|
||||
/* Tailwind CSS v4 - Must be first */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 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); }
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Motion & layout variables */
|
||||
--motion-duration-short: 180ms;
|
||||
--motion-duration-medium: 260ms;
|
||||
--motion-ease-snappy: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
@@ -55,33 +13,22 @@
|
||||
--font-weight-semibold: 600;
|
||||
--font-system-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'PingFang TC', 'PingFang SC', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft JhengHei', 'Microsoft YaHei', 'Noto Sans TC', 'Noto Sans SC', 'Noto Sans CJK TC', 'Noto Sans CJK SC', 'Source Han Sans TC', 'Source Han Sans SC', 'Roboto', 'Ubuntu', 'Cantarell', 'Inter', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
/* Ink + accent palette - Light mode */
|
||||
/* Ink + accent palette */
|
||||
--color-ink-strong: #0f172a;
|
||||
--color-ink-body: #1f2937;
|
||||
--color-ink-muted: #475569;
|
||||
--color-accent: #7c3aed;
|
||||
--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);
|
||||
}
|
||||
|
||||
.dark, [data-theme="dark"] {
|
||||
.dark {
|
||||
--color-ink-strong: #e2e8f0;
|
||||
--color-ink-body: #cbd5e1;
|
||||
--color-ink-muted: #94a3b8;
|
||||
--color-accent: #a78bfa;
|
||||
--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) {
|
||||
@@ -91,22 +38,13 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: white;
|
||||
color: #111827;
|
||||
transition-property: color, background-color;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: var(--motion-ease-snappy);
|
||||
@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);
|
||||
color: var(--color-ink-body);
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: #030712;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
@keyframes timeline-scroll {
|
||||
0% {
|
||||
transform: translate(-50%, -10%);
|
||||
@@ -166,16 +104,49 @@ body {
|
||||
|
||||
|
||||
.toc-target-highlight {
|
||||
background-color: rgb(254 252 232 / 0.6);
|
||||
transition-property: color, background-color;
|
||||
transition-duration: 500ms;
|
||||
@apply bg-yellow-50/60 dark:bg-yellow-900/40 transition-colors duration-500;
|
||||
}
|
||||
|
||||
.dark .toc-target-highlight {
|
||||
background-color: rgb(113 63 18 / 0.4);
|
||||
/* 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(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 {
|
||||
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.2rem);
|
||||
line-height: var(--line-height-body);
|
||||
@@ -186,40 +157,18 @@ body {
|
||||
font-size: clamp(2.2rem, 1.4rem + 2.2vw, 3.4rem);
|
||||
line-height: 1.25;
|
||||
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 {
|
||||
font-size: clamp(1.8rem, 1.1rem + 1.6vw, 2.8rem);
|
||||
line-height: 1.3;
|
||||
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 {
|
||||
font-size: clamp(1.4rem, 0.9rem + 1vw, 2rem);
|
||||
line-height: 1.35;
|
||||
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,
|
||||
@@ -227,58 +176,6 @@ body {
|
||||
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.15rem);
|
||||
line-height: var(--line-height-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,
|
||||
@@ -296,205 +193,6 @@ body {
|
||||
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 {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -738,69 +436,112 @@ body {
|
||||
|
||||
/* Additional custom styling for highlights */
|
||||
.pagefind-ui__result-excerpt mark {
|
||||
background-color: #fef08a;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
@apply bg-yellow-200 font-semibold text-slate-900 dark:bg-yellow-600 dark:text-slate-100;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.dark .pagefind-ui__result-excerpt mark {
|
||||
background-color: #ca8a04;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.pagefind-ui__search-input:focus {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-color: #3b82f6;
|
||||
@apply ring-2 ring-blue-500 dark:ring-blue-400;
|
||||
}
|
||||
|
||||
.dark .pagefind-ui__search-input:focus {
|
||||
ring-color: #60a5fa;
|
||||
/* Code Syntax Highlighting Styles (rehype-pretty-code) */
|
||||
.prose pre {
|
||||
@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 */
|
||||
.prose .callout {
|
||||
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);
|
||||
@apply my-6 rounded-lg border-l-4 p-4 shadow-sm;
|
||||
background: linear-gradient(135deg, var(--callout-bg-start), var(--callout-bg-end));
|
||||
}
|
||||
|
||||
.prose .callout-header {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@apply mb-3 flex items-center gap-2;
|
||||
}
|
||||
|
||||
.prose .callout-icon {
|
||||
font-size: 1.5rem;
|
||||
@apply text-2xl;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.prose .callout-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@apply text-sm font-bold uppercase tracking-wider;
|
||||
color: var(--callout-title-color);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.prose .callout-content {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.625;
|
||||
@apply text-sm leading-relaxed;
|
||||
}
|
||||
|
||||
.prose .callout-content > *:first-child {
|
||||
margin-top: 0;
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
.prose .callout-content > *:last-child {
|
||||
margin-bottom: 0;
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
/* NOTE - Blue */
|
||||
@@ -808,14 +549,14 @@ body {
|
||||
--callout-bg-start: rgba(59, 130, 246, 0.08);
|
||||
--callout-bg-end: rgba(59, 130, 246, 0.04);
|
||||
--callout-title-color: #2563eb;
|
||||
border-left-color: #3b82f6;
|
||||
@apply border-blue-500;
|
||||
}
|
||||
|
||||
.dark .prose .callout-note {
|
||||
--callout-bg-start: rgba(96, 165, 250, 0.12);
|
||||
--callout-bg-end: rgba(96, 165, 250, 0.06);
|
||||
--callout-title-color: #93c5fd;
|
||||
border-left-color: #60a5fa;
|
||||
@apply border-blue-400;
|
||||
}
|
||||
|
||||
/* TIP - Green */
|
||||
@@ -823,14 +564,14 @@ body {
|
||||
--callout-bg-start: rgba(34, 197, 94, 0.08);
|
||||
--callout-bg-end: rgba(34, 197, 94, 0.04);
|
||||
--callout-title-color: #16a34a;
|
||||
border-left-color: #22c55e;
|
||||
@apply border-green-500;
|
||||
}
|
||||
|
||||
.dark .prose .callout-tip {
|
||||
--callout-bg-start: rgba(74, 222, 128, 0.12);
|
||||
--callout-bg-end: rgba(74, 222, 128, 0.06);
|
||||
--callout-title-color: #86efac;
|
||||
border-left-color: #4ade80;
|
||||
@apply border-green-400;
|
||||
}
|
||||
|
||||
/* IMPORTANT - Purple */
|
||||
@@ -838,14 +579,14 @@ body {
|
||||
--callout-bg-start: rgba(168, 85, 247, 0.08);
|
||||
--callout-bg-end: rgba(168, 85, 247, 0.04);
|
||||
--callout-title-color: #9333ea;
|
||||
border-left-color: #a855f7;
|
||||
@apply border-purple-500;
|
||||
}
|
||||
|
||||
.dark .prose .callout-important {
|
||||
--callout-bg-start: rgba(192, 132, 252, 0.12);
|
||||
--callout-bg-end: rgba(192, 132, 252, 0.06);
|
||||
--callout-title-color: #c084fc;
|
||||
border-left-color: #c084fc;
|
||||
@apply border-purple-400;
|
||||
}
|
||||
|
||||
/* WARNING - Orange/Yellow */
|
||||
@@ -853,14 +594,14 @@ body {
|
||||
--callout-bg-start: rgba(251, 191, 36, 0.08);
|
||||
--callout-bg-end: rgba(251, 191, 36, 0.04);
|
||||
--callout-title-color: #d97706;
|
||||
border-left-color: #f59e0b;
|
||||
@apply border-yellow-500;
|
||||
}
|
||||
|
||||
.dark .prose .callout-warning {
|
||||
--callout-bg-start: rgba(253, 224, 71, 0.12);
|
||||
--callout-bg-end: rgba(253, 224, 71, 0.06);
|
||||
--callout-title-color: #fde047;
|
||||
border-left-color: #fde047;
|
||||
@apply border-yellow-400;
|
||||
}
|
||||
|
||||
/* CAUTION - Red */
|
||||
@@ -868,12 +609,12 @@ body {
|
||||
--callout-bg-start: rgba(239, 68, 68, 0.08);
|
||||
--callout-bg-end: rgba(239, 68, 68, 0.04);
|
||||
--callout-title-color: #dc2626;
|
||||
border-left-color: #ef4444;
|
||||
@apply border-red-500;
|
||||
}
|
||||
|
||||
.dark .prose .callout-caution {
|
||||
--callout-bg-start: rgba(248, 113, 113, 0.12);
|
||||
--callout-bg-end: rgba(248, 113, 113, 0.06);
|
||||
--callout-title-color: #fca5a5;
|
||||
border-left-color: #f87171;
|
||||
@apply border-red-400;
|
||||
}
|
||||
|
||||
126
tailwind.config.cjs
Normal file
126
tailwind.config.cjs
Normal file
@@ -0,0 +1,126 @@
|
||||
/** @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')],
|
||||
};
|
||||
Reference in New Issue
Block a user