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.
148 lines
4.3 KiB
Svelte
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
|
|
>  
|
|
{/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>
|