Refine navigation and post UI
This commit is contained in:
@@ -114,7 +114,7 @@ export default function BlogPostPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{relatedPosts.map((related) => (
|
{relatedPosts.map((related) => (
|
||||||
<PostCard key={related._id} post={related} />
|
<PostCard key={related._id} post={related} showTags={false} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ export default function BlogIndexPage() {
|
|||||||
|
|
||||||
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">
|
<header className="space-y-1">
|
||||||
所有文章
|
<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">
|
||||||
{tagline}
|
<MetaItem icon={faPenNib}>
|
||||||
</p>
|
{tagline}
|
||||||
|
</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">
|
||||||
{post.published_at && (
|
<div className="flex flex-wrap gap-3 text-xs">
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-500">
|
{post.published_at && (
|
||||||
{new Date(post.published_at).toLocaleDateString(
|
<MetaItem icon={faCalendarDays}>
|
||||||
siteConfig.defaultLocale
|
{new Date(post.published_at).toLocaleDateString(
|
||||||
)}
|
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}
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, 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';
|
||||||
|
|
||||||
@@ -74,43 +81,52 @@ export function PostListWithControls({ posts, pageSize }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<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 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 className="flex w-full items-center gap-2 text-sm sm:w-auto">
|
<div className="flex w-full items-center text-sm sm:w-auto">
|
||||||
<label htmlFor="post-search" className="text-xs">
|
<label htmlFor="post-search" className="sr-only">
|
||||||
搜尋:
|
搜尋文章
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative w-full sm:w-64">
|
||||||
id="post-search"
|
<FontAwesomeIcon
|
||||||
type="search"
|
icon={faMagnifyingGlass}
|
||||||
placeholder="輸入標題或標籤"
|
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
|
||||||
value={searchTerm}
|
/>
|
||||||
onChange={(event) => setSearchTerm(event.target.value)}
|
<input
|
||||||
className="w-full rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm transition 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 sm:w-56"
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Post } from 'contentlayer/generated';
|
import type { Post } from 'contentlayer/generated';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faArrowLeftLong, faArrowRightLong } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
current: Post;
|
current: Post;
|
||||||
@@ -79,6 +81,10 @@ function Station({ station }: { station: StationConfig }) {
|
|||||||
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}`}
|
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">
|
<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}
|
{label}
|
||||||
</p>
|
</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">
|
<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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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