all repos — weimar @ ab494a4c3e2c99426dd9f18f02bcb3fb7ee678a3

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