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