Vellum/web/src/lib/components/Libraries.svelte
mithral dc95a9f78a Redesign the user-facing experience: home, browse, item pages
The app looked like an admin panel. This is the product people actually see
when they open their library.

- Home: a landing screen with a Continue shelf (progress bars, straight from
  the progress table) and per-type shelves of recent additions. One request
  feeds it, so it does not pop in piece by piece.
- Browse: cover grid per media type with type chips, plus browse-all. Powered
  by a new GET /items that lists across every visible library, filtered by
  kind, through the same access chokepoint.
- Item page: cover, kind, title, authors, progress, a primary action that says
  Continue when you have started, description, and the file list for
  multi-file items. Cards now open the item page rather than jumping straight
  into a reader.
- Search: results grouped by media type, cover-forward.
- Navigation moved from "libraries" to "media types" — you browse Ebooks, not
  "the Ebooks folder". Libraries became an admin concern.
- Design system: dark by default, cover-forward, one accent, tighter type
  scale, horizontal shelves, skeleton loaders.
- media: GET /home, GET /items, and authors on item detail.

Verified by screenshot in a live instance: home shelves, browse grid, and item
page all render correctly.
2026-10-09 21:38:11 -04:00

223 lines
6 KiB
Svelte

<script lang="ts">
import { api, ApiError } from '../api'
import { libraries, mediaTypes, user, refreshLibraries } from '../session'
import { go } from '../nav'
let { kindFilter = null }: { kindFilter?: string | null } = $props()
let showForm = $state(false)
let busy = $state(false)
let error = $state<string | null>(null)
let notice = $state<string | null>(null)
let name = $state('')
let kind = $state('ebooks')
let rootPath = $state('')
const shown = $derived(kindFilter ? $libraries.filter((l) => l.kind === kindFilter) : $libraries)
async function create(event: SubmitEvent) {
event.preventDefault()
busy = true
error = null
try {
await api.createLibrary(name.trim(), kind, rootPath.trim())
name = ''
rootPath = ''
showForm = false
await refreshLibraries()
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not create the library'
} finally {
busy = false
}
}
async function scan(id: number) {
busy = true
error = null
notice = null
try {
const res = await api.scanLibrary(id)
notice = `Scanned ${res.scanned}: ${res.added} added, ${res.updated} updated, ${res.missing} missing.`
} catch (err) {
error = err instanceof ApiError ? err.message : 'scan failed'
} finally {
busy = false
}
}
async function enrich(id: number) {
busy = true
error = null
notice = null
try {
const res = await api.enrichLibrary(id)
notice = `Queued metadata lookup for ${res.queued} item${res.queued === 1 ? '' : 's'}. Titles and covers will fill in shortly.`
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not queue enrichment'
} finally {
busy = false
}
}
async function remove(id: number, label: string) {
if (!confirm(`Delete the library "${label}"? Items and progress for it are removed.`)) return
busy = true
try {
await api.deleteLibrary(id)
await refreshLibraries()
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not delete the library'
} finally {
busy = false
}
}
</script>
{#if error}<div class="alert">{error}</div>{/if}
{#if notice}<div class="ok">{notice}</div>{/if}
{#if $user?.is_admin}
<div class="toolbar">
<button class="btn" onclick={() => (showForm = !showForm)}>
{showForm ? 'Cancel' : 'Add library'}
</button>
</div>
{/if}
{#if showForm && $user?.is_admin}
<form class="card form" onsubmit={create}>
<div class="row">
<div class="field">
<label for="lib-name">Name</label>
<input id="lib-name" class="input" bind:value={name} required placeholder="Ebooks" />
</div>
<div class="field">
<label for="lib-kind">Type</label>
<select id="lib-kind" class="input" bind:value={kind}>
{#each $mediaTypes as t (t.Kind)}
<option value={t.Kind}>{t.Name}</option>
{/each}
</select>
</div>
</div>
<div class="field">
<label for="lib-root">Root path on the server</label>
<input id="lib-root" class="input mono" bind:value={rootPath} required placeholder="/mnt/storage/media/ebooks" />
</div>
<button class="btn btn-primary" type="submit" disabled={busy || !name || !rootPath}>
Create library
</button>
</form>
{/if}
{#if shown.length === 0}
<div class="empty">
<h3>No libraries yet</h3>
<p>
{#if $user?.is_admin}
Add a library and point it at a folder on the server, then run a scan.
{:else}
You do not have access to any libraries yet. Ask an administrator to grant one.
{/if}
</p>
</div>
{:else}
<div class="grid">
{#each shown as lib (lib.id)}
<div class="card lib">
<button class="open" onclick={() => go({ name: 'browse', libraryId: lib.id })}>
<span class="kind">{lib.kind}</span>
<strong>{lib.name}</strong>
<span class="path mono">{lib.root_path}</span>
</button>
{#if $user?.is_admin}
<div class="actions">
<button class="btn btn-ghost" onclick={() => scan(lib.id)} disabled={busy}>Scan</button>
<button class="btn btn-ghost" onclick={() => enrich(lib.id)} disabled={busy}>Enrich</button>
<button class="btn btn-ghost danger" onclick={() => remove(lib.id, lib.name)} disabled={busy}>
Delete
</button>
</div>
{/if}
</div>
{/each}
</div>
{/if}
<style>
.toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: var(--space-4);
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
margin-bottom: var(--space-5);
}
.row {
display: grid;
grid-template-columns: 1fr 180px;
gap: var(--space-3);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-3);
}
.lib {
display: flex;
flex-direction: column;
}
.open {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
width: 100%;
padding: var(--space-4);
border: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
border-radius: var(--radius-lg);
}
.open:hover {
background: var(--bg-hover);
}
.kind {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
}
.path {
color: var(--text-faint);
word-break: break-all;
}
.actions {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--border);
}
.danger {
color: var(--danger);
}
.ok {
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--success) 40%, var(--border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--success) 8%, transparent);
margin-bottom: var(--space-4);
font-size: 13px;
}
.alert {
margin-bottom: var(--space-4);
}
</style>