Vellum/web/src/lib/components/Libraries.svelte
mithral 8178502b73 Appearance settings: text size and card size, and a larger default
- All 50 font-size declarations converted from px to rem, so a single root
  value scales headings, labels and body together. The root is now 15px, a
  deliberate ~7% increase over the previous 14px.
- Card width and grid column width are now --card-w / --grid-min variables,
  driven by --card-scale independently of text. Defaults bumped too: shelf
  cards 150 -> 160px, grid columns 148 -> 158px.
- Appearance panel (nav item, visible to everyone): Text size and Card size
  as segmented controls, a live preview, and a reset. Settings are stored in
  localStorage, per device rather than per account, because a TV across the
  room wants bigger text than a laptop and the same person uses both.
- nav: 'settings' was missing from the View union after the home rewrite;
  added back along with the Appearance entry.

Verified in the built bundle: root font 15px by default; "Larger" scales it to
19.2px; "Large" cards scale 160 to 192px; both persist across a reload and are
independent of each other.
2026-10-10 17:07:43 -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: .786rem;
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: .929rem;
}
.alert {
margin-bottom: var(--space-4);
}
</style>