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:
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user