first commit

This commit is contained in:
Alex Rennie-Lis
2026-06-28 23:06:18 +01:00
commit 811bb9dc74
19 changed files with 1991 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
<script>
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}

225
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,225 @@
<script>
import { PUBLIC_GALLERY_NAME } from '$env/static/public';
import { onMount } from 'svelte';
// Svelte 5 Runes for Reactivity
let photos = $state([]);
let nextToken = $state(null);
let loading = $state(false);
let hasMore = $state(true);
let activePhoto = $state(null);
let observerTarget;
// Touch tracking coordinates for swipes
let touchStartX = 0;
let touchEndX = 0;
// Find the current active index
let activeIndex = $derived(
activePhoto ? photos.findIndex(p => p.id === activePhoto.id) : -1
);
async function loadPhotos() {
if (loading || !hasMore) return;
loading = true;
const url = new URL('/api/photos', window.location.origin);
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];
nextToken = data.nextContinuationToken;
hasMore = !!nextToken;
}
} catch (err) {
console.error('Failed to load photos:', err);
} finally {
loading = false;
}
}
onMount(() => {
loadPhotos();
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadPhotos();
}
}, { rootMargin: '300px' });
if (observerTarget) observer.observe(observerTarget);
return () => observer.disconnect();
});
// Navigation triggers
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
if (activeIndex >= photos.length - 4) loadPhotos();
}
}
function prevPhoto() {
if (activeIndex > 0) {
activePhoto = 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();
}
// Swipe Handlers for Mobile Devices
function handleTouchStart(e) {
touchStartX = e.changedTouches[0].screenX;
}
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);
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<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">{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>
<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">
{#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}
>
<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>
<div bind:this={observerTarget} class="w-full flex justify-center py-12">
{#if loading}
<div class="h-6 w-6 animate-spin rounded-full border-2 border-zinc-500 border-t-transparent"></div>
{/if}
</div>
</main>
</div>
{#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"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={(e) => { if (e.target === e.currentTarget) activePhoto = null; }}
onkeydown={handleKeyDown}
ontouchstart={handleTouchStart}
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}
>
{#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}
</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}
>
<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>
</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>
<p class="mt-4 text-xs text-zinc-500 font-mono tracking-wider">{activePhoto.id}</p>
</div>
{/if}

View File

@@ -0,0 +1,44 @@
// src/routes/api/download/+server.js
import { redirect, error } from '@sveltejs/kit';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import {
B2_ENDPOINT, B2_REGION, B2_ACCESS_KEY_ID, B2_SECRET_ACCESS_KEY, B2_BUCKET_NAME
} from '$env/static/private';
const s3 = new S3Client({
endpoint: B2_ENDPOINT,
region: B2_REGION,
credentials: {
accessKeyId: B2_ACCESS_KEY_ID,
secretAccessKey: B2_SECRET_ACCESS_KEY
}
});
export async function GET({ url }) {
const id = url.searchParams.get('id');
if (!id) throw error(400, 'Missing photo identity ID');
try {
const fullSizeKey = `images/${id}`;
// Create a command that forces the browser to treat this as an attachment download
const command = new GetObjectCommand({
Bucket: 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 the user directly to the freshly generated download link
throw redirect(307, downloadUrl);
} catch (err) {
// SvelteKit uses redirect exceptions, so let them pass through cleanly
if (err.status === 307) throw err;
console.error('Server download redirection failed:', err);
throw error(500, 'Could not generate download routing asset.');
}
}

View File

@@ -0,0 +1,66 @@
import { json, error } from '@sveltejs/kit';
import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import {
B2_ENDPOINT, B2_REGION, B2_ACCESS_KEY_ID, B2_SECRET_ACCESS_KEY, B2_BUCKET_NAME
} from '$env/static/private';
const s3 = new S3Client({
endpoint: B2_ENDPOINT,
region: B2_REGION,
credentials: {
accessKeyId: B2_ACCESS_KEY_ID,
secretAccessKey: B2_SECRET_ACCESS_KEY
}
});
export async function GET({ url }) {
const nextToken = url.searchParams.get('next') || undefined;
try {
// 1. Fetch a batch of files from the thumbs/ folder
const listCommand = new ListObjectsV2Command({
Bucket: B2_BUCKET_NAME,
Prefix: 'thumbs/',
MaxKeys: 40, // Increased slight headroom since we filter on the server side now
ContinuationToken: nextToken
});
const listResponse = await s3.send(listCommand);
const contents = listResponse.Contents || [];
// 2. 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');
});
// 3. 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}`;
const thumbCommand = new GetObjectCommand({ Bucket: B2_BUCKET_NAME, Key: file.Key });
const fullCommand = new GetObjectCommand({ Bucket: B2_BUCKET_NAME, Key: fullSizeKey });
// 10 minutes = 600 seconds
const [thumbUrl, fullUrl] = await Promise.all([
getSignedUrl(s3, thumbCommand, { expiresIn: 600 }),
getSignedUrl(s3, fullCommand, { expiresIn: 600 })
]);
return { id, thumbUrl, fullUrl };
})
);
return json({
photos,
// Pass the original continuation token back so pagination structural flow remains intact
nextContinuationToken: listResponse.NextContinuationToken || null
});
} catch (err) {
console.error(err);
throw error(500, 'Failed to process images from storage.');
}
}

2
src/routes/layout.css Normal file
View File

@@ -0,0 +1,2 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';

View File

@@ -0,0 +1,22 @@
// src/routes/login/+page.server.js
import { fail, redirect } from '@sveltejs/kit';
import { GALLERY_PASSWORD } from '$env/static/private';
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const password = data.get('password');
if (password === GALLERY_PASSWORD) {
cookies.set('gallery_session', 'authenticated', {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7 // 1 week
});
throw redirect(303, '/');
}
return fail(401, { incorrect: true });
}
};

View File

@@ -0,0 +1,33 @@
<script>
import { enhance } from '$app/forms';
import { PUBLIC_GALLERY_NAME } from '$env/static/public';
let form = $props();
</script>
<div class="flex min-h-screen items-center justify-center bg-zinc-950 px-4 text-zinc-100">
<div class="w-full max-w-md rounded-2xl border border-zinc-800 bg-zinc-900 p-8 shadow-xl">
<h1 class="mb-2 text-2xl font-bold tracking-tight">{PUBLIC_GALLERY_NAME}</h1>
<p class="mb-6 text-sm text-zinc-400">Please enter the password to access the photos.</p>
<form method="POST" use:enhance class="space-y-4">
<div>
<input
type="password"
name="password"
placeholder="Password"
required
class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-3 text-white placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition"
/>
</div>
{#if form?.incorrect}
<p class="text-sm text-red-400">Incorrect password. Please try again.</p>
{/if}
<button
type="submit"
class="w-full rounded-lg bg-blue-600 py-3 font-semibold text-white transition hover:bg-blue-500 active:bg-blue-700"
>
Unlock Gallery
</button>
</form>
</div>
</div>