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