initial commit
Generate a build and push to Cloudflare Pages / Build and Deploy to Cloudflare Pages (push) Successful in 1m24s
Generate a build and push to Cloudflare Pages / Build and Deploy to Cloudflare Pages (push) Successful in 1m24s
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
<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"
|
||||
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"
|
||||
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}
|
||||
Reference in New Issue
Block a user