feat: add image overlay viewer with keyboard navigation and idle UI fade Replaces full-page navigation with a Chevereto-inspired lightbox overlay when clicking thumbnails in the browse grid. Supports prev/next via buttons and arrow keys, close via Esc/click-outside, auto-hiding controls after 3s of inactivity, and a bottom info bar with metadata and download. 💘 Generated with Crush Assisted-by: Crush:deepseek-reasoner
Maxwell Jensen maxwelljensen@posteo.net
Wed, 13 May 2026 15:34:07 +0200
2 files changed,
352 insertions(+),
3 deletions(-)
A
web/src/lib/ImageViewer.svelte
@@ -0,0 +1,330 @@
+<script lang="ts"> + import { thumbnailUrl, imageDownloadUrl, type Image } from '$lib/api'; + + let { + images, + initialIndex = 0, + onclose + }: { + images: Image[]; + initialIndex?: number; + onclose: () => void; + } = $props(); + + let currentIndex = $state(initialIndex); // eslint-disable-line svelte/state_referenced_locally + let loading = $state(true); + let showControls = $state(true); + let idleTimer: ReturnType<typeof setTimeout> | undefined = $state(); + + let current = $derived(images[currentIndex]); + + function loadImage() { + loading = true; + showControls = true; + if (idleTimer) clearTimeout(idleTimer); + } + + function resetIdleTimer() { + showControls = true; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + showControls = false; + }, 3000); + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + onclose(); + } else if (e.key === 'ArrowLeft') { + prev(); + } else if (e.key === 'ArrowRight') { + next(); + } + } + + function prev() { + if (currentIndex > 0) { + currentIndex--; + loadImage(); + } + } + + function next() { + if (currentIndex < images.length - 1) { + currentIndex++; + loadImage(); + } + } + + function handleImgLoad() { + loading = false; + resetIdleTimer(); + } + + function handleImgError() { + loading = false; + } + + function handleOverlayClick(e: MouseEvent) { + if (e.target === e.currentTarget) { + onclose(); + } + } + + function formatSize(bytes: number): string { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } +</script> + +<svelte:window onkeydown={handleKeydown} on:mousemove={resetIdleTimer} /> + +<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> +<div class="viewer-overlay" onclick={handleOverlayClick} role="dialog" aria-label="Image viewer" tabindex="-1"> + <!-- Close button --> + <button class="viewer-close" onclick={onclose} aria-label="Close">×</button> + + <!-- Loading indicator --> + {#if loading} + <div class="viewer-loader"> + <div class="spinner"></div> + </div> + {/if} + + <!-- Previous button --> + {#if currentIndex > 0} + <button + class="viewer-nav viewer-prev" + onclick={prev} + aria-label="Previous image" + class:controls-hidden={!showControls} + >‹</button> + {/if} + + <!-- Next button --> + {#if currentIndex < images.length - 1} + <button + class="viewer-nav viewer-next" + onclick={next} + aria-label="Next image" + class:controls-hidden={!showControls} + >›</button> + {/if} + + <!-- Image --> + <div class="viewer-content" class:loading> + {#if current.mime_type.startsWith('video/')} + <!-- svelte-ignore a11y_media_has_caption --> + <video + src={imageDownloadUrl(current.id)} + controls + autoplay + class="viewer-media" + onloadeddata={handleImgLoad} + onerror={handleImgError} + ></video> + {:else} + <img + src={imageDownloadUrl(current.id)} + alt={current.filename} + class="viewer-media" + onload={handleImgLoad} + onerror={handleImgError} + /> + {/if} + </div> + + <!-- Bottom info bar --> + <div class="viewer-info" class:controls-hidden={!showControls}> + <div class="viewer-info-left"> + <span class="viewer-filename">{current.filename}</span> + <span class="viewer-meta"> + {current.width}×{current.height} + · {formatSize(current.file_size)} + · {current.mime_type} + </span> + {#if current.tags.length > 0} + <span class="viewer-tags">{current.tags.join(', ')}</span> + {/if} + </div> + <div class="viewer-info-right"> + <a href={imageDownloadUrl(current.id)} download={current.filename} class="viewer-download"> + Download + </a> + <span class="viewer-counter">{currentIndex + 1} / {images.length}</span> + </div> + </div> +</div> + +<style> + .viewer-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.92); + display: flex; + align-items: center; + justify-content: center; + user-select: none; + } + + .viewer-close { + position: absolute; + top: 1rem; + right: 1rem; + z-index: 10; + background: rgba(0, 0, 0, 0.5); + border: none; + color: #fff; + font-size: 2rem; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + transition: opacity 0.3s; + } + .viewer-close:hover { + background: rgba(255, 255, 255, 0.2); + } + + .viewer-loader { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + } + .spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.2); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.7s linear infinite; + } + @keyframes spin { + to { transform: rotate(360deg); } + } + + .viewer-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 10; + background: rgba(0, 0, 0, 0.4); + border: none; + color: #fff; + font-size: 3rem; + width: 3.5rem; + height: 5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: opacity 0.3s; + line-height: 1; + } + .viewer-nav:hover { + background: rgba(255, 255, 255, 0.15); + } + .viewer-prev { + left: 1rem; + } + .viewer-next { + right: 1rem; + } + + .viewer-content { + display: flex; + align-items: center; + justify-content: center; + max-width: 90vw; + max-height: 85vh; + } + .viewer-media { + max-width: 90vw; + max-height: 85vh; + object-fit: contain; + border-radius: 4px; + transition: opacity 0.3s; + } + .viewer-content.loading .viewer-media { + opacity: 0; + } + + .viewer-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); + padding: 2rem 1.5rem 1.25rem; + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + transition: opacity 0.3s; + } + .viewer-info-left { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; + } + .viewer-filename { + color: #fff; + font-weight: 600; + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .viewer-meta { + color: rgba(255, 255, 255, 0.6); + font-size: 0.8rem; + } + .viewer-tags { + color: rgba(255, 255, 255, 0.5); + font-size: 0.8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .viewer-info-right { + display: flex; + align-items: center; + gap: 1rem; + flex-shrink: 0; + } + .viewer-download { + display: inline-block; + padding: 0.5rem 1rem; + background: #1a1a2e; + color: #eee; + text-decoration: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + transition: background 0.15s; + } + .viewer-download:hover { + background: #16213e; + } + .viewer-counter { + color: rgba(255, 255, 255, 0.5); + font-size: 0.85rem; + white-space: nowrap; + } + + .controls-hidden { + opacity: 0; + pointer-events: none; + } +</style>
M
web/src/routes/browse/+page.svelte
→
web/src/routes/browse/+page.svelte
@@ -1,6 +1,7 @@
<script lang="ts"> import { onMount } from 'svelte'; import { listImages, thumbnailUrl, type Image } from '$lib/api'; + import ImageViewer from '$lib/ImageViewer.svelte'; let images = $state<Image[]>([]); let total = $state(0);@@ -8,6 +9,7 @@ let loading = $state(true);
let error = $state(''); let filterTag = $state(''); let page = $state(1); + let viewerIndex = $state(-1); const perPage = 50; onMount(() => loadImages());@@ -40,6 +42,10 @@ function applyFilter() {
page = 1; loadImages(); } + + function openViewer(index: number) { + viewerIndex = index; + } </script> <h1>Browse</h1>@@ -69,8 +75,8 @@ <p>No images yet. <a href="/upload">Upload one!</a></p>
{/if} {:else} <div class="grid"> - {#each images as img (img.id)} - <a href="/image/{img.id}" class="card"> + {#each images as img, i (img.id)} + <button class="card" onclick={() => openViewer(i)}> {#if img.thumbnail_path} <img src={thumbnailUrl(img.id)} alt={img.filename} loading="lazy" /> {:else}@@ -80,7 +86,7 @@ <div class="info">
<span class="name">{img.filename}</span> <span class="tags">{img.tags.join(', ')}</span> </div> - </a> + </button> {/each} </div>@@ -91,6 +97,14 @@ <button onclick={nextPage} disabled={page * perPage >= total}>Next</button>
</div> {/if} +{#if viewerIndex >= 0} + <ImageViewer + images={images} + initialIndex={viewerIndex} + onclose={() => { viewerIndex = -1; }} + /> +{/if} + <style> .controls { display: flex;@@ -126,6 +140,11 @@ border-radius: 8px;
overflow: hidden; text-decoration: none; color: inherit; + background: none; + padding: 0; + font: inherit; + text-align: left; + cursor: pointer; transition: box-shadow 0.15s; } .card:hover {