all repos — weimar @ ab494a4c3e2c99426dd9f18f02bcb3fb7ee678a3

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