Initial MVP: Go + templ Tolino-optimized ebook library scaffold

This commit is contained in:
Arne
2026-08-06 06:52:41 +02:00
parent bf077c0cf6
commit 8d499c28a7
11 changed files with 440 additions and 0 deletions
+25
View File
@@ -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
+59
View File
@@ -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`)
+1
View File
@@ -0,0 +1 @@
# keep books directory in git
+33
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
module github.com/arnef/ebooks
go 1.22
require github.com/a-h/templ v0.3.833
+78
View File
@@ -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])
}
+9
View File
@@ -0,0 +1,9 @@
package library
type Book struct {
ID string
Title string
Filename string
Path string
Format string
}
+91
View File
@@ -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)
}
}
+79
View File
@@ -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;
}
+53
View File
@@ -0,0 +1,53 @@
package views
import "github.com/arnef/ebooks/internal/library"
templ Layout(title string) {
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{ title }</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<header class="header">
<h1><a href="/">eBook Library</a></h1>
</header>
<main class="container">
{ children... }
</main>
</body>
</html>
}
templ IndexPage(books []library.Book) {
@Layout("Bibliothek") {
<h2>Meine Bücher</h2>
if len(books) == 0 {
<p>Keine eBooks gefunden. Lege EPUB- oder PDF-Dateien im Ordner <code>books/</code> ab.</p>
} else {
<ul class="book-list">
for _, b := range books {
<li class="book-item">
<a class="book-link" href={ "/book/" + b.ID }>{ b.Title }</a>
<span class="format">{ b.Format }</span>
</li>
}
</ul>
}
}
}
templ BookPage(book library.Book) {
@Layout(book.Title) {
<article>
<h2>{ book.Title }</h2>
<p><strong>Datei:</strong> { book.Filename }</p>
<p><strong>Format:</strong> { book.Format }</p>
<p><a class="btn" href={ "/download/" + book.ID }>Auf Tolino herunterladen</a></p>
<p><a href="/"> Zurück zur Liste</a></p>
</article>
}
}
+7
View File
@@ -0,0 +1,7 @@
package views
// Code generated by templ - DO NOT EDIT.
import "github.com/a-h/templ"
var _ templ.Component