web/src/lib/ImageViewer.svelte (view raw)
1<script lang="ts">
2 import { imageDownloadUrl, getImage, type Image, type TagDetail } from '$lib/api';
3
4 let {
5 images,
6 initialIndex = 0,
7 onclose
8 }: {
9 images: Image[];
10 initialIndex?: number;
11 onclose: () => void;
12 } = $props();
13
14 let currentIndex = $state(initialIndex);
15 let loading = $state(true);
16 let showControls = $state(true);
17 let tagDetails = $state<TagDetail[]>([]);
18 let idleTimer: ReturnType<typeof setTimeout> | undefined = $state();
19
20 let current = $derived(images[currentIndex]);
21
22 async function loadTagDetails() {
23 try {
24 const detail = await getImage(current.id);
25 tagDetails = detail.tags_detail;
26 } catch {
27 tagDetails = [];
28 }
29 }
30
31 function loadImage() {
32 loading = true;
33 showControls = true;
34 if (idleTimer) clearTimeout(idleTimer);
35 loadTagDetails();
36 }
37
38 function resetIdleTimer() {
39 showControls = true;
40 if (idleTimer) clearTimeout(idleTimer);
41 idleTimer = setTimeout(() => {
42 showControls = false;
43 }, 3000);
44 }
45
46 function handleKeydown(e: KeyboardEvent) {
47 if (e.key === 'Escape') {
48 onclose();
49 } else if (e.key === 'ArrowLeft') {
50 prev();
51 } else if (e.key === 'ArrowRight') {
52 next();
53 }
54 }
55
56 function prev() {
57 if (currentIndex > 0) {
58 currentIndex--;
59 loadImage();
60 }
61 }
62
63 function next() {
64 if (currentIndex < images.length - 1) {
65 currentIndex++;
66 loadImage();
67 }
68 }
69
70 function handleMediaReady() {
71 loading = false;
72 resetIdleTimer();
73 }
74
75 function handleMediaError() {
76 loading = false;
77 }
78
79 function handleOverlayClick(e: MouseEvent) {
80 if (e.target === e.currentTarget) {
81 onclose();
82 }
83 }
84
85 function formatSize(bytes: number): string {
86 if (bytes < 1024) return bytes + ' B';
87 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
88 return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
89 }
90
91 $effect(() => {
92 current.id;
93 loadTagDetails();
94 });
95</script>
96
97<svelte:window onkeydown={handleKeydown} on:mousemove={resetIdleTimer} />
98
99<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
100<div class="viewer-overlay" onclick={handleOverlayClick} role="dialog" aria-label="Image viewer" tabindex="-1">
101 <button class="viewer-close" onclick={onclose} aria-label="Close">CLOSE</button>
102
103 {#if loading}
104 <div class="viewer-loader">
105 <div class="spinner"></div>
106 </div>
107 {/if}
108
109 {#if currentIndex > 0}
110 <button
111 class="viewer-nav viewer-prev"
112 onclick={prev}
113 aria-label="Previous image"
114 class:controls-hidden={!showControls}
115 >‹</button>
116 {/if}
117
118 {#if currentIndex < images.length - 1}
119 <button
120 class="viewer-nav viewer-next"
121 onclick={next}
122 aria-label="Next image"
123 class:controls-hidden={!showControls}
124 >›</button>
125 {/if}
126
127 <div class="viewer-content" class:loading>
128 {#if current.mime_type.startsWith('video/')}
129 <!-- svelte-ignore a11y_media_has_caption -->
130 <video
131 src={imageDownloadUrl(current.id)}
132 controls
133 autoplay
134 class="viewer-media"
135 onloadeddata={handleMediaReady}
136 onerror={handleMediaError}
137 ></video>
138 {:else}
139 <img
140 src={imageDownloadUrl(current.id)}
141 alt={current.filename}
142 class="viewer-media"
143 onload={handleMediaReady}
144 onerror={handleMediaError}
145 />
146 {/if}
147 </div>
148
149 <div class="viewer-info" class:controls-hidden={!showControls}>
150 <div class="viewer-info-left">
151 <div class="viewer-filename">{current.filename}</div>
152 <div class="viewer-meta">
153 {#if current.avatar_url}
154 <img src={current.avatar_url} alt="" class="viewer-avatar" />
155 {/if}
156 #{current.id}
157 <span class="meta-dot">·</span>
158 {current.width}×{current.height}
159 <span class="meta-dot">·</span>
160 {formatSize(current.file_size)}
161 <span class="meta-dot">·</span>
162 {current.mime_type}
163 <span class="meta-dot">·</span>
164 UPLOADED BY {current.uploader_username}
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.2rem;
309 min-width: 0;
310 }
311 .viewer-filename {
312 color: #fff;
313 font-family: 'Inter', sans-serif;
314 font-weight: 600;
315 font-size: 0.95rem;
316 white-space: nowrap;
317 overflow: hidden;
318 text-overflow: ellipsis;
319 }
320 .viewer-meta {
321 color: rgba(255, 255, 255, 0.5);
322 font-size: 0.78rem;
323 font-weight: 500;
324 display: flex;
325 align-items: center;
326 gap: 0.35rem;
327 }
328 .viewer-avatar {
329 width: 1.2rem;
330 height: 1.2rem;
331 object-fit: cover;
332 flex-shrink: 0;
333 }
334 .meta-dot {
335 margin: 0 0.35rem;
336 color: rgba(255, 255, 255, 0.25);
337 }
338 .viewer-info-right {
339 display: flex;
340 align-items: center;
341 gap: 1rem;
342 flex-shrink: 0;
343 }
344 .viewer-download {
345 display: inline-block;
346 padding: 0.45rem 1rem;
347 background: #c62828;
348 color: #fff;
349 text-decoration: none;
350 font-family: 'Oswald', sans-serif;
351 font-weight: 500;
352 font-size: 0.8rem;
353 letter-spacing: 0.1em;
354 transition: background 0.15s;
355 }
356 .viewer-download:hover {
357 background: #b71c1c;
358 text-decoration: none;
359 }
360 .viewer-counter {
361 color: rgba(255, 255, 255, 0.35);
362 font-family: 'Oswald', sans-serif;
363 font-size: 0.8rem;
364 letter-spacing: 0.1em;
365 white-space: nowrap;
366 }
367
368 .viewer-accent {
369 position: absolute;
370 bottom: 0;
371 left: 0;
372 right: 0;
373 height: 3px;
374 background: #c62828;
375 z-index: 4;
376 transition: opacity 0.3s;
377 }
378
379 .controls-hidden {
380 opacity: 0;
381 pointer-events: none;
382 }
383
384 .viewer-tags {
385 position: absolute;
386 top: 4.5rem;
387 left: 50%;
388 transform: translateX(-50%);
389 display: flex;
390 flex-wrap: wrap;
391 justify-content: center;
392 gap: 0.4rem;
393 max-width: 80vw;
394 transition: opacity 0.3s;
395 z-index: 5;
396 }
397 .tag-pill {
398 display: inline-flex;
399 align-items: center;
400 gap: 0.4rem;
401 background: rgba(255, 255, 255, 0.08);
402 border: 1px solid rgba(255, 255, 255, 0.15);
403 border-radius: 0;
404 padding: 0.35rem 0.7rem 0.35rem 0.9rem;
405 font: inherit;
406 font-family: 'Inter', sans-serif;
407 font-size: 0.85rem;
408 font-weight: 500;
409 color: #fff;
410 white-space: nowrap;
411 cursor: pointer;
412 transition: background 0.15s, border-color 0.15s;
413 }
414 .tag-pill:hover {
415 background: #c62828;
416 border-color: #c62828;
417 }
418 .tag-name {
419 font-weight: 600;
420 }
421 .tag-count {
422 background: rgba(255, 255, 255, 0.15);
423 padding: 0.08rem 0.4rem;
424 font-size: 0.72rem;
425 font-weight: 600;
426 color: rgba(255, 255, 255, 0.7);
427 min-width: 1.2rem;
428 text-align: center;
429 }
430 .tag-pill:hover .tag-count {
431 background: rgba(0, 0, 0, 0.25);
432 }
433</style>