Convert backend from submodule to regular directory
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:
Christoph K.
2026-04-07 16:59:50 +02:00
parent 0bb7758a2f
commit d0b0b4f8bd
35 changed files with 2271 additions and 8 deletions

View 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,
})
}
}

View 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
}

View 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)
}

View 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)
}
}

View 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})
}

View 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))
})
}
}

View 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)
})
}

View 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);
});
});

View 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; }

View 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}}

View 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">&#9678; 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> · &#9675; {{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" .}}

View 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" .}}

View 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" .}}

View File

@@ -0,0 +1 @@
<!-- webapp placeholder: replaced at build time by Vite dist -->

View 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,
})
}