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:
parent
5c63932419
commit
5283e073f5
29 changed files with 2879 additions and 10 deletions
21
Dockerfile
21
Dockerfile
|
|
@ -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}" \
|
||||
|
|
|
|||
19
Makefile
19
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
61
internal/httpapi/static.go
Normal file
61
internal/httpapi/static.go
Normal 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
5
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/*
|
||||
!dist/.gitkeep
|
||||
.vite/
|
||||
*.local
|
||||
42
web/README.md
Normal file
42
web/README.md
Normal 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
3
web/dist/.gitkeep
vendored
Normal 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
13
web/embed.go
Normal 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
13
web/index.html
Normal 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
22
web/package.json
Normal 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
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
10
web/pnpm-workspace.yaml
Normal 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
24
web/src/App.svelte
Normal 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
243
web/src/app.css
Normal 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
7
web/src/epubjs.d.ts
vendored
Normal 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
160
web/src/lib/api.ts
Normal 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[]
|
||||
}
|
||||
208
web/src/lib/components/Libraries.svelte
Normal file
208
web/src/lib/components/Libraries.svelte
Normal 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>
|
||||
114
web/src/lib/components/LibraryView.svelte
Normal file
114
web/src/lib/components/LibraryView.svelte
Normal 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>
|
||||
107
web/src/lib/components/Login.svelte
Normal file
107
web/src/lib/components/Login.svelte
Normal 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>
|
||||
164
web/src/lib/components/Reader.svelte
Normal file
164
web/src/lib/components/Reader.svelte
Normal 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>
|
||||
201
web/src/lib/components/Shell.svelte
Normal file
201
web/src/lib/components/Shell.svelte
Normal 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>
|
||||
139
web/src/lib/components/Users.svelte
Normal file
139
web/src/lib/components/Users.svelte
Normal 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
15
web/src/lib/nav.ts
Normal 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
60
web/src/lib/session.ts
Normal 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
7
web/src/main.ts
Normal 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
5
web/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
||||
19
web/tsconfig.json
Normal file
19
web/tsconfig.json
Normal 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
21
web/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue