Refactor navigation to floating left icon bar on desktop
- Replace horizontal top nav with vertical icon-only sidebar on desktop (lg+) - Add hover tooltips showing page labels - Active page highlighted with accent color background - Theme toggle integrated at bottom of nav bar - Mobile retains hamburger menu with full-screen overlay - Fix mobile menu script for Astro view transitions - Reduce hero/layout top padding since desktop nav no longer occupies header space - Add left padding to main content to accommodate fixed left nav Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
64855131ba
commit
1345012a6a
@ -1,100 +1,120 @@
|
|||||||
---
|
---
|
||||||
import ThemeToggle from './ThemeToggle.astro';
|
import ThemeToggle from './ThemeToggle.astro';
|
||||||
|
|
||||||
|
const currentPath = Astro.url.pathname;
|
||||||
|
|
||||||
|
// Navigation items
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: 'Home', icon: 'home' },
|
||||||
|
{ href: '/dev', label: 'Dev', icon: 'code' },
|
||||||
|
{ href: '/blog', label: 'Blog', icon: 'file' },
|
||||||
|
{ href: '/hubert', label: 'Hubert', icon: 'chat' },
|
||||||
|
{ href: '/contact', label: 'Contact', icon: 'mail' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if a path is active
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
if (href === '/') {
|
||||||
|
return currentPath === '/';
|
||||||
|
}
|
||||||
|
return currentPath.startsWith(href);
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<nav class="fixed top-0 left-0 w-full z-50 px-6 lg:px-12 py-6 lg:py-8 flex justify-between items-center backdrop-blur-md bg-[var(--theme-overlay)] border-b border-[var(--theme-border-secondary)]">
|
<!-- Desktop Navigation: Fixed left sidebar (lg: 1024px+) -->
|
||||||
<!-- Left side - branding and theme toggle -->
|
<nav class="hidden lg:flex fixed left-6 top-1/2 -translate-y-1/2 z-50 flex-col items-center gap-1 p-2 bg-[var(--theme-overlay)] backdrop-blur-md border border-[var(--theme-border-primary)] rounded-2xl">
|
||||||
<div class="flex items-center gap-6">
|
{navItems.map((item) => (
|
||||||
<a href="/" class="text-[10px] font-mono text-[var(--theme-text-muted)] tracking-widest uppercase hover:text-brand-accent transition-colors duration-300">NV / 2026</a>
|
<a
|
||||||
<div class="hidden md:block">
|
href={item.href}
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right side navigation -->
|
|
||||||
<div class="flex items-center gap-6 lg:gap-10 ml-auto">
|
|
||||||
<div class="hidden md:flex items-center gap-10 lg:gap-12">
|
|
||||||
<a href="/"
|
|
||||||
class:list={[
|
|
||||||
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
|
|
||||||
Astro.url.pathname === '/' ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
|
|
||||||
]}>
|
|
||||||
<span class="relative z-10">Home</span>
|
|
||||||
<span class:list={[
|
|
||||||
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
|
|
||||||
Astro.url.pathname === '/' ? "w-full" : "w-0 group-hover:w-full"
|
|
||||||
]}></span>
|
|
||||||
</a>
|
|
||||||
<a href="/dev"
|
|
||||||
class:list={[
|
|
||||||
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
|
|
||||||
Astro.url.pathname.startsWith('/dev') ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
|
|
||||||
]}>
|
|
||||||
<span class="relative z-10">Dev</span>
|
|
||||||
<span class:list={[
|
|
||||||
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
|
|
||||||
Astro.url.pathname.startsWith('/dev') ? "w-full" : "w-0 group-hover:w-full"
|
|
||||||
]}></span>
|
|
||||||
</a>
|
|
||||||
<a href="/blog"
|
|
||||||
class:list={[
|
|
||||||
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
|
|
||||||
Astro.url.pathname.startsWith('/blog') ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
|
|
||||||
]}>
|
|
||||||
<span class="relative z-10">Blog</span>
|
|
||||||
<span class:list={[
|
|
||||||
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
|
|
||||||
Astro.url.pathname.startsWith('/blog') ? "w-full" : "w-0 group-hover:w-full"
|
|
||||||
]}></span>
|
|
||||||
</a>
|
|
||||||
<a href="/hubert"
|
|
||||||
class:list={[
|
|
||||||
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
|
|
||||||
Astro.url.pathname.startsWith('/hubert') ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
|
|
||||||
]}>
|
|
||||||
<span class="relative z-10">Hubert</span>
|
|
||||||
<span class:list={[
|
|
||||||
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
|
|
||||||
Astro.url.pathname.startsWith('/hubert') ? "w-full" : "w-0 group-hover:w-full"
|
|
||||||
]}></span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="/contact"
|
|
||||||
class:list={[
|
class:list={[
|
||||||
"hidden md:block border px-5 lg:px-6 py-2.5 lg:py-3 text-xs font-bold uppercase tracking-[0.15em] transition-all duration-300 rounded-full",
|
"nav-icon relative group w-10 h-10 flex items-center justify-center rounded-xl transition-colors duration-200",
|
||||||
Astro.url.pathname.startsWith('/contact')
|
isActive(item.href)
|
||||||
? "border-brand-accent bg-brand-accent text-brand-dark"
|
? "bg-brand-accent/10 text-brand-accent"
|
||||||
: "border-[var(--theme-border-strong)] text-[var(--theme-text-primary)] hover:border-brand-accent hover:bg-brand-accent hover:text-brand-dark"
|
: "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)] hover:bg-[var(--theme-hover-bg-strong)]"
|
||||||
]}>
|
]}
|
||||||
Let's Talk
|
aria-label={item.label}
|
||||||
|
data-icon={item.icon}
|
||||||
|
>
|
||||||
|
{/* Home icon */}
|
||||||
|
{item.icon === 'home' && (
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{/* Code icon */}
|
||||||
|
{item.icon === 'code' && (
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="16 18 22 12 16 6"></polyline>
|
||||||
|
<polyline points="8 6 2 12 8 18"></polyline>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{/* File/Blog icon */}
|
||||||
|
{item.icon === 'file' && (
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||||
|
<polyline points="14 2 14 8 20 8"></polyline>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||||
|
<polyline points="10 9 9 9 8 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{/* Chat icon */}
|
||||||
|
{item.icon === 'chat' && (
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{/* Mail icon */}
|
||||||
|
{item.icon === 'mail' && (
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
|
||||||
|
<polyline points="22,6 12,13 2,6"></polyline>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{/* Tooltip */}
|
||||||
|
<span class="absolute left-full ml-3 px-3 py-1.5 bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] rounded-lg text-xs font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-200 shadow-lg">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div class="w-6 h-px bg-[var(--theme-border-primary)] my-2"></div>
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<div class="nav-theme-toggle w-10 h-10 flex items-center justify-center">
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile Navigation: Top bar with hamburger (< lg) -->
|
||||||
|
<nav class="lg:hidden fixed top-0 left-0 w-full z-50 px-6 py-6 flex justify-between items-center backdrop-blur-md bg-[var(--theme-overlay)] border-b border-[var(--theme-border-secondary)]">
|
||||||
|
<!-- Left side - branding -->
|
||||||
|
<a href="/" class="text-[10px] font-mono text-[var(--theme-text-muted)] tracking-widest uppercase hover:text-brand-accent transition-colors duration-300">NV / 2026</a>
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
<div class="md:hidden flex items-center">
|
<button
|
||||||
<button
|
id="mobile-menu-toggle"
|
||||||
id="mobile-menu-toggle"
|
class="p-2 text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)] transition-colors z-[60]"
|
||||||
class="p-2 text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)] transition-colors z-[60]"
|
aria-label="Toggle menu"
|
||||||
aria-label="Toggle menu"
|
aria-expanded="false"
|
||||||
aria-expanded="false"
|
>
|
||||||
>
|
<!-- Hamburger icon -->
|
||||||
<!-- Hamburger icon -->
|
<svg id="menu-icon-open" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg id="menu-icon-open" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16M4 18h16"></path>
|
</svg>
|
||||||
</svg>
|
<!-- Close icon (hidden by default) -->
|
||||||
<!-- Close icon (hidden by default) -->
|
<svg id="menu-icon-close" class="w-6 h-6 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg id="menu-icon-close" class="w-6 h-6 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18L18 6M6 6l12 12"></path>
|
</svg>
|
||||||
</svg>
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile Menu Overlay -->
|
<!-- Mobile Menu Overlay -->
|
||||||
<div
|
<div
|
||||||
id="mobile-menu"
|
id="mobile-menu"
|
||||||
class="fixed inset-0 z-40 bg-[var(--theme-overlay-heavy)] backdrop-blur-xl transform translate-x-full transition-transform duration-300 ease-out md:hidden"
|
class="fixed inset-0 z-40 bg-[var(--theme-overlay-heavy)] backdrop-blur-xl transform translate-x-full transition-transform duration-300 ease-out lg:hidden"
|
||||||
>
|
>
|
||||||
<!-- Menu Content -->
|
<!-- Menu Content -->
|
||||||
<div class="flex flex-col justify-center items-center h-full px-8">
|
<div class="flex flex-col justify-center items-center h-full px-8">
|
||||||
@ -155,48 +175,86 @@ import ThemeToggle from './ThemeToggle.astro';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<style>
|
||||||
const toggle = document.getElementById('mobile-menu-toggle');
|
/* Custom styling for the nav theme toggle to make it more compact */
|
||||||
const menu = document.getElementById('mobile-menu');
|
.nav-theme-toggle :global(.theme-toggle-group) {
|
||||||
const iconOpen = document.getElementById('menu-icon-open');
|
flex-direction: column;
|
||||||
const iconClose = document.getElementById('menu-icon-close');
|
gap: 0.5rem;
|
||||||
const mobileNavLinks = document.querySelectorAll('.mobile-nav-link');
|
margin-left: 0;
|
||||||
|
|
||||||
let isOpen = false;
|
|
||||||
|
|
||||||
function toggleMenu() {
|
|
||||||
isOpen = !isOpen;
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
menu?.classList.remove('translate-x-full');
|
|
||||||
menu?.classList.add('translate-x-0');
|
|
||||||
iconOpen?.classList.add('hidden');
|
|
||||||
iconClose?.classList.remove('hidden');
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
toggle?.setAttribute('aria-expanded', 'true');
|
|
||||||
} else {
|
|
||||||
menu?.classList.add('translate-x-full');
|
|
||||||
menu?.classList.remove('translate-x-0');
|
|
||||||
iconOpen?.classList.remove('hidden');
|
|
||||||
iconClose?.classList.add('hidden');
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
toggle?.setAttribute('aria-expanded', 'false');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle?.addEventListener('click', toggleMenu);
|
.nav-theme-toggle :global(.theme-toggle-group > div:first-child) {
|
||||||
|
display: none; /* Hide the arrow icon in compact mode */
|
||||||
|
}
|
||||||
|
|
||||||
// Close menu when clicking a link
|
.nav-theme-toggle :global(.theme-toggle-group > div:last-child) {
|
||||||
mobileNavLinks.forEach(link => {
|
flex-direction: column;
|
||||||
link.addEventListener('click', () => {
|
}
|
||||||
if (isOpen) toggleMenu();
|
</style>
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close menu on escape key
|
<script>
|
||||||
document.addEventListener('keydown', (e) => {
|
function initMobileNav() {
|
||||||
if (e.key === 'Escape' && isOpen) {
|
const toggle = document.getElementById('mobile-menu-toggle');
|
||||||
toggleMenu();
|
const menu = document.getElementById('mobile-menu');
|
||||||
|
const iconOpen = document.getElementById('menu-icon-open');
|
||||||
|
const iconClose = document.getElementById('menu-icon-close');
|
||||||
|
const mobileNavLinks = document.querySelectorAll('.mobile-nav-link');
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
isOpen = !isOpen;
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
menu?.classList.remove('translate-x-full');
|
||||||
|
menu?.classList.add('translate-x-0');
|
||||||
|
iconOpen?.classList.add('hidden');
|
||||||
|
iconClose?.classList.remove('hidden');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
toggle?.setAttribute('aria-expanded', 'true');
|
||||||
|
} else {
|
||||||
|
menu?.classList.add('translate-x-full');
|
||||||
|
menu?.classList.remove('translate-x-0');
|
||||||
|
iconOpen?.classList.remove('hidden');
|
||||||
|
iconClose?.classList.add('hidden');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
toggle?.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// Remove old listeners by cloning
|
||||||
|
if (toggle) {
|
||||||
|
const newToggle = toggle.cloneNode(true);
|
||||||
|
toggle.parentNode?.replaceChild(newToggle, toggle);
|
||||||
|
newToggle.addEventListener('click', toggleMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu when clicking a link
|
||||||
|
mobileNavLinks.forEach(link => {
|
||||||
|
link.addEventListener('click', () => {
|
||||||
|
if (isOpen) toggleMenu();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu on escape key (only add once)
|
||||||
|
let escapeListenerAdded = false;
|
||||||
|
function addEscapeListener() {
|
||||||
|
if (escapeListenerAdded) return;
|
||||||
|
escapeListenerAdded = true;
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const menu = document.getElementById('mobile-menu');
|
||||||
|
if (menu && !menu.classList.contains('translate-x-full')) {
|
||||||
|
const toggle = document.getElementById('mobile-menu-toggle');
|
||||||
|
toggle?.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on load and view transitions
|
||||||
|
initMobileNav();
|
||||||
|
addEscapeListener();
|
||||||
|
document.addEventListener('astro:page-load', initMobileNav);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -54,8 +54,8 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The Content -->
|
<!-- The Content -->
|
||||||
<!-- Adjusted pt to clear fixed nav since BaseLayout padding is removed -->
|
<!-- Mobile: pt-24 clears mobile top bar. Desktop: pt-16 since nav is on left side -->
|
||||||
<div class="absolute inset-0 z-20 flex flex-col justify-between p-6 md:p-12 lg:p-16 pt-32 lg:pt-40 pointer-events-auto">
|
<div class="absolute inset-0 z-20 flex flex-col justify-between p-6 md:p-12 lg:p-16 pt-24 lg:pt-16 pointer-events-auto">
|
||||||
|
|
||||||
<!-- Top Metadata -->
|
<!-- Top Metadata -->
|
||||||
<div class="flex justify-between items-start w-full intro-element opacity-0 translate-y-4 transition-all duration-1000 ease-out delay-300">
|
<div class="flex justify-between items-start w-full intro-element opacity-0 translate-y-4 transition-all duration-1000 ease-out delay-300">
|
||||||
|
|||||||
@ -118,7 +118,7 @@ const personSchema = {
|
|||||||
<GridOverlay />
|
<GridOverlay />
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
|
||||||
<main class:list={["relative z-10 min-h-screen pb-24", { "pt-32 lg:pt-48": usePadding }]}>
|
<main class:list={["relative z-10 min-h-screen pb-24 lg:pl-24", { "pt-32 lg:pt-12": usePadding }]}>
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user