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:
14
components/app-wrapper.tsx
Normal file
14
components/app-wrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
20
components/native-link.tsx
Normal file
20
components/native-link.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
19
components/view-transition-provider.tsx
Normal file
19
components/view-transition-provider.tsx
Normal 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}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user