Skip to content
10 changes: 3 additions & 7 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,10 @@ body {
animation: marqueeScroll 30s linear infinite;
}

/* Scroll-driven progress bar (Safari 18 / iOS 18+) */
@keyframes growX {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* Scroll progress bar — updated via JS in Navbar */
.scroll-progress {
animation: growX linear both;
animation-timeline: scroll(root);
transform: scaleX(0);
will-change: transform;
}

@keyframes shine {
Expand Down
2 changes: 1 addition & 1 deletion components/sections/more-about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function MoreAboutSection() {
</div>

{/* Image Column */}
<div className="relative aspect-[440/600] w-full order-1 md:order-2">
<div className="relative aspect-[440/600] w-full order-1 md:order-2 rounded-3xl overflow-hidden">
<Image
src="/about-me.webp"
alt="Andrés Sánchez"
Expand Down
4 changes: 1 addition & 3 deletions components/ui/animated-marquee.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
"use client"

interface AnimatedMarqueeProps {
items: string[]
}

export function AnimatedMarquee({ items }: AnimatedMarqueeProps) {
const marqueeItems = [...items, ...items, ...items, ...items]
const marqueeItems = [...items, ...items]

return (
<div
Expand Down
24 changes: 16 additions & 8 deletions components/ui/cv-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import dynamic from 'next/dynamic'
import { X, Download } from 'lucide-react'
Expand All @@ -16,10 +16,18 @@ const CV_PDF_URL = `https://github.com/${REPO}/releases/latest/download/cv.pdf`

export function CvDialog() {
const [open, setOpen] = useState(false)
const [closing, setClosing] = useState(false)
const [content, setContent] = useState<string | null>(null)
const [loadFailed, setLoadFailed] = useState(false)

// Fetch only when the dialog is opened, not on page load
const handleClose = useCallback(() => {
setClosing(true)
setTimeout(() => {
setOpen(false)
setClosing(false)
}, 150)
}, [])

useEffect(() => {
if (!open || content !== null || loadFailed) return
fetch('/cv.md')
Expand All @@ -38,10 +46,10 @@ export function CvDialog() {

useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open])
}, [open, handleClose])

return (
<>
Expand All @@ -54,25 +62,25 @@ export function CvDialog() {

{open && createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
className={`fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-150 ${closing ? 'opacity-0' : 'opacity-100'}`}
role="dialog"
aria-modal="true"
aria-label="Curriculum Vitae"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-xl backdrop-saturate-150"
onClick={() => setOpen(false)}
onClick={handleClose}
/>

{/* Dialog */}
<div className="relative z-10 w-full max-w-4xl max-h-[92vh] flex flex-col rounded-2xl bg-white/80 dark:bg-zinc-900/70 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-xl shadow-black/15 ring-1 ring-inset ring-white/40 dark:ring-white/5">
<div className="relative z-10 w-full max-w-4xl max-h-[92dvh] flex flex-col rounded-2xl bg-white/80 dark:bg-zinc-900/70 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-xl shadow-black/15 ring-1 ring-inset ring-white/40 dark:ring-white/5">

{/* Header */}
<div className="flex items-center justify-between px-8 py-5 border-b border-white/30 dark:border-white/8 shrink-0">
<span className="font-semibold text-base text-zinc-800 dark:text-white">Curriculum Vitae</span>
<button
onClick={() => setOpen(false)}
onClick={handleClose}
className="p-1 rounded-lg text-zinc-400 hover:text-zinc-700 dark:hover:text-white transition-colors"
aria-label="Close"
>
Expand Down
4 changes: 2 additions & 2 deletions components/ui/experience-list.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { useRef, useState, useEffect, useLayoutEffect } from "react"
import { useRef, useState, useEffect } from "react"
import { ExperienceViewModel } from "@/src/interface-adapters/presenters/experience-presenter"
import { ExperienceCard } from "./experience-card"
import { ChevronLeft, ChevronRight } from "lucide-react"
Expand Down Expand Up @@ -36,7 +36,7 @@ export function ExperienceList({ experiences }: { experiences: ExperienceViewMod
return () => observer.disconnect()
}, [experiences.length])

useLayoutEffect(() => {
useEffect(() => {
const container = scrollRef.current
if (!container) return
const titles = Array.from(container.querySelectorAll<HTMLElement>('[data-exp-title]'))
Expand Down
2 changes: 1 addition & 1 deletion components/ui/hero-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function HeroImage() {
alt="Andres Sanchez"
width={800}
height={926}
className="w-auto h-auto max-h-[350px] md:max-h-[500px] object-contain"
className="w-auto h-auto max-h-[350px] md:max-h-[500px] object-contain rounded-3xl"
priority
/>
</div>
Expand Down
15 changes: 13 additions & 2 deletions components/ui/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function Navbar() {
const [activeSection, setActiveSection] = useState("")
const [menuOpen, setMenuOpen] = useState(false)

const progressRef = useRef<HTMLDivElement>(null)
const breadTopOpenRef = useRef<SVGAnimateElement>(null)
const breadTopCloseRef = useRef<SVGAnimateElement>(null)
const breadBotOpenRef = useRef<SVGAnimateElement>(null)
Expand All @@ -33,6 +34,17 @@ export function Navbar() {
return () => observer.disconnect()
}, [])

useEffect(() => {
const el = progressRef.current
if (!el) return
const onScroll = () => {
const scrolled = window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)
el.style.transform = `scaleX(${Math.min(1, Math.max(0, scrolled))})`
}
window.addEventListener("scroll", onScroll, { passive: true })
return () => window.removeEventListener("scroll", onScroll)
}, [])

useEffect(() => {
const onResize = () => { if (window.innerWidth >= 768) setMenuOpen(false) }
window.addEventListener("resize", onResize, { passive: true })
Expand Down Expand Up @@ -67,8 +79,7 @@ export function Navbar() {
Skip to content
</a>

{/* Scroll Progress Bar — CSS scroll-driven, no JS */}
<div className="scroll-progress fixed top-0 left-0 right-0 h-[2px] bg-accent z-[60] origin-left" />
<div ref={progressRef} className="scroll-progress fixed top-0 left-0 right-0 h-[2px] bg-accent z-[60] origin-left" />

<nav
aria-label="Main navigation"
Expand Down
6 changes: 2 additions & 4 deletions components/ui/page-transition.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client"
import { ReactNode } from "react"

export const PageTransition = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>
}
export const PageTransition = ({ children }: { children: ReactNode }) => <>{children}</>
4 changes: 2 additions & 2 deletions components/ui/project-list.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { useRef, useState, useEffect, useLayoutEffect } from "react"
import { useRef, useState, useEffect } from "react"
import { ProjectViewModel } from "@/src/interface-adapters/presenters/project-presenter"
import { ProjectCard } from "./project-card"
import { ChevronLeft, ChevronRight } from "lucide-react"
Expand All @@ -13,7 +13,7 @@ export function ProjectList({ projects }: { projects: ProjectViewModel[] }) {
const scrollRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)

useLayoutEffect(() => {
useEffect(() => {
const container = scrollRef.current
if (!container) return
const captions = Array.from(container.querySelectorAll<HTMLElement>('[data-caption]'))
Expand Down
8 changes: 4 additions & 4 deletions components/ui/theme-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"

export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const { resolvedTheme, setTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)

React.useEffect(() => { setMounted(true) }, [])

const isDark = mounted ? theme === "dark" : false
const isDark = mounted ? resolvedTheme === "dark" : false

return (
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="w-10 h-10 rounded-full bg-white/40 dark:bg-zinc-900/40 backdrop-blur-md backdrop-saturate-150 border border-white/50 dark:border-white/10 flex items-center justify-center text-zinc-700 dark:text-white hover:bg-white/60 dark:hover:bg-zinc-900/60 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500"
onClick={(e) => { setTheme(isDark ? "light" : "dark"); e.currentTarget.blur() }}
className="w-10 h-10 rounded-full bg-white/40 dark:bg-zinc-900/40 backdrop-blur-md backdrop-saturate-150 border border-white/50 dark:border-white/10 flex items-center justify-center text-zinc-700 dark:text-white active:bg-white/60 dark:active:bg-zinc-900/60 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
Expand Down
Loading