diff --git a/frontend/src/routes/portfolio/+page.svelte b/frontend/src/routes/portfolio/+page.svelte index a34748d..c86a0e6 100644 --- a/frontend/src/routes/portfolio/+page.svelte +++ b/frontend/src/routes/portfolio/+page.svelte @@ -403,6 +403,150 @@ let imageLoading = $state(false); let nextButtonRef = $state(undefined); let prevButtonRef = $state(undefined); + let closeBtnRef = $state(undefined); + let touchStartX = $state(0); + let swiping = $state(false); + let trackAnimating = $state(false); + let modalContentRef = $state(undefined); + let trackOffset = $state('0px'); + let trackTransition = $state('none'); + + let prevFullURLs = $state(null); + let nextFullURLs = $state(null); + let centerSlideRef = $state(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} - (showModal = v)}> + { if (!v) closeModal(); else showModal = v; }}> -
- {#if imageLoading && selectedThumbURLs} - -
-
-
- {/if} +