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 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 ")
+ }
+ 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
+ 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) {
{ book.Format }
- Auf Tolino herunterladen
+
+ Im Browser lesen
+ Auf Tolino herunterladen
+
}
}
+templ ReaderPage(book library.Book, chapter library.Chapter) {
+ @Layout(book.Title, "/book/" + book.ID) {
+
+ { fmt.Sprintf("Kapitel %d von %d", chapter.Index+1, chapter.Total) }
+ if chapter.Title != "" {
+ { chapter.Title }
+ }
+
+ @templ.Raw(chapter.HTML)
+
+
+
+ }
+}
+
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")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">Im Browser lesen Auf Tolino herunterladen")
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, "")
+ 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, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if chapter.Title != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "")
+ 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, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "")
+ 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, "
")
+ 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"