web/src/routes/browse/+page.svelte (view raw)
1<script lang="ts">
2 import { onMount, onDestroy } from 'svelte';
3 import { listImages, suggestTags, suggestUploaders, thumbnailUrl, type Image, type Tag } from '$lib/api';
4 import ImageViewer from '$lib/ImageViewer.svelte';
5 import { goto } from '$app/navigation';
6
7 let images = $state<Image[]>([]);
8 let total = $state(0);
9 let loading = $state(true);
10 let error = $state('');
11 let filterTag = $state('');
12 let filterUploader = $state('');
13 let page = $state(1);
14 let viewerIndex = $state(-1);
15 const perPage = 60;
16
17 let tagSuggestions = $state<Tag[]>([]);
18 let uploaderSuggestions = $state<Tag[]>([]);
19 let showTagSugs = $state(false);
20 let showUploaderSugs = $state(false);
21 let sugTimer: ReturnType<typeof setTimeout> | undefined;
22
23 onMount(() => {
24 const mainEl = document.querySelector('.main') as HTMLElement;
25 if (mainEl) {
26 mainEl.style.padding = '0';
27 mainEl.style.maxWidth = 'none';
28 }
29 const params = new URLSearchParams(window.location.search);
30 filterTag = params.get('tag') || '';
31 filterUploader = params.get('uploader') || '';
32 loadImages().then(() => {
33 // Check if navigated from /image/{id} (e.g. after OG redirect).
34 const imageParam = params.get('image');
35 if (imageParam) {
36 const idx = images.findIndex((img) => String(img.id) === imageParam);
37 if (idx !== -1) {
38 viewerIndex = idx;
39 history.replaceState(null, '', '/image/' + imageParam);
40 } else {
41 goto('/image/' + imageParam);
42 }
43 }
44 });
45 });
46
47 onDestroy(() => {
48 const mainEl = document.querySelector('.main') as HTMLElement;
49 if (mainEl) {
50 mainEl.style.padding = '';
51 mainEl.style.maxWidth = '';
52 }
53 clearTimeout(sugTimer);
54 });
55
56 async function loadImages() {
57 loading = true;
58 error = '';
59 try {
60 const tags = filterTag ? [filterTag] : [];
61 const result = await listImages(tags, filterUploader, page, perPage);
62 images = result.images;
63 total = result.total;
64 } catch (err) {
65 error = err instanceof Error ? err.message : 'Failed to load images';
66 images = [];
67 } finally {
68 loading = false;
69 }
70 }
71
72 function prevPage() {
73 if (page > 1) { page--; loadImages(); }
74 }
75
76 function nextPage() {
77 if (page * perPage < total) { page++; loadImages(); }
78 }
79
80 function applyFilter() {
81 page = 1;
82 hideSugs();
83 loadImages();
84 }
85
86 function hideSugs() {
87 showTagSugs = false;
88 showUploaderSugs = false;
89 }
90
91 function onTagInput() {
92 clearTimeout(sugTimer);
93 const q = filterTag.trim();
94 if (q.length < 1) {
95 tagSuggestions = [];
96 showTagSugs = false;
97 return;
98 }
99 sugTimer = setTimeout(async () => {
100 try {
101 tagSuggestions = await suggestTags(q);
102 showTagSugs = tagSuggestions.length > 0;
103 } catch {
104 tagSuggestions = [];
105 showTagSugs = false;
106 }
107 }, 200);
108 }
109
110 function onUploaderInput() {
111 clearTimeout(sugTimer);
112 const q = filterUploader.trim();
113 if (q.length < 1) {
114 uploaderSuggestions = [];
115 showUploaderSugs = false;
116 return;
117 }
118 sugTimer = setTimeout(async () => {
119 try {
120 uploaderSuggestions = await suggestUploaders(q);
121 showUploaderSugs = uploaderSuggestions.length > 0;
122 } catch {
123 uploaderSuggestions = [];
124 showUploaderSugs = false;
125 }
126 }, 200);
127 }
128
129 function selectTagSug(s: Tag) {
130 filterTag = s.tag;
131 applyFilter();
132 }
133
134 function selectUploaderSug(s: Tag) {
135 filterUploader = s.tag;
136 applyFilter();
137 }
138
139 function openViewer(index: number) {
140 viewerIndex = index;
141 history.replaceState(null, '', '/image/' + images[index].id);
142 }
143
144 function closeViewer() {
145 viewerIndex = -1;
146 const params = new URLSearchParams();
147 if (filterTag) params.set('tag', filterTag);
148 if (filterUploader) params.set('uploader', filterUploader);
149 history.replaceState(null, '', params.toString() ? '/browse?' + params.toString() : '/browse');
150 // Restore main layout style.
151 const mainEl = document.querySelector('.main') as HTMLElement;
152 if (mainEl) {
153 mainEl.style.padding = '0';
154 mainEl.style.maxWidth = 'none';
155 }
156 }
157</script>
158
159<div class="header">
160 <div class="header-top">
161 <h1>Browse</h1>
162 <span class="header-count">{total} {total === 1 ? 'image' : 'images'}</span>
163 </div>
164 <div class="header-line"></div>
165 <div class="controls">
166 <div class="sug-wrap">
167 <input
168 type="text"
169 placeholder="Filter by tag..."
170 bind:value={filterTag}
171 oninput={onTagInput}
172 onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }}
173 onblur={() => setTimeout(() => { showTagSugs = false; }, 200)}
174 />
175 {#if showTagSugs}
176 <div class="sugs" role="listbox">
177 {#each tagSuggestions as s}
178 <button type="button" role="option" aria-selected={false} class="sug-opt" onmousedown={() => selectTagSug(s)}>
179 <span class="sug-label">{s.tag}</span>
180 <span class="sug-count">{s.usage_count}</span>
181 </button>
182 {/each}
183 </div>
184 {/if}
185 </div>
186 <div class="sug-wrap">
187 <input
188 type="text"
189 placeholder="Filter by uploader..."
190 bind:value={filterUploader}
191 oninput={onUploaderInput}
192 onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }}
193 onblur={() => setTimeout(() => { showUploaderSugs = false; }, 200)}
194 />
195 {#if showUploaderSugs}
196 <div class="sugs" role="listbox">
197 {#each uploaderSuggestions as s}
198 <button type="button" role="option" aria-selected={false} class="sug-opt" onmousedown={() => selectUploaderSug(s)}>
199 <span class="sug-label">{s.tag}</span>
200 <span class="sug-count">{s.usage_count}</span>
201 </button>
202 {/each}
203 </div>
204 {/if}
205 </div>
206 <button class="btn" onclick={applyFilter}>FILTER</button>
207 {#if filterTag || filterUploader}
208 <button class="btn btn-clear" onclick={() => { filterTag = ''; filterUploader = ''; applyFilter(); }}>CLEAR</button>
209 {/if}
210 </div>
211</div>
212
213{#if loading}
214 <p class="state-msg">Loading...</p>
215{:else if error}
216 <p class="state-msg error">{error}</p>
217{:else if images.length === 0}
218 <div class="empty">
219 {#if filterTag || filterUploader}
220 <p>No results
221 {#if filterTag}for tag <strong>“{filterTag}”</strong>{/if}
222 {#if filterTag && filterUploader} and {/if}
223 {#if filterUploader}by uploader <strong>“{filterUploader}”</strong>{/if}.
224 </p>
225 {:else}
226 <p>The archive is empty.</p>
227 <a href="/upload" class="btn">UPLOAD MEDIA</a>
228 {/if}
229 </div>
230{:else}
231 <div class="grid">
232 {#each images as img, i (img.id)}
233 <button class="card" onclick={() => openViewer(i)}>
234 <div class="card-image">
235 {#if img.thumbnail_path}
236 <img src={thumbnailUrl(img.id)} alt={img.filename} loading="lazy" />
237 {:else}
238 <div class="placeholder">{img.mime_type}</div>
239 {/if}
240 </div>
241 <div class="card-overlay">
242 <div class="card-title">{img.filename}</div>
243 <div class="card-user">
244 {#if img.avatar_url}
245 <img src={img.avatar_url} alt="" class="card-avatar" />
246 {/if}
247 #{img.id} · <span class="user-link" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); filterUploader = img.uploader_username; applyFilter(); }} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); filterUploader = img.uploader_username; applyFilter(); } }}>{img.uploader_username}</span>
248 </div>
249 </div>
250 </button>
251 {/each}
252 </div>
253
254 <div class="pagination">
255 <button class="btn" onclick={prevPage} disabled={page <= 1}>PREVIOUS</button>
256 <span class="page-info">PAGE {page} OF {Math.ceil(total / perPage)}</span>
257 <button class="btn" onclick={nextPage} disabled={page * perPage >= total}>NEXT</button>
258 </div>
259{/if}
260
261{#if viewerIndex >= 0}
262 <ImageViewer
263 images={images}
264 initialIndex={viewerIndex}
265 onclose={closeViewer}
266 />
267{/if}
268
269<style>
270 .header {
271 padding: 1.5rem 1.5rem 0.75rem;
272 }
273 .header-top {
274 display: flex;
275 align-items: baseline;
276 gap: 1rem;
277 }
278 .header-top h1 {
279 margin: 0;
280 }
281 .header-count {
282 font-family: 'Oswald', sans-serif;
283 font-weight: 500;
284 font-size: 0.9rem;
285 letter-spacing: 0.1em;
286 color: #888;
287 text-transform: uppercase;
288 }
289 .header-line {
290 height: 3px;
291 background: #c62828;
292 width: 3rem;
293 margin: 0.5rem 0 1rem;
294 }
295 .controls {
296 display: flex;
297 gap: 0.5rem;
298 align-items: flex-start;
299 }
300 .sug-wrap {
301 position: relative;
302 flex: 1;
303 max-width: 280px;
304 }
305 .sug-wrap input {
306 width: 100%;
307 }
308 .controls input {
309 padding: 0.5rem 0.75rem;
310 border: 2px solid #1a1a1a;
311 border-radius: 0;
312 font-family: 'Inter', sans-serif;
313 font-size: 0.85rem;
314 font-weight: 500;
315 background: #fff;
316 }
317 .controls input:focus {
318 outline: none;
319 border-color: #c62828;
320 }
321
322 .sugs {
323 position: absolute;
324 top: 100%;
325 left: 0;
326 right: 0;
327 z-index: 20;
328 list-style: none;
329 margin: 0;
330 padding: 0;
331 background: #fff;
332 border: 2px solid #1a1a1a;
333 border-top: none;
334 max-height: 220px;
335 overflow-y: auto;
336 }
337 .sug-opt {
338 display: flex;
339 align-items: center;
340 justify-content: space-between;
341 width: 100%;
342 text-align: left;
343 padding: 0.45rem 0.75rem;
344 border: none;
345 background: none;
346 font-family: 'Inter', sans-serif;
347 font-size: 0.82rem;
348 font-weight: 500;
349 gap: 0.5rem;
350 cursor: pointer;
351 border-radius: 0;
352 }
353 .sug-opt:hover {
354 background: #f0f0f0;
355 }
356 .sug-label {
357 overflow: hidden;
358 text-overflow: ellipsis;
359 white-space: nowrap;
360 }
361 .sug-count {
362 flex-shrink: 0;
363 font-family: 'Oswald', sans-serif;
364 font-weight: 500;
365 font-size: 0.72rem;
366 letter-spacing: 0.05em;
367 color: #888;
368 }
369
370 .btn {
371 display: inline-flex;
372 align-items: center;
373 padding: 0.5rem 1.25rem;
374 background: #1a1a1a;
375 color: #fff;
376 border: none;
377 border-radius: 0;
378 font-family: 'Oswald', sans-serif;
379 font-weight: 500;
380 font-size: 0.85rem;
381 letter-spacing: 0.1em;
382 text-transform: uppercase;
383 cursor: pointer;
384 text-decoration: none;
385 transition: background 0.15s;
386 }
387 .btn:hover {
388 background: #c62828;
389 }
390 .btn:disabled {
391 opacity: 0.3;
392 cursor: default;
393 }
394 .btn-clear {
395 background: #666;
396 }
397 .btn-clear:hover {
398 background: #c62828;
399 }
400
401 .grid {
402 display: grid;
403 grid-template-columns: repeat(6, 1fr);
404 gap: 2px;
405 }
406
407 @media (max-width: 1200px) {
408 .grid { grid-template-columns: repeat(5, 1fr); }
409 }
410 @media (max-width: 1000px) {
411 .grid { grid-template-columns: repeat(4, 1fr); }
412 }
413 @media (max-width: 750px) {
414 .grid { grid-template-columns: repeat(3, 1fr); }
415 }
416 @media (max-width: 500px) {
417 .grid { grid-template-columns: repeat(2, 1fr); }
418 }
419
420 .card {
421 position: relative;
422 display: block;
423 width: 100%;
424 padding: 0;
425 border: none;
426 background: none;
427 font: inherit;
428 color: inherit;
429 cursor: pointer;
430 text-align: left;
431 overflow: hidden;
432 outline: none;
433 }
434 .card:focus-visible {
435 outline: 3px solid #c62828;
436 outline-offset: -3px;
437 }
438 .card-image {
439 position: relative;
440 overflow: hidden;
441 background: #1a1a1a;
442 aspect-ratio: 16 / 10;
443 }
444 .card-image img {
445 display: block;
446 width: 100%;
447 height: 100%;
448 object-fit: cover;
449 transition: transform 300ms ease, filter 300ms ease;
450 }
451 .card:hover .card-image img {
452 transform: scale(1.08);
453 filter: grayscale(0.5) contrast(1.1);
454 }
455 .placeholder {
456 display: flex;
457 align-items: center;
458 justify-content: center;
459 width: 100%;
460 height: 100%;
461 color: #555;
462 font-size: 0.8rem;
463 font-weight: 600;
464 }
465
466 .card-overlay {
467 position: absolute;
468 bottom: 0;
469 left: 0;
470 right: 0;
471 background: linear-gradient(transparent 30%, #1a1a1a 95%);
472 padding: 2rem 0.6rem 0.5rem;
473 opacity: 0;
474 transition: opacity 200ms ease;
475 pointer-events: none;
476 }
477 .card:hover .card-overlay {
478 opacity: 1;
479 }
480 .card-title {
481 color: #fff;
482 font-family: 'Inter', sans-serif;
483 font-size: 0.78rem;
484 font-weight: 600;
485 white-space: nowrap;
486 overflow: hidden;
487 text-overflow: ellipsis;
488 }
489 .card-user {
490 color: rgba(255, 255, 255, 0.55);
491 font-family: 'Inter', sans-serif;
492 font-size: 0.68rem;
493 font-weight: 500;
494 margin-top: 0.05rem;
495 display: flex;
496 align-items: center;
497 gap: 0.3rem;
498 }
499 .card-avatar {
500 width: 1rem;
501 height: 1rem;
502 border-radius: 0;
503 object-fit: cover;
504 flex-shrink: 0;
505 }
506
507 .user-link {
508 background: none;
509 border: none;
510 padding: 0;
511 font: inherit;
512 color: rgba(255, 255, 255, 0.55);
513 cursor: pointer;
514 text-decoration: none;
515 pointer-events: auto;
516 }
517 .user-link:hover {
518 color: #ef5350;
519 text-decoration: underline;
520 }
521
522 .pagination {
523 display: flex;
524 align-items: center;
525 justify-content: center;
526 gap: 1rem;
527 padding: 2rem 1.5rem 3rem;
528 }
529 .page-info {
530 font-family: 'Oswald', sans-serif;
531 font-weight: 500;
532 font-size: 0.85rem;
533 letter-spacing: 0.12em;
534 color: #888;
535 }
536
537 .state-msg {
538 padding: 3rem 1.5rem;
539 font-size: 1.1rem;
540 color: #888;
541 }
542 .error {
543 color: #c62828;
544 }
545
546 .empty {
547 padding: 3rem 1.5rem;
548 display: flex;
549 flex-direction: column;
550 gap: 1rem;
551 align-items: flex-start;
552 }
553 .empty p {
554 font-size: 1.1rem;
555 color: #888;
556 }
557</style>