fix search

This commit is contained in:
Yuan
2026-02-07 23:28:07 +08:00
parent 55e0a2bde0
commit 1ba2d9885e
8 changed files with 329 additions and 86 deletions
+2
View File
@@ -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)
+42
View File
@@ -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,
})
}
+30
View File
@@ -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 {
+31
View File
@@ -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`
+44
View File
@@ -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.
+152 -86
View File
@@ -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<SearchFeed[]>([]);
const [items, setItems] = useState<SearchItem[]>([]);
// 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 (
<CommandDialog open={isSearchOpen} onOpenChange={setSearchOpen}>
<CommandInput
placeholder="Search feeds and articles..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<Dialog open={isSearchOpen} onOpenChange={setSearchOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Search</DialogTitle>
<DialogDescription>Search feeds and articles</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0" showCloseButton={false}>
<Command
shouldFilter={false}
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
<CommandInput
placeholder="Search feeds and articles..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
{loading && debouncedQuery && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{filteredFeeds.length > 0 && (
<CommandGroup heading="Feeds">
{filteredFeeds.map((feed) => (
<CommandItem
key={`feed-${feed.id}`}
value={`feed-${feed.name}`}
onSelect={() => handleSelectFeed(feed.id)}
className="gap-2"
>
<img
src={getFaviconUrl(feed.link, feed.site_url)}
alt=""
className="h-4 w-4 shrink-0 rounded-sm"
loading="lazy"
/>
<span>{feed.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
{!loading &&
debouncedQuery &&
feeds.length === 0 &&
items.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{filteredFeeds.length > 0 && filteredArticles.length > 0 && (
<CommandSeparator />
)}
{feeds.length > 0 && (
<CommandGroup heading="Feeds">
{feeds.map((feed) => (
<CommandItem
key={`feed-${feed.id}`}
onSelect={() => handleSelectFeed(feed.id)}
className="group gap-2"
>
<img
src={getFaviconUrl(feed.link, feed.site_url)}
alt=""
className="h-4 w-4 shrink-0 rounded-sm"
loading="lazy"
/>
<span className="flex-1 truncate">{feed.name}</span>
<Button
variant="outline"
size="icon"
className="h-6 w-6"
onClick={(e) => handleEditFeed(e, feed.id)}
>
<Settings className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</CommandItem>
))}
</CommandGroup>
)}
{filteredArticles.length > 0 && (
<CommandGroup heading="Articles">
{filteredArticles.map((article) => {
const feed = getFeedById(article.feed_id);
return (
<CommandItem
key={`article-${article.id}`}
value={`article-${article.title}`}
onSelect={() => handleSelectArticle(article.id)}
className="flex-col items-start gap-1"
>
<div className="flex w-full items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{article.title}</span>
</div>
<div className="flex w-full items-center gap-2 pl-6 text-xs text-muted-foreground">
<span>{feed?.name ?? "Unknown"}</span>
<span>·</span>
<span>{formatDate(article.pub_date)}</span>
</div>
{feeds.length > 0 && items.length > 0 && <CommandSeparator />}
{items.length > 0 && (
<CommandGroup heading="Articles">
{items.map((article) => {
const feed = feeds.find((f) => f.id === article.feed_id);
return (
<CommandItem
key={`article-${article.id}`}
onSelect={() => handleSelectArticle(article.id)}
className="flex-col items-start gap-1"
>
<div className="flex w-full items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{article.title}</span>
</div>
<div className="flex w-full items-center gap-2 pl-6 text-xs text-muted-foreground">
<span>{feed?.name ?? "Unknown"}</span>
<span>·</span>
<span>{formatDate(article.pub_date)}</span>
</div>
</CommandItem>
);
})}
</CommandGroup>
)}
{!debouncedQuery && !loading && (
<CommandGroup heading="Quick Actions">
<CommandItem className="gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<span>Type to search feeds and articles</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{!debouncedQuery && (
<CommandGroup heading="Quick Actions">
<CommandItem className="gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<span>Type to search feeds and articles</span>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</CommandDialog>
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
+9
View File
@@ -18,6 +18,7 @@ import type {
ImportOpmlResponse,
BatchCreateFeedsRequest,
BatchCreateFeedsResponse,
SearchResponse,
} from "./types";
// Session APIs
@@ -115,6 +116,14 @@ export const bookmarkAPI = {
api.delete<APIResponse<{ message: string }>>(`/bookmarks/${id}`),
};
// Search APIs
export const searchAPI = {
search: (q: string, limit = 10) =>
api.get<APIResponse<SearchResponse>>(
`/search?q=${encodeURIComponent(q)}&limit=${limit}`,
),
};
// OPML APIs
const API_BASE = import.meta.env.VITE_API_BASE_URL || "/api";
+19
View File
@@ -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;