all repos — weimar @ 3ab9da929b53350b9e5a860642317e5a8f5b3750

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, getImage, type Image, type TagDetail } 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);
 15	let loading = $state(true);
 16	let showControls = $state(true);
 17	let tagDetails = $state<TagDetail[]>([]);
 18	let idleTimer: ReturnType<typeof setTimeout> | undefined = $state();
 19
 20	let current = $derived(images[currentIndex]);
 21	let totalPillCount = $derived(tagDetails.reduce((sum, t) => sum + t.usage_count, 0));
 22
 23	async function loadTagDetails() {
 24		try {
 25			const detail = await getImage(current.id);
 26			tagDetails = detail.tags_detail;
 27		} catch {
 28			tagDetails = [];
 29		}
 30	}
 31
 32	function loadImage() {
 33		loading = true;
 34		showControls = true;
 35		if (idleTimer) clearTimeout(idleTimer);
 36		loadTagDetails();
 37	}
 38
 39	function resetIdleTimer() {
 40		showControls = true;
 41		if (idleTimer) clearTimeout(idleTimer);
 42		idleTimer = setTimeout(() => {
 43			showControls = false;
 44		}, 3000);
 45	}
 46
 47	function handleKeydown(e: KeyboardEvent) {
 48		if (e.key === 'Escape') {
 49			onclose();
 50		} else if (e.key === 'ArrowLeft') {
 51			prev();
 52		} else if (e.key === 'ArrowRight') {
 53			next();
 54		}
 55	}
 56
 57	function prev() {
 58		if (currentIndex > 0) {
 59			currentIndex--;
 60			loadImage();
 61		}
 62	}
 63
 64	function next() {
 65		if (currentIndex < images.length - 1) {
 66			currentIndex++;
 67			loadImage();
 68		}
 69	}
 70
 71	function handleMediaReady() {
 72		loading = false;
 73		resetIdleTimer();
 74	}
 75
 76	function handleMediaError() {
 77		loading = false;
 78	}
 79
 80	function handleOverlayClick(e: MouseEvent) {
 81		if (e.target === e.currentTarget) {
 82			onclose();
 83		}
 84	}
 85
 86	function formatSize(bytes: number): string {
 87		if (bytes < 1024) return bytes + ' B';
 88		if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
 89		return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
 90	}
 91
 92	// Load initial tag details.
 93	$effect(() => {
 94		current.id;
 95		loadTagDetails();
 96	});
 97</script>
 98
 99<svelte:window onkeydown={handleKeydown} on:mousemove={resetIdleTimer} />
