all repos — weimar @ 088c2e33b9850fd8e9a0a65e6876d579b0f5433f

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				<ul class="sugs" role="listbox">
169					{#each tagSuggestions as s}
170						<li role="option" tabindex="0" onmousedown={() => selectTagSug(s)}>
171							<span class="sug-label">{s.tag}</span>
172							<span class="sug-count">{s.usage_count}</span>
173						</li>
174					{/each}
175				</ul>
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				<ul class="sugs" role="listbox">
189					{#each uploaderSuggestions as s}
190						<li role="option" tabindex="0" onmousedown={() => selectUploaderSug(s)}>
191							<span class="sug-label">{s.tag}</span>
192							<span class="sug-count">{s.usage_count}</span>
193						</li>
194					{/each}
195				</ul>
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	.sugs li {
330		display: flex;
331		align-items: center;
332		justify-content: space-between;
333		padding: 0.45rem 0.75rem;
334		cursor: pointer;
335		font-family: 'Inter', sans-serif;
336		font-size: 0.82rem;
337		font-weight: 500;
338		gap: 0.5rem;
339	}
340	.sugs li:hover {
341		background: #f0f0f0;
342	}
343	.sug-label {
344		overflow: hidden;
345		text-overflow: ellipsis;
346		white-space: nowrap;
347	}
348	.sug-count {
349		flex-shrink: 0;
350		font-family: 'Oswald', sans-serif;
351		font-weight: 500;
352		font-size: 0.72rem;
353		letter-spacing: 0.05em;
354		color: #888;
355	}
356
357	.btn {
358		display: inline-flex;
359		align-items: center;
360		padding: 0.5rem 1.25rem;
361		background: #1a1a1a;
362		color: #fff;
363		border: none;
364		border-radius: 0;
365		font-family: 'Oswald', sans-serif;
366		font-weight: 500;
367		font-size: 0.85rem;
368		letter-spacing: 0.1em;
369		text-transform: uppercase;
370		cursor: pointer;
371		text-decoration: none;
372		transition: background 0.15s;
373	}
374	.btn:hover {
375		background: #c62828;
376	}
377	.btn:disabled {
378		opacity: 0.3;
379		cursor: default;
380	}
381	.btn-clear {
382		background: #666;
383	}
384	.btn-clear:hover {
385		background: #c62828;
386	}
387
388	.grid {
389		display: grid;
390		grid-template-columns: repeat(6, 1fr);
391		gap: 2px;
392	}
393
394	@media (max-width: 1200px) {
395		.grid { grid-template-columns: repeat(5, 1fr); }
396	}
397	@media (max-width: 1000px) {
398		.grid { grid-template-columns: repeat(4, 1fr); }
399	}
400	@media (max-width: 750px) {
401		.grid { grid-template-columns: repeat(3, 1fr); }
402	}
403	@media (max-width: 500px) {
404		.grid { grid-template-columns: repeat(2, 1fr); }
405	}
406
407	.card {
408		position: relative;
409		display: block;
410		width: 100%;
411		padding: 0;
412		border: none;
413		background: none;
414		font: inherit;
415		color: inherit;
416		cursor: pointer;
417		text-align: left;
418		overflow: hidden;
419		outline: none;
420	}
421	.card:focus-visible {
422		outline: 3px solid #c62828;
423		outline-offset: -3px;
424	}
425	.card-image {
426		position: relative;
427		overflow: hidden;
428		background: #1a1a1a;
429		aspect-ratio: 16 / 10;
430	}
431	.card-image img {
432		display: block;
433		width: 100%;
434		height: 100%;
435		object-fit: cover;
436		transition: transform 300ms ease, filter 300ms ease;
437	}
438	.card:hover .card-image img {
439		transform: scale(1.08);
440		filter: grayscale(0.5) contrast(1.1);
441	}
442	.placeholder {
443		display: flex;
444		align-items: center;
445		justify-content: center;
446		width: 100%;
447		height: 100%;
448		color: #555;
449		font-size: 0.8rem;
450		font-weight: 600;
451	}
452
453	.card-overlay {
454		position: absolute;
455		bottom: 0;
456		left: 0;
457		right: 0;
458		background: linear-gradient(transparent 30%, #1a1a1a 95%);
459		padding: 2rem 0.6rem 0.5rem;
460		opacity: 0;
461		transition: opacity 200ms ease;
462		pointer-events: none;
463	}
464	.card:hover .card-overlay {
465		opacity: 1;
466	}
467	.card-title {
468		color: #fff;
469		font-family: 'Inter', sans-serif;
470		font-size: 0.78rem;
471		font-weight: 600;
472		white-space: nowrap;
473		overflow: hidden;
474		text-overflow: ellipsis;
475	}
476	.card-user {
477		color: rgba(255, 255, 255, 0.55);
478		font-family: 'Inter', sans-serif;
479		font-size: 0.68rem;
480		font-weight: 500;
481		margin-top: 0.05rem;
482		display: flex;
483		align-items: center;
484		gap: 0.3rem;
485	}
486	.card-avatar {
487		width: 1rem;
488		height: 1rem;
489		border-radius: 0;
490		object-fit: cover;
491		flex-shrink: 0;
492	}
493
494	.user-link {
495		background: none;
496		border: none;
497		padding: 0;
498		font: inherit;
499		color: rgba(255, 255, 255, 0.55);
500		cursor: pointer;
501		text-decoration: none;
502		pointer-events: auto;
503	}
504	.user-link:hover {
505		color: #ef5350;
506		text-decoration: underline;
507	}
508
509	.pagination {
510		display: flex;
511		align-items: center;
512		justify-content: center;
513		gap: 1rem;
514		padding: 2rem 1.5rem 3rem;
515	}
516	.page-info {
517		font-family: 'Oswald', sans-serif;
518		font-weight: 500;
519		font-size: 0.85rem;
520		letter-spacing: 0.12em;
521		color: #888;
522	}
523
524	.state-msg {
525		padding: 3rem 1.5rem;
526		font-size: 1.1rem;
527		color: #888;
528	}
529	.error {
530		color: #c62828;
531	}
532
533	.empty {
534		padding: 3rem 1.5rem;
535		display: flex;
536		flex-direction: column;
537		gap: 1rem;
538		align-items: flex-start;
539	}
540	.empty p {
541		font-size: 1.1rem;
542		color: #888;
543	}
544</style>