all repos — weimar @ a338ad5884f38dd2d5e241df42fd565a97f27f72

Unnamed repository; edit this file 'description' to name the repository.

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 &ldquo;{filterTag}&rdquo;.</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>