all repos — weimar @ 9b2b569d75975b2ae6868e6708a18ee78a305125

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