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.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
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();
|
||||
@@ -104,10 +105,16 @@
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<div class="hover:bg-base-200 flex gap-4 p-3">
|
||||
<BookImage
|
||||
src="/api/{book.cover_image}"
|
||||
class="w-24 rounded object-cover shadow-lg"
|
||||
/>
|
||||
{#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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Book } from '$lib/schema';
|
||||
import GeneratedCover from './generated-cover.svelte';
|
||||
|
||||
let {
|
||||
book,
|
||||
@@ -10,12 +11,6 @@
|
||||
let failed = $state(false);
|
||||
|
||||
const src = $derived(book.cover_image ? `/api/${book.cover_image}` : null);
|
||||
|
||||
// A stable hue per title, so a book with no artwork still gets its own
|
||||
// colour rather than every placeholder looking identical.
|
||||
const hue = $derived([...book.title].reduce((acc, ch) => (acc * 31 + ch.charCodeAt(0)) % 360, 7));
|
||||
|
||||
const authors = $derived(book.authors.map((a) => a.name).join(', '));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -65,20 +60,10 @@
|
||||
{:else}
|
||||
<!-- No cover on record, or the file is missing. Draw one. -->
|
||||
<span
|
||||
class="flex h-full flex-col justify-between overflow-hidden rounded-sm p-3 text-left shadow-lg"
|
||||
style="width: {Math.round(height * 0.66)}px; background: linear-gradient(152deg, hsl({hue}
|
||||
30% 34%), hsl({hue} 38% 18%));"
|
||||
class="h-full overflow-hidden rounded-sm shadow-lg"
|
||||
style="width: {Math.round(height * 0.66)}px;"
|
||||
>
|
||||
<span
|
||||
class="line-clamp-4 font-serif text-xs leading-tight"
|
||||
style="color: hsl({hue} 38% 95%);">{book.title}</span
|
||||
>
|
||||
{#if authors}
|
||||
<span
|
||||
class="line-clamp-2 font-mono text-[8px] tracking-wider uppercase"
|
||||
style="color: hsl({hue} 24% 78%);">{authors}</span
|
||||
>
|
||||
{/if}
|
||||
<GeneratedCover {book} />
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<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';
|
||||
@@ -31,13 +32,30 @@
|
||||
: ''}"
|
||||
onclick={handleClick}
|
||||
>
|
||||
<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'
|
||||
: ''}"
|
||||
/>
|
||||
<!--
|
||||
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 -->
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import type { Book } from '$lib/schema';
|
||||
|
||||
let { book, class: className = '' }: { book: Book; class?: string } = $props();
|
||||
|
||||
/**
|
||||
* Bookcloth tones rather than a point on the hue wheel.
|
||||
*
|
||||
* The old placeholder derived a hue from the title with `hash % 360`, which
|
||||
* eventually lands on acid green. Hashing into a fixed list keeps every
|
||||
* generated cover looking like it came from the same library.
|
||||
*/
|
||||
const CLOTH = [
|
||||
'#1f5f5b', // teal, the app's own accent
|
||||
'#7a2e2a', // oxblood
|
||||
'#2f4858', // slate
|
||||
'#3c5148', // forest
|
||||
'#6b4a6e', // plum
|
||||
'#8a6a1f', // brass
|
||||
'#2b3038', // graphite
|
||||
'#5a4632' // tobacco
|
||||
];
|
||||
|
||||
/** Weaves, as CSS gradients — an SVG <pattern> would need a unique id per cover. */
|
||||
const WEAVE = [
|
||||
// twill
|
||||
`repeating-linear-gradient(45deg, transparent 0 4px, currentColor 4px 5px)`,
|
||||
// horizontal ribbing
|
||||
`repeating-linear-gradient(0deg, transparent 0 5px, currentColor 5px 6px)`,
|
||||
// crosshatch
|
||||
`repeating-linear-gradient(45deg, transparent 0 6px, currentColor 6px 7px),
|
||||
repeating-linear-gradient(-45deg, transparent 0 6px, currentColor 6px 7px)`,
|
||||
// vertical laid lines
|
||||
`repeating-linear-gradient(90deg, transparent 0 5px, currentColor 5px 6px)`
|
||||
];
|
||||
|
||||
/** Stable per title, so a book always looks the same. */
|
||||
function hash(value: string) {
|
||||
let out = 7;
|
||||
for (const char of value) out = (out * 31 + char.charCodeAt(0)) % 100_000;
|
||||
return out;
|
||||
}
|
||||
|
||||
const seed = $derived(hash(book.title || 'Untitled'));
|
||||
const cloth = $derived(CLOTH[seed % CLOTH.length]);
|
||||
const weave = $derived(WEAVE[(seed >> 3) % WEAVE.length]);
|
||||
|
||||
const authors = $derived(book.authors?.map((author) => author.name).join(', ') ?? '');
|
||||
|
||||
/**
|
||||
* Stepped by length rather than scaled continuously: a long title shrunk to
|
||||
* fit would end up smaller than the author line, which reads as a mistake.
|
||||
* Units are cqw so the whole thing works at any size the caller asks for.
|
||||
*/
|
||||
const titleSize = $derived.by(() => {
|
||||
const length = (book.title ?? '').length;
|
||||
if (length <= 14) return 17;
|
||||
if (length <= 28) return 13;
|
||||
if (length <= 46) return 10;
|
||||
return 8.5;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Drawn, not stored. It costs nothing, follows the title when it is edited, and
|
||||
is replaced the moment a real cover is uploaded. Colours are fixed rather than
|
||||
themed: this is an object on a shelf beside real artwork, which does not
|
||||
change with the theme either.
|
||||
-->
|
||||
<div
|
||||
class="generated-cover {className}"
|
||||
style="--cloth: {cloth};"
|
||||
role="img"
|
||||
aria-label="Generated cover for {book.title}"
|
||||
>
|
||||
<div class="weave" style="background-image: {weave};"></div>
|
||||
|
||||
<div class="type">
|
||||
<span class="title" style="font-size: {titleSize}cqw;">{book.title}</span>
|
||||
{#if authors}
|
||||
<span class="author">{authors}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.generated-cover {
|
||||
container-type: inline-size;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--cloth);
|
||||
color: #f4f1ec;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* currentColor drives the gradient, so one rule covers every weave. */
|
||||
.weave {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #ffffff;
|
||||
opacity: 0.14;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* A little depth so it does not read as a flat swatch next to a photograph. */
|
||||
.generated-cover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(150deg, rgb(255 255 255 / 0.1), rgb(0 0 0 / 0.28));
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.type {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 6cqw;
|
||||
padding: 10cqw 9cqw;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--app-font-serif, Georgia, serif);
|
||||
line-height: 1.04;
|
||||
letter-spacing: -0.02em;
|
||||
text-wrap: balance;
|
||||
/* Clip rather than spill: an overflowing title looks broken, a clipped one
|
||||
just looks like a long title. */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 6;
|
||||
line-clamp: 6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-family: var(--app-font-mono, ui-monospace, monospace);
|
||||
font-size: 4.4cqw;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.75;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Below about a centimetre the author is unreadable, and the padding is
|
||||
eating the title. Show the title alone. */
|
||||
@container (max-width: 72px) {
|
||||
.author {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.type {
|
||||
padding: 8cqw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user