web/src/routes/browse/+page.svelte (view raw)
1<script lang="ts">
2 import { onMount, onDestroy } from 'svelte';
3 import { listImages, thumbnailUrl, type Image } 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 onMount(() => {
18 const mainEl = document.querySelector('.main') as HTMLElement;
19 if (mainEl) {
20 mainEl.style.padding = '0';
21 mainEl.style.maxWidth = 'none';
22 }
23 const params = new URLSearchParams(window.location.search);
24 filterTag = params.get('tag') || '';
25 filterUploader = params.get('uploader') || '';
26 loadImages().then(() => {
27 const imageParam = params.get('image');
28 if (imageParam) {
29 const idx = images.findIndex((img) => String(img.id) === imageParam);
30 if (idx !== -1) {
31 viewerIndex = idx;
32 } else {
33 goto('/image/' + imageParam);
34 }
35 }
36 });
37 });
38
39 onDestroy(() => {
40 const mainEl = document.querySelector('.main') as HTMLElement;
41 if (mainEl) {
42 mainEl.style.padding = '';
43 mainEl.style.maxWidth = '';
44 }
45 });
46
47 async function loadImages() {
48 loading = true;
49 error = '';
50 try {
51 const tags = filterTag ? [filterTag] : [];
52 const result = await listImages(tags, filterUploader, page, perPage);
53 images = result.images;
54 total = result.total;
55 } catch (err) {
56 error = err instanceof Error ? err.message : 'Failed to load images';
57 images = [];
58 } finally {
59 loading = false;
60 }
61 }
62
63 function prevPage() {
64 if (page > 1) { page--; loadImages(); }
65 }
66
67 function nextPage() {
68 if (page * perPage < total) { page++; loadImages(); }
69 }
70
71 function applyFilter() {
72 page = 1;
73 loadImages();
74 }
75
76 function openViewer(index: number) {
77 viewerIndex = index;
78 history.replaceState(null, '', '/browse?image=' + images[index].id);
79 }
80
81 function closeViewer() {
82 viewerIndex = -1;
83 const params = new URLSearchParams();
84 if (filterTag) params.set('tag', filterTag);
85 if (filterUploader) params.set('uploader', filterUploader);
86 history.replaceState(null, '', params.toString() ? '/browse?' + params.toString() : '/browse');
87 }
88</script>
89
90<div class="header">
91 <div class="header-top">
92 <h1>Browse</h1>
93 <span class="header-count">{total} {total === 1 ? 'image' : 'images'}</span>
94 </div>
95 <div class="header-line"></div>
96 <div class="controls">
97 <input
98 type="text"
99 placeholder="Filter by tag..."
100 bind:value={filterTag}
101 onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }}
102 />
103 <input
104 type="text"
105 placeholder="Filter by uploader..."
106 bind:value={filterUploader}
107 onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }}
108 />
109 <button class="btn" onclick={applyFilter}>FILTER</button>
110 {#if filterTag || filterUploader}
111 <button class="btn btn-clear" onclick={() => { filterTag = ''; filterUploader = ''; applyFilter(); }}>CLEAR</button>
112 {/if}
113 </div>
114</div>
115
116{#if loading}
117 <p class="state-msg">Loading...</p>
118{:else if error}
119 <p class="state-msg error">{error}</p>
120{:else if images.length === 0}
121 <div class="empty">
122 {#if filterTag || filterUploader}
123 <p>No results
124 {#if filterTag}for tag <strong>“{filterTag}”</strong>{/if}
125 {#if filterTag && filterUploader} and {/if}
126 {#if filterUploader}by uploader <strong>“{filterUploader}”</strong>{/if}.
127 </p>
128 {:else}
129 <p>The archive is empty.</p>
130 <a href="/upload" class="btn">UPLOAD MEDIA</a>
131 {/if}
132 </div>
133{:else}
134 <div class="grid">
135 {#each images as img, i (img.id)}
136 <button class="card" onclick={() => openViewer(i)}>
137 <div class="card-image">
138 {#if img.thumbnail_path}
139 <img src={thumbnailUrl(img.id)} alt={img.filename} loading="lazy" />
140 {:else}
141 <div class="placeholder">{img.mime_type}</div>
142 {/if}
143 </div>
144 <div class="card-overlay">
145 <div class="card-title">{img.filename}</div>
146 <div class="card-user">
147 {#if img.avatar_url}
148 <img src={img.avatar_url} alt="" class="card-avatar" />
149 {/if}
150 #{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>
151 </div>
152 </div>
153 </button>
154 {/each}
155 </div>
156
157 <div class="pagination">
158 <button class="btn" onclick={prevPage} disabled={page <= 1}>PREVIOUS</button>
159 <span class="page-info">PAGE {page} OF {Math.ceil(total / perPage)}</span>
160 <button class="btn" onclick={nextPage} disabled={page * perPage >= total}>NEXT</button>
161 </div>
162{/if}
163
164{#if viewerIndex >= 0}
165 <ImageViewer
166 images={images}
167 initialIndex={viewerIndex}
168 onclose={closeViewer}
169 />
170{/if}
171
172<style>
173 .header {
174 padding: 1.5rem 1.5rem 0.75rem;
175 }
176 .header-top {
177 display: flex;
178 align-items: baseline;
179 gap: 1rem;
180 }
181 .header-top h1 {
182 margin: 0;
183 }
184 .header-count {
185 font-family: 'Oswald', sans-serif;
186 font-weight: 500;
187 font-size: 0.9rem;
188 letter-spacing: 0.1em;
189 color: #888;
190 text-transform: uppercase;
191 }
192 .header-line {
193 height: 3px;
194 background: #c62828;
195 width: 3rem;
196 margin: 0.5rem 0 1rem;
197 }
198 .controls {
199 display: flex;
200 gap: 0.5rem;
201 }
202 .controls input {
203 padding: 0.5rem 0.75rem;
204 border: 2px solid #1a1a1a;
205 border-radius: 0;
206 font-family: 'Inter', sans-serif;
207 font-size: 0.85rem;
208 font-weight: 500;
209 flex: 1;
210 max-width: 280px;
211 background: #fff;
212 }
213 .controls input:focus {
214 outline: none;
215 border-color: #c62828;
216 }
217
218 .btn {
219 display: inline-flex;
220 align-items: center;
221 padding: 0.5rem 1.25rem;
222 background: #1a1a1a;
223 color: #fff;
224 border: none;
225 border-radius: 0;
226 font-family: 'Oswald', sans-serif;
227 font-weight: 500;
228 font-size: 0.85rem;
229 letter-spacing: 0.1em;
230 text-transform: uppercase;
231 cursor: pointer;
232 text-decoration: none;
233 transition: background 0.15s;
234 }
235 .btn:hover {
236 background: #c62828;
237 }
238 .btn:disabled {
239 opacity: 0.3;
240 cursor: default;
241 }
242 .btn-clear {
243 background: #666;
244 }
245 .btn-clear:hover {
246 background: #c62828;
247 }
248
249 .grid {
250 display: grid;
251 grid-template-columns: repeat(6, 1fr);
252 gap: 2px;
253 }
254
255 @media (max-width: 1200px) {
256 .grid { grid-template-columns: repeat(5, 1fr); }
257 }
258 @media (max-width: 1000px) {
259 .grid { grid-template-columns: repeat(4, 1fr); }
260 }
261 @media (max-width: 750px) {
262 .grid { grid-template-columns: repeat(3, 1fr); }
263 }
264 @media (max-width: 500px) {
265 .grid { grid-template-columns: repeat(2, 1fr); }
266 }
267
268 .card {
269 position: relative;
270 display: block;
271 width: 100%;
272 padding: 0;
273 border: none;
274 background: none;
275 font: inherit;
276 color: inherit;
277 cursor: pointer;
278 text-align: left;
279 overflow: hidden;
280 outline: none;
281 }
282 .card:focus-visible {
283 outline: 3px solid #c62828;
284 outline-offset: -3px;
285 }
286 .card-image {
287 position: relative;
288 overflow: hidden;
289 background: #1a1a1a;
290 aspect-ratio: 16 / 10;
291 }
292 .card-image img {
293 display: block;
294 width: 100%;
295 height: 100%;
296 object-fit: cover;
297 transition: transform 300ms ease, filter 300ms ease;
298 }
299 .card:hover .card-image img {
300 transform: scale(1.08);
301 filter: grayscale(0.5) contrast(1.1);
302 }
303 .placeholder {
304 display: flex;
305 align-items: center;
306 justify-content: center;
307 width: 100%;
308 height: 100%;
309 color: #555;
310 font-size: 0.8rem;
311 font-weight: 600;
312 }
313
314 .card-overlay {
315 position: absolute;
316 bottom: 0;
317 left: 0;
318 right: 0;
319 background: linear-gradient(transparent 30%, #1a1a1a 95%);
320 padding: 2rem 0.6rem 0.5rem;
321 opacity: 0;
322 transition: opacity 200ms ease;
323 pointer-events: none;
324 }
325 .card:hover .card-overlay {
326 opacity: 1;
327 }
328 .card-title {
329 color: #fff;
330 font-family: 'Inter', sans-serif;
331 font-size: 0.78rem;
332 font-weight: 600;
333 white-space: nowrap;
334 overflow: hidden;
335 text-overflow: ellipsis;
336 }
337 .card-user {
338 color: rgba(255, 255, 255, 0.55);
339 font-family: 'Inter', sans-serif;
340 font-size: 0.68rem;
341 font-weight: 500;
342 margin-top: 0.05rem;
343 display: flex;
344 align-items: center;
345 gap: 0.3rem;
346 }
347 .card-avatar {
348 width: 1rem;
349 height: 1rem;
350 border-radius: 0;
351 object-fit: cover;
352 flex-shrink: 0;
353 }
354
355 .user-link {
356 background: none;
357 border: none;
358 padding: 0;
359 font: inherit;
360 color: rgba(255, 255, 255, 0.55);
361 cursor: pointer;
362 text-decoration: none;
363 pointer-events: auto;
364 }
365 .user-link:hover {
366 color: #ef5350;
367 text-decoration: underline;
368 }
369
370 .pagination {
371 display: flex;
372 align-items: center;
373 justify-content: center;
374 gap: 1rem;
375 padding: 2rem 1.5rem 3rem;
376 }
377 .page-info {
378 font-family: 'Oswald', sans-serif;
379 font-weight: 500;
380 font-size: 0.85rem;
381 letter-spacing: 0.12em;
382 color: #888;
383 }
384
385 .state-msg {
386 padding: 3rem 1.5rem;
387 font-size: 1.1rem;
388 color: #888;
389 }
390 .error {
391 color: #c62828;
392 }
393
394 .empty {
395 padding: 3rem 1.5rem;
396 display: flex;
397 flex-direction: column;
398 gap: 1rem;
399 align-items: flex-start;
400 }
401 .empty p {
402 font-size: 1.1rem;
403 color: #888;
404 }
405</style>