web/src/lib/ImageViewer.svelte (view raw)
1<script lang="ts">
2 import { thumbnailUrl, imageDownloadUrl, type Image } 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); // eslint-disable-line svelte/state_referenced_locally
15 let loading = $state(true);
16 let showControls = $state(true);
17 let idleTimer: ReturnType<typeof setTimeout> | undefined = $state();
18
19 let current = $derived(images[currentIndex]);
20
21 function loadImage() {
22 loading = true;
23 showControls = true;
24 if (idleTimer) clearTimeout(idleTimer);
25 }
26
27 function resetIdleTimer() {
28 showControls = true;
29 if (idleTimer) clearTimeout(idleTimer);
30 idleTimer = setTimeout(() => {
31 showControls = false;
32 }, 3000);
33 }
34
35 function handleKeydown(e: KeyboardEvent) {
36 if (e.key === 'Escape') {
37 onclose();
38 } else if (e.key === 'ArrowLeft') {
39 prev();
40 } else if (e.key === 'ArrowRight') {
41 next();
42 }
43 }
44
45 function prev() {
46 if (currentIndex > 0) {
47 currentIndex--;
48 loadImage();
49 }
50 }
51
52 function next() {
53 if (currentIndex < images.length - 1) {
54 currentIndex++;
55 loadImage();
56 }
57 }
58
59 function handleImgLoad() {
60 loading = false;
61 resetIdleTimer();
62 }
63
64 function handleImgError() {
65 loading = false;
66 }
67
68 function handleOverlayClick(e: MouseEvent) {
69 if (e.target === e.currentTarget) {
70 onclose();
71 }
72 }
73
74 function formatSize(bytes: number): string {
75 if (bytes < 1024) return bytes + ' B';
76 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
77 return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
78 }
79</script>
80
81<svelte:window onkeydown={handleKeydown} on:mousemove={resetIdleTimer} />
82
83<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
84<div class="viewer-overlay" onclick={handleOverlayClick} role="dialog" aria-label="Image viewer" tabindex="-1">
85 <!-- Close button -->
86 <button class="viewer-close" onclick={onclose} aria-label="Close">×</button>
87
88 <!-- Loading indicator -->
89 {#if loading}
90 <div class="viewer-loader">
91 <div class="spinner"></div>
92 </div>
93 {/if}
94
95 <!-- Previous button -->
96 {#if currentIndex > 0}
97 <button
98 class="viewer-nav viewer-prev"
99 onclick={prev}
100 aria-label="Previous image"
101 class:controls-hidden={!showControls}
102 >‹</button>
103 {/if}
104
105 <!-- Next button -->
106 {#if currentIndex < images.length - 1}
107 <button
108 class="viewer-nav viewer-next"
109 onclick={next}
110 aria-label="Next image"
111 class:controls-hidden={!showControls}
112 >›</button>
113 {/if}
114
115 <!-- Image -->
116 <div class="viewer-content" class:loading>
117 {#if current.mime_type.startsWith('video/')}
118 <!-- svelte-ignore a11y_media_has_caption -->
119 <video
120 src={imageDownloadUrl(current.id)}
121 controls
122 autoplay
123 class="viewer-media"
124 onloadeddata={handleImgLoad}
125 onerror={handleImgError}
126 ></video>
127 {:else}
128 <img
129 src={imageDownloadUrl(current.id)}
130 alt={current.filename}
131 class="viewer-media"
132 onload={handleImgLoad}
133 onerror={handleImgError}
134 />
135 {/if}
136 </div>
137
138 <!-- Bottom info bar -->
139 <div class="viewer-info" class:controls-hidden={!showControls}>
140 <div class="viewer-info-left">
141 <span class="viewer-filename">{current.filename}</span>
142 <span class="viewer-meta">
143 {current.width}×{current.height}
144 · {formatSize(current.file_size)}
145 · {current.mime_type}
146 </span>
147 {#if current.tags.length > 0}
148 <span class="viewer-tags">{current.tags.join(', ')}</span>
149 {/if}
150 </div>
151 <div class="viewer-info-right">
152 <a href={imageDownloadUrl(current.id)} download={current.filename} class="viewer-download">
153 Download
154 </a>
155 <span class="viewer-counter">{currentIndex + 1} / {images.length}</span>
156 </div>
157 </div>
158</div>
159
160<style>
161 .viewer-overlay {
162 position: fixed;
163 inset: 0;
164 z-index: 1000;
165 background: rgba(0, 0, 0, 0.92);
166 display: flex;
167 align-items: center;
168 justify-content: center;
169 user-select: none;
170 }
171
172 .viewer-close {
173 position: absolute;
174 top: 1rem;
175 right: 1rem;
176 z-index: 10;
177 background: rgba(0, 0, 0, 0.5);
178 border: none;
179 color: #fff;
180 font-size: 2rem;
181 width: 2.5rem;
182 height: 2.5rem;
183 border-radius: 50%;
184 cursor: pointer;
185 display: flex;
186 align-items: center;
187 justify-content: center;
188 line-height: 1;
189 transition: opacity 0.3s;
190 }
191 .viewer-close:hover {
192 background: rgba(255, 255, 255, 0.2);
193 }
194
195 .viewer-loader {
196 position: absolute;
197 inset: 0;
198 display: flex;
199 align-items: center;
200 justify-content: center;
201 z-index: 2;
202 }
203 .spinner {
204 width: 40px;
205 height: 40px;
206 border: 3px solid rgba(255, 255, 255, 0.2);
207 border-top-color: #fff;
208 border-radius: 50%;
209 animation: spin 0.7s linear infinite;
210 }
211 @keyframes spin {
212 to { transform: rotate(360deg); }
213 }
214
215 .viewer-nav {
216 position: absolute;
217 top: 50%;
218 transform: translateY(-50%);
219 z-index: 10;
220 background: rgba(0, 0, 0, 0.4);
221 border: none;
222 color: #fff;
223 font-size: 3rem;
224 width: 3.5rem;
225 height: 5rem;
226 cursor: pointer;
227 display: flex;
228 align-items: center;
229 justify-content: center;
230 border-radius: 6px;
231 transition: opacity 0.3s;
232 line-height: 1;
233 }
234 .viewer-nav:hover {
235 background: rgba(255, 255, 255, 0.15);
236 }
237 .viewer-prev {
238 left: 1rem;
239 }
240 .viewer-next {
241 right: 1rem;
242 }
243
244 .viewer-content {
245 display: flex;
246 align-items: center;
247 justify-content: center;
248 max-width: 90vw;
249 max-height: 85vh;
250 }
251 .viewer-media {
252 max-width: 90vw;
253 max-height: 85vh;
254 object-fit: contain;
255 border-radius: 4px;
256 transition: opacity 0.3s;
257 }
258 .viewer-content.loading .viewer-media {
259 opacity: 0;
260 }
261
262 .viewer-info {
263 position: absolute;
264 bottom: 0;
265 left: 0;
266 right: 0;
267 background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
268 padding: 2rem 1.5rem 1.25rem;
269 display: flex;
270 justify-content: space-between;
271 align-items: flex-end;
272 gap: 1rem;
273 transition: opacity 0.3s;
274 }
275 .viewer-info-left {
276 display: flex;
277 flex-direction: column;
278 gap: 0.2rem;
279 min-width: 0;
280 }
281 .viewer-filename {
282 color: #fff;
283 font-weight: 600;
284 font-size: 1rem;
285 white-space: nowrap;
286 overflow: hidden;
287 text-overflow: ellipsis;
288 }
289 .viewer-meta {
290 color: rgba(255, 255, 255, 0.6);
291 font-size: 0.8rem;
292 }
293 .viewer-tags {
294 color: rgba(255, 255, 255, 0.5);
295 font-size: 0.8rem;
296 white-space: nowrap;
297 overflow: hidden;
298 text-overflow: ellipsis;
299 }
300 .viewer-info-right {
301 display: flex;
302 align-items: center;
303 gap: 1rem;
304 flex-shrink: 0;
305 }
306 .viewer-download {
307 display: inline-block;
308 padding: 0.5rem 1rem;
309 background: #1a1a2e;
310 color: #eee;
311 text-decoration: none;
312 border-radius: 6px;
313 font-size: 0.85rem;
314 font-weight: 500;
315 transition: background 0.15s;
316 }
317 .viewer-download:hover {
318 background: #16213e;
319 }
320 .viewer-counter {
321 color: rgba(255, 255, 255, 0.5);
322 font-size: 0.85rem;
323 white-space: nowrap;
324 }
325
326 .controls-hidden {
327 opacity: 0;
328 pointer-events: none;
329 }
330</style>