Files
photogallery/src/routes/+page.svelte
Alex Rennie-Lis daa8598dc8 Performance fix
2026-06-29 01:12:38 +01:00

368 lines
12 KiB
Svelte

<script>
import { onMount } from 'svelte';
import { env } from '$env/dynamic/public';
// Svelte 5 Runes for Core Reactivity
let photos = $state([]);
let nextToken = $state(null);
let loading = $state(false);
let hasMore = $state(true);
let activePhoto = $state(null);
let observerTarget;
// Lightbox Control Flags
let isZoomed = $state(false);
let lightboxContainer = $state(null);
let imageMatrixWrapper = $state(null); // Explicit ref to bypass reactive state lag
let isDragging = false; // Internal mutable flag (not a reactive rune)
// Coordinate Vector Matrix (using raw numbers to bypass Svelte state loops on fast cycles)
let currentX = 0;
let currentY = 0;
let startX = 0;
let startY = 0;
let ticking = false;
// Touch tracking coordinates for layout pagination swipes
let touchStartX = 0;
let touchEndX = 0;
// Compute active index reactively
let activeIndex = $derived(
activePhoto ? photos.findIndex(p => p.id === activePhoto.id) : -1
);
async function loadPhotos() {
if (loading || !hasMore) return;
loading = true;
const url = new URL('/api/photos', window.location.origin);
if (nextToken) url.searchParams.set('next', nextToken);
try {
const res = await fetch(url);
const data = await res.json();
if (data && Array.isArray(data.photos)) {
photos = [...photos, ...data.photos];
nextToken = data.nextContinuationToken;
hasMore = !!nextToken;
}
} catch (err) {
console.error('Failed to load photos:', err);
} finally {
loading = false;
}
}
onMount(() => {
loadPhotos();
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadPhotos();
}
}, { rootMargin: '300px' });
if (observerTarget) observer.observe(observerTarget);
return () => observer.disconnect();
});
function changeActivePhoto(photo) {
activePhoto = photo;
resetZoom();
}
function resetZoom() {
isZoomed = false;
isDragging = false;
currentX = 0;
currentY = 0;
if (imageMatrixWrapper) {
imageMatrixWrapper.style.transform = `translate3d(0px, 0px, 0px)`;
}
}
function nextPhoto() {
if (activeIndex !== -1 && activeIndex < photos.length - 1) {
changeActivePhoto(photos[activeIndex + 1]);
if (activeIndex >= photos.length - 4) loadPhotos();
}
}
function prevPhoto() {
if (activeIndex > 0) {
changeActivePhoto(photos[activeIndex - 1]);
}
}
function handleKeyDown(e) {
if (!activePhoto) return;
if (e.key === 'Escape') {
e.preventDefault();
activePhoto = null;
}
if (e.key === 'ArrowRight' && !isZoomed) {
e.preventDefault();
nextPhoto();
}
if (e.key === 'ArrowLeft' && !isZoomed) {
e.preventDefault();
prevPhoto();
}
}
function toggleFullscreen() {
if (!lightboxContainer) return;
if (!document.fullscreenElement) {
lightboxContainer.requestFullscreen().catch(err => console.error(err));
} else {
document.exitFullscreen();
}
}
// HIGH PERFORMANCE NATIVE INTERFACE PIPELINE (60FPS Engine)
function pointerDown(clientX, clientY, target) {
if (!isZoomed) return;
if (target.tagName === 'BUTTON' || target.closest('button')) return;
isDragging = true;
startX = clientX - currentX;
startY = clientY - currentY;
}
function pointerMove(clientX, clientY) {
if (!isDragging) return;
currentX = clientX - startX;
currentY = clientY - startY;
// requestAnimationFrame schedules updates with the browser screen refresh cycle
if (!ticking) {
window.requestAnimationFrame(() => {
if (imageMatrixWrapper) {
imageMatrixWrapper.style.transform = `translate3d(${currentX}px, ${currentY}px, 0px)`;
}
ticking = false;
});
ticking = true;
}
}
function pointerUp() {
isDragging = false;
}
// Desktop Mouse Interface Subscriptions
function handleMouseDown(e) { pointerDown(e.clientX, e.clientY, e.target); }
function handleMouseMove(e) {
if(isDragging) e.preventDefault();
pointerMove(e.clientX, e.clientY);
}
// Mobile Touch Interface Subscriptions
function handleTouchStart(e) {
if (isZoomed) {
const touch = e.touches[0];
pointerDown(touch.clientX, touch.clientY, e.target);
} else {
touchStartX = e.changedTouches[0].screenX;
}
}
function handleTouchMove(e) {
if (isZoomed && e.touches.length === 1) {
e.preventDefault(); // Kill native momentum bouncing
const touch = e.touches[0];
pointerMove(touch.clientX, touch.clientY);
}
}
function handleTouchEnd(e) {
if (isZoomed) {
pointerUp();
} else {
touchEndX = e.changedTouches[0].screenX;
const threshold = 50;
if (touchStartX - touchEndX > threshold) nextPhoto();
else if (touchEndX - touchStartX > threshold) prevPhoto();
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<div class="min-h-screen bg-zinc-950 text-zinc-100">
<header class="sticky top-0 z-10 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur-md px-6 py-4 flex justify-between items-center">
<h1 class="text-xl font-bold tracking-wide">{env.PUBLIC_GALLERY_NAME}</h1>
<span class="text-xs text-zinc-500 uppercase tracking-widest bg-zinc-900 border border-zinc-800 px-3 py-1 rounded-full">Secure Session</span>
</header>
<main class="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4">
{#each photos as photo (photo.id)}
<button
class="group relative aspect-square overflow-hidden rounded-xl bg-zinc-900 border border-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
onclick={() => changeActivePhoto(photo)}
>
<img
src={photo.thumbUrl}
alt="Gallery item"
loading="lazy"
class="h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-105"
/>
<div class="absolute inset-0 bg-black/20 opacity-0 transition-opacity group-hover:opacity-100"></div>
</button>
{/each}
</div>
<div bind:this={observerTarget} class="w-full flex justify-center py-12">
{#if loading}
<div class="h-6 w-6 animate-spin rounded-full border-2 border-zinc-500 border-t-transparent"></div>
{/if}
</div>
</main>
</div>
{#if activePhoto}
<div
bind:this={lightboxContainer}
class="fixed inset-0 z-50 flex flex-col items-center justify-between bg-black/95 p-4 select-none touch-none"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={(e) => { if (e.target === e.currentTarget) activePhoto = null; }}
onkeydown={handleKeyDown}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
>
<div class="w-full flex justify-between items-center z-50 p-2 pointer-events-none">
<div class="text-sm font-medium tracking-wider bg-zinc-900/80 px-4 py-2 rounded-full border border-zinc-800/60 text-zinc-300 pointer-events-auto">
{activeIndex + 1} <span class="text-zinc-600 px-0.5">/</span> {photos.length}
</div>
<div class="flex items-center gap-2 pointer-events-auto">
<button
class="rounded-full bg-zinc-900/80 p-2.5 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
title={isZoomed ? "Reset Zoom" : "Zoom to Full Resolution"}
onclick={() => { if(isZoomed) { resetZoom(); } else { isZoomed = true; } }}
>
{#if isZoomed}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M15 9V4.5M15 9h4.5M15 9l5.25-5.25M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 15v4.5M15 15h4.5M15 15l5.25 5.25" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75v4.5m0-4.5h-4.5m4.5 0L15 9m5.25 11.25v-4.5m0 4.5h-4.5m4.5 0L15 15" />
</svg>
{/if}
</button>
<button
class="rounded-full bg-zinc-900/80 p-2.5 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
title="Toggle Fullscreen"
onclick={toggleFullscreen}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6h16.5" />
</svg>
</button>
<a
href="/api/download?id={activePhoto.id}"
class="rounded-full bg-zinc-900/80 p-2.5 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
title="Download Original"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</a>
<button
class="rounded-full bg-zinc-900/80 p-2.5 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
title="Close"
onclick={() => activePhoto = null}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div
class="relative w-full flex-1 flex items-center justify-center overflow-hidden my-4 selection:bg-transparent"
class:cursor-grab={isZoomed && !isDragging}
class:cursor-grabbing={isZoomed && isDragging}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmouseup={pointerUp}
onmouseleave={pointerUp}
>
{#if !isZoomed}
<button
type="button"
class="absolute left-0 top-0 bottom-0 w-[10%] z-30 cursor-w-resize focus:outline-none flex items-center justify-start pl-4 group"
onclick={prevPhoto}
aria-label="Previous photo"
disabled={activeIndex === 0}
>
{#if activeIndex > 0}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-8 h-8 text-zinc-500 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
{/if}
</button>
<button
type="button"
class="absolute right-0 top-0 bottom-0 w-[10%] z-30 cursor-e-resize focus:outline-none flex items-center justify-end pr-4 group"
onclick={nextPhoto}
aria-label="Next photo"
disabled={activeIndex === photos.length - 1}
>
{#if activeIndex < photos.length - 1}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-8 h-8 text-zinc-500 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
{/if}
</button>
{/if}
<div
bind:this={imageMatrixWrapper}
class="max-w-full max-h-full flex items-center justify-center p-2 ease-out will-change-transform"
>
<img
src={activePhoto.fullUrl}
alt="Gallery Content Visual"
draggable="false"
class="max-h-[70vh] max-w-full rounded shadow-2xl select-none pointer-events-none transition-transform duration-200 ease-out will-change-transform"
style="transform: scale({isZoomed ? 3.8 : 1});"
/>
</div>
</div>
<div class="w-full max-w-4xl mx-auto z-40 bg-zinc-950/90 border border-zinc-900 px-4 py-3 rounded-xl backdrop-blur-md pointer-events-auto">
<div class="flex items-center gap-2 overflow-x-auto overflow-y-hidden py-1 px-0.5 scrollbar-thin scrollbar-thumb-zinc-800 scroll-smooth">
{#each photos as thumbPhoto, idx}
<button
type="button"
class="relative flex-shrink-0 w-16 h-12 rounded-md overflow-hidden border transition-all duration-200 focus:outline-none
{idx === activeIndex ? 'border-blue-500 scale-105 ring-2 ring-blue-500/20 opacity-100' : 'border-zinc-800/80 opacity-40 hover:opacity-80'}"
onclick={() => changeActivePhoto(thumbPhoto)}
>
<img
src={thumbPhoto.thumbUrl}
alt="Ribbon Navigation Frame"
class="w-full h-full object-cover"
/>
</button>
{/each}
</div>
</div>
</div>
{/if}