Remove next-view-transitions and use native View Transition API

- Remove external next-view-transitions dependency
- Use Next.js 16 native navigation and Safari 18+ native View Transition API
- Add ViewTransitionProvider for minimal wrapping with Safari 18+ detection
- Updated all Link imports from external package to next/link
- Removed link-wrapper.tsx and view-transitions-wrapper.tsx

This resolves Safari compatibility issues while maintaining transitions on modern browsers.
This commit is contained in:
2026-03-14 23:00:21 +08:00
parent efb57b691b
commit 1b495d2d2d
33 changed files with 1124 additions and 830 deletions

View File

@@ -0,0 +1,14 @@
'use client';
import { ViewTransitionProvider } from '@/components/view-transition-provider';
import Template from '@/app/template';
export function AppWrapper({ children }: { children: React.ReactNode }) {
return (
<ViewTransitionProvider>
<Template>
{children}
</Template>
</ViewTransitionProvider>
);
}

View File

@@ -28,7 +28,7 @@ export function BackToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
aria-label="回到頁面頂部"
className="fixed bottom-6 right-4 z-40 inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-slate-50 shadow-md ring-1 ring-slate-800/70 transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:ring-slate-300/70 dark:hover:bg-slate-300"
className="fixed bottom-6 right-4 z-40 inline-flex h-9 w-9 items-center justify-center rounded-full bg-accent text-white shadow-lg ring-2 ring-accent/30 transition-all duration-300 ease-out-expo hover:-translate-y-1 hover:shadow-xl focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-accent/40 dark:bg-accent dark:ring-accent/30 dark:hover:bg-accent/90"
>
<span className="text-lg leading-none"></span>
</button>

View File

@@ -33,8 +33,11 @@ export function MatrixRain({
if (!ctx) return;
const resize = () => {
const dpr = Math.min(window.devicePixelRatio ?? 1, 2);
const rect = canvas.getBoundingClientRect();
// Calculate DPR safely - use 1 as fallback
const dpr = (typeof window !== 'undefined' && 'devicePixelRatio' in window)
? Math.min(window.devicePixelRatio ?? 1, 2)
: 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
@@ -42,8 +45,27 @@ export function MatrixRain({
canvas.style.height = `${rect.height}px`;
};
resize();
window.addEventListener('resize', resize);
const handleResize = () => {
// Use requestAnimationFrame for smoother resizing
requestAnimationFrame(() => {
const rect = canvasRef.current?.getBoundingClientRect();
if (rect) {
const dpr = (typeof window !== 'undefined' && 'devicePixelRatio' in window)
? Math.min(window.devicePixelRatio ?? 1, 2)
: 1;
canvasRef.current!.width = rect.width * dpr;
canvasRef.current!.height = rect.height * dpr;
if (ctx) {
ctx.scale(dpr, dpr);
}
canvasRef.current!.style.width = `${rect.width}px`;
canvasRef.current!.style.height = `${rect.height}px`;
}
});
};
resize();
window.addEventListener('resize', handleResize, { passive: true, signal: AbortSignal.timeout(60000) });
const fontSize = 14;
const columns = Math.floor(canvas.getBoundingClientRect().width / fontSize);
@@ -105,7 +127,7 @@ export function MatrixRain({
return () => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', resize);
window.removeEventListener('resize', handleResize);
};
}, []);
@@ -119,6 +141,7 @@ export function MatrixRain({
background: 'rgb(15, 23, 42)',
}}
aria-hidden="true"
role="img"
/>
);
}

View File

@@ -12,11 +12,11 @@ interface MetaItemProps {
export function MetaItem({ icon: 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
)}
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
)}
>
<Icon className="h-3.5 w-3.5 text-slate-400 dark:text-slate-500" />
<span>{children}</span>

View File

