From 8d499c28a702fdcd4efc763fedc6c3ff496e6ab3 Mon Sep 17 00:00:00 2001 From: Arne <1169654+arnef@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:52:41 +0200 Subject: [PATCH] Initial MVP: Go + templ Tolino-optimized ebook library scaffold --- .gitignore | 25 ++++++++++ README.md | 59 ++++++++++++++++++++++++ books/.gitkeep | 1 + cmd/server/main.go | 33 ++++++++++++++ go.mod | 5 ++ internal/library/library.go | 78 +++++++++++++++++++++++++++++++ internal/library/model.go | 9 ++++ internal/web/handlers.go | 91 +++++++++++++++++++++++++++++++++++++ static/styles.css | 79 ++++++++++++++++++++++++++++++++ views/pages.templ | 53 +++++++++++++++++++++ views/pages_templ.go | 7 +++ 11 files changed, 440 insertions(+) create mode 100644 .gitignore create mode 100644 books/.gitkeep create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 internal/library/library.go create mode 100644 internal/library/model.go create mode 100644 internal/web/handlers.go create mode 100644 static/styles.css create mode 100644 views/pages.templ create mode 100644 views/pages_templ.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1474eb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test artifacts +*.test +*.out +coverage.out + +# Build output +dist/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Local env +.env diff --git a/README.md b/README.md index e69de29..a1cd08f 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,59 @@ +# eBook Library (Go + templ) + +Minimalistische eBook-Bibliothek für den Tolino-Webbrowser. + +## Features (MVP) + +- Listet EPUB/PDF-Dateien aus `books/` +- Detailseite pro Buch +- Download-Link pro Buch (für Tolino) +- Schlichtes, kontrastreiches UI ohne JavaScript-Abhängigkeit + +## Voraussetzungen + +- Go 1.22+ +- [templ](https://github.com/a-h/templ) + +Templ CLI installieren: + +```bash +go install github.com/a-h/templ/cmd/templ@latest +``` + +## Start + +1. Abhängigkeiten laden: + +```bash +go mod tidy +``` + +2. Templates generieren: + +```bash +templ generate +``` + +3. Server starten: + +```bash +go run ./cmd/server +``` + +4. Browser öffnen: + +- `http://localhost:8080` + +## Bücher hinzufügen + +Lege deine Dateien in den Ordner `books/`: + +- `.epub` (bevorzugt) +- `.pdf` (optional) + +## Konfiguration + +Umgebungsvariablen: + +- `ADDR` (Standard `:8080`) +- `BOOKS_DIR` (Standard `books`) diff --git a/books/.gitkeep b/books/.gitkeep new file mode 100644 index 0000000..9724fca --- /dev/null +++ b/books/.gitkeep @@ -0,0 +1 @@ +# keep books directory in git diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..8c335ce --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "log" + "net/http" + "os" + + "github.com/arnef/ebooks/internal/library" + "github.com/arnef/ebooks/internal/web" +) + +func main() { + booksDir := getenv("BOOKS_DIR", "books") + addr := getenv("ADDR", ":8080") + + lib := library.New(booksDir) + h := web.NewHandler(lib) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + log.Printf("ebooks server listening on %s (books dir: %s)", addr, booksDir) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatal(err) + } +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4500731 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/arnef/ebooks + +go 1.22 + +require github.com/a-h/templ v0.3.833 diff --git a/internal/library/library.go b/internal/library/library.go new file mode 100644 index 0000000..c0592b2 --- /dev/null +++ b/internal/library/library.go @@ -0,0 +1,78 @@ +package library + +import ( + "crypto/sha1" + "encoding/hex" + "os" + "path/filepath" + "sort" + "strings" +) + +type Service struct { + booksDir string +} + +func New(booksDir string) *Service { + return &Service{booksDir: booksDir} +} + +func (s *Service) BooksDir() string { return s.booksDir } + +func (s *Service) ListBooks() ([]Book, error) { + entries, err := os.ReadDir(s.booksDir) + if err != nil { + if os.IsNotExist(err) { + return []Book{}, nil + } + return nil, err + } + + books := make([]Book, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext != ".epub" && ext != ".pdf" { + continue + } + + fullPath := filepath.Join(s.booksDir, e.Name()) + id := stableID(e.Name()) + title := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) + + books = append(books, Book{ + ID: id, + Title: title, + Filename: e.Name(), + Path: fullPath, + Format: strings.TrimPrefix(ext, "."), + }) + } + + sort.Slice(books, func(i, j int) bool { + return strings.ToLower(books[i].Title) < strings.ToLower(books[j].Title) + }) + + return books, nil +} + +func (s *Service) FindBook(id string) (*Book, error) { + books, err := s.ListBooks() + if err != nil { + return nil, err + } + for i := range books { + if books[i].ID == id { + return &books[i], nil + } + } + return nil, nil +} + +func stableID(input string) string { + h := sha1.Sum([]byte(strings.ToLower(input))) + return hex.EncodeToString(h[:8]) +} diff --git a/internal/library/model.go b/internal/library/model.go new file mode 100644 index 0000000..c867b05 --- /dev/null +++ b/internal/library/model.go @@ -0,0 +1,9 @@ +package library + +type Book struct { + ID string + Title string + Filename string + Path string + Format string +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go new file mode 100644 index 0000000..fc756df --- /dev/null +++ b/internal/web/handlers.go @@ -0,0 +1,91 @@ +package web + +import ( + "context" + "errors" + "net/http" + "path/filepath" + "strings" + + "github.com/arnef/ebooks/internal/library" + "github.com/arnef/ebooks/views" +) + +type Handler struct { + lib *library.Service +} + +func NewHandler(lib *library.Service) *Handler { + return &Handler{lib: lib} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /", h.listBooks) + mux.HandleFunc("GET /book/{id}", h.bookDetails) + mux.HandleFunc("GET /download/{id}", h.downloadBook) + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) +} + +func (h *Handler) listBooks(w http.ResponseWriter, r *http.Request) { + books, err := h.lib.ListBooks() + if err != nil { + http.Error(w, "failed to load books", http.StatusInternalServerError) + return + } + render(w, r.Context(), views.IndexPage(books)) +} + +func (h *Handler) bookDetails(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + 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 + } + + render(w, r.Context(), views.BookPage(*book)) +} + +func (h *Handler) downloadBook(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + 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 + } + + cleanBase := filepath.Clean(h.lib.BooksDir()) + cleanPath := filepath.Clean(book.Path) + if !strings.HasPrefix(cleanPath, cleanBase+string(filepath.Separator)) && cleanPath != cleanBase { + http.Error(w, "invalid file path", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Disposition", "attachment; filename=\""+book.Filename+"\"") + http.ServeFile(w, r, cleanPath) +} + +func render(w http.ResponseWriter, ctx context.Context, page interface{ Render(context.Context, http.ResponseWriter) error }) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := page.Render(ctx, w); err != nil && !errors.Is(err, context.Canceled) { + http.Error(w, "render failed", http.StatusInternalServerError) + } +} diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..9c5d92a --- /dev/null +++ b/static/styles.css @@ -0,0 +1,79 @@ +:root { + color-scheme: light; +} + +html, body { + margin: 0; + padding: 0; + background: #fff; + color: #000; + font-family: Arial, sans-serif; + font-size: 18px; + line-height: 1.45; +} + +.header { + border-bottom: 2px solid #000; + padding: 0.75rem 1rem; +} + +.header h1 { + margin: 0; + font-size: 1.2rem; +} + +.header a { + color: inherit; + text-decoration: none; +} + +.container { + padding: 1rem; + max-width: 42rem; +} + +h2 { + margin-top: 0; + font-size: 1.25rem; +} + +.book-list { + list-style: none; + margin: 0; + padding: 0; +} + +.book-item { + border: 1px solid #000; + margin-bottom: 0.6rem; + padding: 0.6rem; +} + +.book-link { + display: inline-block; + min-height: 44px; + color: #000; + text-decoration: none; + font-weight: bold; +} + +.format { + display: block; + margin-top: 0.2rem; + font-size: 0.95rem; +} + +.btn { + display: inline-block; + min-height: 44px; + padding: 0.7rem 1rem; + border: 2px solid #000; + text-decoration: none; + color: #000; + font-weight: bold; +} + +code { + border: 1px solid #000; + padding: 0.1rem 0.3rem; +} diff --git a/views/pages.templ b/views/pages.templ new file mode 100644 index 0000000..4ec7864 --- /dev/null +++ b/views/pages.templ @@ -0,0 +1,53 @@ +package views + +import "github.com/arnef/ebooks/internal/library" + +templ Layout(title string) { + + + + + + { title } + + + +
+

eBook Library

+
+
+ { children... } +
+ + +} + +templ IndexPage(books []library.Book) { + @Layout("Bibliothek") { +

Meine Bücher

+ if len(books) == 0 { +

Keine eBooks gefunden. Lege EPUB- oder PDF-Dateien im Ordner books/ ab.

+ } else { + + } + } +} + +templ BookPage(book library.Book) { + @Layout(book.Title) { +
+

{ book.Title }

+

Datei: { book.Filename }

+

Format: { book.Format }

+

Auf Tolino herunterladen

+

← Zurück zur Liste

+
+ } +} diff --git a/views/pages_templ.go b/views/pages_templ.go new file mode 100644 index 0000000..46ed21b --- /dev/null +++ b/views/pages_templ.go @@ -0,0 +1,7 @@ +package views + +// Code generated by templ - DO NOT EDIT. + +import "github.com/a-h/templ" + +var _ templ.Component