Remove read status from article recommendations and unify card templatesUnverified
867e4ef parent: d2575c6 modified
internal/cluster/scoring.go +1 -3 | @@ -43,7 +43,6 @@ type ArticleRecommendation struct { | ||
| 43 | 43 | Author string |
| 44 | 44 | Summary string |
| 45 | 45 | Published sql.NullTime |
| 46 | - IsRead bool | |
| 47 | 46 | Score float64 |
| 48 | 47 | } |
| 49 | 48 | |
| @@ -488,7 +487,6 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user | ||
| 488 | 487 | SELECT a.id, a.title, COALESCE(a.url, ''), la.feed_url, COALESCE(f.title, ''), |
| 489 | 488 | COALESCE(f.favicon_url, ''), |
| 490 | 489 | COALESCE(a.author, ''), COALESCE(a.summary, ''), a.published, |
| 491 | - COALESCE(rs.is_read, 0), | |
| 492 | 490 | COALESCE(la.like_signal, 0) * ? |
| 493 | 491 | + COALESCE(sl.social, 0) * ? |
| 494 | 492 | + COALESCE(cb.score, 0) * ? |
| @@ -520,7 +518,7 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user | ||
| 520 | 518 | for rows.Next() { |
| 521 | 519 | rec := &ArticleRecommendation{} |
| 522 | 520 | if err := rows.Scan(&rec.ArticleID, &rec.Title, &rec.URL, &rec.FeedURL, &rec.FeedTitle, |
| 523 | - &rec.FaviconURL, &rec.Author, &rec.Summary, &rec.Published, &rec.IsRead, &rec.Score); err != nil { | |
| 521 | + &rec.FaviconURL, &rec.Author, &rec.Summary, &rec.Published, &rec.Score); err != nil { | |
| 524 | 522 | return nil, err |
| 525 | 523 | } |
| 526 | 524 | recs = append(recs, rec) |
| @@ -43,7 +43,6 @@ type ArticleRecommendation struct { | |||
| 43 | Author string | 43 | Author string |
| 44 | Summary string | 44 | Summary string |
| 45 | Published sql.NullTime | 45 | Published sql.NullTime |
| 46 | - IsRead bool | ||
| 47 | Score float64 | 46 | Score float64 |
| 48 | } | 47 | } |
| 49 | 48 | ||
| @@ -488,7 +487,6 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user | |||
| 488 | SELECT a.id, a.title, COALESCE(a.url, ''), la.feed_url, COALESCE(f.title, ''), | 487 | SELECT a.id, a.title, COALESCE(a.url, ''), la.feed_url, COALESCE(f.title, ''), |
| 489 | COALESCE(f.favicon_url, ''), | 488 | COALESCE(f.favicon_url, ''), |
| 490 | COALESCE(a.author, ''), COALESCE(a.summary, ''), a.published, | 489 | COALESCE(a.author, ''), COALESCE(a.summary, ''), a.published, |
| 491 | - COALESCE(rs.is_read, 0), | ||
| 492 | COALESCE(la.like_signal, 0) * ? | 490 | COALESCE(la.like_signal, 0) * ? |
| 493 | + COALESCE(sl.social, 0) * ? | 491 | + COALESCE(sl.social, 0) * ? |
| 494 | + COALESCE(cb.score, 0) * ? | 492 | + COALESCE(cb.score, 0) * ? |
| @@ -520,7 +518,7 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user | |||
| 520 | for rows.Next() { | 518 | for rows.Next() { |
| 521 | rec := &ArticleRecommendation{} | 519 | rec := &ArticleRecommendation{} |
| 522 | if err := rows.Scan(&rec.ArticleID, &rec.Title, &rec.URL, &rec.FeedURL, &rec.FeedTitle, | 520 | if err := rows.Scan(&rec.ArticleID, &rec.Title, &rec.URL, &rec.FeedURL, &rec.FeedTitle, |
| 523 | - &rec.FaviconURL, &rec.Author, &rec.Summary, &rec.Published, &rec.IsRead, &rec.Score); err != nil { | 521 | + &rec.FaviconURL, &rec.Author, &rec.Summary, &rec.Published, &rec.Score); err != nil { |
| 524 | return nil, err | 522 | return nil, err |
| 525 | } | 523 | } |
| 526 | recs = append(recs, rec) | 524 | recs = append(recs, rec) |
modified
internal/db/article.go +4 -0 | @@ -42,6 +42,10 @@ type Article struct { | ||
| 42 | 42 | // NavSuffix holds the query string appended to article detail links to preserve |
| 43 | 43 | // listing context (feed scope, liked) for next-article navigation. |
| 44 | 44 | NavSuffix string |
| 45 | + // DismissURL, when non-empty, shows a dismiss button that POSTs to this URL. | |
| 46 | + DismissURL string | |
| 47 | + DismissField string | |
| 48 | + DismissValue string | |
| 45 | 49 | } |
| 46 | 50 | |
| 47 | 51 | type ReadState struct { |
| @@ -42,6 +42,10 @@ type Article struct { | |||
| 42 | // NavSuffix holds the query string appended to article detail links to preserve | 42 | // NavSuffix holds the query string appended to article detail links to preserve |
| 43 | // listing context (feed scope, liked) for next-article navigation. | 43 | // listing context (feed scope, liked) for next-article navigation. |
| 44 | NavSuffix string | 44 | NavSuffix string |
| 45 | + // DismissURL, when non-empty, shows a dismiss button that POSTs to this URL. | ||
| 46 | + DismissURL string | ||
| 47 | + DismissField string | ||
| 48 | + DismissValue string | ||
| 45 | } | 49 | } |
| 46 | 50 | ||
| 47 | type ReadState struct { | 51 | type ReadState struct { |
modified
internal/server/dashboard_handler.go +21 -1 | @@ -2,6 +2,7 @@ package server | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | + "database/sql" | |
| 5 | 6 | "net/http" |
| 6 | 7 | "time" |
| 7 | 8 | |
| @@ -103,6 +104,25 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 103 | 104 | |
| 104 | 105 | resolvePeopleHandles(ctx, peopleRecs) |
| 105 | 106 | |
| 107 | + articleRecArticles := make([]*db.Article, len(articleRecs)) | |
| 108 | + for i, rec := range articleRecs { | |
| 109 | + articleRecArticles[i] = &db.Article{ | |
| 110 | + ID: rec.ArticleID, | |
| 111 | + FeedURL: rec.FeedURL, | |
| 112 | + FeedTitle: rec.FeedTitle, | |
| 113 | + FeedFaviconURL: sql.NullString{String: rec.FaviconURL, Valid: rec.FaviconURL != ""}, | |
| 114 | + Title: rec.Title, | |
| 115 | + URL: sql.NullString{String: rec.URL, Valid: rec.URL != ""}, | |
| 116 | + Author: sql.NullString{String: rec.Author, Valid: rec.Author != ""}, | |
| 117 | + Summary: sql.NullString{String: rec.Summary, Valid: rec.Summary != ""}, | |
| 118 | + Published: rec.Published, | |
| 119 | + IsRead: sql.NullBool{Bool: false, Valid: true}, | |
| 120 | + DismissURL: "/recs/dismiss-article", | |
| 121 | + DismissField: "article_url", | |
| 122 | + DismissValue: rec.URL, | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 106 | 126 | var impressions []feedback.Impression |
| 107 | 127 | for _, rec := range articleRecs { |
| 108 | 128 | impressions = append(impressions, feedback.Impression{TargetType: "article", TargetID: rec.URL}) |
| @@ -133,7 +153,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 133 | 153 | "SubscriptionCount": subCount, |
| 134 | 154 | "UnreadCount": unreadCount, |
| 135 | 155 | "Articles": articles, |
| 136 | - "ArticleRecommendations": articleRecs, | |
| 156 | + "ArticleRecommendations": articleRecArticles, | |
| 137 | 157 | "FeedRecommendations": feedRecs, |
| 138 | 158 | "FollowedPeople": followedPeople, |
| 139 | 159 | "DiscoverPeople": discoverPeople, |
| @@ -2,6 +2,7 @@ package server | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | + "database/sql" | ||
| 5 | "net/http" | 6 | "net/http" |
| 6 | "time" | 7 | "time" |
| 7 | 8 | ||
| @@ -103,6 +104,25 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | |||
| 103 | 104 | ||
| 104 | resolvePeopleHandles(ctx, peopleRecs) | 105 | resolvePeopleHandles(ctx, peopleRecs) |
| 105 | 106 | ||
| 107 | + articleRecArticles := make([]*db.Article, len(articleRecs)) | ||
| 108 | + for i, rec := range articleRecs { | ||
| 109 | + articleRecArticles[i] = &db.Article{ | ||
| 110 | + ID: rec.ArticleID, | ||
| 111 | + FeedURL: rec.FeedURL, | ||
| 112 | + FeedTitle: rec.FeedTitle, | ||
| 113 | + FeedFaviconURL: sql.NullString{String: rec.FaviconURL, Valid: rec.FaviconURL != ""}, | ||
| 114 | + Title: rec.Title, | ||
| 115 | + URL: sql.NullString{String: rec.URL, Valid: rec.URL != ""}, | ||
| 116 | + Author: sql.NullString{String: rec.Author, Valid: rec.Author != ""}, | ||
| 117 | + Summary: sql.NullString{String: rec.Summary, Valid: rec.Summary != ""}, | ||
| 118 | + Published: rec.Published, | ||
| 119 | + IsRead: sql.NullBool{Bool: false, Valid: true}, | ||
| 120 | + DismissURL: "/recs/dismiss-article", | ||
| 121 | + DismissField: "article_url", | ||
| 122 | + DismissValue: rec.URL, | ||
| 123 | + } | ||
| 124 | + } | ||
| 125 | + | ||
| 106 | var impressions []feedback.Impression | 126 | var impressions []feedback.Impression |
| 107 | for _, rec := range articleRecs { | 127 | for _, rec := range articleRecs { |
| 108 | impressions = append(impressions, feedback.Impression{TargetType: "article", TargetID: rec.URL}) | 128 | impressions = append(impressions, feedback.Impression{TargetType: "article", TargetID: rec.URL}) |
| @@ -133,7 +153,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | |||
| 133 | "SubscriptionCount": subCount, | 153 | "SubscriptionCount": subCount, |
| 134 | "UnreadCount": unreadCount, | 154 | "UnreadCount": unreadCount, |
| 135 | "Articles": articles, | 155 | "Articles": articles, |
| 136 | - "ArticleRecommendations": articleRecs, | 156 | + "ArticleRecommendations": articleRecArticles, |
| 137 | "FeedRecommendations": feedRecs, | 157 | "FeedRecommendations": feedRecs, |
| 138 | "FollowedPeople": followedPeople, | 158 | "FollowedPeople": followedPeople, |
| 139 | "DiscoverPeople": discoverPeople, | 159 | "DiscoverPeople": discoverPeople, |
modified
internal/tmpl/dashboard.html +2 -2 | @@ -32,7 +32,7 @@ | ||
| 32 | 32 | <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> |
| 33 | 33 | <div class="space-y-3"> |
| 34 | 34 | {{range .ArticleRecommendations}} |
| 35 | - {{template "recommendation-article-card.html" .}} | |
| 35 | + {{template "article-card.html" .}} | |
| 36 | 36 | {{end}} |
| 37 | 37 | </div> |
| 38 | 38 | </div> |
| @@ -76,7 +76,7 @@ | ||
| 76 | 76 | <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> |
| 77 | 77 | <div class="space-y-3"> |
| 78 | 78 | {{range .ArticleRecommendations}} |
| 79 | - {{template "recommendation-article-card.html" .}} | |
| 79 | + {{template "article-card.html" .}} | |
| 80 | 80 | {{end}} |
| 81 | 81 | </div> |
| 82 | 82 | </div> |
| @@ -32,7 +32,7 @@ | |||
| 32 | <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> | 32 | <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> |
| 33 | <div class="space-y-3"> | 33 | <div class="space-y-3"> |
| 34 | {{range .ArticleRecommendations}} | 34 | {{range .ArticleRecommendations}} |
| 35 | - {{template "recommendation-article-card.html" .}} | 35 | + {{template "article-card.html" .}} |
| 36 | {{end}} | 36 | {{end}} |
| 37 | </div> | 37 | </div> |
| 38 | </div> | 38 | </div> |
| @@ -76,7 +76,7 @@ | |||
| 76 | <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> | 76 | <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2> |
| 77 | <div class="space-y-3"> | 77 | <div class="space-y-3"> |
| 78 | {{range .ArticleRecommendations}} | 78 | {{range .ArticleRecommendations}} |
| 79 | - {{template "recommendation-article-card.html" .}} | 79 | + {{template "article-card.html" .}} |
| 80 | {{end}} | 80 | {{end}} |
| 81 | </div> | 81 | </div> |
| 82 | </div> | 82 | </div> |
modified
internal/tmpl/partials/article-card.html +8 -0 | @@ -21,6 +21,14 @@ | ||
| 21 | 21 | <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> |
| 22 | 22 | <span>{{if .IsRead.Bool}}Unread{{else}}Read{{end}}</span> |
| 23 | 23 | </button> |
| 24 | + {{if .DismissURL}} | |
| 25 | + <button hx-post="{{.DismissURL}}" hx-target="closest article" hx-swap="delete" title="Remove from recommendations" | |
| 26 | + class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-red hover:bg-spot-red/15 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover transition w-full"> | |
| 27 | + <input type="hidden" name="{{.DismissField}}" value="{{.DismissValue}}"> | |
| 28 | + <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg> | |
| 29 | + <span>Hide</span> | |
| 30 | + </button> | |
| 31 | + {{end}} | |
| 24 | 32 | </div> |
| 25 | 33 | </div> |
| 26 | 34 | <div class="annotate-form hidden mt-4 pt-4 border-t border-spot-divider"> |
| @@ -21,6 +21,14 @@ | |||
| 21 | <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> | 21 | <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> |
| 22 | <span>{{if .IsRead.Bool}}Unread{{else}}Read{{end}}</span> | 22 | <span>{{if .IsRead.Bool}}Unread{{else}}Read{{end}}</span> |
| 23 | </button> | 23 | </button> |
| 24 | + {{if .DismissURL}} | ||
| 25 | + <button hx-post="{{.DismissURL}}" hx-target="closest article" hx-swap="delete" title="Remove from recommendations" | ||
| 26 | + class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-red hover:bg-spot-red/15 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover transition w-full"> | ||
| 27 | + <input type="hidden" name="{{.DismissField}}" value="{{.DismissValue}}"> | ||
| 28 | + <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg> | ||
| 29 | + <span>Hide</span> | ||
| 30 | + </button> | ||
| 31 | + {{end}} | ||
| 24 | </div> | 32 | </div> |
| 25 | </div> | 33 | </div> |
| 26 | <div class="annotate-form hidden mt-4 pt-4 border-t border-spot-divider"> | 34 | <div class="annotate-form hidden mt-4 pt-4 border-t border-spot-divider"> |
deleted
internal/tmpl/partials/recommendation-article-card.html +0 -27 | deleted file mode 100644 | ||
| @@ -1,27 +0,0 @@ | ||
| 1 | -{{define "recommendation-article-card.html"}} | |
| 2 | -<article data-article-url="{{.URL}}" class="bg-spot-surface rounded-xl px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot relative {{if not .IsRead}}border-l border-spot-green/80{{end}}"> | |
| 3 | - <div class="flex items-start justify-between gap-4"> | |
| 4 | - <div class="min-w-0 flex-1"> | |
| 5 | - <div class="flex items-start gap-2.5"> | |
| 6 | - <a href="/articles/{{.ArticleID}}" class="{{if .IsRead}}font-semibold text-[15px]{{else}}font-bold text-[17px]{{end}} text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</a> | |
| 7 | - </div> | |
| 8 | - <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap"> | |
| 9 | - {{if .FaviconURL}}{{template "favicon" dict "src" .FaviconURL "size" "w-3.5 h-3.5"}}{{end}} | |
| 10 | - <a href="/articles?feed={{.FeedURL}}" class="hover:text-spot-text transition font-medium">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</a> | |
| 11 | - {{if .Author}}<span class="text-spot-muted">·</span><span>{{.Author}}</span>{{end}} | |
| 12 | - {{if .Published.Valid}}<span class="text-spot-muted">·</span><span>{{.Published.Time.Format "Jan 2"}}</span>{{end}} | |
| 13 | - </div> | |
| 14 | - {{if .Summary}}<p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{plainText .Summary}}</p>{{end}} | |
| 15 | - </div> | |
| 16 | - <div class="shrink-0 pt-0.5"> | |
| 17 | - <button hx-post="/recs/dismiss-article" hx-target="closest article" hx-swap="delete" hx-include="#dismiss-{{.ArticleID}}" | |
| 18 | - class="text-[10px] text-spot-muted hover:text-spot-text uppercase tracking-button transition flex items-center gap-1" | |
| 19 | - title="Not interested"> | |
| 20 | - <input type="hidden" name="article_url" value="{{.URL}}" id="dismiss-{{.ArticleID}}"> | |
| 21 | - <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg> | |
| 22 | - Dismiss | |
| 23 | - </button> | |
| 24 | - </div> | |
| 25 | - </div> | |
| 26 | -</article> | |
| 27 | -{{end}} | |
| deleted file mode 100644 | |||
| @@ -1,27 +0,0 @@ | |||
| 1 | -{{define "recommendation-article-card.html"}} | ||
| 2 | -<article data-article-url="{{.URL}}" class="bg-spot-surface rounded-xl px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot relative {{if not .IsRead}}border-l border-spot-green/80{{end}}"> | ||
| 3 | - <div class="flex items-start justify-between gap-4"> | ||
| 4 | - <div class="min-w-0 flex-1"> | ||
| 5 | - <div class="flex items-start gap-2.5"> | ||
| 6 | - <a href="/articles/{{.ArticleID}}" class="{{if .IsRead}}font-semibold text-[15px]{{else}}font-bold text-[17px]{{end}} text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</a> | ||
| 7 | - </div> | ||
| 8 | - <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap"> | ||
| 9 | - {{if .FaviconURL}}{{template "favicon" dict "src" .FaviconURL "size" "w-3.5 h-3.5"}}{{end}} | ||
| 10 | - <a href="/articles?feed={{.FeedURL}}" class="hover:text-spot-text transition font-medium">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</a> | ||
| 11 | - {{if .Author}}<span class="text-spot-muted">·</span><span>{{.Author}}</span>{{end}} | ||
| 12 | - {{if .Published.Valid}}<span class="text-spot-muted">·</span><span>{{.Published.Time.Format "Jan 2"}}</span>{{end}} | ||
| 13 | - </div> | ||
| 14 | - {{if .Summary}}<p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{plainText .Summary}}</p>{{end}} | ||
| 15 | - </div> | ||
| 16 | - <div class="shrink-0 pt-0.5"> | ||
| 17 | - <button hx-post="/recs/dismiss-article" hx-target="closest article" hx-swap="delete" hx-include="#dismiss-{{.ArticleID}}" | ||
| 18 | - class="text-[10px] text-spot-muted hover:text-spot-text uppercase tracking-button transition flex items-center gap-1" | ||
| 19 | - title="Not interested"> | ||
| 20 | - <input type="hidden" name="article_url" value="{{.URL}}" id="dismiss-{{.ArticleID}}"> | ||
| 21 | - <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg> | ||
| 22 | - Dismiss | ||
| 23 | - </button> | ||
| 24 | - </div> | ||
| 25 | - </div> | ||
| 26 | -</article> | ||
| 27 | -{{end}} | ||