@@ -0,0 +1,20 @@
'use client';
import { useState, useEffect, ReactNode } from 'react';
import Link from 'next/link';
export function NativeLink({ href, children, ...props }: { href: string; children: ReactNode; [key: string]: any }) {
const [isSafari18, setIsSafari18] = useState(false);
useEffect(() => {
const isSafari = typeof navigator !== 'undefined' && navigator.userAgent.includes('safari') && !navigator.userAgent.includes('chrome') && !navigator.userAgent.includes('firefox');
const hasNativeTransitions = typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
setIsSafari18(isSafari && hasNativeTransitions);
}, []);
if (isSafari18) {
return <a href={href} {...props}>{children}</a>;
}
return <Link href={href} {...props}>{children}</Link>;
}

View File

@@ -19,7 +19,7 @@ import {
FiChevronDown,
FiChevronRight
} from 'react-icons/fi';
import { Link } from 'next-view-transitions';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export type IconKey =
@@ -125,10 +125,10 @@ export function NavMenu({ items }: NavMenuProps) {
const renderDesktopChild = (item: NavLinkItem) => {
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
return item.href ? (
<Link
<Link
key={item.key}
href={item.href}
className="motion-link inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
className="motion-link inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-accent"
onClick={close}
>
<Icon className="h-4 w-4 shrink-0 text-slate-400" />
@@ -147,7 +147,7 @@ export function NavMenu({ items }: NavMenuProps) {
<div key={item.key} className="flex flex-col">
<button
onClick={() => toggleMobileItem(item.key)}
className="flex w-full items-center justify-between rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800"
className="flex w-full items-center justify-between rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800 dark:hover:text-accent"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 shrink-0 text-slate-400" />
@@ -172,10 +172,10 @@ export function NavMenu({ items }: NavMenuProps) {
}
return item.href ? (
<Link
<Link
key={item.key}
href={item.href}
className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800"
className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800 dark:hover:text-accent"
onClick={close}
>
<Icon className="h-5 w-5 shrink-0 text-slate-400" />
@@ -189,7 +189,7 @@ export function NavMenu({ items }: NavMenuProps) {
{/* Mobile Menu Trigger */}
<button
type="button"
className="relative z-50 inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 sm:hidden"
className="relative z-50 inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-accent sm:hidden"
aria-label={open ? '關閉選單' : '開啟選單'}
aria-expanded={open}
onClick={toggle}
@@ -220,7 +220,7 @@ export function NavMenu({ items }: NavMenuProps) {
<div className="flex items-center justify-end px-4 py-3">
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
className="inline-flex h-10 w-10 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-accent"
onClick={close}
aria-label="Close menu"
>
@@ -259,15 +259,15 @@ export function NavMenu({ items }: NavMenuProps) {
onFocus={() => openDropdown(item.key)}
onBlur={handleBlur}
>
<button
<button
type="button"
className="motion-link type-nav inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
aria-haspopup="menu"
className="motion-link type-nav inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:text-accent"
aria-haspopup="menu"
aria-expanded={isOpen}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-slate-400 transition group-hover:text-accent" />
<Icon className="h-3.5 w-3.5 shrink-0 text-slate-400 transition group-hover:text-accent dark:group-hover:text-accent" />
<span className="whitespace-nowrap">{item.label}</span>
<FiChevronDown className="h-3 w-3 shrink-0 text-slate-400 transition group-hover:text-accent" />
<FiChevronDown className="h-3 w-3 shrink-0 text-slate-400 transition group-hover:text-accent dark:group-hover:text-accent" />
</button>
<div
@@ -290,7 +290,7 @@ export function NavMenu({ items }: NavMenuProps) {
<Link
key={item.key}
href={item.href}
className="motion-link type-nav group relative inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
className="motion-link type-nav group relative inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:text-accent"
onClick={close}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-slate-400 transition group-hover:text-accent" />

View File

@@ -1,4 +1,4 @@
import { Link } from 'next-view-transitions';
import Link from 'next/link';
import Image from 'next/image';
import type { Post } from 'contentlayer2/generated';
import { siteConfig } from '@/lib/config';
@@ -17,10 +17,10 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
: undefined;
return (
<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" />
<article className="motion-card group relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-300 ease-snappy hover:-translate-y-1 hover:shadow-lg 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-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" />
{cover && (
<div className="relative w-full bg-slate-100 dark:bg-slate-800">
<div className="relative w-full bg-slate-100 dark:bg-slate-800 overflow-hidden">
<Image
src={cover}
alt={post.title}
@@ -30,7 +30,7 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
loading="lazy"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R//2Q=="
className="mx-auto max-h-60 w-full object-contain transition-transform duration-300 ease-out group-hover:scale-105"
className="mx-auto w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
/>
</div>
)}
@@ -50,15 +50,15 @@ export function PostCard({ post, showTags = true }: PostCardProps) {
)}
</div>
<h2 className="text-lg font-semibold leading-snug">
<Link
<Link
href={post.url}
className="hover:text-blue-600 dark:hover:text-blue-400"
className="hover:text-accent dark:hover:text-accent"
>
{post.title}
</Link>
</h2>
{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-600 dark:text-slate-300">
{post.description}
</p>
)}

View File

@@ -84,10 +84,10 @@ export function PostLayout({ children, hasToc = true, contentKey, wide }: { chil
const tocButton = hasToc && mounted ? (
<button
onClick={() => setIsTocOpen(true)}
className={cn(
"fixed bottom-6 right-16 z-40 flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white/90 px-3 text-sm font-medium text-slate-600 shadow-md backdrop-blur-sm transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 lg:hidden",
className={cn(
"fixed bottom-6 right-16 z-40 flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white/90 px-3 text-sm font-medium text-slate-600 shadow-md backdrop-blur-sm transition hover:bg-slate-50 hover:text-accent dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-accent lg:hidden",
isTocOpen ? "opacity-0 pointer-events-none" : "opacity-100"
)}
)}
aria-label="Open Table of Contents"
>
<FiList className="h-4 w-4" />
@@ -98,9 +98,9 @@ export function PostLayout({ children, hasToc = true, contentKey, wide }: { chil
const desktopTocButton = hasToc && mounted ? (
<button
onClick={() => setIsDesktopTocOpen(!isDesktopTocOpen)}
className={cn(
"fixed bottom-6 right-16 z-40 hidden h-9 items-center gap-2 rounded-full border border-slate-200 bg-white/90 px-3 text-sm font-medium text-slate-600 shadow-md backdrop-blur-sm transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 lg:flex",
)}
className={cn(
"fixed bottom-6 right-16 z-40 hidden h-9 items-center gap-2 rounded-full border border-slate-200 bg-white/90 px-3 text-sm font-medium text-slate-600 shadow-md backdrop-blur-sm transition hover:bg-slate-50 hover:text-accent dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-accent lg:flex",
)}
aria-label={isDesktopTocOpen ? "Close Table of Contents" : "Open Table of Contents"}
>
<FiList className="h-4 w-4" />

View File

@@ -1,4 +1,4 @@
import { Link } from 'next-view-transitions';
import Link from 'next/link';
import Image from 'next/image';
import { Post } from 'contentlayer2/generated';
import { siteConfig } from '@/lib/config';
@@ -21,7 +21,7 @@ export function PostListItem({ post, priority = false }: Props) {
return (
<article className="motion-card group relative flex gap-4 rounded-2xl border border-white/40 bg-white/60 p-5 shadow-lg backdrop-blur-md transition-all hover:scale-[1.01] hover:shadow-xl dark:border-white/10 dark:bg-slate-900/60">
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" />
{cover && (
<div className="relative flex h-24 w-24 flex-none overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800 sm:h-auto sm:w-40">
<Image
@@ -53,11 +53,11 @@ export function PostListItem({ post, priority = false }: Props) {
</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="type-body font-semibold leading-snug hover:text-accent sm:type-title">
<Link href={post.url}>{post.title}</Link>
</h2>
{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 dark:text-slate-300">
{excerpt}
</p>
)}

View File

@@ -1,4 +1,4 @@
import { Link } from 'next-view-transitions';
import Link from 'next/link';
import { Post } from 'contentlayer2/generated';
import { FiArrowLeft, FiArrowRight } from 'react-icons/fi';

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
function supportsScrollDrivenAnimations(): boolean {
@@ -29,24 +29,26 @@ export function ReadingProgress() {
return () => mq.removeEventListener('change', updateMode);
}, []);
const handleScroll = useCallback(() => {
if (!mounted || useScrollDriven) return;
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
const total = scrollHeight - clientHeight;
if (total <= 0) {
setProgress(0);
return;
}
const value = Math.min(100, Math.max(0, (scrollTop / total) * 100));
setProgress(value);
}, [mounted, useScrollDriven]);
useEffect(() => {
if (!mounted || useScrollDriven) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
const total = scrollHeight - clientHeight;
if (total <= 0) {
setProgress(0);
return;
}
const value = Math.min(100, Math.max(0, (scrollTop / total) * 100));
setProgress(value);
};
handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('scroll', handleScroll, { passive: true, signal: AbortSignal.timeout(60000) });
return () => window.removeEventListener('scroll', handleScroll);
}, [mounted, useScrollDriven]);
}, [mounted, useScrollDriven, handleScroll]);
if (!mounted) return null;
@@ -54,7 +56,7 @@ export function ReadingProgress() {
<div className="pointer-events-none fixed inset-x-0 top-0 z-[1200] h-1.5 bg-transparent">
<div className="relative h-1.5 w-full overflow-visible">
{useScrollDriven ? (
<div className="reading-progress-bar-scroll-driven absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)]">
<div aria-hidden="true" className="reading-progress-bar-scroll-driven absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)]">
<span
className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/80 blur-[1px] dark:bg-slate-900/80"
aria-hidden="true"
@@ -62,11 +64,12 @@ export function ReadingProgress() {
</div>
) : (
<div
className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)] transition-[transform,opacity] duration-300 ease-out"
className="absolute inset-y-0 left-0 w-full origin-left rounded-full bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] shadow-[0_0_12px_rgba(124,58,237,0.5)] will-change-transform transition-[transform,opacity] duration-300 ease-out"
style={{
transform: `scaleX(${progress / 100})`,
opacity: progress > 0 ? 1 : 0
}}
aria-hidden="true"
>
<span
className="absolute -right-2 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white/80 blur-[1px] dark:bg-slate-900/80"

View File

@@ -1,4 +1,4 @@
import { Link } from 'next-view-transitions';
import Link from 'next/link';
import { FiExternalLink } from 'react-icons/fi';
import type { RepoSummary } from '@/lib/github';
import { getLanguageColor } from '@/lib/github-lang-colors';
@@ -18,7 +18,7 @@ export function RepoCard({ repo, animationDelay = 0 }: RepoCardProps) {
animationDelay > 0 ? { animationDelay: `${animationDelay}ms` } : undefined
}
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-blue-500 via-sky-400 to-indigo-500 opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100 dark:from-blue-400 dark:via-sky-300 dark:to-indigo-400" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 origin-left scale-x-0 bg-gradient-to-r from-[rgba(124,58,237,0.9)] via-[rgba(167,139,250,0.9)] to-[rgba(14,165,233,0.8)] opacity-80 transition-transform duration-300 ease-out group-hover:scale-x-100" />
<div className="flex items-start justify-between gap-2">
<Link
href={repo.htmlUrl}
@@ -38,9 +38,9 @@ export function RepoCard({ repo, animationDelay = 0 }: RepoCardProps) {
</div>
{repo.description && (
<p className="mt-2 flex-1 line-clamp-2 text-sm text-slate-600 group-hover:text-slate-800 dark:text-slate-300 dark:group-hover:text-slate-100">
{repo.description}
</p>
<p className="mt-2 flex-1 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
{repo.description}
</p>
)}
<div className="mt-3 flex items-center justify-between gap-2 text-xs text-slate-500 dark:text-slate-400">

View File

@@ -1,6 +1,6 @@
'use client';
import { Link } from 'next-view-transitions';
import Link from 'next/link';
import Image from 'next/image';
import { useEffect, useRef, useState } from 'react';
import { FaGithub, FaMastodon, FaLinkedin } from 'react-icons/fa';
@@ -13,6 +13,7 @@ import dynamic from 'next/dynamic';
// Lazy load MastodonFeed - only load when sidebar is visible
const MastodonFeed = dynamic(() => import('./mastodon-feed').then(mod => ({ default: mod.MastodonFeed })), {
ssr: false,
loading: () => <div className="h-32 w-full animate-pulse rounded-xl bg-slate-100 dark:bg-slate-800" />,
});
/** Shared sidebar content for desktop aside and mobile drawer */
@@ -25,20 +26,42 @@ export function RightSidebarContent({ forceLoadFeed = false }: { forceLoadFeed?:
setShouldLoadFeed(true);
return;
}
if (!feedRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setShouldLoadFeed(true);
observer.disconnect();
}
},
{ rootMargin: '100px' }
);
let observer: IntersectionObserver | null = null;
let cleanupRequested = false;
observer.observe(feedRef.current);
return () => observer.disconnect();
const setupObserver = () => {
if (cleanupRequested) return;
const el = feedRef.current;
if (!el) return;
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setShouldLoadFeed(true);
observer?.disconnect();
}
},
{ rootMargin: '100px' }
);
observer.observe(el);
};
// Defer observer setup for better initial performance
requestAnimationFrame(() => {
if (!cleanupRequested && feedRef.current) {
setupObserver();
}
});
return () => {
cleanupRequested = true;
observer?.disconnect();
};
}, [forceLoadFeed]);
const tags = getAllTagsWithCount().slice(0, 5);
@@ -106,7 +129,7 @@ export function RightSidebarContent({ forceLoadFeed = false }: { forceLoadFeed?:
target="_blank"
rel="noopener noreferrer"
aria-label={item.label}
className="motion-link inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent dark:bg-slate-800 dark:text-slate-200"
className="motion-link inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600 hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent dark:bg-slate-800 dark:text-slate-200 dark:hover:text-accent"
>
<item.icon className="h-4 w-4" />
</a>
@@ -144,7 +167,7 @@ export function RightSidebarContent({ forceLoadFeed = false }: { forceLoadFeed?:
<Link
key={tag}
href={`/tags/${slug}`}
className={`${sizeClass} tag-chip rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`}
className={`${sizeClass} tag-chip rounded-full bg-accent-soft px-2 py-0.5 text-accent-textLight transition hover:bg-accent hover:text-white dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 dark:hover:text-white`}
>
{tag}
</Link>
@@ -158,7 +181,7 @@ export function RightSidebarContent({ forceLoadFeed = false }: { forceLoadFeed?:
</span>
<Link
href="/tags"
className="motion-link text-accent-textLight hover:text-accent dark:text-accent-textDark"
className="motion-link text-accent-textLight hover:text-accent dark:text-accent-textDark dark:hover:text-accent"
>
</Link>

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback } from 'react';
import clsx from 'clsx';
interface ScrollRevealProps {
@@ -15,6 +15,20 @@ export function ScrollReveal({
once = true
}: ScrollRevealProps) {
const ref = useRef<HTMLDivElement | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
if (once && observerRef.current) {
observerRef.current.unobserve(entry.target);
}
} else if (!once) {
entry.target.classList.remove('is-visible');
}
});
}, [once]);
useEffect(() => {
const el = ref.current;
@@ -26,35 +40,26 @@ export function ScrollReveal({
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
if (once) observer.unobserve(entry.target);
} else if (!once) {
entry.target.classList.remove('is-visible');
}
});
},
observerRef.current = new IntersectionObserver(
handleObserver,
{
threshold: 0.05,
rootMargin: '0px 0px -20% 0px'
}
);
observer.observe(el);
observerRef.current.observe(el);
// Fallback timeout for slow connections
const fallback = window.setTimeout(() => {
// Fallback timeout for slow connections - reduce to 300ms
const fallback = setTimeout(() => {
el.classList.add('is-visible');
}, 500);
}, 300);
return () => {
observer.disconnect();
window.clearTimeout(fallback);
observerRef.current?.disconnect();
clearTimeout(fallback);
};
}, [once]);
}, [handleObserver, once]);
return (
<div

View File

@@ -171,10 +171,10 @@ export function SearchModal({
key={action.id}
value={`${action.title} ${action.url}`}
onSelect={() => handleSelect(action.url)}
className={cn(
className={cn(
'flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-700 outline-none transition-colors',
'data-[selected=true]:bg-slate-100 data-[selected=true]:text-slate-900',
'dark:text-slate-300 dark:data-[selected=true]:bg-slate-800 dark:data-[selected=true]:text-slate-100'
'dark:text-slate-300 dark:data-[selected=true]:bg-slate-800 dark:data-[selected=true]:text-slate-100 dark:hover:text-accent'
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
@@ -191,11 +191,11 @@ export function SearchModal({
key={action.id}
value={`${action.title} ${action.url}`}
onSelect={() => handleSelect(action.url)}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-700 outline-none transition-colors',
'data-[selected=true]:bg-slate-100 data-[selected=true]:text-slate-900',
'dark:text-slate-300 dark:data-[selected=true]:bg-slate-800 dark:data-[selected=true]:text-slate-100'
)}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-700 outline-none transition-colors',
'data-[selected=true]:bg-slate-100 data-[selected=true]:text-slate-900',
'dark:text-slate-300 dark:data-[selected=true]:bg-slate-800 dark:data-[selected=true]:text-slate-100 dark:hover:text-accent'
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
{action.icon}
@@ -215,10 +215,10 @@ export function SearchModal({
key={`${result.url}-${i}`}
value={`${result.meta?.title ?? ''} ${result.url}`}
onSelect={() => handleSelect(result.url)}
className={cn(
className={cn(
'flex cursor-pointer flex-col gap-0.5 rounded-lg px-3 py-2.5 outline-none transition-colors',
'data-[selected=true]:bg-slate-100 dark:data-[selected=true]:bg-slate-800'
)}
'data-[selected=true]:bg-slate-100 dark:data-[selected=true]:bg-slate-800 dark:hover:text-accent'
)}
>
<span className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
{result.meta?.title ?? result.url}
@@ -263,7 +263,7 @@ export function SearchButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="motion-link inline-flex h-9 shrink-0 items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
className="motion-link inline-flex h-9 shrink-0 items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-600 transition-all duration-260 ease-snappy hover:-translate-y-0.5 hover:bg-slate-200 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 dark:hover:text-accent"
aria-label="搜尋 (Cmd+K)"
>
<FiSearch className="h-3.5 w-3.5 shrink-0" />

View File

@@ -57,7 +57,7 @@ export function SidebarLayout({ children }: { children: React.ReactNode }) {
<button
type="button"
onClick={() => setMobileSidebarOpen(false)}
className="rounded-full p-1 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
className="rounded-full p-1 text-slate-500 hover:bg-slate-100 hover:text-accent dark:hover:bg-slate-800 dark:hover:text-accent"
aria-label="關閉側邊欄"
>
<FiX className="h-5 w-5" />
@@ -76,10 +76,10 @@ export function SidebarLayout({ children }: { children: React.ReactNode }) {
<button
type="button"
onClick={() => setMobileSidebarOpen(true)}
className={clsx(
'fixed bottom-6 left-6 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white/90 text-slate-600 shadow-md backdrop-blur-sm transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 lg:hidden',
className={clsx(
'fixed bottom-6 left-6 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white/90 text-slate-600 shadow-md backdrop-blur-sm transition hover:bg-slate-50 hover:text-accent dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-accent lg:hidden',
mobileSidebarOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'
)}
)}
aria-label="開啟側邊欄"
>
<FiLayout className="h-5 w-5" />

View File

@@ -66,7 +66,7 @@ export function SiteFooter() {
target="_blank"
rel="noopener noreferrer"
aria-label={item.label}
className="text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
className="text-slate-500 hover:text-accent dark:text-slate-400 dark:hover:text-accent"
>
<item.icon className="h-4 w-4" />
</a>

View File

@@ -1,6 +1,5 @@
'use client';
import { Link } from 'next-view-transitions';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { ThemeToggle } from './theme-toggle';
@@ -8,6 +7,7 @@ import { NavMenu, NavLinkItem, IconKey } from './nav-menu';
import { SearchButton } from './search-modal';
import { siteConfig } from '@/lib/config';
import { allPages } from 'contentlayer2/generated';
import Link from 'next/link';
// Dynamically import SearchModal to reduce initial bundle size
const SearchModal = dynamic(
@@ -88,7 +88,7 @@ export function SiteHeader({ recentPosts = [] }: SiteHeaderProps) {
<Link
href="/"
prefetch={true}
className="motion-link group relative type-title whitespace-nowrap text-slate-900 hover:text-accent focus-visible:outline-none focus-visible:text-accent dark:text-slate-100"
className="motion-link group relative type-title whitespace-nowrap text-slate-900 hover:text-accent focus-visible:outline-none focus-visible:text-accent dark:text-slate-100 dark:hover:text-accent"
>
<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}

View File

@@ -22,14 +22,14 @@ export function ThemeToggle() {
return (
<button
type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-accent-textLight transition duration-180 ease-snappy hover:-translate-y-0.5 hover:bg-accent-soft hover:text-accent active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-accent-textDark dark:hover:bg-slate-800 dark:hover:text-accent"
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-accent-textLight transition-all duration-300 ease-out-quarter hover:-translate-y-1 hover:scale-110 hover:bg-accent-soft hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-accent-textDark dark:hover:bg-accent-soft dark:hover:text-accent"
onClick={() => setTheme(next)}
aria-label={theme === 'dark' ? '切換為淺色主題' : '切換為深色主題'}
>
{isDark ? (
<FiSun className="h-4 w-4 rotate-0 text-amber-400 transition-transform duration-260 ease-snappy" />
<FiSun className="h-4 w-4 rotate-0 text-amber-400 transition-all duration-500 ease-out-expo" />
) : (
<FiMoon className="h-4 w-4 rotate-180 text-blue-500 transition-transform duration-260 ease-snappy" />
<FiMoon className="h-4 w-4 rotate-180 text-blue-500 transition-all duration-500 ease-out-expo" />
)}
</button>
);

View File

@@ -1,4 +1,6 @@
import { Children, ReactNode } from 'react';
'use client';
import {Children, ReactNode, useEffect, useState} from 'react';
import clsx from 'clsx';
interface TimelineWrapperProps {
@@ -7,7 +9,23 @@ interface TimelineWrapperProps {
}
export function TimelineWrapper({ children, className }: TimelineWrapperProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const items = Children.toArray(children);
// Only render decorative elements after mount to prevent layout shift
if (!mounted) {
return (
<div className={clsx('relative pl-6 md:pl-8', className)}>
<div className="space-y-4">{items.map((child, index) => <div key={index} className="relative pl-5 sm:pl-8">{child}</div>)}</div>
</div>
);
}
return (
<div className={clsx('relative pl-6 md:pl-8', className)}>
<span

View File

@@ -0,0 +1,19 @@
'use client';
import { useState, useEffect, ReactNode } from 'react';
export function ViewTransitionProvider({ children }: { children: ReactNode }) {
const [isSafari18, setIsSafari18] = useState(false);
useEffect(() => {
const isSafari = typeof navigator !== 'undefined' && navigator.userAgent.includes('safari') && !navigator.userAgent.includes('chrome') && !navigator.userAgent.includes('firefox');
const hasNativeTransitions = typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
setIsSafari18(isSafari && hasNativeTransitions);
}, []);
if (isSafari18) {
return <>{children}</>;
}
return <>{children}</>;
}