this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add link popularity view to stats page

Add a new view to the /stats page that displays links ordered by click count.
Users can toggle between "User Stats" and "Link Popularity" views. The link
popularity table shows rank, title, posted by, and click count with styling
that matches the main page (green links, gray usernames). Pagination is
supported for both views.

+171 -48
+10
internal/data/gorm_store.go
··· 331 331 func (s *GormStore) DeleteLinkPreview(ctx context.Context, url string) error { 332 332 return s.db.WithContext(ctx).Delete(&LinkPreview{}, "url = ?", url).Error 333 333 } 334 + 335 + func (s *GormStore) GetLinksByPopularity(ctx context.Context, limit int, offset int) ([]IRCLink, error) { 336 + var links []IRCLink 337 + err := s.db.WithContext(ctx). 338 + Order("clicks DESC, timestamp DESC"). 339 + Limit(limit). 340 + Offset(offset). 341 + Find(&links).Error 342 + return links, err 343 + }
+1
internal/data/store.go
··· 96 96 GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error) 97 97 GetUserTimeline(ctx context.Context, user string, filterType string, limit int, offset int) ([]TimelineItem, error) 98 98 GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) 99 + GetLinksByPopularity(ctx context.Context, limit int, offset int) ([]IRCLink, error) 99 100 100 101 // Caching 101 102 GetLinkPreview(ctx context.Context, url string) (*LinkPreview, error)
+102 -46
internal/handler/handlers.go
··· 8 8 "net/http" 9 9 "strconv" 10 10 "sync" 11 + "time" 11 12 12 13 "tumble/internal/config" 13 14 "tumble/internal/data" ··· 467 468 func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) { 468 469 ctx := r.Context() 469 470 471 + // View mode: "users" (default) or "links" 472 + view := r.URL.Query().Get("view") 473 + if view == "" { 474 + view = "users" 475 + } 476 + 470 477 // Pagination 471 478 page := 1 472 479 pageParam := r.URL.Query().Get("page") ··· 479 486 limit := 50 480 487 offset := (page - 1) * limit 481 488 482 - // Sorting 483 - sortBy := r.URL.Query().Get("sort") 484 - if sortBy == "" { 485 - sortBy = "links" 486 - } 487 - 488 - stats, err := h.Store.GetUserStats(ctx, sortBy, limit, offset) 489 - if err != nil { 490 - h.ServerError(w, r, err) 491 - return 492 - } 493 - 494 - // Prepare View Data with Ranks 495 - type StatViewItem struct { 496 - Rank int 497 - User string 498 - LinkCount int 499 - QuoteCount int 500 - } 501 - 502 - var statsView []StatViewItem 503 - for i, s := range stats { 504 - statsView = append(statsView, StatViewItem{ 505 - Rank: offset + i + 1, 506 - User: s.User, 507 - LinkCount: s.LinkCount, 508 - QuoteCount: s.QuoteCount, 509 - }) 510 - } 511 - 512 489 // Navigation 513 490 nextPage := page + 1 514 491 prevPage := page - 1 ··· 516 493 prevPage = 0 517 494 } 518 495 519 - // Check if we need a next page (simplistic: if we got full limit, likely there's more) 520 - hasNext := len(stats) == limit 496 + var data map[string]interface{} 497 + 498 + if view == "links" { 499 + // Link popularity view 500 + links, err := h.Store.GetLinksByPopularity(ctx, limit, offset) 501 + if err != nil { 502 + h.ServerError(w, r, err) 503 + return 504 + } 505 + 506 + // Prepare View Data with Ranks 507 + type LinkViewItem struct { 508 + Rank int 509 + ID int 510 + User string 511 + Title string 512 + URL string 513 + Clicks int 514 + Timestamp time.Time 515 + ContentType string 516 + } 517 + 518 + var linksView []LinkViewItem 519 + for i, link := range links { 520 + linksView = append(linksView, LinkViewItem{ 521 + Rank: offset + i + 1, 522 + ID: link.ID, 523 + User: link.User, 524 + Title: link.Title, 525 + URL: link.URL, 526 + Clicks: link.Clicks, 527 + Timestamp: link.Timestamp, 528 + ContentType: link.ContentType, 529 + }) 530 + } 531 + 532 + hasNext := len(links) == limit 533 + 534 + data = map[string]interface{}{ 535 + "Links": linksView, 536 + "PageTitle": " > Stats > Link Popularity", 537 + "GitCommit": version.CommitHash, 538 + "GitCommitURL": fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), 539 + "Page": page, 540 + "NextPage": nextPage, 541 + "PrevPage": prevPage, 542 + "HasNext": hasNext, 543 + "View": view, 544 + "Hot": h.getHotHTML(ctx), 545 + } 546 + } else { 547 + // User stats view (original) 548 + // Sorting 549 + sortBy := r.URL.Query().Get("sort") 550 + if sortBy == "" { 551 + sortBy = "links" 552 + } 553 + 554 + stats, err := h.Store.GetUserStats(ctx, sortBy, limit, offset) 555 + if err != nil { 556 + h.ServerError(w, r, err) 557 + return 558 + } 559 + 560 + // Prepare View Data with Ranks 561 + type StatViewItem struct { 562 + Rank int 563 + User string 564 + LinkCount int 565 + QuoteCount int 566 + } 567 + 568 + var statsView []StatViewItem 569 + for i, s := range stats { 570 + statsView = append(statsView, StatViewItem{ 571 + Rank: offset + i + 1, 572 + User: s.User, 573 + LinkCount: s.LinkCount, 574 + QuoteCount: s.QuoteCount, 575 + }) 576 + } 521 577 522 - // Determine Sort Order for links 523 - // Logic: If current sort is X, clicking X again should probably toggle or reset? 524 - // For simplicity, headers always sort descending by that column. 578 + hasNext := len(stats) == limit 525 579 526 - data := map[string]interface{}{ 527 - "Stats": statsView, 528 - "PageTitle": " > Stats", 529 - "GitCommit": version.CommitHash, 530 - "GitCommitURL": fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), 531 - "Page": page, 532 - "NextPage": nextPage, 533 - "PrevPage": prevPage, 534 - "HasNext": hasNext, 535 - "Sort": sortBy, 536 - "Hot": h.getHotHTML(ctx), 580 + data = map[string]interface{}{ 581 + "Stats": statsView, 582 + "PageTitle": " > Stats", 583 + "GitCommit": version.CommitHash, 584 + "GitCommitURL": fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), 585 + "Page": page, 586 + "NextPage": nextPage, 587 + "PrevPage": prevPage, 588 + "HasNext": hasNext, 589 + "Sort": sortBy, 590 + "View": view, 591 + "Hot": h.getHotHTML(ctx), 592 + } 537 593 } 538 594 539 595 if err := h.Renderer.Render(w, "stats.html", data); err != nil {
+58 -2
internal/templates/views/stats.html
··· 30 30 color: var(--link-color); 31 31 text-decoration: underline; 32 32 } 33 + table.stats td a { 34 + color: var(--link-color); 35 + text-decoration: none; 36 + } 37 + table.stats td a:hover { 38 + text-decoration: underline; 39 + } 40 + table.stats td.author a { 41 + color: var(--author-color); 42 + } 43 + table.stats td.author a:hover { 44 + color: var(--link-color); 45 + } 33 46 </style> 34 47 <!-- Theme Init --> 35 48 <script> ··· 54 67 55 68 <div id="content"> 56 69 <div class="item"> 70 + <!-- View Toggle --> 71 + <div style="margin-bottom: 15px;"> 72 + {{if eq .View "users"}} 73 + <strong>User Stats</strong> | <a href="?view=links">Link Popularity</a> 74 + {{else}} 75 + <a href="?view=users">User Stats</a> | <strong>Link Popularity</strong> 76 + {{end}} 77 + </div> 78 + 79 + {{if eq .View "links"}} 80 + <!-- Link Popularity View --> 81 + <div class="header">Link Popularity</div> 82 + <table class="stats"> 83 + <thead> 84 + <tr> 85 + <th>Rank</th> 86 + <th>Title</th> 87 + <th>Posted By</th> 88 + <th>Clicks</th> 89 + </tr> 90 + </thead> 91 + <tbody> 92 + {{range .Links}} 93 + <tr> 94 + <td>{{.Rank}}</td> 95 + <td><a href="/irclink/?{{.ID}}" style="color: var(--link-color); text-decoration: none;">{{.Title}}</a></td> 96 + <td class="author"><a href="/?poster={{.User}}">{{.User}}</a></td> 97 + <td>{{.Clicks}}</td> 98 + </tr> 99 + {{end}} 100 + </tbody> 101 + </table> 102 + {{else}} 103 + <!-- User Stats View --> 57 104 <div class="header">User Stats</div> 58 105 <table class="stats"> 59 106 <thead> ··· 75 122 {{end}} 76 123 </tbody> 77 124 </table> 125 + {{end}} 78 126 </div> 79 127 80 128 <div id="navigation" style="display: flex; justify-content: space-between; margin-top: 20px;"> 81 129 {{if .PrevPage}} 82 - <a href="?page={{.PrevPage}}&sort={{$.Sort}}"><img src="/img/prev.png" border="0" alt="Previous" /></a> 130 + {{if eq .View "links"}} 131 + <a href="?page={{.PrevPage}}&view=links"><img src="/img/prev.png" border="0" alt="Previous" /></a> 132 + {{else}} 133 + <a href="?page={{.PrevPage}}&sort={{$.Sort}}"><img src="/img/prev.png" border="0" alt="Previous" /></a> 134 + {{end}} 83 135 {{else}} 84 136 <div></div> 85 137 {{end}} 86 138 87 139 {{if .HasNext}} 88 - <a href="?page={{.NextPage}}&sort={{$.Sort}}"><img src="/img/next.png" border="0" alt="Next" /></a> 140 + {{if eq .View "links"}} 141 + <a href="?page={{.NextPage}}&view=links"><img src="/img/next.png" border="0" alt="Next" /></a> 142 + {{else}} 143 + <a href="?page={{.NextPage}}&sort={{$.Sort}}"><img src="/img/next.png" border="0" alt="Next" /></a> 144 + {{end}} 89 145 {{end}} 90 146 </div> 91 147 </div>