Compare commits

...
19 Commits
Author SHA1 Message Date
arnefandCopilot cfecc49810 Fix button alignment on book detail page when wrapping
Docker Image bauen und veröffentlichen / docker (push) Successful in 2m27s
Replace .btn + .btn { margin-left } with margin-right/margin-bottom on
.book-detail-body .btn so the "Im Browser lesen" button stays
left-aligned when the buttons wrap to a new line on narrow viewports.
Also drop the now-unused .reader-nav .btn + .btn override.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-15 16:22:43 +02:00
arnefandCopilot 2399d8a5a6 Add in-browser EPUB reader (no JavaScript)
- internal/library/reader.go: new Chapter type and Service.ReadChapter/
  ChapterAsset. Parses the EPUB spine, extracts and sanitizes each
  chapter's <body> HTML using golang.org/x/net/html:
  - strips <script>, <style>, <link>, <iframe>, <object>, <embed>,
    <form>, <meta>, <base>, <audio>, <video>, <noscript> and any
    on*-event-handler attributes
  - rewrites relative <img>/xlink:href asset references to
    /read/{id}/asset/{path}
  - rewrites internal chapter links to /read/{id}/{spineIndex},
    neutralizes javascript: hrefs, leaves external links untouched
  - refactored CoverBytes to share the new findEPUBPath helper
- internal/web/handlers.go: GET /read/{id}/{idx} renders a chapter page
  with prev/next navigation; GET /read/{id}/asset/{path...} serves
  embedded chapter assets (images) with a size cap, mirroring the
  existing cover-image guard
- views/pages.templ: new ReaderPage component (renders sanitized HTML
  via templ.Raw); BookPage gets an "Im Browser lesen" button next to
  the existing download button; templ regenerated
- static/styles.css: reader typography/layout, prev/next nav styling
- go.mod/go.sum: golang.org/x/net/html promoted to a direct dependency
- README: documents the new in-browser reader feature

Verified end-to-end with a hand-built test EPUB (spine ordering,
metadata, script/style stripping, event-handler stripping, chapter
link rewriting, image asset rewriting, javascript: neutralization,
external link passthrough, out-of-range chapter handling, and
path-traversal guard on chapter assets); test file removed afterwards
per repo convention (no test suite checked in yet).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-15 16:17:16 +02:00
arnefandCopilot be3dfaeb47 Allow selecting and uploading multiple EPUB files at once
- views/pages.templ: upload input now has "multiple" attribute; page
  title/heading updated to plural ("Bücher hochladen"); templ regenerated
- internal/web/handlers.go: uploadSubmit now reads all files from
  r.MultipartForm.File["book"] instead of a single r.FormFile, saving
  each via a new saveUpload helper; partial failures are reported per
  filename while successfully saved files are kept

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-15 14:23:05 +02:00
arnefandCopilot a51578cbf1 Drop PDF support, EPUB only
Docker Image bauen und veröffentlichen / docker (push) Successful in 2m38s
- internal/library: ListBooks only scans .epub files, dropping the
  pdf-specific metadata branch (epub metadata reading is now
  unconditional)
- internal/web: uploadSubmit rejects non-.epub uploads with updated
  error message
- views: upload form/file input and empty-library hint now reference
  only EPUB; templ regenerated
- README/copilot-instructions updated to reflect EPUB-only scope

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-15 11:50:27 +02:00
arnefandCopilot e4fe39ecc5 Fix book grid layout with long titles
- static/styles.css: use CSS grid instead of inline-block for .book-list
  so rows align consistently regardless of card content height; add
  overflow-wrap/word-break to titles/authors
- views/pages.templ: truncate long titles/authors server-side
  (truncateTitle helper) instead of relying on CSS line-clamp, which
  behaved inconsistently across browsers (esp. Firefox with flex);
  full title kept in title attribute and on the book detail page
- views/pages_templ.go regenerated via templ generate

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-15 11:12:48 +02:00
arnefandCopilot 0c3e5cec9f Auto-create books directory, log upload errors, document Docker deployment
- library.New now creates BOOKS_DIR (MkdirAll) on startup so uploads
  don't fail when the directory is missing on a fresh bind mount
- uploadSubmit logs the underlying OS error on save/write failures
- README: document docker-compose deployment, container UID/GID (100/101)
  for bind-mounted ./data permissions, and running the admin CLI via
  docker compose exec

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-15 07:11:33 +02:00
arnefandCopilot 4c0aa053ac Add cleanup step to docker workflow to free build agent disk space
Docker Image bauen und veröffentlichen / docker (push) Successful in 2m28s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-14 22:37:53 +02:00
arnefandCopilot 952a4c9b52 Trigger docker workflow on develop branch instead of main
Docker Image bauen und veröffentlichen / docker (push) Successful in 2m49s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-14 22:01:57 +02:00
arnefandCopilot d60d3a703e Add Docker image and multi-arch build workflow
- Add multi-stage Dockerfile (alpine, non-root, builds server+admin binaries)
- Add .dockerignore
- Add GitHub Actions workflow (also read by Gitea Actions) building and
  pushing linux/amd64 + linux/arm64 images to git.arnef.de/arnef/ebooks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-14 21:53:39 +02:00
arnefandCopilot c877abf302 style: .btn als flex-Button mit konsistentem Styling
Ermöglicht die Verwendung von .btn auf <button>-Elementen
(nicht nur <a>-Links) mit einheitlicher Ausrichtung, Schrift
und Cursor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-14 21:32:19 +02:00
arnefandCopilot 817763887a refactor: replace can_upload boolean with role-based access control
Introduces a three-tier role hierarchy: reader < uploader < admin.

- internal/users/role.go: Role type, roleLevel map, AtLeast(min),
  ParseRole() — new roles added by inserting into roleLevel only
- internal/users/store.go: versioned migrations via _schema_version table;
  v2 migration adds 'role' column and migrates existing can_upload data;
  SetRole() replaces SetUpload(); Session carries Role instead of CanUpload
- internal/web/middleware.go: generic requireRole(minRole) middleware
  replaces the ad-hoc requireUpload
- internal/web/handlers.go: upload routes use requireRole(RoleUploader);
  listBooks derives canUpload from sess.Role.AtLeast(RoleUploader)
- cmd/admin/main.go: user add --role <reader|uploader|admin>,
  user set-role replaces user set-upload
- README, copilot-instructions updated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-11 19:43:39 +02:00
arnefandCopilot a66c370de4 feat: add authentication and book upload
- Session-based login (username/password, 30-day cookie)
- SQLite user store with bcrypt password hashing (modernc.org/sqlite)
- Per-user upload permission (can_upload flag)
- Admin CLI (cmd/admin) for user management:
  user add/list/delete/set-upload
- Upload handler for EPUB/PDF with path-traversal protection
- All routes protected by requireAuth middleware;
  /upload additionally requires requireUpload
