all repos — weimar @ fc3b0eba4ae4cb2cc5f9cc7304aa876b090021cf

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, suggestTags, suggestUploaders, thumbnailUrl, type Image, type Tag } 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	let tagSuggestions = $state<Tag[]>([]);
 18	let uploaderSuggestions = $state<Tag[]>([]);
 19	let showTagSugs = $state(false);
 20	let showUploaderSugs = $state(false);
 21	let sugTimer: ReturnType<typeof setTimeout> | undefined;
 22
 23	onMount(() => {
 24		const mainEl = document.querySelector('.main') as HTMLElement;
 25		if (mainEl) {
 26			mainEl.style.padding = '0';
 27			mainEl.style.maxWidth = 'none';
 28		}
 29		const params = new URLSearchParams(window.location.search);
 30		filterTag = params.get('tag') || '';
 31		filterUploader = params.get('uploader') || '';
 32		loadImages().then(() => {
 33			const imageParam = params.get('image');
 34			if (imageParam) {
 35				const idx = images.findIndex((img) => String(img.id) === imageParam);
 36				if (idx !== -1) {
 37					viewerIndex = idx;
 38				} else {
 39					goto('/image/' + imageParam);
 40				}
 41			}
 42		});
 43	});
 44
 45	onDestroy(() => {
 46		const mainEl = document.querySelector('.main') as HTMLElement;
 47		if (mainEl) {
 48			mainEl.style.padding = '';
 49			mainEl.style.maxWidth = '';
 50		}
 51		clearTimeout(sugTimer);
 52	});
 53
 54	async function loadImages() {
 55		loading = true;
 56		error = '';
 57		try {
 58			const tags = filterTag ? [filterTag] : [];
 59			const result = await listImages(tags, filterUploader, page, perPage);
 60			images = result.images;
 61			total = result.total;
 62		} catch (err) {
 63			error = err instanceof Error ? err.message : 'Failed to load images';
 64			images = [];
 65		} finally {
 66			loading = false;
 67		}
 68	}
 69
 70	function prevPage() {
 71		if (page > 1) { page--; loadImages(); }
 72	}
 73
 74	function nextPage() {
 75		if (page * perPage < total) { page++; loadImages(); }
 76	}
 77
 78	function applyFilter() {
 79		page = 1;
 80		hideSugs();
 81		loadImages();
 82	}
 83
 84	function hideSugs() {
 85		showTagSugs = false;
 86		showUploaderSugs = false;
 87	}
 88
 89	function onTagInput() {
 90		clearTimeout(sugTimer);
 91		const q = filterTag.trim();
 92		if (q.length < 1) {
 93			tagSuggestions = [];
 94			showTagSugs = false;
 95			return;
 96		}
 97		sugTimer = setTimeout(async () => {
 98			try {
 99				tagSuggestions = await suggestTags(q);
100				showTagSugs = tagSuggestions.length > 0;
101			} catch {
102				tagSuggestions = [];
103				showTagSugs = false;
104			}
105		}, 200);
106	}
107
108	function onUploaderInput() {
109		clearTimeout(sugTimer);
110		const q = filterUploader.trim();
111		if (q.length < 1) {
112			uploaderSuggestions = [];
113			showUploaderSugs = false;
114			return;
115		}
116		sugTimer = setTimeout(async () => {
117			try {
118				uploaderSuggestions = await suggestUploaders(q);
119				showUploaderSugs = uploaderSuggestions.length > 0;
120			} catch {
121				uploaderSuggestions = [];
122				showUploaderSugs = false;
123			}
124		}, 200);
125	}
126
127	function selectTagSug(s: Tag) {
128		filterTag = s.tag;
129		applyFilter();
130	}
131
132	function selectUploaderSug(s: Tag) {
133		filterUploader = s.tag;
134		applyFilter();
135	}
136
137	function openViewer(index: number) {
138		viewerIndex = index;
139		history.replaceState(null, '', '/browse?image=' + images[index].id);
140	}
141
142	function closeViewer() {
143		viewerIndex = -1;
144		const params = new URLSearchParams();
145		if (filterTag) params.set('tag', filterTag);
146		if (filterUploader) params.set('uploader', filterUploader);
147		history.replaceState(null, '', params.toString() ? '/browse?' + params.toString() : '/browse');
148	}
149</script>
150
151<div class="header">
152	<div class="header-top">
153		<h1>Browse</h1>
154		<span class="header-count">{total} {total === 1 ? 'image' : 'images'}</span>
155	</div>
156	<div class="header-line"></div>
157	<div class="controls">
158		<div class="sug-wrap">
159			<input
160				type="text"
161				placeholder="Filter by tag..."
162				bind:value={filterTag}
163				oninput={onTagInput}
164				onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }}
165				onblur={() => setTimeout(() => { showTagSugs = false; }, 200)}
166			/>
167			{#if showTagSugs}
168				<div class="sugs" role="listbox">
169					{#each tagSuggestions as s}
170						<button type="button" role="option" aria-selected={false} class="sug-opt" onmousedown={() => selectTagSug(s)}>
171							<span class="sug-label">{s.tag}</span>
172							<span class="sug-count">{s.usage_count}</span>
173						</button>
174					{/each}
175				</div>
176			{/if}
177		</div>
178		<div class="sug-wrap">
179			<input
180				type="text"
181				placeholder="Filter by uploader..."
182				bind:value={filterUploader}
183				oninput={onUploaderInput}
184				onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }}
185				onblur={() => setTimeout(() => { showUploaderSugs = false; }, 200)}
186			/>
187			{#if showUploaderSugs}
188				<div class="sugs" role="listbox">
189					{#each uploaderSuggestions as s}
190						<button type="button" role="option" aria-selected={false} class="sug-opt" onmousedown={() => selectUploaderSug(s)}>
191							<span class="sug-label">{s.tag}</span>
192							<span class="sug-count">{s.usage_count}</span>
193						</button>
194					{/each}
195				</div>
196			{/if}
197		</div>
198		<button class="btn" onclick={applyFilter}>FILTER</button>
199		{#if filterTag || filterUploader}
200			<button class="btn btn-clear" onclick={() => { filterTag = ''; filterUploader = ''; applyFilter(); }}>CLEAR</button>
201		{/if}
202	</div>
203</div>
204
205{#if loading}
206	<p class="state-msg">Loading...</p>
207{:else if error}
208	<p class="state-msg error">{error}</p>
209{:else if images.length === 0}
210	<div class="empty">
211		{#if filterTag || filterUploader}
212			<p>No results
213				{#if filterTag}for tag <strong>&ldquo;{filterTag}&rdquo;</strong>{/if}
214				{#if filterTag && filterUploader} and {/if}
215				{#if filterUploader}by uploader <strong>&ldquo;{filterUploader}&rdquo;</strong>{/if}.
216			</p>
217		{:else}
218			<p>The archive is empty.</p>
219			<a href="/upload" class="btn">UPLOAD MEDIA</a>
220		{/if}
221	</div>
222{:else}
223	<div class="grid">
224		{#each images as img, i (img.id)}
225			<button class="card" onclick={() => openViewer(i)}>
226				<div class="card-image">
227					{#if img.thumbnail_path}
228						<img src={thumbnailUrl(img.id)} alt={img.filename} loading="lazy" />
229					{:else}
230						<div class="placeholder">{img.mime_type}</div>
231					{/if}
232				</div>
233				<div class="card-overlay">
234					<div class="card-title">{img.filename}</div>
235					<div class="card-user">
236						{#if img.avatar_url}
237							<img src={img.avatar_url} alt="" class="card-avatar" />
238						{/if}
239						#{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>
240					</div>
241				</div>
242			</button>
243		{/each}
244	</div>
245
246	<div class="pagination">
247		<button class="btn" onclick={prevPage} disabled={page <= 1}>PREVIOUS</button>
248		<span class="page-info">PAGE {page} OF {Math.ceil(total / perPage)}</span>
249		<button class="btn" onclick={nextPage} disabled={page * perPage >= total}>NEXT</button>
250	</div>
251{/if}
252
253{#if viewerIndex >= 0}
254	<ImageViewer
255		images={images}
256		initialIndex={viewerIndex}
257		onclose={closeViewer}
258	/>
259{/if}
260
261<style>
262	.header {
263		padding: 1.5rem 1.5rem 0.75rem;
264	}
265	.header-top {
266		display: flex;
267		align-items: baseline;
268		gap: 1rem;
269	}
270	.header-top h1 {
271		margin: 0;
272	}
273	.header-count {
274		font-family: 'Oswald', sans-serif;
275		font-weight: 500;
276		font-size: 0.9rem;
277		letter-spacing: 0.1em;
278		color: #888;
279		text-transform: uppercase;
280	}
281	.header-line {
282		height: 3px;
283		background: #c62828;
284		width: 3rem;
285		margin: 0.5rem 0 1rem;
286	}
287	.controls {
288		display: flex;
289		gap: 0.5rem;
290		align-items: flex-start;
291	}
292	.sug-wrap {
293		position: relative;
294		flex: 1;
295		max-width: 280px;
296	}
297	.sug-wrap input {
298		width: 100%;
299	}
300	.controls input {
301		padding: 0.5rem 0.75rem;
302		border: 2px solid #1a1a1a;
303		border-radius: 0;
304		font-family: 'Inter', sans-serif;
305		font-size: 0.85rem;
306		font-weight: 500;
307		background: #fff;
308	}
309	.controls input:focus {
310		outline: none;
311		border-color: #c62828;
312	}
313
314	.sugs {
315		position: absolute;
316		top: 100%;
317		left: 0;
318		right: 0;
319		z-index: 20;
320		list-style: none;
321		margin: 0;
322		padding: 0;
323		background: #fff;
324		border: 2px solid #1a1a1a;
325		border-top: none;
326		max-height: 220px;
327		overflow-y: auto;
328	}
329	.sug-opt {
330		display: flex;
331		align-items: center;
332		justify-content: space-between;
333		width: 100%;
334		text-align: left;
335		padding: 0.45rem 0.75rem;
336		border: none;
337		background: none;
338		font-family: 'Inter', sans-serif;
339		font-size: 0.82rem;
340		font-weight: 500;
341		gap: 0.5rem;
342		cursor: pointer;
343		border-radius: 0;
344	}
345	.sug-opt:hover {
346		background: #f0f0f0;
347	}
348	.sug-label {
349		overflow: hidden;
350		text-overflow: ellipsis;
351		white-space: nowrap;
352	}
353	.sug-count {
354		flex-shrink: 0;
355		font-family: 'Oswald', sans-serif;
356		font-weight: 500;
357		font-size: 0.72rem;
358		letter-spacing: 0.05em;
359		color: #888;
360	}
361
362	.btn {
363		display: inline-flex;
364		align-items: center;
365		padding: 0.5rem 1.25rem;
366		background: #1a1a1a;
367		color: #fff;
368		border: none;
369		border-radius: 0;
370		font-family: 'Oswald', sans-serif;
371		font-weight: 500;
372		font-size: 0.85rem;
373		letter-spacing: 0.1em;
374		text-transform: uppercase;
375		cursor: pointer;
376		text-decoration: none;
377		transition: background 0.15s;
378	}
379	.btn:hover {
380		background: #c62828;
381	}
382	.btn:disabled {
383		opacity: 0.3;
384		cursor: default;
385	}
386	.btn-clear {
387		background: #666;
388	}
389	.btn-clear:hover {
390		background: #c62828;
391	}
392
393	.grid {
394		display: grid;
395		grid-template-columns: repeat(6, 1fr);
396		gap: 2px;
397	}
398
399	@media (max-width: 1200px) {
400		.grid { grid-template-columns: repeat(5, 1fr); }
401	}
402	@media (max-width: 1000px) {
403		.grid { grid-template-columns: repeat(4, 1fr); }
404	}
405	@media (max-width: 750px) {
406		.grid { grid-template-columns: repeat(3, 1fr); }
407	}
408	@media (max-width: 500px) {
409		.grid { grid-template-columns: repeat(2, 1fr); }
410	}
411
412	.card {
413		position: relative;
414		display: block;
415		width: 100%;
416		padding: 0;
417		border: none;
418		background: none;
419		font: inherit;
420		color: inherit;
421		cursor: pointer;
422		text-align: left;
423		overflow: hidden;
424		outline: none;
425	}
426	.card:focus-visible {
427		outline: 3px solid #c62828;
428		outline-offset: -3px;
429	}
430	.card-image {
431		position: relative;
432		overflow: hidden;
433		background: #1a1a1a;
434		aspect-ratio: 16 / 10;
435	}
436	.card-image img {
437		display: block;
438		width: 100%;
439		height: 100%;
440		object-fit: cover;
441		transition: transform 300ms ease, filter 300ms ease;
442	}
443	.card:hover .card-image img {
444		transform: scale(1.08);
445		filter: grayscale(0.5) contrast(1.1);
446	}
447	.placeholder {
448		display: flex;
449		align-items: center;
450		justify-content: center;
451		width: 100%;
452		height: 100%;
453		color: #555;
454		font-size: 0.8rem;
455		font-weight: 600;
456	}
457
458	.card-overlay {
459		position: absolute;
460		bottom: 0;
461		left: 0;
462		right: 0;
463		background: linear-gradient(transparent 30%, #1a1a1a 95%);
464		padding: 2rem 0.6rem 0.5rem;
465		opacity: 0;
466		transition: opacity 200ms ease;
467		pointer-events: none;
468	}
469	.card:hover .card-overlay {
470		opacity: 1;
471	}
472	.card-title {
473		color: #fff;
474		font-family: 'Inter', sans-serif;
475		font-size: 0.78rem;
476		font-weight: 600;
477		white-space: nowrap;
478		overflow: hidden;
479		text-overflow: ellipsis;
480	}
481	.card-user {
482		color: rgba(255, 255, 255, 0.55);
483		font-family: 'Inter', sans-serif;
484		font-size: 0.68rem;
485		font-weight: 500;
486		margin-top: 0.05rem;
487		display: flex;
488		align-items: center;
489		gap: 0.3rem;
490	}
491	.card-avatar {
492		width: 1rem;
493		height: 1rem;
494		border-radius: 0;
495		object-fit: cover;
496		flex-shrink: 0;
497	}
498
499	.user-link {
500		background: none;
501		border: none;
502		padding: 0;
503		font: inherit;
504		color: rgba(255, 255, 255, 0.55);
505		cursor: pointer;
506		text-decoration: none;
507		pointer-events: auto;
508	}
509	.user-link:hover {
510		color: #ef5350;
511		text-decoration: underline;
512	}
513
514	.pagination {
515		display: flex;
516		align-items: center;
517		justify-content: center;
518		gap: 1rem;
519		padding: 2rem 1.5rem 3rem;
520	}
521	.page-info {
522		font-family: 'Oswald', sans-serif;
523		font-weight: 500;
524		font-size: 0.85rem;
525		letter-spacing: 0.12em;
526		color: #888;
527	}
528
529	.state-msg {
530		padding: 3rem 1.5rem;
531		font-size: 1.1rem;
532		color: #888;
533	}
534	.error {
535		color: #c62828;
536	}
537
538	.empty {
539		padding: 3rem 1.5rem;
540		display: flex;
541		flex-direction: column;
542		gap: 1rem;
543		align-items: flex-start;
544	}
545	.empty p {
546		font-size: 1.1rem;
547		color: #888;
548	}
549</style>