Compare commits

...

10 Commits

24 changed files with 4899 additions and 170 deletions

5
.eslintrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/eslintrc",
"extends": ["next/core-web-vitals"],
"rules": {}
}

View File

@@ -54,6 +54,7 @@ Markdown content (posts & pages) lives in a separate repository and is consumed
- **Blog index** (`/blog`)
- Uses `PostListWithControls`:
- Keyword search filters posts by title, tags, and excerpt with instant feedback.
- Sort order: new→old or old→new.
- Pagination using `siteConfig.postsPerPage`.
@@ -62,6 +63,8 @@ Markdown content (posts & pages) lives in a separate repository and is consumed
- Top: published date, large title, colored tags.
- Body: `prose` typography with tuned light/dark colors, images, blockquotes, code.
- Top bar: reading progress indicator.
- Metro-inspired "storyline" rail that shows上一站 / 你在這裡 / 下一站with glowing capsules linking to adjacent posts.
- Bottom: "相關文章" cards suggesting up to three related posts that share overlapping tags.
- **Right sidebar** (on large screens)
- Top hero:
@@ -77,6 +80,29 @@ Markdown content (posts & pages) lives in a separate repository and is consumed
- **Misc**
- Floating "back to top" button on long pages.
## Motion & Interaction Guidelines
- Keep motion subtle and purposeful:
- Use small translations (±24px) and short durations (200400ms, `ease-out`).
- Prefer fade/slide-in over large bounces or rotations.
- Respect user preferences:
- Animations that run on their own are wrapped with `motion-safe:` so they are disabled when `prefers-reduced-motion` is enabled.
- Reading experience first:
- Scroll-based reveals are used sparingly (e.g. post header and article body), not on every small element.
- TOC and reading progress bar emphasize orientation, not decoration.
- Hover & focus:
- Use light elevation (shadow + tiny translateY) and accent color changes to indicate interactivity.
- Focus states remain visible and are not replaced by motion-only cues.
### Implemented Visual Touches
- Reading progress bar with a soft gradient glow at the top of post pages.
- Scroll reveal for post header + article body (`ScrollReveal` component).
- Hover elevation + gradient accents for post cards, list items, sidebar author card, and tag chips.
- Smooth theme toggle with icon rotation and global `transition-colors` on the page background.
- TOC smooth scrolling + short-lived highlight on the target heading.
- Subtle hover elevation for `blockquote` and `pre` blocks inside `.prose` content.
## Prerequisites
- Node.js **18+**

View File

