web/src/routes/browse/+page.svelte (view raw)
1<script lang="ts">
2 import { onMount } from 'svelte';
3 import { listImages, thumbnailUrl, imageDownloadUrl, 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 = 50;
14
15 onMount(() => loadImages());
16
17 async function loadImages() {
18 loading = true;
19 error = '';
20 try {
21 const tags = filterTag ? [filterTag] : [];
22 const result = await listImages(tags, page, perPage);
23 images = result.images;
24 total = result.total;
25 } catch (err) {
26 error = err instanceof Error ? err.message : 'Failed to load images';
27 images = [];
28 } finally {
29 loading = false;
30 }
31 }
32
33 function prevPage() {
34 if (page > 1) { page--; loadImages(); }
35 }
36
37 function nextPage() {
38 if (page * perPage < total) { page++; loadImages(); }
39 }
40
41 function applyFilter() {
42 page = 1;
43 loadImages();
44 }
45
46 function openViewer(index: number) {
47 viewerIndex = index;
48 }
49
50 function formatSize(bytes: number): string {
51 if (bytes < 1024) return bytes + ' B';
52 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
53 return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
54 }
55</script>
56
57<h1>Browse</h1>
58
59<div class="controls">
60 <input
61 type="text"
62 placeholder="Filter by tag..."
63 bind:value={filterTag}
64 onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }}
65 />
66 <button onclick={applyFilter}>Filter</button>
67 {#if filterTag}
68 <button class="clear" onclick={() => { filterTag = ''; applyFilter(); }}>Clear</button>
69 {/if}
70</div>
71
72{#if loading}
73 <p>Loading...</p>
74{:else if error}
75 <p class="error">{error}</p>
76{:else if images.length === 0}
77 {#if filterTag}
78 <p>No results found for tag “{filterTag}”.</p>
79 {:else}
80 <p>No images yet. <a href="/upload">Upload one!</a></p>
81 {/if}
82{:else}
83 <div class="grid">
84 {#each images as img, i (img.id)}
85 <button class="card" onclick={() => openViewer(i)}>
86 <div class="card-image">
87 {#if img.thumbnail_path}
88 <img src={thumbnailUrl(img.id)} alt={img.filename} loading="lazy" />
89 {:else}
90 <div class="placeholder">{img.mime_type}</div>
91 {/if}
92 </div>
93 <div class="card-overlay">
94 <div class="card-title">{img.filename}</div>
95 <div class="card-meta">
96 {img.width}×{img.height}
97 {#if img.tags.length > 0}
98 · {img.tags.slice(0, 3).join(', ')}
99 {#if img.tags.length > 3}
100 …
101 {/if}
102 {/if}
103 </div>
104 </div>
105 </button>
106 {/each}
107 </div>
108
109 <div class="pagination">
110 <button onclick={prevPage} disabled={page <= 1}>Previous</button>
111 <span>Page {page} of {Math.ceil(total / perPage)} ({total} total)</span>
112 <button onclick={nextPage} disabled={page * perPage >= total}>Next</button>
113 </div>
114{/if}
115
116{#if viewerIndex >= 0}
117 <ImageViewer
118 images={images}
119 initialIndex={viewerIndex}
120 onclose={() => { viewerIndex = -1; }}
121 />
122{/if}
123
124<style>
125 .controls {
126 display: flex;
127 gap: 0.5rem;
128 margin-bottom: 1.5rem;
129 }
130 .controls input {
131 padding: 0.5rem;
132 border: 1px solid #ccc;
133 border-radius: 4px;
134 flex: 1;
135 max-width: 300px;
136 }
137 .controls button {
138 padding: 0.5rem 1rem;
139 background: #1a1a2e;
140 color: #eee;
141 border: none;
142 border-radius: 4px;
143 cursor: pointer;
144 }
145 .controls button.clear {
146 background: #666;
147 }
148
149 .grid {
150 display: grid;
151 grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
152 gap: 2px;
153 }
154
155 .card {
156 position: relative;
157 display: block;
158 width: 100%;
159 padding: 0;
160 border: none;
161 background: none;
162 font: inherit;
163 color: inherit;
164 cursor: pointer;
165 text-align: left;
166 overflow: hidden;
167 }
168 .card:focus-visible {
169 outline: 2px solid #1a1a2e;
170 outline-offset: -2px;
171 }
172 .card-image {
173 position: relative;
174 overflow: hidden;
175 background: #222;
176 aspect-ratio: 16 / 10;
177 display: flex;
178 align-items: center;
179 justify-content: center;
180 }
181 .card-image img {
182 display: block;
183 width: 100%;
184 height: 100%;
185 object-fit: cover;
186 transition: transform 200ms ease;
187 }
188 .card:hover .card-image img {
189 transform: scale(1.05);
190 }
191 .placeholder {
192 display: flex;
193 align-items: center;
194 justify-content: center;
195 width: 100%;
196 height: 100%;
197 color: #666;
198 font-size: 0.85rem;
199 }
200
201 .card-overlay {
202 position: absolute;
203 bottom: 0;
204 left: 0;
205 right: 0;
206 background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
207 padding: 2rem 0.75rem 0.6rem;
208 opacity: 0;
209 transition: opacity 200ms ease;
210 pointer-events: none;
211 }
212 .card:hover .card-overlay {
213 opacity: 1;
214 }
215 .card-title {
216 color: #fff;
217 font-size: 0.85rem;
218 font-weight: 600;
219 white-space: nowrap;
220 overflow: hidden;
221 text-overflow: ellipsis;
222 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
223 }
224 .card-meta {
225 color: rgba(255, 255, 255, 0.7);
226 font-size: 0.75rem;
227 margin-top: 0.1rem;
228 white-space: nowrap;
229 overflow: hidden;
230 text-overflow: ellipsis;
231 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
232 }
233
234 .pagination {
235 display: flex;
236 align-items: center;
237 justify-content: center;
238 gap: 1rem;
239 margin-top: 2rem;
240 }
241 .pagination button {
242 padding: 0.5rem 1rem;
243 background: #1a1a2e;
244 color: #eee;
245 border: none;
246 border-radius: 4px;
247 cursor: pointer;
248 }
249 .pagination button:disabled {
250 opacity: 0.4;
251 cursor: default;
252 }
253 .error {
254 color: #c0392b;
255 }
256</style>