Vellum/web/src/lib/components/LibraryView.svelte
mithral 5283e073f5 Web UI: Svelte app, embedded into the binary
The frontend the app needs to be usable at all.

- web/: Svelte 5 + Vite + TypeScript SPA. Login, app shell with per-type nav,
  library list/create/scan/delete, item grid with covers, admin user
  management, and an epub.js reader that syncs progress.
- Reader resumes by spine index — an integer — which is why the polymorphic
  progress table needs no text column.
- Session state uses classic stores in a plain .ts module rather than runes in
  a .svelte.ts module, avoiding special module resolution.
- Embedding: web/embed.go embeds web/dist. A committed .gitkeep keeps the
  directive valid before the frontend is built, so `go build ./...` never
  fails. Unknown non-API paths serve index.html for client routes; /api/* and
  /healthz always return JSON and never fall back to HTML.
- Dockerfile builds the UI in a node stage and overlays dist into the Go stage.
- pnpm 12 needs allowBuilds in pnpm-workspace.yaml; esbuild approved, the two
  unused transitive scripts explicitly denied so install exits 0.

svelte-check: 0 errors, 0 warnings. Verified served: SPA shell at /, route
fallback, hashed asset, and JSON 404 on /api/*.
2026-10-09 16:40:58 -04:00

114 lines
2.7 KiB
Svelte

<script lang="ts">
import { api, ApiError, type Item } from '../api'
import { go } from '../nav'
let { libraryId }: { libraryId: number } = $props()
let items = $state<Item[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
$effect(() => {
void load(libraryId)
})
async function load(id: number) {
loading = true
error = null
try {
const res = await api.items(id)
items = res.items
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not load items'
} finally {
loading = false
}
}
</script>
{#if loading}
<div class="center"><div class="spinner"></div></div>
{:else if error}
<div class="alert">{error}</div>
{:else if items.length === 0}
<div class="empty">
<h3>Nothing here yet</h3>
<p>Run a scan on this library to index the files on disk.</p>
</div>
{:else}
<div class="grid">
{#each items as item (item.id)}
<button class="tile" onclick={() => go({ name: 'reader', itemId: item.id, title: item.title })}>
<div class="cover">
<img src={api.coverUrl(item.id)} alt="" loading="lazy" onerror={(e) => (e.currentTarget as HTMLImageElement).remove()} />
<span class="fallback">{item.title.slice(0, 1).toUpperCase()}</span>
</div>
<span class="title" title={item.title}>{item.title}</span>
</button>
{/each}
</div>
{/if}
<style>
.center {
display: grid;
place-items: center;
padding: var(--space-6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-4);
}
.tile {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
}
.cover {
position: relative;
aspect-ratio: 2 / 3;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-raised);
overflow: hidden;
transition: border-color 0.12s ease, transform 0.12s ease;
}
.tile:hover .cover {
border-color: var(--accent);
transform: translateY(-2px);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.fallback {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 40px;
font-weight: 700;
color: var(--text-faint);
}
.cover img + .fallback {
display: none;
}
.title {
font-size: 13px;
line-height: 1.3;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>