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