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