refactor: implement new UI/UX architecture with redesigned desktop and mobile layouts, CSS-in-CSS modularization, and updated components.

This commit is contained in:
2026-08-29 00:53:57 -07:00
parent dfbf646332
commit cd98c0120e
47 changed files with 8039 additions and 7236 deletions
+129 -4
View File
@@ -12,6 +12,7 @@ import (
"sync"
"time"
aiActions "git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/ai"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler"
@@ -222,11 +223,12 @@ func (h *Handler) CreateFeed(w http.ResponseWriter, r *http.Request) {
if req.Title != nil && strings.TrimSpace(*req.Title) != "" {
title = strings.TrimSpace(*req.Title)
}
if title == "" {
title = req.FeedURL
iconURL := ""
if req.IconURL != nil && strings.TrimSpace(*req.IconURL) != "" {
iconURL = strings.TrimSpace(*req.IconURL)
}
feed, err := h.db.CreateFeed(title, req.FeedURL, res.SiteURL, req.CategoryID)
feed, err := h.db.CreateFeed(title, req.FeedURL, res.SiteURL, iconURL, req.CategoryID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -426,7 +428,7 @@ func (h *Handler) ImportOPML(w http.ResponseWriter, r *http.Request) {
title = item.FeedURL
}
_, err := h.db.CreateFeed(title, item.FeedURL, item.SiteURL, catID)
_, err := h.db.CreateFeed(title, item.FeedURL, item.SiteURL, "", catID)
if err == nil {
createdCount++
}
@@ -636,6 +638,70 @@ func (h *Handler) ScrapeEntry(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, http.StatusOK, updatedEntry)
}
// ToggleShareEntry toggles public sharing for an entry and returns the updated state
func (h *Handler) ToggleShareEntry(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
shareUUID, err := h.db.ToggleShareEntry(id)
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
entry, _ := h.db.GetEntry(id)
h.writeJSON(w, http.StatusOK, map[string]interface{}{
"shared": shareUUID != nil,
"share_uuid": shareUUID,
"entry": entry,
})
}
// GenerateAISummary on-demand generates an AI summary for a single article
func (h *Handler) GenerateAISummary(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
entry, err := h.db.GetEntry(id)
if err != nil {
h.writeError(w, http.StatusNotFound, "entry not found")
return
}
cfg := aiActions.LoadConfig(h.db)
if !cfg.Enabled {
h.writeError(w, http.StatusBadRequest, "AI is not enabled in settings")
return
}
if cfg.APIKey == "" && cfg.Provider != "ollama" {
h.writeError(w, http.StatusBadRequest, "AI API key is not configured in settings")
return
}
prompt := "Summarize the key points of this article clearly with 3 concise bullet points and a brief takeaway."
resultText, err := h.aiClient.GenerateEnrichment(r.Context(), cfg, prompt, *entry)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "AI generation failed: "+err.Error())
return
}
variant := models.EntryVariant{
EntryID: entry.ID,
VariantKey: "ai_summary",
ContentType: "text",
TextValue: resultText,
Metadata: map[string]interface{}{
"model": cfg.Model,
},
}
if err := h.db.SetVariant(variant); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save AI summary: "+err.Error())
return
}
updated, err := h.db.GetEntry(id)
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
h.writeJSON(w, http.StatusOK, updated)
}
// Rules API Handlers
func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) {
rules, err := h.db.ListRules()
@@ -967,3 +1033,62 @@ func (h *Handler) TestAIConnection(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, http.StatusOK, res)
}
// Search History Handlers
func (h *Handler) GetSearchHistory(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
limitStr := r.URL.Query().Get("limit")
limit := 10
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
history, err := h.db.GetSearchHistory(q, limit)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to get search history: "+err.Error())
return
}
if history == nil {
history = []models.SearchHistoryItem{}
}
h.writeJSON(w, http.StatusOK, history)
}
func (h *Handler) RecordSearch(w http.ResponseWriter, r *http.Request) {
var req struct {
Keyword string `json:"keyword"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if strings.TrimSpace(req.Keyword) == "" {
h.writeJSON(w, http.StatusOK, map[string]string{"status": "ignored"})
return
}
if err := h.db.RecordSearch(req.Keyword); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to record search: "+err.Error())
return
}
h.writeJSON(w, http.StatusOK, map[string]string{"status": "recorded"})
}
func (h *Handler) DeleteSearchHistory(w http.ResponseWriter, r *http.Request) {
keyword := r.URL.Query().Get("keyword")
if keyword != "" {
if err := h.db.DeleteSearchHistory(keyword); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete search history: "+err.Error())
return
}
} else {
if err := h.db.ClearSearchHistory(); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to clear search history: "+err.Error())
return
}
}
h.writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
+7
View File
@@ -65,6 +65,8 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
r.Get("/entries/{id}", h.GetEntry)
r.Put("/entries/{id}/read", h.UpdateEntryRead)
r.Put("/entries/{id}/star", h.UpdateEntryStar)
r.Post("/entries/{id}/share", h.ToggleShareEntry)
r.Post("/entries/{id}/ai-summary", h.GenerateAISummary)
r.Post("/entries/mark-all-read", h.MarkAllRead)
r.Post("/entries/{id}/scrape", h.ScrapeEntry)
r.Get("/entries/{id}/variants", h.ListEntryVariants)
@@ -82,6 +84,11 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
r.Put("/settings/ai", h.UpdateAISettings)
r.Post("/settings/ai/test", h.TestAIConnection)
// Search History
r.Get("/search/history", h.GetSearchHistory)
r.Post("/search/history", h.RecordSearch)
r.Delete("/search/history", h.DeleteSearchHistory)
// OPML
r.Post("/opml/import", h.ImportOPML)
r.Get("/opml/export", h.ExportOPML)
+165 -14
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
@@ -145,12 +146,27 @@ func (d *DB) migrate() error {
value TEXT NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS search_history (
keyword TEXT PRIMARY KEY,
last_searched_at DATETIME NOT NULL,
search_count INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_search_history_time ON search_history(last_searched_at DESC);
`
if _, err := d.Exec(schema); err != nil {
return err
}
// Migration for timeout_seconds column if older DB exists
_, _ = d.Exec("ALTER TABLE feeds ADD COLUMN timeout_seconds INTEGER DEFAULT 10")
// Migration for icon_url column if older DB exists
_, _ = d.Exec("ALTER TABLE feeds ADD COLUMN icon_url TEXT DEFAULT ''")
// Backfill existing feeds with favicons if empty
_, _ = d.Exec(`
UPDATE feeds
SET icon_url = 'https://www.google.com/s2/favicons?domain=' || REPLACE(REPLACE(REPLACE(site_url, 'https://', ''), 'http://', ''), '/', '') || '&sz=64'
WHERE (icon_url IS NULL OR icon_url = '') AND site_url IS NOT NULL AND site_url != ''
`)
// Migration for rules table schema if older table exists
var testCol string
@@ -294,11 +310,30 @@ func (d *DB) DeleteCategory(id string) error {
return err
}
// ResolveFavicon generates a high-quality favicon URL from a website or feed URL
func ResolveFavicon(siteURL, feedURL string) string {
targetURL := strings.TrimSpace(siteURL)
if targetURL == "" {
targetURL = strings.TrimSpace(feedURL)
}
if targetURL == "" {
return ""
}
if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") {
targetURL = "https://" + targetURL
}
u, err := url.Parse(targetURL)
if err != nil || u.Host == "" {
return ""
}
return fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=64", u.Host)
}
// Feed operations
func (d *DB) ListFeeds() ([]models.Feed, error) {
query := `
SELECT
f.id, f.title, f.feed_url, f.site_url, f.category_id,
f.id, f.title, f.feed_url, f.site_url, COALESCE(f.icon_url, '') as icon_url, f.category_id,
COALESCE(c.title, '') as category_title,
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
@@ -321,10 +356,10 @@ func (d *DB) ListFeeds() ([]models.Feed, error) {
var feeds []models.Feed
for rows.Next() {
var f models.Feed
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
var lastFetched sql.NullTime
if err := rows.Scan(
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.CategoryTitle,
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID, &f.CategoryTitle,
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
); err != nil {
@@ -337,6 +372,12 @@ func (d *DB) ListFeeds() ([]models.Feed, error) {
if siteURL.Valid {
f.SiteURL = siteURL.String
}
if iconURL.Valid {
f.IconURL = iconURL.String
}
if f.IconURL == "" {
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
}
if etag.Valid {
f.ETag = etag.String
}
@@ -358,7 +399,7 @@ func (d *DB) ListFeeds() ([]models.Feed, error) {
func (d *DB) GetFeed(id string) (*models.Feed, error) {
query := `
SELECT
f.id, f.title, f.feed_url, f.site_url, f.category_id,
f.id, f.title, f.feed_url, f.site_url, COALESCE(f.icon_url, '') as icon_url, f.category_id,
COALESCE(c.title, '') as category_title,
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
@@ -371,10 +412,10 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) {
GROUP BY f.id
`
var f models.Feed
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
var lastFetched sql.NullTime
err := d.QueryRow(query, id).Scan(
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.CategoryTitle,
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID, &f.CategoryTitle,
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
)
@@ -388,6 +429,12 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) {
if siteURL.Valid {
f.SiteURL = siteURL.String
}
if iconURL.Valid {
f.IconURL = iconURL.String
}
if f.IconURL == "" {
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
}
if etag.Valid {
f.ETag = etag.String
}
@@ -406,13 +453,13 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) {
func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) {
var f models.Feed
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
var lastFetched sql.NullTime
err := d.QueryRow(
"SELECT id, title, feed_url, site_url, category_id, is_virtual_digest, refresh_interval_minutes, COALESCE(timeout_seconds, 10), etag, last_modified, last_fetched_at, fetch_error, created_at FROM feeds WHERE feed_url = ?",
"SELECT id, title, feed_url, site_url, COALESCE(icon_url, ''), category_id, is_virtual_digest, refresh_interval_minutes, COALESCE(timeout_seconds, 10), etag, last_modified, last_fetched_at, fetch_error, created_at FROM feeds WHERE feed_url = ?",
feedURL,
).Scan(
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID,
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID,
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds, &etag, &lastMod,
&lastFetched, &fetchErr, &f.CreatedAt,
)
@@ -426,16 +473,26 @@ func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) {
if siteURL.Valid {
f.SiteURL = siteURL.String
}
if iconURL.Valid {
f.IconURL = iconURL.String
}
if f.IconURL == "" {
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
}
return &f, nil
}
func (d *DB) CreateFeed(title, feedURL, siteURL string, categoryID *string) (*models.Feed, error) {
func (d *DB) CreateFeed(title, feedURL, siteURL, iconURL string, categoryID *string) (*models.Feed, error) {
now := time.Now().UTC()
if iconURL == "" {
iconURL = ResolveFavicon(siteURL, feedURL)
}
f := &models.Feed{
ID: uuid.New().String(),
Title: title,
FeedURL: feedURL,
SiteURL: siteURL,
IconURL: iconURL,
CategoryID: categoryID,
RefreshIntervalMinutes: 30,
TimeoutSeconds: 10,
@@ -446,8 +503,8 @@ func (d *DB) CreateFeed(title, feedURL, siteURL string, categoryID *string) (*mo
catID = *categoryID
}
_, err := d.Exec(
"INSERT INTO feeds (id, title, feed_url, site_url, category_id, refresh_interval_minutes, timeout_seconds, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
f.ID, f.Title, f.FeedURL, f.SiteURL, catID, f.RefreshIntervalMinutes, f.TimeoutSeconds, f.CreatedAt,
"INSERT INTO feeds (id, title, feed_url, site_url, icon_url, category_id, refresh_interval_minutes, timeout_seconds, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
f.ID, f.Title, f.FeedURL, f.SiteURL, f.IconURL, catID, f.RefreshIntervalMinutes, f.TimeoutSeconds, f.CreatedAt,
)
if err != nil {
return nil, err
@@ -466,6 +523,11 @@ func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error {
title = *req.Title
}
iconURL := feed.IconURL
if req.IconURL != nil {
iconURL = strings.TrimSpace(*req.IconURL)
}
catID := feed.CategoryID
if req.CategoryID != nil {
if *req.CategoryID == "" {
@@ -492,9 +554,9 @@ func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error {
_, err = d.Exec(`
UPDATE feeds
SET title = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ?
SET title = ?, icon_url = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ?
WHERE id = ?
`, title, cID, interval, timeout, id)
`, title, iconURL, cID, interval, timeout, id)
return err
}
@@ -636,6 +698,8 @@ func (d *DB) ListEntries(filter EntryFilter) ([]models.FeedEntry, int, error) {
whereClauses = append(whereClauses, "e.is_read = 0")
case "starred":
whereClauses = append(whereClauses, "e.is_starred = 1")
case "shared":
whereClauses = append(whereClauses, "e.share_uuid IS NOT NULL AND e.share_uuid != ''")
}
if filter.FeedID != "" {
@@ -760,6 +824,25 @@ func (d *DB) UpdateEntryStarred(id string, isStarred bool) error {
return err
}
func (d *DB) ToggleShareEntry(id string) (*string, error) {
entry, err := d.GetEntry(id)
if err != nil {
return nil, err
}
if entry.ShareUUID != nil && *entry.ShareUUID != "" {
// Unshare
_, err := d.Exec("UPDATE feed_entries SET share_uuid = NULL WHERE id = ?", id)
return nil, err
}
// Share
newUUID := uuid.New().String()
_, err = d.Exec("UPDATE feed_entries SET share_uuid = ? WHERE id = ?", newUUID, id)
if err != nil {
return nil, err
}
return &newUUID, nil
}
func (d *DB) MarkAllRead(feedID, categoryID *string) error {
if feedID != nil && *feedID != "" {
_, err := d.Exec("UPDATE feed_entries SET is_read = 1 WHERE feed_id = ?", *feedID)
@@ -1066,6 +1149,7 @@ func (d *DB) GetStats() (*models.Stats, error) {
if err != nil {
return nil, err
}
_ = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE share_uuid IS NOT NULL AND share_uuid != ''").Scan(&s.TotalShared)
err = d.QueryRow("SELECT COUNT(*) FROM categories").Scan(&s.TotalCategories)
if err != nil {
return nil, err
@@ -1116,3 +1200,70 @@ func (d *DB) GetSettingsMap() (map[string]string, error) {
}
return res, nil
}
// Search History operations
func (d *DB) RecordSearch(keyword string) error {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return nil
}
now := time.Now().UTC()
_, err := d.Exec(`
INSERT INTO search_history (keyword, last_searched_at, search_count)
VALUES (?, ?, 1)
ON CONFLICT(keyword) DO UPDATE SET
last_searched_at = excluded.last_searched_at,
search_count = search_history.search_count + 1
`, keyword, now)
return err
}
func (d *DB) GetSearchHistory(query string, limit int) ([]models.SearchHistoryItem, error) {
if limit <= 0 {
limit = 10
}
var rows *sql.Rows
var err error
if query != "" {
rows, err = d.Query(`
SELECT keyword, last_searched_at, search_count
FROM search_history
WHERE keyword LIKE ?
ORDER BY last_searched_at DESC
LIMIT ?
`, "%"+query+"%", limit)
} else {
rows, err = d.Query(`
SELECT keyword, last_searched_at, search_count
FROM search_history
ORDER BY last_searched_at DESC
LIMIT ?
`, limit)
}
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var items []models.SearchHistoryItem
for rows.Next() {
var item models.SearchHistoryItem
if err := rows.Scan(&item.Keyword, &item.LastSearchedAt, &item.SearchCount); err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
func (d *DB) DeleteSearchHistory(keyword string) error {
_, err := d.Exec("DELETE FROM search_history WHERE keyword = ?", keyword)
return err
}
func (d *DB) ClearSearchHistory() error {
_, err := d.Exec("DELETE FROM search_history")
return err
}
+1 -1
View File
@@ -43,7 +43,7 @@ func TestDatabaseOperations(t *testing.T) {
}
// 3. Test Feed
feed, err := database.CreateFeed("Hacker News", "https://news.ycombinator.com/rss", "https://news.ycombinator.com", &cat.ID)
feed, err := database.CreateFeed("Hacker News", "https://news.ycombinator.com/rss", "https://news.ycombinator.com", "", &cat.ID)
if err != nil {
t.Fatalf("CreateFeed failed: %v", err)
}
+11
View File
@@ -13,6 +13,13 @@ type User struct {
UpdatedAt time.Time `json:"updated_at"`
}
// SearchHistoryItem tracks user search history for autocomplete
type SearchHistoryItem struct {
Keyword string `json:"keyword"`
LastSearchedAt time.Time `json:"last_searched_at"`
SearchCount int `json:"search_count"`
}
// Category represents a folder/tag grouping for feeds
type Category struct {
ID string `json:"id"`
@@ -29,6 +36,7 @@ type Feed struct {
Title string `json:"title"`
FeedURL string `json:"feed_url"`
SiteURL string `json:"site_url"`
IconURL string `json:"icon_url,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
CategoryTitle string `json:"category_title,omitempty"`
IsVirtualDigest bool `json:"is_virtual_digest"`
@@ -108,6 +116,7 @@ type Stats struct {
TotalFeeds int `json:"total_feeds"`
TotalUnread int `json:"total_unread"`
TotalStarred int `json:"total_starred"`
TotalShared int `json:"total_shared"`
TotalCategories int `json:"total_categories"`
TotalRules int `json:"total_rules"`
}
@@ -129,6 +138,7 @@ type CreateFeedRequest struct {
FeedURL string `json:"feed_url"`
CategoryID *string `json:"category_id,omitempty"`
Title *string `json:"title,omitempty"`
IconURL *string `json:"icon_url,omitempty"`
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
}
@@ -137,6 +147,7 @@ type CreateFeedRequest struct {
type UpdateFeedRequest struct {
Title *string `json:"title,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
IconURL *string `json:"icon_url,omitempty"`
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
}