feat: implement Deep Forest dark mode, swipe gestures, PWA support, and PostgreSQL Full-Text Search

This commit is contained in:
Michael committed 2026-02-09 09:18:40 +01:00
1 parent 3e468d8885
commit 7e802df2a7
18 files changed
+4800 -128

No files matched your search

+1
View File
@@ -42,4 +42,5 @@ type ArticleRepository interface {
ToggleFavorite(id uuid.UUID) error
GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error)
CountUnread(feedID uuid.UUID) (int, error)
Search(userID uuid.UUID, query string, limit, offset int) ([]*Article, error)
}
+33
View File
@@ -355,3 +355,36 @@ func (h *ArticleHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, articles)
}
// Search handles GET /api/v1/articles/search
func (h *ArticleHandler) Search(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
query := r.URL.Query().Get("q")
if query == "" {
respondJSON(w, http.StatusOK, []*domain.Article{})
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
articles, err := h.articleRepo.Search(userID, query, limit, offset)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to search articles")
return
}
respondJSON(w, http.StatusOK, articles)
}
+85
View File
@@ -411,6 +411,91 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro
return articles, nil
}
// Search performs a full-text search on articles for a specific user.
func (r *ArticleRepository) Search(userID uuid.UUID, query string, limit, offset int) ([]*domain.Article, error) {
ctx := context.Background()
// Use plainto_tsquery or websearch_to_tsquery for natural language search
sql := `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
f.title as feed_title,
ts_rank_cd(a.tsv, websearch_to_tsquery('french', $2)) as rank
FROM articles a
JOIN feeds f ON f.id = a.feed_id
WHERE f.user_id = $1 AND a.tsv @@ websearch_to_tsquery('french', $2)
ORDER BY rank DESC, a.published_at DESC
LIMIT $3 OFFSET $4
`
rows, err := r.pool.Query(ctx, sql, userID, query, limit, offset)
if err != nil {
return nil, fmt.Errorf("searching articles: %w", err)
}
defer rows.Close()
return r.scanArticlesWithRank(rows)
}
// scanArticlesWithRank scans multiple article rows with their rank.
func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Article, error) {
var articles []*domain.Article
for rows.Next() {
var article domain.Article
var url, content, summary, author, imageURL, feedTitle *string
var publishedAt, readAt *time.Time
var rank float32
err := rows.Scan(
&article.ID,
&article.FeedID,
&article.GUID,
&article.Title,
&url,
&content,
&summary,
&author,
&imageURL,
&publishedAt,
&article.IsRead,
&article.IsFavorite,
&readAt,
&article.CreatedAt,
&feedTitle,
&rank,
)
if err != nil {
return nil, fmt.Errorf("scanning article with rank: %w", err)
}
if url != nil {
article.URL = *url
}
if content != nil {
article.Content = *content
}
if summary != nil {
article.Summary = *summary
}
if author != nil {
article.Author = *author
}
if imageURL != nil {
article.ImageURL = *imageURL
}
if feedTitle != nil {
article.FeedTitle = *feedTitle
}
article.PublishedAt = publishedAt
article.ReadAt = readAt
articles = append(articles, &article)
}
return articles, nil
}
// nullString returns nil if string is empty.
func nullString(s string) *string {
if s == "" {