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 >‹</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 >›</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">·</span>
239 {current.width}×{current.height}
240 <span class="meta-dot">·</span>
241 {formatSize(current.file_size)}
242 <span class="meta-dot">·</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>