diff --git a/CLAUDE.md b/CLAUDE.md
index b6cbca9..f537b44 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -62,8 +62,23 @@ internal/
ingest.go POST /v1/trackpoints, POST /v1/trackpoints:batch
query.go GET /v1/days, /v1/trackpoints, /v1/stops, /v1/suggestions
webui.go server-side rendered web UI (login, /days, /days/{date})
- journal.go journal entry endpoints
+ journal.go POST /entries, GET/POST /entries/{id}, GET /entries/{id}/edit
+ media.go POST /media — single-file upload, returns markdown reference
response.go shared response helpers
+ api/static/
+ style.css global styles (Pico CSS overrides)
+ day.js GPS button, time auto-fill
+ editor.js textarea drag-drop/paste upload → markdown ref insert
+ autoplay.js IntersectionObserver: videos autoplay when visible
+ api/templates/
+ base.html layout + global nav (LoggedIn, IsAdmin injected by render())
+ days.html day list + date picker
+ day.html day detail: new entry form, entries, stops, trackpoints
+ edit_entry.html edit existing entry
+ public.html public feed (infinite scroll)
+ login.html login form
+ register.html self-registration
+ admin/ admin layout + entries/users pages
```
Key invariants:
diff --git a/Dockerfile b/Dockerfile
index eec0322..f08955f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,12 +6,23 @@ RUN npm ci
COPY webapp/ ./
RUN npm run build
-# Stage 2: Build Go server
+# Stage 2: Compile static TypeScript
+FROM node:22-alpine AS static-ts-builder
+WORKDIR /build/backend
+COPY backend/static-ts/ ./static-ts/
+RUN mkdir -p ./internal/api/static \
+ && cd ./static-ts \
+ && npm ci \
+ && npm run build
+
+# Stage 3: Build Go server
FROM golang:1.25-alpine AS go-builder
WORKDIR /app
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
+# Inject compiled static JS
+COPY --from=static-ts-builder /build/backend/internal/api/static/ ./internal/api/static/
RUN go test ./...
# Inject built SPA into embed path
COPY --from=webapp-builder /webapp/dist ./internal/api/webapp/
diff --git a/backend/go.mod b/backend/go.mod
index c77476b..6d14615 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -10,11 +10,14 @@ require (
)
require (
+ github.com/disintegration/imaging v1.6.2 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
- golang.org/x/sync v0.19.0 // indirect
+ github.com/yuin/goldmark v1.8.2 // indirect
+ golang.org/x/image v0.39.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
- golang.org/x/text v0.34.0 // indirect
+ golang.org/x/text v0.36.0 // indirect
)
diff --git a/backend/go.sum b/backend/go.sum
index e279519..7044341 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
@@ -63,6 +65,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
@@ -75,12 +79,21 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
+golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/backend/internal/api/journal.go b/backend/internal/api/journal.go
index e46a137..170abfc 100644
--- a/backend/internal/api/journal.go
+++ b/backend/internal/api/journal.go
@@ -2,8 +2,8 @@ package api
import (
"fmt"
- "io"
"log/slog"
+ "mime/multipart"
"net/http"
"os"
"path/filepath"
@@ -20,10 +20,17 @@ const (
)
var allowedMIME = map[string]string{
- "image/jpeg": ".jpg",
- "image/png": ".png",
- "image/webp": ".webp",
- "image/heic": ".heic",
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/webp": ".webp",
+ "image/heic": ".heic",
+ "image/gif": ".gif",
+ "video/mp4": ".mp4",
+ "video/webm": ".webm",
+ "audio/mpeg": ".mp3",
+ "audio/ogg": ".ogg",
+ "audio/wav": ".wav",
+ "audio/aac": ".aac",
}
type JournalHandler struct {
@@ -108,51 +115,8 @@ func (h *JournalHandler) HandleUpdateEntry(w http.ResponseWriter, r *http.Reques
return
}
- // Handle new image uploads
- if r.MultipartForm != nil && r.MultipartForm.File != nil {
- files := r.MultipartForm.File["images"]
- for _, fh := range files {
- if fh.Size == 0 || fh.Size > maxSingleImage {
- continue
- }
- f, err := fh.Open()
- if err != nil {
- continue
- }
- buf := make([]byte, 512)
- n, _ := f.Read(buf)
- mime := http.DetectContentType(buf[:n])
- ext, ok := allowedMIME[mime]
- if !ok {
- f.Close()
- continue
- }
- filename := sanitizeFilename(entryID+"_"+fh.Filename) + ext
- destPath := filepath.Join(h.uploadDir, filename)
- out, err := os.Create(destPath)
- if err != nil {
- f.Close()
- continue
- }
- 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: 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", entryID, "err", err)
- os.Remove(destPath)
- }
- }
+ if r.MultipartForm != nil {
+ h.saveJournalImages(r, entryID, r.MultipartForm.File["images"])
}
http.Redirect(w, r, "/days/"+existing.EntryDate, http.StatusSeeOther)
@@ -219,71 +183,56 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques
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)
- }
- }
+ if r.MultipartForm != nil {
+ h.saveJournalImages(r, saved.EntryID, r.MultipartForm.File["images"])
}
http.Redirect(w, r, "/days/"+date, http.StatusSeeOther)
}
+// saveJournalImages saves uploaded files for a journal entry, with image resizing.
+// Errors per file are logged and silently skipped so the entry is always saved.
+func (h *JournalHandler) saveJournalImages(r *http.Request, entryID string, files []*multipart.FileHeader) {
+ ctx := r.Context()
+ for _, fh := range files {
+ if fh.Size == 0 || fh.Size > maxSingleImage {
+ continue
+ }
+ f, err := fh.Open()
+ if err != nil {
+ continue
+ }
+
+ buf := make([]byte, 512)
+ n, _ := f.Read(buf)
+ mime := http.DetectContentType(buf[:n])
+ if _, ok := allowedMIME[mime]; !ok {
+ f.Close()
+ continue
+ }
+
+ baseName := sanitizeFilename(entryID + "_" + fh.Filename)
+ filename, err := saveUpload(h.uploadDir, baseName, mime, buf[:n], f)
+ f.Close()
+ if err != nil {
+ slog.Error("save upload", "entry_id", entryID, "err", err)
+ continue
+ }
+
+ img := domain.JournalImage{
+ EntryID: entryID,
+ Filename: filename,
+ OriginalName: fh.Filename,
+ MimeType: mime,
+ SizeBytes: fh.Size,
+ }
+ if _, err := h.store.InsertImage(ctx, img); err != nil {
+ slog.Error("insert image", "entry_id", entryID, "err", err)
+ os.Remove(filepath.Join(h.uploadDir, filename))
+ }
+ }
+}
+
// sanitizeFilename strips path separators and non-printable characters.
func sanitizeFilename(name string) string {
name = filepath.Base(name)
diff --git a/backend/internal/api/media.go b/backend/internal/api/media.go
new file mode 100644
index 0000000..46672e5
--- /dev/null
+++ b/backend/internal/api/media.go
@@ -0,0 +1,76 @@
+package api
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "strings"
+)
+
+type MediaHandler struct {
+ uploadDir string
+}
+
+func NewMediaHandler(uploadDir string) *MediaHandler {
+ return &MediaHandler{uploadDir: uploadDir}
+}
+
+// HandleUpload handles POST /media — uploads a single file and returns its markdown reference.
+func (h *MediaHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(maxUploadSize); err != nil {
+ http.Error(w, "too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ fh, _, err := r.FormFile("file")
+ if err != nil {
+ http.Error(w, "missing file", http.StatusBadRequest)
+ return
+ }
+ defer fh.Close()
+
+ buf := make([]byte, 512)
+ n, _ := fh.Read(buf)
+ mime := http.DetectContentType(buf[:n])
+ if _, ok := allowedMIME[mime]; !ok {
+ http.Error(w, "unsupported type", http.StatusUnsupportedMediaType)
+ return
+ }
+
+ filename, err := saveUpload(h.uploadDir, randomID(), mime, buf[:n], fh)
+ if err != nil {
+ http.Error(w, "storage error", http.StatusInternalServerError)
+ return
+ }
+
+ ref := markdownRef(mime, filename)
+ slog.Info("media uploaded", "filename", filename, "mime", mime)
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{
+ "filename": filename,
+ "mime": mime,
+ "ref": ref,
+ })
+}
+
+func markdownRef(mime, filename string) string {
+ url := "/uploads/" + filename
+ switch {
+ case strings.HasPrefix(mime, "image/"):
+ return ""
+ case strings.HasPrefix(mime, "video/"):
+ return ""
+ case strings.HasPrefix(mime, "audio/"):
+ return "[" + filename + "](" + url + ")"
+ default:
+ return "[" + filename + "](" + url + ")"
+ }
+}
+
+func randomID() string {
+ b := make([]byte, 12)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
+}
diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go
index 54c59a4..ce25bbb 100644
--- a/backend/internal/api/router.go
+++ b/backend/internal/api/router.go
@@ -26,6 +26,7 @@ func NewRouter(
webUI := NewWebUI(authStore, tpStore, stopStore, journalStore, userStore)
journalHandler := NewJournalHandler(journalStore, uploadDir)
+ mediaHandler := NewMediaHandler(uploadDir)
authMW := RequireAuth(authStore)
// Health
@@ -70,6 +71,7 @@ func NewRouter(
r.Get("/days", webUI.HandleDaysList)
r.Get("/days/redirect", webUI.HandleDaysRedirect)
r.Get("/days/{date}", webUI.HandleDayDetail)
+ r.Post("/media", mediaHandler.HandleUpload)
r.Post("/entries", journalHandler.HandleCreateEntry)
r.Get("/entries/{id}/edit", journalHandler.HandleGetEditEntry)
r.Post("/entries/{id}", journalHandler.HandleUpdateEntry)
diff --git a/backend/internal/api/static/autoplay.js b/backend/internal/api/static/autoplay.js
new file mode 100644
index 0000000..2256d68
--- /dev/null
+++ b/backend/internal/api/static/autoplay.js
@@ -0,0 +1,103 @@
+"use strict";
+(function () {
+ 'use strict';
+ /* ── Background player ───────────────────────────────────── */
+ const bgAudio = new Audio();
+ let bgPlaying = false;
+ let bgBar = null;
+ let bgTitle = null;
+ let bgPlayBtn = null;
+ function createBgBar() {
+ var _a;
+ if (bgBar)
+ return;
+ bgBar = document.createElement('div');
+ bgBar.id = 'bg-bar';
+ bgBar.innerHTML =
+ '' +
+ '' +
+ '';
+ document.body.appendChild(bgBar);
+ bgTitle = document.getElementById('bg-title');
+ bgPlayBtn = document.getElementById('bg-play');
+ bgPlayBtn.addEventListener('click', function () {
+ if (bgAudio.paused)
+ void bgAudio.play();
+ else
+ bgAudio.pause();
+ });
+ (_a = document.getElementById('bg-close')) === null || _a === void 0 ? void 0 : _a.addEventListener('click', function () {
+ bgAudio.pause();
+ if (bgBar)
+ bgBar.style.display = 'none';
+ });
+ bgAudio.addEventListener('play', function () { if (bgPlayBtn)
+ bgPlayBtn.textContent = '⏸'; });
+ bgAudio.addEventListener('pause', function () { if (bgPlayBtn)
+ bgPlayBtn.textContent = '▶'; });
+ bgAudio.addEventListener('ended', function () { if (bgPlayBtn)
+ bgPlayBtn.textContent = '▶'; });
+ }
+ function sendToBg(src, title) {
+ createBgBar();
+ if (bgBar)
+ bgBar.style.display = 'flex';
+ bgAudio.src = src;
+ if (bgTitle)
+ bgTitle.textContent = title;
+ void bgAudio.play();
+ }
+ // Attach "♪" button to every inline audio player
+ document.querySelectorAll('audio.media-audio').forEach(function (a) {
+ const btn = document.createElement('button');
+ btn.className = 'btn-bg-music';
+ btn.textContent = '♪ Hintergrundmusik';
+ btn.type = 'button';
+ const title = a.title || a.src.split('/').pop() || a.src;
+ btn.addEventListener('click', function () { sendToBg(a.src, title); });
+ a.insertAdjacentElement('afterend', btn);
+ });
+ /* ── Video autoplay + coordination ──────────────────────── */
+ const obs = new IntersectionObserver(function (entries) {
+ entries.forEach(function (e) {
+ const v = e.target;
+ if (e.isIntersecting) {
+ void v.play();
+ }
+ else {
+ v.pause();
+ }
+ });
+ }, { threshold: 0.3 });
+ document.querySelectorAll('video.media-embed').forEach(function (v) {
+ v.muted = true;
+ v.loop = true;
+ v.setAttribute('playsinline', '');
+ obs.observe(v);
+ // User unmutes → pause background music
+ v.addEventListener('volumechange', function () {
+ if (!v.muted && !v.paused) {
+ bgPlaying = !bgAudio.paused;
+ bgAudio.pause();
+ }
+ // Video muted again → resume background
+ if (v.muted && bgPlaying) {
+ void bgAudio.play();
+ bgPlaying = false;
+ }
+ });
+ // Video pauses or ends → resume background if it was playing
+ v.addEventListener('pause', function () {
+ if (bgPlaying) {
+ void bgAudio.play();
+ bgPlaying = false;
+ }
+ });
+ v.addEventListener('ended', function () {
+ if (bgPlaying) {
+ void bgAudio.play();
+ bgPlaying = false;
+ }
+ });
+ });
+})();
diff --git a/backend/internal/api/static/day.js b/backend/internal/api/static/day.js
index 94e1858..4809aa7 100644
--- a/backend/internal/api/static/day.js
+++ b/backend/internal/api/static/day.js
@@ -1,47 +1,28 @@
+"use strict";
+var _a;
// 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 }
- );
+(_a = document.getElementById('btn-gps')) === null || _a === void 0 ? void 0 : _a.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;
- }
+ 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);
- });
-});
diff --git a/backend/internal/api/static/editor.js b/backend/internal/api/static/editor.js
new file mode 100644
index 0000000..6cb6cf0
--- /dev/null
+++ b/backend/internal/api/static/editor.js
@@ -0,0 +1,84 @@
+"use strict";
+(function () {
+ 'use strict';
+ function initEditor(ta) {
+ var _a, _b;
+ async function upload(file) {
+ var _a;
+ const form = new FormData();
+ form.append('file', file);
+ const statusEl = (_a = ta.parentElement) === null || _a === void 0 ? void 0 : _a.querySelector('.upload-status');
+ if (statusEl)
+ statusEl.textContent = '↑ ' + file.name + ' …';
+ try {
+ const res = await fetch('/media', { method: 'POST', body: form });
+ if (!res.ok) {
+ if (statusEl)
+ statusEl.textContent = '✗ Fehler beim Hochladen';
+ return;
+ }
+ const data = await res.json();
+ insertAtCursor('\n' + data.ref + '\n');
+ if (statusEl)
+ statusEl.textContent = '';
+ }
+ catch (_e) {
+ if (statusEl)
+ statusEl.textContent = '✗ Fehler beim Hochladen';
+ }
+ }
+ function insertAtCursor(text) {
+ const start = ta.selectionStart;
+ ta.value = ta.value.slice(0, start) + text + ta.value.slice(ta.selectionEnd);
+ ta.selectionStart = ta.selectionEnd = start + text.length;
+ ta.focus();
+ }
+ // Paste: catch file pastes
+ ta.addEventListener('paste', function (e) {
+ var _a;
+ const items = (_a = e.clipboardData) === null || _a === void 0 ? void 0 : _a.items;
+ if (!items)
+ return;
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].kind === 'file') {
+ e.preventDefault();
+ const file = items[i].getAsFile();
+ if (file)
+ void upload(file);
+ return;
+ }
+ }
+ });
+ // Drag & Drop onto textarea
+ ta.addEventListener('dragover', function (e) {
+ e.preventDefault();
+ ta.classList.add('drag-over');
+ });
+ ta.addEventListener('dragleave', function () {
+ ta.classList.remove('drag-over');
+ });
+ ta.addEventListener('drop', function (e) {
+ var _a;
+ e.preventDefault();
+ ta.classList.remove('drag-over');
+ const files = (_a = e.dataTransfer) === null || _a === void 0 ? void 0 : _a.files;
+ if (!files)
+ return;
+ for (let i = 0; i < files.length; i++)
+ void upload(files[i]);
+ });
+ // File picker button
+ const picker = (_a = ta.parentElement) === null || _a === void 0 ? void 0 : _a.querySelector('.media-picker');
+ const input = (_b = ta.parentElement) === null || _b === void 0 ? void 0 : _b.querySelector('.media-file-input');
+ if (picker && input) {
+ picker.addEventListener('click', function () { input.click(); });
+ input.addEventListener('change', function () {
+ if (!input.files)
+ return;
+ Array.from(input.files).forEach(f => void upload(f));
+ input.value = '';
+ });
+ }
+ }
+ document.querySelectorAll('textarea[name="description"]').forEach(initEditor);
+})();
diff --git a/backend/internal/api/static/style.css b/backend/internal/api/static/style.css
index 2e4fa41..49b89cc 100644
--- a/backend/internal/api/static/style.css
+++ b/backend/internal/api/static/style.css
@@ -27,9 +27,6 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
.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; }
@@ -38,10 +35,7 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
/* Narrow pages (login, register) */
.narrow { max-width: 400px; margin-top: 4rem; }
-/* 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; }
+.thumb { width: 80px; height: 80px; object-fit: cover; border: 1px solid var(--pico-muted-border-color); display: block; }
/* Journal entry cards */
.entry-card {
@@ -54,8 +48,16 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
.entry-meta { font-size: .8rem; margin-bottom: .3rem; display: flex; gap: .6rem; align-items: baseline; flex-wrap: wrap; }
.entry-edit { margin-left: auto; font-size: .75rem; }
.entry-title { font-size: 1rem; margin-bottom: .3rem; }
-.entry-desc { white-space: pre-wrap; font-size: .9rem; }
+.entry-desc { font-size: .9rem; }
+.entry-desc p { margin-bottom: .5rem; }
+.entry-desc img { max-width: 100%; height: auto; display: block; margin: .5rem 0; }
+.entry-desc video { max-width: 100%; display: block; margin: .5rem 0; }
+.entry-desc ul,
+.entry-desc ol { padding-left: 1.2rem; margin-bottom: .5rem; }
+.entry-desc h1, .entry-desc h2, .entry-desc h3 { font-weight: normal; margin: .8rem 0 .3rem; }
.entry-images { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
+.media-embed { width: 100%; max-height: 360px; display: block; margin-top: .5rem; }
+.media-audio { width: 100%; display: block; margin-top: .5rem; }
/* Public feed */
.pub-card { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--pico-muted-border-color); }
@@ -63,7 +65,10 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
.pub-cover { width: 100%; max-height: 320px; object-fit: cover; display: block; margin-bottom: .7rem; }
.pub-meta { display: block; color: var(--pico-muted-color); margin-bottom: .3rem; }
.pub-title { display: block; font-size: 1rem; margin-bottom: .4rem; }
-.pub-desc { margin: 0 0 .4rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; }
+.pub-desc { margin: 0 0 .4rem; font-size: .9rem; }
+.pub-desc p { margin-bottom: .5rem; }
+.pub-desc img { max-width: 100%; height: auto; display: block; margin: .5rem 0; }
+.pub-desc video { max-width: 100%; display: block; margin: .5rem 0; }
.pub-tags { margin-top: .3rem; }
/* Login */
@@ -76,6 +81,25 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
/* Visibility badge */
.badge-public { font-size: .7rem; background: #264; color: #8f8; padding: .1rem .4rem; border-radius: 4px; vertical-align: middle; }
+/* Background music player */
+#bg-bar { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: var(--pico-background-color); border-top: 1px solid var(--pico-muted-border-color); padding: .4rem 1rem; gap: .8rem; align-items: center; z-index: 100; font-size: .8rem; }
+#bg-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pico-muted-color); }
+#bg-play, #bg-close { background: none; border: none; cursor: pointer; padding: 0 .3rem; font-size: .9rem; margin: 0; }
+.btn-bg-music { font-size: .75rem; padding: .15rem .5rem; background: none; border: 1px solid var(--pico-muted-border-color); cursor: pointer; margin-top: .3rem; display: block; }
+
+/* Editor */
+.editor-wrap textarea { margin-bottom: 0; border-bottom: none; border-radius: var(--pico-border-radius) var(--pico-border-radius) 0 0; }
+.editor-wrap textarea.drag-over { outline: 2px dashed var(--pico-primary); }
+.editor-bar { display: flex; align-items: center; gap: .6rem; padding: .3rem .5rem; border: 1px solid var(--pico-form-element-border-color); border-top: none; border-radius: 0 0 var(--pico-border-radius) var(--pico-border-radius); margin-bottom: 1rem; background: var(--pico-form-element-background-color); }
+.editor-bar button { font-size: .78rem; padding: .15rem .5rem; background: none; border: 1px solid var(--pico-muted-border-color); cursor: pointer; margin: 0; }
+.upload-status { font-size: .78rem; color: var(--pico-muted-color); }
+
+/* Media reference rows (edit form) */
+.media-refs { margin-bottom: 1rem; display: flex; flex-direction: column; gap: .5rem; }
+.media-ref-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
+.media-ref-code { font-size: .75rem; background: var(--pico-muted-background-color); padding: .2rem .4rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.btn-insert { font-size: .75rem; padding: .2rem .5rem; background: none; border: 1px solid var(--pico-muted-border-color); cursor: pointer; white-space: nowrap; }
+
/* Delete button */
.btn-delete { background: none; border: 1px solid #c44; color: #c44; padding: .2rem .6rem; cursor: pointer; font-size: .8rem; border-radius: 4px; }
.btn-delete:hover { background: #c44; color: #fff; }
diff --git a/backend/internal/api/templates/base.html b/backend/internal/api/templates/base.html
index 82e8e68..2e1c21e 100644
--- a/backend/internal/api/templates/base.html
+++ b/backend/internal/api/templates/base.html
@@ -20,6 +20,7 @@
{{block "content" .}}{{end}}
{{block "scripts" .}}{{end}}
+