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(`