mirror of
https://github.com/0x2E/fusion.git
synced 2026-05-19 18:30:35 +00:00
fix search
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user