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 #{current.id}
154 <span class="meta-dot">·</span>
155 {current.width}×{current.height}
156 <span class="meta-dot">·</span>
157 {formatSize(current.file_size)}
158 <span class="meta-dot">·</span>
159 {current.mime_type}
160 <span class="meta-dot">·</span>
161 UPLOADED BY {current.uploader_username}
162 </div>
163 </div>
164 <div class="viewer-info-right">
165 <a href={imageDownloadUrl(current.id)} download={current.filename} class="viewer-download">
166 DOWNLOAD
167 </a>
168 <span class="viewer-counter">{currentIndex + 1} / {images.length}</span>
169 </div>
170 </div>
171
172 {#if tagDetails.length > 0}
173 <div class="viewer-tags" class:controls-hidden={!showControls}>
174 {#each tagDetails as t (t.tag)}
175 <button
176 class="tag-pill"
177 title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}"
178 onclick={() => { onclose(); window.location.href = '/browse?tag=' + encodeURIComponent(t.tag); }}
179 >
180 <span class="tag-name">{t.tag}</span>
181 <span class="tag-count">{t.usage_count}</span>
182 </button>
183 {/each}
184 </div>
185 {/if}
186
187 <div class="viewer-accent" class:controls-hidden={!showControls}></div>
188</div>
189
190<style>
191 .viewer-overlay {
192 position: fixed;
193 inset: 0;
194 z-index: 1000;
195 background: #0d0d0d;
196 display: flex;
197 align-items: center;
198 justify-content: center;
199 user-select: none;
200 }
201
202 .viewer-close {
203 position: absolute;
204 top: 0.75rem;
205 right: 0.75rem;
206 z-index: 10;
207 background: none;
208 border: 2px solid rgba(255,255,255,0.3);
209 color: #fff;
210 font-family: 'Oswald', sans-serif;
211 font-weight: 500;
212 font-size: 0.8rem;
213 letter-spacing: 0.12em;
214 padding: 0.4rem 1rem;
215 cursor: pointer;
216 transition: background 0.15s, border-color 0.15s;
217 }
218 .viewer-close:hover {
219 background: #c62828;
220 border-color: #c62828;
221 }
222
223 .viewer-loader {
224 position: absolute;
225 inset: 0;
226 display: flex;
227 align-items: center;
228 justify-content: center;
229 z-index: 2;
230 }
231 .spinner {
232 width: 40px;
233 height: 40px;
234 border: 3px solid rgba(255, 255, 255, 0.15);
235 border-top-color: #c62828;
236 border-radius: 50%;
237 animation: spin 0.7s linear infinite;
238 }
239 @keyframes spin {
240 to { transform: rotate(360deg); }
241 }
242
243 .viewer-nav {
244 position: absolute;
245 top: 50%;
246 transform: translateY(-50%);
247 z-index: 10;
248 background: rgba(0, 0, 0, 0.5);
249 border: none;
250 color: #fff;
251 font-size: 2.5rem;
252 width: 3rem;
253 height: 4rem;
254 cursor: pointer;
255 display: flex;
256 align-items: center;
257 justify-content: center;
258 transition: background 0.15s, opacity 0.3s;
259 line-height: 1;
260 }
261 .viewer-nav:hover {
262 background: #c62828;
263 }
264 .viewer-prev {
265 left: 0;
266 }
267 .viewer-next {
268 right: 0;
269 }
270
271 .viewer-content {
272 display: flex;
273 align-items: center;
274 justify-content: center;
275 max-width: 90vw;
276 max-height: 70vh;
277 }
278 .viewer-media {
279 max-width: 90vw;
280 max-height: 70vh;
281 object-fit: contain;
282 transition: opacity 0.3s;
283 }
284 .viewer-content.loading .viewer-media {
285 opacity: 0;
286 }
287
288 .viewer-info {
289 position: absolute;
290 bottom: 0;
291 left: 0;
292 right: 0;
293 background: linear-gradient(transparent, rgba(13, 13, 13, 0.95));
294 padding: 2.5rem 1.5rem 1rem;
295 display: flex;
296 justify-content: space-between;
297 align-items: flex-end;
298 gap: 1rem;
299 transition: opacity 0.3s;
300 z-index: 3;
301 }
302 .viewer-info-left {
303 display: flex;
304 flex-direction: column;
305 gap: 0.2rem;
306 min-width: 0;
307 }
308 .viewer-filename {
309 color: #fff;
310 font-family: 'Inter', sans-serif;
311 font-weight: 600;
312 font-size: 0.95rem;
313 white-space: nowrap;
314 overflow: hidden;
315 text-overflow: ellipsis;
316 }
317 .viewer-meta {
318 color: rgba(255, 255, 255, 0.5);
319 font-size: 0.78rem;
320 font-weight: 500;
321 }
322 .meta-dot {
323 margin: 0 0.35rem;
324 color: rgba(255, 255, 255, 0.25);
325 }
326 .viewer-info-right {
327 display: flex;
328 align-items: center;
329 gap: 1rem;
330 flex-shrink: 0;
331 }
332 .viewer-download {
333 display: inline-block;
334 padding: 0.45rem 1rem;
335 background: #c62828;
336 color: #fff;
337 text-decoration: none;
338 font-family: 'Oswald', sans-serif;
339 font-weight: 500;
340 font-size: 0.8rem;
341 letter-spacing: 0.1em;
342 transition: background 0.15s;
343 }
344 .viewer-download:hover {
345 background: #b71c1c;
346 text-decoration: none;
347 }
348 .viewer-counter {
349 color: rgba(255, 255, 255, 0.35);
350 font-family: 'Oswald', sans-serif;
351 font-size: 0.8rem;
352 letter-spacing: 0.1em;
353 white-space: nowrap;
354 }
355
356 .viewer-accent {
357 position: absolute;
358 bottom: 0;
359 left: 0;
360 right: 0;
361 height: 3px;
362 background: #c62828;
363 z-index: 4;
364 transition: opacity 0.3s;
365 }
366
367 .controls-hidden {
368 opacity: 0;
369 pointer-events: none;
370 }
371
372 .viewer-tags {
373 position: absolute;
374 top: 4.5rem;
375 left: 50%;
376 transform: translateX(-50%);
377 display: flex;
378 flex-wrap: wrap;
379 justify-content: center;
380 gap: 0.4rem;
381 max-width: 80vw;
382 transition: opacity 0.3s;
383 z-index: 5;
384 }
385 .tag-pill {
386 display: inline-flex;
387 align-items: center;
388 gap: 0.4rem;
389 background: rgba(255, 255, 255, 0.08);
390 border: 1px solid rgba(255, 255, 255, 0.15);
391 border-radius: 0;
392 padding: 0.35rem 0.7rem 0.35rem 0.9rem;
393 font: inherit;
394 font-family: 'Inter', sans-serif;
395 font-size: 0.85rem;
396 font-weight: 500;
397 color: #fff;
398 white-space: nowrap;
399 cursor: pointer;
400 transition: background 0.15s, border-color 0.15s;
401 }
402 .tag-pill:hover {
403 background: #c62828;
404 border-color: #c62828;
405 }
406 .tag-name {
407 font-weight: 600;
408 }
409 .tag-count {
410 background: rgba(255, 255, 255, 0.15);
411 padding: 0.08rem 0.4rem;
412 font-size: 0.72rem;
413 font-weight: 600;
414 color: rgba(255, 255, 255, 0.7);
415 min-width: 1.2rem;
416 text-align: center;
417 }
418 .tag-pill:hover .tag-count {
419 background: rgba(0, 0, 0, 0.25);
420 }
421</style>