feat(feeds): implement rename feed functionality and ui

This commit is contained in:
Antigravity Agent committed 2026-02-04 22:22:57 +01:00
1 parent 6e4915b895
commit fd03cdfd34
5 files changed
+119

No files matched your search

+1
View File
@@ -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)
+46
View File
@@ -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)
+24
View File
@@ -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"`
+10
View File
@@ -44,6 +44,16 @@ export const feedsApi = {
return handleResponse<Feed>(response);
},
async update(id: string, title: string): Promise<Feed> {
const response = await fetch(`${API_BASE}/feeds/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title }),
});
return handleResponse<Feed>(response);
},
async delete(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/feeds/${id}`, {
method: 'DELETE',
+38
View File
@@ -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 (
<>
<aside className="w-72 bg-carbon-dark/50 backdrop-blur-xl border-r border-white/5 h-screen flex flex-col z-20">
@@ -96,6 +111,20 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
<div>
<h3 className="px-4 text-[10px] uppercase tracking-[0.3em] font-black text-paper-muted/30 mb-4">Bibliothèque</h3>
<div className="space-y-1">
<div className="relative group/item mb-2">
<button
onClick={() => onSelectFeed(null)}
className={`w-full flex items-center justify-between px-4 py-2.5 rounded-lg transition-all duration-300 text-left ${selectedFeedId === null
? 'bg-gold/10 text-gold-bright'
: 'text-paper-muted hover:text-gold hover:bg-gold/5'
}`}
>
<span className="text-sm font-medium truncate flex items-center">
<span className={`w-1.5 h-1.5 rounded-full bg-gold/40 mr-3 transition-opacity ${selectedFeedId === null ? 'opacity-100' : 'opacity-0 group-hover/item:opacity-100'}`}></span>
Tous les articles
</span>
</button>
</div>
{feeds?.map((feed) => (
<div key={feed.id} className="relative group/item">
<button
@@ -116,6 +145,15 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
{feed.unread_count}
</span>
)}
<button
onClick={(e) => handleRename(e, feed.id, feed.title)}
className="opacity-0 group-hover/item:opacity-50 hover:!opacity-100 p-1 text-gold transition-all duration-300 transform scale-75 hover:scale-100"
title="Renommer le flux"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={(e) => handleDelete(e, feed.id)}
className="opacity-0 group-hover/item:opacity-50 hover:!opacity-100 p-1 text-red-400 transition-all duration-300 transform scale-75 hover:scale-100"