Add TypeScript migration, image resizing, media upload UX, and multimedia support
All checks were successful
Deploy to NAS / deploy (push) Successful in 2m20s

- Migrate static JS to TypeScript (static-ts/ → compiled to internal/api/static/)
- Add image resizing on upload: JPEG/PNG/WebP scaled to max 1920px at quality 80
- Extract shared upload logic into upload.go (saveUpload, saveResizedImage, saveResizedWebP)
- Add POST /media endpoint for drag-drop/paste media uploads with markdown ref return
- Add background music player with video/audio coordination (autoplay.ts)
- Add global nav, public feed, hashtags, visibility, Markdown rendering for entries
- Add Dockerfile stage for TypeScript compilation (static-ts-builder)
- Add goldmark, disintegration/imaging, golang.org/x/image dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-04-09 23:03:04 +02:00
parent 8eef933573
commit 17186e7b64
24 changed files with 890 additions and 179 deletions

View File

@@ -62,8 +62,23 @@ internal/
ingest.go POST /v1/trackpoints, POST /v1/trackpoints:batch ingest.go POST /v1/trackpoints, POST /v1/trackpoints:batch
query.go GET /v1/days, /v1/trackpoints, /v1/stops, /v1/suggestions query.go GET /v1/days, /v1/trackpoints, /v1/stops, /v1/suggestions
webui.go server-side rendered web UI (login, /days, /days/{date}) 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 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: Key invariants:

View File

@@ -6,12 +6,23 @@ RUN npm ci
COPY webapp/ ./ COPY webapp/ ./
RUN npm run build 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 FROM golang:1.25-alpine AS go-builder
WORKDIR /app WORKDIR /app
COPY backend/go.mod backend/go.sum ./ COPY backend/go.mod backend/go.sum ./
RUN go mod download RUN go mod download
COPY backend/ ./ COPY backend/ ./
# Inject compiled static JS
COPY --from=static-ts-builder /build/backend/internal/api/static/ ./internal/api/static/
RUN go test ./... RUN go test ./...
# Inject built SPA into embed path # Inject built SPA into embed path
COPY --from=webapp-builder /webapp/dist ./internal/api/webapp/ COPY --from=webapp-builder /webapp/dist ./internal/api/webapp/

View File

@@ -10,11 +10,14 @@ require (
) )
require ( require (
github.com/disintegration/imaging v1.6.2 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // 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/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.36.0 // indirect
) )

View File

@@ -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/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 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= 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 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= 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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 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= 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= 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 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -2,8 +2,8 @@ package api
import ( import (
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"mime/multipart"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -20,10 +20,17 @@ const (
) )
var allowedMIME = map[string]string{ var allowedMIME = map[string]string{
"image/jpeg": ".jpg", "image/jpeg": ".jpg",
"image/png": ".png", "image/png": ".png",
"image/webp": ".webp", "image/webp": ".webp",
"image/heic": ".heic", "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 { type JournalHandler struct {
@@ -108,51 +115,8 @@ func (h *JournalHandler) HandleUpdateEntry(w http.ResponseWriter, r *http.Reques
return return
} }
// Handle new image uploads if r.MultipartForm != nil {
if r.MultipartForm != nil && r.MultipartForm.File != nil { h.saveJournalImages(r, entryID, r.MultipartForm.File["images"])
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)
}
}
} }
http.Redirect(w, r, "/days/"+existing.EntryDate, http.StatusSeeOther) http.Redirect(w, r, "/days/"+existing.EntryDate, http.StatusSeeOther)
@@ -219,71 +183,56 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques
return return
} }
// Handle image uploads if r.MultipartForm != nil {
if r.MultipartForm != nil && r.MultipartForm.File != nil { h.saveJournalImages(r, saved.EntryID, r.MultipartForm.File["images"])
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) 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. // sanitizeFilename strips path separators and non-printable characters.
func sanitizeFilename(name string) string { func sanitizeFilename(name string) string {
name = filepath.Base(name) name = filepath.Base(name)

View File

@@ -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 "![" + filename + "](" + url + ")"
case strings.HasPrefix(mime, "video/"):
return "![" + filename + "](" + url + ")"
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)
}

View File

