nicholais-website/app/components/sidebar-menu.tsx
2025-10-08 18:10:07 -06:00

116 lines
3.6 KiB
TypeScript

'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import { useScrollContext } from '@/app/providers/LenisProvider'
import { useDoomOverlay } from '@/app/providers/DoomOverlayProvider'
import { useWhiteboard } from '@/app/providers/WhiteboardProvider'
type Item = { href: string; label: string }
const items: Item[] = [
{ href: '#about', label: 'About' },
{ href: '#writing', label: 'Writing' },
{ href: '#contact', label: 'Contact' },
]
export default function SidebarMenu() {
const [active, setActive] = useState<string>('')
const { lenis } = useScrollContext()
const { open: openDoom } = useDoomOverlay()
const { open: openWhiteboard } = useWhiteboard()
useEffect(() => {
const observers: IntersectionObserver[] = []
const onIntersect: IntersectionObserverCallback = (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActive(`#${entry.target.id}`)
}
})
}
const opts: IntersectionObserverInit = { rootMargin: '-30% 0px -60% 0px', threshold: 0.1 }
const observer = new IntersectionObserver(onIntersect, opts)
items.forEach((it) => {
const id = it.href.replace('#', '')
const el = document.getElementById(id)
if (el) observer.observe(el)
})
observers.push(observer)
return () => {
observers.forEach((o) => o.disconnect())
}
}, [])
return (
<aside className="sticky top-24 h-fit">
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-neutral-800 dark:text-neutral-300">
Menu
</p>
<nav aria-label="Section menu" className="flex flex-col gap-1">
{items.map((it) => {
const isActive = active === it.href
return (
<Link
key={it.href}
href={`/${it.href}`}
onClick={(e) => {
const id = it.href.replace('#', '')
const el = document.getElementById(id)
if (el && lenis) {
e.preventDefault()
lenis.scrollTo(el, { offset: -80 })
try { history.replaceState(null, '', `/${it.href}`) } catch {}
}
}}
className={cn(
'px-3 py-2 text-sm rounded-md transition-colors text-neutral-800 dark:text-neutral-200',
'hover:bg-black/[0.04] dark:hover:bg-white/5',
isActive && 'bg-black/[0.06] dark:bg-white/10'
)}
aria-current={isActive ? 'true' : undefined}
>
{it.label}
</Link>
)
})}
<button
onClick={(e) => {
// Prevent the opening click from bubbling to the modal backdrop and instantly closing it
e.stopPropagation();
openDoom();
}}
className={cn(
'px-3 py-2 text-sm rounded-md transition-colors text-left w-full',
'hover:bg-black/[0.04] dark:hover:bg-white/5',
'text-neutral-900 dark:text-neutral-100'
)}
>
running on a potato
</button>
<button
onClick={(e) => {
e.stopPropagation();
openWhiteboard();
}}
className={cn(
'px-3 py-2 text-sm rounded-md transition-colors text-left w-full',
'hover:bg-black/[0.04] dark:hover:bg-white/5',
'text-neutral-900 dark:text-neutral-100'
)}
aria-label="Open whiteboard"
>
Whiteboard
</button>
</nav>
</aside>
)
}