all repos — weimar @ a338ad5884f38dd2d5e241df42fd565a97f27f72

Unnamed repository; edit this file 'description' to name the repository.

web/src/lib/ImageViewer.svelte (view raw)

  1<script lang="ts">
  2	import { thumbnailUrl, imageDownloadUrl, type Image } from '$lib/api';
  3
  4	let {
  5		images,
  6		initialIndex = 0,
  7		onclose
  8	}: {
  9		images: Image[];
 10		initialIndex?: number;
 11		onclose: () => void;
 12	} = $props();
 13
 14	let currentIndex = $state(initialIndex); // eslint-disable-line svelte/state_referenced_locally
 15	let loading = $state(true);
 16	let showControls = $state(true);
 17	let idleTimer: ReturnType<typeof setTimeout> | undefined = $state();
 18
 19	let current = $derived(images[currentIndex]);
 20
 21	function loadImage() {
 22		loading = true;
 23		showControls = true;
 24		if (idleTimer) clearTimeout(idleTimer);
 25	}
 26
 27	function resetIdleTimer() {
 28		showControls = true;
 29		if (idleTimer) clearTimeout(idleTimer);
 30		idleTimer = setTimeout(() => {
 31			showControls = false;
 32		}, 3000);
 33	}
 34
 35	function handleKeydown(e: KeyboardEvent) {
 36		if (e.key === 'Escape') {
 37			onclose();
 38		} else if (e.key === 'ArrowLeft') {
 39			prev();
 40		} else if (e.key === 'ArrowRight') {
 41			next();
 42		}
 43	}
 44
 45	function prev() {
 46		if (currentIndex > 0) {
 47			currentIndex--;
 48			loadImage();
 49		}
 50	}
 51
 52	function next() {
 53		if (currentIndex < images.length - 1) {
 54			currentIndex++;
 55			loadImage();
 56		}
 57	}
 58
 59	function handleImgLoad() {
 60		loading = false;
 61		resetIdleTimer();
 62	}
 63
 64	function handleImgError() {
 65		loading = false;
 66	}
 67
 68	function handleOverlayClick(e: MouseEvent) {
 69		if (e.target === e.currentTarget) {
 70			onclose();
 71		}
 72	}
 73
 74	function formatSize(bytes: number): string {
 75		if (bytes < 1024) return bytes + ' B';
 76		if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
 77		return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
 78	}
 79</script>
 80
 81<svelte:window onkeydown={handleKeydown} on:mousemove={resetIdleTimer} />
 82
 83<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
 84<div class="viewer-overlay" onclick={handleOverlayClick} role="dialog" aria-label="Image viewer" tabindex="-1">
 85	<!-- Close button -->
 86	<button class="viewer-close" onclick={onclose} aria-label="Close">&times;</button>
 87
 88	<!-- Loading indicator -->
 89	{#if loading}
 90		<div class="viewer-loader">
 91			<div class="spinner"></div>
 92		</div>
 93	{/if}
 94
 95	<!-- Previous button -->
 96	{#if currentIndex > 0}
 97		<button
 98			class="viewer-nav viewer-prev"
 99			onclick={prev}
100			aria-label="Previous image"
101			class:controls-hidden={!showControls}
102		>&lsaquo;</button>
103	{/if}
104
105	<!-- Next button -->
106	{#if currentIndex < images.length - 1}
107		<button
108			class="viewer-nav viewer-next"
109			onclick={next}
110			aria-label="Next image"
111			class:controls-hidden={!showControls}
112		>&rsaquo;</button>
113	{/if}
114
115	<!-- Image -->
116	<div class="viewer-content" class:loading>
117		{#if current.mime_type.startsWith('video/')}
118			<!-- svelte-ignore a11y_media_has_caption -->
119			<video
120				src={imageDownloadUrl(current.id)}
121				controls
122				autoplay
123				class="viewer-media"
124				onloadeddata={handleImgLoad}
125				onerror={handleImgError}
126			></video>
127		{:else}
128			<img
129				src={imageDownloadUrl(current.id)}
130				alt={current.filename}
131				class="viewer-media"
132				onload={handleImgLoad}
133				onerror={handleImgError}
134			/>
135		{/if}
136	</div>
137
138	<!-- Bottom info bar -->
139	<div class="viewer-info" class:controls-hidden={!showControls}>
140		<div class="viewer-info-left">
141			<span class="viewer-filename">{current.filename}</span>
142			<span class="viewer-meta">
143				{current.width}&times;{current.height}
144				&middot; {formatSize(current.file_size)}
145				&middot; {current.mime_type}
146			</span>
147			{#if current.tags.length > 0}
148				<span class="viewer-tags">{current.tags.join(', ')}</span>
149			{/if}
150		</div>
151		<div class="viewer-info-right">
152			<a href={imageDownloadUrl(current.id)} download={current.filename} class="viewer-download">
153				Download
154			</a>
155			<span class="viewer-counter">{currentIndex + 1} / {images.length}</span>
156		</div>
157	</div>
158</div>
159
160<style>
161	.viewer-overlay {
162		position: fixed;
163		inset: 0;
164		z-index: 1000;
165		background: rgba(0, 0, 0, 0.92);
166		display: flex;
167		align-items: center;
168		justify-content: center;
169		user-select: none;
170	}
171
172	.viewer-close {
173		position: absolute;
174		top: 1rem;
175		right: 1rem;
176		z-index: 10;
177		background: rgba(0, 0, 0, 0.5);
178		border: none;
179		color: #fff;
180		font-size: 2rem;
181		width: 2.5rem;
182		height: 2.5rem;
183		border-radius: 50%;
184		cursor: pointer;
185		display: flex;
186		align-items: center;
187		justify-content: center;
188		line-height: 1;
189		transition: opacity 0.3s;
190	}
191	.viewer-close:hover {
192		background: rgba(255, 255, 255, 0.2);
193	}
194
195	.viewer-loader {
196		position: absolute;
197		inset: 0;
198		display: flex;
199		align-items: center;
200		justify-content: center;
201		z-index: 2;
202	}
203	.spinner {
204		width: 40px;
205		height: 40px;
206		border: 3px solid rgba(255, 255, 255, 0.2);
207		border-top-color: #fff;
208		border-radius: 50%;
209		animation: spin 0.7s linear infinite;
210	}
211	@keyframes spin {
212		to { transform: rotate(360deg); }
213	}
214
215	.viewer-nav {
216		position: absolute;
217		top: 50%;
218		transform: translateY(-50%);
219		z-index: 10;
220		background: rgba(0, 0, 0, 0.4);
221		border: none;
222		color: #fff;
223		font-size: 3rem;
224		width: 3.5rem;
225		height: 5rem;
226		cursor: pointer;
227		display: flex;
228		align-items: center;
229		justify-content: center;
230		border-radius: 6px;
231		transition: opacity 0.3s;
232		line-height: 1;
233	}
234	.viewer-nav:hover {
235		background: rgba(255, 255, 255, 0.15);
236	}
237	.viewer-prev {
238		left: 1rem;
239	}
240	.viewer-next {
241		right: 1rem;
242	}
243
244	.viewer-content {
245		display: flex;
246		align-items: center;
247		justify-content: center;
248		max-width: 90vw;
249		max-height: 85vh;
250	}
251	.viewer-media {
252		max-width: 90vw;
253		max-height: 85vh;
254		object-fit: contain;
255		border-radius: 4px;
256		transition: opacity 0.3s;
257	}
258	.viewer-content.loading .viewer-media {
259		opacity: 0;
260	}
261
262	.viewer-info {
263		position: absolute;
264		bottom: 0;
265		left: 0;
266		right: 0;
267		background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
268		padding: 2rem 1.5rem 1.25rem;
269		display: flex;
270		justify-content: space-between;
271		align-items: flex-end;
272		gap: 1rem;
273		transition: opacity 0.3s;
274	}
275	.viewer-info-left {
276		display: flex;
277		flex-direction: column;
278		gap: 0.2rem;
279		min-width: 0;
280	}
281	.viewer-filename {
282		color: #fff;
283		font-weight: 600;
284		font-size: 1rem;
285		white-space: nowrap;
286		overflow: hidden;
287		text-overflow: ellipsis;
288	}
289	.viewer-meta {
290		color: rgba(255, 255, 255, 0.6);
291		font-size: 0.8rem;
292	}
293	.viewer-tags {
294		color: rgba(255, 255, 255, 0.5);
295		font-size: 0.8rem;
296		white-space: nowrap;
297		overflow: hidden;
298		text-overflow: ellipsis;
299	}
300	.viewer-info-right {
301		display: flex;
302		align-items: center;
303		gap: 1rem;
304		flex-shrink: 0;
305	}
306	.viewer-download {
307		display: inline-block;
308		padding: 0.5rem 1rem;
309		background: #1a1a2e;
310		color: #eee;
311		text-decoration: none;
312		border-radius: 6px;
313		font-size: 0.85rem;
314		font-weight: 500;
315		transition: background 0.15s;
316	}
317	.viewer-download:hover {
318		background: #16213e;
319	}
320	.viewer-counter {
321		color: rgba(255, 255, 255, 0.5);
322		font-size: 0.85rem;
323		white-space: nowrap;
324	}
325
326	.controls-hidden {
327		opacity: 0;
328		pointer-events: none;
329	}
330</style>