Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/assets/images/portfolio/hero-dashboard.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/assets/images/portfolio/profile-placeholder.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
92 changes: 92 additions & 0 deletions src/components/project-gallery/GalleryCarousel.tsx
Original file line number Diff line number Diff line change
@@ -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<number | undefined>(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<HTMLDivElement>) => {
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 (
<div
ref={viewportRef}
onWheel={handleWheel}
className="h-full w-full touch-pan-y overflow-hidden"
aria-roledescription="carousel"
aria-label={`${title} screenshots`}
>
<div className="flex h-full items-center">
{images.map((image, index) => (
<div
key={image.src}
className="flex h-full min-w-0 flex-[0_0_100%] items-center justify-center px-3 py-16 sm:px-12 sm:py-20"
aria-roledescription="slide"
aria-label={`${index + 1} of ${images.length}`}
aria-hidden={index !== selectedIndex}
>
<motion.img
layoutId={
index === selectedIndex
? getGalleryLayoutId(title, image.src)
: undefined
}
src={image.src}
alt={image.alt || `${title} screenshot ${index + 1}`}
loading={index === selectedIndex ? 'eager' : 'lazy'}
draggable={false}
className="max-h-full max-w-full rounded-2xl object-contain shadow-2xl shadow-black/60 select-none sm:rounded-3xl"
/>
</div>
))}
</div>
</div>
);
});
13 changes: 13 additions & 0 deletions src/components/project-gallery/GalleryContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createContext, useContext } from 'react';

import type { GalleryContextValue } from './gallery.types';

export const GalleryContext = createContext<GalleryContextValue | null>(null);

export function useProjectGallery() {
const context = useContext(GalleryContext);
if (!context) {
throw new Error('useProjectGallery must be used within ProjectGallery');
}
return context;
}
58 changes: 58 additions & 0 deletions src/components/project-gallery/GalleryControls.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button
type="button"
onClick={onClose}
className={cn(
controlClassName,
'absolute top-4 right-4 z-30 sm:top-6 sm:right-6',
)}
aria-label="Close gallery"
>
<X aria-hidden="true" className="h-5 w-5" />
</button>

{showControls && (
<div className="pointer-events-none absolute inset-x-3 top-1/2 z-20 hidden -translate-y-1/2 justify-between md:flex">
<button
type="button"
onClick={onPrevious}
className={cn(controlClassName, 'pointer-events-auto')}
aria-label="Previous image"
>
<ChevronLeft aria-hidden="true" className="h-6 w-6" />
</button>
<button
type="button"
onClick={onNext}
className={cn(controlClassName, 'pointer-events-auto')}
aria-label="Next image"
>
<ChevronRight aria-hidden="true" className="h-6 w-6" />
</button>
</div>
)}
</>
);
});
137 changes: 137 additions & 0 deletions src/components/project-gallery/GalleryModal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const [api, setApi] = useState<EmblaCarouselType>();

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(
<motion.div
className="fixed inset-0 z-100 bg-black/55 backdrop-blur-3xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={gallerySpring}
onMouseDown={event => {
if (backdropClosable && event.target === event.currentTarget) close();
}}
>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={`${title} image gallery`}
tabIndex={-1}
className="relative flex h-full w-full flex-col overflow-hidden"
initial={{ opacity: 0, scale: 0.985 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.985 }}
transition={gallerySpring}
>
<div className="bg-accent-primary/20 pointer-events-none absolute top-[25%] left-1/2 h-[45%] w-[55%] -translate-x-1/2 rounded-full blur-[120px]" />
<div className="absolute inset-x-2 top-2 bottom-2 overflow-hidden rounded-[2rem] bg-white/[0.035] shadow-2xl shadow-black/60">
<GalleryCarousel
selectedIndex={activeIndex}
onSelect={setActiveIndex}
onApiReady={setApi}
loop={loop}
title={title}
images={images}
/>
</div>

<GalleryControls
onClose={close}
onPrevious={previous}
onNext={next}
showControls={showControls}
/>

<div className="relative z-30 mt-auto flex min-h-24 flex-col justify-end pb-3">
{showCounter && (
<p className="mb-2 text-center text-xs font-medium tracking-widest text-white/65">
{activeIndex + 1} / {images.length}
</p>
)}
{showThumbnails && (
<GalleryThumbnailStrip
images={images}
selectedIndex={activeIndex}
onSelect={index => {
setActiveIndex(index);
api?.scrollTo(index);
}}
title={title}
/>
)}
</div>
</motion.div>
</motion.div>,
document.body,
);
}
Loading
Loading