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