- Login/logout UI, upload form, logout button in header
- New env var: USERS_DB (default: users.db, gitignored)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-11 19:11:40 +02:00
ArneandCopilot Autofix powered by AI a8b31dae8a Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-08 13:40:53 +02:00
ArneandCopilot Autofix powered by AI 1f31d7afae Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-08 13:40:38 +02:00
ArneandCopilot Autofix powered by AI 15d323541c Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-08 13:40:23 +02:00
ArneandCopilot Autofix powered by AI e5931aa5c7 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-08 13:40:09 +02:00
arnef a936c61ba3 update pages_templ 2026-08-08 13:36:19 +02:00
Arne 91173750ad Use Tolino-compatible three-column overview layout 2026-08-06 22:02:44 +02:00
Arne d5a03bc05f Switch overview to three-column book grid 2026-08-06 21:12:34 +02:00
19 changed files with 2402 additions and 132 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitea
.github
books/
users.db
*.db
dist/
bin/
.vscode
.idea
README.md
+79
View File
@@ -0,0 +1,79 @@
# eBook Library (Go + templ)
Minimalistic, no-JS eBook library web app for reading EPUB files on a
Tolino e-reader browser. UI text/comments are in German.
## Build & run
```bash
go mod tidy # install deps
templ generate # regenerate views/*_templ.go from views/*.templ (required after editing .templ files)
go build ./... # build check
go run ./cmd/server # run dev server on :8080
```
- `templ` CLI is required whenever `views/pages.templ` changes — generated
Go code lives in `views/pages_templ.go` and is checked into the repo, so it
must be regenerated and committed together with template edits.
- No test suite exists yet.
- Config via env vars: `ADDR` (default `:8080`), `BOOKS_DIR` (default `books`),
`USERS_DB` (default `users.db`).
## Admin CLI
```bash
go run ./cmd/admin user add <name> [--role reader|uploader|admin] # Standard: reader
go run ./cmd/admin user list
go run ./cmd/admin user delete <name>
go run ./cmd/admin user set-role <name> <reader|uploader|admin>
```
## Architecture
- `cmd/server/main.go` — entrypoint; wires `library.Service`, `users.Store` and
`web.Handler` together and starts the HTTP server.
- `internal/library` — core domain logic, no HTTP dependency (unchanged).
- `internal/users/role.go``Role` type (`reader`, `uploader`, `admin`),
`roleLevel` map für Hierarchie, `AtLeast(min Role)`, `ParseRole(s)`.
Neue Rollen: in `roleLevel` eintragen und ggf. Routen anpassen — kein
Schema-Change nötig.
- `internal/users/store.go` — SQLite-backed user store. Migrations via
`_schema_version`-Tabelle (geordnete `migrations [][]string`); neue
Migrationen am Ende anhängen. `role TEXT` statt `can_upload INTEGER`.
- `internal/web/middleware.go``requireRole(minRole users.Role)` ist die
zentrale Middleware; `requireAuth` ist ein Spezialfall davon (implizit
`RoleReader`).
- Auth: `/login` und `/static/` sind die einzigen öffentlichen Routes.
Upload-Routes verwenden `requireRole(RoleUploader)`.
**Rollen-Hierarchie:**
```
reader (0) < uploader (1) < admin (2)
```
- `cmd/admin/main.go` — CLI binary for user management; reads `USERS_DB` env var.
- `internal/web/handlers.go` — HTTP handlers including `loginPage`,
`loginSubmit`, `logout`, `uploadPage`, `uploadSubmit`.
- `internal/web/middleware.go``requireAuth` and `requireUpload` middleware;
session resolved from `"session"` cookie; stored in request context via `sessionKey`.
- `views/pages.templ``LoginPage(errMsg)`, `UploadPage(errMsg)` added;
`IndexPage` now takes `canUpload bool`; `Layout` includes logout button.
## Conventions
- Path traversal guards matter: `downloadBook` validates the resolved book
path stays within `BooksDir()` before serving; `cleanEPUBPath` rejects
`../`-escaping hrefs inside EPUB zips; `uploadSubmit` validates the
destination path stays within `BooksDir()`. Preserve these checks in any
related changes.
- Cover image reads are capped (`maxCoverBytes = 10 MiB`) to avoid decompression
abuse — keep similar limits when reading zip entries.
- Handlers return `404` for missing/empty `id` and `500` on internal errors;
keep that pattern for new routes.
- Auth: `/login` and `/static/` are the only public routes — everything else
goes through `requireAuth`. Routes needing upload permission use `requireUpload`
(which wraps `requireAuth`).
- Session cookie: `HttpOnly`, `SameSite=Lax`, 30-day expiry; no `Secure` flag
set (intended for LAN use without TLS).
- `modernc.org/sqlite` is a pure-Go SQLite driver (no CGo). `MaxOpenConns(1)`
is set because SQLite doesn't support concurrent writers.
- `users.db` is gitignored; create it at runtime with the admin CLI.
+57
View File
@@ -0,0 +1,57 @@
name: Docker Image bauen und veröffentlichen
on:
push:
branches: [develop]
tags: ["v*"]
workflow_dispatch: {}
env:
REGISTRY: git.arnef.de
IMAGE_NAME: arnef/ebooks
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Repository auschecken
uses: actions/checkout@v4
- name: QEMU einrichten (für Cross-Platform-Builds)
uses: docker/setup-qemu-action@v3
- name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3
- name: Bei Registry anmelden
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Metadaten (Tags/Labels) ermitteln
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=,format=short
- name: Image bauen und pushen (amd64 + arm64)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build-Umgebung aufräumen
if: always()
run: |
docker buildx prune --all --force
docker system prune --all --force --volumes
+1
View File
@@ -24,3 +24,4 @@ dist/
# Local env
.env
books/
users.db
+41
View File
@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build
WORKDIR /src
ARG TARGETOS
ARG TARGETARCH
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# views/pages_templ.go ist bereits generiert und im Repo eingecheckt,
# daher kein "templ generate" im Build notwendig.
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w" -o /out/admin ./cmd/admin
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \
addgroup -S ebooks && adduser -S ebooks -G ebooks
WORKDIR /app
COPY --from=build /out/server /app/server
COPY --from=build /out/admin /app/admin
COPY static ./static
ENV ADDR=:8080
ENV BOOKS_DIR=/data/books
ENV USERS_DB=/data/users.db
RUN mkdir -p /data/books && chown -R ebooks:ebooks /data /app
VOLUME ["/data"]
USER ebooks
EXPOSE 8080
ENTRYPOINT ["/app/server"]
+94 -8
View File
@@ -4,10 +4,13 @@ Minimalistische eBook-Bibliothek für den Tolino-Webbrowser.
## Features (MVP)
- Listet EPUB/PDF-Dateien aus `books/`
- Listet EPUB-Dateien aus `books/`
- Detailseite pro Buch
- Im Browser lesen (serverseitig gerenderte Kapitel, kein JavaScript nötig)
- Download-Link pro Buch (für Tolino)
- Schlichtes, kontrastreiches UI ohne JavaScript-Abhängigkeit
- Login-Pflicht (Session-Cookie, 30 Tage)
- Optionaler Buch-Upload für berechtigte Benutzer
## Voraussetzungen
@@ -34,26 +37,109 @@ go mod tidy
templ generate
```
3. Server starten:
3. Ersten Benutzer anlegen:
```bash
go run ./cmd/admin user add <benutzername>
# mit Upload-Recht:
go run ./cmd/admin user add <benutzername> --upload
```
4. Server starten:
```bash
go run ./cmd/server
```
4. Browser öffnen:
5. Browser öffnen:
- `http://localhost:8080`
## Bücher hinzufügen
Lege deine Dateien in den Ordner `books/`:
Lege deine EPUB-Dateien in den Ordner `books/`.
- `.epub` (bevorzugt)
- `.pdf` (optional)
Alternativ können Benutzer mit Upload-Recht Bücher direkt im Browser hochladen.
## Benutzerverwaltung (CLI)
Rollen (aufsteigend): `reader``uploader``admin`
```bash
go run ./cmd/admin user add <name> [--role reader|uploader|admin]
go run ./cmd/admin user list
go run ./cmd/admin user delete <name>
go run ./cmd/admin user set-role <name> <reader|uploader|admin>
```
## Docker / docker-compose Deployment
Beispiel `docker-compose.yml`:
```yaml
version: '3'
networks:
web:
external: true
services:
app:
image: git.arnef.de/arnef/ebooks:latest
restart: always
networks:
- web
volumes:
- ./data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
labels:
traefik.enable: 'true'
traefik.http.routers.ebooks.rule: Host(`books.arnef.de`)
traefik.http.routers.ebooks.entrypoints: websecure
traefik.http.routers.ebooks.tls.certresolver: letsencrypt
traefik.http.services.ebooks.loadbalancer.server.port: 8080
```
Der Container läuft als nicht-root Benutzer `ebooks` (UID `100`, GID `101`).
`BOOKS_DIR` (`/data/books`) und `USERS_DB` (`/data/users.db`) liegen im
Volume `./data`. Der Server legt `BOOKS_DIR` beim Start automatisch an,
falls es fehlt — gehört das Host-Verzeichnis `./data` aber nicht dem
passenden UID/GID, schlägt das Anlegen fehl bzw. `users.db` kann nicht
geöffnet werden (`unable to open database file`) und Uploads schlagen mit
"Datei konnte nicht gespeichert werden" fehl.
1. Datenverzeichnis anlegen und Rechte setzen:
```bash
mkdir -p data
sudo chown -R 100:101 data
```
2. Container starten:
```bash
docker compose up -d
```
3. Ersten Benutzer über die im Image enthaltene Admin-CLI anlegen (der
Server muss dafür nicht gestoppt werden, `--role admin` empfohlen für
den ersten Account):
```bash
docker compose exec app /app/admin user add <name> --role admin
docker compose exec app /app/admin user list
```
`USERS_DB` ist im Image bereits auf `/data/users.db` gesetzt, daher ist
kein zusätzlicher Parameter nötig.
## Konfiguration
Umgebungsvariablen:
- `ADDR` (Standard `:8080`)
- `BOOKS_DIR` (Standard `books`)
| Variable | Standard | Beschreibung |
|------------|------------|---------------------------------|
| `ADDR` | `:8080` | Listen-Adresse des Servers |
| `BOOKS_DIR`| `books` | Verzeichnis mit Büchern |
| `USERS_DB` | `users.db` | Pfad zur SQLite-Benutzerdatenbank |
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"fmt"
"os"
"strings"
"syscall"
"github.com/arnef/ebooks/internal/users"
"golang.org/x/term"
)
func main() {
if len(os.Args) < 3 {
usage()
os.Exit(1)
}
dbPath := getenv("USERS_DB", "users.db")
store, err := users.Open(dbPath)
if err != nil {
fatalf("open db: %v\n", err)
}
defer store.Close()
resource := os.Args[1]
action := os.Args[2]
switch resource {
case "user":
handleUser(store, action, os.Args[3:])
default:
usage()
os.Exit(1)
}
}
func handleUser(store *users.Store, action string, args []string) {
switch action {
case "add":
if len(args) < 1 {
fatalf("usage: user add <username> [--role reader|uploader|admin]\n")
}
username := args[0]
role := users.RoleReader
if len(args) > 1 && args[1] == "--role" {
if len(args) < 3 {
fatalf("--role erwartet einen Wert: reader, uploader oder admin\n")
}
r, err := users.ParseRole(args[2])
if err != nil {
fatalf("%v\n", err)
}
role = r
}
password, err := readPassword(fmt.Sprintf("Passwort für '%s': ", username))
if err != nil {
fatalf("passwort lesen: %v\n", err)
}
confirm, err := readPassword("Passwort bestätigen: ")
if err != nil {
fatalf("passwort lesen: %v\n", err)
}
if password != confirm {
fatalf("Passwörter stimmen nicht überein.\n")
}
u, err := store.CreateUser(username, password, role)
if err != nil {
fatalf("user anlegen: %v\n", err)
}
fmt.Printf("✓ Benutzer '%s' angelegt (rolle: %s)\n", u.Username, u.Role)
case "list":
list, err := store.ListUsers()
if err != nil {
fatalf("user auflisten: %v\n", err)
}
if len(list) == 0 {
fmt.Println("Keine Benutzer vorhanden.")
return
}
fmt.Printf("%-20s %s\n", "BENUTZERNAME", "ROLLE")
fmt.Println(strings.Repeat("-", 32))
for _, u := range list {
fmt.Printf("%-20s %s\n", u.Username, u.Role)
}
case "delete":
if len(args) < 1 {
fatalf("usage: user delete <username>\n")
}
if err := store.DeleteUser(args[0]); err != nil {
fatalf("user löschen: %v\n", err)
}
fmt.Printf("✓ Benutzer '%s' gelöscht.\n", args[0])
case "set-role":
if len(args) < 2 {
fatalf("usage: user set-role <username> <reader|uploader|admin>\n")
}
role, err := users.ParseRole(args[1])
if err != nil {
fatalf("%v\n", err)
}
if err := store.SetRole(args[0], role); err != nil {
fatalf("rolle setzen: %v\n", err)
}
fmt.Printf("✓ Rolle für '%s' auf '%s' gesetzt.\n", args[0], role)
default:
usage()
os.Exit(1)
}
}
func readPassword(prompt string) (string, error) {
fmt.Print(prompt)
b, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return "", err
}
return string(b), nil
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format, args...)
os.Exit(1)
}
func usage() {
fmt.Fprintln(os.Stderr, `ebooks-admin Benutzerverwaltung
Rollen (aufsteigend):
reader Lesen und Herunterladen
uploader Zusätzlich: Bücher hochladen
admin Alle Rechte
Befehle:
user add <name> [--role reader|uploader|admin] Benutzer anlegen (Standard: reader)
user list Alle Benutzer auflisten
user delete <name> Benutzer löschen
user set-role <name> <reader|uploader|admin> Rolle ändern
Umgebungsvariablen:
USERS_DB Pfad zur SQLite-Datenbank (Standard: users.db)`)
}
+13 -2
View File
@@ -6,15 +6,26 @@ import (
"os"
"github.com/arnef/ebooks/internal/library"
"github.com/arnef/ebooks/internal/users"
"github.com/arnef/ebooks/internal/web"
)
func main() {
booksDir := getenv("BOOKS_DIR", "books")
addr := getenv("ADDR", ":8080")
usersDB := getenv("USERS_DB", "users.db")
lib := library.New(booksDir)
h := web.NewHandler(lib)
store, err := users.Open(usersDB)
if err != nil {
log.Fatalf("users db: %v", err)
}
defer store.Close()
lib, err := library.New(booksDir)
if err != nil {
log.Fatalf("books dir: %v", err)
}
h := web.NewHandler(lib, store)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
+16 -1
View File
@@ -4,5 +4,20 @@ go 1.25.0
require (
github.com/a-h/templ v0.3.1020
github.com/bmaupin/go-epub v0.0.0-20210915022040-e113c1c5e4a3
golang.org/x/crypto v0.55.0
golang.org/x/net v0.58.0
golang.org/x/term v0.45.0
modernc.org/sqlite v1.56.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+56
View File
@@ -1,4 +1,60 @@
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+31 -16
View File
@@ -19,8 +19,11 @@ type Service struct {
booksDir string
}
func New(booksDir string) *Service {
return &Service{booksDir: booksDir}
func New(booksDir string) (*Service, error) {
if err := os.MkdirAll(booksDir, 0o755); err != nil {
return nil, fmt.Errorf("books dir: %w", err)
}
return &Service{booksDir: booksDir}, nil
}
func (s *Service) BooksDir() string { return s.booksDir }
@@ -41,7 +44,7 @@ func (s *Service) ListBooks() ([]Book, error) {
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".epub" && ext != ".pdf" {
if ext != ".epub" {
continue
}
@@ -52,7 +55,6 @@ func (s *Service) ListBooks() ([]Book, error) {
hasCover := false
title := fallbackTitle
if ext == ".epub" {
meta, err := readEPUBMetadata(fullPath)
if err == nil {
if strings.TrimSpace(meta.Title) != "" {
@@ -63,7 +65,6 @@ func (s *Service) ListBooks() ([]Book, error) {
}
hasCover = meta.HasCover
}
}
books = append(books, Book{
ID: id,
@@ -97,17 +98,15 @@ func (s *Service) FindBook(id string) (*Book, error) {
}
func (s *Service) CoverBytes(id string) ([]byte, string, error) {
book, err := s.FindBook(id)
if err != nil {
if id == "" {
return nil, "", nil
}
fullPath, err := s.findEPUBPath(id)
if err != nil || fullPath == "" {
return nil, "", err
}
if book == nil {
return nil, "", nil
}
if book.Format != "epub" {
return nil, "", nil
}
return readEPUBCover(book.Path)
return readEPUBCover(fullPath)
}
func stableID(input string) string {
@@ -144,6 +143,11 @@ type packageXML struct {
Properties string `xml:"properties,attr"`
} `xml:"item"`
} `xml:"manifest"`
Spine struct {
Itemrefs []struct {
IDref string `xml:"idref,attr"`
} `xml:"itemref"`
} `xml:"spine"`
}
func readEPUBMetadata(filePath string) (epubMetadata, error) {
@@ -182,15 +186,22 @@ func readEPUBCover(filePath string) ([]byte, string, error) {
if path.Clean(f.Name) != coverPath {
continue
}
const maxCoverBytes = 10 << 20 // 10 MiB
if f.UncompressedSize64 > uint64(maxCoverBytes) {
return nil, "", fmt.Errorf("cover image too large")
}
rc, err := f.Open()
if err != nil {
return nil, "", err
}
defer rc.Close()
data, err := io.ReadAll(rc)
data, err := io.ReadAll(io.LimitReader(rc, int64(maxCoverBytes)+1))
if err != nil {
return nil, "", err
}
if len(data) > maxCoverBytes {
return nil, "", fmt.Errorf("cover image too large")
}
return data, detectContentType(data, f.Name), nil
}
@@ -263,8 +274,12 @@ func findEPUBCoverPath(rootPath string, pkg packageXML) string {
}
func cleanEPUBPath(rootPath, href string) string {
href = strings.TrimSpace(href)
if href == "" || strings.HasPrefix(href, "/") {
return ""
}
joined := path.Clean(path.Join(rootPath, href))
if strings.HasPrefix(joined, "../") {
if joined == "." || strings.HasPrefix(joined, "../") || strings.HasPrefix(joined, "/") {
return ""
}
return joined
+369
View File
@@ -0,0 +1,369 @@
package library
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// ErrChapterNotFound is returned by ReadChapter when the requested chapter
// index is out of range for the book's spine.
var ErrChapterNotFound = errors.New("chapter not found")
// maxChapterBytes/maxAssetBytes cap how much we read from a zip entry to
// avoid decompression-bomb style abuse, mirroring the cover image limit.
const (
maxChapterBytes = 5 << 20 // 5 MiB
maxAssetBytes = 15 << 20 // 15 MiB (images/fonts referenced by a chapter)
)
// Chapter is a sanitized, ready-to-render view of one EPUB spine entry for
// the in-browser reader. HTML is safe to render unescaped (templ.Raw) -
// scripts, event handlers and stylesheet/style tags have been stripped and
// internal links/images rewritten to point at reader routes.
type Chapter struct {
Index int
Total int
Title string
HTML string
HasPrev bool
HasNext bool
PrevIndex int
NextIndex int
}
// ReadChapter renders the sanitized body HTML of the spine item at index for
// in-browser reading. It returns ErrChapterNotFound if index is out of range.
func (s *Service) ReadChapter(id string, index int) (*Chapter, error) {
fullPath, err := s.findEPUBPath(id)
if err != nil {
return nil, err
}
if fullPath == "" {
return nil, ErrChapterNotFound
}
r, rootPath, pkg, err := openEPUBPackage(fullPath)
if err != nil {
return nil, err
}
defer r.Close()
spine := epubSpine(rootPath, pkg)
if len(spine) == 0 {
return nil, fmt.Errorf("epub has no readable chapters")
}
if index < 0 || index >= len(spine) {
return nil, ErrChapterNotFound
}
data, err := readZipFileLimited(r.File, spine[index], maxChapterBytes)
if err != nil {
return nil, err
}
title, body, err := parseChapterDocument(data)
if err != nil {
return nil, err
}
spineIndex := make(map[string]int, len(spine))
for i, p := range spine {
spineIndex[p] = i
}
assetBase := path.Dir(spine[index])
sanitizeBody(body, id, assetBase, spineIndex)
var buf bytes.Buffer
for c := body.FirstChild; c != nil; c = c.NextSibling {
if err := html.Render(&buf, c); err != nil {
return nil, err
}
}
ch := &Chapter{
Index: index,
Total: len(spine),
Title: title,
HTML: buf.String(),
}
if index > 0 {
ch.HasPrev = true
ch.PrevIndex = index - 1
}
if index < len(spine)-1 {
ch.HasNext = true
ch.NextIndex = index + 1
}
return ch, nil
}
// ChapterAsset returns the raw bytes and content type of a file embedded in
// the given book's EPUB (e.g. an image referenced by a chapter). assetPath
// must already be a zip-internal path (see cleanEPUBPath). Returns
// (nil, "", nil) if the book or the asset doesn't exist.
func (s *Service) ChapterAsset(id, assetPath string) ([]byte, string, error) {
fullPath, err := s.findEPUBPath(id)
if err != nil || fullPath == "" {
return nil, "", err
}
cleanPath := path.Clean(assetPath)
if cleanPath == "." || cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || strings.HasPrefix(cleanPath, "/") {
return nil, "", fmt.Errorf("invalid asset path")
}
r, err := zip.OpenReader(fullPath)
if err != nil {
return nil, "", err
}
defer r.Close()
data, err := readZipFileLimited(r.File, cleanPath, maxAssetBytes)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, "", nil
}
return nil, "", err
}
return data, detectContentType(data, cleanPath), nil
}
// findEPUBPath locates the on-disk path of the book with the given id.
func (s *Service) findEPUBPath(id string) (string, error) {
entries, err := os.ReadDir(s.booksDir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.ToLower(filepath.Ext(e.Name())) != ".epub" {
continue
}
if stableID(e.Name()) != id {
continue
}
return filepath.Join(s.booksDir, e.Name()), nil
}
return "", nil
}
// epubSpine returns the cleaned, zip-internal paths of the book's content
// documents in reading order.
func epubSpine(rootPath string, pkg packageXML) []string {
manifestByID := make(map[string]string, len(pkg.Manifest.Items))
for _, it := range pkg.Manifest.Items {
manifestByID[it.ID] = it.Href
}
spine := make([]string, 0, len(pkg.Spine.Itemrefs))
for _, ref := range pkg.Spine.Itemrefs {
href, ok := manifestByID[ref.IDref]
if !ok {
continue
}
cleaned := cleanEPUBPath(rootPath, href)
if cleaned == "" {
continue
}
spine = append(spine, cleaned)
}
return spine
}
// readZipFileLimited reads a zip entry fully, refusing to read more than
// maxBytes to avoid decompression-bomb style abuse.
func readZipFileLimited(files []*zip.File, name string, maxBytes int64) ([]byte, error) {
cleanName := path.Clean(name)
for _, f := range files {
if path.Clean(f.Name) != cleanName {
continue
}
if int64(f.UncompressedSize64) > maxBytes {
return nil, fmt.Errorf("file too large: %s", name)
}
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
data, err := io.ReadAll(io.LimitReader(rc, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("file too large: %s", name)
}
return data, nil
}
return nil, os.ErrNotExist
}
// parseChapterDocument parses an XHTML/HTML content document and returns its
// <title> text (if any) and its <body> node.
func parseChapterDocument(data []byte) (string, *html.Node, error) {
doc, err := html.Parse(bytes.NewReader(data))
if err != nil {
return "", nil, err
}
var title string
var body *html.Node
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.DataAtom {
case atom.Title:
if n.FirstChild != nil && n.FirstChild.Type == html.TextNode {
title = strings.TrimSpace(n.FirstChild.Data)
}
case atom.Body:
body = n
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
if body == nil {
return title, nil, fmt.Errorf("chapter document has no <body>")
}
return title, body, nil
}
// blockedChapterTags are stripped entirely (with their subtree) from chapter
// content: scripts/forms for security, styles/links/meta/base because the
// reader uses its own site-wide stylesheet, and audio/video/iframe/object
// because their sources aren't served by the reader.
var blockedChapterTags = map[string]bool{
"script": true, "iframe": true, "object": true, "embed": true,
"form": true, "meta": true, "base": true, "link": true, "style": true,
"applet": true, "audio": true, "video": true, "noscript": true,
}
// sanitizeBody strips dangerous/unsupported elements and attributes from a
// chapter's body content in place, and rewrites relative image sources and
// internal chapter links so they resolve against the reader's routes.
func sanitizeBody(body *html.Node, bookID, assetBase string, spineIndex map[string]int) {
for c := body.FirstChild; c != nil; {
next := c.NextSibling
sanitizeNode(c, bookID, assetBase, spineIndex)
c = next
}
}
func sanitizeNode(n *html.Node, bookID, assetBase string, spineIndex map[string]int) {
if n.Type == html.ElementNode && blockedChapterTags[strings.ToLower(n.Data)] {
if n.Parent != nil {
n.Parent.RemoveChild(n)
}
return
}
if n.Type == html.ElementNode {
attrs := n.Attr[:0]
for _, a := range n.Attr {
key := strings.ToLower(a.Key)
if strings.HasPrefix(key, "on") {
continue // strip inline event handlers (onclick, onload, ...)
}
switch key {
case "src":
a.Val = rewriteAssetRef(a.Val, bookID, assetBase)
case "href":
if strings.EqualFold(n.Data, "a") {
a.Val = rewriteLinkHref(a.Val, bookID, assetBase, spineIndex)
} else {
// e.g. SVG <image xlink:href="...">
a.Val = rewriteAssetRef(a.Val, bookID, assetBase)
}
}
attrs = append(attrs, a)
}
n.Attr = attrs
}
for c := n.FirstChild; c != nil; {
next := c.NextSibling
sanitizeNode(c, bookID, assetBase, spineIndex)
c = next
}
}
func rewriteAssetRef(raw, bookID, assetBase string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return raw
}
lower := strings.ToLower(raw)
if strings.HasPrefix(lower, "javascript:") {
return ""
}
if strings.HasPrefix(raw, "data:") || strings.Contains(raw, "://") || strings.HasPrefix(raw, "//") {
return raw
}
cleaned := cleanEPUBPath(assetBase, raw)
if cleaned == "" {
return ""
}
return "/read/" + bookID + "/asset/" + encodeAssetPath(cleaned)
}
func rewriteLinkHref(raw, bookID, assetBase string, spineIndex map[string]int) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return raw
}
lower := strings.ToLower(raw)
if strings.HasPrefix(lower, "javascript:") {
return "#"
}
if strings.HasPrefix(raw, "#") {
return raw // same-page anchor
}
if strings.Contains(raw, "://") || strings.HasPrefix(raw, "//") || strings.HasPrefix(lower, "mailto:") {
return raw // external link
}
target, fragment := raw, ""
if i := strings.IndexByte(raw, '#'); i >= 0 {
target, fragment = raw[:i], raw[i:]
}
if target == "" {
return raw
}
cleaned := cleanEPUBPath(assetBase, target)
if cleaned == "" {
return "#"
}
if idx, ok := spineIndex[cleaned]; ok {
return fmt.Sprintf("/read/%s/%d%s", bookID, idx, fragment)
}
return "#" // unresolved internal reference (e.g. footnote in a non-spine doc)
}
func encodeAssetPath(p string) string {
segments := strings.Split(p, "/")
for i, seg := range segments {
segments[i] = url.PathEscape(seg)
}
return strings.Join(segments, "/")
}
+43
View File
@@ -0,0 +1,43 @@
package users
import "fmt"
// Role represents a user's access level. Roles are ordered hierarchically:
// RoleReader < RoleUploader < RoleAdmin.
// New roles can be inserted into roleLevel without changing existing code.
type Role string
const (
RoleReader Role = "reader"
RoleUploader Role = "uploader"
RoleAdmin Role = "admin"
)
// roleLevel defines the hierarchy. Higher = more permissions.
var roleLevel = map[Role]int{
RoleReader: 0,
RoleUploader: 1,
RoleAdmin: 2,
}
// AtLeast reports whether r has at least the same privilege level as min.
func (r Role) AtLeast(min Role) bool {
return roleLevel[r] >= roleLevel[min]
}
// IsValid reports whether r is a known role.
func (r Role) IsValid() bool {
_, ok := roleLevel[r]
return ok
}
func (r Role) String() string { return string(r) }
// ParseRole parses a role string. Returns an error for unknown values.
func ParseRole(s string) (Role, error) {
r := Role(s)
if !r.IsValid() {
return "", fmt.Errorf("unbekannte Rolle %q, gültig: reader, uploader, admin", s)
}
return r, nil
}
+271
View File
@@ -0,0 +1,271 @@
package users
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
var ErrNotFound = errors.New("user not found")
var ErrUserExists = errors.New("user already exists")
type User struct {
ID int64
Username string
Role Role
}
type Session struct {
Token string
UserID int64
Username string
Role Role
ExpiresAt time.Time
}
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1) // SQLite doesn't support concurrent writers
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
// migrations is an ordered list of schema changes. Each entry is a slice of
// SQL statements to execute in a transaction. Add new entries at the end only.
var migrations = [][]string{
// v1: initial schema
{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
can_upload INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
)`,
},
// v2: replace can_upload with role column; migrate existing data
{
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'reader'`,
`UPDATE users SET role = CASE WHEN can_upload = 1 THEN 'uploader' ELSE 'reader' END`,
},
}
func (s *Store) migrate() error {
if _, err := s.db.Exec(
`CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)`,
); err != nil {
return err
}
var version int
if err := s.db.QueryRow(
`SELECT COALESCE(MAX(version), 0) FROM _schema_version`,
).Scan(&version); err != nil {
return err
}
for i, stmts := range migrations {
v := i + 1
if v <= version {
continue
}
tx, err := s.db.Begin()
if err != nil {
return err
}
for _, stmt := range stmts {
if _, err := tx.Exec(stmt); err != nil {
_ = tx.Rollback()
return fmt.Errorf("migration %d: %w", v, err)
}
}
if _, err := tx.Exec(`INSERT INTO _schema_version (version) VALUES (?)`, v); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
// CreateUser adds a new user with the given role. Returns ErrUserExists if the
// username is already taken.
func (s *Store) CreateUser(username, password string, role Role) (*User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
res, err := s.db.Exec(
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`,
username, string(hash), role,
)
if err != nil {
if isUnique(err) {
return nil, ErrUserExists
}
return nil, err
}
id, _ := res.LastInsertId()
return &User{ID: id, Username: username, Role: role}, nil
}
// SetRole changes the role of an existing user.
func (s *Store) SetRole(username string, role Role) error {
res, err := s.db.Exec(
`UPDATE users SET role = ? WHERE username = ?`,
role, username,
)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteUser removes a user by username.
func (s *Store) DeleteUser(username string) error {
res, err := s.db.Exec(`DELETE FROM users WHERE username = ?`, username)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ListUsers returns all users ordered by username.
func (s *Store) ListUsers() ([]User, error) {
rows, err := s.db.Query(`SELECT id, username, role FROM users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
var roleStr string
if err := rows.Scan(&u.ID, &u.Username, &roleStr); err != nil {
return nil, err
}
u.Role = Role(roleStr)
users = append(users, u)
}
return users, rows.Err()
}
// Authenticate verifies credentials and returns the matching user, or nil on
// wrong username/password.
func (s *Store) Authenticate(username, password string) (*User, error) {
var u User
var hash, roleStr string
err := s.db.QueryRow(
`SELECT id, username, password_hash, role FROM users WHERE username = ?`,
username,
).Scan(&u.ID, &u.Username, &hash, &roleStr)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
return nil, nil
}
u.Role = Role(roleStr)
return &u, nil
}
// CreateSession issues a new session token for a user (TTL: 30 days).
func (s *Store) CreateSession(u *User) (*Session, error) {
token, err := randomToken()
if err != nil {
return nil, err
}
exp := time.Now().Add(30 * 24 * time.Hour)
if _, err = s.db.Exec(
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`,
token, u.ID, exp.Unix(),
); err != nil {
return nil, err
}
return &Session{
Token: token,
UserID: u.ID,
Username: u.Username,
Role: u.Role,
ExpiresAt: exp,
}, nil
}
// LookupSession returns the session if valid, nil if not found or expired.
func (s *Store) LookupSession(token string) (*Session, error) {
var sess Session
var roleStr string
var expUnix int64
err := s.db.QueryRow(`
SELECT s.token, s.user_id, u.username, u.role, s.expires_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ?
`, token).Scan(&sess.Token, &sess.UserID, &sess.Username, &roleStr, &expUnix)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
sess.ExpiresAt = time.Unix(expUnix, 0)
if time.Now().After(sess.ExpiresAt) {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
return nil, nil
}
sess.Role = Role(roleStr)
return &sess, nil
}
// DeleteSession invalidates a session token (logout).
func (s *Store) DeleteSession(token string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
return err
}
func randomToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("random token: %w", err)
}
return hex.EncodeToString(b), nil
}
func isUnique(err error) bool {
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique")
}
+235 -7
View File
@@ -3,29 +3,51 @@ package web
import (
"context"
"errors"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/a-h/templ"
"github.com/arnef/ebooks/internal/library"
"github.com/arnef/ebooks/internal/users"
"github.com/arnef/ebooks/views"
)
const maxUploadBytes = 512 << 20 // 512 MiB
type Handler struct {
lib *library.Service
store *users.Store
}
func NewHandler(lib *library.Service) *Handler {
return &Handler{lib: lib}
func NewHandler(lib *library.Service, store *users.Store) *Handler {
return &Handler{lib: lib, store: store}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", h.listBooks)
mux.HandleFunc("GET /book/{id}", h.bookDetails)
mux.HandleFunc("GET /download/{id}", h.downloadBook)
mux.HandleFunc("GET /cover/{id}", h.bookCover)
// Public
mux.HandleFunc("GET /login", h.loginPage)
mux.HandleFunc("POST /login", h.loginSubmit)
mux.HandleFunc("POST /logout", h.logout)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// Authenticated (any role)
mux.HandleFunc("GET /", h.requireAuth(h.listBooks))
mux.HandleFunc("GET /book/{id}", h.requireAuth(h.bookDetails))
mux.HandleFunc("GET /download/{id}", h.requireAuth(h.downloadBook))
mux.HandleFunc("GET /cover/{id}", h.requireAuth(h.bookCover))
mux.HandleFunc("GET /read/{id}/asset/{path...}", h.requireAuth(h.readAsset))
mux.HandleFunc("GET /read/{id}/{idx}", h.requireAuth(h.readChapter))
// Uploader and above
mux.HandleFunc("GET /upload", h.requireRole(users.RoleUploader, h.uploadPage))
mux.HandleFunc("POST /upload", h.requireRole(users.RoleUploader, h.uploadSubmit))
}
func (h *Handler) listBooks(w http.ResponseWriter, r *http.Request) {
@@ -34,7 +56,9 @@ func (h *Handler) listBooks(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to load books", http.StatusInternalServerError)
return
}
render(w, r.Context(), views.IndexPage(books))
sess := sessionFrom(r)
canUpload := sess != nil && sess.Role.AtLeast(users.RoleUploader)
render(w, r.Context(), views.IndexPage(books, canUpload))
}
func (h *Handler) bookDetails(w http.ResponseWriter, r *http.Request) {
@@ -110,9 +134,213 @@ func (h *Handler) bookCover(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(data)
}
func (h *Handler) readChapter(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
http.NotFound(w, r)
return
}
idx, err := strconv.Atoi(r.PathValue("idx"))
if err != nil || idx < 0 {
http.NotFound(w, r)
return
}
book, err := h.lib.FindBook(id)
if err != nil {
http.Error(w, "failed to load book", http.StatusInternalServerError)
return
}
if book == nil {
http.NotFound(w, r)
return
}
chapter, err := h.lib.ReadChapter(id, idx)
if err != nil {
if errors.Is(err, library.ErrChapterNotFound) {
http.NotFound(w, r)
return
}
log.Printf("read chapter: %v", err)
http.Error(w, "Kapitel konnte nicht geladen werden", http.StatusInternalServerError)
return
}
render(w, r.Context(), views.ReaderPage(*book, *chapter))
}
func (h *Handler) readAsset(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
assetPath := r.PathValue("path")
if id == "" || assetPath == "" {
http.NotFound(w, r)
return
}
data, contentType, err := h.lib.ChapterAsset(id, assetPath)
if err != nil {
log.Printf("read asset: %v", err)
http.Error(w, "Datei konnte nicht geladen werden", http.StatusInternalServerError)
return
}
if len(data) == 0 {
http.NotFound(w, r)
return
}
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=3600")
_, _ = w.Write(data)
}
func render(w http.ResponseWriter, ctx context.Context, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := c.Render(ctx, w); err != nil && !errors.Is(err, context.Canceled) {
http.Error(w, "render failed", http.StatusInternalServerError)
}
}
// --- Auth handlers ---
func (h *Handler) loginPage(w http.ResponseWriter, r *http.Request) {
render(w, r.Context(), views.LoginPage(""))
}
func (h *Handler) loginSubmit(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
user, err := h.store.Authenticate(username, password)
if err != nil {
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
if user == nil {
render(w, r.Context(), views.LoginPage("Benutzername oder Passwort falsch."))
return
}
sess, err := h.store.CreateSession(user)
if err != nil {
http.Error(w, "Session konnte nicht erstellt werden", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sess.Token,
Path: "/",
Expires: sess.ExpiresAt,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil {
_ = h.store.DeleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// --- Upload handlers ---
func (h *Handler) uploadPage(w http.ResponseWriter, r *http.Request) {
render(w, r.Context(), views.UploadPage(""))
}
func (h *Handler) uploadSubmit(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
if err := r.ParseMultipartForm(32 << 20); err != nil {
render(w, r.Context(), views.UploadPage("Datei zu groß oder ungültige Anfrage."))
return
}
var headers []*multipart.FileHeader
if r.MultipartForm != nil {
headers = r.MultipartForm.File["book"]
}
if len(headers) == 0 {
render(w, r.Context(), views.UploadPage("Keine Datei ausgewählt."))
return
}
var errs []string
saved := 0
for _, header := range headers {
if msg := h.saveUpload(header); msg != "" {
errs = append(errs, fmt.Sprintf("%s: %s", header.Filename, msg))
} else {
saved++
}
}
if len(errs) > 0 {
msg := strings.Join(errs, " ")
if saved > 0 {
msg = fmt.Sprintf("%d von %d Dateien hochgeladen. %s", saved, len(headers), msg)
}
render(w, r.Context(), views.UploadPage(msg))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// saveUpload validates and persists a single uploaded file. It returns a
// user-facing German error message, or "" on success.
func (h *Handler) saveUpload(header *multipart.FileHeader) string {
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".epub" {
return "Nur EPUB-Dateien erlaubt."
}
file, err := header.Open()
if err != nil {
log.Printf("upload: open %q failed: %v", header.Filename, err)
return "Datei konnte nicht gelesen werden."
}
defer file.Close()
// Sanitize filename: keep only base name, no path components.
safeFilename := filepath.Base(header.Filename)
destPath := filepath.Join(h.lib.BooksDir(), safeFilename)
// Check it still resolves inside BooksDir.
cleanBase := filepath.Clean(h.lib.BooksDir())
cleanDest := filepath.Clean(destPath)
if !strings.HasPrefix(cleanDest, cleanBase+string(filepath.Separator)) {
return "Ungültiger Dateiname."
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
if os.IsExist(err) {
return fmt.Sprintf("Datei '%s' existiert bereits.", safeFilename)
}
log.Printf("upload: create %q failed: %v", destPath, err)
return "Datei konnte nicht gespeichert werden."
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
log.Printf("upload: write %q failed: %v", destPath, err)
_ = os.Remove(destPath)
return "Upload fehlgeschlagen."
}
return ""
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"context"
"net/http"
"github.com/arnef/ebooks/internal/users"
)
type contextKey int
const sessionKey contextKey = iota
// sessionFrom retrieves the session stored in request context.
func sessionFrom(r *http.Request) *users.Session {
s, _ := r.Context().Value(sessionKey).(*users.Session)
return s
}
// requireAuth redirects unauthenticated requests to /login.
func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, err := h.sessionFromRequest(r)
if err != nil || sess == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := context.WithValue(r.Context(), sessionKey, sess)
next(w, r.WithContext(ctx))
}
}
// requireRole returns middleware that allows access only to users whose role is
// at least minRole. Unknown sessions are redirected to /login; insufficient
// role yields 403.
func (h *Handler) requireRole(minRole users.Role, next http.HandlerFunc) http.HandlerFunc {
return h.requireAuth(func(w http.ResponseWriter, r *http.Request) {
sess := sessionFrom(r)
if !sess.Role.AtLeast(minRole) {
http.Error(w, "Keine Berechtigung", http.StatusForbidden)
return
}
next(w, r)
})
}
func (h *Handler) sessionFromRequest(r *http.Request) (*users.Session, error) {
cookie, err := r.Cookie("session")
if err != nil {
return nil, nil
}
return h.store.LookupSession(cookie.Value)
}
+143 -23
View File
@@ -73,73 +73,106 @@ h2 {
.book-list {
list-style: none;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin: 0;
padding: 0;
}
.book-item {
border: 1px solid #000;
margin-bottom: 0.6rem;
padding: 0.6rem;
box-sizing: border-box;
margin: 0;
border: none;
}
.book-card {
display: table;
width: 100%;
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid #000;
padding: 0.5rem;
color: inherit;
text-decoration: none;
}
.book-card-cover,
.book-card-body {
display: table-cell;
vertical-align: top;
box-sizing: border-box;
}
.book-card-cover {
width: 4.8rem;
padding-right: 0.75rem;
display: block;
margin-bottom: 0.5rem;
}
.book-card-body {
display: block;
}
.book-detail-cover {
display: block;
}
.book-card-cover,
.book-detail-cover {
text-align: center;
}
.book-cover,
.book-cover-placeholder {
display: block;
width: 4.2rem;
height: 6rem;
display: inline-block;
width: 100%;
max-width: 100%;
aspect-ratio: 2 / 3;
border: 1px solid #000;
object-fit: cover;
vertical-align: top;
}
.book-cover-placeholder {
text-align: center;
line-height: 6rem;
font-size: 0.8rem;
font-size: 0.75rem;
font-weight: bold;
line-height: 1.2;
padding-top: 2.3rem;
box-sizing: border-box;
}
.book-cover-detail,
.book-cover-placeholder-detail {
width: 8rem;
height: 11.5rem;
max-width: none;
margin-bottom: 1rem;
}
.book-title {
display: block;
min-height: 44px;
.book-card .book-title {
font-weight: bold;
font-size: 0.95rem;
line-height: 1.25;
margin-bottom: 0.25rem;
overflow-wrap: break-word;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-author,
.book-author-detail {
display: block;
margin-top: 0.2rem;
overflow-wrap: break-word;
word-break: break-word;
}
.book-author {
font-size: 0.85rem;
line-height: 1.25;
}
.format {
display: block;
margin-top: 0.2rem;
font-size: 0.95rem;
font-size: 0.85rem;
}
.book-detail {
@@ -170,17 +203,104 @@ h2 {
color: inherit;
}
.book-detail-body .btn {
margin: 0 0.5rem 0.5rem 0;
}
.reader {
display: block;
}
.reader-progress {
margin: 0 0 0.5rem;
font-size: 0.85rem;
}
.reader-chapter-title {
margin-top: 0;
}
.reader-content {
line-height: 1.6;
overflow-wrap: break-word;
}
.reader-content img {
display: block;
max-width: 100%;
height: auto;
margin: 0.5rem auto;
}
.reader-content p {
margin: 0 0 1rem;
}
.reader-nav {
display: flex;
justify-content: space-between;
gap: 0.5rem;
margin-top: 1.5rem;
}
.reader-nav-next {
margin-left: auto;
}
.btn {
display: inline-block;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.7rem 1rem;
border: 2px solid #000;
background: none;
text-decoration: none;
color: #000;
font-family: inherit;
font-size: inherit;
font-weight: bold;
cursor: pointer;
box-sizing: border-box;
}
code {
border: 1px solid #000;
padding: 0.1rem 0.3rem;
}
/* --- Auth & Upload --- */
.btn-logout {
background: none;
border: 1px solid #000;
cursor: pointer;
padding: 0.3rem 0.6rem;
font-size: 0.9rem;
}
.login-box {
max-width: 22rem;
margin-top: 3rem;
}
.login-form,
.upload-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.login-form input,
.upload-form input[type="file"] {
padding: 0.5rem;
font-size: 1rem;
border: 1px solid #000;
}
.error {
border: 2px solid #000;
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
font-weight: bold;
}
+99 -5
View File
@@ -1,6 +1,7 @@
package views
import (
"fmt"
"strings"
"github.com/arnef/ebooks/internal/library"
)
@@ -21,6 +22,9 @@ templ Layout(title string, backHref string) {
<a class="header-back" href={ backHref } aria-label="Zurück"></a>
}
<h1><a href="/">eBook Library</a></h1>
<form method="post" action="/logout" style="margin-left:auto">
<button type="submit" class="btn-logout">Abmelden</button>
</form>
</div>
</header>
<main class="container">
@@ -30,6 +34,57 @@ templ Layout(title string, backHref string) {
</html>
}
templ LoginLayout(title string) {
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{ title }</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<main class="container">
{ children... }
</main>
</body>
</html>
}
templ LoginPage(errMsg string) {
@LoginLayout("Anmelden") {
<section class="login-box">
<h2>Anmelden</h2>
if errMsg != "" {
<p class="error">{ errMsg }</p>
}
<form method="post" action="/login" class="login-form">
<label for="username">Benutzername</label>
<input id="username" type="text" name="username" required autofocus />
<label for="password">Passwort</label>
<input id="password" type="password" name="password" required />
<button type="submit" class="btn">Anmelden</button>
</form>
</section>
}
}
templ UploadPage(errMsg string) {
@Layout("Bücher hochladen", "/") {
<section class="page-head">
<h2>Bücher hochladen</h2>
</section>
if errMsg != "" {
<p class="error">{ errMsg }</p>
}
<form method="post" action="/upload" enctype="multipart/form-data" class="upload-form">
<label for="book">EPUB(s) auswählen</label>
<input id="book" type="file" name="book" accept=".epub" multiple required />
<button type="submit" class="btn">Hochladen</button>
</form>
}
}
templ BookCover(book library.Book, detail bool) {
if book.HasCover {
<img class={ coverClass(detail) } src={ "/cover/" + book.ID } alt={ "Cover von " + book.Title } loading="lazy" />
@@ -40,14 +95,17 @@ templ BookCover(book library.Book, detail bool) {
}
}
templ IndexPage(books []library.Book) {
templ IndexPage(books []library.Book, canUpload bool) {
@Layout("Bibliothek", "") {
<section class="page-head">
<h2>Meine Bücher</h2>
<p class="page-subtitle">Titel, Autor und Cover auf einen Blick.</p>
if canUpload {
<a class="btn" href="/upload">Buch hochladen</a>
}
</section>
if len(books) == 0 {
<p>Keine eBooks gefunden. Lege EPUB- oder PDF-Dateien im Ordner <code>books/</code> ab.</p>
<p>Keine eBooks gefunden. Lege EPUB-Dateien im Ordner <code>books/</code> ab.</p>
} else {
<ul class="book-list">
for _, b := range books {
@@ -57,8 +115,8 @@ templ IndexPage(books []library.Book) {
@BookCover(b, false)
</div>
<div class="book-card-body">
<strong class="book-title">{ b.Title }</strong>
<span class="book-author">{ b.Author }</span>
<strong class="book-title" title={ b.Title }>{ truncateTitle(b.Title, 60) }</strong>
<span class="book-author">{ truncateTitle(b.Author, 40) }</span>
<span class="format">{ b.Format }</span>
</div>
</a>
@@ -88,12 +146,37 @@ templ BookPage(book library.Book) {
<dd>{ book.Format }</dd>
</div>
</dl>
<p><a class="btn" href={ "/download/" + book.ID }>Auf Tolino herunterladen</a></p>
<p>
<a class="btn" href={ "/read/" + book.ID + "/0" }>Im Browser lesen</a>
<a class="btn" href={ "/download/" + book.ID }>Auf Tolino herunterladen</a>
</p>
</div>
</article>
}
}
templ ReaderPage(book library.Book, chapter library.Chapter) {
@Layout(book.Title, "/book/" + book.ID) {
<article class="reader">
<p class="reader-progress">{ fmt.Sprintf("Kapitel %d von %d", chapter.Index+1, chapter.Total) }</p>
if chapter.Title != "" {
<h2 class="reader-chapter-title">{ chapter.Title }</h2>
}
<div class="reader-content">
@templ.Raw(chapter.HTML)
</div>
<nav class="reader-nav">
if chapter.HasPrev {
<a class="btn reader-nav-prev" href={ fmt.Sprintf("/read/%s/%d", book.ID, chapter.PrevIndex) }> Zurück</a>
}
if chapter.HasNext {
<a class="btn reader-nav-next" href={ fmt.Sprintf("/read/%s/%d", book.ID, chapter.NextIndex) }>Weiter </a>
}
</nav>
</article>
}
}
func coverClass(detail bool) string {
if detail {
return "book-cover book-cover-detail"
@@ -107,3 +190,14 @@ func placeholderClass(detail bool) string {
}
return "book-cover-placeholder"
}
// truncateTitle kürzt lange Titel für die Kartenansicht, damit lange,
// nicht umbrechbare Titel das Grid-Layout nicht sprengen. Der volle Titel
// bleibt im Detail-Seiten-Titel und im alt-Text des Covers erhalten.
func truncateTitle(title string, maxLen int) string {
r := []rune(title)
if len(r) <= maxLen {
return title
}
return string(r[:maxLen]) + "…"
}
+637 -73
View File
@@ -8,9 +8,13 @@ package views
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "github.com/arnef/ebooks/internal/library"
import (
"fmt"
"github.com/arnef/ebooks/internal/library"
"strings"
)
func Layout(title string) templ.Component {
func Layout(title string, backHref string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -38,13 +42,36 @@ func Layout(title string) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 11, Col: 18}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 15, Col: 18}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/static/styles.css\"></head><body><header class=\"header\"><h1><a href=\"/\">eBook Library</a></h1></header><main class=\"container\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/static/styles.css\"></head><body><header class=\"header\"><div class=\"header-bar\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if backHref != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a class=\"header-back\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backHref)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 22, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" aria-label=\"Zurück\">←</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<h1><a href=\"/\">eBook Library</a></h1><form method=\"post\" action=\"/logout\" style=\"margin-left:auto\"><button type=\"submit\" class=\"btn-logout\">Abmelden</button></form></div></header><main class=\"container\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -52,7 +79,7 @@ func Layout(title string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -60,7 +87,7 @@ func Layout(title string) templ.Component {
})
}
func IndexPage(books []library.Book) templ.Component {
func LoginLayout(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -76,12 +103,62 @@ func IndexPage(books []library.Book) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<!doctype html><html lang=\"de\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 43, Col: 18}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</title><link rel=\"stylesheet\" href=\"/static/styles.css\"></head><body><main class=\"container\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var4.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func LoginPage(errMsg string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
@@ -93,73 +170,368 @@ func IndexPage(books []library.Book) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<h2>Meine Bücher</h2>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<section class=\"login-box\"><h2>Anmelden</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(books) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<p>Keine eBooks gefunden. Lege EPUB- oder PDF-Dateien im Ordner <code>books/</code> ab.</p>")
if errMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<p class=\"error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 59, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<form method=\"post\" action=\"/login\" class=\"login-form\"><label for=\"username\">Benutzername</label> <input id=\"username\" type=\"text\" name=\"username\" required autofocus> <label for=\"password\">Passwort</label> <input id=\"password\" type=\"password\" name=\"password\" required> <button type=\"submit\" class=\"btn\">Anmelden</button></form></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = LoginLayout("Anmelden").Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func UploadPage(errMsg string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<section class=\"page-head\"><h2>Bücher hochladen</h2></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 78, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " <form method=\"post\" action=\"/upload\" enctype=\"multipart/form-data\" class=\"upload-form\"><label for=\"book\">EPUB(s) auswählen</label> <input id=\"book\" type=\"file\" name=\"book\" accept=\".epub\" multiple required> <button type=\"submit\" class=\"btn\">Hochladen</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout("Bücher hochladen", "/").Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func BookCover(book library.Book, detail bool) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
if templ_7745c5c3_Var12 == nil {
templ_7745c5c3_Var12 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if book.HasCover {
var templ_7745c5c3_Var13 = []any{coverClass(detail)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<img class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var13).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/cover/" + book.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 90, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("Cover von " + book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 90, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" loading=\"lazy\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<ul class=\"book-list\">")
var templ_7745c5c3_Var17 = []any{placeholderClass(detail)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, b := range books {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<li class=\"book-item\"><a class=\"book-link\" href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs("/book/" + b.ID)
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var17).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 34, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" aria-hidden=\"true\"><span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(b.Title)
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.Format))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 34, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 93, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</a> <span class=\"format\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(b.Format)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 35, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</ul>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
templ_7745c5c3_Err = Layout("Bibliothek").Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer)
}
func IndexPage(books []library.Book, canUpload bool) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
if templ_7745c5c3_Var20 == nil {
templ_7745c5c3_Var20 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var21 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<section class=\"page-head\"><h2>Meine Bücher</h2><p class=\"page-subtitle\">Titel, Autor und Cover auf einen Blick.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canUpload {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<a class=\"btn\" href=\"/upload\">Buch hochladen</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(books) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<p>Keine eBooks gefunden. Lege EPUB-Dateien im Ordner <code>books/</code> ab.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<ul class=\"book-list\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, b := range books {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li class=\"book-item\"><a class=\"book-card\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 templ.SafeURL
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/book/" + b.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 113, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"><div class=\"book-card-cover\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = BookCover(b, false).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div><div class=\"book-card-body\"><strong class=\"book-title\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(b.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 118, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(truncateTitle(b.Title, 60))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 118, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</strong> <span class=\"book-author\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(truncateTitle(b.Author, 40))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 119, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span> <span class=\"format\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(b.Format)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 120, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span></div></a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
templ_7745c5c3_Err = Layout("Bibliothek", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var21), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -183,12 +555,12 @@ func BookPage(book library.Book) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
templ_7745c5c3_Var27 := templ.GetChildren(ctx)
if templ_7745c5c3_Var27 == nil {
templ_7745c5c3_Var27 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var9 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_Var28 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
@@ -200,65 +572,99 @@ func BookPage(book library.Book) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<article><h2>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<article class=\"book-detail\"><div class=\"book-detail-cover\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 46, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
templ_7745c5c3_Err = BookCover(book, true).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h2><p><strong>Datei:</strong> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div><div class=\"book-detail-body\"><h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.Filename)
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 47, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 137, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p><p><strong>Format:</strong> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h2><p class=\"book-author-detail\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(book.Format)
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 48, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 138, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p><p><a class=\"btn\" href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</p><dl class=\"book-meta\"><div><dt>Datei</dt><dd>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/download/" + book.ID)
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(book.Filename)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 49, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 142, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\">Auf Tolino herunterladen</a></p><p><a href=\"/\">← Zurück zur Liste</a></p></article>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</dd></div><div><dt>Format</dt><dd>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Format)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 146, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</dd></div></dl><p><a class=\"btn\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 templ.SafeURL
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs("/read/" + book.ID + "/0")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 150, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">Im Browser lesen</a> <a class=\"btn\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 templ.SafeURL
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs("/download/" + book.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 151, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">Auf Tolino herunterladen</a></p></div></article>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout(book.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var9), templ_7745c5c3_Buffer)
templ_7745c5c3_Err = Layout(book.Title, "/").Render(templ.WithChildren(ctx, templ_7745c5c3_Var28), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -266,4 +672,162 @@ func BookPage(book library.Book) templ.Component {
})
}
func ReaderPage(book library.Book, chapter library.Chapter) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var35 := templ.GetChildren(ctx)
if templ_7745c5c3_Var35 == nil {
templ_7745c5c3_Var35 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var36 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<article class=\"reader\"><p class=\"reader-progress\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Kapitel %d von %d", chapter.Index+1, chapter.Total))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 161, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if chapter.Title != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<h2 class=\"reader-chapter-title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(chapter.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 163, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div class=\"reader-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(chapter.HTML).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div><nav class=\"reader-nav\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if chapter.HasPrev {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<a class=\"btn reader-nav-prev\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 templ.SafeURL
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/read/%s/%d", book.ID, chapter.PrevIndex))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 170, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">← Zurück</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if chapter.HasNext {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<a class=\"btn reader-nav-next\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 templ.SafeURL
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/read/%s/%d", book.ID, chapter.NextIndex))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 173, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">Weiter →</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</nav></article>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout(book.Title, "/book/"+book.ID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var36), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func coverClass(detail bool) string {
if detail {
return "book-cover book-cover-detail"
}
return "book-cover"
}
func placeholderClass(detail bool) string {
if detail {
return "book-cover-placeholder book-cover-placeholder-detail"
}
return "book-cover-placeholder"
}
// truncateTitle kürzt lange Titel für die Kartenansicht, damit lange,
// nicht umbrechbare Titel das Grid-Layout nicht sprengen. Der volle Titel
// bleibt im Detail-Seiten-Titel und im alt-Text des Covers erhalten.
func truncateTitle(title string, maxLen int) string {
r := []rune(title)
if len(r) <= maxLen {
return title
}
return string(r[:maxLen]) + "…"
}
var _ = templruntime.GeneratedTemplate