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/*.
164 lines
4.4 KiB
Svelte
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>
|