all repos — weimar @ 4725091cf8486591001604eeabcb1c4632f7ab7f

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, 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 &ldquo;{filterTag}&rdquo;.</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}&times;{img.height}
 97						{#if img.tags.length > 0}
 98							&middot; {img.tags.slice(0, 3).join(', ')}
 99							{#if img.tags.length > 3}
100								&hellip;
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>