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.
91 lines
2.9 KiB
Svelte
91 lines
2.9 KiB
Svelte
<script lang="ts">
|
|
import { resolve } from '$app/paths';
|
|
import BookImage from './book-image.svelte';
|
|
import GeneratedCover from './generated-cover.svelte';
|
|
import { Progress } from '$lib/components/ui/progress/index';
|
|
import { getLibraryState } from '$lib/state/library.svelte';
|
|
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
|
import type { Book } from '$lib/schema';
|
|
|
|
let { book, class: className = '', ...rest }: { book: Book; class?: string } = $props();
|
|
|
|
const selectionState = getBookSelectionState();
|
|
const libraryState = getLibraryState();
|
|
|
|
let darkened = $derived(selectionState.selectionModeActive);
|
|
let selected = $derived(selectionState.isSelected(book.id));
|
|
|
|
function handleClick(e: MouseEvent) {
|
|
if (darkened) {
|
|
e.preventDefault();
|
|
selectionState.toggleSelection(book);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="flex w-full flex-shrink-0 flex-col gap-1 {className}">
|
|
<!-- Book Cover -->
|
|
<a
|
|
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
|
class="group relative aspect-9/12 w-full overflow-hidden rounded-sm shadow-lg drop-shadow-lg transition-all duration-200 {selected
|
|
? 'ring-2 ring-star'
|
|
: ''}"
|
|
onclick={handleClick}
|
|
>
|
|
<!--
|
|
Checked rather than left to the image's onerror: with no cover_image the
|
|
src became "/api/undefined", 404'd, and fell back to the generic
|
|
default_cover.jpg — which is what made every coverless book look alike in
|
|
the grid.
|
|
-->
|
|
{#if book.cover_image}
|
|
<BookImage
|
|
src="/api/{book.cover_image}"
|
|
class="h-full w-full rounded-sm object-cover transition-all duration-200 group-hover:brightness-50 {selected ||
|
|
darkened
|
|
? 'brightness-50'
|
|
: ''}"
|
|
/>
|
|
{:else}
|
|
<div
|
|
class="h-full w-full transition-all duration-200 group-hover:brightness-50 {selected ||
|
|
darkened
|
|
? 'brightness-50'
|
|
: ''}"
|
|
>
|
|
<GeneratedCover {book} />
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Inside the anchor, which already clips to rounded-sm, so the bar
|
|
follows the cover's corners instead of floating below it -->
|
|
{#if book.progress?.percentage && !selected && !darkened}
|
|
<span class="absolute inset-x-0 bottom-0 h-1 bg-black/30">
|
|
<span
|
|
class="block h-full {book.progress.completed ? 'bg-success' : 'bg-flag'}"
|
|
style="width: {Math.min(100, Math.round(book.progress.percentage * 100))}%;"
|
|
></span>
|
|
</span>
|
|
{/if}
|
|
</a>
|
|
|
|
<!-- Book Title -->
|
|
<a
|
|
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
|
class="text-base-content mt-1 line-clamp-2 w-full font-serif text-sm hover:underline"
|
|
>{book.title}</a
|
|
>
|
|
|
|
<!-- Authors list -->
|
|
<p class="line-clamp-1 w-full text-xs text-muted-foreground">
|
|
{#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}
|
|
</p>
|
|
</div>
|