Files
chitai/frontend/src/lib/components/layout/search-form.svelte
T
patrick 7e04826fa5 feat: draw covers for books without one
Full-bleed serif title on a woven ground, drawn in the browser rather than
stored, sized with container queries so one component works from 36px to full
page. Colour is hashed into a list of bookcloth tones instead of the hue wheel.

The grid and search were falling back to default_cover.jpg because a missing
cover made the src "/api/undefined"; they now check for one first.
2026-08-12 13:44:14 -04:00

148 lines
4.3 KiB
Svelte

<script lang="ts">
import { resolve } from '$app/paths';
import * as Command from '$lib/components/ui/command/index';
import * as Kbd from '$lib/components/ui/kbd/index.js';
import * as InputGroup from '$lib/components/ui/input-group/index.js';
import { Badge } from '$lib/components/ui/badge/index';
import { Spinner } from '$lib/components/ui/spinner/index.js';
import SearchIcon from '@lucide/svelte/icons/search';
import { listBooks } from '$lib/api';
import { getLibraryState } from '$lib/state/library.svelte';
import type { PaginatedResponse, Book } from '$lib/schema';
import BookImage from '../view/book-image.svelte';
import GeneratedCover from '../view/generated-cover.svelte';
import { goto } from '$app/navigation';
const libraryState = getLibraryState();
let open = $state(false);
let searchString = $state('');
let searchResult = $state<PaginatedResponse<Book>>();
let debounceTimeout = $state<NodeJS.Timeout | undefined>(undefined);
let isLoading = $state(false);
async function handleInputChange() {
if (!searchString || searchString === '') {
clearTimeout(debounceTimeout);
searchResult = undefined;
isLoading = false;
return;
}
clearTimeout(debounceTimeout);
isLoading = true;
debounceTimeout = setTimeout(async () => {
try {
const results = await listBooks({
libraries: [libraryState.activeLibrary!.id],
searchString
});
searchResult = results || [];
} finally {
isLoading = false;
}
}, 300);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
open = !open;
}
}
function handleClick(e: any) {
open = true;
e.target.blur();
}
</script>
<svelte:document onkeydown={handleKeydown} />
<form class="min-w-0 flex-1">
<div class="flex w-full max-w-xl flex-col">
<InputGroup.Root>
<InputGroup.Input placeholder="Search books..." onclick={handleClick} />
<InputGroup.Addon>
<SearchIcon />
</InputGroup.Addon>
<InputGroup.Addon align="inline-end" class="hidden sm:flex">
<Kbd.Root>Ctrl</Kbd.Root>+
<Kbd.Root>k</Kbd.Root>
</InputGroup.Addon>
</InputGroup.Root>
</div>
</form>
<Command.Dialog bind:open shouldFilter={false}>
<Command.Input
placeholder="Search for a book..."
oninput={handleInputChange}
bind:value={searchString}
/>
{#if searchString && searchResult?.items.length === 0 && !isLoading}
<div class="flex h-24 items-center justify-center">
<span class="text-sm">No results found.</span>
</div>
{/if}
{#if isLoading}
<div class="flex h-24 items-center justify-center">
<Spinner class="scale-150" />
</div>
{:else}
<Command.List class="max-h-[600px]">
{#if searchResult?.items.length > 0}
<Command.Group heading="Books">
{#each searchResult?.items as book (book.id)}
<Command.Item
value={String(book.id)}
onSelect={() => {
goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) }));
open = false;
}}
class="cursor-pointer"
>
<div class="hover:bg-base-200 flex gap-4 p-3">
{#if book.cover_image}
<BookImage
src="/api/{book.cover_image}"
class="w-24 rounded object-cover shadow-lg"
/>
{:else}
<div class="aspect-9/12 w-24 shrink-0 overflow-hidden rounded shadow-lg">
<GeneratedCover {book} />
</div>
{/if}
<div class="flex-top flex flex-col">
<span class="font-serif text-lg">{book.title}</span>
<span class=" text-md">{book.subtitle}</span>
{#if book.authors.length > 0}
<span class="line-clamp-1 w-full text-sm text-muted-foreground">
by
{#each book.authors as author}
<a
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary!.id)
})}?authors={author.id}"
class="cs-list hover:underline">{author.name}</a
> &thinsp;
{/each}
</span>
{/if}
<div class="mt-1 ml-[-1.5] flex">
{#each book.tags as tag}
<Badge class="scale-75">{tag.name}</Badge>
{/each}
</div>
</div>
</div>
</Command.Item>
{/each}
</Command.Group>
{/if}
</Command.List>
{/if}
</Command.Dialog>