all repos — weimar @ 3d9ddf2add48689bb513ed4f59a427849dfe0805

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