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 function formatDuration(sec: number): string {
163 const m = Math.floor(sec / 60);
164 const s = Math.floor(sec % 60);
165 return m + ':' + String(s).padStart(2, '0');
166 }
167
168 // Auto-scroll the active suggestion into view when keyboard-navigated.
169 $effect(() => {
170 if (tagSugIndex >= 0 || uploaderSugIndex >= 0) {
171 const el = document.querySelector('.sug-opt.active') as HTMLElement | null;
172 if (el) el.scrollIntoView({ block: 'nearest' });
173 }
174 });
175</script>
176
177<div class="header">
178 <div class="header-top">
179 <h1>Browse</h1>
180 <span class="header-count">{total} {total === 1 ? 'image' : 'images'}</span>
181 </div>
182 <div class="header-line"></div>
183 <div class="controls">
184 <div class="sug-wrap">
185 <input
186 type="text"
187 placeholder="Filter by tag..."
188 bind:value={filterTag}
189 oninput={onTagInput}
190 onkeydown={(e) => {
191 if (e.key === 'Enter') {
192 if (tagSugIndex >= 0 && tagSugIndex < tagSuggestions.length) {
193 selectTagSug(tagSuggestions[tagSugIndex]);
194 } else {
195 applyFilter();
196 }
197 } else if (e.key === 'Tab' || e.key === 'ArrowDown') {
198 if (showTagSugs && tagSuggestions.length > 0) {
199 e.preventDefault();
200 tagSugIndex = (tagSugIndex + 1) % tagSuggestions.length;
201 }
202 } else if (e.key === 'ArrowUp') {
203 if (showTagSugs && tagSuggestions.length > 0) {
204 e.preventDefault();
205 tagSugIndex = tagSugIndex <= 0 ? tagSuggestions.length - 1 : tagSugIndex - 1;
206 }
207 } else if (e.key === 'Escape') {
208 showTagSugs = false;
209 tagSugIndex = -1;
210 }
211 }}
212 onblur={() => setTimeout(() => { showTagSugs = false; tagSugIndex = -1; }, 200)}
213 />
214 {#if showTagSugs}
215 <div class="sugs" role="listbox">
216 {#each tagSuggestions as s, i}
217 <button
218 type="button"
219 role="option"
220 aria-selected={i === tagSugIndex}
221 class="sug-opt"
222 class:active={i === tagSugIndex}
223 onmousedown={() => selectTagSug(s)}
224 >
225 <span class="sug-label">{s.tag}</span>
226 <span class="sug-count">{s.usage_count}</span>
227 </button>
228 {/each}
229 </div>
230 {/if}
231 </div>
232 <div class="sug-wrap">
233 <input
234 type="text"
235 placeholder="Filter by uploader..."
236 bind:value={filterUploader}
237 oninput={onUploaderInput}
238 onkeydown={(e) => {
239 if (e.key === 'Enter') {
240 if (uploaderSugIndex >= 0 && uploaderSugIndex < uploaderSuggestions.length) {
241 selectUploaderSug(uploaderSuggestions[uploaderSugIndex]);
242 } else {
243 applyFilter();
244 }
245 } else if (e.key === 'Tab' || e.key === 'ArrowDown') {
246 if (showUploaderSugs && uploaderSuggestions.length > 0) {
247 e.preventDefault();
248 uploaderSugIndex = (uploaderSugIndex + 1) % uploaderSuggestions.length;
249 }
250 } else if (e.key === 'ArrowUp') {
251 if (showUploaderSugs && uploaderSuggestions.length > 0) {
252 e.preventDefault();
253 uploaderSugIndex = uploaderSugIndex <= 0 ? uploaderSuggestions.length - 1 : uploaderSugIndex - 1;
254 }
255 } else if (e.key === 'Escape') {
256 showUploaderSugs = false;
257 uploaderSugIndex = -1;
258 }
259 }}
260 onblur={() => setTimeout(() => { showUploaderSugs = false; uploaderSugIndex = -1; }, 200)}
261 />
262 {#if showUploaderSugs}
263 <div class="sugs" role="listbox">
264 {#each uploaderSuggestions as s, i}
265 <button
266 type="button"
267 role="option"
268 aria-selected={i === uploaderSugIndex}
269 class="sug-opt"
270 class:active={i === uploaderSugIndex}
271 onmousedown={() => selectUploaderSug(s)}
272 >
273 <span class="sug-label">{s.tag}</span>
274 <span class="sug-count">{s.usage_count}</span>
275 </button>
276 {/each}
277 </div>
278 {/if}
279 </div>
280 <button class="btn" onclick={applyFilter}>FILTER</button>
281 {#if filterTag || filterUploader}
282 <button class="btn btn-clear" onclick={() => { filterTag = ''; filterUploader = ''; applyFilter(); }}>CLEAR</button>
283 {/if}
284 </div>
285</div>
286
287{#if loading}
288 <p class="state-msg">Loading...</p>
289{:else if error}
290 <p class="state-msg error">{error}</p>
291{:else if images.length === 0}
292 <div class="empty">
293 {#if filterTag || filterUploader}
294 <p>No results
295 {#if filterTag}for tag <strong>“{filterTag}”</strong>{/if}
296 {#if filterTag && filterUploader} and {/if}
297 {#if filterUploader}by uploader <strong>“{filterUploader}”</strong>{/if}.
298 </p>
299 {:else}
300 <p>The archive is empty.</p>
301 <a href="/upload" class="btn">UPLOAD MEDIA</a>
302 {/if}
303 </div>
304{:else}
305 <div class="grid">
306 {#each images as img, i (img.id)}
307 <button class="card" onclick={() => openViewer(i)}>
308 <div class="card-image">
309 {#if img.thumbnail_path}
310 <img src={thumbnailUrl(img.id)} alt={img.filename} loading="lazy" />
311 {:else}
312 <div class="placeholder">{img.mime_type}</div>
313 {/if}
314 {#if img.mime_type.startsWith('video/') && img.duration}
315 <div class="video-badge">
316 <svg class="video-icon" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><polygon points="6,4 20,12 6,20"/></svg>
317 <span class="video-duration">{formatDuration(img.duration)}</span>
318 </div>
319 {/if}
320 </div>
321 <div class="card-overlay">
322 <div class="card-title">{img.filename}</div>
323 <div class="card-user">
324 {#if img.avatar_url}
325 <img src={img.avatar_url} alt="" class="card-avatar" />
326 {/if}
327 #{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>
328 </div>
329 </div>
330 </button>
331 {/each}
332 </div>
333
334 <div class="pagination">
335 <button class="btn" onclick={prevPage} disabled={page <= 1}>PREVIOUS</button>
336 <span class="page-info">PAGE {page} OF {Math.ceil(total / perPage)}</span>
337 <button class="btn" onclick={nextPage} disabled={page * perPage >= total}>NEXT</button>
338 </div>
339{/if}
340
341{#if viewerIndex >= 0}
342 <ImageViewer
343 images={images}
344 initialIndex={viewerIndex}
345 onclose={closeViewer}
346 />
347{/if}
348
349<style>
350 .header {
351 padding: 1.5rem 1.5rem 0.75rem;
352 }
353 .header-top {
354 display: flex;
355 align-items: baseline;
356 gap: 1rem;
357 }
358 .header-top h1 {
359 margin: 0;
360 }
361 .header-count {
362 font-family: 'Oswald', sans-serif;
363 font-weight: 500;
364 font-size: 0.9rem;
365 letter-spacing: 0.1em;
366 color: #888;
367 text-transform: uppercase;
368 }
369 .header-line {
370 height: 3px;
371 background: #c62828;
372 width: 3rem;
373 margin: 0.5rem 0 1rem;
374 }
375 .controls {
376 display: flex;
377 gap: 0.5rem;
378 align-items: flex-start;
379 }
380 .sug-wrap {
381 position: relative;
382 flex: 1;
383 max-width: 280px;
384 }
385 .sug-wrap input {
386 width: 100%;
387 }
388 .controls input {
389 padding: 0.5rem 0.75rem;
390 border: 2px solid #1a1a1a;
391 border-radius: 0;
392 font-family: 'Inter', sans-serif;
393 font-size: 0.85rem;
394 font-weight: 500;
395 background: #fff;
396 }
397 .controls input:focus {
398 outline: none;
399 border-color: #c62828;
400 }
401
402 .sugs {
403 position: absolute;
404 top: 100%;
405 left: 0;
406 right: 0;
407 z-index: 20;
408 list-style: none;
409 margin: 0;
410 padding: 0;
411 background: #fff;
412 border: 2px solid #1a1a1a;
413 border-top: none;
414 max-height: 220px;
415 overflow-y: auto;
416 }
417 .sug-opt {
418 display: flex;
419 align-items: center;
420 justify-content: space-between;
421 width: 100%;
422 text-align: left;
423 padding: 0.45rem 0.75rem;
424 border: none;
425 background: none;
426 font-family: 'Inter', sans-serif;
427 font-size: 0.82rem;
428 font-weight: 500;
429 gap: 0.5rem;
430 cursor: pointer;
431 border-radius: 0;
432 }
433 .sug-opt:hover {
434 background: #f0f0f0;
435 }
436 .sug-opt.active {
437 background: #c62828;
438 color: #fff;
439 }
440 .sug-opt.active .sug-count {
441 color: rgba(255, 255, 255, 0.7);
442 }
443 .sug-label {
444 overflow: hidden;
445 text-overflow: ellipsis;
446 white-space: nowrap;
447 }
448 .sug-count {
449 flex-shrink: 0;
450 font-family: 'Oswald', sans-serif;
451 font-weight: 500;
452 font-size: 0.72rem;
453 letter-spacing: 0.05em;
454 color: #888;
455 }
456
457 .btn {
458 display: inline-flex;
459 align-items: center;
460 padding: 0.5rem 1.25rem;
461 background: #1a1a1a;
462 color: #fff;
463 border: none;
464 border-radius: 0;
465 font-family: 'Oswald', sans-serif;
466 font-weight: 500;
467 font-size: 0.85rem;
468 letter-spacing: 0.1em;
469 text-transform: uppercase;
470 cursor: pointer;
471 text-decoration: none;
472 transition: background 0.15s;
473 }
474 .btn:hover {
475 background: #c62828;
476 }
477 .btn:disabled {
478 opacity: 0.3;
479 cursor: default;
480 }
481 .btn-clear {
482 background: #666;
483 }
484 .btn-clear:hover {
485 background: #c62828;
486 }
487
488 .grid {
489 display: grid;
490 grid-template-columns: repeat(6, 1fr);
491 gap: 2px;
492 }
493
494 @media (max-width: 1200px) {
495 .grid { grid-template-columns: repeat(5, 1fr); }
496 }
497 @media (max-width: 1000px) {
498 .grid { grid-template-columns: repeat(4, 1fr); }
499 }
500 @media (max-width: 750px) {
501 .grid { grid-template-columns: repeat(3, 1fr); }
502 }
503 @media (max-width: 500px) {
504 .grid { grid-template-columns: repeat(2, 1fr); }
505 }
506
507 .card {
508 position: relative;
509 display: block;
510 width: 100%;
511 padding: 0;
512 border: none;
513 background: none;
514 font: inherit;
515 color: inherit;
516 cursor: pointer;
517 text-align: left;
518 overflow: hidden;
519 outline: none;
520 }
521 .card:focus-visible {
522 outline: 3px solid #c62828;
523 outline-offset: -3px;
524 }
525 .card-image {
526 position: relative;
527 overflow: hidden;
528 background: #1a1a1a;
529 aspect-ratio: 16 / 10;
530 }
531 .card-image img {
532 display: block;
533 width: 100%;
534 height: 100%;
535 object-fit: cover;
536 transition: transform 300ms ease, filter 300ms ease;
537 }
538 .video-badge {
539 position: absolute;
540 bottom: 0.4rem;
541 right: 0.4rem;
542 display: flex;
543 align-items: center;
544 gap: 0.25rem;
545 background: rgba(0, 0, 0, 0.75);
546 color: #fff;
547 padding: 0.15rem 0.45rem 0.15rem 0.35rem;
548 font-family: 'Inter', sans-serif;
549 font-size: 0.7rem;
550 font-weight: 600;
551 line-height: 1.4;
552 pointer-events: none;
553 }
554 .video-icon {
555 flex-shrink: 0;
556 opacity: 0.9;
557 }
558 .card:hover .card-image img {
559 transform: scale(1.08);
560 filter: grayscale(0.5) contrast(1.1);
561 }
562 .placeholder {
563 display: flex;
564 align-items: center;
565 justify-content: center;
566 width: 100%;
567 height: 100%;
568 color: #555;
569 font-size: 0.8rem;
570 font-weight: 600;
571 }
572
573 .card-overlay {
574 position: absolute;
575 bottom: 0;
576 left: 0;
577 right: 0;
578 background: linear-gradient(transparent 30%, #1a1a1a 95%);
579 padding: 2rem 0.6rem 0.5rem;
580 opacity: 0;
581 transition: opacity 200ms ease;
582 pointer-events: none;
583 }
584 .card:hover .card-overlay {
585 opacity: 1;
586 }
587 .card-title {
588 color: #fff;
589 font-family: 'Inter', sans-serif;
590 font-size: 0.78rem;
591 font-weight: 600;
592 white-space: nowrap;
593 overflow: hidden;
594 text-overflow: ellipsis;
595 }
596 .card-user {
597 color: rgba(255, 255, 255, 0.55);
598 font-family: 'Inter', sans-serif;
599 font-size: 0.68rem;
600 font-weight: 500;
601 margin-top: 0.05rem;
602 display: flex;
603 align-items: center;
604 gap: 0.3rem;
605 }
606 .card-avatar {
607 width: 1rem;
608 height: 1rem;
609 border-radius: 0;
610 object-fit: cover;
611 flex-shrink: 0;
612 }
613
614 .user-link {
615 background: none;
616 border: none;
617 padding: 0;
618 font: inherit;
619 color: rgba(255, 255, 255, 0.55);
620 cursor: pointer;
621 text-decoration: none;
622 pointer-events: auto;
623 }
624 .user-link:hover {
625 color: #ef5350;
626 text-decoration: underline;
627 }
628
629 .pagination {
630 display: flex;
631 align-items: center;
632 justify-content: center;
633 gap: 1rem;
634 padding: 2rem 1.5rem 3rem;
635 }
636 .page-info {
637 font-family: 'Oswald', sans-serif;
638 font-weight: 500;
639 font-size: 0.85rem;
640 letter-spacing: 0.12em;
641 color: #888;
642 }
643
644 .state-msg {
645 padding: 3rem 1.5rem;
646 font-size: 1.1rem;
647 color: #888;
648 }
649 .error {
650 color: #c62828;
651 }
652
653 .empty {
654 padding: 3rem 1.5rem;
655 display: flex;
656 flex-direction: column;
657 gap: 1rem;
658 align-items: flex-start;
659 }
660 .empty p {
661 font-size: 1.1rem;
662 color: #888;
663 }
664</style>