web/src/lib/ImageViewer.svelte (view raw)
1<script lang="ts">
2 import { imageDownloadUrl, getImage, 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 idleTimer: ReturnType<typeof setTimeout> | undefined = $state();
20
21 let current = $derived(images[currentIndex]);
22
23 async function loadTagDetails() {
24 try {
25 const detail = await getImage(current.id);
26 tagDetails = detail.tags_detail;
27 } catch {
28 tagDetails = [];
29 }
30 }
31
32 function loadImage() {
33 loading = true;
34 showControls = true;
35 if (idleTimer) clearTimeout(idleTimer);
36 loadTagDetails();
37 }
38
39 function resetIdleTimer() {
40 showControls = true;
41 if (idleTimer) clearTimeout(idleTimer);
42 idleTimer = setTimeout(() => {
43 showControls = false;
44 }, 3000);
45 }
46
47 function handleKeydown(e: KeyboardEvent) {
48 if (e.key === 'Escape') {
49 onclose();
50 } else if (e.key === 'ArrowLeft') {
51 prev();
52 } else if (e.key === 'ArrowRight') {
53 next();
54 }
55 }
56
57 function prev() {
58 if (currentIndex > 0) {
59 currentIndex--;
60 loadImage();
61 }
62 }
63
64 function next() {
65 if (currentIndex < images.length - 1) {
66 currentIndex++;
67 loadImage();
68 }
69 }
70
71 function handleMediaReady() {
72 loading = false;
73 resetIdleTimer();
74 }
75
76 function handleMediaError() {
77 loading = false;
78 }
79
80 function handleOverlayClick(e: MouseEvent) {
81 if (e.target === e.currentTarget) {
82 onclose();
83 }
84 }
85
86 function formatSize(bytes: number): string {
87 if (bytes < 1024) return bytes + ' B';
88 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
89 return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
90 }
91
92 $effect(() => {
93 current.id;
94 loadTagDetails();
95 });
96</script>
97
98<svelte:window onkeydown={handleKeydown} on:mousemove={resetIdleTimer} />
99
100<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
101<div class="viewer-overlay" onclick={handleOverlayClick} role="dialog" aria-label="Image viewer" tabindex="-1">
102 <button class="viewer-close" onclick={onclose} aria-label="Close">CLOSE</button>
103
104 {#if loading}
105 <div class="viewer-loader">
106 <div class="spinner"></div>
107 </div>
108 {/if}
109
110 {#if currentIndex > 0}
111 <button
112 class="viewer-nav viewer-prev"
113 onclick={prev}
114 aria-label="Previous image"
115 class:controls-hidden={!showControls}
116 >‹</button>
117 {/if}
118
119 {#if currentIndex < images.length - 1}
120 <button
121 class="viewer-nav viewer-next"
122 onclick={next}
123 aria-label="Next image"
124 class:controls-hidden={!showControls}
125 >›</button>
126 {/if}
127
128 <div class="viewer-content" class:loading>
129 {#if current.mime_type.startsWith('video/')}
130 <!-- svelte-ignore a11y_media_has_caption -->
131 <video
132 src={imageDownloadUrl(current.id)}
133 controls
134 autoplay
135 class="viewer-media"
136 onloadeddata={handleMediaReady}
137 onerror={handleMediaError}
138 ></video>
139 {:else}
140 <img
141 src={imageDownloadUrl(current.id)}
142 alt={current.filename}
143 class="viewer-media"
144 onload={handleMediaReady}
145 onerror={handleMediaError}
146 />
147 {/if}
148 </div>
149
150 <div class="viewer-info" class:controls-hidden={!showControls}>
151 <div class="viewer-info-left">
152 <div class="viewer-uploader">
153 <Avatar src={current.avatar_url} username={current.uploader_username} size={80} />
154 <span class="viewer-uploader-name">{current.uploader_username}</span>
155 </div>
156 <div class="viewer-filename">{current.filename}</div>
157 <div class="viewer-meta">
158 #{current.id}
159 <span class="meta-dot">·</span>
160 {current.width}×{current.height}
161 <span class="meta-dot">·</span>
162 {formatSize(current.file_size)}
163 <span class="meta-dot">·</span>
164 {current.mime_type}
165 </div>
166 </div>
167 <div class="viewer-info-right">
168 <a href={imageDownloadUrl(current.id)} download={current.filename} class="viewer-download">
169 DOWNLOAD
170 </a>
171 <span class="viewer-counter">{currentIndex + 1} / {images.length}</span>
172 </div>
173 </div>
174
175 {#if tagDetails.length > 0}
176 <div class="viewer-tags" class:controls-hidden={!showControls}>
177 {#each tagDetails as t (t.tag)}
178 <button
179 class="tag-pill"
180 title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}"
181 onclick={() => { onclose(); window.location.href = '/browse?tag=' + encodeURIComponent(t.tag); }}
182 >
183 <span class="tag-name">{t.tag}</span>
184 <span class="tag-count">{t.usage_count}</span>
185 </button>
186 {/each}
187 </div>
188 {/if}
189
190 <div class="viewer-accent" class:controls-hidden={!showControls}></div>
191</div>
192
193<style>
194 .viewer-overlay {
195 position: fixed;
196 inset: 0;
197 z-index: 1000;
198 background: #0d0d0d;
199 display: flex;
200 align-items: center;
201 justify-content: center;
202 user-select: none;
203 }
204
205 .viewer-close {
206 position: absolute;
207 top: 0.75rem;
208 right: 0.75rem;
209 z-index: 10;
210 background: none;
211 border: 2px solid rgba(255,255,255,0.3);
212 color: #fff;
213 font-family: 'Oswald', sans-serif;
214 font-weight: 500;
215 font-size: 0.8rem;
216 letter-spacing: 0.12em;
217 padding: 0.4rem 1rem;
218 cursor: pointer;
219 transition: background 0.15s, border-color 0.15s;
220 }
221 .viewer-close:hover {
222 background: #c62828;
223 border-color: #c62828;
224 }
225
226 .viewer-loader {
227 position: absolute;
228 inset: 0;
229 display: flex;
230 align-items: center;
231 justify-content: center;
232 z-index: 2;
233 }
234 .spinner {
235 width: 40px;
236 height: 40px;
237 border: 3px solid rgba(255, 255, 255, 0.15);
238 border-top-color: #c62828;
239 border-radius: 50%;
240 animation: spin 0.7s linear infinite;
241 }
242 @keyframes spin {
243 to { transform: rotate(360deg); }
244 }
245
246 .viewer-nav {
247 position: absolute;
248 top: 50%;
249 transform: translateY(-50%);
250 z-index: 10;
251 background: rgba(0, 0, 0, 0.5);
252 border: none;
253 color: #fff;
254 font-size: 2.5rem;
255 width: 3rem;
256 height: 4rem;
257 cursor: pointer;
258 display: flex;
259 align-items: center;
260 justify-content: center;
261 transition: background 0.15s, opacity 0.3s;
262 line-height: 1;
263 }
264 .viewer-nav:hover {
265 background: #c62828;
266 }
267 .viewer-prev {
268 left: 0;
269 }
270 .viewer-next {
271 right: 0;
272 }
273
274 .viewer-content {
275 display: flex;
276 align-items: center;
277 justify-content: center;
278 max-width: 90vw;
279 max-height: 70vh;
280 }
281 .viewer-media {
282 max-width: 90vw;
283 max-height: 70vh;
284 object-fit: contain;
285 transition: opacity 0.3s;
286 }
287 .viewer-content.loading .viewer-media {
288 opacity: 0;
289 }
290
291 .viewer-info {
292 position: absolute;
293 bottom: 0;
294 left: 0;
295 right: 0;
296 background: linear-gradient(transparent, rgba(13, 13, 13, 0.95));
297 padding: 2.5rem 1.5rem 1rem;
298 display: flex;
299 justify-content: space-between;
300 align-items: flex-end;
301 gap: 1rem;
302 transition: opacity 0.3s;
303 z-index: 3;
304 }
305 .viewer-info-left {
306 display: flex;
307 flex-direction: column;
308 gap: 0.15rem;
309 min-width: 0;
310 }
311
312 .viewer-uploader {
313 display: flex;
314 align-items: center;
315 gap: 1.2rem;
316 margin-bottom: 0.8rem;
317 }
318 .viewer-uploader-name {
319 color: #fff;
320 font-family: 'Inter', sans-serif;
321 font-weight: 600;
322 font-size: 0.95rem;
323 }
324
325 .viewer-filename {
326 color: rgba(255, 255, 255, 0.65);
327 font-family: 'Inter', sans-serif;
328 font-weight: 500;
329 font-size: 0.82rem;
330 white-space: nowrap;
331 overflow: hidden;
332 text-overflow: ellipsis;
333 }
334 .viewer-meta {
335 color: rgba(255, 255, 255, 0.4);
336 font-size: 0.75rem;
337 font-weight: 500;
338 display: flex;
339 align-items: center;
340 gap: 0.35rem;
341 flex-wrap: wrap;
342 }
343 .meta-dot {
344 margin: 0 0.35rem;
345 color: rgba(255, 255, 255, 0.25);
346 }
347 .viewer-info-right {
348 display: flex;
349 align-items: center;
350 gap: 1rem;
351 flex-shrink: 0;
352 }
353 .viewer-download {
354 display: inline-block;
355 padding: 0.45rem 1rem;
356 background: #c62828;
357 color: #fff;
358 text-decoration: none;
359 font-family: 'Oswald', sans-serif;
360 font-weight: 500;
361 font-size: 0.8rem;
362 letter-spacing: 0.1em;
363 transition: background 0.15s;
364 }
365 .viewer-download:hover {
366 background: #b71c1c;
367 text-decoration: none;
368 }
369 .viewer-counter {
370 color: rgba(255, 255, 255, 0.35);
371 font-family: 'Oswald', sans-serif;
372 font-size: 0.8rem;
373 letter-spacing: 0.1em;
374 white-space: nowrap;
375 }
376
377 .viewer-accent {
378 position: absolute;
379 bottom: 0;
380 left: 0;
381 right: 0;
382 height: 3px;
383 background: #c62828;
384 z-index: 4;
385 transition: opacity 0.3s;
386 }
387
388 .controls-hidden {
389 opacity: 0;
390 pointer-events: none;
391 }
392
393 .viewer-tags {
394 position: absolute;
395 top: 4.5rem;
396 left: 50%;
397 transform: translateX(-50%);
398 display: flex;
399 flex-wrap: wrap;
400 justify-content: center;
401 gap: 0.4rem;
402 max-width: 80vw;
403 transition: opacity 0.3s;
404 z-index: 5;
405 }
406 .tag-pill {
407 display: inline-flex;
408 align-items: center;
409 gap: 0.4rem;
410 background: rgba(255, 255, 255, 0.08);
411 border: 1px solid rgba(255, 255, 255, 0.15);
412 border-radius: 0;
413 padding: 0.35rem 0.7rem 0.35rem 0.9rem;
414 font: inherit;
415 font-family: 'Inter', sans-serif;
416 font-size: 0.85rem;
417 font-weight: 500;
418 color: #fff;
419 white-space: nowrap;
420 cursor: pointer;
421 transition: background 0.15s, border-color 0.15s;
422 }
423 .tag-pill:hover {
424 background: #c62828;
425 border-color: #c62828;
426 }
427 .tag-name {
428 font-weight: 600;
429 }
430 .tag-count {
431 background: rgba(255, 255, 255, 0.15);
432 padding: 0.08rem 0.4rem;
433 font-size: 0.72rem;
434 font-weight: 600;
435 color: rgba(255, 255, 255, 0.7);
436 min-width: 1.2rem;
437 text-align: center;
438 }
439 .tag-pill:hover .tag-count {
440 background: rgba(0, 0, 0, 0.25);
441 }
442</style>