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