UI: make it move like it is alive
Not new features — motion, focus, and depth, kept restrained. - Motion tokens: one easing curve and three durations, used everywhere, so the app moves as a single thing rather than component by component. Honours prefers-reduced-motion by disabling all of it. - View transitions: navigating fades and rises the content instead of swapping it, and each view starts scrolled to the top. - Topbar frosts once the content scrolls, so the header separates from the page without a permanent bar of chrome. - Featured area on Home: the item you are most likely to act on (something in progress, else the newest), with the cover as an ambient blurred wash. The colour comes from the artwork, not a decorative gradient, so it belongs to whatever is on screen. - Cards: lift and scale on hover, a scrim so the action glyph always has contrast, a kind-appropriate glyph (play / read / expand), press feedback, and a visible keyboard focus ring. - Shelves: edge fades and arrow buttons that appear only when the row actually continues that way; the scrollbar is hidden and the row snaps. - Item page: ambient wash from its own cover. - Global :focus-visible ring. Verified in the built bundle: featured renders the in-progress item, hover scales a 192px card to 197.8px with the glyph visible, and the item page tints from its artwork.
This commit is contained in:
parent
8178502b73
commit
83c59522e7
7 changed files with 547 additions and 45 deletions
|
|
@ -50,6 +50,14 @@
|
||||||
--card-scale: 1;
|
--card-scale: 1;
|
||||||
--card-w: calc(160px * var(--card-scale));
|
--card-w: calc(160px * var(--card-scale));
|
||||||
--grid-min: calc(158px * var(--card-scale));
|
--grid-min: calc(158px * var(--card-scale));
|
||||||
|
|
||||||
|
/* Motion. Short, eased, and purposeful: things respond to you, nothing
|
||||||
|
bounces. Every transition uses one of these so the app moves as one. */
|
||||||
|
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
--t-fast: 120ms;
|
||||||
|
--t-base: 200ms;
|
||||||
|
--t-slow: 320ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
|
|
@ -130,6 +138,25 @@ select {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keyboard focus is visible everywhere; pointer clicks stay clean. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anyone who has asked the system for less motion gets none of it. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Controls ───────────────────────────────────────────────────────── */
|
/* ── Controls ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,16 @@
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
badge?: string
|
badge?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
|
// The glyph hints at what opening the item does. It is the only decoration
|
||||||
|
// on the card, and it only appears on hover.
|
||||||
|
const glyph = $derived(
|
||||||
|
item.kind === 'audiobooks' || item.kind === 'music' || item.kind === 'games'
|
||||||
|
? 'play'
|
||||||
|
: item.kind === 'photos'
|
||||||
|
? 'expand'
|
||||||
|
: 'read',
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button class="tile {size}" onclick={() => go({ name: 'item', id: item.id })}>
|
<button class="tile {size}" onclick={() => go({ name: 'item', id: item.id })}>
|
||||||
|
|
@ -30,6 +40,22 @@
|
||||||
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
|
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
|
||||||
/>
|
/>
|
||||||
<span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span>
|
<span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span>
|
||||||
|
|
||||||
|
<span class="scrim"></span>
|
||||||
|
<span class="action" aria-hidden="true">
|
||||||
|
{#if glyph === 'play'}
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M8 5.5v13l11-6.5z" /></svg>
|
||||||
|
{:else if glyph === 'expand'}
|
||||||
|
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H11v16H5.5A1.5 1.5 0 0 1 4 18.5zM20 5.5A1.5 1.5 0 0 0 18.5 4H13v16h5.5a1.5 1.5 0 0 0 1.5-1.5z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
{#if badge}
|
{#if badge}
|
||||||
<span class="badge">{badge}</span>
|
<span class="badge">{badge}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -37,6 +63,7 @@
|
||||||
<span class="progress"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></span>
|
<span class="progress"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
<span class="title">{item.title}</span>
|
<span class="title">{item.title}</span>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
|
|
@ -60,16 +87,72 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.tile:hover .cover {
|
.cover {
|
||||||
transform: translateY(-3px);
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
transition:
|
||||||
|
transform var(--t-base) var(--ease),
|
||||||
|
box-shadow var(--t-base) var(--ease);
|
||||||
|
}
|
||||||
|
.tile:hover .cover,
|
||||||
|
.tile:focus-visible .cover {
|
||||||
|
transform: translateY(-4px) scale(1.03);
|
||||||
box-shadow: var(--shadow-lift);
|
box-shadow: var(--shadow-lift);
|
||||||
}
|
}
|
||||||
|
.tile:active .cover {
|
||||||
|
transform: translateY(-1px) scale(1.005);
|
||||||
|
transition-duration: var(--t-fast);
|
||||||
|
}
|
||||||
|
.tile:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.tile:focus-visible .cover {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
.tile:hover .title {
|
.tile:hover .title {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.cover {
|
|
||||||
width: 100%;
|
/* A soft darkening so the action glyph always has contrast, whatever the
|
||||||
|
artwork underneath happens to be. */
|
||||||
|
.scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.15) 55%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--t-base) var(--ease);
|
||||||
}
|
}
|
||||||
|
.tile:hover .scrim,
|
||||||
|
.tile:focus-visible .scrim {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
margin: -21px 0 0 -21px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
color: #101014;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.86);
|
||||||
|
transition:
|
||||||
|
opacity var(--t-base) var(--ease),
|
||||||
|
transform var(--t-base) var(--ease);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.tile:hover .action,
|
||||||
|
.tile:focus-visible .action {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -77,7 +160,7 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.title {
|
.title {
|
||||||
font-size: .929rem;
|
font-size: 0.929rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -88,7 +171,7 @@
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: .821rem;
|
font-size: 0.821rem;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
@ -99,12 +182,11 @@
|
||||||
top: 6px;
|
top: 6px;
|
||||||
left: 6px;
|
left: 6px;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 999px;
|
border-radius: 6px;
|
||||||
background: rgba(0, 0, 0, 0.66);
|
background: rgba(0, 0, 0, 0.68);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: .786rem;
|
font-size: 0.786rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
}
|
||||||
.progress {
|
.progress {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -122,9 +204,9 @@
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
.sm .title {
|
.sm .title {
|
||||||
font-size: .857rem;
|
font-size: 0.857rem;
|
||||||
}
|
}
|
||||||
.lg .title {
|
.lg .title {
|
||||||
font-size: 1.000rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
169
web/src/lib/components/Featured.svelte
Normal file
169
web/src/lib/components/Featured.svelte
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { api } from '../api'
|
||||||
|
import { mediaTypes } from '../session'
|
||||||
|
import { go } from '../nav'
|
||||||
|
|
||||||
|
let {
|
||||||
|
item,
|
||||||
|
kicker,
|
||||||
|
percent = 0,
|
||||||
|
}: {
|
||||||
|
item: { id: number; title: string; kind?: string }
|
||||||
|
kicker: string
|
||||||
|
percent?: number
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
const kindLabel = $derived(
|
||||||
|
($mediaTypes as { Kind: string; Name: string }[]).find((t) => t.Kind === item.kind)?.Name ??
|
||||||
|
item.kind ??
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
|
||||||
|
const action = $derived(
|
||||||
|
item.kind === 'audiobooks' || item.kind === 'music' || item.kind === 'games'
|
||||||
|
? 'Play'
|
||||||
|
: item.kind === 'photos'
|
||||||
|
? 'View'
|
||||||
|
: 'Read',
|
||||||
|
)
|
||||||
|
|
||||||
|
// The ambient wash is the item's own cover, blurred. The colour comes from
|
||||||
|
// the artwork rather than a decorative gradient, so it is different for every
|
||||||
|
// item and always belongs to what is on screen.
|
||||||
|
const wash = $derived(`url("${api.coverUrl(item.id)}")`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="featured">
|
||||||
|
<div class="wash" style="background-image: {wash}"></div>
|
||||||
|
<div class="scrim"></div>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<span class="kicker">{kicker}</span>
|
||||||
|
<h1>{item.title}</h1>
|
||||||
|
<p class="meta">{kindLabel}{#if percent > 0} · {Math.round(percent * 100)}% complete{/if}</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary btn-lg" onclick={() => go({ name: 'reader', itemId: item.id, title: item.title })}>
|
||||||
|
{percent > 0 ? 'Continue' : action}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-lg" onclick={() => go({ name: 'item', id: item.id })}>Details</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if percent > 0}
|
||||||
|
<div class="bar"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="poster">
|
||||||
|
<div class="cover">
|
||||||
|
<img src={api.coverUrl(item.id)} alt="" onerror={(e) => (e.currentTarget as HTMLImageElement).remove()} />
|
||||||
|
<span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.featured {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-6);
|
||||||
|
min-height: clamp(240px, 34vh, 380px);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
padding: var(--space-6);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wash {
|
||||||
|
position: absolute;
|
||||||
|
inset: -20%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
filter: blur(48px) saturate(1.5);
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(1.1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(to top, var(--surface) 4%, transparent 70%),
|
||||||
|
linear-gradient(to right, color-mix(in srgb, var(--surface) 88%, transparent) 30%, transparent 85%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
font-size: 0.857rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(1.7rem, 3.2vw, 2.4rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.929rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 3px;
|
||||||
|
width: min(320px, 100%);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--text) 18%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster {
|
||||||
|
position: relative;
|
||||||
|
margin-left: auto;
|
||||||
|
align-self: center;
|
||||||
|
flex: none;
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
.poster .cover {
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.featured {
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
.poster {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import { go } from '../nav'
|
import { go } from '../nav'
|
||||||
import Shelf from './Shelf.svelte'
|
import Shelf from './Shelf.svelte'
|
||||||
import Card from './Card.svelte'
|
import Card from './Card.svelte'
|
||||||
|
import Featured from './Featured.svelte'
|
||||||
|
|
||||||
let home = $state<Home | null>(null)
|
let home = $state<Home | null>(null)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
|
@ -48,6 +49,26 @@
|
||||||
const hasAnything = $derived(
|
const hasAnything = $derived(
|
||||||
continueItems.length + mostViewed.length + suggestions.length + recent.length + explore.length > 0,
|
continueItems.length + mostViewed.length + suggestions.length + recent.length + explore.length > 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The featured item is whatever the user is most likely to act on: something
|
||||||
|
// in progress, otherwise the newest arrival. It gives the page a focal point
|
||||||
|
// instead of starting straight into rows.
|
||||||
|
const featured = $derived(continueItems[0] ?? recent[0] ?? null)
|
||||||
|
const kicker = $derived.by(() => {
|
||||||
|
if (!featured) return ''
|
||||||
|
if (continueItems[0]) {
|
||||||
|
switch (featured.kind) {
|
||||||
|
case 'audiobooks':
|
||||||
|
case 'music':
|
||||||
|
return 'Continue listening'
|
||||||
|
case 'games':
|
||||||
|
return 'Continue playing'
|
||||||
|
default:
|
||||||
|
return 'Continue reading'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Recently added'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|
@ -76,6 +97,10 @@
|
||||||
{:else}
|
{:else}
|
||||||
<p class="hello muted">Welcome back, {greeting}.</p>
|
<p class="hello muted">Welcome back, {greeting}.</p>
|
||||||
|
|
||||||
|
{#if featured}
|
||||||
|
<Featured item={featured} {kicker} percent={percentFor(featured.id)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Shelf title="Continue" items={continueItems} {percentFor} />
|
<Shelf title="Continue" items={continueItems} {percentFor} />
|
||||||
|
|
||||||
<Shelf
|
<Shelf
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@
|
||||||
<div class="alert">{error}</div>
|
<div class="alert">{error}</div>
|
||||||
{:else if detail}
|
{:else if detail}
|
||||||
<div class="detail">
|
<div class="detail">
|
||||||
|
<div class="wash" style="background-image: url('{api.coverUrl(detail.item.id)}')"></div>
|
||||||
|
<div class="scrim"></div>
|
||||||
|
|
||||||
<div class="poster">
|
<div class="poster">
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<img
|
<img
|
||||||
|
|
@ -125,11 +128,37 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.detail {
|
.detail {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 260px 1fr;
|
grid-template-columns: 260px 1fr;
|
||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
|
padding: var(--space-5);
|
||||||
|
margin: calc(var(--space-1) * -1);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* Ambient colour taken from the item's own cover, so each page is tinted by
|
||||||
|
what you opened rather than by a decorative gradient. */
|
||||||
|
.wash {
|
||||||
|
position: absolute;
|
||||||
|
inset: -25%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
filter: blur(60px) saturate(1.4);
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--bg) 88%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.poster,
|
||||||
|
.info {
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.poster .cover {
|
.poster .cover {
|
||||||
aspect-ratio: 2 / 3;
|
aspect-ratio: 2 / 3;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
import Card from './Card.svelte'
|
import Card from './Card.svelte'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -16,6 +17,35 @@
|
||||||
badgeFor?: (item: { id: number; title: string; kind?: string }) => string
|
badgeFor?: (item: { id: number; title: string; kind?: string }) => string
|
||||||
onSeeAll?: (() => void) | null
|
onSeeAll?: (() => void) | null
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
|
let scroller = $state<HTMLDivElement>()
|
||||||
|
let canLeft = $state(false)
|
||||||
|
let canRight = $state(false)
|
||||||
|
|
||||||
|
// Edge fades and arrows only appear when there is actually more to see, so
|
||||||
|
// the shelf never implies content that is not there.
|
||||||
|
function update() {
|
||||||
|
if (!scroller) return
|
||||||
|
const max = scroller.scrollWidth - scroller.clientWidth
|
||||||
|
canLeft = scroller.scrollLeft > 4
|
||||||
|
canRight = scroller.scrollLeft < max - 4
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(direction: 1 | -1) {
|
||||||
|
if (!scroller) return
|
||||||
|
scroller.scrollBy({ left: direction * scroller.clientWidth * 0.8, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
update()
|
||||||
|
const ro = new ResizeObserver(update)
|
||||||
|
if (scroller) ro.observe(scroller)
|
||||||
|
window.addEventListener('resize', update)
|
||||||
|
return () => {
|
||||||
|
ro.disconnect()
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
|
|
@ -23,20 +53,34 @@
|
||||||
<header>
|
<header>
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
{#if onSeeAll}
|
{#if onSeeAll}
|
||||||
<button class="see-all" onclick={onSeeAll}>See all →</button>
|
<button class="see-all" onclick={onSeeAll}>See all</button>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
<div class="row">
|
|
||||||
{#each items as item (item.id)}
|
<div class="viewport" class:left={canLeft} class:right={canRight}>
|
||||||
<div class="slot">
|
<div class="row" bind:this={scroller} onscroll={update}>
|
||||||
<Card
|
{#each items as item (item.id)}
|
||||||
{item}
|
<div class="slot">
|
||||||
percent={percentFor(item.id)}
|
<Card
|
||||||
subtitle={subtitleFor(item)}
|
{item}
|
||||||
badge={badgeFor(item)}
|
percent={percentFor(item.id)}
|
||||||
/>
|
subtitle={subtitleFor(item)}
|
||||||
</div>
|
badge={badgeFor(item)}
|
||||||
{/each}
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="arrow left" onclick={() => page(-1)} aria-label="Scroll left" tabindex={canLeft ? 0 : -1}>
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="arrow right" onclick={() => page(1)} aria-label="Scroll right" tabindex={canRight ? 0 : -1}>
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -52,19 +96,53 @@
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.214rem;
|
font-size: 1.143rem;
|
||||||
}
|
}
|
||||||
.see-all {
|
.see-all {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: .929rem;
|
font-size: 0.857rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
transition: color var(--t-fast) var(--ease);
|
||||||
}
|
}
|
||||||
.see-all:hover {
|
.see-all:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade the edge only when the row continues that way. */
|
||||||
|
.viewport::before,
|
||||||
|
.viewport::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: var(--space-2);
|
||||||
|
width: 48px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--t-base) var(--ease);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.viewport::before {
|
||||||
|
left: 0;
|
||||||
|
background: linear-gradient(to right, var(--bg), transparent);
|
||||||
|
}
|
||||||
|
.viewport::after {
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to left, var(--bg), transparent);
|
||||||
|
}
|
||||||
|
.viewport.left::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.viewport.right::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
|
|
@ -73,10 +151,62 @@
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: var(--space-2);
|
padding-bottom: var(--space-2);
|
||||||
scroll-snap-type: x proximity;
|
scroll-snap-type: x proximity;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.row::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.slot {
|
.slot {
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--t-base) var(--ease), background var(--t-fast) var(--ease);
|
||||||
|
}
|
||||||
|
.arrow.left {
|
||||||
|
left: -12px;
|
||||||
|
}
|
||||||
|
.arrow.right {
|
||||||
|
right: -12px;
|
||||||
|
}
|
||||||
|
.arrow:hover {
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
/* Arrows only exist on pointer devices; touch users swipe. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.viewport:hover .arrow.left {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.viewport.right:hover .arrow.right {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.viewport:not(.left) .arrow.left {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.viewport:not(.right) .arrow.right {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (hover: none) {
|
||||||
|
.arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.row {
|
.row {
|
||||||
grid-auto-columns: calc(var(--card-w) * 0.8);
|
grid-auto-columns: calc(var(--card-w) * 0.8);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { fly } from 'svelte/transition'
|
||||||
|
import { cubicOut } from 'svelte/easing'
|
||||||
import { user, logout, mediaTypes, counts } from '../session'
|
import { user, logout, mediaTypes, counts } from '../session'
|
||||||
import { view, go, type View } from '../nav'
|
import { view, go, type View } from '../nav'
|
||||||
import Home from './Home.svelte'
|
import Home from './Home.svelte'
|
||||||
|
|
@ -11,6 +13,25 @@
|
||||||
import Settings from './Settings.svelte'
|
import Settings from './Settings.svelte'
|
||||||
|
|
||||||
let searchBox = $state('')
|
let searchBox = $state('')
|
||||||
|
let scrolled = $state(false)
|
||||||
|
let contentEl = $state<HTMLDivElement>()
|
||||||
|
|
||||||
|
// A stable key for the current view, so navigating transitions the content
|
||||||
|
// instead of swapping it instantly.
|
||||||
|
const viewKey = $derived(JSON.stringify($view))
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
scrolled = (contentEl?.scrollTop ?? 0) > 8
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each new view starts at the top, and the topbar un-frosts.
|
||||||
|
$effect(() => {
|
||||||
|
viewKey
|
||||||
|
if (contentEl) {
|
||||||
|
contentEl.scrollTo({ top: 0 })
|
||||||
|
scrolled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const types = $derived(
|
const types = $derived(
|
||||||
($mediaTypes as { Kind: string; Name: string }[]).filter((t) => ($counts[t.Kind] ?? 0) > 0),
|
($mediaTypes as { Kind: string; Name: string }[]).filter((t) => ($counts[t.Kind] ?? 0) > 0),
|
||||||
|
|
@ -99,7 +120,7 @@
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<header class="topbar">
|
<header class="topbar" class:scrolled>
|
||||||
{#if title($view)}
|
{#if title($view)}
|
||||||
<h1>{title($view)}</h1>
|
<h1>{title($view)}</h1>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -116,24 +137,31 @@
|
||||||
</form>
|
</form>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content" bind:this={contentEl} onscroll={onScroll}>
|
||||||
{#if $view.name === 'home'}
|
{#key viewKey}
|
||||||
<Home />
|
<div
|
||||||
{:else if $view.name === 'browse'}
|
class="view"
|
||||||
<Browse kind={$view.kind} libraryId={$view.libraryId} />
|
in:fly={{ y: 8, duration: 220, easing: cubicOut }}
|
||||||
{:else if $view.name === 'item'}
|
>
|
||||||
<ItemDetail id={$view.id} />
|
{#if $view.name === 'home'}
|
||||||
{:else if $view.name === 'reader'}
|
<Home />
|
||||||
<Reader itemId={$view.itemId} />
|
{:else if $view.name === 'browse'}
|
||||||
{:else if $view.name === 'search'}
|
<Browse kind={$view.kind} libraryId={$view.libraryId} />
|
||||||
<SearchResults query={$view.query} />
|
{:else if $view.name === 'item'}
|
||||||
{:else if $view.name === 'libraries'}
|
<ItemDetail id={$view.id} />
|
||||||
<Libraries />
|
{:else if $view.name === 'reader'}
|
||||||
{:else if $view.name === 'users'}
|
<Reader itemId={$view.itemId} />
|
||||||
<Users />
|
{:else if $view.name === 'search'}
|
||||||
{:else if $view.name === 'settings'}
|
<SearchResults query={$view.query} />
|
||||||
<Settings />
|
{:else if $view.name === 'libraries'}
|
||||||
{/if}
|
<Libraries />
|
||||||
|
{:else if $view.name === 'users'}
|
||||||
|
<Users />
|
||||||
|
{:else if $view.name === 'settings'}
|
||||||
|
<Settings />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -269,6 +297,18 @@
|
||||||
height: 60px;
|
height: 60px;
|
||||||
padding: 0 var(--space-6);
|
padding: 0 var(--space-6);
|
||||||
flex: none;
|
flex: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
transition:
|
||||||
|
background var(--t-base) var(--ease),
|
||||||
|
border-color var(--t-base) var(--ease);
|
||||||
|
}
|
||||||
|
/* Frosts once the content moves, so the header separates from the page
|
||||||
|
without a permanent bar of chrome. */
|
||||||
|
.topbar.scrolled {
|
||||||
|
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
border-bottom-color: var(--border);
|
||||||
}
|
}
|
||||||
.search {
|
.search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue