all repos — weimar @ 453d1fa348ab96e179dc278cf0d13e8f9d0c7121

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	function formatDuration(sec: number): string {
163		const m = Math.floor(sec / 60);
164		const s = Math.floor(sec % 60);
165		return m + ':' + String(s).padStart(2, '0');
166	}
167
168	// Auto-scroll the active suggestion into view when keyboard-navigated.
169	$effect(() => {
170		if (tagSugIndex >= 0 || uploaderSugIndex >= 0) {
171			const el = document.querySelector('.sug-opt.active') as HTMLElement | null;
172			if (el) el.scrollIntoView({ block: 'nearest' });
173		}
174	});
175</script>
176
177<div class="header">
178	<div class="header-top">
179		<h1>Browse</h1>
180		<span class="header-count">{total} {total === 1 ? 'image' : 'images'}</span>
181	</div>
182	<div class="header-line"></div>
183	<div class="controls">
184		<div class="sug-wrap">
185			<input
186				type="text"
187				placeholder="Filter by tag..."
188				bind:value={filterTag}
189				oninput={onTagInput}
190				onkeydown={(e) => {
191					if (e.key === 'Enter') {
192						if (tagSugIndex >= 0 && tagSugIndex < tagSuggestions.length) {
193							selectTagSug(tagSuggestions[tagSugIndex]);
194						} else {
195							applyFilter();
196						}
197					} else if (e.key === 'Tab' || e.key === 'ArrowDown') {
198						if (showTagSugs && tagSuggestions.length > 0) {
199							e.preventDefault();
200							tagSugIndex = (tagSugIndex + 1) % tagSuggestions.length;
201						}
202					} else if (e.key === 'ArrowUp') {
203						if (showTagSugs && tagSuggestions.length > 0) {
204							e.preventDefault();
205							tagSugIndex = tagSugIndex <= 0 ? tagSuggestions.length - 1 : tagSugIndex - 1;
206						}
207					} else if (e.key === 'Escape') {
208						showTagSugs = false;
209						tagSugIndex = -1;
210					}
211				}}
212				onblur={() => setTimeout(() => { showTagSugs = false; tagSugIndex = -1; }, 200)}
213			/>
214			{#if showTagSugs}
215				<div class="sugs" role="listbox">
216					{#each tagSuggestions as s, i}
217						<button
218							type="button"
219							role="option"
220							aria-selected={i === tagSugIndex}
221							class="sug-opt"
222							class:active={i === tagSugIndex}
223							onmousedown={() => selectTagSug(s)}
224						>
225							<span class="sug-label">{s.tag}</span>
226							<span class="sug-count">{s.usage_count}</span>
227						</button>
228					{/each}
229				</div>
230			{/if}
231		</div>
232		<div class="sug-wrap">
233			<input
234				type="text"
235				placeholder="Filter by uploader..."
236				bind:value={filterUploader}
237				oninput={onUploaderInput}
238				onkeydown={(e) => {
239					if (e.key === 'Enter') {
240						if (uploaderSugIndex >= 0 && uploaderSugIndex < uploaderSuggestions.length) {
241							selectUploaderSug(uploaderSuggestions[uploaderSugIndex]);
242						} else {
243							applyFilter();
244						}
245					} else if (e.key === 'Tab' || e.key === 'ArrowDown') {
246						if (showUploaderSugs && uploaderSuggestions.length > 0) {
247							e.preventDefault();
248							uploaderSugIndex = (uploaderSugIndex + 1) % uploaderSuggestions.length;
249						}
250					} else if (e.key === 'ArrowUp') {
251						if (showUploaderSugs && uploaderSuggestions.length > 0) {
252							e.preventDefault();
253							uploaderSugIndex = uploaderSugIndex <= 0 ? uploaderSuggestions.length - 1 : uploaderSugIndex - 1;
254						}
255					} else if (e.key === 'Escape') {
256						showUploaderSugs = false;
257						uploaderSugIndex = -1;
258					}
259				}}
260				onblur={() => setTimeout(() => { showUploaderSugs = false; uploaderSugIndex = -1; }, 200)}
261			/>
262			{#if showUploaderSugs}
263				<div class="sugs" role="listbox">
264					{#each uploaderSuggestions as s, i}
265						<button
266							type="button"
267							role="option"
268							aria-selected={i === uploaderSugIndex}
269							class="sug-opt"
270							class:active={i === uploaderSugIndex}
271							onmousedown={() => selectUploaderSug(s)}
272						>
273							<span class="sug-label">{s.tag}</span>
274							<span class="sug-count">{s.usage_count}</span>
275						</button>
276					{/each}
277				</div>
278			{/if}
279		</div>
280		<button class="btn" onclick={applyFilter}>FILTER</button>
281		{#if filterTag || filterUploader}
282			<button class="btn btn-clear" onclick={() => { filterTag = ''; filterUploader = ''; applyFilter(); }}>CLEAR</button>
283		{/if}
284	</div>
285</div>
286
287{#if loading}
288	<p class="state-msg">Loading...</p>
289{:else if error}
290	<p class="state-msg error">{error}</p>
291{:else if images.length === 0}
292	<div class="empty">
293		{#if filterTag || filterUploader}
294			<p>No results
295				{#if filterTag}for tag <strong>&ldquo;{filterTag}&rdquo;</strong>{/if}
296				{#if filterTag && filterUploader} and {/if}
297				{#if filterUploader}by uploader <strong>&ldquo;{filterUploader}&rdquo;</strong>{/if}.
298			</p>
299		{:else}
300			<p>The archive is empty.</p>
301			<a href="/upload" class="btn">UPLOAD MEDIA</a>
302		{/if}
303	</div>
304{:else}
305	<div class="grid">
306		{#each images as img, i (img.id)}
307			<button class="card" onclick={() => openViewer(i)}>
308				<div class="card-image">
309					{#if img.thumbnail_path}
310						<img src={thumbnailUrl(img.id)} alt={img.filename} loading="lazy" />
311					{:else}
312						<div class="placeholder">{img.mime_type}</div>
313					{/if}
314					{#if img.mime_type.startsWith('video/') && img.duration}
315						<div class="video-badge">
316							<svg class="video-icon" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><polygon points="6,4 20,12 6,20"/></svg>
317							<span class="video-duration">{formatDuration(img.duration)}</span>
318						</div>
319					{/if}
320				</div>
321				<div class="card-overlay">
322					<div class="card-title">{img.filename}</div>
323					<div class="card-user">
324						{#if img.avatar_url}
325							<img src={img.avatar_url} alt="" class="card-avatar" />
326						{/if}
327						#{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>
328					</div>
329				</div>
330			</button>
331		{/each}
332	</div>
333
334	<div class="pagination">
335		<button class="btn" onclick={prevPage} disabled={page <= 1}>PREVIOUS</button>
336		<span class="page-info">PAGE {page} OF {Math.ceil(total / perPage)}</span>
337		<button class="btn" onclick={nextPage} disabled={page * perPage >= total}>NEXT</button>
338	</div>
339{/if}
340
341{#if viewerIndex >= 0}
342	<ImageViewer
343		images={images}
344		initialIndex={viewerIndex}
345		onclose={closeViewer}
346	/>
347{/if}
348
349<style>
350	.header {
351		padding: 1.5rem 1.5rem 0.75rem;
352	}
353	.header-top {
354		display: flex;
355		align-items: baseline;
356		gap: 1rem;
357	}
358	.header-top h1 {
359		margin: 0;
360	}
361	.header-count {
362		font-family: 'Oswald', sans-serif;
363		font-weight: 500;
364		font-size: 0.9rem;
365		letter-spacing: 0.1em;
366		color: #888;
367		text-transform: uppercase;
368	}
369	.header-line {
370		height: 3px;
371		background: #c62828;
372		width: 3rem;
373		margin: 0.5rem 0 1rem;
374	}
375	.controls {
376		display: flex;
377		gap: 0.5rem;
378		align-items: flex-start;
379	}
380	.sug-wrap {
381		position: relative;
382		flex: 1;
383		max-width: 280px;
384	}
385	.sug-wrap input {
386		width: 100%;
387	}
388	.controls input {
389		padding: 0.5rem 0.75rem;
390		border: 2px solid #1a1a1a;
391		border-radius: 0;
392		font-family: 'Inter', sans-serif;
393		font-size: 0.85rem;
394		font-weight: 500;
395		background: #fff;
396	}
397	.controls input:focus {
398		outline: none;
399		border-color: #c62828;
400	}
401
402	.sugs {
403		position: absolute;
404		top: 100%;
405		left: 0;
406		right: 0;
407		z-index: 20;
408		list-style: none;
409		margin: 0;
410		padding: 0;
411		background: #fff;
412		border: 2px solid #1a1a1a;
413		border-top: none;
414		max-height: 220px;
415		overflow-y: auto;
416	}
417	.sug-opt {
418		display: flex;
419		align-items: center;
420		justify-content: space-between;
421		width: 100%;
422		text-align: left;
423		padding: 0.45rem 0.75rem;
424		border: none;
425		background: none;
426		font-family: 'Inter', sans-serif;
427		font-size: 0.82rem;
428		font-weight: 500;
429		gap: 0.5rem;
430		cursor: pointer;
431		border-radius: 0;
432	}
433	.sug-opt:hover {
434		background: #f0f0f0;
435	}
436	.sug-opt.active {
437		background: #c62828;
438		color: #fff;
439	}
440	.sug-opt.active .sug-count {
441		color: rgba(255, 255, 255, 0.7);
442	}
443	.sug-label {
444		overflow: hidden;
445		text-overflow: ellipsis;
446		white-space: nowrap;
447	}
448	.sug-count {
449		flex-shrink: 0;
450		font-family: 'Oswald', sans-serif;
451		font-weight: 500;
452		font-size: 0.72rem;
453		letter-spacing: 0.05em;
454		color: #888;
455	}
456
457	.btn {
458		display: inline-flex;
459		align-items: center;
460		padding: 0.5rem 1.25rem;
461		background: #1a1a1a;
462		color: #fff;
463		border: none;
464		border-radius: 0;
465		font-family: 'Oswald', sans-serif;
466		font-weight: 500;
467		font-size: 0.85rem;
468		letter-spacing: 0.1em;
469		text-transform: uppercase;
470		cursor: pointer;
471		text-decoration: none;
472		transition: background 0.15s;
473	}
474	.btn:hover {
475		background: #c62828;
476	}
477	.btn:disabled {
478		opacity: 0.3;
479		cursor: default;
480	}
481	.btn-clear {
482		background: #666;
483	}
484	.btn-clear:hover {
485		background: #c62828;
486	}
487
488	.grid {
489		display: grid;
490		grid-template-columns: repeat(6, 1fr);
491		gap: 2px;
492	}
493
494	@media (max-width: 1200px) {
495		.grid { grid-template-columns: repeat(5, 1fr); }
496	}
497	@media (max-width: 1000px) {
498		.grid { grid-template-columns: repeat(4, 1fr); }
499	}
500	@media (max-width: 750px) {
501		.grid { grid-template-columns: repeat(3, 1fr); }
502	}
503	@media (max-width: 500px) {
504		.grid { grid-template-columns: repeat(2, 1fr); }
505	}
506
507	.card {
508		position: relative;
509		display: block;
510		width: 100%;
511		padding: 0;
512		border: none;
513		background: none;
514		font: inherit;
515		color: inherit;
516		cursor: pointer;
517		text-align: left;
518		overflow: hidden;
519		outline: none;
520	}
521	.card:focus-visible {
522		outline: 3px solid #c62828;
523		outline-offset: -3px;
524	}
525	.card-image {
526		position: relative;
527		overflow: hidden;
528		background: #1a1a1a;
529		aspect-ratio: 16 / 10;
530	}
531	.card-image img {
532		display: block;
533		width: 100%;
534		height: 100%;
535		object-fit: cover;
536		transition: transform 300ms ease, filter 300ms ease;
537	}
538	.video-badge {
539		position: absolute;
540		bottom: 0.4rem;
541		right: 0.4rem;
542		display: flex;
543		align-items: center;
544		gap: 0.25rem;
545		background: rgba(0, 0, 0, 0.75);
546		color: #fff;
547		padding: 0.15rem 0.45rem 0.15rem 0.35rem;
548		font-family: 'Inter', sans-serif;
549		font-size: 0.7rem;
550		font-weight: 600;
551		line-height: 1.4;
552		pointer-events: none;
553	}
554	.video-icon {
555		flex-shrink: 0;
556		opacity: 0.9;
557	}
558	.card:hover .card-image img {
559		transform: scale(1.08);
560		filter: grayscale(0.5) contrast(1.1);
561	}
562	.placeholder {
563		display: flex;
564		align-items: center;
565		justify-content: center;
566		width: 100%;
567		height: 100%;
568		color: #555;
569		font-size: 0.8rem;
570		font-weight: 600;
571	}
572
573	.card-overlay {
574		position: absolute;
575		bottom: 0;
576		left: 0;
577		right: 0;
578		background: linear-gradient(transparent 30%, #1a1a1a 95%);
579		padding: 2rem 0.6rem 0.5rem;
580		opacity: 0;
581		transition: opacity 200ms ease;
582		pointer-events: none;
583	}
584	.card:hover .card-overlay {
585		opacity: 1;
586	}
587	.card-title {
588		color: #fff;
589		font-family: 'Inter', sans-serif;
590		font-size: 0.78rem;
591		font-weight: 600;
592		white-space: nowrap;
593		overflow: hidden;
594		text-overflow: ellipsis;
595	}
596	.card-user {
597		color: rgba(255, 255, 255, 0.55);
598		font-family: 'Inter', sans-serif;
599		font-size: 0.68rem;
600		font-weight: 500;
601		margin-top: 0.05rem;
602		display: flex;
603		align-items: center;
604		gap: 0.3rem;
605	}
606	.card-avatar {
607		width: 1rem;
608		height: 1rem;
609		border-radius: 0;
610		object-fit: cover;
611		flex-shrink: 0;
612	}
613
614	.user-link {
615		background: none;
616		border: none;
617		padding: 0;
618		font: inherit;
619		color: rgba(255, 255, 255, 0.55);
620		cursor: pointer;
621		text-decoration: none;
622		pointer-events: auto;
623	}
624	.user-link:hover {
625		color: #ef5350;
626		text-decoration: underline;
627	}
628
629	.pagination {
630		display: flex;
631		align-items: center;
632		justify-content: center;
633		gap: 1rem;
634		padding: 2rem 1.5rem 3rem;
635	}
636	.page-info {
637		font-family: 'Oswald', sans-serif;
638		font-weight: 500;
639		font-size: 0.85rem;
640		letter-spacing: 0.12em;
641		color: #888;
642	}
643
644	.state-msg {
645		padding: 3rem 1.5rem;
646		font-size: 1.1rem;
647		color: #888;
648	}
649	.error {
650		color: #c62828;
651	}
652
653	.empty {
654		padding: 3rem 1.5rem;
655		display: flex;
656		flex-direction: column;
657		gap: 1rem;
658		align-items: flex-start;
659	}
660	.empty p {
661		font-size: 1.1rem;
662		color: #888;
663	}
664</style>