@@ -26,6 +26,7 @@ func NewRouter(
webUI := NewWebUI(authStore, tpStore, stopStore, journalStore, userStore) webUI := NewWebUI(authStore, tpStore, stopStore, journalStore, userStore)
journalHandler := NewJournalHandler(journalStore, uploadDir) journalHandler := NewJournalHandler(journalStore, uploadDir)
mediaHandler := NewMediaHandler(uploadDir)
authMW := RequireAuth(authStore) authMW := RequireAuth(authStore)
// Health // Health
@@ -70,6 +71,7 @@ func NewRouter(
r.Get("/days", webUI.HandleDaysList) r.Get("/days", webUI.HandleDaysList)
r.Get("/days/redirect", webUI.HandleDaysRedirect) r.Get("/days/redirect", webUI.HandleDaysRedirect)
r.Get("/days/{date}", webUI.HandleDayDetail) r.Get("/days/{date}", webUI.HandleDayDetail)
r.Post("/media", mediaHandler.HandleUpload)
r.Post("/entries", journalHandler.HandleCreateEntry) r.Post("/entries", journalHandler.HandleCreateEntry)
r.Get("/entries/{id}/edit", journalHandler.HandleGetEditEntry) r.Get("/entries/{id}/edit", journalHandler.HandleGetEditEntry)
r.Post("/entries/{id}", journalHandler.HandleUpdateEntry) r.Post("/entries/{id}", journalHandler.HandleUpdateEntry)

View File

@@ -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 =
'<span id="bg-title"></span>' +
'<button id="bg-play" aria-label="Abspielen">▶</button>' +
'<button id="bg-close" aria-label="Schließen">✕</button>';
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;
}
});
});
})();

View File

