[FA-misc] Various UI updates
All checks were successful
CI / build-backend (pull_request) Successful in 1m38s
CI / build-frontend (pull_request) Successful in 37s

This commit is contained in:
gamer147
2025-12-10 20:37:30 -05:00
parent 45afb57df5
commit f0ea71e00e
13 changed files with 298 additions and 28 deletions

View File

@@ -0,0 +1,141 @@
<script lang="ts">
import Search from '@lucide/svelte/icons/search';
import { Input } from '$lib/components/ui/input';
import { NovelsDocument, type NovelDto } from '$lib/graphql/__generated__/graphql';
import { client } from '$lib/graphql/client';
let searchTerm = $state('');
let results = $state<NovelDto[]>([]);
let isOpen = $state(false);
let fetching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let containerRef: HTMLDivElement;
async function fetchResults(term: string) {
if (!term.trim()) {
results = [];
isOpen = false;
return;
}
fetching = true;
try {
const result = await client
.query(NovelsDocument, {
first: 4,
where: { name: { contains: term } }
})
.toPromise();
if (result.data?.novels?.edges) {
results = result.data.novels.edges.map((edge) => edge.node);
isOpen = results.length > 0;
} else {
results = [];
isOpen = false;
}
} catch (error) {
console.error('Search error:', error);
results = [];
isOpen = false;
} finally {
fetching = false;
}
}
function handleInput(value: string) {
searchTerm = value;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
fetchResults(value);
}, 300);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && searchTerm.trim()) {
event.preventDefault();
isOpen = false;
window.location.href = `/novels?search=${encodeURIComponent(searchTerm.trim())}`;
} else if (event.key === 'Escape') {
isOpen = false;
}
}
function handleResultClick() {
isOpen = false;
searchTerm = '';
results = [];
}
function handleFocus() {
if (results.length > 0) {
isOpen = true;
}
}
function handleClickOutside(event: MouseEvent) {
if (containerRef && !containerRef.contains(event.target as Node)) {
isOpen = false;
}
}
function getCoverSrc(novel: NovelDto): string | undefined {
return novel.coverImage?.newPath ?? novel.coverImage?.originalPath ?? undefined;
}
$effect(() => {
if (typeof window !== 'undefined') {
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}
});
</script>
<div class="relative max-w-xs" bind:this={containerRef}>
<div class="relative">
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search..."
class="pl-8"
value={searchTerm}
oninput={(e) => handleInput(e.currentTarget.value)}
onkeydown={handleKeydown}
onfocus={handleFocus}
/>
</div>
{#if isOpen}
<div
class="absolute top-full left-0 right-0 z-50 mt-1 overflow-hidden rounded-md border bg-white shadow-lg dark:bg-gray-900"
>
{#if fetching}
<div class="px-4 py-3 text-sm text-muted-foreground">Searching...</div>
{:else if results.length === 0}
<div class="px-4 py-3 text-sm text-muted-foreground">No results found</div>
{:else}
{#each results as novel (novel.id)}
<a
href="/novels/{novel.id}"
class="flex items-center gap-3 px-3 py-2 hover:bg-muted transition-colors"
onclick={handleResultClick}
>
{#if getCoverSrc(novel)}
<img
src={getCoverSrc(novel)}
alt=""
class="h-12 w-9 rounded object-cover flex-shrink-0"
loading="lazy"
/>
{:else}
<div class="h-12 w-9 rounded bg-muted flex-shrink-0"></div>
{/if}
<span class="text-sm font-medium truncate">{novel.name}</span>
</a>
{/each}
{/if}
</div>
{/if}
</div>