all repos — weimar @ 87601e8f1973d0b01106ea99ce63228c88130e1a

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