100
101<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
102<div class="viewer-overlay" onclick={handleOverlayClick} role="dialog" aria-label="Image viewer" tabindex="-1">
103	<!-- Close button -->
104	<button class="viewer-close" onclick={onclose} aria-label="Close">&times;</button>
105
106	<!-- Loading indicator -->
107	{#if loading}
108		<div class="viewer-loader">
109			<div class="spinner"></div>
110		</div>
111	{/if}
112
113	<!-- Previous button -->
114	{#if currentIndex > 0}
115		<button
116			class="viewer-nav viewer-prev"
117			onclick={prev}
118			aria-label="Previous image"
119			class:controls-hidden={!showControls}
120		>&lsaquo;</button>
121	{/if}
122
123	<!-- Next button -->
124	{#if currentIndex < images.length - 1}
125		<button
126			class="viewer-nav viewer-next"
127			onclick={next}
128			aria-label="Next image"
129			class:controls-hidden={!showControls}
130		>&rsaquo;</button>
131	{/if}
132
133	<!-- Media -->
134	<div class="viewer-content" class:loading>
135		{#if current.mime_type.startsWith('video/')}
136			<!-- svelte-ignore a11y_media_has_caption -->
137			<video
138				src={imageDownloadUrl(current.id)}
139				controls
140				autoplay
141				class="viewer-media"
142				onloadeddata={handleMediaReady}
143				onerror={handleMediaError}
144			></video>
145		{:else}
146			<img
147				src={imageDownloadUrl(current.id)}
148				alt={current.filename}
149				class="viewer-media"
150				onload={handleMediaReady}
151				onerror={handleMediaError}
152			/>
153		{/if}
154	</div>
155
156	<!-- Bottom info bar -->
157	<div class="viewer-info" class:controls-hidden={!showControls}>
158		<div class="viewer-info-left">
159			<span class="viewer-filename">{current.filename}</span>
160			<span class="viewer-meta">
161				{current.width}&times;{current.height}
162				&middot; {formatSize(current.file_size)}
163				&middot; {current.mime_type}
164			</span>
165		</div>
166		<div class="viewer-info-right">
167			<a href={imageDownloadUrl(current.id)} download={current.filename} class="viewer-download">
168				Download
169			</a>
170			<span class="viewer-counter">{currentIndex + 1} / {images.length}</span>
171		</div>
172	</div>
173
174	<!-- Tags bar -->
175	{#if tagDetails.length > 0}
176		<div class="viewer-tags" class:controls-hidden={!showControls}>
177			{#each tagDetails as t (t.tag)}
178				<button
179					class="tag-pill"
180					title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}"
181					onclick={() => { onclose(); window.location.href = '/browse?tag=' + encodeURIComponent(t.tag); }}
182				>
183					<span class="tag-name">{t.tag}</span>
184					<span class="tag-count">{t.usage_count}</span>
185				</button>
186			{/each}
187		</div>
188	{/if}
189</div>
190
191<style>
192	.viewer-overlay {
193		position: fixed;
194		inset: 0;
195		z-index: 1000;
196		background: rgba(0, 0, 0, 0.92);
197		display: flex;
198		align-items: center;
199		justify-content: center;
200		user-select: none;
201	}
202
203	.viewer-close {
204		position: absolute;
205		top: 1rem;
206		right: 1rem;
207		z-index: 10;
208		background: rgba(0, 0, 0, 0.5);
209		border: none;
210		color: #fff;
211		font-size: 2rem;
212		width: 2.5rem;
213		height: 2.5rem;
214		border-radius: 50%;
215		cursor: pointer;
216		display: flex;
217		align-items: center;
218		justify-content: center;
219		line-height: 1;
220		transition: opacity 0.3s;
221	}
222	.viewer-close:hover {
223		background: rgba(255, 255, 255, 0.2);
224	}
225
226	.viewer-loader {
227		position: absolute;
228		inset: 0;
229		display: flex;
230		align-items: center;
231		justify-content: center;
232		z-index: 2;
233	}
234	.spinner {
235		width: 40px;
236		height: 40px;
237		border: 3px solid rgba(255, 255, 255, 0.2);
238		border-top-color: #fff;
239		border-radius: 50%;
240		animation: spin 0.7s linear infinite;
241	}
242	@keyframes spin {
243		to { transform: rotate(360deg); }
244	}
245
246	.viewer-nav {
247		position: absolute;
248		top: 50%;
249		transform: translateY(-50%);
250		z-index: 10;
251		background: rgba(0, 0, 0, 0.4);
252		border: none;
253		color: #fff;
254		font-size: 3rem;
255		width: 3.5rem;
256		height: 5rem;
257		cursor: pointer;
258		display: flex;
259		align-items: center;
260		justify-content: center;
261		border-radius: 6px;
262		transition: opacity 0.3s;
263		line-height: 1;
264	}
265	.viewer-nav:hover {
266		background: rgba(255, 255, 255, 0.15);
267	}
268	.viewer-prev {
269		left: 1rem;
270	}
271	.viewer-next {
272		right: 1rem;
273	}
274
275	.viewer-content {
276		display: flex;
277		align-items: center;
278		justify-content: center;
279		max-width: 90vw;
280		max-height: 70vh;
281	}
282	.viewer-media {
283		max-width: 90vw;
284		max-height: 70vh;
285		object-fit: contain;
286		border-radius: 4px;
287		transition: opacity 0.3s;
288	}
289	.viewer-content.loading .viewer-media {
290		opacity: 0;
291	}
292
293	.viewer-info {
294		position: absolute;
295		bottom: 0;
296		left: 0;
297		right: 0;
298		background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
299		padding: 2.5rem 1.5rem 1rem;
300		display: flex;
301		justify-content: space-between;
302		align-items: flex-end;
303		gap: 1rem;
304		transition: opacity 0.3s;
305	}
306	.viewer-info-left {
307		display: flex;
308		flex-direction: column;
309		gap: 0.2rem;
310		min-width: 0;
311	}
312	.viewer-filename {
313		color: #fff;
314		font-weight: 600;
315		font-size: 1rem;
316		white-space: nowrap;
317		overflow: hidden;
318		text-overflow: ellipsis;
319	}
320	.viewer-meta {
321		color: rgba(255, 255, 255, 0.6);
322		font-size: 0.8rem;
323	}
324	.viewer-info-right {
325		display: flex;
326		align-items: center;
327		gap: 1rem;
328		flex-shrink: 0;
329	}
330	.viewer-download {
331		display: inline-block;
332		padding: 0.5rem 1rem;
333		background: #1a1a2e;
334		color: #eee;
335		text-decoration: none;
336		border-radius: 6px;
337		font-size: 0.85rem;
338		font-weight: 500;
339		transition: background 0.15s;
340	}
341	.viewer-download:hover {
342		background: #16213e;
343	}
344	.viewer-counter {
345		color: rgba(255, 255, 255, 0.5);
346		font-size: 0.85rem;
347		white-space: nowrap;
348	}
349
350	.controls-hidden {
351		opacity: 0;
352		pointer-events: none;
353	}
354
355	/* Tags bar */
356	.viewer-tags {
357		position: absolute;
358		top: 4.5rem;
359		left: 50%;
360		transform: translateX(-50%);
361		display: flex;
362		flex-wrap: wrap;
363		justify-content: center;
364		gap: 0.4rem;
365		max-width: 80vw;
366		transition: opacity 0.3s;
367		z-index: 5;
368	}
369	.tag-pill {
370		display: inline-flex;
371		align-items: center;
372		gap: 0.4rem;
373		background: rgba(255, 255, 255, 0.12);
374		border: 1px solid rgba(255, 255, 255, 0.25);
375		border-radius: 24px;
376		padding: 0.35rem 0.75rem 0.35rem 0.9rem;
377		font: inherit;
378		font-size: 0.9rem;
379		color: #fff;
380		white-space: nowrap;
381		backdrop-filter: blur(4px);
382		cursor: pointer;
383		transition: background 0.15s;
384	}
385	.tag-pill:hover {
386		background: rgba(255, 255, 255, 0.22);
387	}
388	.tag-name {
389		font-weight: 500;
390	}
391	.tag-count {
392		background: rgba(255, 255, 255, 0.15);
393		border-radius: 12px;
394		padding: 0.1rem 0.45rem;
395		font-size: 0.78rem;
396		font-weight: 600;
397		color: rgba(255, 255, 255, 0.8);
398		min-width: 1.3rem;
399		text-align: center;
400	}
401</style>