all repos — weimar @ 87601e8f1973d0b01106ea99ce63228c88130e1a

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, addTags, removeTags, suggestTags, 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 isOwner = $state(false);
 20	let idleTimer: ReturnType<typeof setTimeout> | undefined = $state();
 21
 22	let tagInput = $state('');
 23	let suggestions = $state<string[]>([]);
 24	let showSuggestions = $state(false);
 25	let tagError = $state('');
 26	let tagBusy = $state(false);
 27
 28	let current = $derived(images[currentIndex]);
 29
 30	async function loadTagDetails() {
 31		try {
 32			const detail = await getImage(current.id);
 33			tagDetails = detail.tags_detail;
 34			isOwner = detail.is_owner;
 35		} catch {
 36			tagDetails = [];
 37			isOwner = false;
 38		}
 39	}
 40
 41	function loadImage() {
 42		loading = true;
 43		showControls = true;
 44		if (idleTimer) clearTimeout(idleTimer);
 45		loadTagDetails();
 46	}
 47
 48	function resetIdleTimer() {
 49		showControls = true;
 50		if (idleTimer) clearTimeout(idleTimer);
 51		idleTimer = setTimeout(() => {
 52			showControls = false;
 53		}, 3000);
 54	}
 55
 56	function handleKeydown(e: KeyboardEvent) {
 57		if (e.key === 'Escape') {
 58			onclose();
 59		} else if (e.key === 'ArrowLeft') {
 60			prev();
 61		} else if (e.key === 'ArrowRight') {
 62			next();
 63		}
 64	}
 65
 66	function prev() {
 67		if (currentIndex > 0) {
 68			currentIndex--;
 69			loadImage();
 70		}
 71	}
 72
 73	function next() {
 74		if (currentIndex < images.length - 1) {
 75			currentIndex++;
 76			loadImage();
 77		}
 78	}
 79
 80	function handleMediaReady() {
 81		loading = false;
 82		resetIdleTimer();
 83	}
 84
 85	function handleMediaError() {
 86		loading = false;
 87	}
 88
 89	function handleOverlayClick(e: MouseEvent) {
 90		if (e.target === e.currentTarget) {
 91			onclose();
 92		}
 93	}
 94
 95	function formatSize(bytes: number): string {
 96		if (bytes < 1024) return bytes + ' B';
 97		if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
 98		return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
 99	}
