diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 626a3f8..f668905 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,8 +1,12 @@ { "permissions": { "allow": [ - "Bash(node -e \"const l = require\\(''lucide-react''\\); console.log\\(Object.keys\\(l\\).filter\\(k => k.toLowerCase\\(\\).includes\\(''github''\\) || k.toLowerCase\\(\\).includes\\(''linkedin''\\)\\)\\)\")", - "Bash(node -e \"const l = require\\(''lucide-react''\\); console.log\\(l.Github === l.GithubIcon, l.Linkedin === l.LinkedinIcon\\)\")" + "Bash(*)", + "Edit(*)", + "Write(*)", + "Read(*)", + "Glob(*)", + "Grep(*)" ] } } \ No newline at end of file diff --git a/README.md b/README.md index e215bc4..0dad8c0 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,28 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# andsang.github.io -## Getting Started +Personal portfolio site for Andrés Sánchez. Built with Next.js (App Router, TypeScript, Tailwind CSS), deployed to GitHub Pages as a static export. -First, run the development server: +## Dev ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +npx next dev --port 3000 --webpack ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +> Use `--webpack` — Turbopack panics with `output: 'export'` mode in Next.js 16. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +Open [http://localhost:3000](http://localhost:3000). -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Build -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +```bash +npm run build +``` -## Deploy on Vercel +Outputs to `out/` via static export. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Stack -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- Next.js 16 (App Router, static export) +- TypeScript +- Tailwind CSS +- Clean Architecture (entities → use cases → interface adapters → infrastructure) diff --git a/app/globals.css b/app/globals.css index 3a43e6a..eaaf94e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -18,6 +18,12 @@ --foreground: #171717; --accent: #6366f1; --secondary: #10b981; + --blob-primary: #06e2ff; + /* suit blue — hue 220°, full saturation */ + --blob-secondary: #d97706; + /* tie gold — hue 38°, full saturation */ + --blob-tertiary: #9333ea; + /* shirt lavender — hue 285°, full saturation */ } /* Dark Theme (Class-based) */ @@ -35,7 +41,15 @@ body { @layer components { .glass-card { - @apply bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl shadow-2xl; + @apply bg-zinc-100/80 dark:bg-zinc-900/40 backdrop-blur-2xl backdrop-saturate-150 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-lg shadow-black/8 dark:shadow-black/25 ring-1 ring-inset ring-zinc-100 dark:ring-white/5; + } + + .no-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; + } + .no-scrollbar::-webkit-scrollbar { + display: none; } } @@ -43,4 +57,57 @@ body { 100% { transform: translateX(200%); } +} + +@keyframes floatFluid { + 0% { + transform: translate(0, 0) scale(1) rotate(0deg); + } + + 100% { + transform: translate(30px, 50px) scale(1.1) rotate(10deg); + } +} + +.fluid-shape { + position: absolute; + border-radius: 50%; + filter: blur(50px); + opacity: 0.8; + animation: floatFluid 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate; + pointer-events: none; +} + +.fluid-shape-1 { + width: 60vh; + height: 60vh; + top: -10%; + right: -10%; + background: radial-gradient(circle, var(--blob-primary), transparent 65%); + animation-delay: 0s; +} + +.fluid-shape-2 { + width: 50vh; + height: 50vh; + bottom: -10%; + left: -10%; + background: radial-gradient(circle, var(--blob-secondary), transparent 65%); + animation-delay: -5s; +} + +.fluid-shape-3 { + width: 30vh; + height: 30vh; + top: 40%; + left: 40%; + background: radial-gradient(circle, var(--blob-tertiary), transparent 65%); + animation-delay: -10s; + opacity: 0.65; +} + +@media (prefers-reduced-motion: reduce) { + .fluid-shape { + animation: none; + } } \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 8479e04..b854079 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -39,7 +39,7 @@ export default function RootLayout({
- + {children} diff --git a/app/page.tsx b/app/page.tsx index b0d00fb..bf3f4ae 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,6 +6,7 @@ import { ExperienceSection } from '@/components/sections/experience' import { MoreAboutSection } from '@/components/sections/more-about' import { ContactSection } from '@/components/sections/contact' import { PageTransition } from '@/components/ui/page-transition' +import { BackToTop } from '@/components/ui/back-to-top' export default function Home() { return ( @@ -19,6 +20,7 @@ export default function Home() { + ) } diff --git a/components/sections/about.tsx b/components/sections/about.tsx index 03c7a91..912cf95 100644 --- a/components/sections/about.tsx +++ b/components/sections/about.tsx @@ -1,16 +1,16 @@ -import { aboutController } from "@/src/di" +import { getAbout } from "@/src/di" import { Reveal } from "@/components/ui/reveal" import { AnimatedStats } from "@/components/ui/animated-stats" import { AnimatedMarquee } from '@/components/ui/animated-marquee' export async function AboutSection() { - const about = await aboutController.getAbout() + const about = await getAbout() return ( -
+
-

+

About Me

diff --git a/components/sections/contact.tsx b/components/sections/contact.tsx index 0af6184..4f3260f 100644 --- a/components/sections/contact.tsx +++ b/components/sections/contact.tsx @@ -5,16 +5,16 @@ export async function ContactSection() { const contact = await contactController.getContact() return ( -
+
-

+

Get in Touch

{/* Location */}
-
+
@@ -25,7 +25,7 @@ export async function ContactSection() { {/* Email */} -
+
{contact.email} @@ -33,7 +33,7 @@ export async function ContactSection() { {/* LinkedIn */}
-
+
LinkedIn @@ -41,7 +41,7 @@ export async function ContactSection() { {/* GitHub */}
-
+
GitHub diff --git a/components/sections/experience.tsx b/components/sections/experience.tsx index d03edd9..3fba41e 100644 --- a/components/sections/experience.tsx +++ b/components/sections/experience.tsx @@ -1,34 +1,17 @@ -import { ExperienceCard } from "@/components/ui/experience-card" +import { ExperienceList } from "@/components/ui/experience-list" import { experienceController } from "@/src/di" export async function ExperienceSection() { const experiences = await experienceController.getExperiences() return ( -
-
-

+
+
+

Professional Journey

-
- {/* Vertical Center Line */} -
- - {/* Mobile Line (Left aligned) */} -
- - -
- {experiences.map((exp, index) => ( - - ))} -
-
+
) diff --git a/components/sections/hero.tsx b/components/sections/hero.tsx index 575fedc..d2ca048 100644 --- a/components/sections/hero.tsx +++ b/components/sections/hero.tsx @@ -1,31 +1,36 @@ -import { aboutController } from '@/src/di' +import { getAbout } from '@/src/di' import { CvDialog } from '@/components/ui/cv-dialog' import { HeroImage } from '@/components/ui/hero-image' import { TactileButton } from '@/components/ui/tactile-button' export async function Hero() { - const about = await aboutController.getAbout() + const about = await getAbout() return ( -
+
{/* Text Column */}

{about.name}

-

+

Developing
High-Availability
Mobile Apps -

-
+

+

{about.heroDescription.map((line, i) => ( -

{line}

+ {line}{' '} ))} +

+ + {/* Mobile-only image, between description and CTAs */} +
+
-
+
Contact Me @@ -42,15 +47,19 @@ export async function Hero() {
- {/* Image Column */} -
+ {/* Image Column — desktop only */} +
- {/* Background Glow Effect */} -
+ {/* Background Fluid Blobs */} +
+
+
+
+
) } diff --git a/components/sections/more-about.tsx b/components/sections/more-about.tsx index 3f33453..90c3aba 100644 --- a/components/sections/more-about.tsx +++ b/components/sections/more-about.tsx @@ -1,11 +1,11 @@ import Image from 'next/image' -import { aboutController } from "@/src/di" +import { getAbout } from "@/src/di" export async function MoreAboutSection() { - const about = await aboutController.getAbout() + const about = await getAbout() return ( -
+
{/* Text Column */}
@@ -24,7 +24,7 @@ export async function MoreAboutSection() {

{/* Swift-styled code snippet */} -
+
let hobbies = diff --git a/components/sections/projects.tsx b/components/sections/projects.tsx index dcd4cf0..a5e8089 100644 --- a/components/sections/projects.tsx +++ b/components/sections/projects.tsx @@ -1,21 +1,17 @@ -import { ProjectCard } from '@/components/ui/project-card' +import { ProjectList } from '@/components/ui/project-list' import { projectController } from '@/src/di' export async function ProjectsSection() { const projects = await projectController.getProjects() return ( -
+
-

+

Selected Projects

-
- {projects.map(project => ( - - ))} -
+
) diff --git a/components/ui/animated-stats.tsx b/components/ui/animated-stats.tsx index 1c65506..ea8dee1 100644 --- a/components/ui/animated-stats.tsx +++ b/components/ui/animated-stats.tsx @@ -1,6 +1,6 @@ "use client" import { motion } from "framer-motion" -import { AboutStat } from "@/src/entities/about" +import { AboutStatViewModel } from "@/src/interface-adapters/presenters/about-presenter" const container = { hidden: { opacity: 0 }, @@ -17,7 +17,7 @@ const item = { visible: { opacity: 1, y: 0 } } -export function AnimatedStats({ stats }: { stats: AboutStat[] }) { +export function AnimatedStats({ stats }: { stats: AboutStatViewModel[] }) { return ( { + const onScroll = () => startTransition(() => setVisible(window.scrollY > 400)) + window.addEventListener("scroll", onScroll, { passive: true }) + return () => window.removeEventListener("scroll", onScroll) + }, []) + + 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 d847ddd..d4a7e81 100644 --- a/components/ui/cv-dialog.tsx +++ b/components/ui/cv-dialog.tsx @@ -2,9 +2,14 @@ import { useState, useEffect } from 'react' import { createPortal } from 'react-dom' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' +import dynamic from 'next/dynamic' import { X, Download } from 'lucide-react' +import { TactileButton } from '@/components/ui/tactile-button' + +const CvMarkdown = dynamic( + () => import('./cv-markdown').then(m => ({ default: m.CvMarkdown })), + { loading: () =>

Loading…

} +) const REPO = 'AndSanG/andsang.github.io' const CV_PDF_URL = `https://github.com/${REPO}/releases/latest/download/cv.pdf` @@ -30,16 +35,23 @@ export function CvDialog() { return () => { document.body.style.overflow = '' } }, [open]) + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [open]) + if (!available) return null return ( <> - + {open && createPortal(
{/* Backdrop */}
setOpen(false)} /> {/* Dialog */} -
+
{/* Header */} -
+
Curriculum Vitae
{/* Footer */} -
, diff --git a/components/ui/cv-markdown.tsx b/components/ui/cv-markdown.tsx new file mode 100644 index 0000000..e276662 --- /dev/null +++ b/components/ui/cv-markdown.tsx @@ -0,0 +1,22 @@ +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' + +export function CvMarkdown({ content }: { content: string }) { + return ( +
+ + {content} + +
+ ) +} diff --git a/components/ui/experience-card.tsx b/components/ui/experience-card.tsx index 08b314c..3469a86 100644 --- a/components/ui/experience-card.tsx +++ b/components/ui/experience-card.tsx @@ -1,55 +1,97 @@ "use client" import Image from "next/image" -import { Experience } from "@/src/entities/experience" -import { motion } from "framer-motion" +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" interface ExperienceCardProps { - experience: Experience - isEven: boolean + experience: ExperienceViewModel + isOpen: boolean + onToggle: () => void } -export function ExperienceCard({ experience, isEven }: ExperienceCardProps) { +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) + + useEffect(() => { + if (isOpen) cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) + }, [isOpen]) + return ( -
- - {/* Content Side */} -
- - - {experience.year} - -

{experience.title}

-

{experience.company}

-

- {experience.description} -

-
-
+
- {/* Timeline Node (Image) */} -
- {experience.company} + {/* Horizontal timeline + circle node */} +
+
+
+ {experience.company} +
- {/* Connection Line (Desktop) */} - + {/* Vertical stem from circle to card */} +
+ + {/* Card */} + + + {experience.year} + +

+ {experience.title} +

+

{experience.company}

- {/* Empty Side for Spacing */} -
+ +

{experience.description}

+
+ +
) } diff --git a/components/ui/experience-list.tsx b/components/ui/experience-list.tsx new file mode 100644 index 0000000..24011b2 --- /dev/null +++ b/components/ui/experience-list.tsx @@ -0,0 +1,140 @@ +"use client" + +import { useRef, useState, useEffect, useLayoutEffect } from "react" +import { ExperienceViewModel } from "@/src/interface-adapters/presenters/experience-presenter" +import { ExperienceCard } from "./experience-card" +import { ChevronLeft, ChevronRight } from "lucide-react" + + +export function ExperienceList({ experiences }: { experiences: ExperienceViewModel[] }) { + const [openId, setOpenId] = useState(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(true) + const scrollRef = useRef(null) + const wrapperRef = useRef(null) + + useEffect(() => { + const container = scrollRef.current + if (!container || experiences.length === 0) return + + const cards = Array.from(container.children).slice(0, experiences.length) as HTMLElement[] + const first = cards[0] + const last = cards[cards.length - 1] + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach(({ target, isIntersecting }) => { + if (target === first) setCanScrollLeft(!isIntersecting) + if (target === last) setCanScrollRight(!isIntersecting) + }) + }, + { root: container, threshold: 0.9 } + ) + + observer.observe(first) + observer.observe(last) + return () => observer.disconnect() + }, [experiences.length]) + + useLayoutEffect(() => { + const container = scrollRef.current + if (!container) return + const titles = Array.from(container.querySelectorAll('[data-exp-title]')) + titles.forEach(el => (el.style.height = '')) + if (titles.length === 0) return + const max = Math.max(...titles.map(el => el.getBoundingClientRect().height)) + titles.forEach(el => (el.style.height = `${max}px`)) + }, [experiences]) + + useEffect(() => { + if (openId !== null) return + const container = scrollRef.current + if (!container) return + const timer = setTimeout(() => { + const cards = Array.from(container.children).slice(0, experiences.length) as HTMLElement[] + cards.forEach(el => (el.style.minHeight = '')) + const max = Math.max(...cards.map(el => el.getBoundingClientRect().height)) + cards.forEach(el => (el.style.minHeight = `${max}px`)) + }, 350) + return () => clearTimeout(timer) + }, [openId, experiences.length]) + + const getCards = () => { + const container = scrollRef.current + if (!container) return [] + return Array.from(container.children).slice(0, experiences.length) as HTMLElement[] + } + + const navigate = (getTarget: (cards: HTMLElement[], containerLeft: number) => HTMLElement | undefined) => { + const container = scrollRef.current + if (!container) return + + const containerLeft = container.getBoundingClientRect().left + const cards = getCards() + const target = getTarget(cards, containerLeft) + if (!target) return + + const doScroll = () => { + const left = target.getBoundingClientRect().left - container.getBoundingClientRect().left + container.scrollBy({ left, behavior: 'smooth' }) + } + + if (openId !== null) { + setOpenId(null) + const wrapper = wrapperRef.current + if (wrapper) { + const top = wrapper.getBoundingClientRect().top + window.scrollY - 96 + window.scrollTo({ top, behavior: 'instant' }) + } + doScroll() + } else { + doScroll() + } + } + + const handleScrollLeft = () => navigate((cards, containerLeft) => { + const currentIdx = cards.findIndex(c => c.getBoundingClientRect().left >= containerLeft - 8) + return cards[Math.max(0, currentIdx - 1)] + }) + + const handleScrollRight = () => navigate((cards, containerLeft) => + cards.find(c => c.getBoundingClientRect().left > containerLeft + 8) + ) + + return ( +
+
+ {experiences.map((experience) => ( + setOpenId(prev => prev === experience.id ? null : experience.id)} + /> + ))} +
+ +
+ + +
+
+ ) +} diff --git a/components/ui/navbar.tsx b/components/ui/navbar.tsx index f6fe30d..4b5a940 100644 --- a/components/ui/navbar.tsx +++ b/components/ui/navbar.tsx @@ -1,37 +1,67 @@ "use client" import Image from 'next/image' import { ThemeToggle } from '@/components/ui/theme-toggle' -import { useState, useEffect } from 'react' -import { motion, useScroll } from 'framer-motion' +import { useState, useEffect, useRef } from 'react' +import { motion, useScroll, AnimatePresence } from 'framer-motion' +import { useGlassTilt } from '@/hooks/use-glass-tilt' + +const navLinks = [ + { name: "About", href: "#about" }, + { name: "Projects", href: "#projects" }, + { name: "More", href: "#more-about" }, + { name: "Contact", href: "#contact" }, +] export function Navbar() { const { scrollYProgress } = useScroll() const [activeSection, setActiveSection] = useState("") + const [menuOpen, setMenuOpen] = useState(false) + const { ref: pillRef, tilt, onMouseMove, onMouseLeave } = useGlassTilt(0.8) + + const breadTopOpenRef = useRef(null) + const breadTopCloseRef = useRef(null) + const breadBotOpenRef = useRef(null) + const breadBotCloseRef = useRef(null) useEffect(() => { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { - if (entry.isIntersecting) { - setActiveSection(entry.target.id) - } + if (entry.isIntersecting) setActiveSection(entry.target.id) }) }, { rootMargin: "-20% 0px -80% 0px" } ) - const sections = document.querySelectorAll("section[id]") sections.forEach((section) => observer.observe(section)) - return () => observer.disconnect() }, []) - const navLinks = [ - { name: "About", href: "#about" }, - { name: "Projects", href: "#projects" }, - { name: "More", href: "#more-about" }, - { name: "Contact", href: "#contact" }, - ] + // Close menu on resize to desktop + useEffect(() => { + const onResize = () => { if (window.innerWidth >= 768) setMenuOpen(false) } + window.addEventListener("resize", onResize, { passive: true }) + return () => window.removeEventListener("resize", onResize) + }, []) + + function toggleMenu() { + const opening = !menuOpen + setMenuOpen(opening) + if (opening) { + breadTopOpenRef.current?.beginElement() + breadBotOpenRef.current?.beginElement() + } else { + breadTopCloseRef.current?.beginElement() + breadBotCloseRef.current?.beginElement() + } + } + + function closeMenu() { + if (!menuOpen) return + setMenuOpen(false) + breadTopCloseRef.current?.beginElement() + breadBotCloseRef.current?.beginElement() + } return ( <> @@ -41,7 +71,7 @@ export function Navbar() { > Skip to content
- + {/* Scroll Progress Bar */} -
+ +
- Swift Logo - Triathlon + Swift Logo + Triathlon + {/* Desktop links */}
{navLinks.map((link) => ( - {link.name} ))}
+ + {/* Mobile: theme toggle + hamburger */} +
+ + +
+ + {/* Mobile menu */} + + {menuOpen && ( + +
+ {navLinks.map((link) => ( + + {link.name} + + ))} +
+
+ )} +
+
) diff --git a/components/ui/project-card.tsx b/components/ui/project-card.tsx index e8c1ba0..b30d319 100644 --- a/components/ui/project-card.tsx +++ b/components/ui/project-card.tsx @@ -1,34 +1,94 @@ "use client" -import { Project } from '@/src/entities/project' -import { motion } from 'framer-motion' +import Image from "next/image" +import { useRef, useEffect } from 'react' +import { ProjectViewModel } from '@/src/interface-adapters/presenters/project-presenter' +import { motion, AnimatePresence } from 'framer-motion' +import { ChevronDown } from 'lucide-react' + +const TEASER_HEIGHT = "2.875rem" + +interface ProjectCardProps { + project: ProjectViewModel + isOpen: boolean + onToggle: () => void +} + +export function ProjectCard({ project, isOpen, onToggle }: ProjectCardProps) { + const cardRef = useRef(null) + + useEffect(() => { + if (isOpen) cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) + }, [isOpen]) -export function ProjectCard({ project }: { project: Project }) { return ( - -

{project.title}

-

{project.role}

- -
-

{project.description}

-
- Challenge - {project.challenge} -
+
+
+

+ {project.title} +

+

{project.role}

-
- {project.techStack.map((tech) => ( - + {project.title} +
+ ) : null} + +
+ +

{project.description}

+ +
+ Challenge +

{project.challenge}

+
+ +
+ {project.techStack.map((tech) => ( + + {tech} + + ))} +
+
+ +
- +
) } diff --git a/components/ui/project-list.tsx b/components/ui/project-list.tsx new file mode 100644 index 0000000..5899489 --- /dev/null +++ b/components/ui/project-list.tsx @@ -0,0 +1,139 @@ +"use client" + +import { useRef, useState, useEffect, useLayoutEffect } from "react" +import { ProjectViewModel } from "@/src/interface-adapters/presenters/project-presenter" +import { ProjectCard } from "./project-card" +import { ChevronLeft, ChevronRight } from "lucide-react" + + +export function ProjectList({ projects }: { projects: ProjectViewModel[] }) { + const [openId, setOpenId] = useState(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(true) + const scrollRef = useRef(null) + const wrapperRef = useRef(null) + + useLayoutEffect(() => { + const container = scrollRef.current + if (!container) return + const captions = Array.from(container.querySelectorAll('[data-caption]')) + captions.forEach(el => (el.style.height = '')) + const max = Math.max(...captions.map(el => el.getBoundingClientRect().height)) + captions.forEach(el => (el.style.height = `${max}px`)) + }, [projects]) + + useEffect(() => { + if (openId !== null) return + const container = scrollRef.current + if (!container) return + const timer = setTimeout(() => { + const cards = Array.from(container.children).slice(0, projects.length) as HTMLElement[] + cards.forEach(el => (el.style.minHeight = '')) + const max = Math.max(...cards.map(el => el.getBoundingClientRect().height)) + cards.forEach(el => (el.style.minHeight = `${max}px`)) + }, 350) + return () => clearTimeout(timer) + }, [openId, projects.length]) + + useEffect(() => { + const container = scrollRef.current + if (!container || projects.length === 0) return + + const cards = Array.from(container.children).slice(0, projects.length) as HTMLElement[] + const first = cards[0] + const last = cards[cards.length - 1] + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach(({ target, isIntersecting }) => { + if (target === first) setCanScrollLeft(!isIntersecting) + if (target === last) setCanScrollRight(!isIntersecting) + }) + }, + { root: container, threshold: 0.9 } + ) + + observer.observe(first) + observer.observe(last) + return () => observer.disconnect() + }, [projects.length]) + + const getCards = () => { + const container = scrollRef.current + if (!container) return [] + return Array.from(container.children).slice(0, projects.length) as HTMLElement[] + } + + const navigate = (getTarget: (cards: HTMLElement[], containerLeft: number) => HTMLElement | undefined) => { + const container = scrollRef.current + if (!container) return + + const containerLeft = container.getBoundingClientRect().left + const cards = getCards() + const target = getTarget(cards, containerLeft) + if (!target) return + + const doScroll = () => { + const left = target.getBoundingClientRect().left - container.getBoundingClientRect().left + container.scrollBy({ left, behavior: 'smooth' }) + } + + if (openId !== null) { + setOpenId(null) + const wrapper = wrapperRef.current + if (wrapper) { + const top = wrapper.getBoundingClientRect().top + window.scrollY - 96 + window.scrollTo({ top, behavior: 'instant' }) + } + doScroll() + } else { + doScroll() + } + } + + const handleScrollLeft = () => navigate((cards, containerLeft) => { + const currentIdx = cards.findIndex(c => c.getBoundingClientRect().left >= containerLeft - 8) + return cards[Math.max(0, currentIdx - 1)] + }) + + const handleScrollRight = () => navigate((cards, containerLeft) => + cards.find(c => c.getBoundingClientRect().left > containerLeft + 8) + ) + + return ( +
+
+ {projects.map(project => ( + setOpenId(prev => prev === project.id ? null : project.id)} + /> + ))} +
+ +
+ + +
+
+ ) +} diff --git a/components/ui/tactile-button.tsx b/components/ui/tactile-button.tsx index fdd81c8..938a3b8 100644 --- a/components/ui/tactile-button.tsx +++ b/components/ui/tactile-button.tsx @@ -10,35 +10,27 @@ interface Props { } export function TactileButton({ href, onClick, className = "", children }: Props) { - const isLink = href !== undefined - - const baseClasses = `relative overflow-hidden group flex items-center justify-center ${className}` - - const shineElement = ( - + const base = `relative overflow-hidden group flex items-center justify-center ${className}` + const shine = ( + ) - if (isLink) { + if (href !== undefined) { return ( - - {children} - {shineElement} + + {children} + {shine} ) } return ( - - {children} - {shineElement} + + {children} + {shine} ) } diff --git a/components/ui/theme-toggle.tsx b/components/ui/theme-toggle.tsx index a42e6fc..998fc88 100644 --- a/components/ui/theme-toggle.tsx +++ b/components/ui/theme-toggle.tsx @@ -15,7 +15,7 @@ export function ThemeToggle() { return (