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:
50
CLAUDE.md
50
CLAUDE.md
@@ -70,3 +70,53 @@ Pushing only to `content/` (personal-blog) does NOT trigger deployment. The main
|
||||
## Language
|
||||
|
||||
The site's default locale is `zh-TW`. UI text, labels, and timestamps are in Traditional Chinese.
|
||||
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
|
||||
- **Medical professionals & students**: Seek clinical insights, case studies, and medical education content
|
||||
- **General public**: Interested inpersonal reflections, medicine explainedaccessibly, and lifestyle content
|
||||
- **Tech enthusiasts & developers**: Drawn to HomeLab, technical tutorials, and developer environment content
|
||||
- **Patients & advocates**: Those with similar conditions (Usher syndrome, hearing/vision impairments) seeking understanding and community
|
||||
|
||||
**Context**: Readers visit for deep, reflective content—often in quiet environments, seeking to learn, reflect, or connect with personal experiences. They value clarity, authenticity, and quality over speed.
|
||||
|
||||
**Job to be done**: Gain meaningful knowledge, find resonance with personal experiences, understand complex topics (medical/technical) in approachable terms.
|
||||
|
||||
### Brand Personality
|
||||
|
||||
- **Voice**: Reflective, professional, and thoughtful—like a trusted physician who also happens to be a developer
|
||||
- **3-word personality**: Professional & refined, Thoughtful & reflective, Technical & practical, Approachable & human
|
||||
- **Emotional goals**: Calm & contemplative, Inspired & curious
|
||||
|
||||
**Not**: Corporate, salesy, alarmist (like news sites), or overly technical/clinical.
|
||||
|
||||
### Aesthetic Direction
|
||||
|
||||
**Visual tone**: Warm & organic with academic & scholarly sensibility, combined with modern technical clarity
|
||||
|
||||
**References**:
|
||||
- Medium (medium.com): Readability-focused, minimal distractions, clean typography
|
||||
- Personal tech blogs: Individual personality, character, and hands-on authenticity
|
||||
- Library aesthetic: Quiet, thoughtful, knowledge-rich environment
|
||||
|
||||
**Anti-references** (explicitly avoid):
|
||||
- News sites: Cluttered, headline-focused, clickbait design
|
||||
- Social media feeds: Infinite scroll, attention-grabbing tactics, dopamine-driven design
|
||||
- Corporate/SaaS: Too polished, salesy, or uniform corporate branding
|
||||
- Dry technical docs: Lacking personality, purely functional
|
||||
|
||||
**Theme**: Both light and dark modes equally important—light for daytime readability, dark for late-night focused reading. Accent colors should be warm (avoid reds/yellows which feel urgent/alerting).
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Calm-first design**: Space, breathing room, and typography hierarchy should prioritize relaxed reading over visual stimulation. Avoid jarring transitions or animation that distracts from content.
|
||||
|
||||
2. **Warm technicality**: Blend technical precision with human warmth—clean, efficient interfaces that don't feel cold or sterile. The HomeLab/developer content should feel hands-on, not just theoretical.
|
||||
|
||||
3. **Academic elegance**: Typography and layout should honor the scholarly nature of medical writing and technical explanations—clear hierarchy, proper spacing, and readability first.
|
||||
|
||||
4. **Inclusive accessibility**: Consider hearing/vision impairments (user has Usher syndrome): high contrast, readable text, motion sensitivity support, clear navigation, and no time-based content hiding.
|
||||
|
||||
5. **Consistent rhythm**: Maintain consistent spacing, sizing, and interaction patterns across pages to create a predictable, trustworthy experience. Subtle interactions > flashy animations.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'next-view-transitions';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
@@ -222,7 +222,7 @@ export default async function BlogPostPage({ params }: Props) {
|
||||
href={`/tags/${encodeURIComponent(
|
||||
t.toLowerCase().replace(/\s+/g, '-')
|
||||
)}`}
|
||||
className="tag-chip rounded-full bg-accent-soft px-3 py-1 text-sm text-accent-textLight dark:bg-slate-800 dark:text-slate-100"
|
||||
className="tag-chip rounded-full bg-accent-soft px-3 py-1 text-sm text-accent-textLight dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 dark:hover:text-white"
|
||||
>
|
||||
#{t}
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'next-view-transitions';
|
||||
import Link from 'next/link';
|
||||
import { getAllPostsSorted } from '@/lib/posts';
|
||||
import { PostListWithControls } from '@/components/post-list-with-controls';
|
||||
import { TimelineWrapper } from '@/components/timeline-wrapper';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ThemeProvider } from 'next-themes';
|
||||
import { Playfair_Display, LXGW_WenKai_TC } from 'next/font/google';
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { WebVitals } from '@/components/web-vitals';
|
||||
import { ViewTransitions } from 'next-view-transitions';
|
||||
import { ViewTransitionProvider } from '@/components/view-transition-provider';
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
@@ -17,12 +17,12 @@ const playfair = Playfair_Display({
|
||||
});
|
||||
|
||||
const lxgwWenKai = LXGW_WenKai_TC({
|
||||
weight: ['400', '700'], // 只加载 Regular 和 Bold
|
||||
weight: ['400', '700'],
|
||||
subsets: ['latin'],
|
||||
variable: '--font-serif-cn',
|
||||
display: 'swap',
|
||||
preload: true,
|
||||
adjustFontFallback: false, // 中文字体不需要 fallback 调整,使用系统字体作为 fallback
|
||||
adjustFontFallback: false,
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -88,7 +88,6 @@ export default async function RootLayout({
|
||||
.slice(0, 5)
|
||||
.map((p) => ({ title: p.title, url: p.url }));
|
||||
|
||||
// WebSite Schema
|
||||
const websiteSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
@@ -111,7 +110,6 @@ export default async function RootLayout({
|
||||
},
|
||||
};
|
||||
|
||||
// Organization Schema
|
||||
const organizationSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
@@ -125,28 +123,27 @@ export default async function RootLayout({
|
||||
].filter(Boolean),
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<ViewTransitions>
|
||||
<html lang={siteConfig.defaultLocale} suppressHydrationWarning className={`${playfair.variable} ${lxgwWenKai.variable}`}>
|
||||
<head>
|
||||
{/* Preconnect to Google Fonts for faster font loading */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
</head>
|
||||
<body>
|
||||
<NextTopLoader
|
||||
color={theme.accent}
|
||||
height={3}
|
||||
showSpinner={false}
|
||||
speed={200}
|
||||
shadow={`0 0 10px ${theme.accent}, 0 0 5px ${theme.accent}`}
|
||||
/>
|
||||
<JsonLd data={websiteSchema} />
|
||||
<JsonLd data={organizationSchema} />
|
||||
<style
|
||||
// Set CSS variables for accent colors (light + dark variants)
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
<html lang={siteConfig.defaultLocale} suppressHydrationWarning className={`${playfair.variable} ${lxgwWenKai.variable}`}>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
</head>
|
||||
<body>
|
||||
<NextTopLoader
|
||||
color={theme.accent}
|
||||
height={3}
|
||||
showSpinner={false}
|
||||
speed={200}
|
||||
shadow={`0 0 10px ${theme.accent}, 0 0 5px ${theme.accent}`}
|
||||
/>
|
||||
<JsonLd data={websiteSchema} />
|
||||
<JsonLd data={organizationSchema} />
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
:root {
|
||||
--color-accent: ${theme.accent};
|
||||
--color-accent-soft: ${theme.accentSoft};
|
||||
@@ -155,13 +152,14 @@ export default async function RootLayout({
|
||||
}
|
||||
`
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<LayoutShell recentPosts={recentPosts}>{children}</LayoutShell>
|
||||
<ViewTransitionProvider>
|
||||
<LayoutShell recentPosts={recentPosts}>{children}</LayoutShell>
|
||||
</ViewTransitionProvider>
|
||||
</ThemeProvider>
|
||||
<WebVitals />
|
||||
</body>
|
||||
</html>
|
||||
</ViewTransitions>
|
||||
<WebVitals />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'next-view-transitions';
|
||||
import Link from 'next/link';
|
||||
import { getAllPostsSorted } from '@/lib/posts';
|
||||
import { siteConfig } from '@/lib/config';
|
||||
import { PostListItem } from '@/components/post-list-item';
|
||||
@@ -51,7 +51,7 @@ export default function HomePage() {
|
||||
<Link
|
||||
href="/blog"
|
||||
prefetch={true}
|
||||
className="text-xs text-blue-600 hover:underline dark:text-blue-400"
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
所有文章 →
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'next-view-transitions';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'next-view-transitions';
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { FiTag, FiTrendingUp } from 'react-icons/fi';
|
||||
import { getAllTagsWithCount } from '@/lib/posts';
|
||||
@@ -87,7 +87,7 @@ export default function TagIndexPage() {
|
||||
>
|
||||
<span className={`mb-3 block h-1.5 w-16 rounded-full bg-gradient-to-r ${color}`} aria-hidden="true" />
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="type-subtitle font-semibold text-slate-900 group-hover:text-blue-600 dark:text-slate-50 dark:group-hover:text-blue-400">
|
||||
<h2 className="type-subtitle font-semibold text-slate-900 group-hover:text-accent dark:text-slate-50 dark:group-hover:text-accent">
|
||||
{tag}
|
||||
</h2>
|
||||
<span className="type-small text-slate-600 dark:text-slate-300">
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Template({ children }: { children: React.ReactNode }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -14,26 +13,6 @@ export default function Template({ children }: { children: React.ReactNode }) {
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
container.style.animation = 'none';
|
||||
container.style.opacity = '1';
|
||||
container.style.transform = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger animation on mount
|
||||
container.style.animation = 'none';
|
||||
void container.offsetHeight;
|
||||
container.style.animation = 'pageEnter 0.45s cubic-bezier(0.32, 0.72, 0, 1) forwards';
|
||||
}, [children, prefersReducedMotion]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="page-transition">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
// ViewTransitions handles page transitions - no additional wrapper needed
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
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}</>;
|
||||
}
|
||||
@@ -27,12 +27,12 @@ export const siteConfig = {
|
||||
gitea: process.env.NEXT_PUBLIC_GITEA_URL || ''
|
||||
},
|
||||
theme: {
|
||||
accent: process.env.NEXT_PUBLIC_COLOR_ACCENT || '#2563eb',
|
||||
accentSoft: process.env.NEXT_PUBLIC_COLOR_ACCENT_SOFT || '#dbeafe',
|
||||
accent: process.env.NEXT_PUBLIC_COLOR_ACCENT || '#7c3aed',
|
||||
accentSoft: process.env.NEXT_PUBLIC_COLOR_ACCENT_SOFT || '#f3e8ff',
|
||||
accentTextLight:
|
||||
process.env.NEXT_PUBLIC_COLOR_ACCENT_TEXT_LIGHT || '#1d4ed8',
|
||||
process.env.NEXT_PUBLIC_COLOR_ACCENT_TEXT_LIGHT || '#6d28d9',
|
||||
accentTextDark:
|
||||
process.env.NEXT_PUBLIC_COLOR_ACCENT_TEXT_DARK || '#93c5fd'
|
||||
process.env.NEXT_PUBLIC_COLOR_ACCENT_TEXT_DARK || '#c4b5fd'
|
||||
},
|
||||
navIconOverrides: {
|
||||
titles: {
|
||||
|
||||
1377
package-lock.json
generated
1377
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,6 @@
|
||||
"next": "^16.0.7",
|
||||
"next-contentlayer2": "^0.5.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"next-view-transitions": "^0.3.5",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
--duration-260: 260ms;
|
||||
|
||||
/* Custom box shadows */
|
||||
--shadow-lifted: 0 12px 30px -14px rgba(15, 23, 42, 0.25);
|
||||
--shadow-outline: 0 0 0 1px rgba(59, 130, 246, 0.25);
|
||||
--shadow-lifted: 0 12px 30px -14px rgba(0, 0, 0, 0.35);
|
||||
--shadow-outline: 0 0 0 1px rgba(139, 92, 246, 0.25);
|
||||
|
||||
/* Custom keyframes */
|
||||
--animate-fade-in-up: fade-in-up 0.6s ease-out both;
|
||||
@@ -1384,28 +1384,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pageEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.99);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.page-transition {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-transition {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll reveal animations - CSS only */
|
||||
.scroll-reveal {
|
||||
opacity: 0;
|
||||
@@ -1765,14 +1743,14 @@ body {
|
||||
|
||||
/* Code Syntax Highlighting Styles (rehype-pretty-code) */
|
||||
.prose pre {
|
||||
@apply overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700;
|
||||
@apply overflow-x-auto rounded-lg border border-slate-700 dark:border-slate-700;
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1.5rem 0;
|
||||
background-color: #f8fafc;
|
||||
background-color: #0f172a;
|
||||
}
|
||||
|
||||
.dark .prose pre {
|
||||
background-color: #0f172a;
|
||||
background-color: #020617;
|
||||
}
|
||||
|
||||
.prose pre > code {
|
||||
@@ -1794,34 +1772,34 @@ body {
|
||||
width: 1.5rem;
|
||||
margin-right: 1.5rem;
|
||||
text-align: right;
|
||||
color: #94a3b8;
|
||||
color: #475569;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dark .prose pre > code > [data-line]::before {
|
||||
color: #475569;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Highlighted lines */
|
||||
.prose pre > code > [data-highlighted-line] {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border-left-color: rgb(59, 130, 246);
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
border-left-color: rgb(139, 92, 246);
|
||||
}
|
||||
|
||||
.dark .prose pre > code > [data-highlighted-line] {
|
||||
background-color: rgba(96, 165, 250, 0.15);
|
||||
border-left-color: rgb(96, 165, 250);
|
||||
background-color: rgba(196, 181, 253, 0.15);
|
||||
border-left-color: rgb(196, 181, 253);
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.prose :not(pre) > code {
|
||||
@apply rounded bg-slate-100 px-1.5 py-0.5 text-sm font-semibold text-slate-800 dark:bg-slate-800 dark:text-slate-200;
|
||||
@apply rounded bg-slate-800 px-1.5 py-0.5 text-sm font-semibold text-slate-100 dark:bg-slate-700 dark:text-slate-100;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Code title (if specified in markdown: ```js title="example.js") */
|
||||
.prose [data-rehype-pretty-code-title] {
|
||||
@apply rounded-t-lg border border-b-0 border-slate-200 bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300;
|
||||
@apply rounded-t-lg border border-b-0 border-slate-700 bg-slate-800 px-4 py-2 text-sm font-semibold text-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user