mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat(feeds): implement rename feed functionality and ui
This commit is contained in:
1 parent
6e4915b895
commit
fd03cdfd34
5 files changed
+119
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user