diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index fa5b81e..820d8d6 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -73,6 +73,8 @@ func (h *Handler) SetupRouter() *gin.Engine { auth.PATCH("/items/-/read", h.markItemsRead) auth.PATCH("/items/-/unread", h.markItemsUnread) + auth.GET("/search", h.search) + auth.GET("/bookmarks", h.listBookmarks) auth.POST("/bookmarks", h.createBookmark) auth.GET("/bookmarks/:id", h.getBookmark) diff --git a/backend/internal/handler/search.go b/backend/internal/handler/search.go new file mode 100644 index 0000000..493a869 --- /dev/null +++ b/backend/internal/handler/search.go @@ -0,0 +1,42 @@ +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) search(c *gin.Context) { + q := c.Query("q") + if q == "" { + badRequestError(c, "q parameter is required") + return + } + + limit := 10 + if l := c.Query("limit"); l != "" { + parsed, err := strconv.Atoi(l) + if err != nil || parsed < 1 { + badRequestError(c, "invalid limit") + return + } + limit = parsed + } + + feeds, err := h.store.SearchFeeds(q) + if err != nil { + c.JSON(500, gin.H{"error": "search feeds: " + err.Error()}) + return + } + + items, err := h.store.SearchItems(q, limit) + if err != nil { + c.JSON(500, gin.H{"error": "search items: " + err.Error()}) + return + } + + dataResponse(c, gin.H{ + "feeds": feeds, + "items": items, + }) +} diff --git a/backend/internal/store/feed.go b/backend/internal/store/feed.go index 7321cf2..2160c6e 100644 --- a/backend/internal/store/feed.go +++ b/backend/internal/store/feed.go @@ -72,6 +72,36 @@ func (s *Store) CreateFeed(groupID int64, name, link, siteURL, proxy string) (*m return s.GetFeed(id) } +type SearchFeedResult struct { + ID int64 `json:"id"` + Name string `json:"name"` + Link string `json:"link"` + SiteURL string `json:"site_url"` +} + +func (s *Store) SearchFeeds(query string) ([]*SearchFeedResult, error) { + rows, err := s.db.Query(` + SELECT id, name, link, site_url + FROM feeds + WHERE name LIKE :query + ORDER BY id + `, sql.Named("query", "%"+query+"%")) + if err != nil { + return nil, err + } + defer rows.Close() + + feeds := []*SearchFeedResult{} + for rows.Next() { + f := &SearchFeedResult{} + if err := rows.Scan(&f.ID, &f.Name, &f.Link, &f.SiteURL); err != nil { + return nil, err + } + feeds = append(feeds, f) + } + return feeds, rows.Err() +} + // UpdateFeedParams supports partial updates. Only non-nil fields will be updated. // Pointer fields distinguish between "not set" (nil) and "set to zero value" (e.g., &false). type UpdateFeedParams struct { diff --git a/backend/internal/store/item.go b/backend/internal/store/item.go index 1229710..5e863cd 100644 --- a/backend/internal/store/item.go +++ b/backend/internal/store/item.go @@ -177,6 +177,37 @@ func (s *Store) ItemExists(feedID int64, guid string) (bool, error) { return exists, err } +type SearchItemResult struct { + ID int64 `json:"id"` + FeedID int64 `json:"feed_id"` + Title string `json:"title"` + PubDate int64 `json:"pub_date"` +} + +func (s *Store) SearchItems(query string, limit int) ([]*SearchItemResult, error) { + rows, err := s.db.Query(` + SELECT id, feed_id, title, pub_date + FROM items + WHERE title LIKE :query OR content LIKE :query + ORDER BY pub_date DESC + LIMIT :limit + `, sql.Named("query", "%"+query+"%"), sql.Named("limit", limit)) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []*SearchItemResult{} + for rows.Next() { + i := &SearchItemResult{} + if err := rows.Scan(&i.ID, &i.FeedID, &i.Title, &i.PubDate); err != nil { + return nil, err + } + items = append(items, i) + } + return items, rows.Err() +} + // CountItems returns the total count of items matching the filter criteria. func (s *Store) CountItems(params ListItemsParams) (int, error) { query := `SELECT COUNT(*) FROM items` diff --git a/docs/api.md b/docs/api.md index 1805129..89702dc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -582,6 +582,50 @@ Or mark all items: } ``` +## Search + +### Search Feeds and Items + +``` +GET /api/search +``` + +Search across feeds (by name) and items (by title and content) using substring matching. + +**Query Parameters** + +| Parameter | Type | Default | Description | +| --------- | ------ | ------- | ----------------------------- | +| q | string | - | Search query (**required**) | +| limit | number | 10 | Max number of items to return | + +**Response** `200 OK` + +```json +{ + "data": { + "feeds": [ + { + "id": 1, + "name": "Hacker News", + "link": "https://news.ycombinator.com/rss", + "site_url": "https://news.ycombinator.com" + } + ], + "items": [ + { + "id": 1, + "feed_id": 1, + "title": "Article Title", + "pub_date": 1703001600 + } + ] + } +} +``` + +Returns empty arrays when no matches are found. Missing `q` parameter returns `400 Bad Request`. + ## Bookmarks Bookmarks store a snapshot of item content, surviving feed/item deletion. diff --git a/frontend/src/components/search/search-dialog.tsx b/frontend/src/components/search/search-dialog.tsx index 7dec56c..47e2794 100644 --- a/frontend/src/components/search/search-dialog.tsx +++ b/frontend/src/components/search/search-dialog.tsx @@ -1,8 +1,10 @@ import { useState, useEffect } from "react"; -import { FileText, Search } from "lucide-react"; +import { FileText, Loader2, Search, Settings } from "lucide-react"; import { getFaviconUrl } from "@/lib/api/favicon"; +import { searchAPI } from "@/lib/api"; +import type { SearchFeed, SearchItem } from "@/lib/api/types"; import { - CommandDialog, + Command, CommandEmpty, CommandGroup, CommandInput, @@ -10,129 +12,193 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; import { useUIStore, useDataStore } from "@/store"; import { useUrlState } from "@/hooks/use-url-state"; import { formatDate } from "@/lib/utils"; export function SearchDialog() { - const { isSearchOpen, setSearchOpen } = useUIStore(); + const { isSearchOpen, setSearchOpen, setEditFeedOpen } = useUIStore(); + const { getFeedById } = useDataStore(); const { setSelectedFeed, setSelectedArticle } = useUrlState(); - const { feeds, items, getFeedById } = useDataStore(); const [query, setQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [feeds, setFeeds] = useState([]); + const [items, setItems] = useState([]); // Debounce search query useEffect(() => { const timer = setTimeout(() => { setDebouncedQuery(query); - }, 200); + }, 500); return () => clearTimeout(timer); }, [query]); - // Reset query when dialog closes + // Reset state when dialog closes useEffect(() => { if (!isSearchOpen) { setQuery(""); setDebouncedQuery(""); + setFeeds([]); + setItems([]); } }, [isSearchOpen]); - // Filter feeds by debounced query - const filteredFeeds = debouncedQuery - ? feeds.filter((feed) => - feed.name.toLowerCase().includes(debouncedQuery.toLowerCase()), - ) - : []; + // Fetch search results from backend + useEffect(() => { + if (!debouncedQuery) { + setFeeds([]); + setItems([]); + return; + } - // Filter articles by debounced query - const filteredArticles = debouncedQuery - ? items - .filter((item) => - item.title.toLowerCase().includes(debouncedQuery.toLowerCase()), - ) - .slice(0, 10) - : []; + let cancelled = false; + setLoading(true); + + searchAPI + .search(debouncedQuery) + .then((res) => { + if (cancelled) return; + setFeeds(res.data?.feeds ?? []); + setItems(res.data?.items ?? []); + }) + .catch(() => { + if (cancelled) return; + setFeeds([]); + setItems([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [debouncedQuery]); const handleSelectFeed = (feedId: number) => { setSelectedFeed(feedId); setSearchOpen(false); }; + const handleEditFeed = (e: React.MouseEvent, feedId: number) => { + e.stopPropagation(); + const fullFeed = getFeedById(feedId); + if (fullFeed) { + setSearchOpen(false); + setEditFeedOpen(true, fullFeed); + } + }; + const handleSelectArticle = (articleId: number) => { setSelectedArticle(articleId); setSearchOpen(false); }; return ( - - - - No results found. + + + Search + Search feeds and articles + + + + + + {loading && debouncedQuery && ( +
+ +
+ )} - {filteredFeeds.length > 0 && ( - - {filteredFeeds.map((feed) => ( - handleSelectFeed(feed.id)} - className="gap-2" - > - - {feed.name} - - ))} - - )} + {!loading && + debouncedQuery && + feeds.length === 0 && + items.length === 0 && ( + No results found. + )} - {filteredFeeds.length > 0 && filteredArticles.length > 0 && ( - - )} + {feeds.length > 0 && ( + + {feeds.map((feed) => ( + handleSelectFeed(feed.id)} + className="group gap-2" + > + + {feed.name} + + + ))} + + )} - {filteredArticles.length > 0 && ( - - {filteredArticles.map((article) => { - const feed = getFeedById(article.feed_id); - return ( - handleSelectArticle(article.id)} - className="flex-col items-start gap-1" - > -
- - {article.title} -
-
- {feed?.name ?? "Unknown"} - · - {formatDate(article.pub_date)} -
+ {feeds.length > 0 && items.length > 0 && } + + {items.length > 0 && ( + + {items.map((article) => { + const feed = feeds.find((f) => f.id === article.feed_id); + return ( + handleSelectArticle(article.id)} + className="flex-col items-start gap-1" + > +
+ + {article.title} +
+
+ {feed?.name ?? "Unknown"} + · + {formatDate(article.pub_date)} +
+
+ ); + })} +
+ )} + + {!debouncedQuery && !loading && ( + + + + Type to search feeds and articles - ); - })} - - )} - - {!debouncedQuery && ( - - - - Type to search feeds and articles - - - )} -
-
+ + )} + + + + ); } diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index 5936745..c07b063 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -18,6 +18,7 @@ import type { ImportOpmlResponse, BatchCreateFeedsRequest, BatchCreateFeedsResponse, + SearchResponse, } from "./types"; // Session APIs @@ -115,6 +116,14 @@ export const bookmarkAPI = { api.delete>(`/bookmarks/${id}`), }; +// Search APIs +export const searchAPI = { + search: (q: string, limit = 10) => + api.get>( + `/search?q=${encodeURIComponent(q)}&limit=${limit}`, + ), +}; + // OPML APIs const API_BASE = import.meta.env.VITE_API_BASE_URL || "/api"; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 27a69f6..3958cf1 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -125,6 +125,25 @@ export interface BatchCreateFeedsRequest { }>; } +export interface SearchFeed { + id: number; + name: string; + link: string; + site_url: string; +} + +export interface SearchItem { + id: number; + feed_id: number; + title: string; + pub_date: number; +} + +export interface SearchResponse { + feeds: SearchFeed[]; + items: SearchItem[]; +} + export interface BatchCreateFeedsResponse { created: number; failed: number;