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.
130 lines
2.7 KiB
Svelte
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>
|