Gallery support
This commit is contained in:
@@ -1,367 +1,426 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
// Svelte 5 Runes for Core Reactivity
|
// Svelte 5 Runes for Core Reactivity
|
||||||
let photos = $state([]);
|
let photos = $state([]);
|
||||||
let nextToken = $state(null);
|
let galleries = $state([]);
|
||||||
let loading = $state(false);
|
let currentGallery = $state('');
|
||||||
let hasMore = $state(true);
|
|
||||||
let activePhoto = $state(null);
|
let nextToken = $state(null);
|
||||||
let observerTarget;
|
let loading = $state(false);
|
||||||
|
let hasMore = $state(true);
|
||||||
|
let activePhoto = $state(null);
|
||||||
|
let observerTarget;
|
||||||
|
|
||||||
// Lightbox Control Flags
|
// Lightbox Control Flags
|
||||||
let isZoomed = $state(false);
|
let isZoomed = $state(false);
|
||||||
let lightboxContainer = $state(null);
|
let lightboxContainer = $state(null);
|
||||||
let imageMatrixWrapper = $state(null); // Explicit ref to bypass reactive state lag
|
let imageMatrixWrapper = $state(null); // Explicit ref to bypass reactive state lag
|
||||||
let isDragging = false; // Internal mutable flag (not a reactive rune)
|
let isDragging = false; // Internal mutable flag (not a reactive rune)
|
||||||
|
|
||||||
// Coordinate Vector Matrix (using raw numbers to bypass Svelte state loops on fast cycles)
|
// Coordinate Vector Matrix (using raw numbers to bypass Svelte state loops on fast cycles)
|
||||||
let currentX = 0;
|
let currentX = 0;
|
||||||
let currentY = 0;
|
let currentY = 0;
|
||||||
let startX = 0;
|
let startX = 0;
|
||||||
let startY = 0;
|
let startY = 0;
|
||||||
let ticking = false;
|
let ticking = false;
|
||||||
|
|
||||||
// Touch tracking coordinates for layout pagination swipes
|
// Touch tracking coordinates for layout pagination swipes
|
||||||
let touchStartX = 0;
|
let touchStartX = 0;
|
||||||
let touchEndX = 0;
|
let touchEndX = 0;
|
||||||
|
|
||||||
// Compute active index reactively
|
// Compute active index reactively
|
||||||
let activeIndex = $derived(
|
let activeIndex = $derived(
|
||||||
activePhoto ? photos.findIndex(p => p.id === activePhoto.id) : -1
|
activePhoto ? photos.findIndex(p => p.id === activePhoto.id) : -1
|
||||||
);
|
);
|
||||||
|
|
||||||
async function loadPhotos() {
|
// Dynamic Title Renderer
|
||||||
if (loading || !hasMore) return;
|
let pageTitle = $derived.by(() => {
|
||||||
|
if (galleries.length <= 1) {
|
||||||
|
return env.PUBLIC_GALLERY_NAME || formatTitle(currentGallery);
|
||||||
|
}
|
||||||
|
return formatTitle(currentGallery);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean folder strings into title representations
|
||||||
|
function formatTitle(folderName) {
|
||||||
|
if (!folderName) return '';
|
||||||
|
return folderName
|
||||||
|
.replace(/[_-]/g, ' ')
|
||||||
|
.replace(/\b\w/g, char => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPhotos(reset = false) {
|
||||||
|
if (loading) return;
|
||||||
|
if (!reset && !hasMore) return;
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
|
if (reset) {
|
||||||
|
photos = [];
|
||||||
|
nextToken = null;
|
||||||
|
hasMore = true;
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL('/api/photos', window.location.origin);
|
const url = new URL('/api/photos', window.location.origin);
|
||||||
if (nextToken) url.searchParams.set('next', nextToken);
|
url.searchParams.set('gallery', currentGallery);
|
||||||
|
if (nextToken) url.searchParams.set('next', nextToken);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data && Array.isArray(data.photos)) {
|
if (data) {
|
||||||
photos = [...photos, ...data.photos];
|
galleries = data.galleries || [];
|
||||||
nextToken = data.nextContinuationToken;
|
if (!currentGallery) currentGallery = data.currentGallery || '';
|
||||||
hasMore = !!nextToken;
|
|
||||||
}
|
photos = reset ? (data.photos || []) : [...photos, ...(data.photos || [])];
|
||||||
} catch (err) {
|
nextToken = data.nextContinuationToken;
|
||||||
console.error('Failed to load photos:', err);
|
hasMore = !!nextToken;
|
||||||
} finally {
|
}
|
||||||
loading = false;
|
} catch (err) {
|
||||||
|
console.error('Failed to load photos:', err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGallerySwitch(e) {
|
||||||
|
currentGallery = e.target.value;
|
||||||
|
resetZoom();
|
||||||
|
loadPhotos(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadPhotos();
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMore && !loading) {
|
||||||
|
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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
function handleKeyDown(e) {
|
||||||
loadPhotos();
|
if (!activePhoto) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
if (e.key === 'Escape') {
|
||||||
if (entries[0].isIntersecting && hasMore) {
|
e.preventDefault();
|
||||||
loadPhotos();
|
activePhoto = null;
|
||||||
}
|
}
|
||||||
}, { rootMargin: '300px' });
|
if (e.key === 'ArrowRight' && !isZoomed) {
|
||||||
|
e.preventDefault();
|
||||||
if (observerTarget) observer.observe(observerTarget);
|
nextPhoto();
|
||||||
|
}
|
||||||
return () => observer.disconnect();
|
if (e.key === 'ArrowLeft' && !isZoomed) {
|
||||||
});
|
e.preventDefault();
|
||||||
|
prevPhoto();
|
||||||
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() {
|
function toggleFullscreen() {
|
||||||
if (!lightboxContainer) return;
|
if (!lightboxContainer) return;
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
lightboxContainer.requestFullscreen().catch(err => console.error(err));
|
lightboxContainer.requestFullscreen().catch(err => console.error(err));
|
||||||
} else {
|
} else {
|
||||||
document.exitFullscreen();
|
document.exitFullscreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HIGH PERFORMANCE NATIVE INTERFACE PIPELINE (60FPS Engine)
|
// HIGH PERFORMANCE NATIVE INTERFACE PIPELINE (60FPS Engine)
|
||||||
function pointerDown(clientX, clientY, target) {
|
function pointerDown(clientX, clientY, target) {
|
||||||
if (!isZoomed) return;
|
if (!isZoomed) return;
|
||||||
if (target.tagName === 'BUTTON' || target.closest('button')) return;
|
if (target.tagName === 'BUTTON' || target.closest('button')) return;
|
||||||
|
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
startX = clientX - currentX;
|
startX = clientX - currentX;
|
||||||
startY = clientY - currentY;
|
startY = clientY - currentY;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointerMove(clientX, clientY) {
|
function pointerMove(clientX, clientY) {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
|
||||||
currentX = clientX - startX;
|
currentX = clientX - startX;
|
||||||
currentY = clientY - startY;
|
currentY = clientY - startY;
|
||||||
|
|
||||||
// requestAnimationFrame schedules updates with the browser screen refresh cycle
|
// requestAnimationFrame schedules updates with the browser screen refresh cycle
|
||||||
if (!ticking) {
|
if (!ticking) {
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
if (imageMatrixWrapper) {
|
if (imageMatrixWrapper) {
|
||||||
imageMatrixWrapper.style.transform = `translate3d(${currentX}px, ${currentY}px, 0px)`;
|
imageMatrixWrapper.style.transform = `translate3d(${currentX}px, ${currentY}px, 0px)`;
|
||||||
}
|
}
|
||||||
ticking = false;
|
ticking = false;
|
||||||
});
|
});
|
||||||
ticking = true;
|
ticking = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointerUp() {
|
function pointerUp() {
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop Mouse Interface Subscriptions
|
// Desktop Mouse Interface Subscriptions
|
||||||
function handleMouseDown(e) { pointerDown(e.clientX, e.clientY, e.target); }
|
function handleMouseDown(e) { pointerDown(e.clientX, e.clientY, e.target); }
|
||||||
function handleMouseMove(e) {
|
function handleMouseMove(e) {
|
||||||
if(isDragging) e.preventDefault();
|
if(isDragging) e.preventDefault();
|
||||||
pointerMove(e.clientX, e.clientY);
|
pointerMove(e.clientX, e.clientY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile Touch Interface Subscriptions
|
// Mobile Touch Interface Subscriptions
|
||||||
function handleTouchStart(e) {
|
function handleTouchStart(e) {
|
||||||
if (isZoomed) {
|
if (isZoomed) {
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
pointerDown(touch.clientX, touch.clientY, e.target);
|
pointerDown(touch.clientX, touch.clientY, e.target);
|
||||||
} else {
|
} else {
|
||||||
touchStartX = e.changedTouches[0].screenX;
|
touchStartX = e.changedTouches[0].screenX;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchMove(e) {
|
// Handled single-finger layout tracking securely
|
||||||
if (isZoomed && e.touches.length === 1) {
|
function handleTouchMove(e) {
|
||||||
e.preventDefault(); // Kill native momentum bouncing
|
if (isZoomed && e.touches.length === 1) {
|
||||||
const touch = e.touches[0];
|
e.preventDefault(); // Kill native momentum bouncing
|
||||||
pointerMove(touch.clientX, touch.clientY);
|
const touch = e.touches[0];
|
||||||
}
|
pointerMove(touch.clientX, touch.clientY);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleTouchEnd(e) {
|
function handleTouchEnd(e) {
|
||||||
if (isZoomed) {
|
if (isZoomed) {
|
||||||
pointerUp();
|
pointerUp();
|
||||||
} else {
|
} else {
|
||||||
touchEndX = e.changedTouches[0].screenX;
|
touchEndX = e.changedTouches[0].screenX;
|
||||||
const threshold = 50;
|
const threshold = 50;
|
||||||
if (touchStartX - touchEndX > threshold) nextPhoto();
|
if (touchStartX - touchEndX > threshold) nextPhoto();
|
||||||
else if (touchEndX - touchStartX > threshold) prevPhoto();
|
else if (touchEndX - touchStartX > threshold) prevPhoto();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeyDown} />
|
<svelte:window onkeydown={handleKeyDown} />
|
||||||
|
|
||||||
<div class="min-h-screen bg-zinc-950 text-zinc-100">
|
<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">
|
<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">{pageTitle}</h1>
|
||||||
</header>
|
|
||||||
|
{#if galleries.length > 1}
|
||||||
|
<select
|
||||||
|
value={currentGallery}
|
||||||
|
onchange={handleGallerySwitch}
|
||||||
|
class="bg-zinc-900 text-zinc-100 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm font-medium focus:border-blue-500 focus:outline-none cursor-pointer hover:bg-zinc-800 transition"
|
||||||
|
>
|
||||||
|
{#each galleries as gallery}
|
||||||
|
<option value={gallery}>{formatTitle(gallery)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
<main class="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
|
<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">
|
{#if !loading && photos.length === 0}
|
||||||
{#each photos as photo (photo.id)}
|
<div class="flex flex-col items-center justify-center py-24 text-center">
|
||||||
<button
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 text-zinc-600 mb-4 animate-pulse">
|
||||||
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"
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
onclick={() => changeActivePhoto(photo)}
|
</svg>
|
||||||
>
|
<h3 class="text-lg font-semibold text-zinc-300 mb-1">No images found</h3>
|
||||||
<img
|
<p class="text-sm text-zinc-500 max-w-sm">This gallery doesn't contain any photos yet, or storage is still syncing.</p>
|
||||||
src={photo.thumbUrl}
|
</div>
|
||||||
alt="Gallery item"
|
{:else}
|
||||||
loading="lazy"
|
<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">
|
||||||
class="h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-105"
|
{#each photos as photo (photo.id)}
|
||||||
/>
|
<button
|
||||||
<div class="absolute inset-0 bg-black/20 opacity-0 transition-opacity group-hover:opacity-100"></div>
|
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"
|
||||||
</button>
|
onclick={() => changeActivePhoto(photo)}
|
||||||
{/each}
|
>
|
||||||
</div>
|
<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>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div bind:this={observerTarget} class="w-full flex justify-center py-12">
|
<div bind:this={observerTarget} class="w-full flex justify-center py-12">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="h-6 w-6 animate-spin rounded-full border-2 border-zinc-500 border-t-transparent"></div>
|
<div class="h-6 w-6 animate-spin rounded-full border-2 border-zinc-500 border-t-transparent"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if activePhoto}
|
{#if activePhoto}
|
||||||
<div
|
<div
|
||||||
bind:this={lightboxContainer}
|
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"
|
class="fixed inset-0 z-50 flex flex-col items-center justify-between bg-black/95 p-4 select-none touch-none"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) activePhoto = null; }}
|
onclick={(e) => { if (e.target === e.currentTarget) activePhoto = null; }}
|
||||||
onkeydown={handleKeyDown}
|
onkeydown={handleKeyDown}
|
||||||
ontouchstart={handleTouchStart}
|
ontouchstart={handleTouchStart}
|
||||||
ontouchmove={handleTouchMove}
|
ontouchmove={handleTouchMove}
|
||||||
ontouchend={handleTouchEnd}
|
ontouchend={handleTouchEnd}
|
||||||
>
|
>
|
||||||
<div class="w-full flex justify-between items-center z-50 p-2 pointer-events-none">
|
<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">
|
<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}
|
{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}&gallery={currentGallery}"
|
||||||
|
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>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 pointer-events-auto">
|
<div
|
||||||
<button
|
class="relative w-full flex-1 flex items-center justify-center overflow-hidden my-4 selection:bg-transparent"
|
||||||
class="rounded-full bg-zinc-900/80 p-2.5 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
|
class:cursor-grab={isZoomed && !isDragging}
|
||||||
title={isZoomed ? "Reset Zoom" : "Zoom to Full Resolution"}
|
class:cursor-grabbing={isZoomed && isDragging}
|
||||||
onclick={() => { if(isZoomed) { resetZoom(); } else { isZoomed = true; } }}
|
onmousedown={handleMouseDown}
|
||||||
>
|
onmousemove={handleMouseMove}
|
||||||
{#if isZoomed}
|
onmouseup={pointerUp}
|
||||||
<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">
|
onmouseleave={pointerUp}
|
||||||
<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>
|
{#if !isZoomed}
|
||||||
{:else}
|
<button
|
||||||
<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">
|
type="button"
|
||||||
<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" />
|
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"
|
||||||
</svg>
|
onclick={prevPhoto}
|
||||||
{/if}
|
aria-label="Previous photo"
|
||||||
</button>
|
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
|
<button
|
||||||
class="rounded-full bg-zinc-900/80 p-2.5 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
|
type="button"
|
||||||
title="Toggle Fullscreen"
|
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={toggleFullscreen}
|
onclick={nextPhoto}
|
||||||
>
|
aria-label="Next photo"
|
||||||
<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">
|
disabled={activeIndex === photos.length - 1}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6h16.5" />
|
>
|
||||||
</svg>
|
{#if activeIndex < photos.length - 1}
|
||||||
</button>
|
<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}
|
||||||
|
|
||||||
<a
|
<div
|
||||||
href="/api/download?id={activePhoto.id}"
|
bind:this={imageMatrixWrapper}
|
||||||
class="rounded-full bg-zinc-900/80 p-2.5 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
|
class="max-w-full max-h-full flex items-center justify-center p-2 ease-out will-change-transform"
|
||||||
title="Download Original"
|
>
|
||||||
>
|
<img
|
||||||
<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">
|
src={activePhoto.fullUrl}
|
||||||
<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" />
|
alt="Gallery Content Visual"
|
||||||
</svg>
|
draggable="false"
|
||||||
</a>
|
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});"
|
||||||
<button
|
/>
|
||||||
class="rounded-full bg-zinc-900/80 p-2.5 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
|
</div>
|
||||||
title="Close"
|
</div>
|
||||||
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
|
<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">
|
||||||
class="relative w-full flex-1 flex items-center justify-center overflow-hidden my-4 selection:bg-transparent"
|
<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">
|
||||||
class:cursor-grab={isZoomed && !isDragging}
|
{#each photos as thumbPhoto, idx}
|
||||||
class:cursor-grabbing={isZoomed && isDragging}
|
<button
|
||||||
onmousedown={handleMouseDown}
|
type="button"
|
||||||
onmousemove={handleMouseMove}
|
class="relative flex-shrink-0 w-16 h-12 rounded-md overflow-hidden border transition-all duration-200 focus:outline-none
|
||||||
onmouseup={pointerUp}
|
{idx === activeIndex ? 'border-blue-500 scale-105 ring-2 ring-blue-500/20 opacity-100' : 'border-zinc-800/80 opacity-40 hover:opacity-80'}"
|
||||||
onmouseleave={pointerUp}
|
onclick={() => changeActivePhoto(thumbPhoto)}
|
||||||
>
|
>
|
||||||
{#if !isZoomed}
|
<img
|
||||||
<button
|
src={thumbPhoto.thumbUrl}
|
||||||
type="button"
|
alt="Ribbon Navigation Frame"
|
||||||
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"
|
class="w-full h-full object-cover"
|
||||||
onclick={prevPhoto}
|
/>
|
||||||
aria-label="Previous photo"
|
</button>
|
||||||
disabled={activeIndex === 0}
|
{/each}
|
||||||
>
|
</div>
|
||||||
{#if activeIndex > 0}
|
</div>
|
||||||
<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">
|
</div>
|
||||||
<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}
|
{/if}
|
||||||
@@ -5,7 +5,9 @@ import { env } from '$env/dynamic/private';
|
|||||||
|
|
||||||
export async function GET({ url }) {
|
export async function GET({ url }) {
|
||||||
const id = url.searchParams.get('id');
|
const id = url.searchParams.get('id');
|
||||||
if (!id) throw error(400, 'Missing photo identity ID');
|
const gallery = url.searchParams.get('gallery');
|
||||||
|
|
||||||
|
if (!id || !gallery) throw error(400, 'Missing identifier parameters.');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
@@ -17,23 +19,19 @@ export async function GET({ url }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const fullSizeKey = `images/${id}`;
|
const fullSizeKey = `${gallery}/images/${id}`;
|
||||||
|
|
||||||
// Force the browser to treat this as an attachment download
|
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: env.B2_BUCKET_NAME,
|
Bucket: env.B2_BUCKET_NAME,
|
||||||
Key: fullSizeKey,
|
Key: fullSizeKey,
|
||||||
ResponseContentDisposition: `attachment; filename="${id}"`
|
ResponseContentDisposition: `attachment; filename="${id}"`
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate a quick 60-second valid link
|
|
||||||
const downloadUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
|
const downloadUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
|
||||||
|
|
||||||
// Redirect browser directly to the download stream
|
|
||||||
throw redirect(307, downloadUrl);
|
throw redirect(307, downloadUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 307) throw err;
|
if (err.status === 307) throw err;
|
||||||
console.error('Server download redirection failed:', err);
|
console.error(err);
|
||||||
throw error(500, 'Could not generate download routing asset.');
|
throw error(500, 'Could not map download routing path.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { env } from '$env/dynamic/private';
|
|||||||
|
|
||||||
export async function GET({ url }) {
|
export async function GET({ url }) {
|
||||||
const nextToken = url.searchParams.get('next') || undefined;
|
const nextToken = url.searchParams.get('next') || undefined;
|
||||||
|
const activeGallery = url.searchParams.get('gallery') || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
@@ -18,10 +19,33 @@ export async function GET({ url }) {
|
|||||||
|
|
||||||
const tokenDuration = parseInt(env.B2_TOKEN_DURATION, 10) || 600;
|
const tokenDuration = parseInt(env.B2_TOKEN_DURATION, 10) || 600;
|
||||||
|
|
||||||
// Fetch a batch of files from the thumbs/ folder
|
// --- PHASE 1: DYNAMICALLY DISCOVER GALLERIES ---
|
||||||
|
// We use a delimiter to find the top-level virtual folders
|
||||||
|
const discoveryCommand = new ListObjectsV2Command({
|
||||||
|
Bucket: env.B2_BUCKET_NAME,
|
||||||
|
Delimiter: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
const discoveryResponse = await s3.send(discoveryCommand);
|
||||||
|
// CommonPrefixes contains the top-level folders (e.g., "vacation-2026/", "wedding/")
|
||||||
|
const prefixes = discoveryResponse.CommonPrefixes || [];
|
||||||
|
|
||||||
|
const galleries = prefixes
|
||||||
|
.map(p => p.Prefix.replace('/', ''))
|
||||||
|
.filter(name => name.length > 0)
|
||||||
|
.sort((a, b) => a.localeCompare(b)); // Sort alphabetically
|
||||||
|
|
||||||
|
// Fallback to a default if no gallery parameter is provided yet
|
||||||
|
const selectedGallery = activeGallery || galleries[0] || '';
|
||||||
|
|
||||||
|
if (!selectedGallery) {
|
||||||
|
return json({ photos: [], galleries: [], nextContinuationToken: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PHASE 2: FETCH PHOTOS FOR SELECTED GALLERY ---
|
||||||
const listCommand = new ListObjectsV2Command({
|
const listCommand = new ListObjectsV2Command({
|
||||||
Bucket: env.B2_BUCKET_NAME,
|
Bucket: env.B2_BUCKET_NAME,
|
||||||
Prefix: 'thumbs/',
|
Prefix: `${selectedGallery}/thumbs/`,
|
||||||
MaxKeys: 40,
|
MaxKeys: 40,
|
||||||
ContinuationToken: nextToken
|
ContinuationToken: nextToken
|
||||||
});
|
});
|
||||||
@@ -29,22 +53,20 @@ export async function GET({ url }) {
|
|||||||
const listResponse = await s3.send(listCommand);
|
const listResponse = await s3.send(listCommand);
|
||||||
const contents = listResponse.Contents || [];
|
const contents = listResponse.Contents || [];
|
||||||
|
|
||||||
// Strict Filter: Only allow actual .jpg or .jpeg extensions
|
|
||||||
const imageFiles = contents.filter(file => {
|
const imageFiles = contents.filter(file => {
|
||||||
const lowerKey = file.Key.toLowerCase();
|
const lowerKey = file.Key.toLowerCase();
|
||||||
return lowerKey.endsWith('.jpg') || lowerKey.endsWith('.jpeg');
|
return lowerKey.endsWith('.jpg') || lowerKey.endsWith('.jpeg');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate presigned URLs for validated items
|
|
||||||
const photos = await Promise.all(
|
const photos = await Promise.all(
|
||||||
imageFiles.map(async (file) => {
|
imageFiles.map(async (file) => {
|
||||||
const id = file.Key.replace('thumbs/', '');
|
// Clean up ID tracking relative to the dynamic folder path
|
||||||
const fullSizeKey = `images/${id}`;
|
const id = file.Key.replace(`${selectedGallery}/thumbs/`, '');
|
||||||
|
const fullSizeKey = `${selectedGallery}/images/${id}`;
|
||||||
|
|
||||||
const thumbCommand = new GetObjectCommand({ Bucket: env.B2_BUCKET_NAME, Key: file.Key });
|
const thumbCommand = new GetObjectCommand({ Bucket: env.B2_BUCKET_NAME, Key: file.Key });
|
||||||
const fullCommand = new GetObjectCommand({ Bucket: env.B2_BUCKET_NAME, Key: fullSizeKey });
|
const fullCommand = new GetObjectCommand({ Bucket: env.B2_BUCKET_NAME, Key: fullSizeKey });
|
||||||
|
|
||||||
// 10 minutes = 600 seconds
|
|
||||||
const [thumbUrl, fullUrl] = await Promise.all([
|
const [thumbUrl, fullUrl] = await Promise.all([
|
||||||
getSignedUrl(s3, thumbCommand, { expiresIn: tokenDuration }),
|
getSignedUrl(s3, thumbCommand, { expiresIn: tokenDuration }),
|
||||||
getSignedUrl(s3, fullCommand, { expiresIn: tokenDuration })
|
getSignedUrl(s3, fullCommand, { expiresIn: tokenDuration })
|
||||||
@@ -56,10 +78,12 @@ export async function GET({ url }) {
|
|||||||
|
|
||||||
return json({
|
return json({
|
||||||
photos,
|
photos,
|
||||||
|
galleries,
|
||||||
|
currentGallery: selectedGallery,
|
||||||
nextContinuationToken: listResponse.NextContinuationToken || null
|
nextContinuationToken: listResponse.NextContinuationToken || null
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
throw error(500, 'Failed to process images from storage.');
|
throw error(500, 'Failed to map multi-gallery structures.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user