all repos — weimar @ c9643af6a7047483b00143b1304a7aff7ce61173

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

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

  1<script lang="ts">
  2	import { imageDownloadUrl, getImage, type Image, type TagDetail } from '$lib/api';
  3	import Avatar from '$lib/Avatar.svelte';
  4
  5	let {
  6		images,
  7		initialIndex = 0,
  8		onclose
  9	}: {
 10		images: Image[];
 11		initialIndex?: number;
 12		onclose: () => void;
 13	} = $props();
 14
 15	let currentIndex = $state(initialIndex);
 16	let loading = $state(true);
 17	let showControls = $state(true);
 18	let tagDetails = $state<TagDetail[]>([]);
 19	let idleTimer: ReturnType<typeof setTimeout> | undefined = $state();
 20
 21	let current = $derived(images[currentIndex]);
 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	$effect(() => {
 93		current.id;
 94		loadTagDetails();
 95	});
 96</script>
 97
 98<svelte:window onkeydown={handleKeydown} on:mousemove={resetIdleTimer} />
 99
100<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
101<div class="viewer-overlay" onclick={handleOverlayClick} role="dialog" aria-label="Image viewer" tabindex="-1">
102	<button class="viewer-close" onclick={onclose} aria-label="Close">CLOSE</button>
103
104	{#if loading}
105		<div class="viewer-loader">
106			<div class="spinner"></div>
107		</div>
108	{/if}
109
110	{#if currentIndex > 0}
111		<button
112			class="viewer-nav viewer-prev"
113			onclick={prev}
114			aria-label="Previous image"
115			class:controls-hidden={!showControls}
116		>&lsaquo;</button>
117	{/if}
118
119	{#if currentIndex < images.length - 1}
120		<button
121			class="viewer-nav viewer-next"
122			onclick={next}
123			aria-label="Next image"
124			class:controls-hidden={!showControls}
125		>&rsaquo;</button>
126	{/if}
127
128	<div class="viewer-content" class:loading>
129		{#if current.mime_type.startsWith('video/')}
130			<!-- svelte-ignore a11y_media_has_caption -->
131			<video
132				src={imageDownloadUrl(current.id)}
133				controls
134				autoplay
135				class="viewer-media"
136				onloadeddata={handleMediaReady}
137				onerror={handleMediaError}
138			></video>
139		{:else}
140			<img
141				src={imageDownloadUrl(current.id)}
142				alt={current.filename}
143				class="viewer-media"
144				onload={handleMediaReady}
145				onerror={handleMediaError}
146			/>
147		{/if}
148	</div>
149
150	<div class="viewer-info" class:controls-hidden={!showControls}>
151		<div class="viewer-info-left">
152			<div class="viewer-uploader">
153				<Avatar src={current.avatar_url} username={current.uploader_username} size={40} />
154				<span class="viewer-uploader-name">{current.uploader_username}</span>
155			</div>
156			<div class="viewer-filename">{current.filename}</div>
157			<div class="viewer-meta">
158				#{current.id}
159				<span class="meta-dot">&middot;</span>
160				{current.width}&times;{current.height}
161				<span class="meta-dot">&middot;</span>
162				{formatSize(current.file_size)}
163				<span class="meta-dot">&middot;</span>
164				{current.mime_type}
165			</div>
166		</div>
167		<div class="viewer-info-right">
168			<a href={imageDownloadUrl(current.id)} download={current.filename} class="viewer-download">
169				DOWNLOAD
170			</a>
171			<span class="viewer-counter">{currentIndex + 1} / {images.length}</span>
172		</div>
173	</div>
174
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
190	<div class="viewer-accent" class:controls-hidden={!showControls}></div>
191</div>
192
193<style>
194	.viewer-overlay {
195		position: fixed;
196		inset: 0;
197		z-index: 1000;
198		background: #0d0d0d;
199		display: flex;
200		align-items: center;
201		justify-content: center;
202		user-select: none;
203	}
204
205	.viewer-close {
206		position: absolute;
207		top: 0.75rem;
208		right: 0.75rem;
209		z-index: 10;
210		background: none;
211		border: 2px solid rgba(255,255,255,0.3);
212		color: #fff;
213		font-family: 'Oswald', sans-serif;
214		font-weight: 500;
215		font-size: 0.8rem;
216		letter-spacing: 0.12em;
217		padding: 0.4rem 1rem;
218		cursor: pointer;
219		transition: background 0.15s, border-color 0.15s;
220	}
221	.viewer-close:hover {
222		background: #c62828;
223		border-color: #c62828;
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.15);
238		border-top-color: #c62828;
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.5);
252		border: none;
253		color: #fff;
254		font-size: 2.5rem;
255		width: 3rem;
256		height: 4rem;
257		cursor: pointer;
258		display: flex;
259		align-items: center;
260		justify-content: center;
261		transition: background 0.15s, opacity 0.3s;
262		line-height: 1;
263	}
264	.viewer-nav:hover {
265		background: #c62828;
266	}
267	.viewer-prev {
268		left: 0;
269	}
270	.viewer-next {
271		right: 0;
272	}
273
274	.viewer-content {
275		display: flex;
276		align-items: center;
277		justify-content: center;
278		max-width: 90vw;
279		max-height: 70vh;
280	}
281	.viewer-media {
282		max-width: 90vw;
283		max-height: 70vh;
284		object-fit: contain;
285		transition: opacity 0.3s;
286	}
287	.viewer-content.loading .viewer-media {
288		opacity: 0;
289	}
290
291	.viewer-info {
292		position: absolute;
293		bottom: 0;
294		left: 0;
295		right: 0;
296		background: linear-gradient(transparent, rgba(13, 13, 13, 0.95));
297		padding: 2.5rem 1.5rem 1rem;
298		display: flex;
299		justify-content: space-between;
300		align-items: flex-end;
301		gap: 1rem;
302		transition: opacity 0.3s;
303		z-index: 3;
304	}
305	.viewer-info-left {
306		display: flex;
307		flex-direction: column;
308		gap: 0.15rem;
309		min-width: 0;
310	}
311
312	.viewer-uploader {
313		display: flex;
314		align-items: center;
315		gap: 1.2rem;
316		margin-bottom: 0.8rem;
317	}
318	.viewer-uploader-name {
319		color: #fff;
320		font-family: 'Inter', sans-serif;
321		font-weight: 600;
322		font-size: 0.95rem;
323	}
324
325	.viewer-filename {
326		color: rgba(255, 255, 255, 0.65);
327		font-family: 'Inter', sans-serif;
328		font-weight: 500;
329		font-size: 0.82rem;
330		white-space: nowrap;
331		overflow: hidden;
332		text-overflow: ellipsis;
333	}
334	.viewer-meta {
335		color: rgba(255, 255, 255, 0.4);
336		font-size: 0.75rem;
337		font-weight: 500;
338		display: flex;
339		align-items: center;
340		gap: 0.35rem;
341		flex-wrap: wrap;
342	}
343	.meta-dot {
344		margin: 0 0.35rem;
345		color: rgba(255, 255, 255, 0.25);
346	}
347	.viewer-info-right {
348		display: flex;
349		align-items: center;
350		gap: 1rem;
351		flex-shrink: 0;
352	}
353	.viewer-download {
354		display: inline-block;
355		padding: 0.45rem 1rem;
356		background: #c62828;
357		color: #fff;
358		text-decoration: none;
359		font-family: 'Oswald', sans-serif;
360		font-weight: 500;
361		font-size: 0.8rem;
362		letter-spacing: 0.1em;
363		transition: background 0.15s;
364	}
365	.viewer-download:hover {
366		background: #b71c1c;
367		text-decoration: none;
368	}
369	.viewer-counter {
370		color: rgba(255, 255, 255, 0.35);
371		font-family: 'Oswald', sans-serif;
372		font-size: 0.8rem;
373		letter-spacing: 0.1em;
374		white-space: nowrap;
375	}
376
377	.viewer-accent {
378		position: absolute;
379		bottom: 0;
380		left: 0;
381		right: 0;
382		height: 3px;
383		background: #c62828;
384		z-index: 4;
385		transition: opacity 0.3s;
386	}
387
388	.controls-hidden {
389		opacity: 0;
390		pointer-events: none;
391	}
392
393	.viewer-tags {
394		position: absolute;
395		top: 4.5rem;
396		left: 50%;
397		transform: translateX(-50%);
398		display: flex;
399		flex-wrap: wrap;
400		justify-content: center;
401		gap: 0.4rem;
402		max-width: 80vw;
403		transition: opacity 0.3s;
404		z-index: 5;
405	}
406	.tag-pill {
407		display: inline-flex;
408		align-items: center;
409		gap: 0.4rem;
410		background: rgba(255, 255, 255, 0.08);
411		border: 1px solid rgba(255, 255, 255, 0.15);
412		border-radius: 0;
413		padding: 0.35rem 0.7rem 0.35rem 0.9rem;
414		font: inherit;
415		font-family: 'Inter', sans-serif;
416		font-size: 0.85rem;
417		font-weight: 500;
418		color: #fff;
419		white-space: nowrap;
420		cursor: pointer;
421		transition: background 0.15s, border-color 0.15s;
422	}
423	.tag-pill:hover {
424		background: #c62828;
425		border-color: #c62828;
426	}
427	.tag-name {
428		font-weight: 600;
429	}
430	.tag-count {
431		background: rgba(255, 255, 255, 0.15);
432		padding: 0.08rem 0.4rem;
433		font-size: 0.72rem;
434		font-weight: 600;
435		color: rgba(255, 255, 255, 0.7);
436		min-width: 1.2rem;
437		text-align: center;
438	}
439	.tag-pill:hover .tag-count {
440		background: rgba(0, 0, 0, 0.25);
441	}
442</style>