all repos — weimar @ 8d72190a752e3e1eaff992da40d75e01c6149993

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">
115						{#if img.avatar_url}
116							<img src={img.avatar_url} alt="" class="card-avatar" />
117						{/if}
118						#{img.id} &middot; {img.uploader_username}
119					</div>
120				</div>
121			</button>
122		{/each}
123	</div>
124
125	<div class="pagination">
126		<button class="btn" onclick={prevPage} disabled={page <= 1}>PREVIOUS</button>
127		<span class="page-info">PAGE {page} OF {Math.ceil(total / perPage)}</span>
128		<button class="btn" onclick={nextPage} disabled={page * perPage >= total}>NEXT</button>
129	</div>
130{/if}
131
132{#if viewerIndex >= 0}
133	<ImageViewer
134		images={images}
135		initialIndex={viewerIndex}
136		onclose={() => { viewerIndex = -1; }}
137	/>
138{/if}
139
140<style>
141	.header {
142		padding: 1.5rem 1.5rem 0.75rem;
143	}
144	.header-top {
145		display: flex;
146		align-items: baseline;
147		gap: 1rem;
148	}
149	.header-top h1 {
150		margin: 0;
151	}
152	.header-count {
153		font-family: 'Oswald', sans-serif;
154		font-weight: 500;
155		font-size: 0.9rem;
156		letter-spacing: 0.1em;
157		color: #888;
158		text-transform: uppercase;
159	}
160	.header-line {
161		height: 3px;
162		background: #c62828;
163		width: 3rem;
164		margin: 0.5rem 0 1rem;
165	}
166	.controls {
167		display: flex;
168		gap: 0.5rem;
169	}
170	.controls input {
171		padding: 0.5rem 0.75rem;
172		border: 2px solid #1a1a1a;
173		border-radius: 0;
174		font-family: 'Inter', sans-serif;
175		font-size: 0.85rem;
176		font-weight: 500;
177		flex: 1;
178		max-width: 280px;
179		background: #fff;
180	}
181	.controls input:focus {
182		outline: none;
183		border-color: #c62828;
184	}
185
186	.btn {
187		display: inline-flex;
188		align-items: center;
189		padding: 0.5rem 1.25rem;
190		background: #1a1a1a;
191		color: #fff;
192		border: none;
193		border-radius: 0;
194		font-family: 'Oswald', sans-serif;
195		font-weight: 500;
196		font-size: 0.85rem;
197		letter-spacing: 0.1em;
198		text-transform: uppercase;
199		cursor: pointer;
200		text-decoration: none;
201		transition: background 0.15s;
202	}
203	.btn:hover {
204		background: #c62828;
205	}
206	.btn:disabled {
207		opacity: 0.3;
208		cursor: default;
209	}
210	.btn-clear {
211		background: #666;
212	}
213	.btn-clear:hover {
214		background: #c62828;
215	}
216
217	.grid {
218		display: grid;
219		grid-template-columns: repeat(6, 1fr);
220		gap: 2px;
221	}
222
223	@media (max-width: 1200px) {
224		.grid { grid-template-columns: repeat(5, 1fr); }
225	}
226	@media (max-width: 1000px) {
227		.grid { grid-template-columns: repeat(4, 1fr); }
228	}
229	@media (max-width: 750px) {
230		.grid { grid-template-columns: repeat(3, 1fr); }
231	}
232	@media (max-width: 500px) {
233		.grid { grid-template-columns: repeat(2, 1fr); }
234	}
235
236	.card {
237		position: relative;
238		display: block;
239		width: 100%;
240		padding: 0;
241		border: none;
242		background: none;
243		font: inherit;
244		color: inherit;
245		cursor: pointer;
246		text-align: left;
247		overflow: hidden;
248		outline: none;
249	}
250	.card:focus-visible {
251		outline: 3px solid #c62828;
252		outline-offset: -3px;
253	}
254	.card-image {
255		position: relative;
256		overflow: hidden;
257		background: #1a1a1a;
258		aspect-ratio: 16 / 10;
259	}
260	.card-image img {
261		display: block;
262		width: 100%;
263		height: 100%;
264		object-fit: cover;
265		transition: transform 300ms ease, filter 300ms ease;
266	}
267	.card:hover .card-image img {
268		transform: scale(1.08);
269		filter: grayscale(0.5) contrast(1.1);
270	}
271	.placeholder {
272		display: flex;
273		align-items: center;
274		justify-content: center;
275		width: 100%;
276		height: 100%;
277		color: #555;
278		font-size: 0.8rem;
279		font-weight: 600;
280	}
281
282	.card-overlay {
283		position: absolute;
284		bottom: 0;
285		left: 0;
286		right: 0;
287		background: linear-gradient(transparent 30%, #1a1a1a 95%);
288		padding: 2rem 0.6rem 0.5rem;
289		opacity: 0;
290		transition: opacity 200ms ease;
291		pointer-events: none;
292	}
293	.card:hover .card-overlay {
294		opacity: 1;
295	}
296	.card-title {
297		color: #fff;
298		font-family: 'Inter', sans-serif;
299		font-size: 0.78rem;
300		font-weight: 600;
301		white-space: nowrap;
302		overflow: hidden;
303		text-overflow: ellipsis;
304	}
305	.card-user {
306		color: rgba(255, 255, 255, 0.55);
307		font-family: 'Inter', sans-serif;
308		font-size: 0.68rem;
309		font-weight: 500;
310		margin-top: 0.05rem;
311		display: flex;
312		align-items: center;
313		gap: 0.3rem;
314	}
315	.card-avatar {
316		width: 1rem;
317		height: 1rem;
318		border-radius: 0;
319		object-fit: cover;
320		flex-shrink: 0;
321	}
322
323	.pagination {
324		display: flex;
325		align-items: center;
326		justify-content: center;
327		gap: 1rem;
328		padding: 2rem 1.5rem 3rem;
329	}
330	.page-info {
331		font-family: 'Oswald', sans-serif;
332		font-weight: 500;
333		font-size: 0.85rem;
334		letter-spacing: 0.12em;
335		color: #888;
336	}
337
338	.state-msg {
339		padding: 3rem 1.5rem;
340		font-size: 1.1rem;
341		color: #888;
342	}
343	.error {
344		color: #c62828;
345	}
346
347	.empty {
348		padding: 3rem 1.5rem;
349		display: flex;
350		flex-direction: column;
351		gap: 1rem;
352		align-items: flex-start;
353	}
354	.empty p {
355		font-size: 1.1rem;
356		color: #888;
357	}
358</style>