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