all repos — weimar @ 8dcf6e79e30fa40a7f7da17169314b250793210c

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