feat: implement portfolio carousel with swipe/nav, FLIP close button, 250ms slide

Adds a 3-slide flex-track carousel to the full-size image dialog with:\n- Swipe & button navigation\n- Dynamic close button positioning (FLIP animation, 150ms)\n- 250ms slide animation (buttons + swipe unified)\n- Preloading of adjacent images\n- Mobile button fix (touch start ignores buttons)\n- Fixed-height slides (95vh) prevent height cropping

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-13 10:41:00 +01:00
co-authored by Sisyphus
parent 18eaa00479
commit 45814a4040
+252 -61
View File
@@ -403,6 +403,150 @@
let imageLoading = $state(false);
let nextButtonRef = $state<HTMLButtonElement | undefined>(undefined);
let prevButtonRef = $state<HTMLButtonElement | undefined>(undefined);
let closeBtnRef = $state<HTMLButtonElement | undefined>(undefined);
let touchStartX = $state(0);
let swiping = $state(false);
let trackAnimating = $state(false);
let modalContentRef = $state<HTMLDivElement | undefined>(undefined);
let trackOffset = $state('0px');
let trackTransition = $state('none');
let prevFullURLs = $state<FullURLs | null>(null);
let nextFullURLs = $state<FullURLs | null>(null);
let centerSlideRef = $state<HTMLDivElement | undefined>(undefined);
const SLIDE_MS = 250;
const BTN_FLIP_MS = 150;
const BUTTON_EASE = 'cubic-bezier(0.4, 0, 0.2, 1)';
/** Old button rect captured before a data swap, used to FLIP the close button
* once the new image has loaded and has its final layout dimensions. */
let pendingBtnRect: DOMRect | null = null;
/** Position the close button at the top-right of the current image.
* Uses `getBoundingClientRect` for accuracy (works after the image has loaded
* and the browser has laid out the final constrained size).
* If `pendingBtnRect` is set, it plays a FLIP animation from that old position. */
function positionCloseBtn() {
if (!closeBtnRef || !centerSlideRef) return;
const isSm = window.innerWidth >= 640;
const offset = isSm ? 16 : 8;
// Find the image inside the center slide
const img = centerSlideRef.querySelector('img');
if (!img) return;
// Use getBoundingClientRect to get the actual rendered image bounds
const imgRect = img.getBoundingClientRect();
const slideRect = centerSlideRef.getBoundingClientRect();
const imgTopRel = imgRect.top - slideRect.top; // image top relative to the slide
// Position button so its top edge is `offset` px below the image top edge.
// Same offset as the right edge (`right-2` / `sm:right-4`), so the circle
// sits equally inside both edges.
closeBtnRef.style.top = (imgTopRel + offset) + 'px';
// FLIP from the old position if we have one
if (pendingBtnRect) {
const newRect = closeBtnRef.getBoundingClientRect();
const dy = pendingBtnRect.top - newRect.top;
pendingBtnRect = null;
if (Math.abs(dy) > 0.5) {
closeBtnRef.animate(
[
{ transform: `translateY(${dy}px)` },
{ transform: 'translateY(0)' }
],
{ duration: BTN_FLIP_MS, easing: BUTTON_EASE }
);
}
}
}
function updateUrlForIndex(index: number) {
const imgUrl = images[index].full.avif || images[index].full.jpg;
const filename = imgUrl.split('/').pop() || '';
const timestamp = filename.replace(/_full|_thumb|\.[^.]+$/g, '') || images[index].id;
const url = new URL(page.url);
url.searchParams.set('img', timestamp);
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
}
function finishSlideTransition(index: number) {
trackAnimating = false;
// Capture old button position BEFORE swapping data.
// The FLIP animation will play once the new image has loaded
// (in handleFullImageLoad) and has correct layout dimensions.
pendingBtnRect = closeBtnRef?.getBoundingClientRect() || null;
// Update data — the slide content changes and the track snaps back.
const target = images[index];
selectedFullURLs = target.full;
selectedThumbURLs = target.thumb;
currentIndex = index;
updateUrlForIndex(index);
updateAdjacentSlides(index);
trackTransition = 'none';
trackOffset = 'calc(-100% / 3)';
}
function navigateNext() {
slideToNext();
}
function navigatePrev() {
slideToPrev();
}
function handleTouchStart(e: TouchEvent) {
if (trackAnimating) return;
// Ignore touches on nav buttons — they use onclick handlers
if ((e.target as HTMLElement).closest('button')) return;
touchStartX = e.touches[0].clientX;
swiping = true;
}
function handleTouchMove(e: TouchEvent) {
if (!swiping || trackAnimating) return;
e.preventDefault();
const delta = e.touches[0].clientX - touchStartX;
trackOffset = `calc(-100% / 3 + ${delta}px)`;
trackTransition = 'none';
}
function handleTouchEnd(e: TouchEvent) {
if (!swiping) return;
swiping = false;
const deltaX = e.changedTouches[0].clientX - touchStartX;
const threshold = 50;
const canGoNext = currentIndex < images.length - 1;
const canGoPrev = currentIndex > 0;
if (Math.abs(deltaX) > threshold && !trackAnimating) {
if (deltaX < 0 && canGoNext) {
slideToNext();
} else if (deltaX > 0 && canGoPrev) {
slideToPrev();
} else {
animateBounceBack();
}
} else {
animateBounceBack();
}
}
function animateBounceBack() {
trackAnimating = true;
trackTransition = `transform ${SLIDE_MS}ms ${BUTTON_EASE}`;
trackOffset = 'calc(-100% / 3)';
setTimeout(() => {
trackAnimating = false;
trackTransition = 'none';
}, SLIDE_MS + 50);
}
function openModal(img: PortfolioImage) {
const idx = images.findIndex((i) => i.id === img.id);
@@ -422,6 +566,9 @@
selectedThumbURLs = images[index].thumb;
imageLoading = true;
showModal = true;
trackOffset = 'calc(-100% / 3)';
trackTransition = 'none';
updateAdjacentSlides(index);
const imgUrl = images[index].full.avif || images[index].full.jpg;
const filename = imgUrl.split('/').pop() || '';
@@ -432,6 +579,9 @@
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
requestAnimationFrame(() => {
// Position close button (no pendingBtnRect → no FLIP animation)
positionCloseBtn();
const hasNext = currentIndex < images.length - 1;
const hasPrev = currentIndex > 0;
if (hasNext && nextButtonRef) {
@@ -442,38 +592,40 @@
});
}
function navigateNext() {
if (currentIndex < images.length - 1) {
currentIndex++;
selectedFullURLs = images[currentIndex].full;
selectedThumbURLs = images[currentIndex].thumb;
imageLoading = true;
const imgUrl = images[currentIndex].full.avif || images[currentIndex].full.jpg;
const filename = imgUrl.split('/').pop() || '';
const timestamp = filename.replace(/_full|_thumb|\.[^.]+$/g, '') || images[currentIndex].id;
const url = new URL(page.url);
url.searchParams.set('img', timestamp);
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
}
}
function navigatePrev() {
if (currentIndex > 0) {
currentIndex--;
selectedFullURLs = images[currentIndex].full;
selectedThumbURLs = images[currentIndex].thumb;
imageLoading = true;
const imgUrl = images[currentIndex].full.avif || images[currentIndex].full.jpg;
const filename = imgUrl.split('/').pop() || '';
const timestamp = filename.replace(/_full|_thumb|\.[^.]+$/g, '') || images[currentIndex].id;
const url = new URL(page.url);
url.searchParams.set('img', timestamp);
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
}
}
function handleFullImageLoad() {
imageLoading = false;
// Wait a frame so the browser has laid out the image with its final
// constrained size, then position the button and FLIP from the old position.
requestAnimationFrame(() => positionCloseBtn());
}
function updateAdjacentSlides(index: number) {
const nextIdx = index + 1;
nextFullURLs = nextIdx < images.length ? images[nextIdx].full : null;
const prevIdx = index - 1;
prevFullURLs = prevIdx >= 0 ? images[prevIdx].full : null;
}
// -- carousel -- //
function slideToNext() {
if (trackAnimating || currentIndex >= images.length - 1) return;
trackAnimating = true;
trackTransition = `transform ${SLIDE_MS}ms ${BUTTON_EASE}`;
trackOffset = 'calc(-200% / 3)';
setTimeout(() => {
finishSlideTransition(currentIndex + 1);
}, SLIDE_MS + 50);
}
function slideToPrev() {
if (trackAnimating || currentIndex <= 0) return;
trackAnimating = true;
trackTransition = `transform ${SLIDE_MS}ms ${BUTTON_EASE}`;
trackOffset = 'calc(0%)';
setTimeout(() => {
finishSlideTransition(currentIndex - 1);
}, SLIDE_MS + 50);
}
function handleKeydown(e: KeyboardEvent) {
@@ -487,7 +639,7 @@
navigatePrev();
} else if (e.key === 'Escape') {
e.preventDefault();
showModal = false;
closeModal();
}
}
@@ -715,41 +867,80 @@
{/if}
{#if showModal}
<Dialog open={showModal} onOpenChange={(v) => (showModal = v)}>
<Dialog open={showModal} onOpenChange={(v) => { if (!v) closeModal(); else showModal = v; }}>
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm" />
<DialogContent
showCloseButton={false}
hideClose={true}
class="fixed top-1/2 left-1/2 z-50 max-h-[95vh] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 border-0 bg-transparent p-0 shadow-none focus:outline-none sm:max-h-[90vh] sm:max-w-[90vw]"
>
<div class="relative flex items-center justify-center">
{#if imageLoading && selectedThumbURLs}
<ImageVariant
urls={selectedThumbURLs}
type="thumb"
alt="Loading preview"
class="absolute max-h-[95vh] max-w-[95vw] scale-110 rounded-sm object-contain blur-xl sm:max-h-[90vh] sm:max-w-[90vw]"
/>
<div class="absolute">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-white/30 border-t-white"
></div>
</div>
{/if}
<div
bind:this={modalContentRef}
class="relative flex max-h-[95vh] max-w-[95vw] items-center justify-center sm:max-h-[90vh] sm:max-w-[90vw]"
role="presentation"
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
>
<div class="overflow-hidden rounded-sm">
<div
class="flex w-[300%]"
style="transform: translateX({trackOffset}); transition: {trackTransition}"
>
<div class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
{#if prevFullURLs}
<ImageVariant
urls={prevFullURLs}
type="full"
alt=""
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
/>
{/if}
</div>
{#if selectedFullURLs}
<ImageVariant
urls={selectedFullURLs}
type="full"
alt="Portfolio full size"
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
? 'opacity-0'
: ''}"
onload={handleFullImageLoad}
/>
{/if}
<div bind:this={centerSlideRef} class="relative flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
{#if imageLoading && selectedThumbURLs}
<ImageVariant
urls={selectedThumbURLs}
type="thumb"
alt="Loading preview"
class="absolute max-h-[95vh] max-w-[95vw] scale-110 rounded-sm object-contain blur-xl sm:max-h-[90vh] sm:max-w-[90vw]"
/>
<div class="absolute inset-0 flex items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-white/30 border-t-white"
></div>
</div>
{/if}
{#if selectedFullURLs}
<ImageVariant
urls={selectedFullURLs}
type="full"
alt="Portfolio full size"
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
? 'opacity-0'
: ''}"
onload={handleFullImageLoad}
/>
{/if}
</div>
<div class="flex h-[95vh] shrink-0 items-center justify-center sm:h-[90vh]" style="width: calc(100% / 3)">
{#if nextFullURLs}
<ImageVariant
urls={nextFullURLs}
type="full"
alt=""
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain sm:max-h-[90vh] sm:max-w-[90vw]"
/>
{/if}
</div>
</div>
</div>
<button
class="absolute top-2 right-2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 sm:top-4 sm:right-4"
bind:this={closeBtnRef}
class="absolute right-2 z-[60] rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 sm:right-4"
onclick={closeModal}
aria-label="Close modal"
>
@@ -766,7 +957,7 @@
{#if currentIndex > 0}
<button
bind:this={prevButtonRef}
class="absolute top-1/2 left-2 -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 sm:left-4"
class="absolute top-1/2 left-2 z-[60] -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 sm:left-4"
onclick={navigatePrev}
aria-label="Previous image"
>
@@ -784,7 +975,7 @@
{#if currentIndex < images.length - 1}
<button
bind:this={nextButtonRef}
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 sm:right-4"
class="absolute top-1/2 right-2 z-[60] -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 sm:right-4"
onclick={navigateNext}
aria-label="Next image"
>