From b7c138fb50fb907ed1daa1b028313cdc5cf1662b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:03:18 +0000 Subject: [PATCH 1/8] Initial plan From 10c61888988d424626e784731a89de1708a61b6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:16:56 +0000 Subject: [PATCH 2/8] Implement HTMX feed management - backend and frontend refactor Co-authored-by: cubny <172368+cubny@users.noreply.github.com> --- internal/infra/http/api/feed.go | 85 ++++- internal/infra/http/api/fragments_feed.go | 71 ++++ internal/infra/http/api/model.go | 32 +- public/index.html | 19 +- public/js/feeds.js | 376 +++++++++++++++------- public/js/main.js | 165 +++++----- 6 files changed, 529 insertions(+), 219 deletions(-) create mode 100644 internal/infra/http/api/fragments_feed.go diff --git a/internal/infra/http/api/feed.go b/internal/infra/http/api/feed.go index 798d13f..24349a6 100644 --- a/internal/infra/http/api/feed.go +++ b/internal/infra/http/api/feed.go @@ -26,18 +26,46 @@ import ( func (h *Router) addFeed(w http.ResponseWriter, r *http.Request, p httprouter.Params) { command, err := toAddFeedCommand(w, r, p) if err != nil { + if isHTMXRequest(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(renderFeedError("Invalid feed URL"))) + } return } log.Infof("addFeed: command %v", command) - // define t as a new uuid t, err := h.feedService.AddFeed(command) if err != nil { log.WithError(err).Errorf("addFeed: service %s", err) + if isHTMXRequest(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(renderFeedError("Failed to add feed"))) + return + } _ = InternalError(w, "failed to add feed due to server internal error") return } + // For HTMX requests, get all feeds and return the complete list + if isHTMXRequest(r) { + userID := r.Context().Value(cxutil.UserIDKey).(int) + feeds, err := h.feedService.ListFeeds(userID) + if err != nil { + log.WithError(err).Errorf("addFeed: listFeeds %s", err) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(renderFeedError("Failed to refresh feed list"))) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(renderFeedList(feeds))) + return + } + + // JSON response for non-HTMX requests w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(toAddFeedResponse(t)); err != nil { log.WithError(err).Errorf("setFeed: encoder %s", err) @@ -50,10 +78,25 @@ func (h *Router) listFeeds(w http.ResponseWriter, r *http.Request, _ httprouter. resp, err := h.feedService.ListFeeds(userID) if err != nil { log.WithError(err).Errorf("listFeeds: service %s", err) + if isHTMXRequest(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(renderFeedError("Cannot list feeds"))) + return + } _ = InternalError(w, "cannot list feeds") return } + // For HTMX requests, return HTML fragment + if isHTMXRequest(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(renderFeedList(resp))) + return + } + + // JSON response for non-HTMX requests w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(toListFeedResponse(resp)); err != nil { log.WithError(err).Errorf("listFeeds: encoder %s", err) @@ -153,23 +196,63 @@ func (h *Router) unreadFeedItems(w http.ResponseWriter, r *http.Request, p httpr func (h *Router) deleteFeed(w http.ResponseWriter, r *http.Request, p httprouter.Params) { command, err := toDeleteFeedCommand(w, r, p) if err != nil { + if isHTMXRequest(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(renderFeedError("Invalid feed ID"))) + } return } cmdDeleteFeedItems, err := toDeleteFeedItemsCommand(w, r, p) if err != nil { + if isHTMXRequest(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(renderFeedError("Invalid feed ID"))) + } return } if err := h.itemService.DeleteFeedItems(cmdDeleteFeedItems); err != nil { + if isHTMXRequest(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(renderFeedError("Cannot delete feed"))) + return + } _ = InternalError(w, "cannot delete feed") return } if err := h.feedService.DeleteFeed(command); err != nil { + if isHTMXRequest(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(renderFeedError("Cannot delete feed"))) + return + } _ = InternalError(w, "cannot delete feed") return } + // For HTMX requests, return updated feed list + if isHTMXRequest(r) { + userID := r.Context().Value(cxutil.UserIDKey).(int) + feeds, err := h.feedService.ListFeeds(userID) + if err != nil { + log.WithError(err).Errorf("deleteFeed: listFeeds %s", err) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(renderFeedError("Failed to refresh feed list"))) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(renderFeedList(feeds))) + return + } + + // JSON response for non-HTMX requests w.WriteHeader(http.StatusNoContent) } diff --git a/internal/infra/http/api/fragments_feed.go b/internal/infra/http/api/fragments_feed.go new file mode 100644 index 0000000..9c0f89b --- /dev/null +++ b/internal/infra/http/api/fragments_feed.go @@ -0,0 +1,71 @@ +package api + +import ( + "fmt" + "html" + "strings" + + "github.com/cubny/lite-reader/internal/app/feed" +) + +// renderFeedRow generates an HTML fragment for a single feed row +func renderFeedRow(f *feed.Feed) string { + return fmt.Sprintf(`
  • + +
    %s
    +
    %s
    +
  • `, + f.ID, + html.EscapeString(f.Link), + html.EscapeString(f.Title), + formatUnreadCount(f.UnreadCount), + ) +} + +// renderFeedList generates an HTML fragment for the complete feed list +func renderFeedList(feeds []*feed.Feed) string { + var builder strings.Builder + + // Always include the Unread and Starred static feeds first + builder.WriteString(`
  • +
    + +
    Unread
    +
  • +
  • +
    + +
    Starred
    +
  • +`) + + if len(feeds) == 0 { + // No user feeds, but we still have unread/starred + return builder.String() + } + + // Add user feeds + for _, f := range feeds { + builder.WriteString(renderFeedRow(f)) + builder.WriteString("\n") + } + return builder.String() +} + +// renderFeedError returns an error message fragment +func renderFeedError(msg string) string { + errorStyle := "padding: 10px; color: #d9534f; background: #f8d7da; " + + "border: 1px solid #f5c6cb; border-radius: 4px; margin: 10px;" + return fmt.Sprintf(`
    + %s +
    `, errorStyle, html.EscapeString(msg)) +} + +// formatUnreadCount formats the unread count for display +func formatUnreadCount(count int) string { + if count > 0 { + return fmt.Sprintf("%d", count) + } + return "" +} + diff --git a/internal/infra/http/api/model.go b/internal/infra/http/api/model.go index f846838..a4293b3 100644 --- a/internal/infra/http/api/model.go +++ b/internal/infra/http/api/model.go @@ -17,10 +17,16 @@ import ( "github.com/cubny/lite-reader/internal/infra/http/api/cxutil" ) +const ( + contentTypeFormURLEncoded = "application/x-www-form-urlencoded" + contentTypeMultipartForm = "multipart/form-data" +) + type AddFeedRequest struct { URL string `json:"url"` } + func (r *AddFeedRequest) Validate() error { if _, err := url.ParseRequestURI(r.URL); err != nil { return err @@ -96,10 +102,24 @@ func toGetItemsResponse(items []*item.Item) []*ItemResponse { func toAddFeedCommand(w http.ResponseWriter, r *http.Request, _ httprouter.Params) (*feed.AddFeedCommand, error) { request := &AddFeedRequest{} - if err := json.NewDecoder(r.Body).Decode(request); err != nil { - log.WithError(err).Errorf("toAddFeedCommand: decoder %s", err) - _ = BadRequest(w, "cannot decode request body") - return nil, err + + // Check if this is a form submission (HTMX) or JSON request + contentType := r.Header.Get("Content-Type") + if isHTMXRequest(r) || contentType == contentTypeFormURLEncoded || contentType == contentTypeMultipartForm { + // Parse form data + if err := r.ParseForm(); err != nil { + log.WithError(err).Error("addFeed: failed to parse form data") + _ = BadRequest(w, "invalid request body") + return nil, err + } + request.URL = r.FormValue("url") + } else { + // Parse JSON + if err := json.NewDecoder(r.Body).Decode(request); err != nil { + log.WithError(err).Errorf("toAddFeedCommand: decoder %s", err) + _ = BadRequest(w, "cannot decode request body") + return nil, err + } } if err := request.Validate(); err != nil { @@ -245,7 +265,7 @@ func toLoginCommand(w http.ResponseWriter, r *http.Request, _ httprouter.Params) // Check if this is a form submission (HTMX) or JSON request contentType := r.Header.Get("Content-Type") - if isHTMXRequest(r) || contentType == "application/x-www-form-urlencoded" || contentType == "multipart/form-data" { + if isHTMXRequest(r) || contentType == contentTypeFormURLEncoded || contentType == contentTypeMultipartForm { // Parse form data if err := r.ParseForm(); err != nil { log.WithError(err).Error("login: failed to parse form data") @@ -292,7 +312,7 @@ func toSignupCommand(w http.ResponseWriter, r *http.Request, _ httprouter.Params // Check if this is a form submission (HTMX) or JSON request contentType := r.Header.Get("Content-Type") - if isHTMXRequest(r) || contentType == "application/x-www-form-urlencoded" || contentType == "multipart/form-data" { + if isHTMXRequest(r) || contentType == contentTypeFormURLEncoded || contentType == contentTypeMultipartForm { // Parse form data if err := r.ParseForm(); err != nil { log.WithError(err).Error("signup: failed to parse form data") diff --git a/public/index.html b/public/index.html index 952429f..229edfd 100755 --- a/public/index.html +++ b/public/index.html @@ -13,13 +13,25 @@
    - + Feed - + +
    -