From b73d91ccaabcc9537d560755ffae36f593de27d6 Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Thu, 9 Apr 2026 22:25:31 +0200 Subject: [PATCH] edit added --- backend/internal/api/journal.go | 124 ++++++++++++++++++ backend/internal/api/router.go | 2 + backend/internal/api/static/style.css | 3 +- backend/internal/api/templates/day.html | 1 + .../internal/api/templates/edit_entry.html | 45 +++++++ backend/internal/db/journal.go | 27 ++++ 6 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 backend/internal/api/templates/edit_entry.html diff --git a/backend/internal/api/journal.go b/backend/internal/api/journal.go index db9c19b..d869651 100644 --- a/backend/internal/api/journal.go +++ b/backend/internal/api/journal.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/go-chi/chi/v5" "github.com/jacek/pamietnik/backend/internal/db" "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} } +// 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). func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(maxUploadSize); err != nil { diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index a3fb6c2..54c59a4 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -71,6 +71,8 @@ func NewRouter( r.Get("/days/redirect", webUI.HandleDaysRedirect) r.Get("/days/{date}", webUI.HandleDayDetail) r.Post("/entries", journalHandler.HandleCreateEntry) + r.Get("/entries/{id}/edit", journalHandler.HandleGetEditEntry) + r.Post("/entries/{id}", journalHandler.HandleUpdateEntry) }) // Admin routes diff --git a/backend/internal/api/static/style.css b/backend/internal/api/static/style.css index 5d6c735..f6a7333 100644 --- a/backend/internal/api/static/style.css +++ b/backend/internal/api/static/style.css @@ -44,7 +44,8 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; } background: var(--pico-card-background-color); border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0; } -.entry-meta { font-size: .8rem; margin-bottom: .3rem; } +.entry-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-images { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; } diff --git a/backend/internal/api/templates/day.html b/backend/internal/api/templates/day.html index 7171dff..8c193da 100644 --- a/backend/internal/api/templates/day.html +++ b/backend/internal/api/templates/day.html @@ -35,6 +35,7 @@ {{.EntryTime}} {{if eq .Visibility "public"}}öffentlich{{end}} {{if .Lat}} · ○ {{printf "%.5f" (deref .Lat)}}, {{printf "%.5f" (deref .Lon)}}{{end}} + bearbeiten {{if .Title}}
{{.Title}}
{{end}} {{if .Description}}
{{.Description}}
{{end}} diff --git a/backend/internal/api/templates/edit_entry.html b/backend/internal/api/templates/edit_entry.html new file mode 100644 index 0000000..3206b11 --- /dev/null +++ b/backend/internal/api/templates/edit_entry.html @@ -0,0 +1,45 @@ +{{define "title"}}Eintrag bearbeiten{{end}} + +{{define "content"}} +
+ +

Eintrag bearbeiten

+ +
+
+ + +
+ + +
+ + + +
+ + + {{if .Entry.Images}} +
+ {{range .Entry.Images}} + + {{.OriginalName}} + + {{end}} +
+ {{end}} + +
+ +
+
+{{end}} + +{{define "scripts"}} + +{{end}} + +{{template "base" .}} diff --git a/backend/internal/db/journal.go b/backend/internal/db/journal.go index 8413129..fda512e 100644 --- a/backend/internal/db/journal.go +++ b/backend/internal/db/journal.go @@ -45,6 +45,33 @@ func (s *JournalStore) InsertImage(ctx context.Context, img domain.JournalImage) 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. func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]domain.JournalEntry, error) { rows, err := s.pool.Query(ctx,