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/*.
This commit is contained in:
mithral 2026-10-09 16:40:58 -04:00
parent 5c63932419
commit 5283e073f5
29 changed files with 2879 additions and 10 deletions

View file

@ -1,7 +1,21 @@
# syntax=docker/dockerfile:1
# ── Build ──────────────────────────────────────────────────────────────
# Runs on the builder's native platform; cross-compiles to the target arch.
# ── Frontend ───────────────────────────────────────────────────────────
# Built first so the Go stage can embed the result. Runs on the builder's
# native platform; the output is architecture-independent.
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /web
RUN corepack enable
# Dependency layer, cached independently of source changes.
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY web/ ./
RUN pnpm build
# ── Go build ───────────────────────────────────────────────────────────
# CGO_ENABLED=0 works because the SQLite driver (modernc.org/sqlite) is pure
# Go, so there is no cgo and no libc to match against.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
@ -12,11 +26,12 @@ ARG VERSION=dev
WORKDIR /src
# Cache the module layer separately from the source.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Overlay the freshly built UI over the committed placeholder.
COPY --from=web /web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \

View file

@ -1,14 +1,23 @@
# Vellum — developer commands.
#
# Requires Go >= 1.26. On Fedora: sudo dnf install golang
# Requires Go >= 1.26 and pnpm. On Fedora: sudo dnf install golang
# Frontend: cd web && pnpm install
BINARY := vellum
VERSION ?= 0.1.0-dev
VERSION ?= 0.4.0-dev
LDFLAGS := -s -w -X main.version=$(VERSION)
.PHONY: build run test fmt vet tidy check docker clean
.PHONY: build web run test fmt vet tidy check docker clean
build:
# Build the frontend first: the Go build embeds web/dist.
build: web
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/vellum
web:
cd web && pnpm install --silent && pnpm build
# Go-only build, for when the frontend is unchanged.
build-go:
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/vellum
run:
@ -34,4 +43,4 @@ docker:
clean:
rm -f $(BINARY)
rm -rf data config
rm -rf data config web/dist/*.js web/dist/*.css web/dist/assets web/dist/index.html

View file

@ -38,6 +38,7 @@ func New(db *sql.DB, log *slog.Logger, version string) *Server {
mux: http.NewServeMux(),
}
s.routes()
s.mountWeb()
return s
}
@ -56,7 +57,6 @@ func (s *Server) Handle(pattern string, h http.Handler) {
func (s *Server) routes() {
s.mux.HandleFunc("GET /healthz", s.handleHealth)
s.mux.HandleFunc("GET /api/v1/version", s.handleVersion)
s.mux.HandleFunc("/", s.handleNotFound)
}
// Handler returns the fully wrapped root handler.

View file

@ -54,8 +54,9 @@ func TestHealth(t *testing.T) {
func TestNotFoundIsJSON(t *testing.T) {
srv := newTestServer(t)
// API paths must never fall back to the SPA shell.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/nope", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {

View file

@ -0,0 +1,61 @@
package httpapi
import (
"io/fs"
"net/http"
"path"
"strings"
"github.com/mithral/vellum/web"
)
// mountWeb serves the embedded single-page app at the root path.
//
// Unknown non-API paths fall back to index.html so client-side routes work on
// reload. API paths never fall back: a typo in an endpoint returns JSON, not
// an HTML page.
func (s *Server) mountWeb() {
sub, err := fs.Sub(web.Dist, "dist")
if err != nil {
s.mux.HandleFunc("/", s.handleNotFound)
return
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
// Frontend not built into this binary. Keep JSON 404s rather than
// pretending the app exists.
s.log.Warn("web UI not embedded; run the frontend build to include it")
s.mux.HandleFunc("/", s.handleNotFound)
return
}
files := http.FileServer(http.FS(sub))
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if isAPIPath(r.URL.Path) {
s.handleNotFound(w, r)
return
}
if r.URL.Path == "/" || r.URL.Path == "" {
serveIndex(w, index)
return
}
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if f, err := sub.Open(name); err == nil {
_ = f.Close()
files.ServeHTTP(w, r)
return
}
// SPA route: hand back the shell and let the client router decide.
serveIndex(w, index)
})
}
func serveIndex(w http.ResponseWriter, index []byte) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(index)
}
func isAPIPath(p string) bool {
return p == "/healthz" || strings.HasPrefix(p, "/api/")
}

5
web/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/*
!dist/.gitkeep
.vite/
*.local

42
web/README.md Normal file
View file

@ -0,0 +1,42 @@
# Vellum web
Svelte 5 + Vite + TypeScript single-page app. It is built to a static bundle in
`dist/` and embedded into the Go binary, so all asset paths are relative
(`base: './'` in `vite.config.ts`).
## Commands
```bash
pnpm install
pnpm dev # dev server on :5173, proxying /api to :7788
pnpm build # writes dist/
pnpm check # svelte-check
```
Run the Go server on `:7788` (`make run` from the repo root) and `pnpm dev`
proxies API and health requests to it, so cookies and sessions behave exactly
as they will in production.
## Layout
```
src/
main.ts entry point
App.svelte login gate -> shell
app.css design tokens and base styles
lib/
api.ts typed client; mirrors the Go handlers
session.ts auth + library stores
nav.ts view state
components/
Login.svelte
Shell.svelte nav + view switch
Libraries.svelte library list, create, scan, delete
LibraryView.svelte item grid with covers
Reader.svelte epub.js reader with progress sync
Users.svelte admin user management
```
The reader resumes from a spine index, which is why progress positions are
integers: it round-trips through the polymorphic `progress` table without
needing a text column.

3
web/dist/.gitkeep vendored Normal file
View file

@ -0,0 +1,3 @@
The Vite build output goes here. This file keeps the directory present so the
Go embed directive in ../embed.go is always valid, even before the frontend has
been built. Run `pnpm build` (or `make build` from the repo root) to populate it.

13
web/embed.go Normal file
View file

@ -0,0 +1,13 @@
// Package web embeds the built single-page app.
//
// The directory is embedded as-is; `make build` runs the frontend build first.
// A committed .gitkeep keeps the embed directive valid even when the frontend
// has not been built, so `go build ./...` never fails for a missing directory.
package web
import "embed"
// Dist holds the Vite build output (index.html plus hashed assets).
//
//go:embed all:dist
var Dist embed.FS

13
web/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Vellum</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

22
web/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "vellum-web",
"private": true,
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"epubjs": "^0.3.93"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}

1181
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

10
web/pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,10 @@
# pnpm settings live here (not in package.json) as of pnpm 10+.
#
# esbuild ships its platform binary via a postinstall script; Vite needs it.
# core-js and es5-ext are transitive and their scripts are not required, so they
# are explicitly denied rather than left undecided — pnpm fails an install when
# any build script is neither approved nor denied.
allowBuilds:
esbuild: true
core-js: false
es5-ext: false

24
web/src/App.svelte Normal file
View file

@ -0,0 +1,24 @@
<script lang="ts">
import { onMount } from 'svelte'
import { ready, user, bootstrap } from './lib/session'
import Login from './lib/components/Login.svelte'
import Shell from './lib/components/Shell.svelte'
onMount(bootstrap)
</script>
{#if !$ready}
<div class="boot"><div class="spinner"></div></div>
{:else if !$user}
<Login />
{:else}
<Shell />
{/if}
<style>
.boot {
display: grid;
place-items: center;
height: 100%;
}
</style>

243
web/src/app.css Normal file
View file

@ -0,0 +1,243 @@
/* Vellum design tokens.
Restrained by intent: one accent, a tight spacing scale, hairline borders,
and no decorative gradient or shadow noise. Dark is the default surface. */
:root {
--bg: #0b0d10;
--bg-raised: #131619;
--bg-hover: #191d21;
--bg-active: #1f2429;
--border: #23282e;
--border-strong: #333a42;
--text: #e8eaed;
--text-muted: #9aa3ad;
--text-faint: #6b747e;
--accent: #6d7cff;
--accent-hover: #8290ff;
--accent-text: #ffffff;
--danger: #ff6b6b;
--success: #4ade80;
--radius: 6px;
--radius-lg: 10px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
'Noto Sans', sans-serif;
--mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
--nav-width: 220px;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #ffffff;
--bg-raised: #fafafa;
--bg-hover: #f2f3f5;
--bg-active: #e9ebee;
--border: #e4e6ea;
--border-strong: #cfd3d9;
--text: #14171a;
--text-muted: #5c6672;
--text-faint: #8b949e;
--accent: #4f5bd5;
--accent-hover: #3f4bc4;
}
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 600;
letter-spacing: -0.01em;
}
h1 {
font-size: 20px;
}
h2 {
font-size: 16px;
}
h3 {
font-size: 14px;
}
button,
input,
select {
font: inherit;
color: inherit;
}
/* ── Controls ───────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 7px 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg-raised);
color: var(--text);
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: var(--bg-hover);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-ghost {
border-color: transparent;
background: transparent;
}
.btn-ghost:hover:not(:disabled) {
background: var(--bg-hover);
}
.input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
/* ── Surfaces ───────────────────────────────────────────────────────── */
.card {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-raised);
}
.badge {
display: inline-block;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid var(--border-strong);
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.alert {
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--danger) 10%, transparent);
color: var(--text);
font-size: 13px;
}
.muted {
color: var(--text-muted);
}
.faint {
color: var(--text-faint);
}
.mono {
font-family: var(--mono);
font-size: 12px;
}
/* ── Empty state ────────────────────────────────────────────────────── */
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-6) var(--space-4);
border: 1px dashed var(--border-strong);
border-radius: var(--radius-lg);
text-align: center;
color: var(--text-muted);
}
.empty h3 {
color: var(--text);
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border-strong);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

