Vellum/web/src/lib/components/Card.svelte
mithral 4c51482dd6 Home: five sections — Continue, Most Viewed, Suggestions, Recently Added, Explore
The landing screen now reads as a product rather than a list.

- migration 0006: item_views, per user. "Most viewed" is personal — what I
  re-read is not what you re-read. Counted when an item is opened in a reader,
  so the number means consumed, not merely fetched.
- Suggestions: unstarted items related to what the user has been consuming —
  same author first, then same kind, then recent as a floor. Each carries the
  reason it was chosen ("More by Daft Punk"), because a suggestion nobody can
  understand is noise.
- Explore: a mixed sample across every kind, excluding in-progress items so the
  zone is discovery rather than a repeat.
- Home fetches all five shelves in one request; the screen never pops in.
- web: Shelf gained subtitle/badge support, Card gained a subtitle, and the
  home renders four shelves plus a wrapping Explore grid.

Three bugs found and fixed while verifying:

- Continue showed a multi-track audiobook once per chapter. GROUP BY item_id
  with MAX(updated_at) picks one card per item (SQLite takes the bare columns
  from the row that produced the max).
- Suggestions and Explore passed their SQL arguments in the wrong order: the IN
  list precedes the user id in those queries, so a user id was being bound as a
  library id. Covered by a scoping test that would have caught it.
- The sidebar lost its media types on a cold, logged-out load. bootstrap()
  fetched media types inside a Promise.all with /auth/me, so the expected 401
  rejected the whole thing and nothing refetched after login. Media types are
  public and now load independently.
2026-10-10 16:59:39 -04:00

130 lines
2.7 KiB
Svelte

<script lang="ts">
import { api } from '../api'
import { go } from '../nav'
let {
item,
percent = 0,
wide = false,
size = 'md',
showKind = false,
subtitle = '',
badge = '',
}: {
item: { id: number; title: string; kind?: string }
percent?: number
wide?: boolean
size?: 'sm' | 'md' | 'lg'
showKind?: boolean
subtitle?: string
badge?: string
} = $props()
</script>
<button class="tile {size}" onclick={() => go({ name: 'item', id: item.id })}>
<div class="cover" class:wide>
<img
src={api.coverUrl(item.id)}
alt=""
loading="lazy"
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
/>
<span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span>
{#if badge}
<span class="badge">{badge}</span>
{/if}
{#if percent > 0}
<span class="progress"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></span>
{/if}
</div>
<span class="meta">
<span class="title">{item.title}</span>
{#if subtitle}
<span class="subtitle">{subtitle}</span>
{:else if showKind && item.kind}
<span class="subtitle">{item.kind}</span>
{/if}
</span>
</button>
<style>
.tile {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: 0;
border: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
width: 100%;
}
.tile:hover .cover {
transform: translateY(-3px);
box-shadow: var(--shadow-lift);
}
.tile:hover .title {
color: var(--text);
}
.cover {
width: 100%;
}
.meta {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.title {
font-size: 13px;
font-weight: 500;
color: var(--text);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
}
.subtitle {
font-size: 11.5px;
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
position: absolute;
top: 6px;
left: 6px;
padding: 2px 7px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.66);
color: #fff;
font-size: 11px;
font-weight: 600;
backdrop-filter: blur(4px);
}
.progress {
position: absolute;
left: 6px;
right: 6px;
bottom: 6px;
height: 3px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
overflow: hidden;
}
.progress i {
display: block;
height: 100%;
background: var(--accent);
}
.sm .title {
font-size: 12px;
}
.lg .title {
font-size: 14px;
}
</style>