Add public feed, admin area, self-registration, visibility & hashtags
Some checks failed
Deploy to NAS / deploy (push) Failing after 26s
Some checks failed
Deploy to NAS / deploy (push) Failing after 26s
- Public feed (/) with infinite scroll via Intersection Observer - Self-registration (/register) - Admin area (/admin/entries, /admin/users) with user management - journal_entries: visibility (public/private) + hashtags fields - users: is_admin flag - DB schema updated (recreate DB to apply) - CI: run go test via docker run (golang:1.25-alpine) — fixes 'go not found' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +28,7 @@ var funcMap = template.FuncMap{
|
||||
}
|
||||
return *p
|
||||
},
|
||||
"join": strings.Join,
|
||||
}
|
||||
|
||||
var tmpls = template.Must(
|
||||
@@ -47,17 +49,14 @@ type WebUI struct {
|
||||
tpStore *db.TrackpointStore
|
||||
stopStore *db.StopStore
|
||||
journalStore *db.JournalStore
|
||||
userStore *db.UserStore
|
||||
}
|
||||
|
||||
func NewWebUI(a *auth.Store, tp *db.TrackpointStore, st *db.StopStore, j *db.JournalStore) *WebUI {
|
||||
return &WebUI{authStore: a, tpStore: tp, stopStore: st, journalStore: j}
|
||||
func NewWebUI(a *auth.Store, tp *db.TrackpointStore, st *db.StopStore, j *db.JournalStore, u *db.UserStore) *WebUI {
|
||||
return &WebUI{authStore: a, tpStore: tp, stopStore: st, journalStore: j, userStore: u}
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, page string, data any) {
|
||||
// Each page defines its own blocks; "base" assembles the full document.
|
||||
// We must clone and re-associate per request because ParseFS loads all
|
||||
// templates into one set — ExecuteTemplate("base") picks up the blocks
|
||||
// defined by the last parsed file otherwise.
|
||||
t, err := tmpls.Clone()
|
||||
if err == nil {
|
||||
_, err = t.ParseFS(assets, "templates/"+page)
|
||||
@@ -67,8 +66,6 @@ func render(w http.ResponseWriter, page string, data any) {
|
||||
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Render into buffer first so we can still send a proper error status
|
||||
// if execution fails — once we write to w the status code is committed.
|
||||
var buf bytes.Buffer
|
||||
if err := t.ExecuteTemplate(&buf, "base", data); err != nil {
|
||||
slog.Error("template execute", "page", page, "err", err)
|
||||
@@ -79,6 +76,49 @@ func render(w http.ResponseWriter, page string, data any) {
|
||||
_, _ = buf.WriteTo(w)
|
||||
}
|
||||
|
||||
func renderFragment(w http.ResponseWriter, page, block string, data any) {
|
||||
t, err := tmpls.Clone()
|
||||
if err == nil {
|
||||
_, err = t.ParseFS(assets, "templates/"+page)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("template parse", "page", page, "err", err)
|
||||
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.ExecuteTemplate(&buf, block, data); err != nil {
|
||||
slog.Error("template execute fragment", "page", page, "block", block, "err", err)
|
||||
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = buf.WriteTo(w)
|
||||
}
|
||||
|
||||
func renderAdmin(w http.ResponseWriter, page string, data any) {
|
||||
// Parse layout (admin_base) + specific page fresh each request (no shared state between pages).
|
||||
t, err := template.New("").Funcs(funcMap).ParseFS(assets,
|
||||
"templates/admin/layout.html",
|
||||
"templates/admin/"+page,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("template parse admin", "page", page, "err", err)
|
||||
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.ExecuteTemplate(&buf, "admin_base", data); err != nil {
|
||||
slog.Error("template execute admin", "page", page, "err", err)
|
||||
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = buf.WriteTo(w)
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
func (ui *WebUI) HandleGetLogin(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, "login.html", map[string]any{"Error": "", "Username": ""})
|
||||
}
|
||||
@@ -128,6 +168,75 @@ func (ui *WebUI) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Register ---
|
||||
|
||||
func (ui *WebUI) HandleGetRegister(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, "register.html", map[string]any{"Error": "", "Username": ""})
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandlePostRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
confirm := r.FormValue("confirm")
|
||||
|
||||
if username == "" || password == "" {
|
||||
render(w, "register.html", map[string]any{"Error": "Benutzername und Passwort sind Pflichtfelder.", "Username": username})
|
||||
return
|
||||
}
|
||||
if password != confirm {
|
||||
render(w, "register.html", map[string]any{"Error": "Passwörter stimmen nicht überein.", "Username": username})
|
||||
return
|
||||
}
|
||||
|
||||
if err := ui.authStore.Register(r.Context(), username, password); err != nil {
|
||||
msg := "Interner Fehler."
|
||||
if errors.Is(err, auth.ErrUsernameTaken) {
|
||||
msg = "Benutzername bereits vergeben."
|
||||
}
|
||||
render(w, "register.html", map[string]any{"Error": msg, "Username": username})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Public Feed ---
|
||||
|
||||
func (ui *WebUI) HandleFeed(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := ui.journalStore.ListPublic(r.Context(), 20, 0)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
render(w, "public.html", map[string]any{
|
||||
"Entries": entries,
|
||||
"Offset": 20,
|
||||
"HasMore": len(entries) == 20,
|
||||
})
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandleFeedFragment(w http.ResponseWriter, r *http.Request) {
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
entries, err := ui.journalStore.ListPublic(r.Context(), 20, offset)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderFragment(w, "public.html", "feed_items", map[string]any{
|
||||
"Entries": entries,
|
||||
"Offset": offset + 20,
|
||||
"HasMore": len(entries) == 20,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Days ---
|
||||
|
||||
func (ui *WebUI) HandleDaysRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
date := strings.TrimSpace(r.URL.Query().Get("date"))
|
||||
if date == "" {
|
||||
@@ -142,16 +251,16 @@ func (ui *WebUI) HandleDaysRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandleDaysList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
user := userFromContext(r.Context())
|
||||
now := time.Now().UTC()
|
||||
from := now.AddDate(-20, 0, 0).Format("2006-01-02")
|
||||
to := now.AddDate(0, 0, 1).Format("2006-01-02")
|
||||
days, err := ui.tpStore.ListDays(r.Context(), userID, from, to)
|
||||
days, err := ui.tpStore.ListDays(r.Context(), user.UserID, from, to)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
render(w, "days.html", map[string]any{"Days": days})
|
||||
render(w, "days.html", map[string]any{"Days": days, "IsAdmin": user.IsAdmin})
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandleDayDetail(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -186,3 +295,67 @@ func (ui *WebUI) HandleDayDetail(w http.ResponseWriter, r *http.Request) {
|
||||
"Entries": entries,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Admin ---
|
||||
|
||||
func (ui *WebUI) HandleAdminEntries(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r.Context())
|
||||
entries, err := ui.journalStore.ListByUser(r.Context(), user.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderAdmin(w, "entries.html", map[string]any{"Entries": entries, "User": user})
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r.Context())
|
||||
users, err := ui.userStore.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": ""})
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
user := userFromContext(r.Context())
|
||||
|
||||
if username == "" || password == "" {
|
||||
users, _ := ui.userStore.ListUsers(r.Context())
|
||||
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": "Benutzername und Passwort erforderlich."})
|
||||
return
|
||||
}
|
||||
|
||||
if err := ui.authStore.Register(r.Context(), username, password); err != nil {
|
||||
msg := "Interner Fehler."
|
||||
if errors.Is(err, auth.ErrUsernameTaken) {
|
||||
msg = "Benutzername bereits vergeben."
|
||||
}
|
||||
users, _ := ui.userStore.ListUsers(r.Context())
|
||||
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": msg})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
targetID := chi.URLParam(r, "id")
|
||||
currentUser := userFromContext(r.Context())
|
||||
if targetID == currentUser.UserID {
|
||||
http.Error(w, "Eigenen Account nicht löschbar", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := ui.userStore.DeleteUser(r.Context(), targetID); err != nil {
|
||||
http.Error(w, "Fehler beim Löschen", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user