Compare commits
9
Commits
c877abf302
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfecc49810 | ||
|
|
2399d8a5a6 | ||
|
|
be3dfaeb47 | ||
|
|
a51578cbf1 | ||
|
|
e4fe39ecc5 | ||
|
|
0c3e5cec9f | ||
|
|
4c0aa053ac | ||
|
|
952a4c9b52 | ||
|
|
d60d3a703e |
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
books/
|
||||||
|
users.db
|
||||||
|
*.db
|
||||||
|
dist/
|
||||||
|
bin/
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
README.md
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# eBook Library (Go + templ)
|
# eBook Library (Go + templ)
|
||||||
|
|
||||||
Minimalistic, no-JS eBook library web app for reading files (EPUB/PDF) on a
|
Minimalistic, no-JS eBook library web app for reading EPUB files on a
|
||||||
Tolino e-reader browser. UI text/comments are in German.
|
Tolino e-reader browser. UI text/comments are in German.
|
||||||
|
|
||||||
## Build & run
|
## Build & run
|
||||||
|
|||||||
@@ -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
|
||||||
+41
@@ -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"]
|
||||||
@@ -4,8 +4,9 @@ Minimalistische eBook-Bibliothek für den Tolino-Webbrowser.
|
|||||||
|
|
||||||
## Features (MVP)
|
## Features (MVP)
|
||||||
|
|
||||||
- Listet EPUB/PDF-Dateien aus `books/`
|
- Listet EPUB-Dateien aus `books/`
|
||||||
- Detailseite pro Buch
|
- Detailseite pro Buch
|
||||||
|
- Im Browser lesen (serverseitig gerenderte Kapitel, kein JavaScript nötig)
|
||||||
- Download-Link pro Buch (für Tolino)
|
- Download-Link pro Buch (für Tolino)
|
||||||
- Schlichtes, kontrastreiches UI ohne JavaScript-Abhängigkeit
|
- Schlichtes, kontrastreiches UI ohne JavaScript-Abhängigkeit
|
||||||
- Login-Pflicht (Session-Cookie, 30 Tage)
|
- Login-Pflicht (Session-Cookie, 30 Tage)
|
||||||
@@ -56,10 +57,7 @@ go run ./cmd/server
|
|||||||
|
|
||||||
## Bücher hinzufügen
|
## 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.
|
Alternativ können Benutzer mit Upload-Recht Bücher direkt im Browser hochladen.
|
||||||
|
|
||||||
@@ -74,6 +72,68 @@ go run ./cmd/admin user delete <name>
|
|||||||
go run ./cmd/admin user set-role <name> <reader|uploader|admin>
|
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
|
## Konfiguration
|
||||||
|
|
||||||
Umgebungsvariablen:
|
Umgebungsvariablen:
|
||||||
|
|||||||
+4
-1
@@ -21,7 +21,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
lib := library.New(booksDir)
|
lib, err := library.New(booksDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("books dir: %v", err)
|
||||||
|
}
|
||||||
h := web.NewHandler(lib, store)
|
h := web.NewHandler(lib, store)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ module github.com/arnef/ebooks
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require github.com/a-h/templ v0.3.1020
|
require (
|
||||||
|
github.com/a-h/templ v0.3.1020
|
||||||
|
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 (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
@@ -10,11 +16,8 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/crypto v0.54.0 // indirect
|
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/term v0.45.0 // indirect
|
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.56.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,25 +4,57 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
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 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
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 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||||
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
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=
|
||||||
|
|||||||
+22
-34
@@ -19,8 +19,11 @@ type Service struct {
|
|||||||
booksDir string
|
booksDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(booksDir string) *Service {
|
func New(booksDir string) (*Service, error) {
|
||||||
return &Service{booksDir: booksDir}
|
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 }
|
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()))
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||||
if ext != ".epub" && ext != ".pdf" {
|
if ext != ".epub" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,17 +55,15 @@ func (s *Service) ListBooks() ([]Book, error) {
|
|||||||
hasCover := false
|
hasCover := false
|
||||||
title := fallbackTitle
|
title := fallbackTitle
|
||||||
|
|
||||||
if ext == ".epub" {
|
meta, err := readEPUBMetadata(fullPath)
|
||||||
meta, err := readEPUBMetadata(fullPath)
|
if err == nil {
|
||||||
if err == nil {
|
if strings.TrimSpace(meta.Title) != "" {
|
||||||
if strings.TrimSpace(meta.Title) != "" {
|
title = strings.TrimSpace(meta.Title)
|
||||||
title = strings.TrimSpace(meta.Title)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(meta.Author) != "" {
|
|
||||||
author = strings.TrimSpace(meta.Author)
|
|
||||||
}
|
|
||||||
hasCover = meta.HasCover
|
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(meta.Author) != "" {
|
||||||
|
author = strings.TrimSpace(meta.Author)
|
||||||
|
}
|
||||||
|
hasCover = meta.HasCover
|
||||||
}
|
}
|
||||||
|
|
||||||
books = append(books, Book{
|
books = append(books, Book{
|
||||||
@@ -101,29 +102,11 @@ func (s *Service) CoverBytes(id string) ([]byte, string, error) {
|
|||||||
return nil, "", nil
|
return nil, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(s.booksDir)
|
fullPath, err := s.findEPUBPath(id)
|
||||||
if err != nil {
|
if err != nil || fullPath == "" {
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, "", nil
|
|
||||||
}
|
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
return readEPUBCover(fullPath)
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
|
||||||
if ext != ".epub" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if stableID(e.Name()) != id {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return readEPUBCover(filepath.Join(s.booksDir, e.Name()))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, "", nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stableID(input string) string {
|
func stableID(input string) string {
|
||||||
@@ -160,6 +143,11 @@ type packageXML struct {
|
|||||||
Properties string `xml:"properties,attr"`
|
Properties string `xml:"properties,attr"`
|
||||||
} `xml:"item"`
|
} `xml:"item"`
|
||||||
} `xml:"manifest"`
|
} `xml:"manifest"`
|
||||||
|
Spine struct {
|
||||||
|
Itemrefs []struct {
|
||||||
|
IDref string `xml:"idref,attr"`
|
||||||
|
} `xml:"itemref"`
|
||||||
|
} `xml:"spine"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func readEPUBMetadata(filePath string) (epubMetadata, error) {
|
func readEPUBMetadata(filePath string) (epubMetadata, error) {
|
||||||
|
|||||||
@@ -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, "/")
|
||||||
|
}
|
||||||
+114
-15
@@ -5,9 +5,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
@@ -39,6 +42,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /book/{id}", h.requireAuth(h.bookDetails))
|
mux.HandleFunc("GET /book/{id}", h.requireAuth(h.bookDetails))
|
||||||
mux.HandleFunc("GET /download/{id}", h.requireAuth(h.downloadBook))
|
mux.HandleFunc("GET /download/{id}", h.requireAuth(h.downloadBook))
|
||||||
mux.HandleFunc("GET /cover/{id}", h.requireAuth(h.bookCover))
|
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
|
// Uploader and above
|
||||||
mux.HandleFunc("GET /upload", h.requireRole(users.RoleUploader, h.uploadPage))
|
mux.HandleFunc("GET /upload", h.requireRole(users.RoleUploader, h.uploadPage))
|
||||||
@@ -129,6 +134,69 @@ func (h *Handler) bookCover(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(data)
|
_, _ = 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) {
|
func render(w http.ResponseWriter, ctx context.Context, c templ.Component) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := c.Render(ctx, w); err != nil && !errors.Is(err, context.Canceled) {
|
if err := c.Render(ctx, w); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
@@ -201,19 +269,52 @@ func (h *Handler) uploadSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, header, err := r.FormFile("book")
|
var headers []*multipart.FileHeader
|
||||||
if err != nil {
|
if r.MultipartForm != nil {
|
||||||
|
headers = r.MultipartForm.File["book"]
|
||||||
|
}
|
||||||
|
if len(headers) == 0 {
|
||||||
render(w, r.Context(), views.UploadPage("Keine Datei ausgewählt."))
|
render(w, r.Context(), views.UploadPage("Keine Datei ausgewählt."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
var errs []string
|
||||||
if ext != ".epub" && ext != ".pdf" {
|
saved := 0
|
||||||
render(w, r.Context(), views.UploadPage("Nur EPUB- und PDF-Dateien erlaubt."))
|
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
|
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.
|
// Sanitize filename: keep only base name, no path components.
|
||||||
safeFilename := filepath.Base(header.Filename)
|
safeFilename := filepath.Base(header.Filename)
|
||||||
destPath := filepath.Join(h.lib.BooksDir(), safeFilename)
|
destPath := filepath.Join(h.lib.BooksDir(), safeFilename)
|
||||||
@@ -222,26 +323,24 @@ func (h *Handler) uploadSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
cleanBase := filepath.Clean(h.lib.BooksDir())
|
cleanBase := filepath.Clean(h.lib.BooksDir())
|
||||||
cleanDest := filepath.Clean(destPath)
|
cleanDest := filepath.Clean(destPath)
|
||||||
if !strings.HasPrefix(cleanDest, cleanBase+string(filepath.Separator)) {
|
if !strings.HasPrefix(cleanDest, cleanBase+string(filepath.Separator)) {
|
||||||
render(w, r.Context(), views.UploadPage("Ungültiger Dateiname."))
|
return "Ungültiger Dateiname."
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsExist(err) {
|
if os.IsExist(err) {
|
||||||
render(w, r.Context(), views.UploadPage(fmt.Sprintf("Datei '%s' existiert bereits.", safeFilename)))
|
return fmt.Sprintf("Datei '%s' existiert bereits.", safeFilename)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
http.Error(w, "Datei konnte nicht gespeichert werden", http.StatusInternalServerError)
|
log.Printf("upload: create %q failed: %v", destPath, err)
|
||||||
return
|
return "Datei konnte nicht gespeichert werden."
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
if _, err := io.Copy(out, file); err != nil {
|
if _, err := io.Copy(out, file); err != nil {
|
||||||
|
log.Printf("upload: write %q failed: %v", destPath, err)
|
||||||
_ = os.Remove(destPath)
|
_ = os.Remove(destPath)
|
||||||
http.Error(w, "Upload fehlgeschlagen", http.StatusInternalServerError)
|
return "Upload fehlgeschlagen."
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-8
@@ -73,26 +73,28 @@ h2 {
|
|||||||
|
|
||||||
.book-list {
|
.book-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 -0.25rem;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-item {
|
.book-item {
|
||||||
display: inline-block;
|
|
||||||
width: 33.3333%;
|
|
||||||
vertical-align: top;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0.25rem;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-card {
|
.book-card {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
border: 1px solid #000;
|
border: 1px solid #000;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-card-cover {
|
.book-card-cover {
|
||||||
@@ -141,18 +143,25 @@ h2 {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-title {
|
.book-card .book-title {
|
||||||
display: block;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
margin-bottom: 0.25rem;
|
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,
|
||||||
.book-author-detail {
|
.book-author-detail {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-author {
|
.book-author {
|
||||||
@@ -194,6 +203,50 @@ h2 {
|
|||||||
color: inherit;
|
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 {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+45
-8
@@ -1,6 +1,7 @@
|
|||||||
package views
|
package views
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"github.com/arnef/ebooks/internal/library"
|
"github.com/arnef/ebooks/internal/library"
|
||||||
)
|
)
|
||||||
@@ -69,16 +70,16 @@ templ LoginPage(errMsg string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ UploadPage(errMsg string) {
|
templ UploadPage(errMsg string) {
|
||||||
@Layout("Buch hochladen", "/") {
|
@Layout("Bücher hochladen", "/") {
|
||||||
<section class="page-head">
|
<section class="page-head">
|
||||||
<h2>Buch hochladen</h2>
|
<h2>Bücher hochladen</h2>
|
||||||
</section>
|
</section>
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
<p class="error">{ errMsg }</p>
|
<p class="error">{ errMsg }</p>
|
||||||
}
|
}
|
||||||
<form method="post" action="/upload" enctype="multipart/form-data" class="upload-form">
|
<form method="post" action="/upload" enctype="multipart/form-data" class="upload-form">
|
||||||
<label for="book">EPUB oder PDF auswählen</label>
|
<label for="book">EPUB(s) auswählen</label>
|
||||||
<input id="book" type="file" name="book" accept=".epub,.pdf" required />
|
<input id="book" type="file" name="book" accept=".epub" multiple required />
|
||||||
<button type="submit" class="btn">Hochladen</button>
|
<button type="submit" class="btn">Hochladen</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
@@ -104,7 +105,7 @@ templ IndexPage(books []library.Book, canUpload bool) {
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
if len(books) == 0 {
|
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 {
|
} else {
|
||||||
<ul class="book-list">
|
<ul class="book-list">
|
||||||
for _, b := range books {
|
for _, b := range books {
|
||||||
@@ -114,8 +115,8 @@ templ IndexPage(books []library.Book, canUpload bool) {
|
|||||||
@BookCover(b, false)
|
@BookCover(b, false)
|
||||||
</div>
|
</div>
|
||||||
<div class="book-card-body">
|
<div class="book-card-body">
|
||||||
<strong class="book-title">{ b.Title }</strong>
|
<strong class="book-title" title={ b.Title }>{ truncateTitle(b.Title, 60) }</strong>
|
||||||
<span class="book-author">{ b.Author }</span>
|
<span class="book-author">{ truncateTitle(b.Author, 40) }</span>
|
||||||
<span class="format">{ b.Format }</span>
|
<span class="format">{ b.Format }</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -145,12 +146,37 @@ templ BookPage(book library.Book) {
|
|||||||
<dd>{ book.Format }</dd>
|
<dd>{ book.Format }</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</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>
|
</div>
|
||||||
</article>
|
</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 {
|
func coverClass(detail bool) string {
|
||||||
if detail {
|
if detail {
|
||||||
return "book-cover book-cover-detail"
|
return "book-cover book-cover-detail"
|
||||||
@@ -164,3 +190,14 @@ func placeholderClass(detail bool) string {
|
|||||||
}
|
}
|
||||||
return "book-cover-placeholder"
|
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]) + "…"
|
||||||
|
}
|
||||||
|
|||||||
+229
-58
@@ -9,6 +9,7 @@ import "github.com/a-h/templ"
|
|||||||
import templruntime "github.com/a-h/templ/runtime"
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/arnef/ebooks/internal/library"
|
"github.com/arnef/ebooks/internal/library"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -41,7 +42,7 @@ func Layout(title string, backHref string) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 14, 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -59,7 +60,7 @@ func Layout(title string, backHref string) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 templ.SafeURL
|
var templ_7745c5c3_Var3 templ.SafeURL
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backHref)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backHref)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 21, Col: 48}
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -114,7 +115,7 @@ func LoginLayout(title string) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 42, Col: 18}
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -181,7 +182,7 @@ func LoginPage(errMsg string) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 58, Col: 33}
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -239,7 +240,7 @@ func UploadPage(errMsg string) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<section class=\"page-head\"><h2>Buch hochladen</h2></section>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<section class=\"page-head\"><h2>Bücher hochladen</h2></section>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -251,7 +252,7 @@ func UploadPage(errMsg string) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 77, Col: 31}
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -262,13 +263,13 @@ func UploadPage(errMsg string) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
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 oder PDF auswählen</label> <input id=\"book\" type=\"file\" name=\"book\" accept=\".epub,.pdf\" required> <button type=\"submit\" class=\"btn\">Hochladen</button></form>")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
templ_7745c5c3_Err = Layout("Buch hochladen", "/").Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Layout("Bücher hochladen", "/").Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -323,7 +324,7 @@ func BookCover(book library.Book, detail bool) templ.Component {
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/cover/" + book.ID)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/cover/" + book.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 89, Col: 63}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -336,7 +337,7 @@ func BookCover(book library.Book, detail bool) templ.Component {
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("Cover von " + book.Title)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("Cover von " + book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 89, Col: 97}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -372,7 +373,7 @@ func BookCover(book library.Book, detail bool) templ.Component {
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.Format))
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.Format))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 92, Col: 42}
|
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_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -435,7 +436,7 @@ func IndexPage(books []library.Book, canUpload bool) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(books) == 0 {
|
if len(books) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<p>Keine eBooks gefunden. Lege EPUB- oder PDF-Dateien im Ordner <code>books/</code> ab.</p>")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -452,7 +453,7 @@ func IndexPage(books []library.Book, canUpload bool) templ.Component {
|
|||||||
var templ_7745c5c3_Var22 templ.SafeURL
|
var templ_7745c5c3_Var22 templ.SafeURL
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/book/" + b.ID)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/book/" + b.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 112, Col: 55}
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -466,51 +467,64 @@ func IndexPage(books []library.Book, canUpload bool) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div><div class=\"book-card-body\"><strong class=\"book-title\">")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(b.Title)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(b.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 117, Col: 52}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 118, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</strong> <span class=\"book-author\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(b.Author)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(truncateTitle(b.Title, 60))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 118, Col: 52}
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span> <span class=\"format\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</strong> <span class=\"book-author\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(b.Format)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(truncateTitle(b.Author, 40))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 119, Col: 47}
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span></div></a></li>")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</ul>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</ul>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -541,12 +555,12 @@ func BookPage(book library.Book) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var27 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var26 == nil {
|
if templ_7745c5c3_Var27 == nil {
|
||||||
templ_7745c5c3_Var26 = templ.NopComponent
|
templ_7745c5c3_Var27 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Var27 := 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_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
if !templ_7745c5c3_IsBuffer {
|
if !templ_7745c5c3_IsBuffer {
|
||||||
@@ -558,7 +572,7 @@ func BookPage(book library.Book) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<article class=\"book-detail\"><div class=\"book-detail-cover\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<article class=\"book-detail\"><div class=\"book-detail-cover\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -566,78 +580,224 @@ func BookPage(book library.Book) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div><div class=\"book-detail-body\"><h2>")
|
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_Var28 string
|
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 136, Col: 24}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</h2><p class=\"book-author-detail\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 137, Col: 51}
|
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_Var29))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</p><dl class=\"book-meta\"><div><dt>Datei</dt><dd>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h2><p class=\"book-author-detail\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var30 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.Filename)
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 141, Col: 31}
|
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_Var30))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</dd></div><div><dt>Format</dt><dd>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</p><dl class=\"book-meta\"><div><dt>Datei</dt><dd>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var31 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(book.Format)
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(book.Filename)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 145, Col: 29}
|
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_Var31))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</dd></div></dl><p><a class=\"btn\" href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</dd></div><div><dt>Format</dt><dd>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var32 templ.SafeURL
|
var templ_7745c5c3_Var32 string
|
||||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs("/download/" + book.ID)
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Format)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 148, Col: 55}
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">Auf Tolino herunterladen</a></p></div></article>")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
templ_7745c5c3_Err = Layout(book.Title, "/").Render(templ.WithChildren(ctx, templ_7745c5c3_Var27), 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
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -659,4 +819,15 @@ func placeholderClass(detail bool) string {
|
|||||||
return "book-cover-placeholder"
|
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
|
var _ = templruntime.GeneratedTemplate
|
||||||
|
|||||||
Reference in New Issue
Block a user