Gallery support

This commit is contained in:
Alex Rennie-Lis
2026-06-29 11:19:11 +01:00
parent 714d5815a8
commit ae70184ad9
3 changed files with 426 additions and 345 deletions

View File

@@ -4,6 +4,9 @@
// Svelte 5 Runes for Core Reactivity
let photos = $state([]);
let galleries = $state([]);
let currentGallery = $state('');
let nextToken = $state(null);
let loading = $state(false);
let hasMore = $state(true);
@@ -32,19 +35,46 @@
activePhoto ? photos.findIndex(p => p.id === activePhoto.id) : -1
);
async function loadPhotos() {
if (loading || !hasMore) return;
// Dynamic Title Renderer
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;
if (reset) {
photos = [];
nextToken = null;
hasMore = true;
}
const url = new URL('/api/photos', window.location.origin);
url.searchParams.set('gallery', currentGallery);
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];
if (data) {
galleries = data.galleries || [];
if (!currentGallery) currentGallery = data.currentGallery || '';
photos = reset ? (data.photos || []) : [...photos, ...(data.photos || [])];
nextToken = data.nextContinuationToken;
hasMore = !!nextToken;
}
@@ -55,11 +85,17 @@
}
}
function handleGallerySwitch(e) {
currentGallery = e.target.value;
resetZoom();
loadPhotos(true);
}
onMount(() => {
loadPhotos();
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
if (entries[0].isIntersecting && hasMore && !loading) {
loadPhotos();
}
}, { rootMargin: '300px' });
@@ -172,6 +208,7 @@
}
}
// Handled single-finger layout tracking securely
function handleTouchMove(e) {
if (isZoomed && e.touches.length === 1) {
e.preventDefault(); // Kill native momentum bouncing
@@ -196,10 +233,31 @@
<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">{pageTitle}</h1>
{#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">
{#if !loading && photos.length === 0}
<div class="flex flex-col items-center justify-center py-24 text-center">
<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">
<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" />
</svg>
<h3 class="text-lg font-semibold text-zinc-300 mb-1">No images found</h3>
<p class="text-sm text-zinc-500 max-w-sm">This gallery doesn't contain any photos yet, or storage is still syncing.</p>
</div>
{:else}
<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
@@ -216,6 +274,7 @@
</button>
{/each}
</div>
{/if}
<div bind:this={observerTarget} class="w-full flex justify-center py-12">
{#if loading}
@@ -271,7 +330,7 @@
</button>
<a
href="/api/download?id={activePhoto.id}"
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"
>

View File

@@ -5,7 +5,9 @@ import { env } from '$env/dynamic/private';
export async function GET({ url }) {
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 {
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({
Bucket: env.B2_BUCKET_NAME,
Key: fullSizeKey,
ResponseContentDisposition: `attachment; filename="${id}"`
});
// Generate a quick 60-second valid link
const downloadUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
// Redirect browser directly to the download stream
throw redirect(307, downloadUrl);
} catch (err) {
if (err.status === 307) throw err;
console.error('Server download redirection failed:', err);
throw error(500, 'Could not generate download routing asset.');
console.error(err);
throw error(500, 'Could not map download routing path.');
}
}

View File

@@ -5,6 +5,7 @@ import { env } from '$env/dynamic/private';
export async function GET({ url }) {
const nextToken = url.searchParams.get('next') || undefined;
const activeGallery = url.searchParams.get('gallery') || '';
try {
const s3 = new S3Client({
@@ -18,10 +19,33 @@ export async function GET({ url }) {
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({
Bucket: env.B2_BUCKET_NAME,
Prefix: 'thumbs/',
Prefix: `${selectedGallery}/thumbs/`,
MaxKeys: 40,
ContinuationToken: nextToken
});
@@ -29,22 +53,20 @@ export async function GET({ url }) {
const listResponse = await s3.send(listCommand);
const contents = listResponse.Contents || [];
// Strict Filter: Only allow actual .jpg or .jpeg extensions
const imageFiles = contents.filter(file => {
const lowerKey = file.Key.toLowerCase();
return lowerKey.endsWith('.jpg') || lowerKey.endsWith('.jpeg');
});
// Generate presigned URLs for validated items
const photos = await Promise.all(
imageFiles.map(async (file) => {
const id = file.Key.replace('thumbs/', '');
const fullSizeKey = `images/${id}`;
// Clean up ID tracking relative to the dynamic folder path
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 fullCommand = new GetObjectCommand({ Bucket: env.B2_BUCKET_NAME, Key: fullSizeKey });
// 10 minutes = 600 seconds
const [thumbUrl, fullUrl] = await Promise.all([
getSignedUrl(s3, thumbCommand, { expiresIn: tokenDuration }),
getSignedUrl(s3, fullCommand, { expiresIn: tokenDuration })
@@ -56,10 +78,12 @@ export async function GET({ url }) {
return json({
photos,
galleries,
currentGallery: selectedGallery,
nextContinuationToken: listResponse.NextContinuationToken || null
});
} catch (err) {
console.error(err);
throw error(500, 'Failed to process images from storage.');
throw error(500, 'Failed to map multi-gallery structures.');
}
}