// SPDX-License-Identifier: AGPL-3.0-or-later package handler import ( "errors" "log/slog" "net/http" "strconv" "strings" "kode.naiv.no/olemd/favoritter/internal/image" "kode.naiv.no/olemd/favoritter/internal/middleware" "kode.naiv.no/olemd/favoritter/internal/render" "kode.naiv.no/olemd/favoritter/internal/store" ) const defaultPageSize = 24 // handleFaveList shows the current user's faves. func (h *Handler) handleFaveList(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) page := queryInt(r, "page", 1) offset := (page - 1) * defaultPageSize faves, total, err := h.deps.Faves.ListByUser(user.ID, defaultPageSize, offset) if err != nil { slog.Error("list faves error", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } // Load tags for each fave. if err := h.deps.Faves.LoadTags(faves); err != nil { slog.Error("load tags error", "error", err) } totalPages := (total + defaultPageSize - 1) / defaultPageSize h.deps.Renderer.Page(w, r, "fave_list", render.PageData{ Title: "Mine favoritter", Data: map[string]any{ "Faves": faves, "Page": page, "TotalPages": totalPages, "Total": total, }, }) } // handleFaveNew shows the form for creating a new fave. func (h *Handler) handleFaveNew(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) h.deps.Renderer.Page(w, r, "fave_form", render.PageData{ Title: "Ny favoritt", Data: map[string]any{ "IsNew": true, "DefaultPrivacy": user.DefaultFavePrivacy, }, }) } // handleFaveCreate processes the form for creating a new fave. func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) if err := r.ParseMultipartForm(h.deps.Config.MaxUploadSize); err != nil { h.flash(w, r, "fave_form", "Filen er for stor.", "error", map[string]any{"IsNew": true}) return } description := strings.TrimSpace(r.FormValue("description")) url := strings.TrimSpace(r.FormValue("url")) privacy := r.FormValue("privacy") tagStr := r.FormValue("tags") if description == "" { h.flash(w, r, "fave_form", "Beskrivelse er påkrevd.", "error", map[string]any{ "IsNew": true, "Description": description, "URL": url, "Tags": tagStr, "Privacy": privacy, }) return } if privacy != "public" && privacy != "private" { privacy = user.DefaultFavePrivacy } // Handle image upload. var imagePath string file, header, err := r.FormFile("image") if err == nil { defer file.Close() result, err := image.Process(file, header, h.deps.Config.UploadDir) if err != nil { slog.Error("image process error", "error", err) h.flash(w, r, "fave_form", "Kunne ikke behandle bildet. Sjekk at filen er et gyldig bilde (JPEG, PNG, GIF eller WebP).", "error", map[string]any{ "IsNew": true, "Description": description, "URL": url, "Tags": tagStr, "Privacy": privacy, }) return } imagePath = result.Filename } // Create the fave. fave, err := h.deps.Faves.Create(user.ID, description, url, imagePath, privacy) if err != nil { slog.Error("create fave error", "error", err) h.flash(w, r, "fave_form", "Noe gikk galt. Prøv igjen.", "error", map[string]any{"IsNew": true}) return } // Set tags. if tagStr != "" { tags := parseTags(tagStr) if err := h.deps.Tags.SetFaveTags(fave.ID, tags); err != nil { slog.Error("set tags error", "error", err) } } http.Redirect(w, r, h.deps.Config.BasePath+"/faves/"+strconv.FormatInt(fave.ID, 10), http.StatusSeeOther) } // handleFaveDetail shows a single fave. func (h *Handler) handleFaveDetail(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { http.NotFound(w, r) return } fave, err := h.deps.Faves.GetByID(id) if err != nil { if errors.Is(err, store.ErrFaveNotFound) { http.NotFound(w, r) return } slog.Error("get fave error", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } // Check access: private faves are only visible to their owner. user := middleware.UserFromContext(r.Context()) if fave.Privacy == "private" && (user == nil || user.ID != fave.UserID) { http.NotFound(w, r) return } // Load tags. tags, err := h.deps.Tags.ForFave(fave.ID) if err != nil { slog.Error("load tags error", "error", err) } fave.Tags = tags h.deps.Renderer.Page(w, r, "fave_detail", render.PageData{ Title: fave.Description, Data: map[string]any{ "Fave": fave, "IsOwner": user != nil && user.ID == fave.UserID, }, }) } // handleFaveEdit shows the edit form for a fave. func (h *Handler) handleFaveEdit(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { http.NotFound(w, r) return } fave, err := h.deps.Faves.GetByID(id) if err != nil { if errors.Is(err, store.ErrFaveNotFound) { http.NotFound(w, r) return } slog.Error("get fave error", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } // Only the owner can edit. if user.ID != fave.UserID { http.Error(w, "Forbidden", http.StatusForbidden) return } tags, _ := h.deps.Tags.ForFave(fave.ID) fave.Tags = tags tagNames := make([]string, len(tags)) for i, t := range tags { tagNames[i] = t.Name } h.deps.Renderer.Page(w, r, "fave_form", render.PageData{ Title: "Rediger favoritt", Data: map[string]any{ "IsNew": false, "Fave": fave, "Description": fave.Description, "URL": fave.URL, "Privacy": fave.Privacy, "Tags": strings.Join(tagNames, ", "), }, }) } // handleFaveUpdate processes the edit form. func (h *Handler) handleFaveUpdate(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { http.NotFound(w, r) return } fave, err := h.deps.Faves.GetByID(id) if err != nil { if errors.Is(err, store.ErrFaveNotFound) { http.NotFound(w, r) return } slog.Error("get fave error", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if user.ID != fave.UserID { http.Error(w, "Forbidden", http.StatusForbidden) return } if err := r.ParseMultipartForm(h.deps.Config.MaxUploadSize); err != nil { h.flash(w, r, "fave_form", "Filen er for stor.", "error", map[string]any{"IsNew": false, "Fave": fave}) return } description := strings.TrimSpace(r.FormValue("description")) url := strings.TrimSpace(r.FormValue("url")) privacy := r.FormValue("privacy") tagStr := r.FormValue("tags") if description == "" { h.flash(w, r, "fave_form", "Beskrivelse er påkrevd.", "error", map[string]any{"IsNew": false, "Fave": fave}) return } if privacy != "public" && privacy != "private" { privacy = fave.Privacy } // Handle image upload (optional on edit). imagePath := fave.ImagePath file, header, err := r.FormFile("image") if err == nil { defer file.Close() result, err := image.Process(file, header, h.deps.Config.UploadDir) if err != nil { slog.Error("image process error", "error", err) h.flash(w, r, "fave_form", "Kunne ikke behandle bildet. Sjekk at filen er et gyldig bilde (JPEG, PNG, GIF eller WebP).", "error", map[string]any{"IsNew": false, "Fave": fave}) return } if fave.ImagePath != "" { if delErr := image.Delete(h.deps.Config.UploadDir, fave.ImagePath); delErr != nil { slog.Error("image delete error", "error", delErr) } } imagePath = result.Filename } // Check if user wants to remove the existing image. if r.FormValue("remove_image") == "1" && imagePath != "" { if delErr := image.Delete(h.deps.Config.UploadDir, imagePath); delErr != nil { slog.Error("image delete error", "error", delErr) } imagePath = "" } if err := h.deps.Faves.Update(id, description, url, imagePath, privacy); err != nil { slog.Error("update fave error", "error", err) h.flash(w, r, "fave_form", "Noe gikk galt. Prøv igjen.", "error", map[string]any{"IsNew": false, "Fave": fave}) return } // Update tags. tags := parseTags(tagStr) if err := h.deps.Tags.SetFaveTags(id, tags); err != nil { slog.Error("set tags error", "error", err) } http.Redirect(w, r, h.deps.Config.BasePath+"/faves/"+strconv.FormatInt(id, 10), http.StatusSeeOther) } // handleFaveDelete deletes a fave. func (h *Handler) handleFaveDelete(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { http.NotFound(w, r) return } fave, err := h.deps.Faves.GetByID(id) if err != nil { if errors.Is(err, store.ErrFaveNotFound) { http.NotFound(w, r) return } slog.Error("get fave error", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if user.ID != fave.UserID { http.Error(w, "Forbidden", http.StatusForbidden) return } if fave.ImagePath != "" { if delErr := image.Delete(h.deps.Config.UploadDir, fave.ImagePath); delErr != nil { slog.Error("image delete error", "error", delErr) } } if err := h.deps.Faves.Delete(id); err != nil { slog.Error("delete fave error", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } // If this was an HTMX request, return empty (the element is removed). if r.Header.Get("HX-Request") == "true" { w.WriteHeader(http.StatusOK) return } http.Redirect(w, r, h.deps.Config.BasePath+"/faves", http.StatusSeeOther) } // handleTagSearch handles tag autocomplete HTMX requests. func (h *Handler) handleTagSearch(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") tags, err := h.deps.Tags.Search(q, 10) if err != nil { slog.Error("tag search error", "error", err) w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := h.deps.Renderer.Partial(w, "tag_suggestions", tags); err != nil { slog.Error("render tag suggestions error", "error", err) } } // handleTagBrowse shows all public faves with a given tag. func (h *Handler) handleTagBrowse(w http.ResponseWriter, r *http.Request) { tagName := r.PathValue("name") page := queryInt(r, "page", 1) offset := (page - 1) * defaultPageSize faves, total, err := h.deps.Faves.ListByTag(tagName, defaultPageSize, offset) if err != nil { slog.Error("list by tag error", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if err := h.deps.Faves.LoadTags(faves); err != nil { slog.Error("load tags error", "error", err) } totalPages := (total + defaultPageSize - 1) / defaultPageSize h.deps.Renderer.Page(w, r, "tag_browse", render.PageData{ Title: "Merkelapp: " + tagName, Data: map[string]any{ "TagName": tagName, "Faves": faves, "Page": page, "TotalPages": totalPages, "Total": total, }, }) } // parseTags splits a comma-separated tag string into individual tag names. func parseTags(s string) []string { parts := strings.Split(s, ",") var tags []string for _, p := range parts { p = strings.TrimSpace(p) if p != "" { tags = append(tags, p) } } return tags } // queryInt parses an integer query parameter with a default. func queryInt(r *http.Request, key string, fallback int) int { v := r.URL.Query().Get(key) n, err := strconv.Atoi(v) if err != nil || n < 1 { return fallback } return n }