all repos — weimar @ 9b2b569d75975b2ae6868e6708a18ee78a305125

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