all repos — weimar @ 74fc3e1e24b47e370bcf5ea6b4e016cb50630920

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