@@ -2,10 +2,13 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { allPosts } from 'contentlayer/generated';
import { getPostBySlug } from '@/lib/posts';
import { getPostBySlug, getRelatedPosts, getPostNeighbors } from '@/lib/posts';
import { siteConfig } from '@/lib/config';
import { ReadingProgress } from '@/components/reading-progress';
import { PostToc } from '@/components/post-toc';
import { ScrollReveal } from '@/components/scroll-reveal';
import { PostCard } from '@/components/post-card';
import { PostStorylineNav } from '@/components/post-storyline-nav';
export function generateStaticParams() {
return allPosts.map((post) => ({
@@ -34,6 +37,9 @@ export default function BlogPostPage({ params }: Props) {
if (!post) return notFound();
const relatedPosts = getRelatedPosts(post, 3);
const neighbors = getPostNeighbors(post);
return (
<>
<ReadingProgress />
@@ -41,46 +47,79 @@ export default function BlogPostPage({ params }: Props) {
<aside className="hidden shrink-0 lg:block lg:w-44">
<PostToc />
</aside>
<div className="flex-1">
<header className="mb-6 space-y-2">
{post.published_at && (
<p className="text-xs text-slate-500 dark:text-slate-500">
{new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale
)}
</p>
)}
<h1 className="text-2xl font-bold leading-tight text-slate-900 sm:text-3xl dark:text-slate-50">
{post.title}
</h1>
{post.tags && (
<div className="flex flex-wrap gap-2 pt-1">
{post.tags.map((t) => (
<Link
key={t}
href={`/tags/${encodeURIComponent(
t.toLowerCase().replace(/\s+/g, '-')
)}`}
className="rounded-full bg-accent-soft px-2 py-0.5 text-xs text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
>
#{t}
</Link>
))}
</div>
)}
</header>
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
{post.feature_image && (
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
// eslint-disable-next-line @next/next/no-img-element
<img
src={post.feature_image.replace('../assets', '/assets')}
alt={post.title}
className="my-4 rounded"
/>
)}
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
</article>
<div className="flex-1 space-y-6">
<ScrollReveal>
<header className="mb-2 space-y-2">
{post.published_at && (
<p className="text-xs text-slate-500 dark:text-slate-500">
{new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale
)}
</p>
)}
<h1 className="text-2xl font-bold leading-tight text-slate-900 sm:text-3xl dark:text-slate-50">
{post.title}
</h1>
{post.tags && (
<div className="flex flex-wrap gap-2 pt-1">
{post.tags.map((t) => (
<Link
key={t}
href={`/tags/${encodeURIComponent(
t.toLowerCase().replace(/\s+/g, '-')
)}`}
className="rounded-full bg-accent-soft px-2 py-0.5 text-xs text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
>
#{t}
</Link>
))}
</div>
)}
</header>
</ScrollReveal>
<ScrollReveal>
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
{post.feature_image && (
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
// eslint-disable-next-line @next/next/no-img-element
<img
src={post.feature_image.replace('../assets', '/assets')}
alt={post.title}
className="my-4 rounded"
/>
)}
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
</article>
</ScrollReveal>
<ScrollReveal>
<PostStorylineNav
current={post}
newer={neighbors.newer}
older={neighbors.older}
/>
</ScrollReveal>
{relatedPosts.length > 0 && (
<ScrollReveal>
<section className="space-y-4 rounded-xl border border-slate-200 bg-white/80 p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900/50">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-50">
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{relatedPosts.map((related) => (
<PostCard key={related._id} post={related} showTags={false} />
))}
</div>
</section>
</ScrollReveal>
)}
</div>
</div>
</>

View File

@@ -10,9 +10,14 @@ export default function BlogIndexPage() {
return (
<section className="space-y-4">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
</h1>
<header className="space-y-1">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">
</p>
</header>
<PostListWithControls posts={posts} />
</section>
);

View File

@@ -1,5 +1,7 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTags } from '@fortawesome/free-solid-svg-icons';
import { getAllTagsWithCount } from '@/lib/posts';
export const metadata: Metadata = {
@@ -19,7 +21,8 @@ export default function TagIndexPage() {
return (
<section className="space-y-4">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
<h1 className="flex items-center gap-2 text-lg font-semibold text-slate-900 dark:text-slate-50">
<FontAwesomeIcon icon={faTags} className="h-5 w-5 text-slate-400" />
</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">
@@ -32,7 +35,7 @@ export default function TagIndexPage() {
<Link
key={tag}
href={`/tags/${slug}`}
className={`rounded-full px-3 py-1 transition ${color}`}
className={`rounded-full px-3 py-1 shadow-sm transition-transform transition-shadow duration-200 ease-out hover:-translate-y-0.5 hover:shadow-md ${color}`}
>
<span className="mr-1">{tag}</span>
<span className="opacity-70">({count})</span>
@@ -43,4 +46,3 @@ export default function TagIndexPage() {
</section>
);
}

View File

@@ -7,7 +7,8 @@ import {
faGitAlt,
faLinkedin
} from '@fortawesome/free-brands-svg-icons';
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
import { faEnvelope, faPenNib } from '@fortawesome/free-solid-svg-icons';
import { MetaItem } from './meta-item';
export function Hero() {
const { name, tagline, social } = siteConfig;
@@ -58,18 +59,23 @@ export function Hero() {
}[];
return (
<section className="mb-8 rounded-xl border bg-gradient-to-r from-sky-50 via-indigo-50 to-slate-50 px-6 py-6 shadow-sm dark:border-slate-800 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-2xl font-semibold text-slate-50 dark:bg-slate-100 dark:text-slate-900">
<section className="motion-card group relative mb-8 overflow-hidden rounded-xl border bg-gradient-to-r from-sky-50 via-indigo-50 to-slate-50 px-6 py-6 shadow-sm dark:border-slate-800 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900">
<div className="pointer-events-none absolute -left-16 -top-16 h-40 w-40 rounded-full bg-sky-300/40 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-sky-500/25" />
<div className="pointer-events-none absolute -bottom-20 right-[-3rem] h-44 w-44 rounded-full bg-indigo-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/25" />
<div className="relative flex items-center gap-4 motion-safe:animate-fade-in-up">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-2xl font-semibold text-slate-50 shadow-md transition-transform duration-300 ease-out group-hover:scale-105 dark:bg-slate-100 dark:text-slate-900">
{initial}
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
{name}
</h1>
<p className="mt-1 max-w-2xl text-sm text-slate-700 dark:text-slate-100">
{tagline}
</p>
<div className="mt-1">
<MetaItem icon={faPenNib}>
{tagline}
</MetaItem>
</div>
{items.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-slate-500">
{items.map((item) => (
@@ -78,7 +84,7 @@ export function Hero() {
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-full bg-white/80 px-3 py-1 shadow-sm ring-1 ring-slate-200 hover:bg-accent-soft dark:bg-slate-900/80 dark:ring-slate-700"
className="motion-link flex items-center gap-2 rounded-full bg-white/80 px-3 py-1 text-slate-600 shadow-sm ring-1 ring-slate-200 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent hover:shadow-md dark:bg-slate-900/80 dark:text-slate-200 dark:ring-slate-700"
>
<FontAwesomeIcon icon={item.icon} className="h-3.5 w-3.5 text-accent" />
<span>{item.label}</span>

26
components/meta-item.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { ReactNode } from 'react';
import clsx from 'clsx';
interface MetaItemProps {
icon: IconDefinition;
children: ReactNode;
className?: string;
tone?: 'default' | 'muted';
}
export function MetaItem({ icon, children, className, tone = 'default' }: MetaItemProps) {
return (
<span
className={clsx(
'inline-flex items-center gap-1.5 text-xs transition-colors duration-180 ease-snappy',
tone === 'muted' ? 'text-slate-500 dark:text-slate-400' : 'text-slate-600 dark:text-slate-200',
className
)}
>
<FontAwesomeIcon icon={icon} className="h-3.5 w-3.5 text-slate-400 dark:text-slate-500" />
<span>{children}</span>
</span>
);
}

98
components/nav-menu.tsx Normal file
View File

@@ -0,0 +1,98 @@
'use client';
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBars,
faXmark,
faHouse,
faNewspaper,
faFileLines,
faUser,
faEnvelope,
faLocationDot,
faPenNib,
faTags,
faServer,
faMicrochip,
faBarsStaggered
} from '@fortawesome/free-solid-svg-icons';
import Link from 'next/link';
export type IconKey =
| 'home'
| 'blog'
| 'file'
| 'user'
| 'contact'
| 'location'
| 'pen'
| 'tags'
| 'server'
| 'device'
| 'menu';
const ICON_MAP: Record<IconKey, any> = {
home: faHouse,
blog: faNewspaper,
file: faFileLines,
user: faUser,
contact: faEnvelope,
location: faLocationDot,
pen: faPenNib,
tags: faTags,
server: faServer,
device: faMicrochip,
menu: faBarsStaggered
};
export interface NavLinkItem {
key: string;
href: string;
label?: string;
iconKey: IconKey;
}
interface NavMenuProps {
items: NavLinkItem[];
}
export function NavMenu({ items }: NavMenuProps) {
const [open, setOpen] = useState(false);
const toggle = () => setOpen((val) => !val);
const close = () => setOpen(false);
return (
<div className="flex items-center gap-3">
<button
type="button"
className="sm:hidden inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition duration-180 ease-snappy hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
aria-label={open ? '關閉選單' : '開啟選單'}
aria-expanded={open}
onClick={toggle}
>
<FontAwesomeIcon icon={open ? faXmark : faBars} className="h-4 w-4" />
</button>
<nav
className={`${open ? 'flex' : 'hidden'} flex-col gap-2 text-base sm:flex sm:flex-row sm:items-center sm:gap-3 sm:text-sm`}
>
{items.map((item) => (
<Link
key={item.key}
href={item.href}
className="motion-link group relative inline-flex items-center gap-1.5 rounded-full px-3 py-1 font-medium text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
onClick={close}
>
<FontAwesomeIcon
icon={ICON_MAP[item.iconKey] ?? faFileLines}
className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent"
/>
<span>{item.label}</span>
<span className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-accent transition duration-180 ease-snappy group-hover:scale-x-100" aria-hidden="true" />
</Link>
))}
</nav>
</div>
);
}

View File

@@ -1,30 +1,48 @@
import Link from 'next/link';
import type { Post } from 'contentlayer/generated';
import { siteConfig } from '@/lib/config';
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons';
import { MetaItem } from './meta-item';
interface PostCardProps {
post: Post;
showTags?: boolean;
}
export function PostCard({ post }: PostCardProps) {
export function PostCard({ post, showTags = true }: PostCardProps) {
const cover =
post.feature_image && post.feature_image.startsWith('../assets')
? post.feature_image.replace('../assets', '/assets')
: undefined;
return (
<article className="group overflow-hidden rounded-xl border bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900">
<article className="motion-card group relative overflow-hidden rounded-xl border bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
{cover && (
<div className="w-full bg-slate-100 dark:bg-slate-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={cover}
alt={post.title}
className="mx-auto max-h-60 w-full object-contain"
className="mx-auto max-h-60 w-full object-contain transition-transform duration-300 ease-out group-hover:scale-105"
/>
</div>
)}
<div className="space-y-2 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={faCalendarDays}>
{new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale
)}
</MetaItem>
)}
{showTags && post.tags && post.tags.length > 0 && (
<MetaItem icon={faTags} tone="muted">
{post.tags.slice(0, 3).join(', ')}
</MetaItem>
)}
</div>
<h2 className="text-lg font-semibold leading-snug">
<Link
href={post.url}
@@ -33,27 +51,6 @@ export function PostCard({ post }: PostCardProps) {
{post.title}
</Link>
</h2>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
{post.published_at && (
<span>
{new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale
)}
</span>
)}
{post.tags && post.tags.length > 0 && (
<span className="flex flex-wrap gap-1">
{post.tags.slice(0, 3).map((t) => (
<span
key={t}
className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] dark:bg-slate-800"
>
#{t}
</span>
))}
</span>
)}
</div>
{post.description && (
<p className="line-clamp-3 text-sm text-slate-700 dark:text-slate-100">
{post.description}

View File

@@ -1,6 +1,8 @@
import Link from 'next/link';
import type { Post } from 'contentlayer/generated';
import { siteConfig } from '@/lib/config';
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons';
import { MetaItem } from './meta-item';
interface Props {
post: Post;
@@ -17,43 +19,36 @@ export function PostListItem({ post }: Props) {
return (
<li>
<article className="group flex gap-4 rounded-lg border border-slate-200/70 bg-white/80 p-4 transition hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900/80 dark:hover:bg-slate-900">
<article className="motion-card group relative flex gap-4 rounded-lg border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900/80">
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
{cover && (
<div className="hidden flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:block sm:w-40">
<div className="flex h-24 w-24 flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:h-auto sm:w-40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={cover}
alt={post.title}
className="h-full w-full object-contain"
className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
/>
</div>
)}
<div className="flex-1 space-y-1.5">
{post.published_at && (
<p className="text-xs text-slate-500 dark:text-slate-500">
{new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale
)}
</p>
)}
<div className="flex flex-wrap gap-3 text-xs">
{post.published_at && (
<MetaItem icon={faCalendarDays}>
{new Date(post.published_at).toLocaleDateString(
siteConfig.defaultLocale
)}
</MetaItem>
)}
{post.tags && post.tags.length > 0 && (
<MetaItem icon={faTags} tone="muted">
{post.tags.slice(0, 3).join(', ')}
</MetaItem>
)}
</div>
<h2 className="text-base font-semibold leading-snug text-slate-900 hover:text-accent sm:text-lg dark:text-slate-50 dark:hover:text-accent">
<Link href={post.url}>{post.title}</Link>
</h2>
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 pt-0.5">
{post.tags.slice(0, 4).map((t) => (
<Link
key={t}
href={`/tags/${encodeURIComponent(
t.toLowerCase().replace(/\s+/g, '-')
)}`}
className="rounded-full bg-accent-soft px-2 py-0.5 text-[11px] text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
>
#{t}
</Link>
))}
</div>
)}
{excerpt && (
<p className="line-clamp-2 text-sm text-slate-600 group-hover:text-slate-800 dark:text-slate-300 dark:group-hover:text-slate-100">
{excerpt}

View File

@@ -1,7 +1,14 @@
'use client';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { Post } from 'contentlayer/generated';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowDownWideShort,
faArrowUpWideShort,
faMagnifyingGlass,
faListUl
} from '@fortawesome/free-solid-svg-icons';
import { siteConfig } from '@/lib/config';
import { PostListItem } from './post-list-item';
@@ -15,11 +22,31 @@ type SortOrder = 'new' | 'old';
export function PostListWithControls({ posts, pageSize }: Props) {
const [sortOrder, setSortOrder] = useState<SortOrder>('new');
const [page, setPage] = useState(1);
const [searchTerm, setSearchTerm] = useState('');
const size = pageSize ?? siteConfig.postsPerPage ?? 5;
const normalizedQuery = searchTerm.trim().toLowerCase();
const filteredPosts = useMemo(() => {
if (!normalizedQuery) return posts;
return posts.filter((post) => {
const haystack = [
post.title,
post.description,
post.custom_excerpt,
post.tags?.join(' ')
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(normalizedQuery);
});
}, [posts, normalizedQuery]);
const sortedPosts = useMemo(() => {
const arr = [...posts];
const arr = [...filteredPosts];
arr.sort((a, b) => {
const aDate = a.published_at
? new Date(a.published_at).getTime()
@@ -30,13 +57,17 @@ export function PostListWithControls({ posts, pageSize }: Props) {
return sortOrder === 'new' ? bDate - aDate : aDate - bDate;
});
return arr;
}, [posts, sortOrder]);
}, [filteredPosts, sortOrder]);
const totalPages = Math.max(1, Math.ceil(sortedPosts.length / size));
const currentPage = Math.min(page, totalPages);
const start = (currentPage - 1) * size;
const currentPosts = sortedPosts.slice(start, start + size);
useEffect(() => {
setPage(1);
}, [normalizedQuery]);
const handleChangeSort = (order: SortOrder) => {
setSortOrder(order);
setPage(1);
@@ -49,44 +80,85 @@ export function PostListWithControls({ posts, pageSize }: Props) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4 text-xs text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-2">
<span></span>
<div className="flex flex-col gap-4 text-xs text-slate-500 dark:text-slate-400 sm:flex-row sm:items-center sm:justify-between">
<div className="inline-flex items-center gap-2 rounded-full bg-slate-100/70 px-2 py-1 text-slate-600 dark:bg-slate-800/70 dark:text-slate-300">
<FontAwesomeIcon icon={faListUl} className="h-3.5 w-3.5" />
<span></span>
<button
type="button"
onClick={() => handleChangeSort('new')}
className={`rounded-full px-2 py-0.5 ${
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-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 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'
}`}
>
<FontAwesomeIcon icon={faArrowDownWideShort} className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => handleChangeSort('old')}
className={`rounded-full px-2 py-0.5 ${
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-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 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'
}`}
>
<FontAwesomeIcon icon={faArrowUpWideShort} className="h-3 w-3" />
</button>
</div>
<div>
{currentPage} / {totalPages}
<div className="flex w-full items-center text-sm sm:w-auto">
<label htmlFor="post-search" className="sr-only">
</label>
<div className="relative w-full sm:w-64">
<FontAwesomeIcon
icon={faMagnifyingGlass}
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"
type="search"
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 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>
</div>
<ul className="space-y-3">
{currentPosts.map((post) => (
<PostListItem key={post._id} post={post} />
))}
</ul>
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<p>
{currentPage} / {totalPages} · {sortedPosts.length}
{normalizedQuery && `(搜尋「${searchTerm}」)`}
</p>
{normalizedQuery && sortedPosts.length === 0 && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
>
</button>
)}
</div>
{totalPages > 1 && (
{currentPosts.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
</div>
) : (
<ul className="space-y-3">
{currentPosts.map((post) => (
<PostListItem key={post._id} post={post} />
))}
</ul>
)}
{totalPages > 1 && currentPosts.length > 0 && (
<nav className="flex items-center justify-center gap-3 text-xs text-slate-600 dark:text-slate-300">
<button
type="button"
@@ -129,4 +201,3 @@ export function PostListWithControls({ posts, pageSize }: Props) {
</div>
);
}

View File

@@ -0,0 +1,100 @@
import Link from 'next/link';
import type { Post } from 'contentlayer/generated';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeftLong, faArrowRightLong } from '@fortawesome/free-solid-svg-icons';
interface Props {
current: Post;
newer?: Post;
older?: Post;
}
interface StationConfig {
key: 'older' | 'newer';
label: string;
post?: Post;
rel?: 'prev' | 'next';
subtitle: string;
align: 'start' | 'end';
}
export function PostStorylineNav({ current, newer, older }: Props) {
const stations: StationConfig[] = [
{
key: 'older',
label: '上一站',
post: older,
subtitle: older ? '回顧這篇' : '到達起點',
rel: 'prev',
align: 'end'
},
{
key: 'newer',
label: '下一站',
post: newer,
subtitle: newer ? '繼續前往' : '尚無新章',
rel: 'next',
align: 'start'
}
];
return (
<nav aria-label="文章導覽" className="relative mt-10">
<div className="relative overflow-hidden rounded-[32px] border border-slate-200/70 bg-gradient-to-r from-white via-slate-50 to-white px-6 py-8 shadow-lg dark:border-slate-800/70 dark:from-slate-900 dark:via-slate-900/80 dark:to-slate-900">
<div className="pointer-events-none absolute inset-x-12 top-1/2 hidden md:block">
<div className="relative flex items-center text-slate-200 dark:text-slate-700">
<span className="h-0 w-0 -translate-x-3 border-y-[7px] border-y-transparent border-r-[14px] border-r-current" />
<span className="flex-1 border-t border-dashed border-current" />
<span className="h-0 w-0 translate-x-3 rotate-180 border-y-[7px] border-y-transparent border-r-[14px] border-r-current" />
</div>
</div>
<div className="relative grid gap-6 md:grid-cols-[1fr_auto_1fr] md:items-center">
<Station station={stations[0]} />
<div className="hidden flex-col items-center gap-2 text-center text-xs uppercase tracking-[0.4em] text-slate-400 md:flex">
<span className="h-2 w-2 rounded-full bg-blue-500" aria-hidden="true" />
<span></span>
</div>
<Station station={stations[1]} />
</div>
</div>
</nav>
);
}
function Station({ station }: { station: StationConfig }) {
const { post, label, subtitle, rel, align } = station;
const alignClass = align === 'end' ? 'items-end text-right' : 'items-start text-left';
if (!post) {
return (
<div className={`flex flex-col gap-1 text-slate-400 ${alignClass}`}>
<p className="text-[11px] uppercase tracking-[0.4em]">{label}</p>
<p className="text-base font-semibold">{subtitle}</p>
</div>
);
}
return (
<Link
href={post.url}
rel={rel}
className={`group flex flex-col gap-1 text-center transition duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 dark:focus-visible:ring-blue-300 ${alignClass}`}
>
<p className="text-[11px] uppercase tracking-[0.4em] text-slate-400 transition group-hover:text-blue-500 dark:text-slate-500">
<FontAwesomeIcon
icon={align === 'end' ? faArrowLeftLong : faArrowRightLong}
className="mr-1 h-3 w-3"
/>
{label}
</p>
<p className="text-lg font-semibold leading-snug tracking-tight text-slate-900 transition group-hover:text-blue-600 dark:text-slate-50 dark:group-hover:text-blue-300">
{post.title}
</p>
<span
className={`mt-2 h-0.5 w-16 rounded-full bg-slate-200 transition group-hover:w-24 group-hover:bg-blue-400 dark:bg-slate-700 ${
align === 'end' ? 'self-end' : 'self-start'
}`}
/>
</Link>
);
}

View File

@@ -1,6 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faListUl, faCircle } from '@fortawesome/free-solid-svg-icons';
interface TocItem {
id: string;
@@ -48,11 +50,36 @@ export function PostToc() {
return () => observer.disconnect();
}, []);
const handleClick = (id: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
const el = document.getElementById(id);
if (!el) return;
el.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
// Temporary highlight
el.classList.add('toc-target-highlight');
setTimeout(() => {
el.classList.remove('toc-target-highlight');
}, 700);
// Update hash without instant jump
if (history.replaceState) {
const url = new URL(window.location.href);
url.hash = id;
history.replaceState(null, '', url.toString());
}
};
if (items.length === 0) return null;
return (
<nav className="sticky top-20 text-xs text-slate-500 dark:text-slate-400">
<div className="mb-2 font-semibold text-slate-700 dark:text-slate-200">
<div className="mb-2 inline-flex items-center gap-2 font-semibold text-slate-700 dark:text-slate-200">
<FontAwesomeIcon icon={faListUl} className="h-3 w-3 text-slate-400" />
</div>
<ul className="space-y-1">
@@ -60,12 +87,14 @@ export function PostToc() {
<li key={item.id} className={item.depth === 3 ? 'pl-3' : ''}>
<a
href={`#${item.id}`}
className={`line-clamp-2 hover:text-blue-600 dark:hover:text-blue-400 ${
onClick={handleClick(item.id)}
className={`line-clamp-2 inline-flex items-center gap-2 hover:text-blue-600 dark:hover:text-blue-400 ${
item.id === activeId
? 'text-blue-600 dark:text-blue-400 font-semibold'
: ''
}`}
>
<FontAwesomeIcon icon={faCircle} className="h-1.5 w-1.5 text-slate-300" />
{item.text}
</a>
</li>

View File

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

View File

@@ -1,6 +1,7 @@
import Link from 'next/link';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGithub, faMastodon, faLinkedin } from '@fortawesome/free-brands-svg-icons';
import { faFire, faIdCard, faArrowRight } from '@fortawesome/free-solid-svg-icons';
import { siteConfig } from '@/lib/config';
import { getAllTagsWithCount } from '@/lib/posts';
import { allPages } from 'contentlayer/generated';
@@ -36,30 +37,33 @@ export function RightSidebar() {
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
return (
<aside className="hidden lg:block text-sm">
<aside className="hidden text-sm lg:block">
<div className="sticky top-20 flex flex-col gap-4">
<section className="rounded-xl border bg-white px-4 py-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
<div className="flex flex-col items-center">
<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" />
<div className="relative flex flex-col items-center">
<Link
href={aboutPage?.url || '/pages/關於作者'}
aria-label="關於作者"
className="mb-2 inline-block"
className="mb-2 inline-block transition-transform duration-300 ease-out group-hover:-translate-y-0.5"
>
{avatarSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarSrc}
alt={siteConfig.name}
className="h-24 w-24 rounded-full border border-slate-200 object-cover dark:border-slate-700"
className="h-24 w-24 rounded-full border border-slate-200 object-cover shadow-sm transition-transform duration-300 ease-out group-hover:scale-105 dark:border-slate-700"
/>
) : (
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-slate-900 text-lg font-semibold text-slate-50 dark:bg-slate-100 dark:text-slate-900">
<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()}
</div>
)}
</Link>
{socialItems.length > 0 && (
<div className="flex items-center gap-3 text-base text-accent-textLight dark:text-accent-textDark">
<div className="mt-2 flex items-center gap-3 text-base text-accent-textLight dark:text-accent-textDark">
{socialItems.map((item) => (
<a
key={item.key}
@@ -67,7 +71,7 @@ export function RightSidebar() {
target="_blank"
rel="noopener noreferrer"
aria-label={item.label}
className="transition hover:text-accent"
className="motion-link inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent dark:bg-slate-800 dark:text-slate-200"
>
<FontAwesomeIcon icon={item.icon} className="h-4 w-4" />
</a>
@@ -75,16 +79,18 @@ export function RightSidebar() {
</div>
)}
{siteConfig.aboutShort && (
<p className="mt-2 max-w-[11rem] text-center text-[13px] text-slate-600 dark:text-slate-200">
{siteConfig.aboutShort}
<p className="mt-3 flex items-center gap-2 text-[13px] text-slate-600 dark:text-slate-200">
<FontAwesomeIcon icon={faIdCard} className="h-3 w-3 text-slate-400" />
<span>{siteConfig.aboutShort}</span>
</p>
)}
</div>
</section>
{tags.length > 0 && (
<section className="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="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
<section className="motion-card rounded-xl border bg-white px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100">
<h2 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
<FontAwesomeIcon icon={faFire} className="h-3 w-3 text-orange-400" />
</h2>
<div className="mt-2 flex flex-wrap gap-2 text-[13px]">
@@ -104,12 +110,16 @@ export function RightSidebar() {
);
})}
</div>
<div className="mt-2 text-right text-[11px]">
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1">
<FontAwesomeIcon icon={faArrowRight} className="h-3 w-3" />
</span>
<Link
href="/tags"
className="text-slate-500 hover:text-accent dark:text-slate-400 dark:hover:text-accent"
className="motion-link text-accent-textLight hover:text-accent dark:text-accent-textDark"
>
</Link>
</div>
</section>

View File

@@ -0,0 +1,59 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
interface ScrollRevealProps {
children: React.ReactNode;
className?: string;
once?: boolean;
}
export function ScrollReveal({
children,
className,
once = true
}: ScrollRevealProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setVisible(true);
if (once) observer.unobserve(entry.target);
} else if (!once) {
setVisible(false);
}
});
},
{
threshold: 0.15
}
);
observer.observe(el);
return () => observer.disconnect();
}, [once]);
return (
<div
ref={ref}
className={clsx(
'motion-safe:transition-all motion-safe:duration-500 motion-safe:ease-out',
'motion-safe:opacity-0 motion-safe:translate-y-3',
visible &&
'motion-safe:opacity-100 motion-safe:translate-y-0 motion-safe:animate-none',
className
)}
>
{children}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import Link from 'next/link';
import { ThemeToggle } from './theme-toggle';
import { NavMenu, NavLinkItem, IconKey } from './nav-menu';
import { siteConfig } from '@/lib/config';
import { allPages } from 'contentlayer/generated';
@@ -8,34 +9,73 @@ export function SiteHeader() {
.slice()
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
const navItems: NavLinkItem[] = [
{ key: 'home', href: '/', label: '首頁', iconKey: 'home' },
{ key: 'blog', href: '/blog', label: 'Blog', iconKey: 'blog' },
...pages.map((page) => ({
key: page._id,
href: page.url,
label: page.title,
iconKey: getIconForPage(page.title, page.slug)
}))
];
return (
<header className="bg-white/80 backdrop-blur dark:bg-gray-950/80">
<header className="bg-white/80 backdrop-blur transition-colors duration-200 ease-snappy dark:bg-gray-950/80">
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-slate-900 dark:text-slate-100">
<Link
href="/"
className="font-semibold transition hover:text-accent-textDark focus-visible:outline-none focus-visible:text-accent-textDark"
className="motion-link group relative font-semibold tracking-tight text-lg text-slate-900 hover:text-accent focus-visible:outline-none focus-visible:text-accent dark:text-slate-100"
>
<span className="absolute -bottom-0.5 left-0 h-[2px] w-0 bg-accent transition-all duration-180 ease-snappy group-hover:w-full" aria-hidden="true" />
{siteConfig.title}
</Link>
<nav className="flex items-center gap-4 text-base sm:text-lg">
<Link
href="/blog"
className="transition hover:text-accent-textDark focus-visible:outline-none focus-visible:text-accent-textDark"
>
Blog
</Link>
{pages.map((page) => (
<Link
key={page._id}
href={page.url}
className="transition hover:text-accent-textDark focus-visible:outline-none focus-visible:text-accent-textDark"
>
{page.title}
</Link>
))}
<div className="flex items-center gap-3">
<NavMenu items={navItems} />
<ThemeToggle />
</nav>
</div>
</div>
</header>
);
}
const titleOverrides = Object.fromEntries(
Object.entries(siteConfig.navIconOverrides?.titles ?? {}).map(([key, value]) => [
key.trim().toLowerCase(),
value as IconKey
])
);
const slugOverrides = Object.fromEntries(
Object.entries(siteConfig.navIconOverrides?.slugs ?? {}).map(([key, value]) => [
key.trim().toLowerCase(),
value as IconKey
])
);
function getIconForPage(title?: string, slug?: string): IconKey {
const normalizedTitle = title?.trim().toLowerCase();
if (normalizedTitle && titleOverrides[normalizedTitle]) {
return titleOverrides[normalizedTitle];
}
const normalizedSlug = slug?.trim().toLowerCase();
if (normalizedSlug && slugOverrides[normalizedSlug]) {
return slugOverrides[normalizedSlug];
}
if (!title) return 'file';
const lower = title.toLowerCase();
if (lower.includes('關於本站')) return 'menu';
if (lower.includes('關於') || lower.includes('about')) return 'user';
if (lower.includes('聯絡') || lower.includes('contact')) return 'contact';
if (lower.includes('位置') || lower.includes('map')) return 'location';
if (lower.includes('作品') || lower.includes('portfolio')) return 'pen';
if (lower.includes('標籤') || lower.includes('tags')) return 'tags';
if (lower.includes('homelab')) return 'server';
if (lower.includes('server') || lower.includes('伺服') || lower.includes('infrastructure')) return 'server';
if (lower.includes('開發工作環境')) return 'device';
if (lower.includes('device') || lower.includes('設備') || lower.includes('硬體') || lower.includes('hardware')) return 'device';
return 'file';
}

View File

@@ -2,6 +2,8 @@
import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
@@ -16,17 +18,21 @@ export function ThemeToggle() {
}
const next = theme === 'dark' ? 'light' : 'dark';
const isDark = theme === 'dark';
return (
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-accent-textLight transition hover:bg-accent-soft hover:text-accent dark:text-accent-textDark dark:hover:bg-slate-800 dark:hover:text-accent"
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' ? '切換為淺色主題' : '切換為深色主題'}
>
<span className="text-lg leading-none">
{theme === 'dark' ? '☀' : '☾'}
</span>
<FontAwesomeIcon
icon={isDark ? faSun : faMoon}
className={`h-4 w-4 transition-transform duration-260 ease-snappy ${
isDark ? 'rotate-0 text-amber-400' : 'rotate-180 text-blue-500'
}`}
/>
</button>
);
}

View File

@@ -34,6 +34,14 @@ export const siteConfig = {
accentTextDark:
process.env.NEXT_PUBLIC_COLOR_ACCENT_TEXT_DARK || '#93c5fd'
},
navIconOverrides: {
titles: {
homelab: 'server',
'開發工作環境': 'device',
'關於本站': 'menu'
},
slugs: {}
},
ogImage: process.env.NEXT_PUBLIC_OG_DEFAULT_IMAGE || '/assets/og-default.jpg',
twitterCard:
(process.env.NEXT_PUBLIC_TWITTER_CARD_TYPE as

View File

@@ -47,3 +47,58 @@ export function getAllTagsWithCount(): { tag: string; slug: string; count: numbe
return b.count - a.count;
});
}
export function getRelatedPosts(target: Post, limit = 3): Post[] {
const targetTags = new Set(target.tags?.map((tag) => tag.toLowerCase()) ?? []);
const candidates = getAllPostsSorted().filter((post) => post._id !== target._id);
if (candidates.length === 0) return [];
const scored = candidates
.map((post) => {
const sharedTags = (post.tags ?? []).reduce((acc, tag) => {
return acc + (targetTags.has(tag.toLowerCase()) ? 1 : 0);
}, 0);
return { post, score: sharedTags };
})
.filter((entry) => entry.score > 0)
.sort((a, b) => {
if (b.score === a.score) {
const aDate = a.post.published_at
? new Date(a.post.published_at).getTime()
: 0;
const bDate = b.post.published_at
? new Date(b.post.published_at).getTime()
: 0;
return bDate - aDate;
}
return b.score - a.score;
})
.slice(0, limit)
.map((entry) => entry.post);
if (scored.length >= limit) {
return scored;
}
const fallback = candidates.filter(
(post) => !scored.some((existing) => existing._id === post._id)
);
return [...scored, ...fallback.slice(0, limit - scored.length)].slice(0, limit);
}
export function getPostNeighbors(target: Post): {
newer?: Post;
older?: Post;
} {
const sorted = getAllPostsSorted();
const index = sorted.findIndex((post) => post._id === target._id);
if (index === -1) return {};
return {
newer: index > 0 ? sorted[index - 1] : undefined,
older: index < sorted.length - 1 ? sorted[index + 1] : undefined
};
}

4063
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,8 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"autoprefixer": "^10.4.22",
"eslint": "^8.57.1",
"eslint-config-next": "^13.5.11",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.9.3"

View File

@@ -2,6 +2,62 @@
@tailwind components;
@tailwind utilities;
body {
@apply bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100 text-[15px] sm:text-base;
:root {
--motion-duration-short: 180ms;
--motion-duration-medium: 260ms;
--motion-ease-snappy: cubic-bezier(0.32, 0.72, 0, 1);
--card-translate-y: -6px;
}
body {
@apply bg-white text-gray-900 transition-colors duration-200 ease-snappy dark:bg-gray-950 dark:text-gray-100 text-[15px] sm:text-base;
}
.toc-target-highlight {
@apply bg-yellow-50/60 dark:bg-yellow-900/40 transition-colors duration-500;
}
/* Subtle hover for article elements */
.prose blockquote {
@apply transition-transform transition-shadow duration-180 ease-snappy;
}
.prose blockquote:hover {
@apply -translate-y-0.5 shadow-sm;
}
.prose pre {
@apply transition-transform transition-shadow duration-180 ease-snappy;
}
.prose pre:hover {
@apply -translate-y-0.5 shadow-md;
}
.prose h1 > a,
.prose h2 > a,
.prose h3 > a,
.prose h4 > a,
.prose h5 > a,
.prose h6 > a {
text-decoration: none !important;
color: inherit !important;
}
@layer components {
.motion-card {
transition: transform var(--motion-duration-medium) var(--motion-ease-snappy),
box-shadow var(--motion-duration-medium) var(--motion-ease-snappy),
background-color var(--motion-duration-medium) var(--motion-ease-snappy),
border-color var(--motion-duration-medium) var(--motion-ease-snappy);
}
.motion-card:hover {
transform: translateY(var(--card-translate-y));
}
.motion-link {
transition: color var(--motion-duration-short) var(--motion-ease-snappy),
transform var(--motion-duration-short) var(--motion-ease-snappy);
}
}

View File

@@ -16,6 +16,32 @@ module.exports = {
textDark: 'var(--color-accent-text-dark)'
}
},
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: {