Vellum/web/src/lib/components/Reader.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

164 lines
4.4 KiB
Svelte

<script lang="ts">
import ePub from 'epubjs'
import { onMount, onDestroy } from 'svelte'
import { api, ApiError, type Item, type EbookMetadata } from '../api'
let { itemId }: { itemId: number } = $props()
let container = $state<HTMLDivElement>()
let item = $state<Item | null>(null)
let meta = $state<EbookMetadata | null>(null)
let error = $state<string | null>(null)
let loading = $state(true)
let percent = $state(0)
let book: any = null
let rendition: any = null
let saveTimer: number | undefined
onMount(async () => {
try {
const detail = await api.item(itemId)
item = detail.item
if (item.kind !== 'ebooks') return
meta = (await api.ebook(itemId)).metadata
await openBook(detail.progress?.position ?? 0)
window.addEventListener('keydown', onKey)
window.addEventListener('beforeunload', flush)
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not open this item'
} finally {
loading = false
}
})
onDestroy(() => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('beforeunload', flush)
if (saveTimer) clearTimeout(saveTimer)
if (book) book.destroy()
})
async function openBook(startIndex: number) {
book = ePub(api.fileUrl(itemId), { openAs: 'epub' })
rendition = book.renderTo(container, {
width: '100%',
height: '100%',
flow: 'paginated',
spread: 'auto',
})
await rendition.display()
// Resume by spine index. Positions are integers, so a spine index
// round-trips through the progress table without a text column.
if (startIndex > 0) {
const section = book.spine.get(startIndex)
if (section) await rendition.display(section.href)
}
rendition.on('relocated', (loc: any) => {
percent = Math.round((loc?.start?.percentage ?? 0) * 100)
scheduleSave(loc?.start?.index ?? 0, loc?.start?.percentage ?? 0)
})
}
function onKey(event: KeyboardEvent) {
if (event.key === 'ArrowRight') void rendition?.next()
if (event.key === 'ArrowLeft') void rendition?.prev()
}
// Debounced: a fast reader generates a relocated event per page.
function scheduleSave(index: number, pct: number) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = window.setTimeout(() => {
void api.putProgress(itemId, { position: index, percent: pct }).catch(() => {})
}, 900)
}
function flush() {
const loc = rendition?.currentLocation()
if (loc?.start) {
void api.putProgress(itemId, {
position: loc.start.index ?? 0,
percent: loc.start.percentage ?? 0,
})
}
}
</script>
{#if error}
<div class="alert">{error}</div>
{:else if item && item.kind !== 'ebooks'}
<div class="empty">
<h3>{item.title}</h3>
<p>There is no reader for “{item.kind}” yet.</p>
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
</div>
{:else}
<div class="stage">
<div class="viewport" bind:this={container}></div>
{#if loading}
<div class="overlay"><div class="spinner"></div></div>
{/if}
</div>
<footer class="bar">
<button class="btn btn-ghost" onclick={() => rendition?.prev()} disabled={loading}>←</button>
<div class="info">
{#if meta}
<strong>{meta.title}</strong>
{#if meta.authors?.length}<span class="muted">{meta.authors.join(', ')}</span>{/if}
{/if}
</div>
<span class="pct mono">{percent}%</span>
<button class="btn btn-ghost" onclick={() => rendition?.next()} disabled={loading}>→</button>
</footer>
{/if}
<style>
.stage {
position: relative;
height: calc(100vh - 52px - 96px);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-raised);
overflow: hidden;
}
.viewport {
height: 100%;
}
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: var(--bg-raised);
}
.bar {
display: flex;
align-items: center;
gap: var(--space-3);
height: 56px;
padding: 0 var(--space-2);
}
.info {
display: flex;
flex-direction: column;
line-height: 1.25;
min-width: 0;
flex: 1;
}
.info strong {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info .muted {
font-size: 12px;
}
.pct {
color: var(--text-faint);
}
</style>