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