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.
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: 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>
|