all repos — weimar @ master

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