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