Gallery support
This commit is contained in:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user