Compare commits
10 Commits
0df0a85579
...
80d0b236c5
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d0b236c5 | |||
| c404be0822 | |||
| 71680252a4 | |||
| 3d3090c4e2 | |||
| 904434774b | |||
| 91dec52db6 | |||
| df10c8b751 | |||
| 009f4bf41e | |||
| 96ebca37d6 | |||
| 4b3329d66f |
5
.eslintrc.json
Normal file
5
.eslintrc.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/eslintrc",
|
||||||
|
"extends": ["next/core-web-vitals"],
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
26
README.md
26
README.md
@@ -54,6 +54,7 @@ Markdown content (posts & pages) lives in a separate repository and is consumed
|
|||||||
|
|
||||||
- **Blog index** (`/blog`)
|
- **Blog index** (`/blog`)
|
||||||
- Uses `PostListWithControls`:
|
- Uses `PostListWithControls`:
|
||||||
|
- Keyword search filters posts by title, tags, and excerpt with instant feedback.
|
||||||
- Sort order: new→old or old→new.
|
- Sort order: new→old or old→new.
|
||||||
- Pagination using `siteConfig.postsPerPage`.
|
- 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.
|
- Top: published date, large title, colored tags.
|
||||||
- Body: `prose` typography with tuned light/dark colors, images, blockquotes, code.
|
- Body: `prose` typography with tuned light/dark colors, images, blockquotes, code.
|
||||||
- Top bar: reading progress indicator.
|
- 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)
|
- **Right sidebar** (on large screens)
|
||||||
- Top hero:
|
- Top hero:
|
||||||
@@ -77,6 +80,29 @@ Markdown content (posts & pages) lives in a separate repository and is consumed
|
|||||||
- **Misc**
|
- **Misc**
|
||||||
- Floating "back to top" button on long pages.
|
- Floating "back to top" button on long pages.
|
||||||
|
|
||||||
|
## Motion & Interaction Guidelines
|
||||||
|
|
||||||
|
- Keep motion subtle and purposeful:
|
||||||
|
- Use small translations (±2–4px) and short durations (200–400ms, `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
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js **18+**
|
- Node.js **18+**
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import Link from 'next/link';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { allPosts } from 'contentlayer/generated';
|
import { allPosts } from 'contentlayer/generated';
|
||||||
import { getPostBySlug } from '@/lib/posts';
|
import { getPostBySlug, getRelatedPosts, getPostNeighbors } from '@/lib/posts';
|
||||||
import { siteConfig } from '@/lib/config';
|
import { siteConfig } from '@/lib/config';
|
||||||
import { ReadingProgress } from '@/components/reading-progress';
|
import { ReadingProgress } from '@/components/reading-progress';
|
||||||
import { PostToc } from '@/components/post-toc';
|
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() {
|
export function generateStaticParams() {
|
||||||
return allPosts.map((post) => ({
|
return allPosts.map((post) => ({
|
||||||
@@ -34,6 +37,9 @@ export default function BlogPostPage({ params }: Props) {
|
|||||||
|
|
||||||
if (!post) return notFound();
|
if (!post) return notFound();
|
||||||
|
|
||||||
|
const relatedPosts = getRelatedPosts(post, 3);
|
||||||
|
const neighbors = getPostNeighbors(post);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ReadingProgress />
|
<ReadingProgress />
|
||||||
@@ -41,8 +47,9 @@ export default function BlogPostPage({ params }: Props) {
|
|||||||
<aside className="hidden shrink-0 lg:block lg:w-44">
|
<aside className="hidden shrink-0 lg:block lg:w-44">
|
||||||
<PostToc />
|
<PostToc />
|
||||||
</aside>
|
</aside>
|
||||||
<div className="flex-1">
|
<div className="flex-1 space-y-6">
|
||||||
<header className="mb-6 space-y-2">
|
<ScrollReveal>
|
||||||
|
<header className="mb-2 space-y-2">
|
||||||
{post.published_at && (
|
{post.published_at && (
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-500">
|
<p className="text-xs text-slate-500 dark:text-slate-500">
|
||||||
{new Date(post.published_at).toLocaleDateString(
|
{new Date(post.published_at).toLocaleDateString(
|
||||||
@@ -69,6 +76,9 @@ export default function BlogPostPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
</ScrollReveal>
|
||||||
|
|
||||||
|
<ScrollReveal>
|
||||||
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
|
<article className="prose prose-lg prose-slate max-w-none dark:prose-dark">
|
||||||
{post.feature_image && (
|
{post.feature_image && (
|
||||||
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
|
// feature_image is stored as "../assets/xyz", serve from "/assets/xyz"
|
||||||
@@ -81,6 +91,35 @@ export default function BlogPostPage({ params }: Props) {
|
|||||||
)}
|
)}
|
||||||
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
|
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
|
||||||
</article>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ export default function BlogIndexPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
|
<header className="space-y-1">
|
||||||
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||||
所有文章
|
所有文章
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
繼續往下滑,慢慢逛逛。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
<PostListWithControls posts={posts} />
|
<PostListWithControls posts={posts} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faTags } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { getAllTagsWithCount } from '@/lib/posts';
|
import { getAllTagsWithCount } from '@/lib/posts';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -19,7 +21,8 @@ export default function TagIndexPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<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>
|
</h1>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
@@ -32,7 +35,7 @@ export default function TagIndexPage() {
|
|||||||
<Link
|
<Link
|
||||||
key={tag}
|
key={tag}
|
||||||
href={`/tags/${slug}`}
|
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="mr-1">{tag}</span>
|
||||||
<span className="opacity-70">({count})</span>
|
<span className="opacity-70">({count})</span>
|
||||||
@@ -43,4 +46,3 @@ export default function TagIndexPage() {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
faGitAlt,
|
faGitAlt,
|
||||||
faLinkedin
|
faLinkedin
|
||||||
} from '@fortawesome/free-brands-svg-icons';
|
} 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() {
|
export function Hero() {
|
||||||
const { name, tagline, social } = siteConfig;
|
const { name, tagline, social } = siteConfig;
|
||||||
@@ -58,18 +59,23 @@ export function Hero() {
|
|||||||
}[];
|
}[];
|
||||||
|
|
||||||
return (
|
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">
|
<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="flex items-center gap-4">
|
<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="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">
|
<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}
|
{initial}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||||
{name}
|
{name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 max-w-2xl text-sm text-slate-700 dark:text-slate-100">
|
<div className="mt-1">
|
||||||
|
<MetaItem icon={faPenNib}>
|
||||||
{tagline}
|
{tagline}
|
||||||
</p>
|
</MetaItem>
|
||||||
|
</div>
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@@ -78,7 +84,7 @@ export function Hero() {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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" />
|
<FontAwesomeIcon icon={item.icon} className="h-3.5 w-3.5 text-accent" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
|
|||||||
26
components/meta-item.tsx
Normal file
26
components/meta-item.tsx
Normal 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
98
components/nav-menu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,48 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Post } from 'contentlayer/generated';
|
import type { Post } from 'contentlayer/generated';
|
||||||
import { siteConfig } from '@/lib/config';
|
import { siteConfig } from '@/lib/config';
|
||||||
|
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { MetaItem } from './meta-item';
|
||||||
|
|
||||||
interface PostCardProps {
|
interface PostCardProps {
|
||||||
post: Post;
|
post: Post;
|
||||||
|
showTags?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostCard({ post }: PostCardProps) {
|
export function PostCard({ post, showTags = true }: PostCardProps) {
|
||||||
const cover =
|
const cover =
|
||||||
post.feature_image && post.feature_image.startsWith('../assets')
|
post.feature_image && post.feature_image.startsWith('../assets')
|
||||||
? post.feature_image.replace('../assets', '/assets')
|
? post.feature_image.replace('../assets', '/assets')
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
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 && (
|
{cover && (
|
||||||
<div className="w-full bg-slate-100 dark:bg-slate-800">
|
<div className="w-full bg-slate-100 dark:bg-slate-800">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={cover}
|
src={cover}
|
||||||
alt={post.title}
|
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>
|
||||||
)}
|
)}
|
||||||
<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">
|
<h2 className="text-lg font-semibold leading-snug">
|
||||||
<Link
|
<Link
|
||||||
href={post.url}
|
href={post.url}
|
||||||
@@ -33,27 +51,6 @@ export function PostCard({ post }: PostCardProps) {
|
|||||||
{post.title}
|
{post.title}
|
||||||
</Link>
|
</Link>
|
||||||
</h2>
|
</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 && (
|
{post.description && (
|
||||||
<p 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}
|
{post.description}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Post } from 'contentlayer/generated';
|
import type { Post } from 'contentlayer/generated';
|
||||||
import { siteConfig } from '@/lib/config';
|
import { siteConfig } from '@/lib/config';
|
||||||
|
import { faCalendarDays, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { MetaItem } from './meta-item';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
post: Post;
|
post: Post;
|
||||||
@@ -17,43 +19,36 @@ export function PostListItem({ post }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<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 && (
|
{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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={cover}
|
src={cover}
|
||||||
alt={post.title}
|
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>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 space-y-1.5">
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs">
|
||||||
{post.published_at && (
|
{post.published_at && (
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-500">
|
<MetaItem icon={faCalendarDays}>
|
||||||
{new Date(post.published_at).toLocaleDateString(
|
{new Date(post.published_at).toLocaleDateString(
|
||||||
siteConfig.defaultLocale
|
siteConfig.defaultLocale
|
||||||
)}
|
)}
|
||||||
</p>
|
</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">
|
<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>
|
<Link href={post.url}>{post.title}</Link>
|
||||||
</h2>
|
</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 && (
|
{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">
|
<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}
|
{excerpt}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { Post } from 'contentlayer/generated';
|
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 { siteConfig } from '@/lib/config';
|
||||||
import { PostListItem } from './post-list-item';
|
import { PostListItem } from './post-list-item';
|
||||||
|
|
||||||
@@ -15,11 +22,31 @@ type SortOrder = 'new' | 'old';
|
|||||||
export function PostListWithControls({ posts, pageSize }: Props) {
|
export function PostListWithControls({ posts, pageSize }: Props) {
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('new');
|
const [sortOrder, setSortOrder] = useState<SortOrder>('new');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const size = pageSize ?? siteConfig.postsPerPage ?? 5;
|
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 sortedPosts = useMemo(() => {
|
||||||
const arr = [...posts];
|
const arr = [...filteredPosts];
|
||||||
arr.sort((a, b) => {
|
arr.sort((a, b) => {
|
||||||
const aDate = a.published_at
|
const aDate = a.published_at
|
||||||
? new Date(a.published_at).getTime()
|
? new Date(a.published_at).getTime()
|
||||||
@@ -30,13 +57,17 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
|||||||
return sortOrder === 'new' ? bDate - aDate : aDate - bDate;
|
return sortOrder === 'new' ? bDate - aDate : aDate - bDate;
|
||||||
});
|
});
|
||||||
return arr;
|
return arr;
|
||||||
}, [posts, sortOrder]);
|
}, [filteredPosts, sortOrder]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(sortedPosts.length / size));
|
const totalPages = Math.max(1, Math.ceil(sortedPosts.length / size));
|
||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
const start = (currentPage - 1) * size;
|
const start = (currentPage - 1) * size;
|
||||||
const currentPosts = sortedPosts.slice(start, start + size);
|
const currentPosts = sortedPosts.slice(start, start + size);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [normalizedQuery]);
|
||||||
|
|
||||||
const handleChangeSort = (order: SortOrder) => {
|
const handleChangeSort = (order: SortOrder) => {
|
||||||
setSortOrder(order);
|
setSortOrder(order);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -49,44 +80,85 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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 flex-col gap-4 text-xs text-slate-500 dark:text-slate-400 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
||||||
<span>排序:</span>
|
<FontAwesomeIcon icon={faListUl} className="h-3.5 w-3.5" />
|
||||||
|
<span>排序</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleChangeSort('new')}
|
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'
|
sortOrder === 'new'
|
||||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
? '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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleChangeSort('old')}
|
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'
|
sortOrder === 'old'
|
||||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
? '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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex w-full items-center text-sm sm:w-auto">
|
||||||
第 {currentPage} / {totalPages} 頁
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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">
|
<ul className="space-y-3">
|
||||||
{currentPosts.map((post) => (
|
{currentPosts.map((post) => (
|
||||||
<PostListItem key={post._id} post={post} />
|
<PostListItem key={post._id} post={post} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && currentPosts.length > 0 && (
|
||||||
<nav className="flex items-center justify-center gap-3 text-xs text-slate-600 dark:text-slate-300">
|
<nav className="flex items-center justify-center gap-3 text-xs text-slate-600 dark:text-slate-300">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -129,4 +201,3 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
100
components/post-storyline-nav.tsx
Normal file
100
components/post-storyline-nav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faListUl, faCircle } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
interface TocItem {
|
interface TocItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,11 +50,36 @@ export function PostToc() {
|
|||||||
return () => observer.disconnect();
|
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;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="sticky top-20 text-xs text-slate-500 dark:text-slate-400">
|
<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>
|
</div>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
@@ -60,12 +87,14 @@ export function PostToc() {
|
|||||||
<li key={item.id} className={item.depth === 3 ? 'pl-3' : ''}>
|
<li key={item.id} className={item.depth === 3 ? 'pl-3' : ''}>
|
||||||
<a
|
<a
|
||||||
href={`#${item.id}`}
|
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
|
item.id === activeId
|
||||||
? 'text-blue-600 dark:text-blue-400 font-semibold'
|
? 'text-blue-600 dark:text-blue-400 font-semibold'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<FontAwesomeIcon icon={faCircle} className="h-1.5 w-1.5 text-slate-300" />
|
||||||
{item.text}
|
{item.text}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faBookOpen } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
export function ReadingProgress() {
|
export function ReadingProgress() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -32,12 +34,15 @@ export function ReadingProgress() {
|
|||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-1 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
|
<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})` }}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faGithub, faMastodon, faLinkedin } from '@fortawesome/free-brands-svg-icons';
|
import { faGithub, faMastodon, faLinkedin } from '@fortawesome/free-brands-svg-icons';
|
||||||
|
import { faFire, faIdCard, faArrowRight } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { siteConfig } from '@/lib/config';
|
import { siteConfig } from '@/lib/config';
|
||||||
import { getAllTagsWithCount } from '@/lib/posts';
|
import { getAllTagsWithCount } from '@/lib/posts';
|
||||||
import { allPages } from 'contentlayer/generated';
|
import { allPages } from 'contentlayer/generated';
|
||||||
@@ -36,30 +37,33 @@ export function RightSidebar() {
|
|||||||
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
|
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden lg:block text-sm">
|
<aside className="hidden text-sm lg:block">
|
||||||
<div className="sticky top-20 flex flex-col gap-4">
|
<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">
|
<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="flex flex-col items-center">
|
<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
|
<Link
|
||||||
href={aboutPage?.url || '/pages/關於作者'}
|
href={aboutPage?.url || '/pages/關於作者'}
|
||||||
aria-label="關於作者"
|
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 ? (
|
{avatarSrc ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={avatarSrc}
|
src={avatarSrc}
|
||||||
alt={siteConfig.name}
|
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()}
|
{siteConfig.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
{socialItems.length > 0 && (
|
{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) => (
|
{socialItems.map((item) => (
|
||||||
<a
|
<a
|
||||||
key={item.key}
|
key={item.key}
|
||||||
@@ -67,7 +71,7 @@ export function RightSidebar() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={item.label}
|
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" />
|
<FontAwesomeIcon icon={item.icon} className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
@@ -75,16 +79,18 @@ export function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{siteConfig.aboutShort && (
|
{siteConfig.aboutShort && (
|
||||||
<p className="mt-2 max-w-[11rem] text-center text-[13px] text-slate-600 dark:text-slate-200">
|
<p className="mt-3 flex items-center gap-2 text-[13px] text-slate-600 dark:text-slate-200">
|
||||||
{siteConfig.aboutShort}
|
<FontAwesomeIcon icon={faIdCard} className="h-3 w-3 text-slate-400" />
|
||||||
|
<span>{siteConfig.aboutShort}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{tags.length > 0 && (
|
{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">
|
<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="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
<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>
|
</h2>
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-[13px]">
|
<div className="mt-2 flex flex-wrap gap-2 text-[13px]">
|
||||||
@@ -104,12 +110,16 @@ export function RightSidebar() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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
|
<Link
|
||||||
href="/tags"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
59
components/scroll-reveal.tsx
Normal file
59
components/scroll-reveal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ThemeToggle } from './theme-toggle';
|
import { ThemeToggle } from './theme-toggle';
|
||||||
|
import { NavMenu, NavLinkItem, IconKey } from './nav-menu';
|
||||||
import { siteConfig } from '@/lib/config';
|
import { siteConfig } from '@/lib/config';
|
||||||
import { allPages } from 'contentlayer/generated';
|
import { allPages } from 'contentlayer/generated';
|
||||||
|
|
||||||
@@ -8,34 +9,73 @@ export function SiteHeader() {
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
.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 (
|
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">
|
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-slate-900 dark:text-slate-100">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="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}
|
{siteConfig.title}
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-4 text-base sm:text-lg">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<NavMenu items={navItems} />
|
||||||
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>
|
|
||||||
))}
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</nav>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
@@ -16,17 +18,21 @@ export function ThemeToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const next = theme === 'dark' ? 'light' : 'dark';
|
const next = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => setTheme(next)}
|
||||||
aria-label={theme === 'dark' ? '切換為淺色主題' : '切換為深色主題'}
|
aria-label={theme === 'dark' ? '切換為淺色主題' : '切換為深色主題'}
|
||||||
>
|
>
|
||||||
<span className="text-lg leading-none">
|
<FontAwesomeIcon
|
||||||
{theme === 'dark' ? '☀' : '☾'}
|
icon={isDark ? faSun : faMoon}
|
||||||
</span>
|
className={`h-4 w-4 transition-transform duration-260 ease-snappy ${
|
||||||
|
isDark ? 'rotate-0 text-amber-400' : 'rotate-180 text-blue-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export const siteConfig = {
|
|||||||
accentTextDark:
|
accentTextDark:
|
||||||
process.env.NEXT_PUBLIC_COLOR_ACCENT_TEXT_DARK || '#93c5fd'
|
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',
|
ogImage: process.env.NEXT_PUBLIC_OG_DEFAULT_IMAGE || '/assets/og-default.jpg',
|
||||||
twitterCard:
|
twitterCard:
|
||||||
(process.env.NEXT_PUBLIC_TWITTER_CARD_TYPE as
|
(process.env.NEXT_PUBLIC_TWITTER_CARD_TYPE as
|
||||||
|
|||||||
55
lib/posts.ts
55
lib/posts.ts
@@ -47,3 +47,58 @@ export function getAllTagsWithCount(): { tag: string; slug: string; count: numbe
|
|||||||
return b.count - a.count;
|
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
4063
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,8 @@
|
|||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-next": "^13.5.11",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.18",
|
"tailwindcss": "^3.4.18",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@@ -2,6 +2,62 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
:root {
|
||||||
@apply bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100 text-[15px] sm:text-base;
|
--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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,32 @@ module.exports = {
|
|||||||
textDark: 'var(--color-accent-text-dark)'
|
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) => ({
|
typography: (theme) => ({
|
||||||
DEFAULT: {
|
DEFAULT: {
|
||||||
css: {
|
css: {
|
||||||
|
|||||||
Reference in New Issue
Block a user