all repos — weimar @ 3ab9da929b53350b9e5a860642317e5a8f5b3750

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