mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-17 20:27:46 +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.Post("/import/opml", feedHandler.ImportOPML)
|
||||||
r.Get("/export/opml", feedHandler.ExportOPML)
|
r.Get("/export/opml", feedHandler.ExportOPML)
|
||||||
r.Get("/{id}", feedHandler.Get)
|
r.Get("/{id}", feedHandler.Get)
|
||||||
|
r.Patch("/{id}", feedHandler.Update)
|
||||||
r.Delete("/{id}", feedHandler.Delete)
|
r.Delete("/{id}", feedHandler.Delete)
|
||||||
r.Get("/{id}/articles", articleHandler.ListByFeed)
|
r.Get("/{id}/articles", articleHandler.ListByFeed)
|
||||||
r.Post("/{id}/read-all", articleHandler.MarkAllRead)
|
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"})
|
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
|
// ImportOPML handles POST /api/v1/feeds/import/opml
|
||||||
func (h *FeedHandler) ImportOPML(w http.ResponseWriter, r *http.Request) {
|
func (h *FeedHandler) ImportOPML(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, err := h.getUserFromRequest(r)
|
userID, err := h.getUserFromRequest(r)
|
||||||
|
|||||||
@@ -129,6 +129,30 @@ func (s *FeedService) DeleteFeed(feedID, userID uuid.UUID) error {
|
|||||||
return nil
|
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.
|
// ImportOPMLResult contains the result of an OPML import.
|
||||||
type ImportOPMLResult struct {
|
type ImportOPMLResult struct {
|
||||||
Imported int `json:"imported"`
|
Imported int `json:"imported"`
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ export const feedsApi = {
|
|||||||
return handleResponse<Feed>(response);
|
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> {
|
async delete(id: string): Promise<void> {
|
||||||
const response = await fetch(`${API_BASE}/feeds/${id}`, {
|
const response = await fetch(`${API_BASE}/feeds/${id}`, {
|
||||||
method: 'DELETE',
|
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) => {
|
const handleDelete = (e: React.MouseEvent, id: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (window.confirm('Voulez-vous vraiment supprimer ce flux ?')) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside className="w-72 bg-carbon-dark/50 backdrop-blur-xl border-r border-white/5 h-screen flex flex-col z-20">
|
<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>
|
<div>
|
||||||
<h3 className="px-4 text-[10px] uppercase tracking-[0.3em] font-black text-paper-muted/30 mb-4">Bibliothèque</h3>
|
<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="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) => (
|
{feeds?.map((feed) => (
|
||||||
<div key={feed.id} className="relative group/item">
|
<div key={feed.id} className="relative group/item">
|
||||||
<button
|
<button
|
||||||
@@ -116,6 +145,15 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
|
|||||||
{feed.unread_count}
|
{feed.unread_count}
|
||||||
</span>
|
</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
|
<button
|
||||||
onClick={(e) => handleDelete(e, feed.id)}
|
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"
|
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