100
101	async function onTagInput() {
102		const q = tagInput.split(',').pop()?.trim() || '';
103		if (q.length < 1) {
104			suggestions = [];
105			showSuggestions = false;
106			return;
107		}
108		try {
109			const result = await suggestTags(q);
110			suggestions = result.map((t) => t.tag);
111			showSuggestions = suggestions.length > 0;
112		} catch {
113			suggestions = [];
114			showSuggestions = false;
115		}
116	}
117
118	function selectSuggestion(s: string) {
119		const parts = tagInput.split(',').slice(0, -1);
120		parts.push(s);
121		tagInput = parts.join(', ') + ', ';
122		showSuggestions = false;
123	}
124
125	async function handleAddTags() {
126		const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
127		if (tags.length === 0) return;
128		tagError = '';
129		tagBusy = true;
130		try {
131			const result = await addTags(current.id, tags);
132			tagDetails = result.tags_detail;
133			tagInput = '';
134		} catch (err) {
135			tagError = err instanceof Error ? err.message : 'Failed to add tags';
136		} finally {
137			tagBusy = false;
138		}
139	}
140
141	async function handleRemoveTags() {
142		const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
143		if (tags.length === 0) return;
144		tagError = '';
145		tagBusy = true;
146		try {
147			const result = await removeTags(current.id, tags);
148			tagDetails = result.tags_detail;
149			tagInput = '';
150		} catch (err) {
151			tagError = err instanceof Error ? err.message : 'Failed to remove tags';
152		} finally {
153			tagBusy = false;
154		}
155	}
156
157	$effect(() => {
158		current.id;
159		loadTagDetails();
160	});
161</script>
162
163<svelte:window onkeydown={handleKeydown} on:mousemove={resetIdleTimer} />
164
165<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
166<div class="viewer-overlay" onclick={handleOverlayClick} role="dialog" aria-label="Image viewer" tabindex="-1">
167	<button class="viewer-close" onclick={onclose} aria-label="Close">CLOSE</button>
168
169	{#if loading}
170		<div class="viewer-loader">
171			<div class="spinner"></div>
172		</div>
173	{/if}
174
175	{#if currentIndex > 0}
176		<button
177			class="viewer-nav viewer-prev"
178			onclick={prev}
179			aria-label="Previous image"
180			class:controls-hidden={!showControls}
181		>&lsaquo;</button>
182	{/if}
183
184	{#if currentIndex < images.length - 1}
185		<button
186			class="viewer-nav viewer-next"
187			onclick={next}
188			aria-label="Next image"
189			class:controls-hidden={!showControls}
190		>&rsaquo;</button>
191	{/if}
192
193	<div class="viewer-content" class:loading>
194		{#if current.mime_type.startsWith('video/')}
195			<!-- svelte-ignore a11y_media_has_caption -->
196			<video
197				src={imageDownloadUrl(current.id)}
198				controls
199				autoplay
200				class="viewer-media"
201				onloadeddata={handleMediaReady}
202				onerror={handleMediaError}
203			></video>
204		{:else}
205			<img
206				src={imageDownloadUrl(current.id)}
207				alt={current.filename}
208				class="viewer-media"
209				onload={handleMediaReady}
210				onerror={handleMediaError}
211			/>
212		{/if}
213	</div>
214
215	<div class="viewer-info" class:controls-hidden={!showControls}>
216		<div class="viewer-info-left">
217			<div class="viewer-uploader">
218				<Avatar src={current.avatar_url} username={current.uploader_username} size={80} />
219				<span class="viewer-uploader-name">{current.uploader_username}</span>
220			</div>
221			<div class="viewer-filename">{current.filename}</div>
222			<div class="viewer-meta">
223				#{current.id}
224				<span class="meta-dot">&middot;</span>
225				{current.width}&times;{current.height}
226				<span class="meta-dot">&middot;</span>
227				{formatSize(current.file_size)}
228				<span class="meta-dot">&middot;</span>
229				{current.mime_type}
230			</div>
231		</div>
232		<div class="viewer-info-right">
233			<a href={imageDownloadUrl(current.id)} download={current.filename} class="viewer-download">
234				DOWNLOAD
235			</a>
236			<span class="viewer-counter">{currentIndex + 1} / {images.length}</span>
237		</div>
238	</div>
239
240	{#if tagDetails.length > 0}
241		<div class="viewer-tags" class:controls-hidden={!showControls}>
242			{#each tagDetails as t (t.tag)}
243				<button
244					class="tag-pill"
245					title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}"
246					onclick={() => { onclose(); window.location.href = '/browse?tag=' + encodeURIComponent(t.tag); }}
247				>
248					<span class="tag-name">{t.tag}</span>
249					<span class="tag-count">{t.usage_count}</span>
250				</button>
251			{/each}
252		</div>
253	{/if}
254
255	{#if isOwner}
256		<div class="viewer-edit-tags" class:controls-hidden={!showControls}>
257			{#if tagError}
258				<span class="tag-err">{tagError}</span>
259			{/if}
260			<div class="tag-edit-row">
261				<div class="tag-edit-wrapper">
262					<input
263						type="text"
264						placeholder="funny, meme, cat"
265						bind:value={tagInput}
266						oninput={onTagInput}
267						onblur={() => setTimeout(() => { showSuggestions = false; }, 200)}
268						disabled={tagBusy}
269					/>
270					{#if showSuggestions}
271						<ul class="viewer-suggestions">
272							{#each suggestions as s}
273								<li role="button" tabindex="0" onmousedown={() => selectSuggestion(s)}>{s}</li>
274							{/each}
275						</ul>
276					{/if}
277				</div>
278				<button class="viewer-tag-btn add" onclick={handleAddTags} disabled={tagBusy}>ADD</button>
279				<button class="viewer-tag-btn remove" onclick={handleRemoveTags} disabled={tagBusy}>REMOVE</button>
280			</div>
281		</div>
282	{/if}
283
284	<div class="viewer-accent" class:controls-hidden={!showControls}></div>
285</div>
286
287<style>
288	.viewer-overlay {
289		position: fixed;
290		inset: 0;
291		z-index: 1000;
292		background: #0d0d0d;
293		display: flex;
294		align-items: center;
295		justify-content: center;
296		user-select: none;
297	}
298
299	.viewer-close {
300		position: absolute;
301		top: 0.75rem;
302		right: 0.75rem;
303		z-index: 10;
304		background: none;
305		border: 2px solid rgba(255,255,255,0.3);
306		color: #fff;
307		font-family: 'Oswald', sans-serif;
308		font-weight: 500;
309		font-size: 0.8rem;
310		letter-spacing: 0.12em;
311		padding: 0.4rem 1rem;
312		cursor: pointer;
313		transition: background 0.15s, border-color 0.15s;
314	}
315	.viewer-close:hover {
316		background: #c62828;
317		border-color: #c62828;
318	}
319
320	.viewer-loader {
321		position: absolute;
322		inset: 0;
323		display: flex;
324		align-items: center;
325		justify-content: center;
326		z-index: 2;
327	}
328	.spinner {
329		width: 40px;
330		height: 40px;
331		border: 3px solid rgba(255, 255, 255, 0.15);
332		border-top-color: #c62828;
333		border-radius: 50%;
334		animation: spin 0.7s linear infinite;
335	}
336	@keyframes spin {
337		to { transform: rotate(360deg); }
338	}
339
340	.viewer-nav {
341		position: absolute;
342		top: 50%;
343		transform: translateY(-50%);
344		z-index: 10;
345		background: rgba(0, 0, 0, 0.5);
346		border: none;
347		color: #fff;
348		font-size: 2.5rem;
349		width: 3rem;
350		height: 4rem;
351		cursor: pointer;
352		display: flex;
353		align-items: center;
354		justify-content: center;
355		transition: background 0.15s, opacity 0.3s;
356		line-height: 1;
357	}
358	.viewer-nav:hover {
359		background: #c62828;
360	}
361	.viewer-prev {
362		left: 0;
363	}
364	.viewer-next {
365		right: 0;
366	}
367
368	.viewer-content {
369		display: flex;
370		align-items: center;
371		justify-content: center;
372		max-width: 90vw;
373		max-height: 70vh;
374	}
375	.viewer-media {
376		max-width: 90vw;
377		max-height: 70vh;
378		object-fit: contain;
379		transition: opacity 0.3s;
380	}
381	.viewer-content.loading .viewer-media {
382		opacity: 0;
383	}
384
385	.viewer-info {
386		position: absolute;
387		bottom: 0;
388		left: 0;
389		right: 0;
390		background: linear-gradient(transparent, rgba(13, 13, 13, 0.95));
391		padding: 2.5rem 1.5rem 1rem;
392		display: flex;
393		justify-content: space-between;
394		align-items: flex-end;
395		gap: 1rem;
396		transition: opacity 0.3s;
397		z-index: 3;
398	}
399	.viewer-info-left {
400		display: flex;
401		flex-direction: column;
402		gap: 0.15rem;
403		min-width: 0;
404	}
405
406	.viewer-uploader {
407		display: flex;
408		align-items: center;
409		gap: 1.2rem;
410		margin-bottom: 0.8rem;
411	}
412	.viewer-uploader-name {
413		color: #fff;
414		font-family: 'Inter', sans-serif;
415		font-weight: 600;
416		font-size: 0.95rem;
417	}
418
419	.viewer-filename {
420		color: rgba(255, 255, 255, 0.65);
421		font-family: 'Inter', sans-serif;
422		font-weight: 500;
423		font-size: 0.82rem;
424		white-space: nowrap;
425		overflow: hidden;
426		text-overflow: ellipsis;
427	}
428	.viewer-meta {
429		color: rgba(255, 255, 255, 0.4);
430		font-size: 0.75rem;
431		font-weight: 500;
432		display: flex;
433		align-items: center;
434		gap: 0.35rem;
435		flex-wrap: wrap;
436	}
437	.meta-dot {
438		margin: 0 0.35rem;
439		color: rgba(255, 255, 255, 0.25);
440	}
441	.viewer-info-right {
442		display: flex;
443		align-items: center;
444		gap: 1rem;
445		flex-shrink: 0;
446	}
447	.viewer-download {
448		display: inline-block;
449		padding: 0.45rem 1rem;
450		background: #c62828;
451		color: #fff;
452		text-decoration: none;
453		font-family: 'Oswald', sans-serif;
454		font-weight: 500;
455		font-size: 0.8rem;
456		letter-spacing: 0.1em;
457		transition: background 0.15s;
458	}
459	.viewer-download:hover {
460		background: #b71c1c;
461		text-decoration: none;
462	}
463	.viewer-counter {
464		color: rgba(255, 255, 255, 0.35);
465		font-family: 'Oswald', sans-serif;
466		font-size: 0.8rem;
467		letter-spacing: 0.1em;
468		white-space: nowrap;
469	}
470
471	.viewer-accent {
472		position: absolute;
473		bottom: 0;
474		left: 0;
475		right: 0;
476		height: 3px;
477		background: #c62828;
478		z-index: 4;
479		transition: opacity 0.3s;
480	}
481
482	.controls-hidden {
483		opacity: 0;
484		pointer-events: none;
485	}
486
487	.viewer-tags {
488		position: absolute;
489		top: 4.5rem;
490		left: 50%;
491		transform: translateX(-50%);
492		display: flex;
493		flex-wrap: wrap;
494		justify-content: center;
495		gap: 0.4rem;
496		max-width: 80vw;
497		transition: opacity 0.3s;
498		z-index: 5;
499	}
500	.tag-pill {
501		display: inline-flex;
502		align-items: center;
503		gap: 0.4rem;
504		background: rgba(255, 255, 255, 0.08);
505		border: 1px solid rgba(255, 255, 255, 0.15);
506		border-radius: 0;
507		padding: 0.35rem 0.7rem 0.35rem 0.9rem;
508		font: inherit;
509		font-family: 'Inter', sans-serif;
510		font-size: 0.85rem;
511		font-weight: 500;
512		color: #fff;
513		white-space: nowrap;
514		cursor: pointer;
515		transition: background 0.15s, border-color 0.15s;
516	}
517	.tag-pill:hover {
518		background: #c62828;
519		border-color: #c62828;
520	}
521	.tag-name {
522		font-weight: 600;
523	}
524	.tag-count {
525		background: rgba(255, 255, 255, 0.15);
526		padding: 0.08rem 0.4rem;
527		font-size: 0.72rem;
528		font-weight: 600;
529		color: rgba(255, 255, 255, 0.7);
530		min-width: 1.2rem;
531		text-align: center;
532	}
533	.tag-pill:hover .tag-count {
534		background: rgba(0, 0, 0, 0.25);
535	}
536
537	.viewer-edit-tags {
538		position: absolute;
539		top: 9rem;
540		left: 50%;
541		transform: translateX(-50%);
542		display: flex;
543		flex-direction: column;
544		align-items: center;
545		gap: 0.3rem;
546		max-width: 420px;
547		width: 90vw;
548		transition: opacity 0.3s;
549		z-index: 5;
550	}
551	.tag-err {
552		color: #ef5350;
553		font-family: 'Inter', sans-serif;
554		font-size: 0.7rem;
555		font-weight: 600;
556	}
557	.tag-edit-row {
558		display: flex;
559		gap: 0.3rem;
560		width: 100%;
561	}
562	.tag-edit-wrapper {
563		flex: 1;
564		position: relative;
565		min-width: 0;
566	}
567	.tag-edit-wrapper input[type="text"] {
568		width: 100%;
569		padding: 0.35rem 0.5rem;
570		border: 1px solid rgba(255, 255, 255, 0.2);
571		border-radius: 0;
572		background: rgba(0, 0, 0, 0.5);
573		color: #fff;
574		font-family: 'Inter', sans-serif;
575		font-size: 0.75rem;
576		font-weight: 500;
577		box-sizing: border-box;
578	}
579	.tag-edit-wrapper input[type="text"]:focus {
580		outline: none;
581		border-color: #c62828;
582	}
583	.tag-edit-wrapper input[type="text"]::placeholder {
584		color: rgba(255, 255, 255, 0.3);
585	}
586	.viewer-suggestions {
587		position: absolute;
588		top: 100%;
589		left: 0;
590		right: 0;
591		background: #1a1a1a;
592		border: 1px solid rgba(255, 255, 255, 0.2);
593		border-top: none;
594		list-style: none;
595		margin: 0;
596		padding: 0;
597		z-index: 15;
598		max-height: 120px;
599		overflow-y: auto;
600	}
601	.viewer-suggestions li {
602		padding: 0.3rem 0.5rem;
603		cursor: pointer;
604		font-family: 'Inter', sans-serif;
605		font-size: 0.75rem;
606		font-weight: 500;
607		color: #fff;
608	}
609	.viewer-suggestions li:hover {
610		background: #c62828;
611	}
612	.viewer-tag-btn {
613		padding: 0.35rem 0.6rem;
614		border: none;
615		font-family: 'Oswald', sans-serif;
616		font-weight: 500;
617		font-size: 0.68rem;
618		letter-spacing: 0.08em;
619		cursor: pointer;
620		white-space: nowrap;
621		transition: background 0.15s;
622		color: #fff;
623	}
624	.viewer-tag-btn.add {
625		background: rgba(255, 255, 255, 0.15);
626	}
627	.viewer-tag-btn.add:hover {
628		background: #2e7d32;
629	}
630	.viewer-tag-btn.remove {
631		background: rgba(255, 255, 255, 0.15);
632	}
633	.viewer-tag-btn.remove:hover {
634		background: #c62828;
635	}
636	.viewer-tag-btn:disabled {
637		opacity: 0.4;
638		cursor: default;
639	}
640</style>