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