Implement recommendation caching and unify dashboard UI/UXUnverified
465d821 parent: 0b50627 modified
docs/specs.md +3 -3 | @@ -765,7 +765,7 @@ New users with <5 subscriptions get a fallback strategy: | ||
| 765 | 765 | |
| 766 | 766 | ### 7.10 Clustering Engine (Cron) |
| 767 | 767 | |
| 768 | -A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 10m): | |
| 768 | +A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 1h): | |
| 769 | 769 | |
| 770 | 770 | 1. **Compute feed embeddings**: Embed new feed descriptions via embedding API into `feed_embeddings` table (skipped if no embedder configured) |
| 771 | 771 | 2. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + embedding cosine similarity) |
| @@ -907,7 +907,7 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee | ||
| 907 | 907 | | Route | Method | Description | |
| 908 | 908 | | ------------------------------ | ------ | ------------------------------------------------------------------- | |
| 909 | 909 | | `/` | GET | Landing page / auth redirect | |
| 910 | -| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar | | |
| 910 | +| `/dashboard` | GET | Main dashboard: article recs, unread articles, trending, people, feeds | | |
| 911 | 911 | | `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | |
| 912 | 912 | | `/feeds/list` | GET | Feed list fragment (htmx partial) | |
| 913 | 913 | | `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) | |
| @@ -1092,7 +1092,7 @@ Browser ──GET /articles──► Server | ||
| 1092 | 1092 | ### 11.3 Recommendations |
| 1093 | 1093 | |
| 1094 | 1094 | ``` |
| 1095 | -Cron (every 10m) ──► Cluster Engine | |
| 1095 | +Cron (every 1h) ──► Cluster Engine | |
| 1096 | 1096 | │ |
| 1097 | 1097 | ├─► Compute feed similarity |
| 1098 | 1098 | ├─► Compute user similarity |
| @@ -765,7 +765,7 @@ New users with <5 subscriptions get a fallback strategy: | |||
| 765 | 765 | ||
| 766 | ### 7.10 Clustering Engine (Cron) | 766 | ### 7.10 Clustering Engine (Cron) |
| 767 | 767 | ||
| 768 | -A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 10m): | 768 | +A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 1h): |
| 769 | 769 | ||
| 770 | 1. **Compute feed embeddings**: Embed new feed descriptions via embedding API into `feed_embeddings` table (skipped if no embedder configured) | 770 | 1. **Compute feed embeddings**: Embed new feed descriptions via embedding API into `feed_embeddings` table (skipped if no embedder configured) |
| 771 | 2. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + embedding cosine similarity) | 771 | 2. **Compute feed similarity**: Batch-update `feed_similarity` table (Jaccard over subscriber sets + embedding cosine similarity) |
| @@ -907,7 +907,7 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee | |||
| 907 | | Route | Method | Description | | 907 | | Route | Method | Description | |
| 908 | | ------------------------------ | ------ | ------------------------------------------------------------------- | | 908 | | ------------------------------ | ------ | ------------------------------------------------------------------- | |
| 909 | | `/` | GET | Landing page / auth redirect | | 909 | | `/` | GET | Landing page / auth redirect | |
| 910 | -| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar | | 910 | +| `/dashboard` | GET | Main dashboard: article recs, unread articles, trending, people, feeds | |
| 911 | | `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | | 911 | | `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | |
| 912 | | `/feeds/list` | GET | Feed list fragment (htmx partial) | | 912 | | `/feeds/list` | GET | Feed list fragment (htmx partial) | |
| 913 | | `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) | | 913 | | `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) | |
| @@ -1092,7 +1092,7 @@ Browser ──GET /articles──► Server | |||
| 1092 | ### 11.3 Recommendations | 1092 | ### 11.3 Recommendations |
| 1093 | 1093 | ||
| 1094 | ``` | 1094 | ``` |
| 1095 | -Cron (every 10m) ──► Cluster Engine | 1095 | +Cron (every 1h) ──► Cluster Engine |
| 1096 | │ | 1096 | │ |
| 1097 | ├─► Compute feed similarity | 1097 | ├─► Compute feed similarity |
| 1098 | ├─► Compute user similarity | 1098 | ├─► Compute user similarity |
modified
go.mod +1 -1 | @@ -8,6 +8,7 @@ require ( | ||
| 8 | 8 | github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda28 |
| 9 | 9 | github.com/go-chi/chi/v5 v5.2.5 |
| 10 | 10 | github.com/go-chi/cors v1.2.2 |
| 11 | + github.com/hashicorp/golang-lru/v2 v2.0.7 | |
| 11 | 12 | github.com/mattn/go-sqlite3 v1.14.22 |
| 12 | 13 | github.com/openai/openai-go v1.12.0 |
| 13 | 14 | github.com/prometheus/client_golang v1.19.1 |
| @@ -27,7 +28,6 @@ require ( | ||
| 27 | 28 | github.com/google/go-cmp v0.6.0 // indirect |
| 28 | 29 | github.com/google/go-querystring v1.1.0 // indirect |
| 29 | 30 | github.com/gorilla/websocket v1.5.3 // indirect |
| 30 | - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect | |
| 31 | 31 | github.com/ipfs/go-cid v0.4.1 // indirect |
| 32 | 32 | github.com/klauspost/compress v1.17.9 // indirect |
| 33 | 33 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect |
| @@ -8,6 +8,7 @@ require ( | |||
| 8 | github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda28 | 8 | github.com/bluesky-social/jetstream v0.0.0-20260415170838-8a65de4eda28 |
| 9 | github.com/go-chi/chi/v5 v5.2.5 | 9 | github.com/go-chi/chi/v5 v5.2.5 |
| 10 | github.com/go-chi/cors v1.2.2 | 10 | github.com/go-chi/cors v1.2.2 |
| 11 | + github.com/hashicorp/golang-lru/v2 v2.0.7 | ||
| 11 | github.com/mattn/go-sqlite3 v1.14.22 | 12 | github.com/mattn/go-sqlite3 v1.14.22 |
| 12 | github.com/openai/openai-go v1.12.0 | 13 | github.com/openai/openai-go v1.12.0 |
| 13 | github.com/prometheus/client_golang v1.19.1 | 14 | github.com/prometheus/client_golang v1.19.1 |
| @@ -27,7 +28,6 @@ require ( | |||
| 27 | github.com/google/go-cmp v0.6.0 // indirect | 28 | github.com/google/go-cmp v0.6.0 // indirect |
| 28 | github.com/google/go-querystring v1.1.0 // indirect | 29 | github.com/google/go-querystring v1.1.0 // indirect |
| 29 | github.com/gorilla/websocket v1.5.3 // indirect | 30 | github.com/gorilla/websocket v1.5.3 // indirect |
| 30 | - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect | ||
| 31 | github.com/ipfs/go-cid v0.4.1 // indirect | 31 | github.com/ipfs/go-cid v0.4.1 // indirect |
| 32 | github.com/klauspost/compress v1.17.9 // indirect | 32 | github.com/klauspost/compress v1.17.9 // indirect |
| 33 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect | 33 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect |
modified
internal/cluster/cron.go +6 -0 | @@ -32,6 +32,12 @@ func (c *Cron) Run(ctx context.Context) error { | ||
| 32 | 32 | if !c.engine.mu.TryLock() { |
| 33 | 33 | c.logger.Info("skipping computation: already in progress") |
| 34 | 34 | } else { |
| 35 | + c.engine.feedCache.Purge() | |
| 36 | + c.engine.peopleCache.Purge() | |
| 37 | + c.engine.articleCache.Purge() | |
| 38 | + c.engine.globalTrendingCache.Purge() | |
| 39 | + c.engine.personalTrendingCache.Purge() | |
| 40 | + | |
| 35 | 41 | if err := c.engine.ComputeFeedEmbeddings(ctx); err != nil { |
| 36 | 42 | c.engine.logger.Error("feed embeddings failed", "error", err) |
| 37 | 43 | } |
| @@ -32,6 +32,12 @@ func (c *Cron) Run(ctx context.Context) error { | |||
| 32 | if !c.engine.mu.TryLock() { | 32 | if !c.engine.mu.TryLock() { |
| 33 | c.logger.Info("skipping computation: already in progress") | 33 | c.logger.Info("skipping computation: already in progress") |
| 34 | } else { | 34 | } else { |
| 35 | + c.engine.feedCache.Purge() | ||
| 36 | + c.engine.peopleCache.Purge() | ||
| 37 | + c.engine.articleCache.Purge() | ||
| 38 | + c.engine.globalTrendingCache.Purge() | ||
| 39 | + c.engine.personalTrendingCache.Purge() | ||
| 40 | + | ||
| 35 | if err := c.engine.ComputeFeedEmbeddings(ctx); err != nil { | 41 | if err := c.engine.ComputeFeedEmbeddings(ctx); err != nil { |
| 36 | c.engine.logger.Error("feed embeddings failed", "error", err) | 42 | c.engine.logger.Error("feed embeddings failed", "error", err) |
| 37 | } | 43 | } |
modified
internal/cluster/jaccard.go +32 -9 | @@ -6,6 +6,11 @@ import ( | ||
| 6 | 6 | "fmt" |
| 7 | 7 | "log/slog" |
| 8 | 8 | "sync" |
| 9 | + "time" | |
| 10 | + | |
| 11 | + "github.com/hashicorp/golang-lru/v2/expirable" | |
| 12 | + | |
| 13 | + "pkg.rbrt.fr/glean/internal/db" | |
| 9 | 14 | ) |
| 10 | 15 | |
| 11 | 16 | // Config controls weights used during similarity computation (feed similarity |
| @@ -33,20 +38,38 @@ func DefaultConfig() Config { | ||
| 33 | 38 | // All public methods are safe for concurrent use (cron writes, on-demand reads). |
| 34 | 39 | type Engine struct { |
| 35 | 40 | db *sql.DB |
| 36 | - logger *slog.Logger | |
| 37 | - mu sync.Mutex | |
| 38 | - config Config | |
| 41 | + articles *db.ArticleStore | |
| 42 | + | |
| 43 | + logger *slog.Logger | |
| 44 | + mu sync.Mutex | |
| 45 | + config Config | |
| 46 | + | |
| 39 | 47 | embedder Embedder |
| 40 | 48 | llm *LLMClient |
| 49 | + | |
| 50 | + feedCache *expirable.LRU[string, []*FeedRecommendation] | |
| 51 | + peopleCache *expirable.LRU[string, []*PersonRecommendation] | |
| 52 | + articleCache *expirable.LRU[string, []*ArticleRecommendation] | |
| 53 | + globalTrendingCache *expirable.LRU[string, []*db.TrendingItem] | |
| 54 | + personalTrendingCache *expirable.LRU[string, []*db.TrendingItem] | |
| 41 | 55 | } |
| 42 | 56 | |
| 43 | -func NewEngine(db *sql.DB, embedder Embedder, llm *LLMClient, logger *slog.Logger) *Engine { | |
| 57 | +// recCacheSize is the maximum number of recommendations to cache per user. | |
| 58 | +const recCacheSize = 512 | |
| 59 | + | |
| 60 | +func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder Embedder, llm *LLMClient, logger *slog.Logger, cacheTTL time.Duration, config Config) *Engine { | |
| 44 | 61 | return &Engine{ |
| 45 | - db: db, | |
| 46 | - logger: logger, | |
| 47 | - config: DefaultConfig(), | |
| 48 | - embedder: embedder, | |
| 49 | - llm: llm, | |
| 62 | + db: sqlDB, | |
| 63 | + articles: articles, | |
| 64 | + logger: logger, | |
| 65 | + config: config, | |
| 66 | + embedder: embedder, | |
| 67 | + llm: llm, | |
| 68 | + feedCache: expirable.NewLRU[string, []*FeedRecommendation](recCacheSize, nil, cacheTTL), | |
| 69 | + peopleCache: expirable.NewLRU[string, []*PersonRecommendation](recCacheSize, nil, cacheTTL), | |
| 70 | + articleCache: expirable.NewLRU[string, []*ArticleRecommendation](recCacheSize, nil, cacheTTL), | |
| 71 | + globalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL), | |
| 72 | + personalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL), | |
| 50 | 73 | } |
| 51 | 74 | } |
| 52 | 75 | |
| @@ -6,6 +6,11 @@ import ( | |||
| 6 | "fmt" | 6 | "fmt" |
| 7 | "log/slog" | 7 | "log/slog" |
| 8 | "sync" | 8 | "sync" |
| 9 | + "time" | ||
| 10 | + | ||
| 11 | + "github.com/hashicorp/golang-lru/v2/expirable" | ||
| 12 | + | ||
| 13 | + "pkg.rbrt.fr/glean/internal/db" | ||
| 9 | ) | 14 | ) |
| 10 | 15 | ||
| 11 | // Config controls weights used during similarity computation (feed similarity | 16 | // Config controls weights used during similarity computation (feed similarity |
| @@ -33,20 +38,38 @@ func DefaultConfig() Config { | |||
| 33 | // All public methods are safe for concurrent use (cron writes, on-demand reads). | 38 | // All public methods are safe for concurrent use (cron writes, on-demand reads). |
| 34 | type Engine struct { | 39 | type Engine struct { |
| 35 | db *sql.DB | 40 | db *sql.DB |
| 36 | - logger *slog.Logger | 41 | + articles *db.ArticleStore |
| 37 | - mu sync.Mutex | 42 | + |
| 38 | - config Config | 43 | + logger *slog.Logger |
| 44 | + mu sync.Mutex | ||
| 45 | + config Config | ||
| 46 | + | ||
| 39 | embedder Embedder | 47 | embedder Embedder |
| 40 | llm *LLMClient | 48 | llm *LLMClient |
| 49 | + | ||
| 50 | + feedCache *expirable.LRU[string, []*FeedRecommendation] | ||
| 51 | + peopleCache *expirable.LRU[string, []*PersonRecommendation] | ||
| 52 | + articleCache *expirable.LRU[string, []*ArticleRecommendation] | ||
| 53 | + globalTrendingCache *expirable.LRU[string, []*db.TrendingItem] | ||
| 54 | + personalTrendingCache *expirable.LRU[string, []*db.TrendingItem] | ||
| 41 | } | 55 | } |
| 42 | 56 | ||
| 43 | -func NewEngine(db *sql.DB, embedder Embedder, llm *LLMClient, logger *slog.Logger) *Engine { | 57 | +// recCacheSize is the maximum number of recommendations to cache per user. |
| 58 | +const recCacheSize = 512 | ||
| 59 | + | ||
| 60 | +func NewEngine(sqlDB *sql.DB, articles *db.ArticleStore, embedder Embedder, llm *LLMClient, logger *slog.Logger, cacheTTL time.Duration, config Config) *Engine { | ||
| 44 | return &Engine{ | 61 | return &Engine{ |
| 45 | - db: db, | 62 | + db: sqlDB, |
| 46 | - logger: logger, | 63 | + articles: articles, |
| 47 | - config: DefaultConfig(), | 64 | + logger: logger, |
| 48 | - embedder: embedder, | 65 | + config: config, |
| 49 | - llm: llm, | 66 | + embedder: embedder, |
| 67 | + llm: llm, | ||
| 68 | + feedCache: expirable.NewLRU[string, []*FeedRecommendation](recCacheSize, nil, cacheTTL), | ||
| 69 | + peopleCache: expirable.NewLRU[string, []*PersonRecommendation](recCacheSize, nil, cacheTTL), | ||
| 70 | + articleCache: expirable.NewLRU[string, []*ArticleRecommendation](recCacheSize, nil, cacheTTL), | ||
| 71 | + globalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL), | ||
| 72 | + personalTrendingCache: expirable.NewLRU[string, []*db.TrendingItem](recCacheSize, nil, cacheTTL), | ||
| 50 | } | 73 | } |
| 51 | } | 74 | } |
| 52 | 75 | ||
modified
internal/cluster/jaccard_test.go +2 -1 | @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "os" |
| 9 | 9 | "strings" |
| 10 | 10 | "testing" |
| 11 | + "time" | |
| 11 | 12 | |
| 12 | 13 | vec "github.com/asg017/sqlite-vec-go-bindings/cgo" |
| 13 | 14 | "pkg.rbrt.fr/glean/internal/db" |
| @@ -94,7 +95,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Store) { | ||
| 94 | 95 | } |
| 95 | 96 | |
| 96 | 97 | func newTestEngine(dbs *db.Store) *Engine { |
| 97 | - return NewEngine(dbs.SQLDB(), NewMockEmbedder(8), nil, slog.Default()) | |
| 98 | + return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, slog.Default(), time.Hour, DefaultConfig()) | |
| 98 | 99 | } |
| 99 | 100 | |
| 100 | 101 | func TestComputeFeedSimilarity(t *testing.T) { |
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | "os" | 8 | "os" |
| 9 | "strings" | 9 | "strings" |
| 10 | "testing" | 10 | "testing" |
| 11 | + "time" | ||
| 11 | 12 | ||
| 12 | vec "github.com/asg017/sqlite-vec-go-bindings/cgo" | 13 | vec "github.com/asg017/sqlite-vec-go-bindings/cgo" |
| 13 | "pkg.rbrt.fr/glean/internal/db" | 14 | "pkg.rbrt.fr/glean/internal/db" |
| @@ -94,7 +95,7 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Store) { | |||
| 94 | } | 95 | } |
| 95 | 96 | ||
| 96 | func newTestEngine(dbs *db.Store) *Engine { | 97 | func newTestEngine(dbs *db.Store) *Engine { |
| 97 | - return NewEngine(dbs.SQLDB(), NewMockEmbedder(8), nil, slog.Default()) | 98 | + return NewEngine(dbs.SQLDB(), dbs.Articles, NewMockEmbedder(8), nil, slog.Default(), time.Hour, DefaultConfig()) |
| 98 | } | 99 | } |
| 99 | 100 | ||
| 100 | func TestComputeFeedSimilarity(t *testing.T) { | 101 | func TestComputeFeedSimilarity(t *testing.T) { |
modified
internal/cluster/scoring.go +59 -1 | @@ -4,6 +4,8 @@ import ( | ||
| 4 | 4 | "context" |
| 5 | 5 | "database/sql" |
| 6 | 6 | "fmt" |
| 7 | + "strings" | |
| 8 | + "time" | |
| 7 | 9 | |
| 8 | 10 | "pkg.rbrt.fr/glean/internal/db" |
| 9 | 11 | ) |
| @@ -49,6 +51,10 @@ type ArticleRecommendation struct { | ||
| 49 | 51 | // KNN or graph+popular fallback). Results are min-max normalized and |
| 50 | 52 | // diversity-filtered before returning. |
| 51 | 53 | func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) { |
| 54 | + if entry, ok := e.feedCache.Get(userDID); ok { | |
| 55 | + return entry, nil | |
| 56 | + } | |
| 57 | + | |
| 52 | 58 | subCount := 0 |
| 53 | 59 | _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?`, userDID).Scan(&subCount) |
| 54 | 60 | |
| @@ -66,7 +72,9 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim | ||
| 66 | 72 | } |
| 67 | 73 | |
| 68 | 74 | normalizeFeedScores(recs) |
| 69 | - return ApplyDiversity(recs, limit), nil | |
| 75 | + result := ApplyDiversity(recs, limit) | |
| 76 | + e.feedCache.Add(userDID, result) | |
| 77 | + return result, nil | |
| 70 | 78 | } |
| 71 | 79 | |
| 72 | 80 | // GetPeopleRecommendations returns similar users based on subscription overlap, |
| @@ -74,6 +82,10 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim | ||
| 74 | 82 | // limit come from the user's network (followed) and half from outside. When |
| 75 | 83 | // outside-network candidates are scarce, in-network fills the remaining slots. |
| 76 | 84 | func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) { |
| 85 | + if entry, ok := e.peopleCache.Get(userDID); ok { | |
| 86 | + return entry, nil | |
| 87 | + } | |
| 88 | + | |
| 77 | 89 | recs, err := e.ComputePeopleRecommendationsOnDemand(ctx, userDID, limit*2) |
| 78 | 90 | if err != nil { |
| 79 | 91 | return nil, err |
| @@ -97,6 +109,7 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l | ||
| 97 | 109 | recs = append(recs, outNet[:outTake]...) |
| 98 | 110 | |
| 99 | 111 | normalizePersonScores(recs) |
| 112 | + e.peopleCache.Add(userDID, recs) | |
| 100 | 113 | return recs, nil |
| 101 | 114 | } |
| 102 | 115 | |
| @@ -105,14 +118,59 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l | ||
| 105 | 118 | // (embedding KNN against user's liked articles), and recency. Scores are |
| 106 | 119 | // min-max normalized. |
| 107 | 120 | func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) { |
| 121 | + artKey := userDID + "|" + strings.Join(languages, ",") | |
| 122 | + if entry, ok := e.articleCache.Get(artKey); ok { | |
| 123 | + return entry, nil | |
| 124 | + } | |
| 125 | + | |
| 108 | 126 | recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, languages, limit) |
| 109 | 127 | if err != nil { |
| 110 | 128 | return nil, err |
| 111 | 129 | } |
| 112 | 130 | normalizeArticleScores(recs) |
| 131 | + e.articleCache.Add(artKey, recs) | |
| 113 | 132 | return recs, nil |
| 114 | 133 | } |
| 115 | 134 | |
| 135 | +func (e *Engine) GetGlobalTrending(ctx context.Context, userDID string, limit, offset int) ([]*db.TrendingItem, error) { | |
| 136 | + if offset == 0 { | |
| 137 | + if entry, ok := e.globalTrendingCache.Get(userDID); ok { | |
| 138 | + return entry, nil | |
| 139 | + } | |
| 140 | + } | |
| 141 | + | |
| 142 | + since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | |
| 143 | + items, err := e.articles.ListTrendingArticles(ctx, userDID, since, limit, offset) | |
| 144 | + if err != nil { | |
| 145 | + return nil, err | |
| 146 | + } | |
| 147 | + | |
| 148 | + if offset == 0 { | |
| 149 | + e.globalTrendingCache.Add(userDID, items) | |
| 150 | + } | |
| 151 | + return items, nil | |
| 152 | +} | |
| 153 | + | |
| 154 | +func (e *Engine) GetPersonalTrending(ctx context.Context, userDID string, languages []string, limit, offset int) ([]*db.TrendingItem, error) { | |
| 155 | + key := userDID + "|" + strings.Join(languages, ",") | |
| 156 | + if offset == 0 { | |
| 157 | + if entry, ok := e.personalTrendingCache.Get(key); ok { | |
| 158 | + return entry, nil | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | |
| 163 | + items, err := e.articles.ListTrendingArticlesForUser(ctx, userDID, since, languages, limit, offset) | |
| 164 | + if err != nil { | |
| 165 | + return nil, err | |
| 166 | + } | |
| 167 | + | |
| 168 | + if offset == 0 { | |
| 169 | + e.personalTrendingCache.Add(key, items) | |
| 170 | + } | |
| 171 | + return items, nil | |
| 172 | +} | |
| 173 | + | |
| 116 | 174 | // SignalWeights holds per-signal multipliers used in the recommendation scoring |
| 117 | 175 | // formula. Weights are auto-tuned per user via a bandit-style reward/penalty |
| 118 | 176 | // system (see weights.go). Each field maps to a column in |
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | "context" | 4 | "context" |
| 5 | "database/sql" | 5 | "database/sql" |
| 6 | "fmt" | 6 | "fmt" |
| 7 | + "strings" | ||
| 8 | + "time" | ||
| 7 | 9 | ||
| 8 | "pkg.rbrt.fr/glean/internal/db" | 10 | "pkg.rbrt.fr/glean/internal/db" |
| 9 | ) | 11 | ) |
| @@ -49,6 +51,10 @@ type ArticleRecommendation struct { | |||
| 49 | // KNN or graph+popular fallback). Results are min-max normalized and | 51 | // KNN or graph+popular fallback). Results are min-max normalized and |
| 50 | // diversity-filtered before returning. | 52 | // diversity-filtered before returning. |
| 51 | func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) { | 53 | func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) { |
| 54 | + if entry, ok := e.feedCache.Get(userDID); ok { | ||
| 55 | + return entry, nil | ||
| 56 | + } | ||
| 57 | + | ||
| 52 | subCount := 0 | 58 | subCount := 0 |
| 53 | _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?`, userDID).Scan(&subCount) | 59 | _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?`, userDID).Scan(&subCount) |
| 54 | 60 | ||
| @@ -66,7 +72,9 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim | |||
| 66 | } | 72 | } |
| 67 | 73 | ||
| 68 | normalizeFeedScores(recs) | 74 | normalizeFeedScores(recs) |
| 69 | - return ApplyDiversity(recs, limit), nil | 75 | + result := ApplyDiversity(recs, limit) |
| 76 | + e.feedCache.Add(userDID, result) | ||
| 77 | + return result, nil | ||
| 70 | } | 78 | } |
| 71 | 79 | ||
| 72 | // GetPeopleRecommendations returns similar users based on subscription overlap, | 80 | // GetPeopleRecommendations returns similar users based on subscription overlap, |
| @@ -74,6 +82,10 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim | |||
| 74 | // limit come from the user's network (followed) and half from outside. When | 82 | // limit come from the user's network (followed) and half from outside. When |
| 75 | // outside-network candidates are scarce, in-network fills the remaining slots. | 83 | // outside-network candidates are scarce, in-network fills the remaining slots. |
| 76 | func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) { | 84 | func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) { |
| 85 | + if entry, ok := e.peopleCache.Get(userDID); ok { | ||
| 86 | + return entry, nil | ||
| 87 | + } | ||
| 88 | + | ||
| 77 | recs, err := e.ComputePeopleRecommendationsOnDemand(ctx, userDID, limit*2) | 89 | recs, err := e.ComputePeopleRecommendationsOnDemand(ctx, userDID, limit*2) |
| 78 | if err != nil { | 90 | if err != nil { |
| 79 | return nil, err | 91 | return nil, err |
| @@ -97,6 +109,7 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l | |||
| 97 | recs = append(recs, outNet[:outTake]...) | 109 | recs = append(recs, outNet[:outTake]...) |
| 98 | 110 | ||
| 99 | normalizePersonScores(recs) | 111 | normalizePersonScores(recs) |
| 112 | + e.peopleCache.Add(userDID, recs) | ||
| 100 | return recs, nil | 113 | return recs, nil |
| 101 | } | 114 | } |
| 102 | 115 | ||
| @@ -105,14 +118,59 @@ func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, l | |||
| 105 | // (embedding KNN against user's liked articles), and recency. Scores are | 118 | // (embedding KNN against user's liked articles), and recency. Scores are |
| 106 | // min-max normalized. | 119 | // min-max normalized. |
| 107 | func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) { | 120 | func (e *Engine) GetArticleRecommendations(ctx context.Context, userDID string, languages []string, limit int) ([]*ArticleRecommendation, error) { |
| 121 | + artKey := userDID + "|" + strings.Join(languages, ",") | ||
| 122 | + if entry, ok := e.articleCache.Get(artKey); ok { | ||
| 123 | + return entry, nil | ||
| 124 | + } | ||
| 125 | + | ||
| 108 | recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, languages, limit) | 126 | recs, err := e.ComputeArticleRecommendationsOnDemand(ctx, userDID, languages, limit) |
| 109 | if err != nil { | 127 | if err != nil { |
| 110 | return nil, err | 128 | return nil, err |
| 111 | } | 129 | } |
| 112 | normalizeArticleScores(recs) | 130 | normalizeArticleScores(recs) |
| 131 | + e.articleCache.Add(artKey, recs) | ||
| 113 | return recs, nil | 132 | return recs, nil |
| 114 | } | 133 | } |
| 115 | 134 | ||
| 135 | +func (e *Engine) GetGlobalTrending(ctx context.Context, userDID string, limit, offset int) ([]*db.TrendingItem, error) { | ||
| 136 | + if offset == 0 { | ||
| 137 | + if entry, ok := e.globalTrendingCache.Get(userDID); ok { | ||
| 138 | + return entry, nil | ||
| 139 | + } | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | ||
| 143 | + items, err := e.articles.ListTrendingArticles(ctx, userDID, since, limit, offset) | ||
| 144 | + if err != nil { | ||
| 145 | + return nil, err | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + if offset == 0 { | ||
| 149 | + e.globalTrendingCache.Add(userDID, items) | ||
| 150 | + } | ||
| 151 | + return items, nil | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +func (e *Engine) GetPersonalTrending(ctx context.Context, userDID string, languages []string, limit, offset int) ([]*db.TrendingItem, error) { | ||
| 155 | + key := userDID + "|" + strings.Join(languages, ",") | ||
| 156 | + if offset == 0 { | ||
| 157 | + if entry, ok := e.personalTrendingCache.Get(key); ok { | ||
| 158 | + return entry, nil | ||
| 159 | + } | ||
| 160 | + } | ||
| 161 | + | ||
| 162 | + since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | ||
| 163 | + items, err := e.articles.ListTrendingArticlesForUser(ctx, userDID, since, languages, limit, offset) | ||
| 164 | + if err != nil { | ||
| 165 | + return nil, err | ||
| 166 | + } | ||
| 167 | + | ||
| 168 | + if offset == 0 { | ||
| 169 | + e.personalTrendingCache.Add(key, items) | ||
| 170 | + } | ||
| 171 | + return items, nil | ||
| 172 | +} | ||
| 173 | + | ||
| 116 | // SignalWeights holds per-signal multipliers used in the recommendation scoring | 174 | // SignalWeights holds per-signal multipliers used in the recommendation scoring |
| 117 | // formula. Weights are auto-tuned per user via a bandit-style reward/penalty | 175 | // formula. Weights are auto-tuned per user via a bandit-style reward/penalty |
| 118 | // system (see weights.go). Each field maps to a column in | 176 | // system (see weights.go). Each field maps to a column in |
modified
internal/db/social.go +1 -1 | @@ -398,7 +398,7 @@ func (s *ArticleStore) ListTrendingArticlesForUser(ctx context.Context, userDID, | ||
| 398 | 398 | WHERE l.created_at >= ? |
| 399 | 399 | AND l.author_did IN ( |
| 400 | 400 | SELECT CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END |
| 401 | - FROM user_similarity us | |
| 401 | + FROM recs.user_similarity us | |
| 402 | 402 | WHERE us.user_a = ? OR us.user_b = ? |
| 403 | 403 | UNION SELECT ? |
| 404 | 404 | UNION SELECT f.target_did FROM follows f WHERE f.user_did = ? |
| @@ -398,7 +398,7 @@ func (s *ArticleStore) ListTrendingArticlesForUser(ctx context.Context, userDID, | |||
| 398 | WHERE l.created_at >= ? | 398 | WHERE l.created_at >= ? |
| 399 | AND l.author_did IN ( | 399 | AND l.author_did IN ( |
| 400 | SELECT CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END | 400 | SELECT CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END |
| 401 | - FROM user_similarity us | 401 | + FROM recs.user_similarity us |
| 402 | WHERE us.user_a = ? OR us.user_b = ? | 402 | WHERE us.user_a = ? OR us.user_b = ? |
| 403 | UNION SELECT ? | 403 | UNION SELECT ? |
| 404 | UNION SELECT f.target_did FROM follows f WHERE f.user_did = ? | 404 | UNION SELECT f.target_did FROM follows f WHERE f.user_did = ? |
modified
internal/server/articles_handler.go +14 -5 | @@ -19,17 +19,26 @@ import ( | ||
| 19 | 19 | |
| 20 | 20 | func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int, bordered bool) { |
| 21 | 21 | fill := "none" |
| 22 | - colorCls := "text-spot-muted" | |
| 22 | + likedCls := "text-spot-text bg-spot-hover hover:text-spot-red hover:bg-spot-red/15" | |
| 23 | 23 | if liked { |
| 24 | 24 | fill = "currentColor" |
| 25 | - colorCls = "text-spot-red" | |
| 25 | + likedCls = "text-spot-red bg-spot-red/15 hover:bg-spot-red/25" | |
| 26 | 26 | } |
| 27 | + | |
| 27 | 28 | w.Header().Set("Content-Type", "text/html") |
| 28 | - cls := "text-spot-text rounded-pill px-3 py-1 text-xs font-bold transition inline-flex items-center gap-1.5 hover:text-spot-green" | |
| 29 | + | |
| 29 | 30 | if bordered { |
| 30 | - cls = "border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5" | |
| 31 | + fmt.Fprintf(w, `<button hx-post="/articles/%d/like?bordered=true" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition %s"><svg class="w-3.5 h-3.5" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), likedCls, fill, count) | |
| 32 | + } else { | |
| 33 | + fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center gap-1 text-[10px] uppercase tracking-button px-2 py-0.5 rounded-pill transition %s"><svg class="w-3 h-3" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), likedCls, fill, count) | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +func likeTitle(liked bool) string { | |
| 38 | + if liked { | |
| 39 | + return "Unlike" | |
| 31 | 40 | } |
| 32 | - _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like?bordered=%t" hx-target="this" hx-swap="outerHTML" class="%s"><svg class="w-3.5 h-3.5 %s" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, bordered, cls, colorCls, fill, count) | |
| 41 | + return "Like" | |
| 33 | 42 | } |
| 34 | 43 | |
| 35 | 44 | func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { |
| @@ -19,17 +19,26 @@ import ( | |||
| 19 | 19 | ||
| 20 | func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int, bordered bool) { | 20 | func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int, bordered bool) { |
| 21 | fill := "none" | 21 | fill := "none" |
| 22 | - colorCls := "text-spot-muted" | 22 | + likedCls := "text-spot-text bg-spot-hover hover:text-spot-red hover:bg-spot-red/15" |
| 23 | if liked { | 23 | if liked { |
| 24 | fill = "currentColor" | 24 | fill = "currentColor" |
| 25 | - colorCls = "text-spot-red" | 25 | + likedCls = "text-spot-red bg-spot-red/15 hover:bg-spot-red/25" |
| 26 | } | 26 | } |
| 27 | + | ||
| 27 | w.Header().Set("Content-Type", "text/html") | 28 | w.Header().Set("Content-Type", "text/html") |
| 28 | - cls := "text-spot-text rounded-pill px-3 py-1 text-xs font-bold transition inline-flex items-center gap-1.5 hover:text-spot-green" | 29 | + |
| 29 | if bordered { | 30 | if bordered { |
| 30 | - cls = "border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5" | 31 | + fmt.Fprintf(w, `<button hx-post="/articles/%d/like?bordered=true" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition %s"><svg class="w-3.5 h-3.5" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), likedCls, fill, count) |
| 32 | + } else { | ||
| 33 | + fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center gap-1 text-[10px] uppercase tracking-button px-2 py-0.5 rounded-pill transition %s"><svg class="w-3 h-3" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), likedCls, fill, count) | ||
| 34 | + } | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +func likeTitle(liked bool) string { | ||
| 38 | + if liked { | ||
| 39 | + return "Unlike" | ||
| 31 | } | 40 | } |
| 32 | - _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like?bordered=%t" hx-target="this" hx-swap="outerHTML" class="%s"><svg class="w-3.5 h-3.5 %s" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, bordered, cls, colorCls, fill, count) | 41 | + return "Like" |
| 33 | } | 42 | } |
| 34 | 43 | ||
| 35 | func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | 44 | func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { |
modified
internal/server/dashboard_handler.go +26 -62 | @@ -28,9 +28,6 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 28 | 28 | globalTrending []*db.TrendingItem |
| 29 | 29 | ) |
| 30 | 30 | |
| 31 | - page := pageFromRequest(r, 25) | |
| 32 | - since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | |
| 33 | - | |
| 34 | 31 | g, gCtx := errgroup.WithContext(ctx) |
| 35 | 32 | |
| 36 | 33 | g.Go(func() error { |
| @@ -53,95 +50,65 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 53 | 50 | |
| 54 | 51 | g.Go(func() error { |
| 55 | 52 | var err error |
| 56 | - articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", page.Limit()+1, page.Offset()) | |
| 53 | + articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 5, 0) | |
| 57 | 54 | if err != nil { |
| 58 | 55 | s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID) |
| 59 | - return nil | |
| 60 | - } | |
| 61 | - totalFetched := len(articles) | |
| 62 | - page = page.Paginate(totalFetched) | |
| 63 | - if page.HasNext { | |
| 64 | - articles = articles[:page.PageSize] | |
| 65 | 56 | } |
| 66 | 57 | return nil |
| 67 | 58 | }) |
| 68 | 59 | |
| 69 | 60 | g.Go(func() error { |
| 70 | - var err error | |
| 71 | - userLangs, err = s.dbs.Users.GetLanguages(gCtx, user.DID) | |
| 72 | - if err != nil { | |
| 73 | - s.logger.Warn("failed to get user languages", "error", err, "did", user.DID) | |
| 74 | - } | |
| 61 | + userLangs, _ = s.dbs.Users.GetLanguages(gCtx, user.DID) | |
| 75 | 62 | return nil |
| 76 | 63 | }) |
| 77 | 64 | |
| 78 | 65 | g.Go(func() error { |
| 79 | 66 | var err error |
| 80 | 67 | peopleRecs, err = s.engine.GetPeopleRecommendations(gCtx, user.DID, 5) |
| 81 | - if err != nil { | |
| 82 | - s.logger.Warn("failed to get people recommendations", "error", err, "did", user.DID) | |
| 83 | - } | |
| 84 | - return nil | |
| 68 | + return err | |
| 85 | 69 | }) |
| 86 | 70 | |
| 87 | 71 | g.Go(func() error { |
| 88 | 72 | var err error |
| 89 | 73 | feedRecs, err = s.engine.GetFeedRecommendations(gCtx, user.DID, 5) |
| 90 | - if err != nil { | |
| 91 | - s.logger.Warn("failed to get feed recommendations", "error", err, "did", user.DID) | |
| 92 | - } | |
| 93 | - return nil | |
| 74 | + return err | |
| 94 | 75 | }) |
| 95 | 76 | |
| 96 | - g.Go(func() error { | |
| 97 | - var err error | |
| 98 | - globalTrending, err = s.dbs.Articles.ListTrendingArticles(gCtx, user.DID, since, 10, 0) | |
| 99 | - if err != nil { | |
| 100 | - s.logger.Warn("failed to list global trending", "error", err, "did", user.DID) | |
| 101 | - } | |
| 102 | - return nil | |
| 103 | - }) | |
| 77 | + if subCount == 0 { | |
| 78 | + g.Go(func() error { | |
| 79 | + var err error | |
| 80 | + globalTrending, err = s.engine.GetGlobalTrending(gCtx, user.DID, 5, 0) | |
| 81 | + return err | |
| 82 | + }) | |
| 83 | + } else { | |
| 84 | + g.Go(func() error { | |
| 85 | + var err error | |
| 86 | + personalTrending, err = s.engine.GetPersonalTrending(gCtx, user.DID, userLangs, 5, 0) | |
| 87 | + return err | |
| 88 | + }) | |
| 89 | + } | |
| 104 | 90 | |
| 105 | 91 | if err := g.Wait(); err != nil { |
| 106 | - s.logger.Warn("dashboard phase 1 error", "error", err, "did", user.DID) | |
| 92 | + s.logger.Warn("dashboard error", "error", err, "did", user.DID) | |
| 107 | 93 | } |
| 108 | 94 | |
| 109 | - g2, gCtx2 := errgroup.WithContext(ctx) | |
| 110 | - | |
| 111 | - g2.Go(func() error { | |
| 95 | + if subCount > 0 { | |
| 112 | 96 | var err error |
| 113 | - articleRecs, err = s.engine.GetArticleRecommendations(gCtx2, user.DID, userLangs, 5) | |
| 97 | + articleRecs, err = s.engine.GetArticleRecommendations(ctx, user.DID, userLangs, 5) | |
| 114 | 98 | if err != nil { |
| 115 | 99 | s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID) |
| 116 | 100 | } |
| 117 | - return nil | |
| 118 | - }) | |
| 119 | - | |
| 120 | - g2.Go(func() error { | |
| 121 | - var err error | |
| 122 | - personalTrending, err = s.dbs.Articles.ListTrendingArticlesForUser(gCtx2, user.DID, since, userLangs, 5, 0) | |
| 123 | - if err != nil { | |
| 124 | - s.logger.Warn("failed to list personal trending", "error", err, "did", user.DID) | |
| 125 | - } | |
| 126 | - return nil | |
| 127 | - }) | |
| 128 | - | |
| 129 | - g2.Go(func() error { | |
| 130 | - resolvePeopleHandles(gCtx2, peopleRecs) | |
| 131 | - return nil | |
| 132 | - }) | |
| 133 | - | |
| 134 | - if err := g2.Wait(); err != nil { | |
| 135 | - s.logger.Warn("dashboard phase 2 error", "error", err, "did", user.DID) | |
| 136 | 101 | } |
| 137 | 102 | |
| 103 | + resolvePeopleHandles(ctx, peopleRecs) | |
| 104 | + | |
| 138 | 105 | var impressions []cluster.Impression |
| 139 | - for _, rec := range feedRecs { | |
| 140 | - impressions = append(impressions, cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL}) | |
| 141 | - } | |
| 142 | 106 | for _, rec := range articleRecs { |
| 143 | 107 | impressions = append(impressions, cluster.Impression{TargetType: "article", TargetID: rec.URL}) |
| 144 | 108 | } |
| 109 | + for _, rec := range feedRecs { | |
| 110 | + impressions = append(impressions, cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL}) | |
| 111 | + } | |
| 145 | 112 | if len(impressions) > 0 { |
| 146 | 113 | if err := s.engine.RecordImpressions(ctx, user.DID, impressions); err != nil { |
| 147 | 114 | s.logger.Warn("failed to record impressions", "error", err) |
| @@ -159,8 +126,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 159 | 126 | |
| 160 | 127 | s.render(w, r, "dashboard.html", map[string]any{ |
| 161 | 128 | "User": user, |
| 162 | - "UnreadCount": unreadCount, | |
| 163 | 129 | "SubscriptionCount": subCount, |
| 130 | + "UnreadCount": unreadCount, | |
| 164 | 131 | "Articles": articles, |
| 165 | 132 | "ArticleRecommendations": articleRecs, |
| 166 | 133 | "FeedRecommendations": feedRecs, |
| @@ -168,9 +135,6 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 168 | 135 | "DiscoverPeople": discoverPeople, |
| 169 | 136 | "PersonalTrending": personalTrending, |
| 170 | 137 | "GlobalTrending": globalTrending, |
| 171 | - "Page": page, | |
| 172 | - "BaseURL": "/dashboard", | |
| 173 | - "QueryParams": map[string]string{}, | |
| 174 | 138 | "Now": time.Now(), |
| 175 | 139 | }) |
| 176 | 140 | } |
| @@ -28,9 +28,6 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | |||
| 28 | globalTrending []*db.TrendingItem | 28 | globalTrending []*db.TrendingItem |
| 29 | ) | 29 | ) |
| 30 | 30 | ||
| 31 | - page := pageFromRequest(r, 25) | ||
| 32 | - since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | ||
| 33 | - | ||
| 34 | g, gCtx := errgroup.WithContext(ctx) | 31 | g, gCtx := errgroup.WithContext(ctx) |
| 35 | 32 | ||
| 36 | g.Go(func() error { | 33 | g.Go(func() error { |
| @@ -53,95 +50,65 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | |||
| 53 | 50 | ||
| 54 | g.Go(func() error { | 51 | g.Go(func() error { |
| 55 | var err error | 52 | var err error |
| 56 | - articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", page.Limit()+1, page.Offset()) | 53 | + articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 5, 0) |
| 57 | if err != nil { | 54 | if err != nil { |
| 58 | s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID) | 55 | s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID) |
| 59 | - return nil | ||
| 60 | - } | ||
| 61 | - totalFetched := len(articles) | ||
| 62 | - page = page.Paginate(totalFetched) | ||
| 63 | - if page.HasNext { | ||
| 64 | - articles = articles[:page.PageSize] | ||
| 65 | } | 56 | } |
| 66 | return nil | 57 | return nil |
| 67 | }) | 58 | }) |
| 68 | 59 | ||
| 69 | g.Go(func() error { | 60 | g.Go(func() error { |
| 70 | - var err error | 61 | + userLangs, _ = s.dbs.Users.GetLanguages(gCtx, user.DID) |
| 71 | - userLangs, err = s.dbs.Users.GetLanguages(gCtx, user.DID) | ||
| 72 | - if err != nil { | ||
| 73 | - s.logger.Warn("failed to get user languages", "error", err, "did", user.DID) | ||
| 74 | - } | ||
| 75 | return nil | 62 | return nil |
| 76 | }) | 63 | }) |
| 77 | 64 | ||
| 78 | g.Go(func() error { | 65 | g.Go(func() error { |
| 79 | var err error | 66 | var err error |
| 80 | peopleRecs, err = s.engine.GetPeopleRecommendations(gCtx, user.DID, 5) | 67 | peopleRecs, err = s.engine.GetPeopleRecommendations(gCtx, user.DID, 5) |
| 81 | - if err != nil { | 68 | + return err |
| 82 | - s.logger.Warn("failed to get people recommendations", "error", err, "did", user.DID) | ||
| 83 | - } | ||
| 84 | - return nil | ||
| 85 | }) | 69 | }) |
| 86 | 70 | ||
| 87 | g.Go(func() error { | 71 | g.Go(func() error { |
| 88 | var err error | 72 | var err error |
| 89 | feedRecs, err = s.engine.GetFeedRecommendations(gCtx, user.DID, 5) | 73 | feedRecs, err = s.engine.GetFeedRecommendations(gCtx, user.DID, 5) |
| 90 | - if err != nil { | 74 | + return err |
| 91 | - s.logger.Warn("failed to get feed recommendations", "error", err, "did", user.DID) | ||
| 92 | - } | ||
| 93 | - return nil | ||
| 94 | }) | 75 | }) |
| 95 | 76 | ||
| 96 | - g.Go(func() error { | 77 | + if subCount == 0 { |
| 97 | - var err error | 78 | + g.Go(func() error { |
| 98 | - globalTrending, err = s.dbs.Articles.ListTrendingArticles(gCtx, user.DID, since, 10, 0) | 79 | + var err error |
| 99 | - if err != nil { | 80 | + globalTrending, err = s.engine.GetGlobalTrending(gCtx, user.DID, 5, 0) |
| 100 | - s.logger.Warn("failed to list global trending", "error", err, "did", user.DID) | 81 | + return err |
| 101 | - } | 82 | + }) |
| 102 | - return nil | 83 | + } else { |
| 103 | - }) | 84 | + g.Go(func() error { |
| 85 | + var err error | ||
| 86 | + personalTrending, err = s.engine.GetPersonalTrending(gCtx, user.DID, userLangs, 5, 0) | ||
| 87 | + return err | ||
| 88 | + }) | ||
| 89 | + } | ||
| 104 | 90 | ||
| 105 | if err := g.Wait(); err != nil { | 91 | if err := g.Wait(); err != nil { |
| 106 | - s.logger.Warn("dashboard phase 1 error", "error", err, "did", user.DID) | 92 | + s.logger.Warn("dashboard error", "error", err, "did", user.DID) |
| 107 | } | 93 | } |
| 108 | 94 | ||
| 109 | - g2, gCtx2 := errgroup.WithContext(ctx) | 95 | + if subCount > 0 { |
| 110 | - | ||
| 111 | - g2.Go(func() error { | ||
| 112 | var err error | 96 | var err error |
| 113 | - articleRecs, err = s.engine.GetArticleRecommendations(gCtx2, user.DID, userLangs, 5) | 97 | + articleRecs, err = s.engine.GetArticleRecommendations(ctx, user.DID, userLangs, 5) |
| 114 | if err != nil { | 98 | if err != nil { |
| 115 | s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID) | 99 | s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID) |
| 116 | } | 100 | } |
| 117 | - return nil | ||
| 118 | - }) | ||
| 119 | - | ||
| 120 | - g2.Go(func() error { | ||
| 121 | - var err error | ||
| 122 | - personalTrending, err = s.dbs.Articles.ListTrendingArticlesForUser(gCtx2, user.DID, since, userLangs, 5, 0) | ||
| 123 | - if err != nil { | ||
| 124 | - s.logger.Warn("failed to list personal trending", "error", err, "did", user.DID) | ||
| 125 | - } | ||
| 126 | - return nil | ||
| 127 | - }) | ||
| 128 | - | ||
| 129 | - g2.Go(func() error { | ||
| 130 | - resolvePeopleHandles(gCtx2, peopleRecs) | ||
| 131 | - return nil | ||
| 132 | - }) | ||
| 133 | - | ||
| 134 | - if err := g2.Wait(); err != nil { | ||
| 135 | - s.logger.Warn("dashboard phase 2 error", "error", err, "did", user.DID) | ||
| 136 | } | 101 | } |
| 137 | 102 | ||
| 103 | + resolvePeopleHandles(ctx, peopleRecs) | ||
| 104 | + | ||
| 138 | var impressions []cluster.Impression | 105 | var impressions []cluster.Impression |
| 139 | - for _, rec := range feedRecs { | ||
| 140 | - impressions = append(impressions, cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL}) | ||
| 141 | - } | ||
| 142 | for _, rec := range articleRecs { | 106 | for _, rec := range articleRecs { |
| 143 | impressions = append(impressions, cluster.Impression{TargetType: "article", TargetID: rec.URL}) | 107 | impressions = append(impressions, cluster.Impression{TargetType: "article", TargetID: rec.URL}) |
| 144 | } | 108 | } |
| 109 | + for _, rec := range feedRecs { | ||
| 110 | + impressions = append(impressions, cluster.Impression{TargetType: "feed", TargetID: rec.FeedURL}) | ||
| 111 | + } | ||
| 145 | if len(impressions) > 0 { | 112 | if len(impressions) > 0 { |
| 146 | if err := s.engine.RecordImpressions(ctx, user.DID, impressions); err != nil { | 113 | if err := s.engine.RecordImpressions(ctx, user.DID, impressions); err != nil { |
| 147 | s.logger.Warn("failed to record impressions", "error", err) | 114 | s.logger.Warn("failed to record impressions", "error", err) |
| @@ -159,8 +126,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | |||
| 159 | 126 | ||
| 160 | s.render(w, r, "dashboard.html", map[string]any{ | 127 | s.render(w, r, "dashboard.html", map[string]any{ |
| 161 | "User": user, | 128 | "User": user, |
| 162 | - "UnreadCount": unreadCount, | ||
| 163 | "SubscriptionCount": subCount, | 129 | "SubscriptionCount": subCount, |
| 130 | + "UnreadCount": unreadCount, | ||
| 164 | "Articles": articles, | 131 | "Articles": articles, |
| 165 | "ArticleRecommendations": articleRecs, | 132 | "ArticleRecommendations": articleRecs, |
| 166 | "FeedRecommendations": feedRecs, | 133 | "FeedRecommendations": feedRecs, |
| @@ -168,9 +135,6 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | |||
| 168 | "DiscoverPeople": discoverPeople, | 135 | "DiscoverPeople": discoverPeople, |
| 169 | "PersonalTrending": personalTrending, | 136 | "PersonalTrending": personalTrending, |
| 170 | "GlobalTrending": globalTrending, | 137 | "GlobalTrending": globalTrending, |
| 171 | - "Page": page, | ||
| 172 | - "BaseURL": "/dashboard", | ||
| 173 | - "QueryParams": map[string]string{}, | ||
| 174 | "Now": time.Now(), | 138 | "Now": time.Now(), |
| 175 | }) | 139 | }) |
| 176 | } | 140 | } |
modified
internal/server/feeds_handler.go +3 -3 | @@ -350,9 +350,9 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { | ||
| 350 | 350 | for _, fu := range feedURLs { |
| 351 | 351 | f := &db.Feed{ |
| 352 | 352 | FeedURL: fu.URL, |
| 353 | - Title: db.NullStr(fu.Title), | |
| 354 | - SiteURL: db.NullStr(fu.SiteURL), | |
| 355 | - Description: db.NullStr(fu.Description), | |
| 353 | + Title: db.NullStr(fu.Title), | |
| 354 | + SiteURL: db.NullStr(fu.SiteURL), | |
| 355 | + Description: db.NullStr(fu.Description), | |
| 356 | 356 | } |
| 357 | 357 | if upsertErr := s.dbs.Articles.UpsertFeed(r.Context(), f); upsertErr != nil { |
| 358 | 358 | s.logger.Error("failed to upsert feed", "error", upsertErr) |
| @@ -350,9 +350,9 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { | |||
| 350 | for _, fu := range feedURLs { | 350 | for _, fu := range feedURLs { |
| 351 | f := &db.Feed{ | 351 | f := &db.Feed{ |
| 352 | FeedURL: fu.URL, | 352 | FeedURL: fu.URL, |
| 353 | - Title: db.NullStr(fu.Title), | 353 | + Title: db.NullStr(fu.Title), |
| 354 | - SiteURL: db.NullStr(fu.SiteURL), | 354 | + SiteURL: db.NullStr(fu.SiteURL), |
| 355 | - Description: db.NullStr(fu.Description), | 355 | + Description: db.NullStr(fu.Description), |
| 356 | } | 356 | } |
| 357 | if upsertErr := s.dbs.Articles.UpsertFeed(r.Context(), f); upsertErr != nil { | 357 | if upsertErr := s.dbs.Articles.UpsertFeed(r.Context(), f); upsertErr != nil { |
| 358 | s.logger.Error("failed to upsert feed", "error", upsertErr) | 358 | s.logger.Error("failed to upsert feed", "error", upsertErr) |
modified
internal/server/trending_handler.go +8 -10 | @@ -2,7 +2,6 @@ package server | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | - "time" | |
| 6 | 5 | |
| 7 | 6 | "pkg.rbrt.fr/glean/internal/db" |
| 8 | 7 | ) |
| @@ -23,24 +22,23 @@ func (s *Server) handleTrending(w http.ResponseWriter, r *http.Request) { | ||
| 23 | 22 | } |
| 24 | 23 | |
| 25 | 24 | page := pageFromRequest(r, 25) |
| 26 | - since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | |
| 27 | 25 | |
| 28 | 26 | var userDID string |
| 29 | 27 | if user != nil { |
| 30 | 28 | userDID = user.DID |
| 31 | 29 | } |
| 32 | 30 | |
| 33 | - var userLangs []string | |
| 34 | - if user != nil { | |
| 35 | - userLangs, _ = s.dbs.Users.GetLanguages(ctx, user.DID) | |
| 36 | - } | |
| 37 | - | |
| 38 | 31 | var trending []*db.TrendingItem |
| 39 | 32 | var err error |
| 40 | - if scope == "for-me" { | |
| 41 | - trending, err = s.dbs.Articles.ListTrendingArticlesForUser(ctx, userDID, since, userLangs, page.Limit()+1, page.Offset()) | |
| 33 | + | |
| 34 | + if scope == scopeForMe { | |
| 35 | + var userLangs []string | |
| 36 | + if user != nil { | |
| 37 | + userLangs, _ = s.dbs.Users.GetLanguages(ctx, user.DID) | |
| 38 | + } | |
| 39 | + trending, err = s.engine.GetPersonalTrending(ctx, userDID, userLangs, page.Limit()+1, page.Offset()) | |
| 42 | 40 | } else { |
| 43 | - trending, err = s.dbs.Articles.ListTrendingArticles(ctx, userDID, since, page.Limit()+1, page.Offset()) | |
| 41 | + trending, err = s.engine.GetGlobalTrending(ctx, userDID, page.Limit()+1, page.Offset()) | |
| 44 | 42 | } |
| 45 | 43 | if err != nil { |
| 46 | 44 | s.logger.Warn("failed to list trending articles", "error", err, "scope", scope) |
| @@ -2,7 +2,6 @@ package server | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "net/http" | 4 | "net/http" |
| 5 | - "time" | ||
| 6 | 5 | ||
| 7 | "pkg.rbrt.fr/glean/internal/db" | 6 | "pkg.rbrt.fr/glean/internal/db" |
| 8 | ) | 7 | ) |
| @@ -23,24 +22,23 @@ func (s *Server) handleTrending(w http.ResponseWriter, r *http.Request) { | |||
| 23 | } | 22 | } |
| 24 | 23 | ||
| 25 | page := pageFromRequest(r, 25) | 24 | page := pageFromRequest(r, 25) |
| 26 | - since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | ||
| 27 | 25 | ||
| 28 | var userDID string | 26 | var userDID string |
| 29 | if user != nil { | 27 | if user != nil { |
| 30 | userDID = user.DID | 28 | userDID = user.DID |
| 31 | } | 29 | } |
| 32 | 30 | ||
| 33 | - var userLangs []string | ||
| 34 | - if user != nil { | ||
| 35 | - userLangs, _ = s.dbs.Users.GetLanguages(ctx, user.DID) | ||
| 36 | - } | ||
| 37 | - | ||
| 38 | var trending []*db.TrendingItem | 31 | var trending []*db.TrendingItem |
| 39 | var err error | 32 | var err error |
| 40 | - if scope == "for-me" { | 33 | + |
| 41 | - trending, err = s.dbs.Articles.ListTrendingArticlesForUser(ctx, userDID, since, userLangs, page.Limit()+1, page.Offset()) | 34 | + if scope == scopeForMe { |
| 35 | + var userLangs []string | ||
| 36 | + if user != nil { | ||
| 37 | + userLangs, _ = s.dbs.Users.GetLanguages(ctx, user.DID) | ||
| 38 | + } | ||
| 39 | + trending, err = s.engine.GetPersonalTrending(ctx, userDID, userLangs, page.Limit()+1, page.Offset()) | ||
| 42 | } else { | 40 | } else { |
| 43 | - trending, err = s.dbs.Articles.ListTrendingArticles(ctx, userDID, since, page.Limit()+1, page.Offset()) | 41 | + trending, err = s.engine.GetGlobalTrending(ctx, userDID, page.Limit()+1, page.Offset()) |
| 44 | } | 42 | } |
| 45 | if err != nil { | 43 | if err != nil { |
| 46 | s.logger.Warn("failed to list trending articles", "error", err, "scope", scope) | 44 | s.logger.Warn("failed to list trending articles", "error", err, "scope", scope) |
modified
internal/tmpl/dashboard.html +61 -141 | @@ -12,9 +12,9 @@ | ||
| 12 | 12 | {{if eq .SubscriptionCount 0}}Get started by subscribing to RSS feeds.{{else}}Your personalized feed, based on your social graph.{{end}} |
| 13 | 13 | </p> |
| 14 | 14 | |
| 15 | -{{if eq .SubscriptionCount 0}} | |
| 16 | 15 | <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div> |
| 17 | 16 | |
| 17 | +{{if eq .SubscriptionCount 0}} | |
| 18 | 18 | <div class="bg-spot-surface rounded-2xl py-12 px-6 text-center mb-8"> |
| 19 | 19 | <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> |
| 20 | 20 | <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg> |
| @@ -26,184 +26,104 @@ | ||
| 26 | 26 | Add feeds |
| 27 | 27 | </a> |
| 28 | 28 | </div> |
| 29 | - | |
| 30 | -{{if .GlobalTrending}} | |
| 29 | +{{else if .Articles}} | |
| 30 | +{{if .ArticleRecommendations}} | |
| 31 | 31 | <div class="mb-8"> |
| 32 | - <div class="flex items-center justify-between mb-4"> | |
| 33 | - <h2 class="text-lg font-semibold text-spot-text">Trending</h2> | |
| 34 | - <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a> | |
| 35 | - </div> | |
| 36 | - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> | |
| 37 | - {{range .GlobalTrending}} | |
| 38 | - {{template "trending-card.html" .}} | |
| 32 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> | |
| 33 | + <div class="space-y-3"> | |
| 34 | + {{range .ArticleRecommendations}} | |
| 35 | + {{template "recommendation-article-card.html" .}} | |
| 39 | 36 | {{end}} |
| 40 | 37 | </div> |
| 41 | 38 | </div> |
| 42 | 39 | {{end}} |
| 43 | 40 | |
| 44 | -{{if .FeedRecommendations}} | |
| 45 | 41 | <div class="mb-8"> |
| 46 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Popular feeds to get started</h2> | |
| 47 | - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> | |
| 48 | - {{range .FeedRecommendations}} | |
| 49 | - {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}} | |
| 42 | + <div class="flex items-center justify-between mb-4"> | |
| 43 | + <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2> | |
| 44 | + <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">View all</a> | |
| 45 | + </div> | |
| 46 | + <div class="space-y-3"> | |
| 47 | + {{range .Articles}} | |
| 48 | + {{template "article-card.html" .}} | |
| 50 | 49 | {{end}} |
| 51 | 50 | </div> |
| 51 | + {{if gt .UnreadCount 5}} | |
| 52 | + <a href="/articles" class="block mt-4 text-center text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition py-2">View all {{.UnreadCount}} unread articles</a> | |
| 53 | + {{end}} | |
| 52 | 54 | </div> |
| 53 | -{{end}} | |
| 54 | - | |
| 55 | 55 | {{else}} |
| 56 | -<div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div> | |
| 57 | - | |
| 58 | -{{if .Articles}} | |
| 59 | -<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| 60 | - <div class="lg:col-span-2"> | |
| 61 | - {{if .ArticleRecommendations}} | |
| 62 | - <div class="mb-8"> | |
| 63 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> | |
| 64 | - <div class="space-y-3"> | |
| 65 | - {{range .ArticleRecommendations}} | |
| 66 | - {{template "recommendation-article-card.html" .}} | |
| 67 | - {{end}} | |
| 68 | - </div> | |
| 69 | - </div> | |
| 56 | +{{if .ArticleRecommendations}} | |
| 57 | +<div class="mb-8"> | |
| 58 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> | |
| 59 | + <div class="space-y-3"> | |
| 60 | + {{range .ArticleRecommendations}} | |
| 61 | + {{template "recommendation-article-card.html" .}} | |
| 70 | 62 | {{end}} |
| 63 | + </div> | |
| 64 | +</div> | |
| 65 | +{{end}} | |
| 71 | 66 | |
| 72 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2> | |
| 73 | - <div id="article-list" class="space-y-3"> | |
| 74 | - {{range .Articles}} | |
| 75 | - {{template "article-card.html" .}} | |
| 76 | - {{end}} | |
| 67 | +<div class="mb-8"> | |
| 68 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2> | |
| 69 | + <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center"> | |
| 70 | + <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> | |
| 71 | + <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> | |
| 77 | 72 | </div> |
| 78 | - | |
| 79 | - {{template "pagination.html" (dict "HasPrev" .Page.HasPrev "HasNext" .Page.HasNext "PrevPage" .Page.PrevPage "NextPage" .Page.NextPage "Page" .Page.Page "BaseURL" .BaseURL "QueryParams" .QueryParams)}} | |
| 73 | + <h3 class="text-xl font-semibold text-spot-text mb-2">You're all caught up!</h3> | |
| 74 | + <p class="text-sm text-spot-secondary">No unread articles. Check back later for new content.</p> | |
| 80 | 75 | </div> |
| 76 | +</div> | |
| 77 | +{{end}} | |
| 81 | 78 | |
| 82 | - <div class="space-y-8"> | |
| 83 | - {{if .PersonalTrending}} | |
| 84 | - <div> | |
| 85 | - <div class="flex items-center justify-between mb-4"> | |
| 86 | - <h2 class="text-lg font-semibold text-spot-text">Trending in your network</h2> | |
| 87 | - <a href="/trending?scope=for-me" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a> | |
| 88 | - </div> | |
| 89 | - <div class="space-y-3"> | |
| 90 | - {{range .PersonalTrending}} | |
| 91 | - {{template "trending-card.html" .}} | |
| 92 | - {{end}} | |
| 93 | - </div> | |
| 94 | - </div> | |
| 95 | - {{end}} | |
| 79 | +{{if or .GlobalTrending .PersonalTrending}} | |
| 80 | +<div class="mb-8"> | |
| 81 | + <div class="flex items-center justify-between mb-4"> | |
| 82 | + <h2 class="text-lg font-semibold text-spot-text">{{if eq .SubscriptionCount 0}}Trending{{else}}Trending in your network{{end}}</h2> | |
| 83 | + <a href="{{if eq .SubscriptionCount 0}}/trending{{else}}/trending?scope=for-me{{end}}" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a> | |
| 84 | + </div> | |
| 85 | + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> | |
| 86 | + {{range .GlobalTrending}}{{template "trending-card.html" .}}{{end}} | |
| 87 | + {{range .PersonalTrending}}{{template "trending-card.html" .}}{{end}} | |
| 88 | + </div> | |
| 89 | +</div> | |
| 90 | +{{end}} | |
| 96 | 91 | |
| 97 | - {{if or .FollowedPeople .DiscoverPeople}} | |
| 92 | +{{if or .FollowedPeople .DiscoverPeople}} | |
| 93 | +<div class="mb-8"> | |
| 94 | + <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| 95 | + {{if .FollowedPeople}} | |
| 98 | 96 | <div> |
| 99 | - {{if .FollowedPeople}} | |
| 100 | 97 | <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2> |
| 101 | 98 | <div class="space-y-3"> |
| 102 | 99 | {{range .FollowedPeople}} |
| 103 | 100 | {{template "profile-card.html" .}} |
| 104 | 101 | {{end}} |
| 105 | 102 | </div> |
| 106 | - {{end}} | |
| 107 | - {{if .DiscoverPeople}} | |
| 108 | - <h2 class="text-lg font-semibold text-spot-text mb-4 {{if .FollowedPeople}}mt-8{{end}}">Discover new readers</h2> | |
| 109 | - <div class="space-y-3"> | |
| 110 | - {{range .DiscoverPeople}} | |
| 111 | - {{template "profile-card.html" .}} | |
| 112 | - {{end}} | |
| 113 | - </div> | |
| 114 | - {{end}} | |
| 115 | 103 | </div> |
| 116 | 104 | {{end}} |
| 117 | - | |
| 118 | - {{if .FeedRecommendations}} | |
| 105 | + {{if .DiscoverPeople}} | |
| 119 | 106 | <div> |
| 120 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2> | |
| 107 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2> | |
| 121 | 108 | <div class="space-y-3"> |
| 122 | - {{range .FeedRecommendations}} | |
| 123 | - {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}} | |
| 109 | + {{range .DiscoverPeople}} | |
| 110 | + {{template "profile-card.html" .}} | |
| 124 | 111 | {{end}} |
| 125 | 112 | </div> |
| 126 | 113 | </div> |
| 127 | 114 | {{end}} |
| 128 | 115 | </div> |
| 129 | 116 | </div> |
| 130 | -{{else}} | |
| 131 | -<div id="article-list"></div> | |
| 132 | -<div class="bg-spot-surface rounded-2xl py-14 px-6 text-center mb-8"> | |
| 133 | - <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> | |
| 134 | - <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> | |
| 135 | - </div> | |
| 136 | - <h2 class="text-xl font-semibold text-spot-text mb-2">You're all caught up!</h2> | |
| 137 | - <p class="text-sm text-spot-secondary">No unread articles. Check back later for new content.</p> | |
| 138 | -</div> | |
| 117 | +{{end}} | |
| 139 | 118 | |
| 140 | -{{if .ArticleRecommendations}} | |
| 119 | +{{if .FeedRecommendations}} | |
| 141 | 120 | <div class="mb-8"> |
| 142 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> | |
| 143 | - <div class="grid grid-cols-1 md:grid-cols-2 gap-3"> | |
| 144 | - {{range .ArticleRecommendations}} | |
| 145 | - {{template "recommendation-article-card.html" .}} | |
| 121 | + <h2 class="text-lg font-semibold text-spot-text mb-4">{{if eq .SubscriptionCount 0}}Popular feeds to get started{{else}}Recommended feeds{{end}}</h2> | |
| 122 | + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> | |
| 123 | + {{range .FeedRecommendations}} | |
| 124 | + {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}} | |
| 146 | 125 | {{end}} |
| 147 | 126 | </div> |
| 148 | 127 | </div> |
| 149 | 128 | {{end}} |
| 150 | - | |
| 151 | -{{if or .PersonalTrending (or .FollowedPeople .DiscoverPeople) .FeedRecommendations}} | |
| 152 | -<div class="space-y-8"> | |
| 153 | - {{if .PersonalTrending}} | |
| 154 | - <div> | |
| 155 | - <div class="flex items-center justify-between mb-4"> | |
| 156 | - <h2 class="text-lg font-semibold text-spot-text">Trending in your network</h2> | |
| 157 | - <a href="/trending?scope=for-me" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a> | |
| 158 | - </div> | |
| 159 | - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> | |
| 160 | - {{range .PersonalTrending}} | |
| 161 | - {{template "trending-card.html" .}} | |
| 162 | - {{end}} | |
| 163 | - </div> | |
| 164 | - </div> | |
| 165 | - {{end}} | |
| 166 | - | |
| 167 | - {{if or .FollowedPeople .DiscoverPeople}} | |
| 168 | - <div> | |
| 169 | - <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| 170 | - {{if .FollowedPeople}} | |
| 171 | - <div> | |
| 172 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2> | |
| 173 | - <div class="space-y-3"> | |
| 174 | - {{range .FollowedPeople}} | |
| 175 | - {{template "profile-card.html" .}} | |
| 176 | - {{end}} | |
| 177 | - </div> | |
| 178 | - </div> | |
| 179 | - {{end}} | |
| 180 | - {{if .DiscoverPeople}} | |
| 181 | - <div> | |
| 182 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2> | |
| 183 | - <div class="space-y-3"> | |
| 184 | - {{range .DiscoverPeople}} | |
| 185 | - {{template "profile-card.html" .}} | |
| 186 | - {{end}} | |
| 187 | - </div> | |
| 188 | - </div> | |
| 189 | - {{end}} | |
| 190 | - </div> | |
| 191 | - </div> | |
| 192 | - {{end}} | |
| 193 | - | |
| 194 | - {{if .FeedRecommendations}} | |
| 195 | - <div> | |
| 196 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2> | |
| 197 | - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> | |
| 198 | - {{range .FeedRecommendations}} | |
| 199 | - {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}} | |
| 200 | - {{end}} | |
| 201 | - </div> | |
| 202 | - </div> | |
| 203 | - {{end}} | |
| 204 | -</div> | |
| 205 | -{{end}} | |
| 206 | - | |
| 207 | -{{end}} | |
| 208 | -{{end}} | |
| 209 | 129 | {{end}} |
| @@ -12,9 +12,9 @@ | |||
| 12 | {{if eq .SubscriptionCount 0}}Get started by subscribing to RSS feeds.{{else}}Your personalized feed, based on your social graph.{{end}} | 12 | {{if eq .SubscriptionCount 0}}Get started by subscribing to RSS feeds.{{else}}Your personalized feed, based on your social graph.{{end}} |
| 13 | </p> | 13 | </p> |
| 14 | 14 | ||
| 15 | -{{if eq .SubscriptionCount 0}} | ||
| 16 | <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div> | 15 | <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div> |
| 17 | 16 | ||
| 17 | +{{if eq .SubscriptionCount 0}} | ||
| 18 | <div class="bg-spot-surface rounded-2xl py-12 px-6 text-center mb-8"> | 18 | <div class="bg-spot-surface rounded-2xl py-12 px-6 text-center mb-8"> |
| 19 | <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> | 19 | <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> |
| 20 | <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg> | 20 | <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg> |
| @@ -26,184 +26,104 @@ | |||
| 26 | Add feeds | 26 | Add feeds |
| 27 | </a> | 27 | </a> |
| 28 | </div> | 28 | </div> |
| 29 | - | 29 | +{{else if .Articles}} |
| 30 | -{{if .GlobalTrending}} | 30 | +{{if .ArticleRecommendations}} |
| 31 | <div class="mb-8"> | 31 | <div class="mb-8"> |
| 32 | - <div class="flex items-center justify-between mb-4"> | 32 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> |
| 33 | - <h2 class="text-lg font-semibold text-spot-text">Trending</h2> | 33 | + <div class="space-y-3"> |
| 34 | - <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a> | 34 | + {{range .ArticleRecommendations}} |
| 35 | - </div> | 35 | + {{template "recommendation-article-card.html" .}} |
| 36 | - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> | ||
| 37 | - {{range .GlobalTrending}} | ||
| 38 | - {{template "trending-card.html" .}} | ||
| 39 | {{end}} | 36 | {{end}} |
| 40 | </div> | 37 | </div> |
| 41 | </div> | 38 | </div> |
| 42 | {{end}} | 39 | {{end}} |
| 43 | 40 | ||
| 44 | -{{if .FeedRecommendations}} | ||
| 45 | <div class="mb-8"> | 41 | <div class="mb-8"> |
| 46 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Popular feeds to get started</h2> | 42 | + <div class="flex items-center justify-between mb-4"> |
| 47 | - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> | 43 | + <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2> |
| 48 | - {{range .FeedRecommendations}} | 44 | + <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">View all</a> |
| 49 | - {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}} | 45 | + </div> |
| 46 | + <div class="space-y-3"> | ||
| 47 | + {{range .Articles}} | ||
| 48 | + {{template "article-card.html" .}} | ||
| 50 | {{end}} | 49 | {{end}} |
| 51 | </div> | 50 | </div> |
| 51 | + {{if gt .UnreadCount 5}} | ||
| 52 | + <a href="/articles" class="block mt-4 text-center text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition py-2">View all {{.UnreadCount}} unread articles</a> | ||
| 53 | + {{end}} | ||
| 52 | </div> | 54 | </div> |
| 53 | -{{end}} | ||
| 54 | - | ||
| 55 | {{else}} | 55 | {{else}} |
| 56 | -<div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div> | 56 | +{{if .ArticleRecommendations}} |
| 57 | - | 57 | +<div class="mb-8"> |
| 58 | -{{if .Articles}} | 58 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> |
| 59 | -<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | 59 | + <div class="space-y-3"> |
| 60 | - <div class="lg:col-span-2"> | 60 | + {{range .ArticleRecommendations}} |
| 61 | - {{if .ArticleRecommendations}} | 61 | + {{template "recommendation-article-card.html" .}} |
| 62 | - <div class="mb-8"> | ||
| 63 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> | ||
| 64 | - <div class="space-y-3"> | ||
| 65 | - {{range .ArticleRecommendations}} | ||
| 66 | - {{template "recommendation-article-card.html" .}} | ||
| 67 | - {{end}} | ||
| 68 | - </div> | ||
| 69 | - </div> | ||
| 70 | {{end}} | 62 | {{end}} |
| 63 | + </div> | ||
| 64 | +</div> | ||
| 65 | +{{end}} | ||
| 71 | 66 | ||
| 72 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2> | 67 | +<div class="mb-8"> |
| 73 | - <div id="article-list" class="space-y-3"> | 68 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2> |
| 74 | - {{range .Articles}} | 69 | + <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center"> |
| 75 | - {{template "article-card.html" .}} | 70 | + <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> |
| 76 | - {{end}} | 71 | + <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> |
| 77 | </div> | 72 | </div> |
| 78 | - | 73 | + <h3 class="text-xl font-semibold text-spot-text mb-2">You're all caught up!</h3> |
| 79 | - {{template "pagination.html" (dict "HasPrev" .Page.HasPrev "HasNext" .Page.HasNext "PrevPage" .Page.PrevPage "NextPage" .Page.NextPage "Page" .Page.Page "BaseURL" .BaseURL "QueryParams" .QueryParams)}} | 74 | + <p class="text-sm text-spot-secondary">No unread articles. Check back later for new content.</p> |
| 80 | </div> | 75 | </div> |
| 76 | +</div> | ||
| 77 | +{{end}} | ||
| 81 | 78 | ||
| 82 | - <div class="space-y-8"> | 79 | +{{if or .GlobalTrending .PersonalTrending}} |
| 83 | - {{if .PersonalTrending}} | 80 | +<div class="mb-8"> |
| 84 | - <div> | 81 | + <div class="flex items-center justify-between mb-4"> |
| 85 | - <div class="flex items-center justify-between mb-4"> | 82 | + <h2 class="text-lg font-semibold text-spot-text">{{if eq .SubscriptionCount 0}}Trending{{else}}Trending in your network{{end}}</h2> |
| 86 | - <h2 class="text-lg font-semibold text-spot-text">Trending in your network</h2> | 83 | + <a href="{{if eq .SubscriptionCount 0}}/trending{{else}}/trending?scope=for-me{{end}}" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a> |
| 87 | - <a href="/trending?scope=for-me" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a> | 84 | + </div> |
| 88 | - </div> | 85 | + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> |
| 89 | - <div class="space-y-3"> | 86 | + {{range .GlobalTrending}}{{template "trending-card.html" .}}{{end}} |
| 90 | - {{range .PersonalTrending}} | 87 | + {{range .PersonalTrending}}{{template "trending-card.html" .}}{{end}} |
| 91 | - {{template "trending-card.html" .}} | 88 | + </div> |
| 92 | - {{end}} | 89 | +</div> |
| 93 | - </div> | 90 | +{{end}} |
| 94 | - </div> | ||
| 95 | - {{end}} | ||
| 96 | 91 | ||
| 97 | - {{if or .FollowedPeople .DiscoverPeople}} | 92 | +{{if or .FollowedPeople .DiscoverPeople}} |
| 93 | +<div class="mb-8"> | ||
| 94 | + <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | ||
| 95 | + {{if .FollowedPeople}} | ||
| 98 | <div> | 96 | <div> |
| 99 | - {{if .FollowedPeople}} | ||
| 100 | <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2> | 97 | <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2> |
| 101 | <div class="space-y-3"> | 98 | <div class="space-y-3"> |
| 102 | {{range .FollowedPeople}} | 99 | {{range .FollowedPeople}} |
| 103 | {{template "profile-card.html" .}} | 100 | {{template "profile-card.html" .}} |
| 104 | {{end}} | 101 | {{end}} |
| 105 | </div> | 102 | </div> |
| 106 | - {{end}} | ||
| 107 | - {{if .DiscoverPeople}} | ||
| 108 | - <h2 class="text-lg font-semibold text-spot-text mb-4 {{if .FollowedPeople}}mt-8{{end}}">Discover new readers</h2> | ||
| 109 | - <div class="space-y-3"> | ||
| 110 | - {{range .DiscoverPeople}} | ||
| 111 | - {{template "profile-card.html" .}} | ||
| 112 | - {{end}} | ||
| 113 | - </div> | ||
| 114 | - {{end}} | ||
| 115 | </div> | 103 | </div> |
| 116 | {{end}} | 104 | {{end}} |
| 117 | - | 105 | + {{if .DiscoverPeople}} |
| 118 | - {{if .FeedRecommendations}} | ||
| 119 | <div> | 106 | <div> |
| 120 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2> | 107 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2> |
| 121 | <div class="space-y-3"> | 108 | <div class="space-y-3"> |
| 122 | - {{range .FeedRecommendations}} | 109 | + {{range .DiscoverPeople}} |
| 123 | - {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}} | 110 | + {{template "profile-card.html" .}} |
| 124 | {{end}} | 111 | {{end}} |
| 125 | </div> | 112 | </div> |
| 126 | </div> | 113 | </div> |
| 127 | {{end}} | 114 | {{end}} |
| 128 | </div> | 115 | </div> |
| 129 | </div> | 116 | </div> |
| 130 | -{{else}} | 117 | +{{end}} |
| 131 | -<div id="article-list"></div> | ||
| 132 | -<div class="bg-spot-surface rounded-2xl py-14 px-6 text-center mb-8"> | ||
| 133 | - <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> | ||
| 134 | - <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> | ||
| 135 | - </div> | ||
| 136 | - <h2 class="text-xl font-semibold text-spot-text mb-2">You're all caught up!</h2> | ||
| 137 | - <p class="text-sm text-spot-secondary">No unread articles. Check back later for new content.</p> | ||
| 138 | -</div> | ||
| 139 | 118 | ||
| 140 | -{{if .ArticleRecommendations}} | 119 | +{{if .FeedRecommendations}} |
| 141 | <div class="mb-8"> | 120 | <div class="mb-8"> |
| 142 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> | 121 | + <h2 class="text-lg font-semibold text-spot-text mb-4">{{if eq .SubscriptionCount 0}}Popular feeds to get started{{else}}Recommended feeds{{end}}</h2> |
| 143 | - <div class="grid grid-cols-1 md:grid-cols-2 gap-3"> | 122 | + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> |
| 144 | - {{range .ArticleRecommendations}} | 123 | + {{range .FeedRecommendations}} |
| 145 | - {{template "recommendation-article-card.html" .}} | 124 | + {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}} |
| 146 | {{end}} | 125 | {{end}} |
| 147 | </div> | 126 | </div> |
| 148 | </div> | 127 | </div> |
| 149 | {{end}} | 128 | {{end}} |
| 150 | - | ||
| 151 | -{{if or .PersonalTrending (or .FollowedPeople .DiscoverPeople) .FeedRecommendations}} | ||
| 152 | -<div class="space-y-8"> | ||
| 153 | - {{if .PersonalTrending}} | ||
| 154 | - <div> | ||
| 155 | - <div class="flex items-center justify-between mb-4"> | ||
| 156 | - <h2 class="text-lg font-semibold text-spot-text">Trending in your network</h2> | ||
| 157 | - <a href="/trending?scope=for-me" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a> | ||
| 158 | - </div> | ||
| 159 | - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> | ||
| 160 | - {{range .PersonalTrending}} | ||
| 161 | - {{template "trending-card.html" .}} | ||
| 162 | - {{end}} | ||
| 163 | - </div> | ||
| 164 | - </div> | ||
| 165 | - {{end}} | ||
| 166 | - | ||
| 167 | - {{if or .FollowedPeople .DiscoverPeople}} | ||
| 168 | - <div> | ||
| 169 | - <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | ||
| 170 | - {{if .FollowedPeople}} | ||
| 171 | - <div> | ||
| 172 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2> | ||
| 173 | - <div class="space-y-3"> | ||
| 174 | - {{range .FollowedPeople}} | ||
| 175 | - {{template "profile-card.html" .}} | ||
| 176 | - {{end}} | ||
| 177 | - </div> | ||
| 178 | - </div> | ||
| 179 | - {{end}} | ||
| 180 | - {{if .DiscoverPeople}} | ||
| 181 | - <div> | ||
| 182 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2> | ||
| 183 | - <div class="space-y-3"> | ||
| 184 | - {{range .DiscoverPeople}} | ||
| 185 | - {{template "profile-card.html" .}} | ||
| 186 | - {{end}} | ||
| 187 | - </div> | ||
| 188 | - </div> | ||
| 189 | - {{end}} | ||
| 190 | - </div> | ||
| 191 | - </div> | ||
| 192 | - {{end}} | ||
| 193 | - | ||
| 194 | - {{if .FeedRecommendations}} | ||
| 195 | - <div> | ||
| 196 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2> | ||
| 197 | - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> | ||
| 198 | - {{range .FeedRecommendations}} | ||
| 199 | - {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}} | ||
| 200 | - {{end}} | ||
| 201 | - </div> | ||
| 202 | - </div> | ||
| 203 | - {{end}} | ||
| 204 | -</div> | ||
| 205 | -{{end}} | ||
| 206 | - | ||
| 207 | -{{end}} | ||
| 208 | -{{end}} | ||
| 209 | {{end}} | 129 | {{end}} |
modified
main.go +1 -1 | @@ -99,7 +99,7 @@ func main() { | ||
| 99 | 99 | }) |
| 100 | 100 | } |
| 101 | 101 | |
| 102 | - engine := cluster.NewEngine(dbs.SQLDB(), embedder, llm, logger) | |
| 102 | + engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, logger, *clusterInterval, cluster.DefaultConfig()) | |
| 103 | 103 | |
| 104 | 104 | fetcher := feed.NewFetcher(siteFetcher) |
| 105 | 105 | srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey)) |
| @@ -99,7 +99,7 @@ func main() { | |||
| 99 | }) | 99 | }) |
| 100 | } | 100 | } |
| 101 | 101 | ||
| 102 | - engine := cluster.NewEngine(dbs.SQLDB(), embedder, llm, logger) | 102 | + engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, logger, *clusterInterval, cluster.DefaultConfig()) |
| 103 | 103 | ||
| 104 | fetcher := feed.NewFetcher(siteFetcher) | 104 | fetcher := feed.NewFetcher(siteFetcher) |
| 105 | srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey)) | 105 | srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey)) |