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">#{img.id} · {img.uploader_username}</div>
115 </div>
116 </button>
117 {/each}
118 </div>
119
120 <div class="pagination">
121 <button class="btn" onclick={prevPage} disabled={page <= 1}>PREVIOUS</button>
122 <span class="page-info">PAGE {page} OF {Math.ceil(total / perPage)}</span>
123 <button class="btn" onclick={nextPage} disabled={page * perPage >= total}>NEXT</button>
124 </div>
125{/if}
126
127{#if viewerIndex >= 0}
128 <ImageViewer
129 images={images}
130 initialIndex={viewerIndex}
131 onclose={() => { viewerIndex = -1; }}
132 />
133{/if}
134
135<style>
136 .header {
137 padding: 1.5rem 1.5rem 0.75rem;
138 }
139 .header-top {
140 display: flex;
141 align-items: baseline;
142 gap: 1rem;
143 }
144 .header-top h1 {
145 margin: 0;
146 }
147 .header-count {
148 font-family: 'Oswald', sans-serif;
149 font-weight: 500;
150 font-size: 0.9rem;
151 letter-spacing: 0.1em;
152 color: #888;
153 text-transform: uppercase;
154 }
155 .header-line {
156 height: 3px;
157 background: #c62828;
158 width: 3rem;
159 margin: 0.5rem 0 1rem;
160 }
161 .controls {
162 display: flex;
163 gap: 0.5rem;
164 }
165 .controls input {
166 padding: 0.5rem 0.75rem;
167 border: 2px solid #1a1a1a;
168 border-radius: 0;
169 font-family: 'Inter', sans-serif;
170 font-size: 0.85rem;
171 font-weight: 500;
172 flex: 1;
173 max-width: 280px;
174 background: #fff;
175 }
176 .controls input:focus {
177 outline: none;
178 border-color: #c62828;
179 }
180
181 .btn {
182 display: inline-flex;
183 align-items: center;
184 padding: 0.5rem 1.25rem;
185 background: #1a1a1a;
186 color: #fff;
187 border: none;
188 border-radius: 0;
189 font-family: 'Oswald', sans-serif;
190 font-weight: 500;
191 font-size: 0.85rem;
192 letter-spacing: 0.1em;
193 text-transform: uppercase;
194 cursor: pointer;
195 text-decoration: none;
196 transition: background 0.15s;
197 }
198 .btn:hover {
199 background: #c62828;
200 }
201 .btn:disabled {
202 opacity: 0.3;
203 cursor: default;
204 }
205 .btn-clear {
206 background: #666;
207 }
208 .btn-clear:hover {
209 background: #c62828;
210 }
211
212 .grid {
213 display: grid;
214 grid-template-columns: repeat(6, 1fr);
215 gap: 2px;
216 }
217
218 @media (max-width: 1200px) {
219 .grid { grid-template-columns: repeat(5, 1fr); }
220 }
221 @media (max-width: 1000px) {
222 .grid { grid-template-columns: repeat(4, 1fr); }
223 }
224 @media (max-width: 750px) {
225 .grid { grid-template-columns: repeat(3, 1fr); }
226 }
227 @media (max-width: 500px) {
228 .grid { grid-template-columns: repeat(2, 1fr); }
229 }
230
231 .card {
232 position: relative;
233 display: block;
234 width: 100%;
235 padding: 0;
236 border: none;
237 background: none;
238 font: inherit;
239 color: inherit;
240 cursor: pointer;
241 text-align: left;
242 overflow: hidden;
243 outline: none;
244 }
245 .card:focus-visible {
246 outline: 3px solid #c62828;
247 outline-offset: -3px;
248 }
249 .card-image {
250 position: relative;
251 overflow: hidden;
252 background: #1a1a1a;
253 aspect-ratio: 16 / 10;
254 }
255 .card-image img {
256 display: block;
257 width: 100%;
258 height: 100%;
259 object-fit: cover;
260 transition: transform 300ms ease, filter 300ms ease;
261 }
262 .card:hover .card-image img {
263 transform: scale(1.08);
264 filter: grayscale(0.5) contrast(1.1);
265 }
266 .placeholder {
267 display: flex;
268 align-items: center;
269 justify-content: center;
270 width: 100%;
271 height: 100%;
272 color: #555;
273 font-size: 0.8rem;
274 font-weight: 600;
275 }
276
277 .card-overlay {
278 position: absolute;
279 bottom: 0;
280 left: 0;
281 right: 0;
282 background: linear-gradient(transparent 30%, #1a1a1a 95%);
283 padding: 2rem 0.6rem 0.5rem;
284 opacity: 0;
285 transition: opacity 200ms ease;
286 pointer-events: none;
287 }
288 .card:hover .card-overlay {
289 opacity: 1;
290 }
291 .card-title {
292 color: #fff;
293 font-family: 'Inter', sans-serif;
294 font-size: 0.78rem;
295 font-weight: 600;
296 white-space: nowrap;
297 overflow: hidden;
298 text-overflow: ellipsis;
299 }
300 .card-user {
301 color: rgba(255, 255, 255, 0.55);
302 font-family: 'Inter', sans-serif;
303 font-size: 0.68rem;
304 font-weight: 500;
305 margin-top: 0.05rem;
306 }
307
308 .pagination {
309 display: flex;
310 align-items: center;
311 justify-content: center;
312 gap: 1rem;
313 padding: 2rem 1.5rem 3rem;
314 }
315 .page-info {
316 font-family: 'Oswald', sans-serif;
317 font-weight: 500;
318 font-size: 0.85rem;
319 letter-spacing: 0.12em;
320 color: #888;
321 }
322
323 .state-msg {
324 padding: 3rem 1.5rem;
325 font-size: 1.1rem;
326 color: #888;
327 }
328 .error {
329 color: #c62828;
330 }
331
332 .empty {
333 padding: 3rem 1.5rem;
334 display: flex;
335 flex-direction: column;
336 gap: 1rem;
337 align-items: flex-start;
338 }
339 .empty p {
340 font-size: 1.1rem;
341 color: #888;
342 }
343</style>