all repos — weimar @ 74fc3e1e24b47e370bcf5ea6b4e016cb50630920

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