Generate a build and push to Cloudflare Pages / Build and Deploy to Cloudflare Pages (push) Successful in 1m24s
247 lines
8.3 KiB
Svelte
247 lines
8.3 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte'
|
|
import { slide, fade } from 'svelte/transition'
|
|
import { cn } from '@/lib/utils'
|
|
import { NAV_LINKS, SITE } from '@/consts'
|
|
import Logo from './Logo.svelte'
|
|
import Link from './Link.svelte'
|
|
import ThemeToggle from './ThemeToggle.svelte'
|
|
|
|
// Icons from unplugin-icons (bundled at build time, no flash)
|
|
import IconDownload from '~icons/lucide/download'
|
|
import IconMenu from '~icons/lucide/menu'
|
|
import IconX from '~icons/lucide/x'
|
|
|
|
let scrollLevel = $state(0)
|
|
let isScrolled = $state(false)
|
|
let isMobile = $state(false)
|
|
let mobileMenuOpen = $state(false)
|
|
let activePath = $state('/')
|
|
|
|
// Width transitions from padded full-width down to content width (56rem)
|
|
// Using max() to ensure width never goes below 56rem at any scroll level
|
|
const widthMap: Record<number, string> = {
|
|
0: 'calc(100% - 3rem)', // Padded from edges at top
|
|
1: 'max(calc(100% - 4rem), 56rem)',
|
|
2: 'max(calc(100% - 6rem), 56rem)',
|
|
3: 'max(calc(100% - 8rem), 56rem)',
|
|
4: '56rem', // Matches actual content width
|
|
}
|
|
|
|
let headerWidth = $derived(isMobile ? '100%' : widthMap[scrollLevel])
|
|
|
|
onMount(() => {
|
|
activePath = window.location.pathname
|
|
|
|
const handleRouteChange = () => {
|
|
activePath = window.location.pathname
|
|
}
|
|
|
|
let resizeTimer: ReturnType<typeof setTimeout>
|
|
const handleResize = () => {
|
|
clearTimeout(resizeTimer)
|
|
resizeTimer = setTimeout(() => {
|
|
const isMobileView = window.matchMedia('(max-width: 768px)').matches
|
|
isMobile = isMobileView
|
|
if (!isMobileView && mobileMenuOpen) {
|
|
mobileMenuOpen = false
|
|
}
|
|
}, 100)
|
|
}
|
|
|
|
let scrollTimer: ReturnType<typeof setTimeout>
|
|
const handleScroll = () => {
|
|
clearTimeout(scrollTimer)
|
|
scrollTimer = setTimeout(() => {
|
|
const scrollY = window.scrollY
|
|
scrollLevel = scrollY > 500 ? 4 : scrollY > 300 ? 3 : scrollY > 150 ? 2 : scrollY > 0 ? 1 : 0
|
|
isScrolled = scrollY > 0
|
|
}, 50)
|
|
}
|
|
|
|
handleResize()
|
|
handleScroll()
|
|
|
|
window.addEventListener('popstate', handleRouteChange)
|
|
window.addEventListener('resize', handleResize)
|
|
window.addEventListener('scroll', handleScroll)
|
|
|
|
return () => {
|
|
window.removeEventListener('popstate', handleRouteChange)
|
|
window.removeEventListener('resize', handleResize)
|
|
window.removeEventListener('scroll', handleScroll)
|
|
clearTimeout(resizeTimer)
|
|
clearTimeout(scrollTimer)
|
|
}
|
|
})
|
|
|
|
$effect(() => {
|
|
if (typeof document !== 'undefined') {
|
|
if (mobileMenuOpen) {
|
|
document.body.style.overflow = 'hidden'
|
|
} else {
|
|
document.body.style.overflow = ''
|
|
}
|
|
}
|
|
})
|
|
|
|
function toggleMobileMenu() {
|
|
mobileMenuOpen = !mobileMenuOpen
|
|
}
|
|
|
|
function closeMobileMenu() {
|
|
mobileMenuOpen = false
|
|
}
|
|
|
|
function handleNavClick(href: string) {
|
|
activePath = href
|
|
}
|
|
</script>
|
|
|
|
<header
|
|
aria-label="Navigation"
|
|
role="banner"
|
|
style="width: {headerWidth};"
|
|
class={cn(
|
|
'fixed left-1/2 z-30 -translate-x-1/2 transform backdrop-blur-lg',
|
|
'bg-background/80 border-0',
|
|
'rounded-none shadow-none transition-all duration-300 ease-in-out',
|
|
'border border-transparent',
|
|
isScrolled && !isMobile && 'rounded-full',
|
|
isScrolled && !isMobile && 'backdrop-blur-md',
|
|
isScrolled && !isMobile && 'border-foreground/10',
|
|
isScrolled && !isMobile && 'border',
|
|
isScrolled && !isMobile && 'bg-background/80',
|
|
isScrolled && !isMobile && 'max-w-[min(90vw,calc(100vw-2rem))]',
|
|
!isMobile && 'top-2 lg:top-4 xl:top-6',
|
|
isMobile && 'top-0',
|
|
isMobile && 'rounded-none',
|
|
isMobile && 'border-0',
|
|
isMobile && 'shadow-none'
|
|
)}
|
|
>
|
|
<div class="mx-auto flex max-w-7xl items-center justify-between gap-4 p-4">
|
|
<a
|
|
href="/"
|
|
class="font-custom flex shrink-0 items-center gap-2 text-xl font-bold"
|
|
aria-label="Home"
|
|
title="Home"
|
|
>
|
|
<Logo class="h-8 w-8 mr-2" />
|
|
<span class="transition-opacity duration-200 ease-in-out text-foreground/90 dark:text-white">
|
|
{SITE.title}
|
|
</span>
|
|
</a>
|
|
|
|
<div class="flex items-center gap-2 md:gap-4">
|
|
<nav class="hidden items-center gap-6 md:flex" aria-label="Main navigation">
|
|
{#each NAV_LINKS as item}
|
|
{@const isActive = activePath.startsWith(item.href) && item.href !== '/'}
|
|
<div class="relative hover:scale-105 transition-transform">
|
|
<a
|
|
href={item.href}
|
|
class={cn(
|
|
'text-sm font-medium capitalize transition-colors duration-200',
|
|
'relative py-1 px-1',
|
|
'after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-0 after:bg-primary after:transition-all after:duration-300',
|
|
'hover:after:w-full hover:text-foreground',
|
|
isActive
|
|
? 'text-foreground after:w-full after:bg-primary'
|
|
: 'text-foreground/70'
|
|
)}
|
|
onclick={() => handleNavClick(item.href)}
|
|
>
|
|
{item.label}
|
|
</a>
|
|
</div>
|
|
{/each}
|
|
<a
|
|
href="/static/resume.pdf?v=2026-08"
|
|
download
|
|
class="flex items-center gap-2 rounded-full border border-foreground/20 px-4 py-1.5 text-sm font-medium transition-all duration-200 hover:border-foreground/40 hover:bg-foreground/5"
|
|
>
|
|
Resume
|
|
<IconDownload class="size-4" />
|
|
</a>
|
|
</nav>
|
|
|
|
<ThemeToggle />
|
|
|
|
{#if isMobile}
|
|
<button
|
|
onclick={toggleMobileMenu}
|
|
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
|
|
class="ml-1 h-9 w-9 rounded-full p-0 transition-colors duration-200 ease-in-out inline-flex items-center justify-center hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
|
>
|
|
{#if mobileMenuOpen}
|
|
<IconX class="h-5 w-5" />
|
|
{:else}
|
|
<IconMenu class="h-5 w-5" />
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{#if mobileMenuOpen}
|
|
<div
|
|
class="fixed inset-0 z-20 flex flex-col items-center justify-start bg-background border-0 shadow-none"
|
|
transition:fade={{ duration: 200 }}
|
|
>
|
|
<div class="flex flex-col items-center justify-start h-full pt-24 w-full p-6">
|
|
<nav class="flex flex-col items-center justify-start gap-1 w-full">
|
|
{#each NAV_LINKS as item, i}
|
|
<div
|
|
class="w-full text-start"
|
|
transition:slide={{ delay: i * 50, duration: 200 }}
|
|
>
|
|
<a
|
|
href={item.href}
|
|
onclick={closeMobileMenu}
|
|
class="dark:text-white text-lg font-bold font-custom capitalize dark:hover:text-white/80 transition-colors inline-block py-2 relative group"
|
|
>
|
|
{item.label}
|
|
<span class="absolute left-0 bottom-0 w-0 h-0.5 bg-neutral-900 dark:bg-white group-hover:w-full transition-all duration-300 ease-in-out"></span>
|
|
</a>
|
|
</div>
|
|
{/each}
|
|
<div
|
|
class="w-full text-start mt-4"
|
|
transition:slide={{ delay: NAV_LINKS.length * 50, duration: 200 }}
|
|
>
|
|
<a
|
|
href="/static/resume.pdf?v=2026-08"
|
|
download
|
|
onclick={closeMobileMenu}
|
|
class="inline-flex items-center gap-2 rounded-full border border-foreground/20 px-5 py-2 text-lg font-bold font-custom transition-all duration-200 hover:border-foreground/40 hover:bg-foreground/5"
|
|
>
|
|
Resume
|
|
<IconDownload class="size-5" />
|
|
</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="mt-auto flex flex-col items-center gap-6">
|
|
<div class="flex flex-wrap items-center justify-center gap-x-2 text-center">
|
|
<span class="text-muted-foreground text-sm" aria-label="copyright">
|
|
2025 - {new Date().getFullYear()} © All rights reserved.
|
|
</span>
|
|
<div class="hidden h-4 w-px bg-border sm:block"></div>
|
|
<p class="text-muted-foreground text-sm" aria-label="open-source description">
|
|
<Link
|
|
href="https://git.jaroszew.ski/patrick/portfolio"
|
|
class="text-foreground"
|
|
external
|
|
underline
|
|
>
|
|
Open-source
|
|
</Link>
|
|
under MIT license
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|