[FA-misc] Various UI updates
This commit is contained in:
141
fictionarchive-web-astro/src/lib/components/SearchBar.svelte
Normal file
141
fictionarchive-web-astro/src/lib/components/SearchBar.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user