From fd03cdfd3475d8f7a1bc25f13d81808860ce5e65 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Wed, 4 Feb 2026 22:22:57 +0100 Subject: [PATCH] feat(feeds): implement rename feed functionality and ui --- cmd/server/main.go | 1 + internal/handler/feed.go | 46 ++++++++++++++++++++++++++++++++++ internal/service/feed.go | 24 ++++++++++++++++++ web/src/api/feeds.ts | 10 ++++++++ web/src/components/Sidebar.tsx | 38 ++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+) diff --git a/cmd/server/main.go b/cmd/server/main.go index 475e982..2b6abd4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -113,6 +113,7 @@ func main() { r.Post("/import/opml", feedHandler.ImportOPML) r.Get("/export/opml", feedHandler.ExportOPML) r.Get("/{id}", feedHandler.Get) + r.Patch("/{id}", feedHandler.Update) r.Delete("/{id}", feedHandler.Delete) r.Get("/{id}/articles", articleHandler.ListByFeed) r.Post("/{id}/read-all", articleHandler.MarkAllRead) diff --git a/internal/handler/feed.go b/internal/handler/feed.go index 8e27f5d..fdef4c7 100644 --- a/internal/handler/feed.go +++ b/internal/handler/feed.go @@ -182,6 +182,52 @@ func (h *FeedHandler) Delete(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]string{"message": "Feed deleted"}) } +// UpdateRequest represents the request body for updating a feed. +type UpdateRequest struct { + Title string `json:"title"` +} + +// Update handles PATCH /api/v1/feeds/{id} +func (h *FeedHandler) Update(w http.ResponseWriter, r *http.Request) { + userID, err := h.getUserFromRequest(r) + if err != nil { + respondError(w, http.StatusUnauthorized, err.Error()) + return + } + + feedID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid feed ID") + return + } + + var req UpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + + if req.Title == "" { + respondError(w, http.StatusBadRequest, "Title is required") + return + } + + feed, err := h.feedService.UpdateFeed(feedID, userID, req.Title) + if err != nil { + switch { + case errors.Is(err, service.ErrFeedNotFound): + respondError(w, http.StatusNotFound, "Feed not found") + case errors.Is(err, service.ErrUnauthorized): + respondError(w, http.StatusForbidden, "Access denied") + default: + respondError(w, http.StatusInternalServerError, "Failed to update feed") + } + return + } + + respondJSON(w, http.StatusOK, feed) +} + // ImportOPML handles POST /api/v1/feeds/import/opml func (h *FeedHandler) ImportOPML(w http.ResponseWriter, r *http.Request) { userID, err := h.getUserFromRequest(r) diff --git a/internal/service/feed.go b/internal/service/feed.go index 069dae6..6ff6747 100644 --- a/internal/service/feed.go +++ b/internal/service/feed.go @@ -129,6 +129,30 @@ func (s *FeedService) DeleteFeed(feedID, userID uuid.UUID) error { return nil } +// UpdateFeed updates a feed's title. +func (s *FeedService) UpdateFeed(feedID, userID uuid.UUID, title string) (*domain.Feed, error) { + feed, err := s.feedRepo.GetByID(feedID) + if err != nil { + return nil, fmt.Errorf("getting feed: %w", err) + } + if feed == nil { + return nil, ErrFeedNotFound + } + if feed.UserID != userID { + return nil, ErrUnauthorized + } + + feed.Title = title + // UpdatedAt is handled by repo or we can set it here if we strictly follow domain logic, + // but repo.Update sets it to time.Now(). + + if err := s.feedRepo.Update(feed); err != nil { + return nil, fmt.Errorf("updating feed: %w", err) + } + + return feed, nil +} + // ImportOPMLResult contains the result of an OPML import. type ImportOPMLResult struct { Imported int `json:"imported"` diff --git a/web/src/api/feeds.ts b/web/src/api/feeds.ts index 1eabd11..13e47fc 100644 --- a/web/src/api/feeds.ts +++ b/web/src/api/feeds.ts @@ -44,6 +44,16 @@ export const feedsApi = { return handleResponse(response); }, + async update(id: string, title: string): Promise { + const response = await fetch(`${API_BASE}/feeds/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ title }), + }); + return handleResponse(response); + }, + async delete(id: string): Promise { const response = await fetch(`${API_BASE}/feeds/${id}`, { method: 'DELETE', diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 635cd12..91f9871 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -41,6 +41,13 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) { }, }); + const updateFeedMutation = useMutation({ + mutationFn: ({ id, title }: { id: string; title: string }) => feedsApi.update(id, title), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['feeds'] }); + }, + }); + const handleDelete = (e: React.MouseEvent, id: string) => { e.stopPropagation(); if (window.confirm('Voulez-vous vraiment supprimer ce flux ?')) { @@ -48,6 +55,14 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) { } }; + const handleRename = (e: React.MouseEvent, id: string, currentTitle: string) => { + e.stopPropagation(); + const newTitle = window.prompt('Renommer le flux :', currentTitle); + if (newTitle && newTitle.trim() !== '' && newTitle !== currentTitle) { + updateFeedMutation.mutate({ id, title: newTitle }); + } + }; + return ( <>