134 lines
3.0 KiB
Svelte
134 lines
3.0 KiB
Svelte
<script lang="ts">
|
|
// A book cover: the real image when there is one, otherwise a generated cover in a
|
|
// colour derived from the title (also used when the image fails to load).
|
|
import { authorLine, coverHue } from './shared.js';
|
|
|
|
let {
|
|
src,
|
|
title,
|
|
authors = [],
|
|
placeholder = false,
|
|
size = 'md',
|
|
class: className = ''
|
|
}: {
|
|
src: string | null;
|
|
title: string;
|
|
authors?: readonly string[];
|
|
placeholder?: boolean;
|
|
size?: 'sm' | 'md' | 'lg';
|
|
class?: string;
|
|
} = $props();
|
|
|
|
let failed = $state<string | null>(null);
|
|
const showImage = $derived(!!src && failed !== src);
|
|
const hue = $derived(coverHue(title));
|
|
</script>
|
|
|
|
<div
|
|
class={['cover', `cover-${size}`, placeholder && 'is-placeholder', className]}
|
|
style:--hue={hue}
|
|
>
|
|
{#if showImage}
|
|
<img
|
|
{src}
|
|
alt=""
|
|
loading="lazy"
|
|
decoding="async"
|
|
referrerpolicy="no-referrer"
|
|
onerror={() => (failed = src)}
|
|
/>
|
|
{:else}
|
|
<div class="generated" aria-hidden="true">
|
|
<span class="g-title">{title}</span>
|
|
{#if authors.length}<span class="g-author">{authorLine(authors)}</span>{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.cover {
|
|
position: relative;
|
|
aspect-ratio: 2 / 3;
|
|
width: 100%;
|
|
overflow: hidden;
|
|
container-type: inline-size;
|
|
border-radius: 0.375rem 0.625rem 0.625rem 0.375rem;
|
|
background: var(--site-hover);
|
|
box-shadow:
|
|
0 1px 2px rgb(0 0 0 / 0.25),
|
|
0 8px 24px -12px rgb(0 0 0 / 0.5);
|
|
}
|
|
/* Spine highlight, like a real paperback. */
|
|
.cover::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
background: linear-gradient(
|
|
90deg,
|
|
rgb(0 0 0 / 0.28) 0,
|
|
rgb(255 255 255 / 0.12) 2.5%,
|
|
rgb(0 0 0 / 0.08) 5%,
|
|
transparent 9%
|
|
);
|
|
border-radius: inherit;
|
|
}
|
|
img {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
.generated {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
padding: 14% 10% 10% 14%;
|
|
color: hsl(var(--hue) 30% 96%);
|
|
background:
|
|
radial-gradient(
|
|
120% 80% at 100% 0%,
|
|
hsl(calc(var(--hue) + 40) 60% 45% / 0.55),
|
|
transparent 60%
|
|
),
|
|
linear-gradient(160deg, hsl(var(--hue) 45% 32%), hsl(calc(var(--hue) + 20) 50% 16%));
|
|
}
|
|
.g-title {
|
|
font-family: Georgia, 'Times New Roman', serif;
|
|
font-weight: 600;
|
|
line-height: 1.15;
|
|
/* Scale with the cover's width so thumbnails don't break words mid-way. */
|
|
font-size: clamp(0.55rem, 12cqi, 1.6rem);
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 6;
|
|
line-clamp: 6;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
overflow-wrap: break-word;
|
|
hyphens: auto;
|
|
}
|
|
.g-author {
|
|
font-size: clamp(0.5rem, 6.5cqi, 0.85rem);
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
opacity: 0.8;
|
|
}
|
|
@container (max-width: 90px) {
|
|
.g-author {
|
|
display: none;
|
|
}
|
|
}
|
|
.is-placeholder {
|
|
box-shadow: none;
|
|
outline: 1.5px dashed color-mix(in srgb, var(--site-muted) 60%, transparent);
|
|
outline-offset: 2px;
|
|
}
|
|
.is-placeholder img,
|
|
.is-placeholder .generated {
|
|
filter: grayscale(0.85) opacity(0.55);
|
|
}
|
|
</style>
|