416 lines
11 KiB
Go
416 lines
11 KiB
Go
|
|
// 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
|
||
|
|
}
|