Convert backend from submodule to regular directory
Some checks failed
Deploy to NAS / deploy (push) Failing after 4s
Some checks failed
Deploy to NAS / deploy (push) Failing after 4s
Remove submodule tracking; backend is now a plain directory in the repo. Also update deploy workflow: remove --recurse-submodules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
134
backend/internal/api/ingest.go
Normal file
134
backend/internal/api/ingest.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jacek/pamietnik/backend/internal/db"
|
||||
"github.com/jacek/pamietnik/backend/internal/domain"
|
||||
)
|
||||
|
||||
type trackpointInput struct {
|
||||
EventID string `json:"event_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
TripID string `json:"trip_id"`
|
||||
Timestamp string `json:"timestamp"` // RFC3339
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
AccuracyM *float64 `json:"accuracy_m,omitempty"`
|
||||
SpeedMps *float64 `json:"speed_mps,omitempty"`
|
||||
BearingDeg *float64 `json:"bearing_deg,omitempty"`
|
||||
AltitudeM *float64 `json:"altitude_m,omitempty"`
|
||||
}
|
||||
|
||||
func (t trackpointInput) toDomain() (domain.Trackpoint, error) {
|
||||
ts, err := time.Parse(time.RFC3339, t.Timestamp)
|
||||
if err != nil {
|
||||
return domain.Trackpoint{}, err
|
||||
}
|
||||
src := t.Source
|
||||
if src == "" {
|
||||
src = "gps"
|
||||
}
|
||||
return domain.Trackpoint{
|
||||
EventID: t.EventID,
|
||||
DeviceID: t.DeviceID,
|
||||
TripID: t.TripID,
|
||||
Timestamp: ts,
|
||||
Lat: t.Lat,
|
||||
Lon: t.Lon,
|
||||
Source: src,
|
||||
Note: t.Note,
|
||||
AccuracyM: t.AccuracyM,
|
||||
SpeedMps: t.SpeedMps,
|
||||
BearingDeg: t.BearingDeg,
|
||||
AltitudeM: t.AltitudeM,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type batchResponse struct {
|
||||
ServerTime string `json:"server_time"`
|
||||
AcceptedIDs []string `json:"accepted_ids"`
|
||||
Rejected []db.RejectedItem `json:"rejected"`
|
||||
}
|
||||
|
||||
// HandleSingleTrackpoint handles POST /v1/trackpoints
|
||||
func HandleSingleTrackpoint(store *db.TrackpointStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var input trackpointInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
point, err := input.toDomain()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid timestamp: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
userID := userIDFromContext(r.Context())
|
||||
accepted, rejected, err := store.UpsertBatch(r.Context(), userID, []domain.Trackpoint{point})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, batchResponse{
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||||
AcceptedIDs: accepted,
|
||||
Rejected: rejected,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleBatchTrackpoints handles POST /v1/trackpoints:batch
|
||||
func HandleBatchTrackpoints(store *db.TrackpointStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var inputs []trackpointInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&inputs); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
|
||||
return
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "empty batch")
|
||||
return
|
||||
}
|
||||
if len(inputs) > 500 {
|
||||
writeError(w, http.StatusBadRequest, "TOO_LARGE", "batch exceeds 500 items")
|
||||
return
|
||||
}
|
||||
|
||||
points := make([]domain.Trackpoint, 0, len(inputs))
|
||||
var parseRejected []db.RejectedItem
|
||||
for _, inp := range inputs {
|
||||
p, err := inp.toDomain()
|
||||
if err != nil {
|
||||
parseRejected = append(parseRejected, db.RejectedItem{
|
||||
EventID: inp.EventID,
|
||||
Code: "INVALID_TIMESTAMP",
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
points = append(points, p)
|
||||
}
|
||||
|
||||
userID := userIDFromContext(r.Context())
|
||||
accepted, rejected, err := store.UpsertBatch(r.Context(), userID, points)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
|
||||
return
|
||||
}
|
||||
rejected = append(rejected, parseRejected...)
|
||||
|
||||
writeJSON(w, http.StatusOK, batchResponse{
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||||
AcceptedIDs: accepted,
|
||||
Rejected: rejected,
|
||||
})
|
||||
}
|
||||
}
|
||||
164
backend/internal/api/journal.go
Normal file
164
backend/internal/api/journal.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jacek/pamietnik/backend/internal/db"
|
||||
"github.com/jacek/pamietnik/backend/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
maxUploadSize = 32 << 20 // 32 MB per request
|
||||
maxSingleImage = 10 << 20 // 10 MB per image
|
||||
)
|
||||
|
||||
var allowedMIME = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/heic": ".heic",
|
||||
}
|
||||
|
||||
type JournalHandler struct {
|
||||
store *db.JournalStore
|
||||
uploadDir string
|
||||
}
|
||||
|
||||
func NewJournalHandler(store *db.JournalStore, uploadDir string) *JournalHandler {
|
||||
return &JournalHandler{store: store, uploadDir: uploadDir}
|
||||
}
|
||||
|
||||
// HandleCreateEntry handles POST /entries (multipart/form-data).
|
||||
func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
http.Error(w, "Formular zu groß", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
userID := userIDFromContext(r.Context())
|
||||
date := strings.TrimSpace(r.FormValue("date"))
|
||||
entryTime := strings.TrimSpace(r.FormValue("time"))
|
||||
title := strings.TrimSpace(r.FormValue("title"))
|
||||
description := strings.TrimSpace(r.FormValue("description"))
|
||||
|
||||
if date == "" || entryTime == "" {
|
||||
http.Error(w, "Datum und Uhrzeit sind Pflichtfelder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
entry := domain.JournalEntry{
|
||||
UserID: userID,
|
||||
EntryDate: date,
|
||||
EntryTime: entryTime,
|
||||
Title: title,
|
||||
Description: description,
|
||||
}
|
||||
|
||||
if lat := r.FormValue("lat"); lat != "" {
|
||||
var v float64
|
||||
if _, err := fmt.Sscanf(lat, "%f", &v); err == nil {
|
||||
entry.Lat = &v
|
||||
}
|
||||
}
|
||||
if lon := r.FormValue("lon"); lon != "" {
|
||||
var v float64
|
||||
if _, err := fmt.Sscanf(lon, "%f", &v); err == nil {
|
||||
entry.Lon = &v
|
||||
}
|
||||
}
|
||||
|
||||
saved, err := h.store.InsertEntry(r.Context(), entry)
|
||||
if err != nil {
|
||||
http.Error(w, "Datenbankfehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle image uploads
|
||||
if r.MultipartForm != nil && r.MultipartForm.File != nil {
|
||||
files := r.MultipartForm.File["images"]
|
||||
for _, fh := range files {
|
||||
if fh.Size > maxSingleImage {
|
||||
continue // skip oversized images silently
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect MIME type from first 512 bytes
|
||||
buf := make([]byte, 512)
|
||||
n, _ := f.Read(buf)
|
||||
mime := http.DetectContentType(buf[:n])
|
||||
ext, ok := allowedMIME[mime]
|
||||
if !ok {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
filename := saved.EntryID + "_" + fh.Filename
|
||||
filename = sanitizeFilename(filename) + ext
|
||||
destPath := filepath.Join(h.uploadDir, filename)
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
// Write already-read bytes + rest; clean up file on any write error
|
||||
if _, err := out.Write(buf[:n]); err != nil {
|
||||
out.Close()
|
||||
f.Close()
|
||||
os.Remove(destPath)
|
||||
continue
|
||||
}
|
||||
if _, err := io.Copy(out, f); err != nil {
|
||||
out.Close()
|
||||
f.Close()
|
||||
os.Remove(destPath)
|
||||
continue
|
||||
}
|
||||
out.Close()
|
||||
f.Close()
|
||||
|
||||
img := domain.JournalImage{
|
||||
EntryID: saved.EntryID,
|
||||
Filename: filename,
|
||||
OriginalName: fh.Filename,
|
||||
MimeType: mime,
|
||||
SizeBytes: fh.Size,
|
||||
}
|
||||
if _, err := h.store.InsertImage(r.Context(), img); err != nil {
|
||||
slog.Error("insert image", "entry_id", saved.EntryID, "filename", filename, "err", err)
|
||||
os.Remove(destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/days/"+date, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// sanitizeFilename strips path separators and non-printable characters.
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(name)
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
|
||||
b.WriteRune('_')
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
// strip extension — we append the detected one
|
||||
if idx := strings.LastIndex(s, "."); idx > 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
return s
|
||||
}
|
||||
43
backend/internal/api/middleware.go
Normal file
43
backend/internal/api/middleware.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/jacek/pamietnik/backend/internal/auth"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const ctxUserID contextKey = "user_id"
|
||||
|
||||
const sessionCookieName = "session"
|
||||
|
||||
// RequireAuth is a middleware that validates the session cookie.
|
||||
func RequireAuth(authStore *auth.Store) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "login required")
|
||||
return
|
||||
}
|
||||
sess, err := authStore.GetSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxUserID, sess.UserID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func userIDFromContext(ctx context.Context) string {
|
||||
v, _ := ctx.Value(ctxUserID).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func contextWithUserID(ctx context.Context, userID string) context.Context {
|
||||
return context.WithValue(ctx, ctxUserID, userID)
|
||||
}
|
||||
102
backend/internal/api/query.go
Normal file
102
backend/internal/api/query.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/jacek/pamietnik/backend/internal/db"
|
||||
"github.com/jacek/pamietnik/backend/internal/domain"
|
||||
)
|
||||
|
||||
// HandleListDays handles GET /v1/days?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
func HandleListDays(store *db.TrackpointStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
from := r.URL.Query().Get("from")
|
||||
to := r.URL.Query().Get("to")
|
||||
if from == "" || to == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "from and to are required (YYYY-MM-DD)")
|
||||
return
|
||||
}
|
||||
|
||||
days, err := store.ListDays(r.Context(), userID, from, to)
|
||||
if err != nil {
|
||||
slog.Error("list days", "user_id", userID, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
|
||||
return
|
||||
}
|
||||
if days == nil {
|
||||
days = []domain.DaySummary{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, days)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListTrackpoints handles GET /v1/trackpoints?date=YYYY-MM-DD
|
||||
func HandleListTrackpoints(store *db.TrackpointStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
date := r.URL.Query().Get("date")
|
||||
if date == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "date is required (YYYY-MM-DD)")
|
||||
return
|
||||
}
|
||||
|
||||
points, err := store.ListByDate(r.Context(), userID, date)
|
||||
if err != nil {
|
||||
slog.Error("list trackpoints", "user_id", userID, "date", date, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
|
||||
return
|
||||
}
|
||||
if points == nil {
|
||||
points = []domain.Trackpoint{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, points)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListStops handles GET /v1/stops?date=YYYY-MM-DD
|
||||
func HandleListStops(store *db.StopStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
date := r.URL.Query().Get("date")
|
||||
if date == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "date is required (YYYY-MM-DD)")
|
||||
return
|
||||
}
|
||||
|
||||
stops, err := store.ListByDate(r.Context(), userID, date)
|
||||
if err != nil {
|
||||
slog.Error("list stops", "user_id", userID, "date", date, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
|
||||
return
|
||||
}
|
||||
if stops == nil {
|
||||
stops = []domain.Stop{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, stops)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListSuggestions handles GET /v1/suggestions?date=YYYY-MM-DD
|
||||
func HandleListSuggestions(store *db.SuggestionStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
date := r.URL.Query().Get("date")
|
||||
if date == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "date is required (YYYY-MM-DD)")
|
||||
return
|
||||
}
|
||||
|
||||
suggestions, err := store.ListByDate(r.Context(), userID, date)
|
||||
if err != nil {
|
||||
slog.Error("list suggestions", "user_id", userID, "date", date, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
|
||||
return
|
||||
}
|
||||
if suggestions == nil {
|
||||
suggestions = []domain.Suggestion{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, suggestions)
|
||||
}
|
||||
}
|
||||
21
backend/internal/api/response.go
Normal file
21
backend/internal/api/response.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
writeJSON(w, status, errorResponse{Code: code, Message: message})
|
||||
}
|
||||
106
backend/internal/api/router.go
Normal file
106
backend/internal/api/router.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/jacek/pamietnik/backend/internal/auth"
|
||||
"github.com/jacek/pamietnik/backend/internal/db"
|
||||
)
|
||||
|
||||
func NewRouter(
|
||||
authStore *auth.Store,
|
||||
tpStore *db.TrackpointStore,
|
||||
stopStore *db.StopStore,
|
||||
suggStore *db.SuggestionStore,
|
||||
journalStore *db.JournalStore,
|
||||
uploadDir string,
|
||||
) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
webUI := NewWebUI(authStore, tpStore, stopStore, journalStore)
|
||||
journalHandler := NewJournalHandler(journalStore, uploadDir)
|
||||
authMW := RequireAuth(authStore)
|
||||
webAuthMW := requireWebAuth(authStore)
|
||||
|
||||
// Health
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
r.Get("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// Ingest (session auth; Android API-Key auth TBD)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMW)
|
||||
r.Post("/v1/trackpoints", HandleSingleTrackpoint(tpStore))
|
||||
r.Post("/v1/trackpoints:batch", HandleBatchTrackpoints(tpStore))
|
||||
})
|
||||
|
||||
// Query API (session auth)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMW)
|
||||
r.Get("/v1/days", HandleListDays(tpStore))
|
||||
r.Get("/v1/trackpoints", HandleListTrackpoints(tpStore))
|
||||
r.Get("/v1/stops", HandleListStops(stopStore))
|
||||
r.Get("/v1/suggestions", HandleListSuggestions(suggStore))
|
||||
})
|
||||
|
||||
// Static assets (CSS etc.)
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS()))))
|
||||
|
||||
// Web UI
|
||||
r.Get("/login", webUI.HandleGetLogin)
|
||||
r.Post("/login", webUI.HandlePostLogin)
|
||||
r.Post("/logout", webUI.HandleLogout)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(webAuthMW)
|
||||
r.Get("/days", webUI.HandleDaysList)
|
||||
r.Get("/days/redirect", webUI.HandleDaysRedirect)
|
||||
r.Get("/days/{date}", webUI.HandleDayDetail)
|
||||
r.Post("/entries", journalHandler.HandleCreateEntry)
|
||||
})
|
||||
|
||||
// Serve uploaded images
|
||||
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir))))
|
||||
|
||||
// SPA (Vite webapp) — served under /app/*
|
||||
spaPrefix := "/app"
|
||||
r.Handle(spaPrefix, http.RedirectHandler(spaPrefix+"/", http.StatusMovedPermanently))
|
||||
r.Handle(spaPrefix+"/*", http.StripPrefix(spaPrefix, SPAHandler(spaPrefix)))
|
||||
|
||||
// Redirect root to Go Web UI /days
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/days", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// requireWebAuth redirects to /login for unauthenticated web users (HTML response).
|
||||
func requireWebAuth(authStore *auth.Store) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
sess, err := authStore.GetSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
ctx = contextWithUserID(ctx, sess.UserID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
53
backend/internal/api/spa.go
Normal file
53
backend/internal/api/spa.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// spaFS holds the built Vite SPA.
|
||||
// The directory backend/internal/api/webapp/ is populated by the Docker
|
||||
// multi-stage build (node → copy dist → go build).
|
||||
// A placeholder file keeps the embed valid when building without Docker.
|
||||
|
||||
//go:embed webapp
|
||||
var spaFS embed.FS
|
||||
|
||||
// SPAHandler serves the Vite SPA under the given prefix (e.g. "/app").
|
||||
// Static assets (paths with file extensions) are served directly.
|
||||
// All other paths fall back to index.html for client-side routing.
|
||||
func SPAHandler(prefix string) http.Handler {
|
||||
sub, err := fs.Sub(spaFS, "webapp")
|
||||
if err != nil {
|
||||
return http.NotFoundHandler()
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(sub))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Strip the mount prefix to get the file path
|
||||
path := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if path == "" || path == "/" {
|
||||
// Serve index.html
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = "/index.html"
|
||||
fileServer.ServeHTTP(w, r2)
|
||||
return
|
||||
}
|
||||
|
||||
// Has a file extension → serve asset directly (JS, CSS, fonts, …)
|
||||
if filepath.Ext(path) != "" {
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = path
|
||||
fileServer.ServeHTTP(w, r2)
|
||||
return
|
||||
}
|
||||
|
||||
// SPA route → serve index.html
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = "/index.html"
|
||||
fileServer.ServeHTTP(w, r2)
|
||||
})
|
||||
}
|
||||
47
backend/internal/api/static/day.js
Normal file
47
backend/internal/api/static/day.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// GPS button
|
||||
document.getElementById('btn-gps')?.addEventListener('click', function () {
|
||||
const status = document.getElementById('gps-status');
|
||||
if (!navigator.geolocation) {
|
||||
status.textContent = '// GPS nicht verfügbar';
|
||||
return;
|
||||
}
|
||||
status.textContent = '// Standort wird ermittelt...';
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function (pos) {
|
||||
document.getElementById('entry-lat').value = pos.coords.latitude.toFixed(6);
|
||||
document.getElementById('entry-lon').value = pos.coords.longitude.toFixed(6);
|
||||
status.textContent = '// Standort gesetzt (' + pos.coords.accuracy.toFixed(0) + ' m Genauigkeit)';
|
||||
},
|
||||
function (err) {
|
||||
status.textContent = '// Fehler: ' + err.message;
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
});
|
||||
|
||||
// Set current time as default
|
||||
(function () {
|
||||
const input = document.getElementById('entry-time');
|
||||
if (input && !input.value) {
|
||||
const now = new Date();
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
input.value = hh + ':' + mm;
|
||||
}
|
||||
})();
|
||||
|
||||
// Image preview
|
||||
document.getElementById('image-input')?.addEventListener('change', function () {
|
||||
const preview = document.getElementById('image-preview');
|
||||
preview.innerHTML = '';
|
||||
Array.from(this.files).forEach(function (file) {
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
const img = document.createElement('img');
|
||||
img.src = e.target.result;
|
||||
preview.appendChild(img);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
});
|
||||
54
backend/internal/api/static/style.css
Normal file
54
backend/internal/api/static/style.css
Normal file
@@ -0,0 +1,54 @@
|
||||
/* Font + monochrome override */
|
||||
:root {
|
||||
--pico-font-family: 'Courier New', Courier, monospace;
|
||||
--pico-font-size: 14px;
|
||||
--pico-primary: #111;
|
||||
--pico-primary-background: #111;
|
||||
--pico-primary-border: #111;
|
||||
--pico-primary-hover: #333;
|
||||
--pico-primary-hover-background: #333;
|
||||
--pico-primary-hover-border: #333;
|
||||
--pico-primary-focus: rgba(0,0,0,.25);
|
||||
--pico-primary-inverse: #fff;
|
||||
--pico-primary-underline: rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
h1 { font-size: 1.4rem; font-weight: normal; letter-spacing: .05em; }
|
||||
h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
|
||||
|
||||
.err { color: #c00; }
|
||||
.source-gps { color: #060; }
|
||||
.source-manual { color: #888; }
|
||||
|
||||
/* Top bar */
|
||||
.page-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.5rem; }
|
||||
|
||||
/* GPS row */
|
||||
.gps-row { display: flex; gap: .4rem; align-items: center; }
|
||||
.gps-row input { flex: 1; margin-bottom: 0; }
|
||||
.gps-row button { white-space: nowrap; margin-bottom: 0; }
|
||||
|
||||
/* Two-column form */
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||||
@media (max-width: 480px) { .form-row { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Image preview */
|
||||
.image-preview { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: .8rem; }
|
||||
.image-preview img, .thumb { width: 80px; height: 80px; object-fit: cover; border: 1px solid var(--pico-muted-border-color); }
|
||||
.thumb { width: 100px; height: 100px; display: block; }
|
||||
|
||||
/* Journal entry cards */
|
||||
.entry-card {
|
||||
border-left: 3px solid var(--pico-primary);
|
||||
padding: .6rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--pico-card-background-color);
|
||||
border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0;
|
||||
}
|
||||
.entry-meta { font-size: .8rem; margin-bottom: .3rem; }
|
||||
.entry-title { font-size: 1rem; margin-bottom: .3rem; }
|
||||
.entry-desc { white-space: pre-wrap; font-size: .9rem; }
|
||||
.entry-images { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
|
||||
|
||||
/* Login */
|
||||
.login-box { max-width: 360px; margin: 4rem auto; }
|
||||
15
backend/internal/api/templates/base.html
Normal file
15
backend/internal/api/templates/base.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{{define "base"}}<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{block "title" .}}Reisejournal{{end}}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.slate.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{{block "content" .}}{{end}}
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
104
backend/internal/api/templates/day.html
Normal file
104
backend/internal/api/templates/day.html
Normal file
@@ -0,0 +1,104 @@
|
||||
{{define "title"}}{{.Date}} — Reisejournal{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main class="container">
|
||||
<nav><a href="/days">← Alle Tage</a></nav>
|
||||
<h1>{{.Date}}</h1>
|
||||
|
||||
<h2>Neuer Eintrag</h2>
|
||||
<form method="post" action="/entries" enctype="multipart/form-data" class="entry-form">
|
||||
<input type="hidden" name="date" value="{{.Date}}">
|
||||
<div class="form-row">
|
||||
<div class="form-col">
|
||||
<label>Uhrzeit</label>
|
||||
<input type="time" name="time" required id="entry-time">
|
||||
</div>
|
||||
<div class="form-col">
|
||||
<label>GPS-Koordinaten <small>(optional)</small></label>
|
||||
<div class="gps-row">
|
||||
<input type="number" name="lat" id="entry-lat" step="any" placeholder="Breite">
|
||||
<input type="number" name="lon" id="entry-lon" step="any" placeholder="Länge">
|
||||
<button type="button" id="btn-gps" title="Aktuellen Standort ermitteln">◎ GPS</button>
|
||||
</div>
|
||||
<small id="gps-status"></small>
|
||||
</div>
|
||||
</div>
|
||||
<label>Überschrift</label>
|
||||
<input type="text" name="title" placeholder="Titel des Eintrags">
|
||||
<label>Beschreibung</label>
|
||||
<textarea name="description" rows="4" placeholder="Was ist passiert?"></textarea>
|
||||
<label>Bilder <small>(optional, max. 10 MB pro Bild)</small></label>
|
||||
<input type="file" name="images" multiple accept="image/*" id="image-input">
|
||||
<div id="image-preview" class="image-preview"></div>
|
||||
<button type="submit">Eintrag speichern</button>
|
||||
</form>
|
||||
|
||||
<h2>Einträge <small>({{len .Entries}})</small></h2>
|
||||
{{range .Entries}}
|
||||
<div class="entry-card">
|
||||
<div class="entry-meta">
|
||||
<strong>{{.EntryTime}}</strong>
|
||||
{{if .Lat}}<small> · ○ {{printf "%.5f" (deref .Lat)}}, {{printf "%.5f" (deref .Lon)}}</small>{{end}}
|
||||
</div>
|
||||
{{if .Title}}<div class="entry-title">{{.Title}}</div>{{end}}
|
||||
{{if .Description}}<div class="entry-desc">{{.Description}}</div>{{end}}
|
||||
{{if .Images}}
|
||||
<div class="entry-images">
|
||||
{{range .Images}}
|
||||
<a href="/uploads/{{.Filename}}" target="_blank">
|
||||
<img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb">
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p><small>// Noch keine Einträge</small></p>
|
||||
{{end}}
|
||||
|
||||
<h2>Trackpunkte <small>({{len .Points}})</small></h2>
|
||||
<figure>
|
||||
<table>
|
||||
<thead><tr><th>Zeit</th><th>Lat</th><th>Lon</th><th>Quelle</th><th>Notiz</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Points}}
|
||||
<tr>
|
||||
<td>{{.Timestamp.Format "15:04:05"}}</td>
|
||||
<td>{{printf "%.5f" .Lat}}</td>
|
||||
<td>{{printf "%.5f" .Lon}}</td>
|
||||
<td class="source-{{.Source}}">{{.Source}}</td>
|
||||
<td><small>{{.Note}}</small></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5"><small>// Keine Punkte</small></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
|
||||
<h2>Aufenthalte <small>({{len .Stops}})</small></h2>
|
||||
<figure>
|
||||
<table>
|
||||
<thead><tr><th>Von</th><th>Bis</th><th>Dauer</th><th>Ort</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Stops}}
|
||||
<tr>
|
||||
<td>{{.StartTS.Format "15:04"}}</td>
|
||||
<td>{{.EndTS.Format "15:04"}}</td>
|
||||
<td><small>{{divInt .DurationS 60}} min</small></td>
|
||||
<td>{{if .PlaceLabel}}{{.PlaceLabel}}{{else}}<small>—</small>{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="4"><small>// Keine Aufenthalte</small></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
</main>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/day.js"></script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
46
backend/internal/api/templates/days.html
Normal file
46
backend/internal/api/templates/days.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{{define "title"}}Tage — Reisejournal{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main class="container">
|
||||
<div class="page-header">
|
||||
<h1>REISEJOURNAL</h1>
|
||||
<a href="/logout">[ Ausloggen ]</a>
|
||||
</div>
|
||||
<form method="get" action="/days/redirect">
|
||||
<fieldset role="group">
|
||||
<input type="date" name="date" id="nav-date" required>
|
||||
<button type="submit">Tag öffnen</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<h2>Reisetage</h2>
|
||||
<figure>
|
||||
<table>
|
||||
<thead><tr><th>Datum</th><th>Punkte</th><th>Von</th><th>Bis</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Days}}
|
||||
<tr>
|
||||
<td><a href="/days/{{.Date}}">{{.Date}}</a></td>
|
||||
<td>{{.Count}}</td>
|
||||
<td><small>{{if .FirstTS}}{{.FirstTS.Format "15:04"}}{{end}}</small></td>
|
||||
<td><small>{{if .LastTS}}{{.LastTS.Format "15:04"}}{{end}}</small></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="4"><small>// Keine Daten vorhanden</small></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
</main>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
var d = document.getElementById('nav-date');
|
||||
if (d && !d.value) {
|
||||
d.value = new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
21
backend/internal/api/templates/login.html
Normal file
21
backend/internal/api/templates/login.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{{define "title"}}Login — Reisejournal{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main class="container">
|
||||
<article class="login-box">
|
||||
<h1>REISEJOURNAL</h1>
|
||||
{{if .Error}}<p class="err">// {{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label>Benutzername
|
||||
<input name="username" autocomplete="username" value="{{.Username}}">
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input type="password" name="password" autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit">Einloggen</button>
|
||||
</form>
|
||||
</article>
|
||||
</main>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
1
backend/internal/api/webapp/index.html
Normal file
1
backend/internal/api/webapp/index.html
Normal file
@@ -0,0 +1 @@
|
||||
<!-- webapp placeholder: replaced at build time by Vite dist -->
|
||||
188
backend/internal/api/webui.go
Normal file
188
backend/internal/api/webui.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jacek/pamietnik/backend/internal/auth"
|
||||
"github.com/jacek/pamietnik/backend/internal/db"
|
||||
)
|
||||
|
||||
//go:embed static templates
|
||||
var assets embed.FS
|
||||
|
||||
var funcMap = template.FuncMap{
|
||||
"divInt": func(a, b int) int { return a / b },
|
||||
"deref": func(p *float64) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
},
|
||||
}
|
||||
|
||||
var tmpls = template.Must(
|
||||
template.New("").Funcs(funcMap).ParseFS(assets, "templates/*.html"),
|
||||
)
|
||||
|
||||
func staticFS() fs.FS {
|
||||
sub, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// WebUI groups all web UI handlers.
|
||||
type WebUI struct {
|
||||
authStore *auth.Store
|
||||
tpStore *db.TrackpointStore
|
||||
stopStore *db.StopStore
|
||||
journalStore *db.JournalStore
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("template parse", "page", page, "err", err)
|
||||
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)
|
||||
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = buf.WriteTo(w)
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandleGetLogin(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, "login.html", map[string]any{"Error": "", "Username": ""})
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandlePostLogin(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")
|
||||
|
||||
sess, err := ui.authStore.Login(r.Context(), username, password)
|
||||
if err != nil {
|
||||
msg := "Interner Fehler."
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
msg = "Ungültige Zugangsdaten."
|
||||
}
|
||||
render(w, "login.html", map[string]any{"Error": msg, "Username": username})
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: sess.SessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: sess.ExpiresAt,
|
||||
})
|
||||
http.Redirect(w, r, "/days", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err == nil {
|
||||
ui.authStore.Logout(r.Context(), cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandleDaysRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
date := strings.TrimSpace(r.URL.Query().Get("date"))
|
||||
if date == "" {
|
||||
http.Redirect(w, r, "/days", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", date); err != nil {
|
||||
http.Redirect(w, r, "/days", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/days/"+date, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandleDaysList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(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)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
render(w, "days.html", map[string]any{"Days": days})
|
||||
}
|
||||
|
||||
func (ui *WebUI) HandleDayDetail(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
|
||||
date := chi.URLParam(r, "date")
|
||||
if date == "" {
|
||||
http.Error(w, "Datum fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
points, err := ui.tpStore.ListByDate(r.Context(), userID, date)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
stops, err := ui.stopStore.ListByDate(r.Context(), userID, date)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
entries, err := ui.journalStore.ListByDate(r.Context(), userID, date)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
render(w, "day.html", map[string]any{
|
||||
"Date": date,
|
||||
"Points": points,
|
||||
"Stops": stops,
|
||||
"Entries": entries,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user