7
web/src/epubjs.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
declare module 'epubjs' {
// epub.js ships no usable types. We only touch a small, stable surface, so a
// permissive declaration is better than an inaccurate hand-written one.
/* eslint-disable @typescript-eslint/no-explicit-any */
const ePub: any
export default ePub
}

160
web/src/lib/api.ts Normal file
View file

@ -0,0 +1,160 @@
// Typed client for the Vellum HTTP API. Every response shape here mirrors a
// Go handler; keep them in sync when endpoints change.
export interface User {
id: number
username: string
display_name: string
is_admin: boolean
created_at: string
}
export interface Library {
id: number
name: string
kind: string
root_path: string
created_at: string
}
export interface MediaType {
Kind: string
Name: string
Extensions: string[]
}
export interface Item {
id: number
kind: string
path: string
title: string
year: number
added_at: string
missing?: boolean
}
export interface Progress {
item_id: number
file_id: number
kind: string
position: number
duration: number
percent: number
finished: boolean
updated_at: string
}
export interface EbookMetadata {
title: string
authors: string[] | null
language: string
identifier: string
publisher: string
date: string
description: string
spine_length: number
}
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
this.name = 'ApiError'
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
credentials: 'same-origin',
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
if (res.status === 204) return undefined as T
const text = await res.text()
let parsed: unknown = undefined
if (text) {
try {
parsed = JSON.parse(text)
} catch {
// Fall through to the status-based error below.
}
}
if (!res.ok) {
const message =
parsed && typeof parsed === 'object' && 'error' in parsed
? String((parsed as { error: unknown }).error)
: `request failed (${res.status})`
throw new ApiError(res.status, message)
}
return parsed as T
}
export const api = {
login: (username: string, password: string) =>
request<{ user: User; expires_in: number }>('POST', '/api/v1/auth/login', { username, password }),
logout: () => request<{ status: string }>('POST', '/api/v1/auth/logout'),
me: () => request<{ user: User; visible_libraries: number[] }>('GET', '/api/v1/auth/me'),
changePassword: (current_password: string, new_password: string) =>
request<{ status: string }>('POST', '/api/v1/auth/password', { current_password, new_password }),
mediaTypes: () => request<{ media_types: MediaType[] }>('GET', '/api/v1/media-types'),
libraries: () => request<{ libraries: Library[] }>('GET', '/api/v1/libraries'),
library: (id: number) =>
request<{ library: Library; item_count: number; reachable: boolean }>('GET', `/api/v1/libraries/${id}`),
createLibrary: (name: string, kind: string, root_path: string) =>
request<{ library: Library }>('POST', '/api/v1/libraries', { name, kind, root_path }),
deleteLibrary: (id: number) => request<void>('DELETE', `/api/v1/libraries/${id}`),
scanLibrary: (id: number) => request<ScanResult>('POST', `/api/v1/libraries/${id}/scan`),
items: (libraryId: number, limit = 200) =>
request<{ items: Item[]; limit: number }>('GET', `/api/v1/libraries/${libraryId}/items?limit=${limit}`),
item: (id: number) =>
request<{ item: Item; files: { id: number; path: string; size: number; container: string }[]; has_cover: boolean; progress?: Progress }>(
'GET',
`/api/v1/items/${id}`,
),
ebook: (id: number) => request<{ metadata: EbookMetadata }>('GET', `/api/v1/items/${id}/ebook`),
coverUrl: (id: number) => `/api/v1/items/${id}/cover`,
fileUrl: (id: number) => `/api/v1/items/${id}/file`,
progress: (id: number) => request<{ progress: Progress | null }>('GET', `/api/v1/items/${id}/progress`),
putProgress: (id: number, p: { position?: number; duration?: number; percent?: number; finished?: boolean }) =>
request<{ progress: Progress }>('PUT', `/api/v1/items/${id}/progress`, p),
recent: () => request<{ progress: Progress[] }>('GET', '/api/v1/progress/recent'),
users: () => request<{ users: User[] }>('GET', '/api/v1/users'),
createUser: (username: string, password: string, display_name: string, is_admin: boolean) =>
request<{ user: User }>('POST', '/api/v1/users', { username, password, display_name, is_admin }),
}
export interface ScanResult {
library_id: number
scanned: number
added: number
updated: number
restored: number
missing: number
unchanged: number
duration_ms: number
errors?: string[]
}

