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 <h1>Browse</h1>
57 <div class="controls">
58 <input
59 type="text"
60 placeholder="Filter by tag..."
61 bind:value={filterTag}
62 onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }}
63 />
64 <button onclick={applyFilter}>Filter</button>
65 {#if filterTag}
66 <button class="clear" onclick={() => { filterTag = ''; applyFilter(); }}>Clear</button>
67 {/if}
68 </div>
69</div>
70
71{#if loading}
72 <p class="empty-state">Loading...</p>
73{:else if error}
74 <p class="empty-state error">{error}</p>
75{:else if images.length === 0}
76 {#if filterTag}
77 <p class="empty-state">No results found for tag “{filterTag}”.</p>
78 {:else}
79 <p class="empty-state">No images yet. <a href="/upload">Upload one!</a></p>
80 {/if}
81{:else}
82 <div class="grid">
83 {#each images as img, i (img.id)}
84 <button class="card" onclick={() => openViewer(i)}>
85 <div class="card-image">
86 {#if img.thumbnail_path}
87 <img src={thumbnailUrl(img.id)} alt={img.filename} loading="lazy" />
88 {:else}
89 <div class="placeholder">{img.mime_type}</div>
90 {/if}
91 </div>
92 <div class="card-overlay">
93 <div class="card-title">{img.filename}</div>
94 </div>
95 </button>
96 {/each}
97 </div>
98
99 <div class="pagination">
100 <button onclick={prevPage} disabled={page <= 1}>Previous</button>
101 <span>Page {page} of {Math.ceil(total / perPage)} ({total} total)</span>
102 <button onclick={nextPage} disabled={page * perPage >= total}>Next</button>
103 </div>
104{/if}
105
106{#if viewerIndex >= 0}
107 <ImageViewer
108 images={images}
109 initialIndex={viewerIndex}
110 onclose={() => { viewerIndex = -1; }}
111 />
112{/if}
113
114<style>
115 :global(main) {
116 padding: 0 !important;
117 max-width: none !important;
118 }
119
120 .header {
121 padding: 1.5rem 1.5rem 0.75rem;
122 }
123 .header h1 {
124 margin: 0 0 0.5rem;
125 }
126 .controls {
127 display: flex;
128 gap: 0.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(6, 1fr);
152 gap: 2px;
153 }
154
155 @media (max-width: 1200px) {
156 .grid { grid-template-columns: repeat(5, 1fr); }
157 }
158 @media (max-width: 1000px) {
159 .grid { grid-template-columns: repeat(4, 1fr); }
160 }
161 @media (max-width: 750px) {
162 .grid { grid-template-columns: repeat(3, 1fr); }
163 }
164 @media (max-width: 500px) {
165 .grid { grid-template-columns: repeat(2, 1fr); }
166 }
167
168 .card {
169 position: relative;
170 display: block;
171 width: 100%;
172 padding: 0;
173 border: none;
174 background: none;
175 font: inherit;
176 color: inherit;
177 cursor: pointer;
178 text-align: left;
179 overflow: hidden;
180 }
181 .card:focus-visible {
182 outline: 2px solid #1a1a2e;
183 outline-offset: -2px;
184 }
185 .card-image {
186 position: relative;
187 overflow: hidden;
188 background: #222;
189 aspect-ratio: 16 / 10;
190 }
191 .card-image img {
192 display: block;
193 width: 100%;
194 height: 100%;
195 object-fit: cover;
196 transition: transform 200ms ease;
197 }
198 .card:hover .card-image img {
199 transform: scale(1.05);
200 }
201 .placeholder {
202 display: flex;
203 align-items: center;
204 justify-content: center;
205 width: 100%;
206 height: 100%;
207 color: #666;
208 font-size: 0.85rem;
209 }
210
211 .card-overlay {
212 position: absolute;
213 bottom: 0;
214 left: 0;
215 right: 0;
216 background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
217 padding: 2rem 0.6rem 0.5rem;
218 opacity: 0;
219 transition: opacity 200ms ease;
220 pointer-events: none;
221 }
222 .card:hover .card-overlay {
223 opacity: 1;
224 }
225 .card-title {
226 color: #fff;
227 font-size: 0.8rem;
228 font-weight: 600;
229 white-space: nowrap;
230 overflow: hidden;
231 text-overflow: ellipsis;
232 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
233 }
234
235 .pagination {
236 display: flex;
237 align-items: center;
238 justify-content: center;
239 gap: 1rem;
240 padding: 2rem 1.5rem;
241 }
242 .pagination button {
243 padding: 0.5rem 1rem;
244 background: #1a1a2e;
245 color: #eee;
246 border: none;
247 border-radius: 4px;
248 cursor: pointer;
249 }
250 .pagination button:disabled {
251 opacity: 0.4;
252 cursor: default;
253 }
254
255 .empty-state {
256 padding: 2rem 1.5rem;
257 }
258 .error {
259 color: #c0392b;
260 }
261</style>