all repos — weimar @ 8dcf6e79e30fa40a7f7da17169314b250793210c

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