all repos — weimar @ a8921d226b7ab5b6d366d88c51d3308362388cf7

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	<div class="header-top">
 57		<h1>Browse</h1>
 58		<span class="header-count">{total} {total === 1 ? 'image' : 'images'}</span>
 59	</div>
 60	<div class="header-line"></div>
 61	<div class="controls">
 62		<input
 63			type="text"
 64			placeholder="Filter by tag..."
 65			bind:value={filterTag}
 66			onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }}
 67		/>
 68		<button class="btn" onclick={applyFilter}>FILTER</button>
 69		{#if filterTag}
 70			<button class="btn btn-clear" onclick={() => { filterTag = ''; applyFilter(); }}>CLEAR</button>
 71		{/if}
 72	</div>
 73</div>
 74
 75{#if loading}
 76	<p class="state-msg">Loading...</p>
 77{:else if error}
 78	<p class="state-msg error">{error}</p>
 79{:else if images.length === 0}
 80	<div class="empty">
 81		{#if filterTag}
 82			<p>No results for tag <strong>&ldquo;{filterTag}&rdquo;</strong>.</p>
 83		{:else}
 84			<p>The archive is empty.</p>
 85			<a href="/upload" class="btn">UPLOAD MEDIA</a>
 86		{/if}
 87	</div>
 88{:else}
 89	<div class="grid">
 90		{#each images as img, i (img.id)}
 91			<button class="card" onclick={() => openViewer(i)}>
 92				<div class="card-image">
 93					{#if img.thumbnail_path}
 94						<img src={thumbnailUrl(img.id)} alt={img.filename} loading="lazy" />
 95					{:else}
 96						<div class="placeholder">{img.mime_type}</div>
 97					{/if}
 98				</div>
 99				<div class="card-overlay">
100					<div class="card-title">{img.filename}</div>
101				</div>
102			</button>
103		{/each}
104	</div>
105
106	<div class="pagination">
107		<button class="btn" onclick={prevPage} disabled={page <= 1}>PREVIOUS</button>
108		<span class="page-info">PAGE {page} OF {Math.ceil(total / perPage)}</span>
109		<button class="btn" onclick={nextPage} disabled={page * perPage >= total}>NEXT</button>
110	</div>
111{/if}
112
113{#if viewerIndex >= 0}
114	<ImageViewer
115		images={images}
116		initialIndex={viewerIndex}
117		onclose={() => { viewerIndex = -1; }}
118	/>
119{/if}
120
121<style>
122	:global(main) {
123		padding: 0 !important;
124		max-width: none !important;
125	}
126
127	.header {
128		padding: 1.5rem 1.5rem 0.75rem;
129	}
130	.header-top {
131		display: flex;
132		align-items: baseline;
133		gap: 1rem;
134	}
135	.header-top h1 {
136		margin: 0;
137	}
138	.header-count {
139		font-family: 'Oswald', sans-serif;
140		font-weight: 500;
141		font-size: 0.9rem;
142		letter-spacing: 0.1em;
143		color: #888;
144		text-transform: uppercase;
145	}
146	.header-line {
147		height: 3px;
148		background: #c62828;
149		width: 3rem;
150		margin: 0.5rem 0 1rem;
151	}
152	.controls {
153		display: flex;
154		gap: 0.5rem;
155	}
156	.controls input {
157		padding: 0.5rem 0.75rem;
158		border: 2px solid #1a1a1a;
159		border-radius: 0;
160		font-family: 'Inter', sans-serif;
161		font-size: 0.85rem;
162		font-weight: 500;
163		flex: 1;
164		max-width: 280px;
165		background: #fff;
166	}
167	.controls input:focus {
168		outline: none;
169		border-color: #c62828;
170	}
171
172	.btn {
173		display: inline-flex;
174		align-items: center;
175		padding: 0.5rem 1.25rem;
176		background: #1a1a1a;
177		color: #fff;
178		border: none;
179		border-radius: 0;
180		font-family: 'Oswald', sans-serif;
181		font-weight: 500;
182		font-size: 0.85rem;
183		letter-spacing: 0.1em;
184		text-transform: uppercase;
185		cursor: pointer;
186		text-decoration: none;
187		transition: background 0.15s;
188	}
189	.btn:hover {
190		background: #c62828;
191	}
192	.btn:disabled {
193		opacity: 0.3;
194		cursor: default;
195	}
196	.btn-clear {
197		background: #666;
198	}
199	.btn-clear:hover {
200		background: #c62828;
201	}
202
203	.grid {
204		display: grid;
205		grid-template-columns: repeat(6, 1fr);
206		gap: 2px;
207	}
208
209	@media (max-width: 1200px) {
210		.grid { grid-template-columns: repeat(5, 1fr); }
211	}
212	@media (max-width: 1000px) {
213		.grid { grid-template-columns: repeat(4, 1fr); }
214	}
215	@media (max-width: 750px) {
216		.grid { grid-template-columns: repeat(3, 1fr); }
217	}
218	@media (max-width: 500px) {
219		.grid { grid-template-columns: repeat(2, 1fr); }
220	}
221
222	.card {
223		position: relative;
224		display: block;
225		width: 100%;
226		padding: 0;
227		border: none;
228		background: none;
229		font: inherit;
230		color: inherit;
231		cursor: pointer;
232		text-align: left;
233		overflow: hidden;
234		outline: none;
235	}
236	.card:focus-visible {
237		outline: 3px solid #c62828;
238		outline-offset: -3px;
239	}
240	.card-image {
241		position: relative;
242		overflow: hidden;
243		background: #1a1a1a;
244		aspect-ratio: 16 / 10;
245	}
246	.card-image img {
247		display: block;
248		width: 100%;
249		height: 100%;
250		object-fit: cover;
251		transition: transform 300ms ease, filter 300ms ease;
252	}
253	.card:hover .card-image img {
254		transform: scale(1.08);
255		filter: grayscale(0.5) contrast(1.1);
256	}
257	.placeholder {
258		display: flex;
259		align-items: center;
260		justify-content: center;
261		width: 100%;
262		height: 100%;
263		color: #555;
264		font-size: 0.8rem;
265		font-weight: 600;
266	}
267
268	.card-overlay {
269		position: absolute;
270		bottom: 0;
271		left: 0;
272		right: 0;
273		background: linear-gradient(transparent 30%, #1a1a1a 95%);
274		padding: 2rem 0.6rem 0.5rem;
275		opacity: 0;
276		transition: opacity 200ms ease;
277		pointer-events: none;
278	}
279	.card:hover .card-overlay {
280		opacity: 1;
281	}
282	.card-title {
283		color: #fff;
284		font-family: 'Inter', sans-serif;
285		font-size: 0.78rem;
286		font-weight: 600;
287		white-space: nowrap;
288		overflow: hidden;
289		text-overflow: ellipsis;
290	}
291
292	.pagination {
293		display: flex;
294		align-items: center;
295		justify-content: center;
296		gap: 1rem;
297		padding: 2rem 1.5rem 3rem;
298	}
299	.page-info {
300		font-family: 'Oswald', sans-serif;
301		font-weight: 500;
302		font-size: 0.85rem;
303		letter-spacing: 0.12em;
304		color: #888;
305	}
306
307	.state-msg {
308		padding: 3rem 1.5rem;
309		font-size: 1.1rem;
310		color: #888;
311	}
312	.error {
313		color: #c62828;
314	}
315
316	.empty {
317		padding: 3rem 1.5rem;
318		display: flex;
319		flex-direction: column;
320		gap: 1rem;
321		align-items: flex-start;
322	}
323	.empty p {
324		font-size: 1.1rem;
325		color: #888;
326	}
327</style>