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 >‹</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 >›</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">·</span>
255 {current.width}×{current.height}
256 <span class="meta-dot">·</span>
257 {formatSize(current.file_size)}
258 <span class="meta-dot">·</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>