Add system theme toggler

This commit is contained in:
2024-10-30 10:47:47 -04:00
parent 8863630674
commit 670d346f31
3 changed files with 187 additions and 76 deletions

View File

@@ -32,7 +32,7 @@
"fs": "0.0.1-security", "fs": "0.0.1-security",
"geist": "^1.3.1", "geist": "^1.3.1",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "^15.0.1", "next": "^15.0.2",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"radix-ui": "^1.0.1", "radix-ui": "^1.0.1",
"react": "^18.3.1", "react": "^18.3.1",

104
pnpm-lock.yaml generated
View File

@@ -52,10 +52,10 @@ importers:
version: 0.11.1(typescript@5.6.3)(zod@3.23.8) version: 0.11.1(typescript@5.6.3)(zod@3.23.8)
'@vercel/analytics': '@vercel/analytics':
specifier: ^1.3.2 specifier: ^1.3.2
version: 1.3.2(next@15.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) version: 1.3.2(next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@vercel/speed-insights': '@vercel/speed-insights':
specifier: ^1.0.14 specifier: ^1.0.14
version: 1.0.14(next@15.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) version: 1.0.14(next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
class-variance-authority: class-variance-authority:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
@@ -70,13 +70,13 @@ importers:
version: 0.0.1-security version: 0.0.1-security
geist: geist:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.1(next@15.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) version: 1.3.1(next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
lucide-react: lucide-react:
specifier: ^0.454.0 specifier: ^0.454.0
version: 0.454.0(react@18.3.1) version: 0.454.0(react@18.3.1)
next: next:
specifier: ^15.0.1 specifier: ^15.0.2
version: 15.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-themes: next-themes:
specifier: ^0.3.0 specifier: ^0.3.0
version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -350,56 +350,56 @@ packages:
'@jridgewell/trace-mapping@0.3.25': '@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@next/env@15.0.1': '@next/env@15.0.2':
resolution: {integrity: sha512-lc4HeDUKO9gxxlM5G2knTRifqhsY6yYpwuHspBZdboZe0Gp+rZHBNNSIjmQKDJIdRXiXGyVnSD6gafrbQPvILQ==} resolution: {integrity: sha512-c0Zr0ModK5OX7D4ZV8Jt/wqoXtitLNPwUfG9zElCZztdaZyNVnN40rDXVZ/+FGuR4CcNV5AEfM6N8f+Ener7Dg==}
'@next/eslint-plugin-next@15.0.1': '@next/eslint-plugin-next@15.0.1':
resolution: {integrity: sha512-bKWsMaGPbiFAaGqrDJvbE8b4Z0uKicGVcgOI77YM2ui3UfjHMr4emFPrZTLeZVchi7fT1mooG2LxREfUUClIKw==} resolution: {integrity: sha512-bKWsMaGPbiFAaGqrDJvbE8b4Z0uKicGVcgOI77YM2ui3UfjHMr4emFPrZTLeZVchi7fT1mooG2LxREfUUClIKw==}
'@next/swc-darwin-arm64@15.0.1': '@next/swc-darwin-arm64@15.0.2':
resolution: {integrity: sha512-C9k/Xv4sxkQRTA37Z6MzNq3Yb1BJMmSqjmwowoWEpbXTkAdfOwnoKOpAb71ItSzoA26yUTIo6ZhN8rKGu4ExQw==} resolution: {integrity: sha512-GK+8w88z+AFlmt+ondytZo2xpwlfAR8U6CRwXancHImh6EdGfHMIrTSCcx5sOSBei00GyLVL0ioo1JLKTfprgg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/swc-darwin-x64@15.0.1': '@next/swc-darwin-x64@15.0.2':
resolution: {integrity: sha512-uHl13HXOuq1G7ovWFxCACDJHTSDVbn/sbLv8V1p+7KIvTrYQ5HNoSmKBdYeEKRRCbEmd+OohOgg9YOp8Ux3MBg==} resolution: {integrity: sha512-KUpBVxIbjzFiUZhiLIpJiBoelqzQtVZbdNNsehhUn36e2YzKHphnK8eTUW1s/4aPy5kH/UTid8IuVbaOpedhpw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/swc-linux-arm64-gnu@15.0.1': '@next/swc-linux-arm64-gnu@15.0.2':
resolution: {integrity: sha512-LvyhvxHOihFTEIbb35KxOc3q8w8G4xAAAH/AQnsYDEnOvwawjL2eawsB59AX02ki6LJdgDaHoTEnC54Gw+82xw==} resolution: {integrity: sha512-9J7TPEcHNAZvwxXRzOtiUvwtTD+fmuY0l7RErf8Yyc7kMpE47MIQakl+3jecmkhOoIyi/Rp+ddq7j4wG6JDskQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@next/swc-linux-arm64-musl@15.0.1': '@next/swc-linux-arm64-musl@15.0.2':
resolution: {integrity: sha512-vFmCGUFNyk/A5/BYcQNhAQqPIw01RJaK6dRO+ZEhz0DncoW+hJW1kZ8aH2UvTX27zPq3m85zN5waMSbZEmANcQ==} resolution: {integrity: sha512-BjH4ZSzJIoTTZRh6rG+a/Ry4SW0HlizcPorqNBixBWc3wtQtj4Sn9FnRZe22QqrPnzoaW0ctvSz4FaH4eGKMww==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@next/swc-linux-x64-gnu@15.0.1': '@next/swc-linux-x64-gnu@15.0.2':
resolution: {integrity: sha512-5by7IYq0NCF8rouz6Qg9T97jYU68kaClHPfGpQG2lCZpSYHtSPQF1kjnqBTd34RIqPKMbCa4DqCufirgr8HM5w==} resolution: {integrity: sha512-i3U2TcHgo26sIhcwX/Rshz6avM6nizrZPvrDVDY1bXcLH1ndjbO8zuC7RoHp0NSK7wjJMPYzm7NYL1ksSKFreA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@next/swc-linux-x64-musl@15.0.1': '@next/swc-linux-x64-musl@15.0.2':
resolution: {integrity: sha512-lmYr6H3JyDNBJLzklGXLfbehU3ay78a+b6UmBGlHls4xhDXBNZfgb0aI67sflrX+cGBnv1LgmWzFlYrAYxS1Qw==} resolution: {integrity: sha512-AMfZfSVOIR8fa+TXlAooByEF4OB00wqnms1sJ1v+iu8ivwvtPvnkwdzzFMpsK5jA2S9oNeeQ04egIWVb4QWmtQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@next/swc-win32-arm64-msvc@15.0.1': '@next/swc-win32-arm64-msvc@15.0.2':
resolution: {integrity: sha512-DS8wQtl6diAj0eZTdH0sefykm4iXMbHT4MOvLwqZiIkeezKpkgPFcEdFlz3vKvXa2R/2UEgMh48z1nEpNhjeOQ==} resolution: {integrity: sha512-JkXysDT0/hEY47O+Hvs8PbZAeiCQVxKfGtr4GUpNAhlG2E0Mkjibuo8ryGD29Qb5a3IOnKYNoZlh/MyKd2Nbww==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/swc-win32-x64-msvc@15.0.1': '@next/swc-win32-x64-msvc@15.0.2':
resolution: {integrity: sha512-4Ho2ggvDdMKlZ/0e9HNdZ9ngeaBwtc+2VS5oCeqrbXqOgutX6I4U2X/42VBw0o+M5evn4/7v3zKgGHo+9v/VjA==} resolution: {integrity: sha512-foaUL0NqJY/dX0Pi/UcZm5zsmSk5MtP/gxx3xOPyREkMFN+CTjctPfu3QaqrQHinaKdPnMWPJDKt4VjDfTBe/Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -2183,16 +2183,16 @@ packages:
react: ^16.8 || ^17 || ^18 react: ^16.8 || ^17 || ^18
react-dom: ^16.8 || ^17 || ^18 react-dom: ^16.8 || ^17 || ^18
next@15.0.1: next@15.0.2:
resolution: {integrity: sha512-PSkFkr/w7UnFWm+EP8y/QpHrJXMqpZzAXpergB/EqLPOh4SGPJXv1wj4mslr2hUZBAS9pX7/9YLIdxTv6fwytw==} resolution: {integrity: sha512-rxIWHcAu4gGSDmwsELXacqAPUk+j8dV/A9cDF5fsiCMpkBDYkO2AEaL1dfD+nNmDiU6QMCFN8Q30VEKapT9UHQ==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
'@opentelemetry/api': ^1.1.0 '@opentelemetry/api': ^1.1.0
'@playwright/test': ^1.41.2 '@playwright/test': ^1.41.2
babel-plugin-react-compiler: '*' babel-plugin-react-compiler: '*'
react: ^18.2.0 || 19.0.0-rc-69d4b800-20241021 react: ^18.2.0 || 19.0.0-rc-02c0e824-20241028
react-dom: ^18.2.0 || 19.0.0-rc-69d4b800-20241021 react-dom: ^18.2.0 || 19.0.0-rc-02c0e824-20241028
sass: ^1.3.0 sass: ^1.3.0
peerDependenciesMeta: peerDependenciesMeta:
'@opentelemetry/api': '@opentelemetry/api':
@@ -3069,34 +3069,34 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
'@next/env@15.0.1': {} '@next/env@15.0.2': {}
'@next/eslint-plugin-next@15.0.1': '@next/eslint-plugin-next@15.0.1':
dependencies: dependencies:
fast-glob: 3.3.1 fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.0.1': '@next/swc-darwin-arm64@15.0.2':
optional: true optional: true
'@next/swc-darwin-x64@15.0.1': '@next/swc-darwin-x64@15.0.2':
optional: true optional: true
'@next/swc-linux-arm64-gnu@15.0.1': '@next/swc-linux-arm64-gnu@15.0.2':
optional: true optional: true
'@next/swc-linux-arm64-musl@15.0.1': '@next/swc-linux-arm64-musl@15.0.2':
optional: true optional: true
'@next/swc-linux-x64-gnu@15.0.1': '@next/swc-linux-x64-gnu@15.0.2':
optional: true optional: true
'@next/swc-linux-x64-musl@15.0.1': '@next/swc-linux-x64-musl@15.0.2':
optional: true optional: true
'@next/swc-win32-arm64-msvc@15.0.1': '@next/swc-win32-arm64-msvc@15.0.2':
optional: true optional: true
'@next/swc-win32-x64-msvc@15.0.1': '@next/swc-win32-x64-msvc@15.0.2':
optional: true optional: true
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
@@ -4026,16 +4026,16 @@ snapshots:
'@typescript-eslint/types': 8.12.1 '@typescript-eslint/types': 8.12.1
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@vercel/analytics@1.3.2(next@15.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': '@vercel/analytics@1.3.2(next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
server-only: 0.0.1 server-only: 0.0.1
optionalDependencies: optionalDependencies:
next: 15.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next: 15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1 react: 18.3.1
'@vercel/speed-insights@1.0.14(next@15.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': '@vercel/speed-insights@1.0.14(next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
optionalDependencies: optionalDependencies:
next: 15.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next: 15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1 react: 18.3.1
abs-svg-path@0.1.1: {} abs-svg-path@0.1.1: {}
@@ -4759,9 +4759,9 @@ snapshots:
functions-have-names@1.2.3: {} functions-have-names@1.2.3: {}
geist@1.3.1(next@15.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): geist@1.3.1(next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)):
dependencies: dependencies:
next: 15.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next: 15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
get-intrinsic@1.2.4: get-intrinsic@1.2.4:
dependencies: dependencies:
@@ -5111,9 +5111,9 @@ snapshots:
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
next@15.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
'@next/env': 15.0.1 '@next/env': 15.0.2
'@swc/counter': 0.1.3 '@swc/counter': 0.1.3
'@swc/helpers': 0.5.13 '@swc/helpers': 0.5.13
busboy: 1.6.0 busboy: 1.6.0
@@ -5123,14 +5123,14 @@ snapshots:
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
styled-jsx: 5.1.6(react@18.3.1) styled-jsx: 5.1.6(react@18.3.1)
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 15.0.1 '@next/swc-darwin-arm64': 15.0.2
'@next/swc-darwin-x64': 15.0.1 '@next/swc-darwin-x64': 15.0.2
'@next/swc-linux-arm64-gnu': 15.0.1 '@next/swc-linux-arm64-gnu': 15.0.2
'@next/swc-linux-arm64-musl': 15.0.1 '@next/swc-linux-arm64-musl': 15.0.2
'@next/swc-linux-x64-gnu': 15.0.1 '@next/swc-linux-x64-gnu': 15.0.2
'@next/swc-linux-x64-musl': 15.0.1 '@next/swc-linux-x64-musl': 15.0.2
'@next/swc-win32-arm64-msvc': 15.0.1 '@next/swc-win32-arm64-msvc': 15.0.2
'@next/swc-win32-x64-msvc': 15.0.1 '@next/swc-win32-x64-msvc': 15.0.2
sharp: 0.33.5 sharp: 0.33.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'

View File

@@ -30,25 +30,12 @@ export function Navigation() {
// Update the document title based on the current pathname // Update the document title based on the current pathname
useEffect(() => { useEffect(() => {
const currentItem = navItems.find(item => item.href === pathname); const currentItem = navItems.find(item => item.href === pathname);
document.title = currentItem ? `${currentItem.label} - Sean O'Connor` : 'Sean O\'Connor'; // Default title document.title = currentItem ? `${currentItem.label} - Sean O'Connor` : 'Sean O\'Connor';
}, [pathname]); }, [pathname]);
// Determine the icon to show based on the theme // Always render a consistent initial state for SSR
const themeIcon = theme === 'dark' ? <Sun size={20} /> : <Moon size={20} />; if (!mounted) {
const defaultThemeIcon = <SunMoon size={20} />; // Default icon for server-side rendering return (
return (
<>
{/* Backdrop overlay - faster fade */}
<div
className={`fixed inset-0 bg-background/80 backdrop-blur-sm lg:hidden transition-opacity duration-200 ${
isOpen ? 'opacity-100 z-50' : 'opacity-0 pointer-events-none'
}`}
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
{/* Existing nav component */}
<nav className="sticky top-0 z-[51] bg-background border-b border-border shadow-sm"> <nav className="sticky top-0 z-[51] bg-background border-b border-border shadow-sm">
<div className="relative max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="relative max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between"> <div className="flex h-16 items-center justify-between">
@@ -56,13 +43,11 @@ export function Navigation() {
<span className="text-lg font-bold">Sean O'Connor</span> <span className="text-lg font-bold">Sean O'Connor</span>
</Link> </Link>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
{/* Render the theme icon as a placeholder */}
<button <button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} className="ml-4 text-sm font-medium text-muted-foreground hover:text-primary flex items-center"
className="text-sm font-medium text-muted-foreground hover:text-primary flex items-center lg:hidden"
aria-label="Toggle theme" aria-label="Toggle theme"
> >
{mounted ? themeIcon : defaultThemeIcon} {/* Use the default icon for server-side rendering */} <SunMoon size={20} />
</button> </button>
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
@@ -92,11 +77,137 @@ export function Navigation() {
); );
})} })}
<button <button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="ml-4 text-sm font-medium text-muted-foreground hover:text-primary flex items-center" className="ml-4 text-sm font-medium text-muted-foreground hover:text-primary flex items-center"
aria-label="Toggle theme" aria-label="Toggle theme"
> >
{mounted ? themeIcon : defaultThemeIcon} {/* Use the default icon for server-side rendering */} <SunMoon size={20} />
</button>
</div>
</div>
</div>
{/* Mobile menu - delayed fade */}
<div
className={`
absolute
top-full
left-0
right-0
z-40
bg-background
border-b
border-border
shadow-sm
lg:hidden
overflow-hidden
transition-all
duration-300
delay-50
${isOpen ? 'h-auto opacity-100' : 'h-0 opacity-0'}
`}
>
<div className="flex flex-col p-4 space-y-2 bg-background">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`
flex items-center
text-sm font-medium
transition-colors
${isActive ? 'text-primary' : 'text-muted-foreground'}
hover:text-primary
gap-2
`}
onClick={() => setIsOpen(false)}
>
<item.icon size={16} />
<span>{item.label}</span>
</Link>
);
})}
</div>
</div>
</nav>
);
}
// Client-side only code
const themeIcon = theme === 'dark' ? <Sun size={20} /> :
theme === 'light' ? <Moon size={20} /> :
<SunMoon size={20} />;
const cycleTheme = () => {
if (theme === 'dark') {
setTheme('light');
} else if (theme === 'light') {
setTheme('system');
} else {
setTheme('dark');
}
};
return (
<>
{/* Backdrop overlay - faster fade */}
<div
className={`fixed inset-0 bg-background/80 backdrop-blur-sm lg:hidden transition-opacity duration-200 ${
isOpen ? 'opacity-100 z-50' : 'opacity-0 pointer-events-none'
}`}
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
{/* Existing nav component */}
<nav className="sticky top-0 z-[51] bg-background border-b border-border shadow-sm">
<div className="relative max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<Link href="/">
<span className="text-lg font-bold">Sean O'Connor</span>
</Link>
<div className="flex items-center space-x-4">
<button
onClick={cycleTheme}
className="text-sm font-medium text-muted-foreground hover:text-primary flex items-center lg:hidden"
aria-label="Toggle theme"
>
{themeIcon}
</button>
<button
onClick={() => setIsOpen(!isOpen)}
className="text-gray-500 hover:text-primary focus:outline-none relative h-6 w-6 lg:hidden"
aria-label={isOpen ? 'Close menu' : 'Open menu'}
>
<span className={`absolute inset-0 transition-opacity duration-300 ${isOpen ? 'opacity-0' : 'opacity-100'}`}>
<Menu size={24} />
</span>
<span className={`absolute inset-0 transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0'}`}>
<X size={24} />
</span>
</button>
</div>
<div className="hidden lg:flex lg:space-x-4 lg:justify-end">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`text-sm font-medium transition-colors ${isActive ? 'text-primary' : 'text-muted-foreground'} hover:text-primary flex items-center gap-2`}
>
<item.icon size={16} />
{item.label}
</Link>
);
})}
<button
onClick={cycleTheme}
className="ml-4 text-sm font-medium text-muted-foreground hover:text-primary flex items-center"
aria-label="Toggle theme"
>
{themeIcon}
</button> </button>
</div> </div>
</div> </div>