This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jacek/pamietnik/backend/internal/db"
|
"github.com/jacek/pamietnik/backend/internal/db"
|
||||||
"github.com/jacek/pamietnik/backend/internal/domain"
|
"github.com/jacek/pamietnik/backend/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -34,6 +35,129 @@ func NewJournalHandler(store *db.JournalStore, uploadDir string) *JournalHandler
|
|||||||
return &JournalHandler{store: store, uploadDir: uploadDir}
|
return &JournalHandler{store: store, uploadDir: uploadDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleGetEditEntry renders the edit form for an existing entry.
|
||||||
|
func (h *JournalHandler) HandleGetEditEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
|
entryID := chi.URLParam(r, "id")
|
||||||
|
entry, err := h.store.GetEntry(r.Context(), entryID, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Eintrag nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
render(w, "edit_entry.html", map[string]any{"Entry": entry})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateEntry handles POST /entries/{id} (multipart/form-data).
|
||||||
|
func (h *JournalHandler) HandleUpdateEntry(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())
|
||||||
|
entryID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
// Verify ownership first
|
||||||
|
existing, err := h.store.GetEntry(r.Context(), entryID, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Eintrag nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entryTime := strings.TrimSpace(r.FormValue("time"))
|
||||||
|
title := strings.TrimSpace(r.FormValue("title"))
|
||||||
|
description := strings.TrimSpace(r.FormValue("description"))
|
||||||
|
visibility := r.FormValue("visibility")
|
||||||
|
if visibility != "public" && visibility != "private" {
|
||||||
|
visibility = "private"
|
||||||
|
}
|
||||||
|
var hashtags []string
|
||||||
|
if raw := strings.TrimSpace(r.FormValue("hashtags")); raw != "" {
|
||||||
|
for _, tag := range strings.Split(raw, ",") {
|
||||||
|
tag = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
|
||||||
|
if tag != "" {
|
||||||
|
hashtags = append(hashtags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := domain.JournalEntry{
|
||||||
|
EntryID: entryID,
|
||||||
|
UserID: userID,
|
||||||
|
EntryDate: existing.EntryDate,
|
||||||
|
EntryTime: entryTime,
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Visibility: visibility,
|
||||||
|
Hashtags: hashtags,
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.UpdateEntry(r.Context(), entry); err != nil {
|
||||||
|
http.Error(w, "Datenbankfehler", http.StatusInternalServerError)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/days/"+existing.EntryDate, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleCreateEntry handles POST /entries (multipart/form-data).
|
// HandleCreateEntry handles POST /entries (multipart/form-data).
|
||||||
func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ func NewRouter(
|
|||||||
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("/entries", journalHandler.HandleCreateEntry)
|
r.Post("/entries", journalHandler.HandleCreateEntry)
|
||||||
|
r.Get("/entries/{id}/edit", journalHandler.HandleGetEditEntry)
|
||||||
|
r.Post("/entries/{id}", journalHandler.HandleUpdateEntry)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Admin routes
|
// Admin routes
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
|
|||||||
background: var(--pico-card-background-color);
|
background: var(--pico-card-background-color);
|
||||||
border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0;
|
border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0;
|
||||||
}
|
}
|
||||||
.entry-meta { font-size: .8rem; margin-bottom: .3rem; }
|
.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-title { font-size: 1rem; margin-bottom: .3rem; }
|
||||||
.entry-desc { white-space: pre-wrap; font-size: .9rem; }
|
.entry-desc { white-space: pre-wrap; font-size: .9rem; }
|
||||||
.entry-images { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
|
.entry-images { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<strong>{{.EntryTime}}</strong>
|
<strong>{{.EntryTime}}</strong>
|
||||||
{{if eq .Visibility "public"}}<span class="badge-public">öffentlich</span>{{end}}
|
{{if eq .Visibility "public"}}<span class="badge-public">öffentlich</span>{{end}}
|
||||||
{{if .Lat}}<small> · ○ {{printf "%.5f" (deref .Lat)}}, {{printf "%.5f" (deref .Lon)}}</small>{{end}}
|
{{if .Lat}}<small> · ○ {{printf "%.5f" (deref .Lat)}}, {{printf "%.5f" (deref .Lon)}}</small>{{end}}
|
||||||
|
<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">{{.Description}}</div>{{end}}
|
||||||
|
|||||||
45
backend/internal/api/templates/edit_entry.html
Normal file
45
backend/internal/api/templates/edit_entry.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{{define "title"}}Eintrag bearbeiten{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<main class="container">
|
||||||
|
<nav><a href="/days/{{.Entry.EntryDate}}">← {{.Entry.EntryDate}}</a></nav>
|
||||||
|
<h1>Eintrag bearbeiten</h1>
|
||||||
|
|
||||||
|
<form method="post" action="/entries/{{.Entry.EntryID}}" enctype="multipart/form-data">
|
||||||
|
<div class="gps-row">
|
||||||
|
<input type="time" name="time" required value="{{.Entry.EntryTime}}">
|
||||||
|
<select name="visibility">
|
||||||
|
<option value="private"{{if eq .Entry.Visibility "private"}} selected{{end}}>Privat</option>
|
||||||
|
<option value="public"{{if eq .Entry.Visibility "public"}} selected{{end}}>Öffentlich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="title" placeholder="Überschrift" value="{{.Entry.Title}}">
|
||||||
|
<textarea name="description" rows="4" placeholder="Beschreibung">{{.Entry.Description}}</textarea>
|
||||||
|
<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="lon" id="entry-lon" step="any" placeholder="Länge"{{if .Entry.Lon}} value="{{printf "%.6f" (deref .Entry.Lon)}}"{{end}}>
|
||||||
|
<button type="button" id="btn-gps">◎ GPS</button>
|
||||||
|
</div>
|
||||||
|
<small id="gps-status"></small>
|
||||||
|
<input type="text" name="hashtags" placeholder="Hashtags (kommagetrennt)" value="{{join .Entry.Hashtags ", "}}">
|
||||||
|
{{if .Entry.Images}}
|
||||||
|
<div class="entry-images" style="margin-bottom:.5rem">
|
||||||
|
{{range .Entry.Images}}
|
||||||
|
<a href="/uploads/{{.Filename}}" target="_blank">
|
||||||
|
<img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb">
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{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>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script src="/static/day.js"></script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
@@ -45,6 +45,33 @@ func (s *JournalStore) InsertImage(ctx context.Context, img domain.JournalImage)
|
|||||||
return img, err
|
return img, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetEntry returns a single entry by ID, verifying ownership.
|
||||||
|
func (s *JournalStore) GetEntry(ctx context.Context, entryID, userID string) (domain.JournalEntry, error) {
|
||||||
|
var e domain.JournalEntry
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, visibility, hashtags, created_at
|
||||||
|
FROM journal_entries WHERE entry_id = $1 AND user_id = $2`,
|
||||||
|
entryID, userID,
|
||||||
|
).Scan(&e.EntryID, &e.UserID, &e.EntryDate, &e.EntryTime,
|
||||||
|
&e.Title, &e.Description, &e.Lat, &e.Lon, &e.Visibility, &e.Hashtags, &e.CreatedAt)
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEntry updates mutable fields of an existing entry.
|
||||||
|
func (s *JournalStore) UpdateEntry(ctx context.Context, e domain.JournalEntry) error {
|
||||||
|
if e.Hashtags == nil {
|
||||||
|
e.Hashtags = []string{}
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE journal_entries
|
||||||
|
SET entry_time = $1, title = $2, description = $3, lat = $4, lon = $5, visibility = $6, hashtags = $7
|
||||||
|
WHERE entry_id = $8 AND user_id = $9`,
|
||||||
|
e.EntryTime, e.Title, e.Description, e.Lat, e.Lon, e.Visibility, e.Hashtags,
|
||||||
|
e.EntryID, e.UserID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ListByDate returns all journal entries for a given date (YYYY-MM-DD), including their images.
|
// ListByDate returns all journal entries for a given date (YYYY-MM-DD), including their images.
|
||||||
func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]domain.JournalEntry, error) {
|
func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]domain.JournalEntry, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
rows, err := s.pool.Query(ctx,
|
||||||
|
|||||||
Reference in New Issue
Block a user