refactor: implement new UI/UX architecture with redesigned desktop and mobile layouts, CSS-in-CSS modularization, and updated components.
This commit is contained in:
+165
-14
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user