all repos — weimar @ master

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