From 5283e073f5f7b345c29fc1c4b3f7ab6cd270e04b Mon Sep 17 00:00:00 2001 From: mithral Date: Fri, 9 Oct 2026 16:40:58 -0400 Subject: [PATCH] Web UI: Svelte app, embedded into the binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/*. --- Dockerfile | 21 +- Makefile | 19 +- internal/httpapi/server.go | 2 +- internal/httpapi/server_test.go | 3 +- internal/httpapi/static.go | 61 ++ web/.gitignore | 5 + web/README.md | 42 + web/dist/.gitkeep | 3 + web/embed.go | 13 + web/index.html | 13 + web/package.json | 22 + web/pnpm-lock.yaml | 1181 +++++++++++++++++++++ web/pnpm-workspace.yaml | 10 + web/src/App.svelte | 24 + web/src/app.css | 243 +++++ web/src/epubjs.d.ts | 7 + web/src/lib/api.ts | 160 +++ web/src/lib/components/Libraries.svelte | 208 ++++ web/src/lib/components/LibraryView.svelte | 114 ++ web/src/lib/components/Login.svelte | 107 ++ web/src/lib/components/Reader.svelte | 164 +++ web/src/lib/components/Shell.svelte | 201 ++++ web/src/lib/components/Users.svelte | 139 +++ web/src/lib/nav.ts | 15 + web/src/lib/session.ts | 60 ++ web/src/main.ts | 7 + web/svelte.config.js | 5 + web/tsconfig.json | 19 + web/vite.config.ts | 21 + 29 files changed, 2879 insertions(+), 10 deletions(-) create mode 100644 internal/httpapi/static.go create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/dist/.gitkeep create mode 100644 web/embed.go create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/pnpm-workspace.yaml create mode 100644 web/src/App.svelte create mode 100644 web/src/app.css create mode 100644 web/src/epubjs.d.ts create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/components/Libraries.svelte create mode 100644 web/src/lib/components/LibraryView.svelte create mode 100644 web/src/lib/components/Login.svelte create mode 100644 web/src/lib/components/Reader.svelte create mode 100644 web/src/lib/components/Shell.svelte create mode 100644 web/src/lib/components/Users.svelte create mode 100644 web/src/lib/nav.ts create mode 100644 web/src/lib/session.ts create mode 100644 web/src/main.ts create mode 100644 web/svelte.config.js create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/Dockerfile b/Dockerfile index 845803b..e8b24eb 100644 --- a/Dockerfile +++ b/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}" \ diff --git a/Makefile b/Makefile index 91d04a4..a7790d2 100644 --- a/Makefile +++ b/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 diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index a9dff54..1e84627 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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. diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 993c566..b45b093 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -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 { diff --git a/internal/httpapi/static.go b/internal/httpapi/static.go new file mode 100644 index 0000000..51b5e69 --- /dev/null +++ b/internal/httpapi/static.go @@ -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/") +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a3780fb --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/* +!dist/.gitkeep +.vite/ +*.local diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..9142f28 --- /dev/null +++ b/web/README.md @@ -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. diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep new file mode 100644 index 0000000..674ae75 --- /dev/null +++ b/web/dist/.gitkeep @@ -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. diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..ff17c53 --- /dev/null +++ b/web/embed.go @@ -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 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..577880e --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Vellum + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..e037e7c --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..fa881d1 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1181 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + epubjs: + specifier: ^0.3.93 + version: 0.3.93 + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.3 + version: 5.1.1(svelte@5.57.2)(vite@6.4.4) + svelte: + specifier: ^5.19.0 + version: 5.57.2 + svelte-check: + specifier: ^4.1.4 + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.2)(typescript@5.9.3) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.4 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.64.3': + resolution: {integrity: sha512-a31GFbL70GlGUGhn8goVjRLtdDG1urLCJFWLZ7JcxdHK9pPvCohLFtYLgJDdNbtrNZSrJl1u9bK05ISsSC2EpA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.64.3': + resolution: {integrity: sha512-IRv757RLiPGlNxAX5JXU0eiUlUPvKiwFRbkqLd+gywptLK4uSEg1qkl6en6r0cXMazSnWUWOBABv+QfuLV3n5Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.64.3': + resolution: {integrity: sha512-UkzI35Fnz6+MJNATdnjnZ3TpdG/rXab8h7oDOD9R0zDVIFmuuYrvgUj8V8mh+A//+LR4TlEQo4ZW4RhyRtWv3A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.64.3': + resolution: {integrity: sha512-C3AyMtAnKQ/jSkZ0chIoWbPXRbFz8JnPLhl1ULecEhcaOGkT19xt7KmO8BulQxt9S/XideHRXvsVQUbGj4JCOg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.64.3': + resolution: {integrity: sha512-M8yAdBMyHh14JLeXDNGvEkljzuHdcoLBQ399O4Fp4nvX51lueuEY/5U2G9Ci0D6/bQlBp/3JLiBQH55STwwbwg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.64.3': + resolution: {integrity: sha512-WQjuOKGh2FN98ZK6i0uM6M6YqnPEds85p3oRkrxiEeTQo/p7RpuU8c9MwQ88LfgwVAzohumlzbQQSikz4Zw9rw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.64.3': + resolution: {integrity: sha512-5tDLZG5s9WyVKoEHgwos6FpKrgpotl61EYeHSLSVkQzMzTsaClwEr8mok9rm3y734zjiGInG4dfw/56ebWVvEQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.64.3': + resolution: {integrity: sha512-Mh5KbLeplM2HX6lQI18jsxSsqvPW8d+Hyk8HtY6Zl93woMitRSCZ92rBEzq6v8bfZGlpyMgj4P/sKW4KCjhH3A==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.64.3': + resolution: {integrity: sha512-QZrrwHnznTv6y4iiHtCqDN+ZsF4qNFQObMOLgPrbxhh9O5o4rgoUe5OnkqG/ceCObMrAjhQt+eQ6HSDzBFgVMw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.64.3': + resolution: {integrity: sha512-4vLehpXSwlkltnvk5ETB+Hz+d3xayZS3NamZ5Vq4bSo+IUoIALovEJF1YgWPMjHwN7R5o/R4BpP9h3q8EK1xAA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.64.3': + resolution: {integrity: sha512-SEN7TTTOdXJKZmsWKOnQeVfgy3Dje/08ISXWwFhTggQ3SxjuIaChvmOB5ZDZqJNlBjaXrV6nAHraqEAvdLGqcw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.64.3': + resolution: {integrity: sha512-1LDsQMRMCEdGF9Dih5qyh6fF1LK3NnA+pAtE2+9AgZwUdXyd19TOBVC9crSB63iZBHR4vOG7h8gN/LcUH3J7xw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.64.3': + resolution: {integrity: sha512-v3D2tAfRriqGOi+5Ac5CiYbBo2NXtLVxJlludTvUZRj502lVMFeVIuvvWbz4qCrotn3e3Uvpvh7FcMpG2QxxXQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.64.3': + resolution: {integrity: sha512-db5q6dP189V2+JkMpsXBl8ym6bC0x8ydZ47fUJZ9YdM6T2OF5uqNzX9HaAZHRP/lqjA9bJeNwi0qw1/2/37dUA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.64.3': + resolution: {integrity: sha512-nArAVDmrL5ix9UoipcKEsYJw4kMUQCIPYRWuYF2POOC7jd9rmN95d/48ewsYFC/OdtwEx6VYYWYxrp8FbMOU2Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.64.3': + resolution: {integrity: sha512-2dc7LElu7EVSvpQT8epNnBLa02wlqilNYqHN0FA/RaKqCr9U9ZFYQ0Eh4sfgSzLneLDuJsJRjcezR/l2UyYGXA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.64.3': + resolution: {integrity: sha512-9PYq2IEzSwXcvQLERcT9LKXRzw8J05GFTbJHA2anJR2WmlRjA1esgXVYhanp+bmjKsDVoZeU2L7Z2pRClLcZTw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.64.3': + resolution: {integrity: sha512-vMh+mt0cw1BHldd8cqaU6A5s1W4EUKl6+8MRsv86mj954L4xyruKD7qPXMUI2icImadAZARK10h0onaGTLmCVg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.64.3': + resolution: {integrity: sha512-PpiKU+sWbqDGc0U0su8oXiyeyqm0UQjmaaPKHktX+DaLe6YKJwW2YVD0f2AXHdVvblZbRqpwRVClWP2F5HCqrQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.64.3': + resolution: {integrity: sha512-/3enY/AmaIt8IS0fWjX4ztYVPBqkj49cQTy/hCryhBFaEzDfJwwEolq9sxvyWAM2qmsmVuPdeRg5JXSrmeqvJg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.64.3': + resolution: {integrity: sha512-zsEP/QDuet52hhwvi5T1VPg0/Tkms0m3zkVv7CPsznvhdO4pL4oJTMz26EJsiCe18OeLKPgpTu3XVO3C1eq3ew==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.64.3': + resolution: {integrity: sha512-4ESknW4Db+kqvazVOfsReYJaHxcIsOqcgwjKV2J1tG5Bn4RVf5ocTQKSGuq2HWBJcOLuyim8IWX8DuH6nKmELA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.64.3': + resolution: {integrity: sha512-3B8VCQyIyv7FQxYYhb3HpR3bIpronR20txjMSffSoz/sm/DDs3/49CFpEueOsvq9qW9sWoI/6pi6mtciD9NXFg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.64.3': + resolution: {integrity: sha512-YOoDkKh2oQxOxRB5D9AR8sj3qpMShpmyTkjojiBjMGigz3caV7UvCnXWWUQC0TBmrZ5zuHe2pWl8S+cMfvXzRg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.64.3': + resolution: {integrity: sha512-qTv4bG+eKJKifzxNJevMUh8L6CjJrz9/Kn1IbP2F5bo0tBzNIR7HMg4vni6XmDzcKUl00lHHY+J5ZgjbGiujOQ==} + cpu: [x64] + os: [win32] + + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/localforage@0.0.34': + resolution: {integrity: sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==} + deprecated: This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed! + + '@xmldom/xmldom@0.7.13': + resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} + engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version + + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + core-js@3.50.0: + resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + d@1.0.2: + resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} + engines: {node: '>=0.12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + + epubjs@0.3.93: + resolution: {integrity: sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==} + + es5-ext@0.10.64: + resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} + engines: {node: '>=0.10'} + + es6-iterator@2.0.3: + resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} + + es6-symbol@3.1.4: + resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} + engines: {node: '>=0.12'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esniff@2.0.1: + resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} + engines: {node: '>=0.10'} + + esrap@2.4.0: + resolution: {integrity: sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + event-emitter@0.3.5: + resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + + ext@1.7.0: + resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + jszip@3.10.2: + resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lie@3.1.1: + resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + localforage@1.10.0: + resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marks-pane@1.0.9: + resolution: {integrity: sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next-tick@1.1.0: + resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + path-webpack@0.0.3: + resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rollup@4.64.3: + resolution: {integrity: sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + svelte-check@4.7.6: + resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.0.0 || ^6.0.0 + + svelte@5.57.2: + resolution: {integrity: sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + type@2.7.3: + resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@6.4.4: + resolution: {integrity: sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + zimmerframe@1.1.5: + resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.64.3': + optional: true + + '@rollup/rollup-android-arm64@4.64.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.64.3': + optional: true + + '@rollup/rollup-darwin-x64@4.64.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.64.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.64.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.64.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.64.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.64.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.64.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.64.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.64.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.64.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.64.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.64.3': + optional: true + + '@sveltejs/acorn-typescript@1.0.13(acorn@8.19.0)': + dependencies: + acorn: 8.19.0 + + '@sveltejs/load-config@0.2.3': {} + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.57.2)(vite@6.4.4))(svelte@5.57.2)(vite@6.4.4)': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.57.2)(vite@6.4.4) + debug: 4.4.3 + svelte: 5.57.2 + vite: 6.4.4 + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.57.2)(vite@6.4.4)': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.57.2)(vite@6.4.4))(svelte@5.57.2)(vite@6.4.4) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.57.2 + vite: 6.4.4 + vitefu: 1.1.3(vite@6.4.4) + transitivePeerDependencies: + - supports-color + + '@types/estree@1.0.9': {} + + '@types/localforage@0.0.34': + dependencies: + localforage: 1.10.0 + + '@xmldom/xmldom@0.7.13': {} + + acorn@8.19.0: {} + + aria-query@5.3.1: {} + + axobject-query@4.1.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + core-js@3.50.0: {} + + core-util-is@1.0.3: {} + + d@1.0.2: + dependencies: + es5-ext: 0.10.64 + type: 2.7.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepmerge@4.3.1: {} + + devalue@5.9.4: {} + + epubjs@0.3.93: + dependencies: + '@types/localforage': 0.0.34 + '@xmldom/xmldom': 0.7.13 + core-js: 3.50.0 + event-emitter: 0.3.5 + jszip: 3.10.2 + localforage: 1.10.0 + lodash: 4.18.1 + marks-pane: 1.0.9 + path-webpack: 0.0.3 + + es5-ext@0.10.64: + dependencies: + es6-iterator: 2.0.3 + es6-symbol: 3.1.4 + esniff: 2.0.1 + next-tick: 1.1.0 + + es6-iterator@2.0.3: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + es6-symbol: 3.1.4 + + es6-symbol@3.1.4: + dependencies: + d: 1.0.2 + ext: 1.7.0 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esm-env@1.2.2: {} + + esniff@2.0.1: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + event-emitter: 0.3.5 + type: 2.7.3 + + esrap@2.4.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + event-emitter@0.3.5: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + + ext@1.7.0: + dependencies: + type: 2.7.3 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + immediate@3.0.6: {} + + inherits@2.0.4: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + isarray@1.0.0: {} + + jszip@3.10.2: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + kleur@4.1.5: {} + + lie@3.1.1: + dependencies: + immediate: 3.0.6 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + localforage@1.10.0: + dependencies: + lie: 3.1.1 + + locate-character@3.0.0: {} + + lodash@4.18.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + marks-pane@1.0.9: {} + + mri@1.2.0: {} + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + next-tick@1.1.0: {} + + pako@1.0.11: {} + + path-webpack@0.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + process-nextick-args@2.0.1: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + rollup@4.64.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.64.3 + '@rollup/rollup-android-arm64': 4.64.3 + '@rollup/rollup-darwin-arm64': 4.64.3 + '@rollup/rollup-darwin-x64': 4.64.3 + '@rollup/rollup-freebsd-arm64': 4.64.3 + '@rollup/rollup-freebsd-x64': 4.64.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.3 + '@rollup/rollup-linux-arm-musleabihf': 4.64.3 + '@rollup/rollup-linux-arm64-gnu': 4.64.3 + '@rollup/rollup-linux-arm64-musl': 4.64.3 + '@rollup/rollup-linux-loong64-gnu': 4.64.3 + '@rollup/rollup-linux-loong64-musl': 4.64.3 + '@rollup/rollup-linux-ppc64-gnu': 4.64.3 + '@rollup/rollup-linux-ppc64-musl': 4.64.3 + '@rollup/rollup-linux-riscv64-gnu': 4.64.3 + '@rollup/rollup-linux-riscv64-musl': 4.64.3 + '@rollup/rollup-linux-s390x-gnu': 4.64.3 + '@rollup/rollup-linux-x64-gnu': 4.64.3 + '@rollup/rollup-linux-x64-musl': 4.64.3 + '@rollup/rollup-openbsd-x64': 4.64.3 + '@rollup/rollup-openharmony-arm64': 4.64.3 + '@rollup/rollup-win32-arm64-msvc': 4.64.3 + '@rollup/rollup-win32-ia32-msvc': 4.64.3 + '@rollup/rollup-win32-x64-gnu': 4.64.3 + '@rollup/rollup-win32-x64-msvc': 4.64.3 + fsevents: 2.3.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safe-buffer@5.1.2: {} + + setimmediate@1.0.5: {} + + source-map-js@1.2.2: {} + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + svelte-check@4.7.6(picomatch@4.0.7)(svelte@5.57.2)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@sveltejs/load-config': 0.2.3 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.7) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.57.2 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte@5.57.2: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.6.0 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.19.0) + '@types/estree': 1.0.9 + acorn: 8.19.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.9.4 + esm-env: 1.2.2 + esrap: 2.4.0 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.5 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + type@2.7.3: {} + + typescript@5.9.3: {} + + util-deprecate@1.0.2: {} + + vite@6.4.4: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.29 + rollup: 4.64.3 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitefu@1.1.3(vite@6.4.4): + optionalDependencies: + vite: 6.4.4 + + zimmerframe@1.1.5: {} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..635ea73 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -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 diff --git a/web/src/App.svelte b/web/src/App.svelte new file mode 100644 index 0000000..6fea5aa --- /dev/null +++ b/web/src/App.svelte @@ -0,0 +1,24 @@ + + +{#if !$ready} +
+{:else if !$user} + +{:else} + +{/if} + + diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..f1148b9 --- /dev/null +++ b/web/src/app.css @@ -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); + } +} diff --git a/web/src/epubjs.d.ts b/web/src/epubjs.d.ts new file mode 100644 index 0000000..52e0b03 --- /dev/null +++ b/web/src/epubjs.d.ts @@ -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 +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..55587e8 --- /dev/null +++ b/web/src/lib/api.ts @@ -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(method: string, path: string, body?: unknown): Promise { + 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('DELETE', `/api/v1/libraries/${id}`), + + scanLibrary: (id: number) => request('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[] +} diff --git a/web/src/lib/components/Libraries.svelte b/web/src/lib/components/Libraries.svelte new file mode 100644 index 0000000..959254f --- /dev/null +++ b/web/src/lib/components/Libraries.svelte @@ -0,0 +1,208 @@ + + +{#if error}
{error}
{/if} +{#if notice}
{notice}
{/if} + +{#if $user?.is_admin} +
+ +
+{/if} + +{#if showForm && $user?.is_admin} +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+{/if} + +{#if shown.length === 0} +
+

No libraries yet

+

+ {#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} +

+
+{:else} +
+ {#each shown as lib (lib.id)} +
+ + {#if $user?.is_admin} +
+ + +
+ {/if} +
+ {/each} +
+{/if} + + diff --git a/web/src/lib/components/LibraryView.svelte b/web/src/lib/components/LibraryView.svelte new file mode 100644 index 0000000..56a5436 --- /dev/null +++ b/web/src/lib/components/LibraryView.svelte @@ -0,0 +1,114 @@ + + +{#if loading} +
+{:else if error} +
{error}
+{:else if items.length === 0} +
+

Nothing here yet

+

Run a scan on this library to index the files on disk.

+
+{:else} +
+ {#each items as item (item.id)} + + {/each} +
+{/if} + + diff --git a/web/src/lib/components/Login.svelte b/web/src/lib/components/Login.svelte new file mode 100644 index 0000000..b7a1485 --- /dev/null +++ b/web/src/lib/components/Login.svelte @@ -0,0 +1,107 @@ + + +
+
+
+ V +

Vellum

+
+

Sign in to your media server.

+ + {#if error} + + {/if} + +
+ + +
+ +
+ + +
+ + +
+
+ + diff --git a/web/src/lib/components/Reader.svelte b/web/src/lib/components/Reader.svelte new file mode 100644 index 0000000..334f9ce --- /dev/null +++ b/web/src/lib/components/Reader.svelte @@ -0,0 +1,164 @@ + + +{#if error} +
{error}
+{:else if item && item.kind !== 'ebooks'} +
+

{item.title}

+

There is no reader for “{item.kind}” yet.

+ Download the file +
+{:else} +
+
+ {#if loading} +
+ {/if} +
+ +
+ +
+ {#if meta} + {meta.title} + {#if meta.authors?.length}{meta.authors.join(', ')}{/if} + {/if} +
+ {percent}% + +
+{/if} + + diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte new file mode 100644 index 0000000..59fd378 --- /dev/null +++ b/web/src/lib/components/Shell.svelte @@ -0,0 +1,201 @@ + + +
+ + +
+
+

{title($view)}

+
+ +
+ {#if $view.name === 'libraries'} + + {:else if $view.name === 'library'} + + {:else if $view.name === 'reader'} + + {:else if $view.name === 'users'} + + {/if} +
+
+
+ + diff --git a/web/src/lib/components/Users.svelte b/web/src/lib/components/Users.svelte new file mode 100644 index 0000000..9cb452a --- /dev/null +++ b/web/src/lib/components/Users.svelte @@ -0,0 +1,139 @@ + + +{#if error}
{error}
{/if} + +
+
+ {#if loading} +
+ {:else} + {#each users as u (u.id)} +
+
+ {u.display_name || u.username} + {u.username} +
+ {#if u.is_admin}admin{/if} +
+ {/each} + {/if} +
+ +
+

Add user

+
+ + +
+
+ + +
+
+ + +
+ + +

Grant library access per user from the API; a grants UI is coming.

+
+
+ + diff --git a/web/src/lib/nav.ts b/web/src/lib/nav.ts new file mode 100644 index 0000000..8e2b418 --- /dev/null +++ b/web/src/lib/nav.ts @@ -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({ name: 'libraries' }) + +export function go(next: View) { + view.set(next) +} diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts new file mode 100644 index 0000000..f215bcd --- /dev/null +++ b/web/src/lib/session.ts @@ -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(null) +export const visibleLibraries = writable([]) +export const libraries = writable([]) +export const mediaTypes = writable([]) +export const loadingLibraries = writable(false) +export const bootError = writable(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([]) + } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..fcf8922 --- /dev/null +++ b/web/src/main.ts @@ -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 diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..8abe436 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + preprocess: vitePreprocess(), +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..72a8e44 --- /dev/null +++ b/web/tsconfig.json @@ -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"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..115f7b3 --- /dev/null +++ b/web/vite.config.ts @@ -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', + }, + }, +})