@@ -1,47 +1,28 @@
"use strict";
var _a;
// GPS button // GPS button
document.getElementById('btn-gps')?.addEventListener('click', function () { (_a = document.getElementById('btn-gps')) === null || _a === void 0 ? void 0 : _a.addEventListener('click', function () {
const status = document.getElementById('gps-status'); const status = document.getElementById('gps-status');
if (!navigator.geolocation) { if (!navigator.geolocation) {
status.textContent = '// GPS nicht verfügbar'; status.textContent = '// GPS nicht verfügbar';
return; return;
} }
status.textContent = '// Standort wird ermittelt...'; status.textContent = '// Standort wird ermittelt...';
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(function (pos) {
function (pos) { document.getElementById('entry-lat').value = pos.coords.latitude.toFixed(6);
document.getElementById('entry-lat').value = pos.coords.latitude.toFixed(6); document.getElementById('entry-lon').value = pos.coords.longitude.toFixed(6);
document.getElementById('entry-lon').value = pos.coords.longitude.toFixed(6); status.textContent = '// Standort gesetzt (' + pos.coords.accuracy.toFixed(0) + ' m Genauigkeit)';
status.textContent = '// Standort gesetzt (' + pos.coords.accuracy.toFixed(0) + ' m Genauigkeit)'; }, function (err) {
}, status.textContent = '// Fehler: ' + err.message;
function (err) { }, { enableHighAccuracy: true, timeout: 10000 });
status.textContent = '// Fehler: ' + err.message;
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}); });
// Set current time as default // Set current time as default
(function () { (function () {
const input = document.getElementById('entry-time'); const input = document.getElementById('entry-time');
if (input && !input.value) { if (input && !input.value) {
const now = new Date(); const now = new Date();
const hh = String(now.getHours()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0');
input.value = hh + ':' + mm; 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,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);
})();

View File

@@ -27,9 +27,6 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
.source-gps { color: #060; } .source-gps { color: #060; }
.source-manual { color: #888; } .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 */
.gps-row { display: flex; gap: .4rem; align-items: center; } .gps-row { display: flex; gap: .4rem; align-items: center; }
.gps-row input { flex: 1; margin-bottom: 0; } .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 pages (login, register) */
.narrow { max-width: 400px; margin-top: 4rem; } .narrow { max-width: 400px; margin-top: 4rem; }
/* Image preview */ .thumb { width: 80px; height: 80px; object-fit: cover; border: 1px solid var(--pico-muted-border-color); display: block; }
.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 */ /* Journal entry cards */
.entry-card { .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-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-edit { margin-left: auto; font-size: .75rem; }
.entry-title { font-size: 1rem; margin-bottom: .3rem; } .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; } .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 */ /* Public feed */
.pub-card { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--pico-muted-border-color); } .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-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-meta { display: block; color: var(--pico-muted-color); margin-bottom: .3rem; }
.pub-title { display: block; font-size: 1rem; margin-bottom: .4rem; } .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; } .pub-tags { margin-top: .3rem; }
/* Login */ /* Login */
@@ -76,6 +81,25 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
/* Visibility badge */ /* Visibility badge */
.badge-public { font-size: .7rem; background: #264; color: #8f8; padding: .1rem .4rem; border-radius: 4px; vertical-align: middle; } .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 */ /* 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 { 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; } .btn-delete:hover { background: #c44; color: #fff; }

View File

@@ -20,6 +20,7 @@
</nav> </nav>
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
{{block "scripts" .}}{{end}} {{block "scripts" .}}{{end}}
<script src="/static/autoplay.js"></script>
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@@ -15,7 +15,14 @@
</select> </select>
</div> </div>
<input type="text" name="title" placeholder="Überschrift"> <input type="text" name="title" placeholder="Überschrift">
<textarea name="description" rows="4" placeholder="Beschreibung"></textarea> <div class="editor-wrap">
<textarea name="description" rows="6" placeholder="Beschreibung — Markdown unterstützt&#10;Medien: per Drag & Drop oder Einfügen (Strg+V)"></textarea>
<div class="editor-bar">
<button type="button" class="media-picker">&#128206; Datei anhängen</button>
<span class="upload-status"></span>
<input type="file" class="media-file-input" multiple accept="image/*,video/*,audio/*" style="display:none">
</div>
</div>
<div class="gps-row"> <div class="gps-row">
<input type="number" name="lat" id="entry-lat" step="any" placeholder="Breite"> <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"> <input type="number" name="lon" id="entry-lon" step="any" placeholder="Länge">
@@ -23,8 +30,6 @@
</div> </div>
<small id="gps-status"></small> <small id="gps-status"></small>
<input type="text" name="hashtags" placeholder="Hashtags (kommagetrennt)"> <input type="text" name="hashtags" placeholder="Hashtags (kommagetrennt)">
<input type="file" name="images" multiple accept="image/*" id="image-input">
<div id="image-preview" class="image-preview"></div>
<button type="submit">Speichern</button> <button type="submit">Speichern</button>
</form> </form>
@@ -38,15 +43,21 @@
<a href="/entries/{{.EntryID}}/edit" class="entry-edit">bearbeiten</a> <a href="/entries/{{.EntryID}}/edit" class="entry-edit">bearbeiten</a>
</div> </div>
{{if .Title}}<div class="entry-title">{{.Title}}</div>{{end}} {{if .Title}}<div class="entry-title">{{.Title}}</div>{{end}}
{{if .Description}}<div class="entry-desc">{{.Description}}</div>{{end}} {{if .Description}}<div class="entry-desc">{{markdown .Description}}</div>{{end}}
{{if .Hashtags}}<div class="hashtags">{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}</div>{{end}} {{if .Hashtags}}<div class="hashtags">{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}</div>{{end}}
{{if .Images}} {{if .Images}}
<div class="entry-images"> <div class="entry-images">
{{range .Images}} {{range .Images}}
{{if isVideo .MimeType}}
<video src="/uploads/{{.Filename}}" controls class="media-embed"></video>
{{else if isAudio .MimeType}}
<audio src="/uploads/{{.Filename}}" controls class="media-audio"></audio>
{{else}}
<a href="/uploads/{{.Filename}}" target="_blank"> <a href="/uploads/{{.Filename}}" target="_blank">
<img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb"> <img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb">
</a> </a>
{{end}} {{end}}
{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>
@@ -96,6 +107,7 @@
{{define "scripts"}} {{define "scripts"}}
<script src="/static/day.js"></script> <script src="/static/day.js"></script>
<script src="/static/editor.js"></script>
{{end}} {{end}}
{{template "base" .}} {{template "base" .}}

View File

@@ -14,7 +14,14 @@
</select> </select>
</div> </div>
<input type="text" name="title" placeholder="Überschrift" value="{{.Entry.Title}}"> <input type="text" name="title" placeholder="Überschrift" value="{{.Entry.Title}}">
<textarea name="description" rows="4" placeholder="Beschreibung">{{.Entry.Description}}</textarea> <div class="editor-wrap">
<textarea name="description" rows="6" placeholder="Beschreibung — Markdown unterstützt&#10;Medien: per Drag & Drop oder Einfügen (Strg+V)">{{.Entry.Description}}</textarea>
<div class="editor-bar">
<button type="button" class="media-picker">&#128206; Datei anhängen</button>
<span class="upload-status"></span>
<input type="file" class="media-file-input" multiple accept="image/*,video/*,audio/*" style="display:none">
</div>
</div>
<div class="gps-row"> <div class="gps-row">
<input type="number" name="lat" id="entry-lat" step="any" placeholder="Breite"{{if .Entry.Lat}} value="{{printf "%.6f" (deref .Entry.Lat)}}"{{end}}> <input type="number" name="lat" id="entry-lat" step="any" placeholder="Breite"{{if .Entry.Lat}} value="{{printf "%.6f" (deref .Entry.Lat)}}"{{end}}>
<input type="number" name="lon" id="entry-lon" step="any" placeholder="Länge"{{if .Entry.Lon}} value="{{printf "%.6f" (deref .Entry.Lon)}}"{{end}}> <input type="number" name="lon" id="entry-lon" step="any" placeholder="Länge"{{if .Entry.Lon}} value="{{printf "%.6f" (deref .Entry.Lon)}}"{{end}}>
@@ -23,16 +30,22 @@
<small id="gps-status"></small> <small id="gps-status"></small>
<input type="text" name="hashtags" placeholder="Hashtags (kommagetrennt)" value="{{join .Entry.Hashtags ", "}}"> <input type="text" name="hashtags" placeholder="Hashtags (kommagetrennt)" value="{{join .Entry.Hashtags ", "}}">
{{if .Entry.Images}} {{if .Entry.Images}}
<div class="entry-images" style="margin-bottom:.5rem"> <div class="media-refs">
{{range .Entry.Images}} {{range .Entry.Images}}
<a href="/uploads/{{.Filename}}" target="_blank"> <div class="media-ref-row">
{{if isVideo .MimeType}}
<code class="media-ref-code">![{{.OriginalName}}](/uploads/{{.Filename}})</code>
{{else if isAudio .MimeType}}
<code class="media-ref-code">[{{.OriginalName}}](/uploads/{{.Filename}})</code>
{{else}}
<img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb"> <img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb">
</a> <code class="media-ref-code">![{{.OriginalName}}](/uploads/{{.Filename}})</code>
{{end}}
<button type="button" class="btn-insert" data-ref="{{if isVideo .MimeType}}![{{.OriginalName}}](/uploads/{{.Filename}}){{else if isAudio .MimeType}}[{{.OriginalName}}](/uploads/{{.Filename}}){{else}}![{{.OriginalName}}](/uploads/{{.Filename}}){{end}}">↩ einfügen</button>
</div>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
<input type="file" name="images" multiple accept="image/*" id="image-input">
<div id="image-preview" class="image-preview"></div>
<button type="submit">Speichern</button> <button type="submit">Speichern</button>
</form> </form>
</main> </main>
@@ -40,6 +53,7 @@
{{define "scripts"}} {{define "scripts"}}
<script src="/static/day.js"></script> <script src="/static/day.js"></script>
<script src="/static/editor.js"></script>
{{end}} {{end}}
{{template "base" .}} {{template "base" .}}

View File

@@ -12,14 +12,22 @@
{{range .Entries}} {{range .Entries}}
<article class="pub-card"> <article class="pub-card">
{{if .Images}} {{if .Images}}
<a href="/uploads/{{(index .Images 0).Filename}}" target="_blank"> {{with (index .Images 0)}}
<img class="pub-cover" src="/uploads/{{(index .Images 0).Filename}}" alt=""> {{if isVideo .MimeType}}
<video src="/uploads/{{.Filename}}" controls class="media-embed"></video>
{{else if isAudio .MimeType}}
<audio src="/uploads/{{.Filename}}" controls class="media-audio"></audio>
{{else}}
<a href="/uploads/{{.Filename}}" target="_blank">
<img class="pub-cover" src="/uploads/{{.Filename}}" alt="">
</a> </a>
{{end}} {{end}}
{{end}}
{{end}}
<div class="pub-body"> <div class="pub-body">
<small class="pub-meta">{{.EntryDate}} · {{.EntryTime}}</small> <small class="pub-meta">{{.EntryDate}} · {{.EntryTime}}</small>
{{if .Title}}<strong class="pub-title">{{.Title}}</strong>{{end}} {{if .Title}}<strong class="pub-title">{{.Title}}</strong>{{end}}
{{if .Description}}<p class="pub-desc">{{.Description}}</p>{{end}} {{if .Description}}<div class="pub-desc">{{markdown .Description}}</div>{{end}}
{{if .Hashtags}}<div class="pub-tags">{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}</div>{{end}} {{if .Hashtags}}<div class="pub-tags">{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}</div>{{end}}
</div> </div>
</article> </article>

View File

@@ -0,0 +1,125 @@
package api
import (
"bytes"
"image"
"image/jpeg"
_ "image/jpeg"
"image/png"
_ "image/png"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/disintegration/imaging"
"golang.org/x/image/webp"
)
const maxImageDimension = 1920
const jpegQuality = 80
// saveUpload writes an uploaded file to uploadDir, resizing images where applicable.
// peeked contains the first bytes already read for MIME detection.
// Returns the saved filename (including extension).
func saveUpload(uploadDir, baseName, mime string, peeked []byte, rest io.Reader) (string, error) {
full := io.MultiReader(bytes.NewReader(peeked), rest)
switch mime {
case "image/jpeg", "image/png":
return saveResizedImage(uploadDir, baseName, mime, full)
case "image/webp":
return saveResizedWebP(uploadDir, baseName, full)
default:
ext := allowedMIME[mime]
dest := filepath.Join(uploadDir, baseName+ext)
return baseName + ext, saveRaw(dest, full)
}
}
func saveResizedImage(uploadDir, baseName, mime string, r io.Reader) (string, error) {
img, _, err := image.Decode(r)
if err != nil {
// Fallback: save raw if decode fails
slog.Warn("image decode failed, saving raw", "mime", mime, "err", err)
return "", err
}
b := img.Bounds()
if b.Dx() > maxImageDimension || b.Dy() > maxImageDimension {
img = imaging.Fit(img, maxImageDimension, maxImageDimension, imaging.Lanczos)
}
ext := allowedMIME[mime]
filename := baseName + ext
dest := filepath.Join(uploadDir, filename)
out, err := os.Create(dest)
if err != nil {
return "", err
}
defer out.Close()
switch mime {
case "image/jpeg":
err = jpeg.Encode(out, img, &jpeg.Options{Quality: jpegQuality})
case "image/png":
err = png.Encode(out, img)
}
if err != nil {
os.Remove(dest)
return "", err
}
return filename, nil
}
func saveResizedWebP(uploadDir, baseName string, r io.Reader) (string, error) {
img, err := webp.Decode(r)
if err != nil {
slog.Warn("webp decode failed, saving raw", "err", err)
return "", err
}
b := img.Bounds()
if b.Dx() > maxImageDimension || b.Dy() > maxImageDimension {
img = imaging.Fit(img, maxImageDimension, maxImageDimension, imaging.Lanczos)
}
// Re-encode as JPEG (no pure-Go WebP encoder available)
filename := baseName + ".jpg"
dest := filepath.Join(uploadDir, filename)
out, err := os.Create(dest)
if err != nil {
return "", err
}
defer out.Close()
if err := jpeg.Encode(out, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
os.Remove(dest)
return "", err
}
return filename, nil
}
func saveRaw(dest string, r io.Reader) error {
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, r)
return err
}
// mimeCategory returns "image", "video", "audio" or "other".
func mimeCategory(mime string) string {
switch {
case strings.HasPrefix(mime, "image/"):
return "image"
case strings.HasPrefix(mime, "video/"):
return "video"
case strings.HasPrefix(mime, "audio/"):
return "audio"
default:
return "other"
}
}

View File

@@ -13,10 +13,28 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/jacek/pamietnik/backend/internal/auth" "github.com/jacek/pamietnik/backend/internal/auth"
"github.com/jacek/pamietnik/backend/internal/db" "github.com/jacek/pamietnik/backend/internal/db"
) )
var md = goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithHardWraps()),
)
func renderMarkdown(src string) template.HTML {
var buf bytes.Buffer
if err := md.Convert([]byte(src), &buf); err != nil {
return template.HTML(template.HTMLEscapeString(src))
}
return template.HTML(buf.String())
}
//go:embed static templates //go:embed static templates
var assets embed.FS var assets embed.FS
@@ -28,7 +46,11 @@ var funcMap = template.FuncMap{
} }
return *p return *p
}, },
"join": strings.Join, "join": strings.Join,
"isVideo": func(mime string) bool { return strings.HasPrefix(mime, "video/") },
"isAudio": func(mime string) bool { return strings.HasPrefix(mime, "audio/") },
"isImage": func(mime string) bool { return strings.HasPrefix(mime, "image/") },
"markdown": renderMarkdown,
} }
var tmpls = template.Must( var tmpls = template.Must(

1
backend/static-ts/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

View File

@@ -0,0 +1,95 @@
(function () {
'use strict';
/* ── Background player ───────────────────────────────────── */
const bgAudio = new Audio();
let bgPlaying = false;
let bgBar: HTMLElement | null = null;
let bgTitle: HTMLElement | null = null;
let bgPlayBtn: HTMLButtonElement | null = null;
function createBgBar(): void {
if (bgBar) return;
bgBar = document.createElement('div');
bgBar.id = 'bg-bar';
bgBar.innerHTML =
'<span id="bg-title"></span>' +
'<button id="bg-play" aria-label="Abspielen">▶</button>' +
'<button id="bg-close" aria-label="Schließen">✕</button>';
document.body.appendChild(bgBar);
bgTitle = document.getElementById('bg-title');
bgPlayBtn = document.getElementById('bg-play') as HTMLButtonElement;
bgPlayBtn.addEventListener('click', function () {
if (bgAudio.paused) void bgAudio.play(); else bgAudio.pause();
});
document.getElementById('bg-close')?.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: string, title: string): void {
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<HTMLAudioElement>('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: IntersectionObserverEntry[]) {
entries.forEach(function (e) {
const v = e.target as HTMLVideoElement;
if (e.isIntersecting) {
void v.play();
} else {
v.pause();
}
});
}, { threshold: 0.3 });
document.querySelectorAll<HTMLVideoElement>('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; }
});
});
})();

