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/*.
114 lines
2.7 KiB
Svelte
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>
|