+
GitHub
diff --git a/components/ui/animated-marquee.tsx b/components/ui/animated-marquee.tsx
index a0c7086..7b4824b 100644
--- a/components/ui/animated-marquee.tsx
+++ b/components/ui/animated-marquee.tsx
@@ -1,29 +1,18 @@
"use client"
-import { motion } from "framer-motion"
-
interface AnimatedMarqueeProps {
items: string[]
}
export function AnimatedMarquee({ items }: AnimatedMarqueeProps) {
- // Duplicate items to ensure smooth infinite loop
const marqueeItems = [...items, ...items, ...items, ...items]
return (
-
-
+
{marqueeItems.map((item, i) => (
))}
-
+
)
}
diff --git a/components/ui/animated-skills.tsx b/components/ui/animated-skills.tsx
index 6c39bad..0dc39f0 100644
--- a/components/ui/animated-skills.tsx
+++ b/components/ui/animated-skills.tsx
@@ -1,39 +1,45 @@
"use client"
-import { motion } from "framer-motion"
-const container = {
- hidden: { opacity: 0 },
- visible: {
- opacity: 1,
- transition: {
- staggerChildren: 0.05
- }
- }
-}
-
-const item = {
- hidden: { opacity: 0, scale: 0.8 },
- visible: { opacity: 1, scale: 1 }
-}
+import { useRef, useEffect, useState } from "react"
export function AnimatedSkills({ skills }: { skills: string[] }) {
+ const ref = useRef(null)
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => { setMounted(true) }, [])
+
+ useEffect(() => {
+ if (!mounted || !ref.current) return
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ ref.current!.querySelectorAll("[data-skill]").forEach((el, i) => {
+ ;(el as HTMLElement).style.animationDelay = `${i * 0.05}s`
+ el.classList.remove("skill-hidden")
+ el.classList.add("skill-visible")
+ })
+ observer.disconnect()
+ }
+ },
+ { rootMargin: "-50px" }
+ )
+
+ observer.observe(ref.current)
+ return () => observer.disconnect()
+ }, [mounted])
+
return (
-
+
{skills.map((skill) => (
-
- {skill}
-
+ {skill}
+
))}
-
+
)
}
diff --git a/components/ui/animated-stats.tsx b/components/ui/animated-stats.tsx
index ea8dee1..51ba3c7 100644
--- a/components/ui/animated-stats.tsx
+++ b/components/ui/animated-stats.tsx
@@ -1,37 +1,47 @@
"use client"
-import { motion } from "framer-motion"
+
+import { useRef, useEffect, useState } from "react"
import { AboutStatViewModel } from "@/src/interface-adapters/presenters/about-presenter"
-const container = {
- hidden: { opacity: 0 },
- visible: {
- opacity: 1,
- transition: {
- staggerChildren: 0.1
- }
- }
-}
+export function AnimatedStats({ stats }: { stats: AboutStatViewModel[] }) {
+ const ref = useRef(null)
+ const [mounted, setMounted] = useState(false)
-const item = {
- hidden: { opacity: 0, y: 10 },
- visible: { opacity: 1, y: 0 }
-}
+ useEffect(() => { setMounted(true) }, [])
+
+ useEffect(() => {
+ if (!mounted || !ref.current) return
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ ref.current!.querySelectorAll("[data-stat]").forEach((el, i) => {
+ ;(el as HTMLElement).style.animationDelay = `${i * 0.1}s`
+ el.classList.remove("stat-hidden")
+ el.classList.add("stat-visible")
+ })
+ observer.disconnect()
+ }
+ },
+ { rootMargin: "-50px" }
+ )
+
+ observer.observe(ref.current)
+ return () => observer.disconnect()
+ }, [mounted])
-export function AnimatedStats({ stats }: { stats: AboutStatViewModel[] }) {
return (
-
+
{stats.map((stat) => (
-
- {stat.label}
- {stat.value}
-
+
+ {stat.label}
+ {stat.value}
+
))}
-
+
)
}
diff --git a/components/ui/back-to-top.tsx b/components/ui/back-to-top.tsx
index 7cd90c8..dd9e59c 100644
--- a/components/ui/back-to-top.tsx
+++ b/components/ui/back-to-top.tsx
@@ -1,7 +1,6 @@
"use client"
import { useEffect, useState, startTransition } from "react"
-import { motion, AnimatePresence } from "framer-motion"
import { ArrowUp } from "lucide-react"
export function BackToTop() {
@@ -14,20 +13,13 @@ export function BackToTop() {
}, [])
return (
-
- {visible && (
- window.scrollTo({ top: 0, behavior: "smooth" })}
- className="fixed bottom-8 right-8 z-50 p-3 rounded-full glass-card text-zinc-500 dark:text-zinc-400 hover:text-accent dark:hover:text-accent transition-colors"
- aria-label="Back to top"
- >
-
-
- )}
-
+
)
}
diff --git a/components/ui/cv-dialog.tsx b/components/ui/cv-dialog.tsx
index d4a7e81..cc911aa 100644
--- a/components/ui/cv-dialog.tsx
+++ b/components/ui/cv-dialog.tsx
@@ -17,18 +17,19 @@ const CV_PDF_URL = `https://github.com/${REPO}/releases/latest/download/cv.pdf`
export function CvDialog() {
const [open, setOpen] = useState(false)
const [content, setContent] = useState(null)
- const [available, setAvailable] = useState(null)
+ const [loadFailed, setLoadFailed] = useState(false)
- // Fetch from own origin — cv.md is copied to public/ by the update-cv workflow
+ // Fetch only when the dialog is opened, not on page load
useEffect(() => {
+ if (!open || content !== null || loadFailed) return
fetch('/cv.md')
.then(res => {
if (!res.ok) throw new Error()
return res.text()
})
- .then(text => { setContent(text); setAvailable(true) })
- .catch(() => setAvailable(false))
- }, [])
+ .then(text => setContent(text))
+ .catch(() => setLoadFailed(true))
+ }, [open, content, loadFailed])
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : ''
@@ -42,8 +43,6 @@ export function CvDialog() {
return () => document.removeEventListener('keydown', onKey)
}, [open])
- if (!available) return null
-
return (
<>
- {content === null ? (
- Loading…
- ) : content === '' ? (
+ {loadFailed ? (
Could not load CV content.
+ ) : content === null ? (
+ Loading…
) : (
)}
diff --git a/components/ui/experience-card.tsx b/components/ui/experience-card.tsx
index 3469a86..3f6aba8 100644
--- a/components/ui/experience-card.tsx
+++ b/components/ui/experience-card.tsx
@@ -3,7 +3,6 @@
import Image from "next/image"
import { useRef, useEffect } from "react"
import { ExperienceViewModel } from "@/src/interface-adapters/presenters/experience-presenter"
-import { motion, AnimatePresence } from "framer-motion"
import { ChevronDown } from "lucide-react"
import { useGlassTilt } from "@/hooks/use-glass-tilt"
@@ -17,7 +16,7 @@ const TEASER_HEIGHT = "2.875rem"
export function ExperienceCard({ experience, isOpen, onToggle }: ExperienceCardProps) {
const cardRef = useRef(null)
- const { ref: tiltRef, tilt, onMouseMove, onMouseLeave } = useGlassTilt(0.6)
+ const { ref: tiltRef, onMouseMove, onMouseLeave } = useGlassTilt(0.6)
useEffect(() => {
if (isOpen) cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
@@ -43,13 +42,11 @@ export function ExperienceCard({ experience, isOpen, onToggle }: ExperienceCardP
{/* Card */}
-
@@ -60,38 +57,34 @@ export function ExperienceCard({ experience, isOpen, onToggle }: ExperienceCardP
{experience.company}
-
- {experience.description}
-
+
+
{experience.description}
+
+
-
+