diff --git a/bun.lock b/bun.lock index a5dc5d9..c99e86f 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,8 @@ "@reduxjs/toolkit": "^2.12.0", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "embla-carousel": "^8.6.0", + "embla-carousel-react": "^8.6.0", "framer-motion": "^12.42.2", "lucide-react": "^1.25.0", "react": "^19.2.7", @@ -453,6 +455,12 @@ "electron-to-chromium": ["electron-to-chromium@1.5.385", "", {}, "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q=="], + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], + + "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], + + "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], diff --git a/package.json b/package.json index 1a9291f..04b536b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "@reduxjs/toolkit": "^2.12.0", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "embla-carousel": "^8.6.0", + "embla-carousel-react": "^8.6.0", "framer-motion": "^12.42.2", "lucide-react": "^1.25.0", "react": "^19.2.7", diff --git a/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-0.webp b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-0.webp new file mode 100644 index 0000000..e12a6c3 Binary files /dev/null and b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-0.webp differ diff --git a/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-1.webp b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-1.webp new file mode 100644 index 0000000..fb53961 Binary files /dev/null and b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-1.webp differ diff --git a/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-2.webp b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-2.webp new file mode 100644 index 0000000..5a6d3f3 Binary files /dev/null and b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-2.webp differ diff --git a/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-3.webp b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-3.webp new file mode 100644 index 0000000..fed99f8 Binary files /dev/null and b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-3.webp differ diff --git a/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-4.webp b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-4.webp new file mode 100644 index 0000000..f5dd558 Binary files /dev/null and b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-4.webp differ diff --git a/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-5.webp b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-5.webp new file mode 100644 index 0000000..0e35cec Binary files /dev/null and b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-5.webp differ diff --git a/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-6.webp b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-6.webp new file mode 100644 index 0000000..a409f37 Binary files /dev/null and b/src/assets/images/portfolio/featured-project/vaultdrive-dashboard-6.webp differ diff --git a/src/assets/images/portfolio/hero-dashboard.webp b/src/assets/images/portfolio/hero-dashboard.webp index 0da2b4f..0853e19 100644 Binary files a/src/assets/images/portfolio/hero-dashboard.webp and b/src/assets/images/portfolio/hero-dashboard.webp differ diff --git a/src/assets/images/portfolio/profile-placeholder.webp b/src/assets/images/portfolio/profile-placeholder.webp index f13c16a..d351f65 100644 Binary files a/src/assets/images/portfolio/profile-placeholder.webp and b/src/assets/images/portfolio/profile-placeholder.webp differ diff --git a/src/assets/images/portfolio/vaultdrive-dashboard.webp b/src/assets/images/portfolio/vaultdrive-dashboard.webp deleted file mode 100644 index 960c99d..0000000 Binary files a/src/assets/images/portfolio/vaultdrive-dashboard.webp and /dev/null differ diff --git a/src/components/project-gallery/GalleryCarousel.tsx b/src/components/project-gallery/GalleryCarousel.tsx new file mode 100644 index 0000000..44c10da --- /dev/null +++ b/src/components/project-gallery/GalleryCarousel.tsx @@ -0,0 +1,92 @@ +import useEmblaCarousel from 'embla-carousel-react'; +import { motion } from 'framer-motion'; +import { memo, useCallback, useEffect, useRef } from 'react'; + +import type { GalleryCarouselProps } from './gallery.types'; +import { getGalleryLayoutId } from './gallery.utils'; + +export const GalleryCarousel = memo(function GalleryCarousel({ + selectedIndex, + onSelect, + onApiReady, + loop, + title, + images, +}: GalleryCarouselProps) { + const [viewportRef, api] = useEmblaCarousel({ + loop, + align: 'center', + duration: 28, + }); + const wheelTimeout = useRef(undefined); + + const syncSelectedIndex = useCallback(() => { + if (api) onSelect(api.selectedScrollSnap()); + }, [api, onSelect]); + + useEffect(() => { + if (!api) return; + onApiReady(api); + api.on('select', syncSelectedIndex); + api.on('reInit', syncSelectedIndex); + syncSelectedIndex(); + + return () => { + api.off('select', syncSelectedIndex); + api.off('reInit', syncSelectedIndex); + onApiReady(undefined); + }; + }, [api, onApiReady, syncSelectedIndex]); + + useEffect(() => { + api?.scrollTo(selectedIndex); + }, [api, selectedIndex]); + + const handleWheel = useCallback( + (event: React.WheelEvent) => { + if (!api || Math.abs(event.deltaY) < Math.abs(event.deltaX)) return; + event.preventDefault(); + if (wheelTimeout.current) window.clearTimeout(wheelTimeout.current); + wheelTimeout.current = window.setTimeout(() => { + if (event.deltaY > 0) api.scrollNext(); + else api.scrollPrev(); + }, 25); + }, + [api], + ); + + return ( +
+
+ {images.map((image, index) => ( +
+ +
+ ))} +
+
+ ); +}); diff --git a/src/components/project-gallery/GalleryContext.ts b/src/components/project-gallery/GalleryContext.ts new file mode 100644 index 0000000..bb9da3b --- /dev/null +++ b/src/components/project-gallery/GalleryContext.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +import type { GalleryContextValue } from './gallery.types'; + +export const GalleryContext = createContext(null); + +export function useProjectGallery() { + const context = useContext(GalleryContext); + if (!context) { + throw new Error('useProjectGallery must be used within ProjectGallery'); + } + return context; +} diff --git a/src/components/project-gallery/GalleryControls.tsx b/src/components/project-gallery/GalleryControls.tsx new file mode 100644 index 0000000..4c195bc --- /dev/null +++ b/src/components/project-gallery/GalleryControls.tsx @@ -0,0 +1,58 @@ +import { ChevronLeft, ChevronRight, X } from 'lucide-react'; +import { memo } from 'react'; + +import { cn } from '#lib/utils'; + +interface GalleryControlsProps { + onClose: () => void; + onPrevious: () => void; + onNext: () => void; + showControls: boolean; +} + +const controlClassName = + 'flex h-11 w-11 items-center justify-center rounded-full bg-black/35 text-white shadow-lg backdrop-blur-xl transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white'; + +export const GalleryControls = memo(function GalleryControls({ + onClose, + onPrevious, + onNext, + showControls, +}: GalleryControlsProps) { + return ( + <> + + + {showControls && ( +
+ + +
+ )} + + ); +}); diff --git a/src/components/project-gallery/GalleryModal.tsx b/src/components/project-gallery/GalleryModal.tsx new file mode 100644 index 0000000..309948b --- /dev/null +++ b/src/components/project-gallery/GalleryModal.tsx @@ -0,0 +1,137 @@ +import type { EmblaCarouselType } from 'embla-carousel'; +import { motion } from 'framer-motion'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { gallerySpring } from './gallery.constants'; +import { getFocusableElements, lockBodyScroll } from './gallery.utils'; +import { GalleryCarousel } from './GalleryCarousel'; +import { useProjectGallery } from './GalleryContext'; +import { GalleryControls } from './GalleryControls'; +import { GalleryThumbnailStrip } from './GalleryThumbnailStrip'; + +export function GalleryModal() { + const { + activeIndex, + backdropClosable, + close, + images, + loop, + setActiveIndex, + showControls, + showCounter, + showThumbnails, + title, + } = useProjectGallery(); + const dialogRef = useRef(null); + const [api, setApi] = useState(); + + const previous = useCallback(() => api?.scrollPrev(), [api]); + const next = useCallback(() => api?.scrollNext(), [api]); + + useEffect(() => { + const unlockBodyScroll = lockBodyScroll(); + const dialog = dialogRef.current; + const focusable = dialog ? getFocusableElements(dialog) : []; + focusable[0]?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + close(); + } + if (event.key === 'ArrowLeft') { + event.preventDefault(); + previous(); + } + if (event.key === 'ArrowRight') { + event.preventDefault(); + next(); + } + if (event.key === 'Tab' && dialog) { + const elements = getFocusableElements(dialog); + if (elements.length === 0) return; + const first = elements[0]; + const last = elements[elements.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + unlockBodyScroll(); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [close, next, previous]); + + return createPortal( + { + if (backdropClosable && event.target === event.currentTarget) close(); + }} + > + +
+
+ +
+ + + +
+ {showCounter && ( +

+ {activeIndex + 1} / {images.length} +

+ )} + {showThumbnails && ( + { + setActiveIndex(index); + api?.scrollTo(index); + }} + title={title} + /> + )} +
+ + , + document.body, + ); +} diff --git a/src/components/project-gallery/GalleryStack.tsx b/src/components/project-gallery/GalleryStack.tsx new file mode 100644 index 0000000..dfaa132 --- /dev/null +++ b/src/components/project-gallery/GalleryStack.tsx @@ -0,0 +1,106 @@ +import { motion } from 'framer-motion'; +import { memo, useState } from 'react'; + +import { cn } from '#lib/utils'; + +import { cardOffsets, gallerySpring } from './gallery.constants'; +import { getGalleryLayoutId } from './gallery.utils'; +import { useProjectGallery } from './GalleryContext'; + +interface GalleryStackProps { + className?: string; +} + +export const GalleryStack = memo(function GalleryStack({ + className, +}: GalleryStackProps) { + const { images, title, isOpen, openAt, activeIndex } = useProjectGallery(); + const [isHovered, setIsHovered] = useState(false); + + if (images.length === 0) return null; + + return ( + setIsHovered(true)} + onHoverEnd={() => setIsHovered(false)} + animate={{ + y: isHovered && !isOpen ? -4 : 0, + scale: isHovered && !isOpen ? 1.01 : 1, + }} + transition={gallerySpring} + className={cn( + 'group relative mx-auto w-full max-w-2xl pb-7 pl-5 sm:pb-10 sm:pl-8', + className, + )} + > +
+ {images.map((image, index) => { + const stackIndex = images.length - 1 - index; + const offset = cardOffsets[stackIndex % cardOffsets.length]; + const isTopCard = index === 0; + const spreadMultiplier = stackIndex - (images.length - 1) / 2; + + return ( + openAt(index, event.currentTarget)} + className="bg-surface ring-offset-background focus-visible:ring-accent-primary absolute inset-0 origin-bottom-left cursor-zoom-in overflow-hidden rounded-2xl text-left shadow-xl shadow-black/30 ring-offset-4 outline-none focus-visible:ring-2 sm:rounded-3xl" + style={{ zIndex: stackIndex + 1 }} + aria-label={`Open ${image.alt || `${title} screenshot ${index + 1}`}`} + tabIndex={isTopCard && !isOpen ? 0 : -1} + > + + + + ); + })} +
+ + {images.length} {images.length === 1 ? 'screenshot' : 'screenshots'} ยท + Click to explore + +
+ ); +}); diff --git a/src/components/project-gallery/GalleryThumbnailStrip.tsx b/src/components/project-gallery/GalleryThumbnailStrip.tsx new file mode 100644 index 0000000..7fc3851 --- /dev/null +++ b/src/components/project-gallery/GalleryThumbnailStrip.tsx @@ -0,0 +1,67 @@ +import { motion } from 'framer-motion'; +import { memo, useEffect, useRef } from 'react'; + +import { cn } from '#lib/utils'; + +import type { GalleryImage } from './gallery.types'; + +interface GalleryThumbnailStripProps { + images: GalleryImage[]; + selectedIndex: number; + onSelect: (index: number) => void; + title: string; +} + +export const GalleryThumbnailStrip = memo(function GalleryThumbnailStrip({ + images, + selectedIndex, + onSelect, + title, +}: GalleryThumbnailStripProps) { + const selectedRef = useRef(null); + + useEffect(() => { + selectedRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }); + }, [selectedIndex]); + + return ( +
+ {images.map((image, index) => { + const isSelected = index === selectedIndex; + + return ( + + ); + })} +
+ ); +}); diff --git a/src/components/project-gallery/ProjectGallery.tsx b/src/components/project-gallery/ProjectGallery.tsx new file mode 100644 index 0000000..e2b855d --- /dev/null +++ b/src/components/project-gallery/ProjectGallery.tsx @@ -0,0 +1,83 @@ +import { AnimatePresence, LayoutGroup } from 'framer-motion'; +import { memo, useCallback, useMemo, useRef, useState } from 'react'; + +import type { GalleryContextValue, ProjectGalleryProps } from './gallery.types'; +import { clampIndex } from './gallery.utils'; +import { GalleryContext } from './GalleryContext'; +import { GalleryModal } from './GalleryModal'; +import { GalleryStack } from './GalleryStack'; + +export const ProjectGallery = memo(function ProjectGallery({ + title, + images, + initialIndex = 0, + showCounter = true, + showThumbnails = true, + showControls = true, + loop = true, + backdropClosable = true, + className, +}: ProjectGalleryProps) { + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(() => + clampIndex(initialIndex, images.length), + ); + const triggerRef = useRef(null); + + const openAt = useCallback( + (index: number, trigger: HTMLElement) => { + triggerRef.current = trigger; + setActiveIndex(clampIndex(index, images.length)); + setIsOpen(true); + }, + [images.length], + ); + + const close = useCallback(() => { + setIsOpen(false); + window.setTimeout(() => triggerRef.current?.focus(), 220); + }, []); + + const contextValue = useMemo( + () => ({ + title, + images, + initialIndex, + showCounter, + showThumbnails, + showControls, + loop, + backdropClosable, + activeIndex, + isOpen, + setActiveIndex, + openAt, + close, + }), + [ + activeIndex, + backdropClosable, + close, + images, + initialIndex, + isOpen, + loop, + openAt, + showControls, + showCounter, + showThumbnails, + title, + ], + ); + + if (images.length === 0) return null; + + return ( + + + + {isOpen && } + + + ); +}); diff --git a/src/components/project-gallery/gallery.constants.ts b/src/components/project-gallery/gallery.constants.ts new file mode 100644 index 0000000..7c80544 --- /dev/null +++ b/src/components/project-gallery/gallery.constants.ts @@ -0,0 +1,23 @@ +export const gallerySpring = { + type: 'spring' as const, + stiffness: 320, + damping: 30, + mass: 0.8, +}; + +export const cardOffsets = [ + { rotate: -4, x: -10, y: -10, scale: 0.94 }, + { rotate: 2, x: -5, y: 8, scale: 0.96 }, + { rotate: -2, x: -3, y: -11, scale: 0.98 }, + { rotate: 4, x: 5, y: -8, scale: 1 }, + { rotate: -2, x: 10, y: 10, scale: 0.94 }, +] as const; + +export const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'textarea:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); diff --git a/src/components/project-gallery/gallery.types.ts b/src/components/project-gallery/gallery.types.ts new file mode 100644 index 0000000..b67d8df --- /dev/null +++ b/src/components/project-gallery/gallery.types.ts @@ -0,0 +1,37 @@ +import type { EmblaCarouselType } from 'embla-carousel'; + +export interface GalleryImage { + src: string; + alt?: string; +} + +export interface ProjectGalleryProps { + title: string; + images: GalleryImage[]; + initialIndex?: number; + showCounter?: boolean; + showThumbnails?: boolean; + showControls?: boolean; + loop?: boolean; + backdropClosable?: boolean; + className?: string; +} + +export interface GalleryContextValue extends Required< + Omit +> { + activeIndex: number; + isOpen: boolean; + setActiveIndex: (index: number) => void; + openAt: (index: number, trigger: HTMLElement) => void; + close: () => void; +} + +export interface GalleryCarouselProps { + selectedIndex: number; + onSelect: (index: number) => void; + onApiReady: (api: EmblaCarouselType | undefined) => void; + loop: boolean; + title: string; + images: GalleryImage[]; +} diff --git a/src/components/project-gallery/gallery.utils.ts b/src/components/project-gallery/gallery.utils.ts new file mode 100644 index 0000000..c359150 --- /dev/null +++ b/src/components/project-gallery/gallery.utils.ts @@ -0,0 +1,33 @@ +import { FOCUSABLE_SELECTOR } from './gallery.constants'; + +export function clampIndex(index: number, length: number) { + if (length === 0) return 0; + return Math.min(Math.max(index, 0), length - 1); +} + +export function getGalleryLayoutId(title: string, src: string) { + return `project-gallery-${title}-${src}`; +} + +export function getFocusableElements(container: HTMLElement) { + return Array.from( + container.querySelectorAll(FOCUSABLE_SELECTOR), + ).filter(element => !element.hasAttribute('disabled')); +} + +export function lockBodyScroll() { + const previousOverflow = document.body.style.overflow; + const previousPaddingRight = document.body.style.paddingRight; + const scrollbarWidth = + window.innerWidth - document.documentElement.clientWidth; + + document.body.style.overflow = 'hidden'; + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + } + + return () => { + document.body.style.overflow = previousOverflow; + document.body.style.paddingRight = previousPaddingRight; + }; +} diff --git a/src/components/project-gallery/index.ts b/src/components/project-gallery/index.ts new file mode 100644 index 0000000..c958c3c --- /dev/null +++ b/src/components/project-gallery/index.ts @@ -0,0 +1,2 @@ +export type { GalleryImage, ProjectGalleryProps } from './gallery.types'; +export { ProjectGallery } from './ProjectGallery'; diff --git a/src/components/sections/ContactButton.tsx b/src/components/sections/ContactButton.tsx index 0b93b27..bab354a 100644 --- a/src/components/sections/ContactButton.tsx +++ b/src/components/sections/ContactButton.tsx @@ -4,13 +4,18 @@ import { Button } from '#components/ui/Button.tsx'; interface ContactButtonProps { text?: string; + onClick?: () => void; className?: string; } -export function ContactButton({ text, className }: ContactButtonProps) { +export function ContactButton({ + text, + onClick, + className, +}: ContactButtonProps) { return ( + + - {/* Mobile Menu Overlay */} -