diff --git a/README.md b/README.md index ab13444..ab1b4bb 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Minimalistische eBook-Bibliothek für den Tolino-Webbrowser. - 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) diff --git a/go.mod b/go.mod index 03ca39c..cf5a6fa 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,13 @@ module github.com/arnef/ebooks 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 ( 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/ncruces/go-strftime v1.0.0 // 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/term v0.45.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.56.0 // indirect ) diff --git a/go.sum b/go.sum index f9943ec..59cdda1 100644 --- a/go.sum +++ b/go.sum @@ -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/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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +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= diff --git a/internal/library/library.go b/internal/library/library.go index b4956dc..1359852 100644 --- a/internal/library/library.go +++ b/internal/library/library.go @@ -102,29 +102,11 @@ func (s *Service) CoverBytes(id string) ([]byte, string, error) { return nil, "", nil } - entries, err := os.ReadDir(s.booksDir) - if err != nil { - if os.IsNotExist(err) { - return nil, "", nil - } + fullPath, err := s.findEPUBPath(id) + if err != nil || fullPath == "" { return nil, "", err } - - 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 + return readEPUBCover(fullPath) } func stableID(input string) string { @@ -161,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) { diff --git a/internal/library/reader.go b/internal/library/reader.go new file mode 100644 index 0000000..b645e59 --- /dev/null +++ b/internal/library/reader.go @@ -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 +// 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, "/") +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 91176e0..dec48c6 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "github.com/a-h/templ" @@ -41,6 +42,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { 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)) @@ -131,6 +134,69 @@ 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) { diff --git a/static/styles.css b/static/styles.css index 2c24649..bee1caa 100644 --- a/static/styles.css +++ b/static/styles.css @@ -203,6 +203,54 @@ h2 { color: inherit; } +.btn + .btn { + margin-left: 0.5rem; +} + +.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 .btn + .btn { + margin-left: 0; +} + +.reader-nav-next { + margin-left: auto; +} + .btn { display: inline-flex; align-items: center; diff --git a/views/pages.templ b/views/pages.templ index e71a73d..44ec052 100644 --- a/views/pages.templ +++ b/views/pages.templ @@ -1,6 +1,7 @@ package views import ( + "fmt" "strings" "github.com/arnef/ebooks/internal/library" ) @@ -145,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" diff --git a/views/pages_templ.go b/views/pages_templ.go index e75243b..188518f 100644 --- a/views/pages_templ.go +++ b/views/pages_templ.go @@ -9,6 +9,7 @@ import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" import ( + "fmt" "github.com/arnef/ebooks/internal/library" "strings" ) @@ -41,7 +42,7 @@ func Layout(title string, backHref 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: 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)) if templ_7745c5c3_Err != nil { @@ -59,7 +60,7 @@ func Layout(title string, backHref string) templ.Component { 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: 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)) if templ_7745c5c3_Err != nil { @@ -114,7 +115,7 @@ func LoginLayout(title string) templ.Component { 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: 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)) if templ_7745c5c3_Err != nil { @@ -181,7 +182,7 @@ func LoginPage(errMsg string) templ.Component { 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: 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)) if templ_7745c5c3_Err != nil { @@ -251,7 +252,7 @@ func UploadPage(errMsg string) templ.Component { 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: 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)) if templ_7745c5c3_Err != nil { @@ -323,7 +324,7 @@ func BookCover(book library.Book, detail bool) templ.Component { 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: 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) if templ_7745c5c3_Err != nil { @@ -336,7 +337,7 @@ func BookCover(book library.Book, detail bool) templ.Component { 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: 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) if templ_7745c5c3_Err != nil { @@ -372,7 +373,7 @@ func BookCover(book library.Book, detail bool) templ.Component { 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: 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)) if templ_7745c5c3_Err != nil { @@ -452,7 +453,7 @@ func IndexPage(books []library.Book, canUpload bool) templ.Component { 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: 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)) if templ_7745c5c3_Err != nil { @@ -473,7 +474,7 @@ func IndexPage(books []library.Book, canUpload bool) templ.Component { 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: 117, Col: 58} + 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 { @@ -486,7 +487,7 @@ func IndexPage(books []library.Book, canUpload bool) templ.Component { 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: 117, Col: 89} + 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 { @@ -499,7 +500,7 @@ func IndexPage(books []library.Book, canUpload bool) templ.Component { 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: 118, Col: 71} + 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 { @@ -512,7 +513,7 @@ func IndexPage(books []library.Book, canUpload bool) templ.Component { 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: 119, Col: 47} + 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 { @@ -586,7 +587,7 @@ func BookPage(book library.Book) templ.Component { 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: 136, Col: 24} + 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)) if templ_7745c5c3_Err != nil { @@ -599,7 +600,7 @@ func BookPage(book library.Book) templ.Component { 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: 137, Col: 51} + 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)) if templ_7745c5c3_Err != nil { @@ -612,7 +613,7 @@ func BookPage(book library.Book) templ.Component { 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: 141, Col: 31} + 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)) if templ_7745c5c3_Err != nil { @@ -625,7 +626,7 @@ func BookPage(book library.Book) templ.Component { 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: 145, Col: 29} + 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 { @@ -636,15 +637,28 @@ func BookPage(book library.Book) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var33 templ.SafeURL - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs("/download/" + book.ID) + 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: 148, Col: 55} + 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, "\">Auf Tolino herunterladen</a></p></div></article>") + 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 } @@ -658,6 +672,139 @@ 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"