View file

@ -0,0 +1,208 @@
<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 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: 'library', id: 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 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>

View file

@ -0,0 +1,114 @@
<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>

View file

@ -0,0 +1,107 @@
<script lang="ts">
import { login } from '../session'
import { ApiError } from '../api'
let username = $state('')
let password = $state('')
let error = $state<string | null>(null)
let busy = $state(false)
async function submit(event: SubmitEvent) {
event.preventDefault()
if (busy) return
busy = true
error = null
try {
await login(username.trim(), password)
} catch (err) {
error = err instanceof ApiError ? err.message : 'Could not reach the server.'
} finally {
busy = false
}
}
</script>
<div class="wrap">
<form class="card panel" onsubmit={submit}>
<div class="brand">
<span class="mark">V</span>
<h1>Vellum</h1>
</div>
<p class="muted lead">Sign in to your media server.</p>
{#if error}
<div class="alert" role="alert">{error}</div>
{/if}
<div class="field">
<label for="username">Username</label>
<input
id="username"
class="input"
autocomplete="username"
bind:value={username}
disabled={busy}
required
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
class="input"
type="password"
autocomplete="current-password"
bind:value={password}
disabled={busy}
required
/>
</div>
<button class="btn btn-primary submit" type="submit" disabled={busy || !username || !password}>
{#if busy}<span class="spinner"></span>{/if}
{busy ? 'Signing in' : 'Sign in'}
</button>
</form>
</div>
<style>
.wrap {
display: grid;
place-items: center;
height: 100%;
padding: var(--space-4);
}
.panel {
width: 100%;
max-width: 340px;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.brand {
display: flex;
align-items: center;
gap: var(--space-3);
}
.mark {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 7px;
background: var(--accent);
color: var(--accent-text);
font-weight: 700;
}
.lead {
margin: -8px 0 0;
font-size: 13px;
}
.submit {
justify-content: center;
margin-top: var(--space-1);
}
</style>

View file

@ -0,0 +1,164 @@
<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>

View file

@ -0,0 +1,201 @@
<script lang="ts">
import { user, libraries, mediaTypes, logout } from '../session'
import { view, go, type View } from '../nav'
import Libraries from './Libraries.svelte'
import LibraryView from './LibraryView.svelte'
import Reader from './Reader.svelte'
import Users from './Users.svelte'
let kindFilter = $state<string | null>(null)
const kinds = $derived(
$mediaTypes.filter((t) => $libraries.some((l) => l.kind === t.Kind)),
)
function countsFor(kind: string) {
return $libraries.filter((l) => l.kind === kind).length
}
function title(v: View): string {
switch (v.name) {
case 'libraries':
return kindFilter ? ($mediaTypes.find((t) => t.Kind === kindFilter)?.Name ?? 'Library') : 'All libraries'
case 'library':
return $libraries.find((l) => l.id === v.id)?.name ?? 'Library'
case 'reader':
return v.title
case 'users':
return 'Users'
case 'settings':
return 'Settings'
}
}
function selectKind(kind: string | null) {
kindFilter = kind
go({ name: 'libraries' })
}
</script>
<div class="shell">
<aside class="nav">
<div class="brand">
<span class="mark">V</span>
<strong>Vellum</strong>
</div>
<nav class="links">
<button class="navlink" class:active={$view.name === 'libraries' && kindFilter === null} onclick={() => selectKind(null)}>
<span>All libraries</span>
<span class="count">{$libraries.length}</span>
</button>
{#each kinds as kind (kind.Kind)}
<button class="navlink" class:active={kindFilter === kind.Kind} onclick={() => selectKind(kind.Kind)}>
<span>{kind.Name}</span>
<span class="count">{countsFor(kind.Kind)}</span>
</button>
{/each}
</nav>
{#if $user?.is_admin}
<div class="section">Admin</div>
<nav class="links">
<button class="navlink" class:active={$view.name === 'users'} onclick={() => go({ name: 'users' })}>
<span>Users</span>
</button>
</nav>
{/if}
<div class="foot">
<div class="who">
<strong>{$user?.display_name}</strong>
{#if $user?.is_admin}<span class="badge">admin</span>{/if}
</div>
<button class="btn btn-ghost" onclick={logout}>Sign out</button>
</div>
</aside>
<main class="main">
<header class="topbar">
<h1>{title($view)}</h1>
</header>
<div class="content">
{#if $view.name === 'libraries'}
<Libraries {kindFilter} />
{:else if $view.name === 'library'}
<LibraryView libraryId={$view.id} />
{:else if $view.name === 'reader'}
<Reader itemId={$view.itemId} />
{:else if $view.name === 'users'}
<Users />
{/if}
</div>
</main>
</div>
<style>
.shell {
display: grid;
grid-template-columns: var(--nav-width) 1fr;
height: 100%;
}
.nav {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4);
border-right: 1px solid var(--border);
background: var(--bg-raised);
overflow-y: auto;
}
.brand {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-1);
}
.mark {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: var(--accent);
color: var(--accent-text);
font-size: 12px;
font-weight: 700;
}
.links {
display: flex;
flex-direction: column;
gap: 2px;
}
.navlink {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 6px 10px;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--text-muted);
text-align: left;
cursor: pointer;
}
.navlink:hover {
background: var(--bg-hover);
color: var(--text);
}
.navlink.active {
background: var(--bg-active);
color: var(--text);
font-weight: 500;
}
.count {
font-size: 12px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
.section {
margin: 0 var(--space-1) -8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-faint);
}
.foot {
margin-top: auto;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border);
}
.who {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 13px;
}
.main {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.topbar {
display: flex;
align-items: center;
height: 52px;
padding: 0 var(--space-5);
border-bottom: 1px solid var(--border);
}
.content {
flex: 1;
overflow-y: auto;
padding: var(--space-5);
}
</style>

View file

@ -0,0 +1,139 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api, ApiError, type User } from '../api'
let users = $state<User[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
let busy = $state(false)
let username = $state('')
let displayName = $state('')
let password = $state('')
let isAdmin = $state(false)
onMount(load)
async function load() {
loading = true
try {
users = (await api.users()).users
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not load users'
} finally {
loading = false
}
}
async function create(event: SubmitEvent) {
event.preventDefault()
busy = true
error = null
try {
await api.createUser(username.trim(), password, displayName.trim(), isAdmin)
username = displayName = password = ''
isAdmin = false
await load()
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not create the user'
} finally {
busy = false
}
}
</script>
{#if error}<div class="alert">{error}</div>{/if}
<div class="layout">
<div class="card list">
{#if loading}
<div class="center"><div class="spinner"></div></div>
{:else}
{#each users as u (u.id)}
<div class="row">
<div class="id">
<strong>{u.display_name || u.username}</strong>
<span class="muted mono">{u.username}</span>
</div>
{#if u.is_admin}<span class="badge">admin</span>{/if}
</div>
{/each}
{/if}
</div>
<form class="card form" onsubmit={create}>
<h2>Add user</h2>
<div class="field">
<label for="u-name">Username</label>
<input id="u-name" class="input" bind:value={username} required placeholder="alice" />
</div>
<div class="field">
<label for="u-display">Display name</label>
<input id="u-display" class="input" bind:value={displayName} placeholder="Alice" />
</div>
<div class="field">
<label for="u-pass">Password</label>
<input id="u-pass" class="input" type="password" bind:value={password} required minlength="8" />
</div>
<label class="check">
<input type="checkbox" bind:checked={isAdmin} />
Administrator
</label>
<button class="btn btn-primary" type="submit" disabled={busy || !username || password.length < 8}>
Create user
</button>
<p class="muted hint">Grant library access per user from the API; a grants UI is coming.</p>
</form>
</div>
<style>
.layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: var(--space-5);
align-items: start;
}
.list {
overflow: hidden;
}
.center {
display: grid;
place-items: center;
padding: var(--space-5);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
}
.row:last-child {
border-bottom: none;
}
.id {
display: flex;
flex-direction: column;
}
.id .muted {
font-size: 12px;
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
}
.check {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 13px;
color: var(--text-muted);
}
.hint {
font-size: 12px;
margin: 0;
}
</style>

15
web/src/lib/nav.ts Normal file
View file

@ -0,0 +1,15 @@
import { writable } from 'svelte/store'
// The app is small enough that a typed discriminated union beats a router.
export type View =
| { name: 'libraries' }
| { name: 'library'; id: number }
| { name: 'reader'; itemId: number; title: string }
| { name: 'users' }
| { name: 'settings' }
export const view = writable<View>({ name: 'libraries' })
export function go(next: View) {
view.set(next)
}

60
web/src/lib/session.ts Normal file
View file

@ -0,0 +1,60 @@
import { writable } from 'svelte/store'
import { api, ApiError, type Library, type MediaType, type User } from './api'
// Session state. Classic stores rather than runes-in-a-module: this is a plain
// .ts file, so it needs no special module handling and every component can use
// $store auto-subscription.
export const ready = writable(false)
export const user = writable<User | null>(null)
export const visibleLibraries = writable<number[]>([])
export const libraries = writable<Library[]>([])
export const mediaTypes = writable<MediaType[]>([])
export const loadingLibraries = writable(false)
export const bootError = writable<string | null>(null)
export async function bootstrap() {
try {
const [me, types] = await Promise.all([api.me(), api.mediaTypes()])
user.set(me.user)
visibleLibraries.set(me.visible_libraries)
mediaTypes.set(types.media_types)
await refreshLibraries()
} catch (err) {
if (!(err instanceof ApiError && err.status === 401)) {
bootError.set(err instanceof Error ? err.message : 'failed to reach the server')
}
user.set(null)
} finally {
ready.set(true)
}
}
export async function refreshLibraries() {
loadingLibraries.set(true)
try {
const res = await api.libraries()
libraries.set(res.libraries)
} finally {
loadingLibraries.set(false)
}
}
export async function login(username: string, password: string) {
const res = await api.login(username, password)
user.set(res.user)
bootError.set(null)
const me = await api.me()
visibleLibraries.set(me.visible_libraries)
await refreshLibraries()
}
export async function logout() {
try {
await api.logout()
} finally {
user.set(null)
libraries.set([])
visibleLibraries.set([])
}
}

7
web/src/main.ts Normal file
View file

@ -0,0 +1,7 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, { target: document.getElementById('app')! })
export default app

5
web/svelte.config.js Normal file
View file

@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
preprocess: vitePreprocess(),
}

19
web/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["svelte", "vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"isolatedModules": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}

21
web/vite.config.ts Normal file
View file

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// The bundle is embedded into the Go binary and served from /, so all asset
// paths must be relative (base: './').
export default defineConfig({
plugins: [svelte()],
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
target: 'es2020',
},
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:7788',
'/healthz': 'http://localhost:7788',
},
},
})