- 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.
223 lines
6 KiB
Svelte
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>
|