31
backend/static-ts/day.ts Normal file
View File

@@ -0,0 +1,31 @@
// GPS button
document.getElementById('btn-gps')?.addEventListener('click', function () {
const status = document.getElementById('gps-status') as HTMLElement;
if (!navigator.geolocation) {
status.textContent = '// GPS nicht verfügbar';
return;
}
status.textContent = '// Standort wird ermittelt...';
navigator.geolocation.getCurrentPosition(
function (pos: GeolocationPosition) {
(document.getElementById('entry-lat') as HTMLInputElement).value = pos.coords.latitude.toFixed(6);
(document.getElementById('entry-lon') as HTMLInputElement).value = pos.coords.longitude.toFixed(6);
status.textContent = '// Standort gesetzt (' + pos.coords.accuracy.toFixed(0) + ' m Genauigkeit)';
},
function (err: GeolocationPositionError) {
status.textContent = '// Fehler: ' + err.message;
},
{ enableHighAccuracy: true, timeout: 10000 }
);
});
// Set current time as default
(function () {
const input = document.getElementById('entry-time') as HTMLInputElement | null;
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;
}
})();

View File

@@ -0,0 +1,81 @@
(function () {
'use strict';
interface UploadResponse {
filename: string;
mime: string;
ref: string;
}
function initEditor(ta: HTMLTextAreaElement): void {
async function upload(file: File): Promise<void> {
const form = new FormData();
form.append('file', file);
const statusEl = ta.parentElement?.querySelector<HTMLElement>('.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: UploadResponse = await res.json();
insertAtCursor('\n' + data.ref + '\n');
if (statusEl) statusEl.textContent = '';
} catch (_e) {
if (statusEl) statusEl.textContent = '✗ Fehler beim Hochladen';
}
}
function insertAtCursor(text: string): void {
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: ClipboardEvent) {
const items = e.clipboardData?.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: DragEvent) {
e.preventDefault();
ta.classList.add('drag-over');
});
ta.addEventListener('dragleave', function () {
ta.classList.remove('drag-over');
});
ta.addEventListener('drop', function (e: DragEvent) {
e.preventDefault();
ta.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (!files) return;
for (let i = 0; i < files.length; i++) void upload(files[i]);
});
// File picker button
const picker = ta.parentElement?.querySelector<HTMLButtonElement>('.media-picker');
const input = ta.parentElement?.querySelector<HTMLInputElement>('.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<HTMLTextAreaElement>('textarea[name="description"]').forEach(initEditor);
})();

30
backend/static-ts/package-lock.json generated Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "static-ts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "static-ts",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"typescript": "^6.0.2"
}
},
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"name": "static-ts",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "tsc",
"watch": "tsc --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"typescript": "^6.0.2"
}
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["ES2017", "DOM"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"module": "None",
"ignoreDeprecations": "6.0",
"outDir": "../internal/api/static"
},
"include": ["./*.ts"]
}