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 >‹</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 >›</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">·</span>
259 {current.width}×{current.height}
260 <span class="meta-dot">·</span>
261 {formatSize(current.file_size)}
262 <span class="meta-dot">·</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>