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
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# ── Build ──────────────────────────────────────────────────────────────
|
# ── Frontend ───────────────────────────────────────────────────────────
|
||||||
# Runs on the builder's native platform; cross-compiles to the target arch.
|
# 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
|
# 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.
|
# Go, so there is no cgo and no libc to match against.
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
||||||
|
|
@ -12,11 +26,12 @@ ARG VERSION=dev
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Cache the module layer separately from the source.
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
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} \
|
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||||
go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
|
go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
|
||||||
|
|
|
||||||
19
Makefile
19
Makefile
|
|
@ -1,14 +1,23 @@
|
||||||
# Vellum — developer commands.
|
# 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
|
BINARY := vellum
|
||||||
VERSION ?= 0.1.0-dev
|
VERSION ?= 0.4.0-dev
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
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
|
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/vellum
|
||||||
|
|
||||||
run:
|
run:
|
||||||
|
|
@ -34,4 +43,4 @@ docker:
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BINARY)
|
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(),
|
mux: http.NewServeMux(),
|
||||||
}
|
}
|
||||||
s.routes()
|
s.routes()
|
||||||
|
s.mountWeb()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +57,6 @@ func (s *Server) Handle(pattern string, h http.Handler) {
|
||||||
func (s *Server) routes() {
|
func (s *Server) routes() {
|
||||||
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||||
s.mux.HandleFunc("GET /api/v1/version", s.handleVersion)
|
s.mux.HandleFunc("GET /api/v1/version", s.handleVersion)
|
||||||
s.mux.HandleFunc("/", s.handleNotFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler returns the fully wrapped root handler.
|
// Handler returns the fully wrapped root handler.
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,9 @@ func TestHealth(t *testing.T) {
|
||||||
func TestNotFoundIsJSON(t *testing.T) {
|
func TestNotFoundIsJSON(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
|
|
||||||
|
// API paths must never fall back to the SPA shell.
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/nope", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil)
|
||||||
srv.Handler().ServeHTTP(rec, req)
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusNotFound {
|
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