The skeleton, not features. Two things felt rushed: the colour had no point of view, and type and shape came from ad-hoc values. Design language - Palette reworked to warm-neutral dark with a single muted gold accent (#d9a441). The chrome is deliberately colourless because the artwork is the colour in this app; gold carries emphasis without competing with covers. Light mode re-tuned to match. - A real type scale (--fs-xs … --fs-3xl) replaces the ad-hoc rem values, with fixed line heights and letter-spacing per level. Headings are 620 weight and tighter; body is a consistent 15px. - Radii tightened to a 5/8/12 scale, one shadow pair, and the primary button now uses dark ink on gold for contrast. Featured carousel - The banner rotates through up to six items — in progress first, then most viewed, then newest — drawn only from the libraries the user was granted, so it can never show something they cannot open. - Auto-advances every 8s, pauses on hover and focus, and has dots plus arrows for manual control. Slides crossfade, and the ambient wash changes with them. Verified in the built bundle: accent resolves to #d9a441, six slides, rotation confirmed across three items, and hover pauses it.
293 lines
7.3 KiB
Svelte
293 lines
7.3 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy } from 'svelte'
|
|
import { fade } from 'svelte/transition'
|
|
import { api } from '../api'
|
|
import { mediaTypes } from '../session'
|
|
import { go } from '../nav'
|
|
|
|
let {
|
|
items,
|
|
kickerFor,
|
|
percentFor = () => 0,
|
|
}: {
|
|
items: { id: number; title: string; kind?: string }[]
|
|
kickerFor: (item: { id: number; title: string; kind?: string }) => string
|
|
percentFor?: (id: number) => number
|
|
} = $props()
|
|
|
|
const ROTATE_MS = 8000
|
|
|
|
let index = $state(0)
|
|
let paused = $state(false)
|
|
let timer: number | undefined
|
|
|
|
const current = $derived(items[index])
|
|
|
|
function start() {
|
|
stop()
|
|
if (items.length < 2) return
|
|
timer = window.setInterval(() => {
|
|
if (!paused) index = (index + 1) % items.length
|
|
}, ROTATE_MS)
|
|
}
|
|
|
|
function stop() {
|
|
if (timer) clearInterval(timer)
|
|
timer = undefined
|
|
}
|
|
|
|
function goTo(i: number) {
|
|
index = (i + items.length) % items.length
|
|
start() // restart the dwell after a manual move
|
|
}
|
|
|
|
// Re-arm whenever the set of items changes.
|
|
$effect(() => {
|
|
items.length
|
|
start()
|
|
return stop
|
|
})
|
|
|
|
onDestroy(stop)
|
|
|
|
const kindLabel = (item: { kind?: string }) =>
|
|
($mediaTypes as { Kind: string; Name: string }[]).find((t) => t.Kind === item.kind)?.Name ??
|
|
item.kind ??
|
|
''
|
|
|
|
const action = (item: { kind?: string }) =>
|
|
item.kind === 'audiobooks' || item.kind === 'music' || item.kind === 'games'
|
|
? 'Play'
|
|
: item.kind === 'photos'
|
|
? 'View'
|
|
: 'Read'
|
|
</script>
|
|
|
|
{#if current}
|
|
<section
|
|
class="featured"
|
|
aria-roledescription="carousel"
|
|
aria-label="Featured"
|
|
onmouseenter={() => (paused = true)}
|
|
onmouseleave={() => (paused = false)}
|
|
onfocusin={() => (paused = true)}
|
|
onfocusout={() => (paused = false)}
|
|
>
|
|
{#key index}
|
|
<div class="slide" in:fade={{ duration: 420 }} out:fade={{ duration: 420 }}>
|
|
<div class="wash" style="background-image: url('{api.coverUrl(current.id)}')"></div>
|
|
<div class="scrim"></div>
|
|
|
|
<div class="body">
|
|
<span class="kicker">{kickerFor(current)}</span>
|
|
<h1>{current.title}</h1>
|
|
<p class="meta">
|
|
{kindLabel(current)}
|
|
{#if percentFor(current.id) > 0}
|
|
· {Math.round(percentFor(current.id) * 100)}% complete
|
|
{/if}
|
|
</p>
|
|
<div class="actions">
|
|
<button
|
|
class="btn btn-primary btn-lg"
|
|
onclick={() => go({ name: 'reader', itemId: current.id, title: current.title })}
|
|
>
|
|
{percentFor(current.id) > 0 ? 'Continue' : action(current)}
|
|
</button>
|
|
<button class="btn btn-lg" onclick={() => go({ name: 'item', id: current.id })}>Details</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="poster">
|
|
<div class="cover">
|
|
<img
|
|
src={api.coverUrl(current.id)}
|
|
alt=""
|
|
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
|
|
/>
|
|
<span class="placeholder">{(current.title || '?').slice(0, 1).toUpperCase()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/key}
|
|
|
|
{#if items.length > 1}
|
|
<div class="controls">
|
|
<div class="dots" role="tablist" aria-label="Featured items">
|
|
{#each items as item, i (item.id)}
|
|
<button
|
|
class="dot"
|
|
class:active={i === index}
|
|
role="tab"
|
|
aria-selected={i === index}
|
|
aria-label={item.title}
|
|
onclick={() => goTo(i)}
|
|
></button>
|
|
{/each}
|
|
</div>
|
|
<div class="arrows">
|
|
<button class="arrow" onclick={() => goTo(index - 1)} aria-label="Previous">
|
|
<svg viewBox="0 0 24 24" width="16" height="16" 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" onclick={() => goTo(index + 1)} aria-label="Next">
|
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M9 18l6-6-6-6" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
<style>
|
|
.featured {
|
|
position: relative;
|
|
min-height: clamp(240px, 34vh, 380px);
|
|
margin-bottom: var(--space-6);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
overflow: hidden;
|
|
background: var(--surface);
|
|
}
|
|
|
|
.slide {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: var(--space-6);
|
|
padding: var(--space-6);
|
|
}
|
|
|
|
/* Ambient colour taken from the item's own cover, so every slide is tinted
|
|
by its own artwork rather than by a decorative gradient. */
|
|
.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) 90%, transparent) 32%,
|
|
transparent 86%
|
|
);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.body {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
max-width: 560px;
|
|
}
|
|
.kicker {
|
|
font-size: var(--fs-sm);
|
|
font-weight: 600;
|
|
color: var(--accent);
|
|
}
|
|
h1 {
|
|
font-size: clamp(1.7rem, 3.2vw, 2.5rem);
|
|
text-wrap: balance;
|
|
}
|
|
.meta {
|
|
margin: 0;
|
|
font-size: var(--fs-md);
|
|
color: var(--muted);
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
gap: var(--space-3);
|
|
margin-top: var(--space-3);
|
|
}
|
|
|
|
.poster {
|
|
position: relative;
|
|
margin-left: auto;
|
|
align-self: center;
|
|
flex: none;
|
|
width: 150px;
|
|
}
|
|
.poster .cover {
|
|
aspect-ratio: 2 / 3;
|
|
}
|
|
|
|
.controls {
|
|
position: absolute;
|
|
right: var(--space-5);
|
|
bottom: var(--space-5);
|
|
z-index: 2;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-4);
|
|
}
|
|
.dots {
|
|
display: flex;
|
|
gap: 7px;
|
|
}
|
|
.dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
padding: 0;
|
|
border: none;
|
|
border-radius: 50%;
|
|
background: color-mix(in srgb, var(--text) 30%, transparent);
|
|
cursor: pointer;
|
|
transition:
|
|
background var(--t-base) var(--ease),
|
|
width var(--t-base) var(--ease);
|
|
}
|
|
.dot:hover {
|
|
background: color-mix(in srgb, var(--text) 55%, transparent);
|
|
}
|
|
.dot.active {
|
|
width: 20px;
|
|
border-radius: 999px;
|
|
background: var(--accent);
|
|
}
|
|
.arrows {
|
|
display: flex;
|
|
gap: var(--space-2);
|
|
}
|
|
.arrow {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 50%;
|
|
border: 1px solid var(--border-strong);
|
|
background: color-mix(in srgb, var(--surface) 80%, transparent);
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
transition: background var(--t-fast) var(--ease);
|
|
}
|
|
.arrow:hover {
|
|
background: var(--surface-3);
|
|
}
|
|
|
|
@media (max-width: 820px) {
|
|
.slide {
|
|
padding: var(--space-5);
|
|
}
|
|
.poster {
|
|
display: none;
|
|
}
|
|
.body {
|
|
max-width: none;
|
|
}
|
|
}
|
|
</style>
|