UI improvements.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { onMount } from 'svelte';
|
||||
import { PUBLIC_GALLERY_NAME } from '$env/static/public';
|
||||
|
||||
// Svelte 5 Runes for Reactivity
|
||||
let photos = $state([]);
|
||||
@@ -10,11 +10,22 @@
|
||||
let activePhoto = $state(null);
|
||||
let observerTarget;
|
||||
|
||||
// Touch tracking coordinates for swipes
|
||||
// Lightbox Pan & Zoom Matrix State
|
||||
let isZoomed = $state(false);
|
||||
let lightboxContainer = $state(null);
|
||||
let isDragging = $state(false);
|
||||
|
||||
// Coordinate state vectors for custom 2D transform panning
|
||||
let panX = $state(0);
|
||||
let panY = $state(0);
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
// Touch tracking coordinates for mobile swipes
|
||||
let touchStartX = 0;
|
||||
let touchEndX = 0;
|
||||
|
||||
// Find the current active index
|
||||
// Compute active index reactively
|
||||
let activeIndex = $derived(
|
||||
activePhoto ? photos.findIndex(p => p.id === activePhoto.id) : -1
|
||||
);
|
||||
@@ -56,63 +67,108 @@
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Navigation triggers
|
||||
function changeActivePhoto(photo) {
|
||||
activePhoto = photo;
|
||||
resetZoom();
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
isZoomed = false;
|
||||
isDragging = false;
|
||||
panX = 0;
|
||||
panY = 0;
|
||||
}
|
||||
|
||||
function nextPhoto() {
|
||||
if (activeIndex !== -1 && activeIndex < photos.length - 1) {
|
||||
activePhoto = photos[activeIndex + 1];
|
||||
// Automatically pre-fetch next batch if user reaches near the end of loaded list
|
||||
changeActivePhoto(photos[activeIndex + 1]);
|
||||
if (activeIndex >= photos.length - 4) loadPhotos();
|
||||
}
|
||||
}
|
||||
|
||||
function prevPhoto() {
|
||||
if (activeIndex > 0) {
|
||||
activePhoto = photos[activeIndex - 1];
|
||||
changeActivePhoto(photos[activeIndex - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard Handler
|
||||
function handleKeyDown(e) {
|
||||
if (!activePhoto) return;
|
||||
if (e.key === 'Escape') activePhoto = null;
|
||||
if (e.key === 'ArrowRight') nextPhoto();
|
||||
if (e.key === 'ArrowLeft') prevPhoto();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
activePhoto = null;
|
||||
}
|
||||
if (e.key === 'ArrowRight' && !isZoomed) {
|
||||
e.preventDefault(); // 👈 Stops the underlying grid buttons from changing focus
|
||||
nextPhoto();
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && !isZoomed) {
|
||||
e.preventDefault(); // 👈 Stops the underlying grid buttons from changing focus
|
||||
prevPhoto();
|
||||
}
|
||||
}
|
||||
|
||||
// Swipe Handlers for Mobile Devices
|
||||
function toggleFullscreen() {
|
||||
if (!lightboxContainer) return;
|
||||
if (!document.fullscreenElement) {
|
||||
lightboxContainer.requestFullscreen().catch(err => console.error(err));
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic Pointer Interface Input Routing (Desktop Mouse & Mobile Touch Dragging)
|
||||
function pointerDown(clientX, clientY, target) {
|
||||
if (!isZoomed) return;
|
||||
if (target.tagName === 'BUTTON' || target.closest('button')) return;
|
||||
|
||||
isDragging = true;
|
||||
startX = clientX - panX;
|
||||
startY = clientY - panY;
|
||||
}
|
||||
|
||||
function pointerMove(clientX, clientY, preventDefaultFunc) {
|
||||
if (!isDragging) return;
|
||||
if (typeof preventDefaultFunc === 'function') preventDefaultFunc();
|
||||
|
||||
panX = clientX - startX;
|
||||
panY = clientY - startY;
|
||||
}
|
||||
|
||||
function pointerUp() {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
// Desktop Mouse Adapters
|
||||
function handleMouseDown(e) { pointerDown(e.clientX, e.clientY, e.target); }
|
||||
function handleMouseMove(e) { pointerMove(e.clientX, e.clientY, () => e.preventDefault()); }
|
||||
|
||||
// Mobile Touch Panning Adapters
|
||||
function handleTouchStart(e) {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
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) {
|
||||
const touch = e.touches[0];
|
||||
pointerMove(touch.clientX, touch.clientY, () => e.preventDefault());
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd(e) {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
handleSwipeGesture();
|
||||
}
|
||||
|
||||
function handleSwipeGesture() {
|
||||
const threshold = 50; // Minimum distance in pixels to count as a swipe
|
||||
if (touchStartX - touchEndX > threshold) {
|
||||
nextPhoto(); // Swiped left, view next
|
||||
} else if (touchEndX - touchStartX > threshold) {
|
||||
prevPhoto(); // Swiped right, view previous
|
||||
}
|
||||
}
|
||||
|
||||
// File Downloader
|
||||
async function downloadImage(url, filename) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
console.error('Download failed', err);
|
||||
if (isZoomed) {
|
||||
pointerUp();
|
||||
} else {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
const threshold = 50;
|
||||
if (touchStartX - touchEndX > threshold) nextPhoto();
|
||||
else if (touchEndX - touchStartX > threshold) prevPhoto();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -121,7 +177,7 @@
|
||||
|
||||
<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>
|
||||
<h1 class="text-xl font-bold tracking-wide">{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>
|
||||
|
||||
@@ -130,7 +186,7 @@
|
||||
{#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={() => activePhoto = photo}
|
||||
onclick={() => changeActivePhoto(photo)}
|
||||
>
|
||||
<img
|
||||
src={photo.thumbUrl}
|
||||
@@ -153,73 +209,140 @@
|
||||
|
||||
{#if activePhoto}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/95 p-4 transition-opacity select-none touch-none"
|
||||
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}
|
||||
onttouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
>
|
||||
<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}
|
||||
<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 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-600 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 !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}
|
||||
</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-600 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>
|
||||
|
||||
<div class="absolute top-4 right-4 flex items-center gap-3 z-50">
|
||||
<a
|
||||
href="/api/download?id={activePhoto.id}"
|
||||
class="rounded-full bg-zinc-900/80 p-3 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-6 h-6">
|
||||
<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-3 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
|
||||
title="Close"
|
||||
onclick={() => activePhoto = null}
|
||||
<div
|
||||
class="max-w-full max-h-full flex items-center justify-center p-2 transition-transform duration-300 ease-out"
|
||||
style="transform: translate3d({panX}px, {panY}px, 0px);"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<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-300 will-change-transform"
|
||||
style="transform: scale({isZoomed ? 3.8 : 1});"
|
||||
/> </div>
|
||||
</div>
|
||||
|
||||
<div class="relative max-h-[85vh] max-w-full flex items-center justify-center pointer-events-none">
|
||||
<img
|
||||
src={activePhoto.fullUrl}
|
||||
alt="High-resolution visual"
|
||||
class="max-h-[85vh] max-w-full object-contain rounded shadow-2xl transition-all duration-200"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<p class="mt-4 text-xs text-zinc-500 font-mono tracking-wider">{activePhoto.id}</p>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user