Gallery support
This commit is contained in:
@@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
// Svelte 5 Runes for Core Reactivity
|
// Svelte 5 Runes for Core Reactivity
|
||||||
let photos = $state([]);
|
let photos = $state([]);
|
||||||
|
let galleries = $state([]);
|
||||||
|
let currentGallery = $state('');
|
||||||
|
|
||||||
let nextToken = $state(null);
|
let nextToken = $state(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let hasMore = $state(true);
|
let hasMore = $state(true);
|
||||||
@@ -32,19 +35,46 @@
|
|||||||
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);
|
||||||
|
url.searchParams.set('gallery', currentGallery);
|
||||||
if (nextToken) url.searchParams.set('next', nextToken);
|
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 || [];
|
||||||
|
if (!currentGallery) currentGallery = data.currentGallery || '';
|
||||||
|
|
||||||
|
photos = reset ? (data.photos || []) : [...photos, ...(data.photos || [])];
|
||||||
nextToken = data.nextContinuationToken;
|
nextToken = data.nextContinuationToken;
|
||||||
hasMore = !!nextToken;
|
hasMore = !!nextToken;
|
||||||
}
|
}
|
||||||
@@ -55,11 +85,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleGallerySwitch(e) {
|
||||||
|
currentGallery = e.target.value;
|
||||||
|
resetZoom();
|
||||||
|
loadPhotos(true);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadPhotos();
|
loadPhotos();
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting && hasMore) {
|
if (entries[0].isIntersecting && hasMore && !loading) {
|
||||||
loadPhotos();
|
loadPhotos();
|
||||||
}
|
}
|
||||||
}, { rootMargin: '300px' });
|
}, { rootMargin: '300px' });
|
||||||
@@ -172,6 +208,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handled single-finger layout tracking securely
|
||||||
function handleTouchMove(e) {
|
function handleTouchMove(e) {
|
||||||
if (isZoomed && e.touches.length === 1) {
|
if (isZoomed && e.touches.length === 1) {
|
||||||
e.preventDefault(); // Kill native momentum bouncing
|
e.preventDefault(); // Kill native momentum bouncing
|
||||||
@@ -196,10 +233,31 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
{#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>
|
</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">
|
||||||
|
{#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">
|
<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)}
|
{#each photos as photo (photo.id)}
|
||||||
<button
|
<button
|
||||||
@@ -216,6 +274,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
||||||
@@ -271,7 +330,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a
|
<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"
|
class="rounded-full bg-zinc-900/80 p-2.5 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
|
||||||
title="Download Original"
|
title="Download Original"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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