web/src/lib/ImageViewer.svelte (view raw)
1<script lang="ts">
2 import { thumbnailUrl, 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 let totalPillCount = $derived(tagDetails.reduce((sum, t) => sum + t.usage_count, 0));
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 // Load initial tag details.
93 $effect(() => {
94 current.id;
95 loadTagDetails();
96 });
97</script>
98
99<svelte:window onkeydown={handleKeydown} on:mousemove={resetIdleTimer} />
100
101<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
102<div class="viewer-overlay" onclick={handleOverlayClick} role="dialog" aria-label="Image viewer" tabindex="-1">
103 <!-- Close button -->
104 <button class="viewer-close" onclick={onclose} aria-label="Close">×</button>
105
106 <!-- Loading indicator -->
107 {#if loading}
108 <div class="viewer-loader">
109 <div class="spinner"></div>
110 </div>
111 {/if}
112
113 <!-- Previous button -->
114 {#if currentIndex > 0}
115 <button
116 class="viewer-nav viewer-prev"
117 onclick={prev}
118 aria-label="Previous image"
119 class:controls-hidden={!showControls}
120 >‹</button>
121 {/if}
122
123 <!-- Next button -->
124 {#if currentIndex < images.length - 1}
125 <button
126 class="viewer-nav viewer-next"
127 onclick={next}
128 aria-label="Next image"
129 class:controls-hidden={!showControls}
130 >›</button>
131 {/if}
132
133 <!-- Media -->
134 <div class="viewer-content" class:loading>
135 {#if current.mime_type.startsWith('video/')}
136 <!-- svelte-ignore a11y_media_has_caption -->
137 <video
138 src={imageDownloadUrl(current.id)}
139 controls
140 autoplay
141 class="viewer-media"
142 onloadeddata={handleMediaReady}
143 onerror={handleMediaError}
144 ></video>
145 {:else}
146 <img
147 src={imageDownloadUrl(current.id)}
148 alt={current.filename}
149 class="viewer-media"
150 onload={handleMediaReady}
151 onerror={handleMediaError}
152 />
153 {/if}
154 </div>
155
156 <!-- Bottom info bar -->
157 <div class="viewer-info" class:controls-hidden={!showControls}>
158 <div class="viewer-info-left">
159 <span class="viewer-filename">{current.filename}</span>
160 <span class="viewer-meta">
161 {current.width}×{current.height}
162 · {formatSize(current.file_size)}
163 · {current.mime_type}
164 </span>
165 </div>
166 <div class="viewer-info-right">
167 <a href={imageDownloadUrl(current.id)} download={current.filename} class="viewer-download">
168 Download
169 </a>
170 <span class="viewer-counter">{currentIndex + 1} / {images.length}</span>
171 </div>
172 </div>
173
174 <!-- Tags bar -->
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</div>
190
191<style>
192 .viewer-overlay {
193 position: fixed;
194 inset: 0;
195 z-index: 1000;
196 background: rgba(0, 0, 0, 0.92);
197 display: flex;
198 align-items: center;
199 justify-content: center;
200 user-select: none;
201 }
202
203 .viewer-close {
204 position: absolute;
205 top: 1rem;
206 right: 1rem;
207 z-index: 10;
208 background: rgba(0, 0, 0, 0.5);
209 border: none;
210 color: #fff;
211 font-size: 2rem;
212 width: 2.5rem;
213 height: 2.5rem;
214 border-radius: 50%;
215 cursor: pointer;
216 display: flex;
217 align-items: center;
218 justify-content: center;
219 line-height: 1;
220 transition: opacity 0.3s;
221 }
222 .viewer-close:hover {
223 background: rgba(255, 255, 255, 0.2);
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.2);
238 border-top-color: #fff;
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.4);
252 border: none;
253 color: #fff;
254 font-size: 3rem;
255 width: 3.5rem;
256 height: 5rem;
257 cursor: pointer;
258 display: flex;
259 align-items: center;
260 justify-content: center;
261 border-radius: 6px;
262 transition: opacity 0.3s;
263 line-height: 1;
264 }
265 .viewer-nav:hover {
266 background: rgba(255, 255, 255, 0.15);
267 }
268 .viewer-prev {
269 left: 1rem;
270 }
271 .viewer-next {
272 right: 1rem;
273 }
274
275 .viewer-content {
276 display: flex;
277 align-items: center;
278 justify-content: center;
279 max-width: 90vw;
280 max-height: 70vh;
281 }
282 .viewer-media {
283 max-width: 90vw;
284 max-height: 70vh;
285 object-fit: contain;
286 border-radius: 4px;
287 transition: opacity 0.3s;
288 }
289 .viewer-content.loading .viewer-media {
290 opacity: 0;
291 }
292
293 .viewer-info {
294 position: absolute;
295 bottom: 0;
296 left: 0;
297 right: 0;
298 background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
299 padding: 2.5rem 1.5rem 1rem;
300 display: flex;
301 justify-content: space-between;
302 align-items: flex-end;
303 gap: 1rem;
304 transition: opacity 0.3s;
305 }
306 .viewer-info-left {
307 display: flex;
308 flex-direction: column;
309 gap: 0.2rem;
310 min-width: 0;
311 }
312 .viewer-filename {
313 color: #fff;
314 font-weight: 600;
315 font-size: 1rem;
316 white-space: nowrap;
317 overflow: hidden;
318 text-overflow: ellipsis;
319 }
320 .viewer-meta {
321 color: rgba(255, 255, 255, 0.6);
322 font-size: 0.8rem;
323 }
324 .viewer-info-right {
325 display: flex;
326 align-items: center;
327 gap: 1rem;
328 flex-shrink: 0;
329 }
330 .viewer-download {
331 display: inline-block;
332 padding: 0.5rem 1rem;
333 background: #1a1a2e;
334 color: #eee;
335 text-decoration: none;
336 border-radius: 6px;
337 font-size: 0.85rem;
338 font-weight: 500;
339 transition: background 0.15s;
340 }
341 .viewer-download:hover {
342 background: #16213e;
343 }
344 .viewer-counter {
345 color: rgba(255, 255, 255, 0.5);
346 font-size: 0.85rem;
347 white-space: nowrap;
348 }
349
350 .controls-hidden {
351 opacity: 0;
352 pointer-events: none;
353 }
354
355 /* Tags bar */
356 .viewer-tags {
357 position: absolute;
358 top: 4.5rem;
359 left: 50%;
360 transform: translateX(-50%);
361 display: flex;
362 flex-wrap: wrap;
363 justify-content: center;
364 gap: 0.4rem;
365 max-width: 80vw;
366 transition: opacity 0.3s;
367 z-index: 5;
368 }
369 .tag-pill {
370 display: inline-flex;
371 align-items: center;
372 gap: 0.4rem;
373 background: rgba(255, 255, 255, 0.12);
374 border: 1px solid rgba(255, 255, 255, 0.25);
375 border-radius: 24px;
376 padding: 0.35rem 0.75rem 0.35rem 0.9rem;
377 font: inherit;
378 font-size: 0.9rem;
379 color: #fff;
380 white-space: nowrap;
381 backdrop-filter: blur(4px);
382 cursor: pointer;
383 transition: background 0.15s;
384 }
385 .tag-pill:hover {
386 background: rgba(255, 255, 255, 0.22);
387 }
388 .tag-name {
389 font-weight: 500;
390 }
391 .tag-count {
392 background: rgba(255, 255, 255, 0.15);
393 border-radius: 12px;
394 padding: 0.1rem 0.45rem;
395 font-size: 0.78rem;
396 font-weight: 600;
397 color: rgba(255, 255, 255, 0.8);
398 min-width: 1.3rem;
399 text-align: center;
400 }
401</style>