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(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 <span class="viewer-uploader-name">{current.uploader_username}</span>
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 <ul class="viewer-suggestions">
272 {#each suggestions as s}
273 <li role="button" tabindex="0" onmousedown={() => selectSuggestion(s)}>{s}</li>
274 {/each}
275 </ul>
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 }
418
419 .viewer-filename {
420 color: rgba(255, 255, 255, 0.65);
421 font-family: 'Inter', sans-serif;
422 font-weight: 500;
423 font-size: 0.82rem;
424 white-space: nowrap;
425 overflow: hidden;
426 text-overflow: ellipsis;
427 }
428 .viewer-meta {
429 color: rgba(255, 255, 255, 0.4);
430 font-size: 0.75rem;
431 font-weight: 500;
432 display: flex;
433 align-items: center;
434 gap: 0.35rem;
435 flex-wrap: wrap;
436 }
437 .meta-dot {
438 margin: 0 0.35rem;
439 color: rgba(255, 255, 255, 0.25);
440 }
441 .viewer-info-right {
442 display: flex;
443 align-items: center;
444 gap: 1rem;
445 flex-shrink: 0;
446 }
447 .viewer-download {
448 display: inline-block;
449 padding: 0.45rem 1rem;
450 background: #c62828;
451 color: #fff;
452 text-decoration: none;
453 font-family: 'Oswald', sans-serif;
454 font-weight: 500;
455 font-size: 0.8rem;
456 letter-spacing: 0.1em;
457 transition: background 0.15s;
458 }
459 .viewer-download:hover {
460 background: #b71c1c;
461 text-decoration: none;
462 }
463 .viewer-counter {
464 color: rgba(255, 255, 255, 0.35);
465 font-family: 'Oswald', sans-serif;
466 font-size: 0.8rem;
467 letter-spacing: 0.1em;
468 white-space: nowrap;
469 }
470
471 .viewer-accent {
472 position: absolute;
473 bottom: 0;
474 left: 0;
475 right: 0;
476 height: 3px;
477 background: #c62828;
478 z-index: 4;
479 transition: opacity 0.3s;
480 }
481
482 .controls-hidden {
483 opacity: 0;
484 pointer-events: none;
485 }
486
487 .viewer-tags {
488 position: absolute;
489 top: 4.5rem;
490 left: 50%;
491 transform: translateX(-50%);
492 display: flex;
493 flex-wrap: wrap;
494 justify-content: center;
495 gap: 0.4rem;
496 max-width: 80vw;
497 transition: opacity 0.3s;
498 z-index: 5;
499 }
500 .tag-pill {
501 display: inline-flex;
502 align-items: center;
503 gap: 0.4rem;
504 background: rgba(255, 255, 255, 0.08);
505 border: 1px solid rgba(255, 255, 255, 0.15);
506 border-radius: 0;
507 padding: 0.35rem 0.7rem 0.35rem 0.9rem;
508 font: inherit;
509 font-family: 'Inter', sans-serif;
510 font-size: 0.85rem;
511 font-weight: 500;
512 color: #fff;
513 white-space: nowrap;
514 cursor: pointer;
515 transition: background 0.15s, border-color 0.15s;
516 }
517 .tag-pill:hover {
518 background: #c62828;
519 border-color: #c62828;
520 }
521 .tag-name {
522 font-weight: 600;
523 }
524 .tag-count {
525 background: rgba(255, 255, 255, 0.15);
526 padding: 0.08rem 0.4rem;
527 font-size: 0.72rem;
528 font-weight: 600;
529 color: rgba(255, 255, 255, 0.7);
530 min-width: 1.2rem;
531 text-align: center;
532 }
533 .tag-pill:hover .tag-count {
534 background: rgba(0, 0, 0, 0.25);
535 }
536
537 .viewer-edit-tags {
538 position: absolute;
539 top: 9rem;
540 left: 50%;
541 transform: translateX(-50%);
542 display: flex;
543 flex-direction: column;
544 align-items: center;
545 gap: 0.3rem;
546 max-width: 420px;
547 width: 90vw;
548 transition: opacity 0.3s;
549 z-index: 5;
550 }
551 .tag-err {
552 color: #ef5350;
553 font-family: 'Inter', sans-serif;
554 font-size: 0.7rem;
555 font-weight: 600;
556 }
557 .tag-edit-row {
558 display: flex;
559 gap: 0.3rem;
560 width: 100%;
561 }
562 .tag-edit-wrapper {
563 flex: 1;
564 position: relative;
565 min-width: 0;
566 }
567 .tag-edit-wrapper input[type="text"] {
568 width: 100%;
569 padding: 0.35rem 0.5rem;
570 border: 1px solid rgba(255, 255, 255, 0.2);
571 border-radius: 0;
572 background: rgba(0, 0, 0, 0.5);
573 color: #fff;
574 font-family: 'Inter', sans-serif;
575 font-size: 0.75rem;
576 font-weight: 500;
577 box-sizing: border-box;
578 }
579 .tag-edit-wrapper input[type="text"]:focus {
580 outline: none;
581 border-color: #c62828;
582 }
583 .tag-edit-wrapper input[type="text"]::placeholder {
584 color: rgba(255, 255, 255, 0.3);
585 }
586 .viewer-suggestions {
587 position: absolute;
588 top: 100%;
589 left: 0;
590 right: 0;
591 background: #1a1a1a;
592 border: 1px solid rgba(255, 255, 255, 0.2);
593 border-top: none;
594 list-style: none;
595 margin: 0;
596 padding: 0;
597 z-index: 15;
598 max-height: 120px;
599 overflow-y: auto;
600 }
601 .viewer-suggestions li {
602 padding: 0.3rem 0.5rem;
603 cursor: pointer;
604 font-family: 'Inter', sans-serif;
605 font-size: 0.75rem;
606 font-weight: 500;
607 color: #fff;
608 }
609 .viewer-suggestions li:hover {
610 background: #c62828;
611 }
612 .viewer-tag-btn {
613 padding: 0.35rem 0.6rem;
614 border: none;
615 font-family: 'Oswald', sans-serif;
616 font-weight: 500;
617 font-size: 0.68rem;
618 letter-spacing: 0.08em;
619 cursor: pointer;
620 white-space: nowrap;
621 transition: background 0.15s;
622 color: #fff;
623 }
624 .viewer-tag-btn.add {
625 background: rgba(255, 255, 255, 0.15);
626 }
627 .viewer-tag-btn.add:hover {
628 background: #2e7d32;
629 }
630 .viewer-tag-btn.remove {
631 background: rgba(255, 255, 255, 0.15);
632 }
633 .viewer-tag-btn.remove:hover {
634 background: #c62828;
635 }
636 .viewer-tag-btn:disabled {
637 opacity: 0.4;
638 cursor: default;
639 }
640</style>