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