this repo has no description
1
fork

Configure Feed

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

Stats page added

Now /stats has a leaderboard of posters

+464 -17
+2
cmd/tumble/main.go
··· 138 138 // Main Routes 139 139 mux.HandleFunc("/", h.Index) 140 140 mux.HandleFunc("/index.cgi", h.Index) 141 + mux.HandleFunc("/stats", h.Stats) 141 142 mux.HandleFunc("/search.cgi", h.Search) 142 143 mux.HandleFunc("/irclink/", h.IRCLinkHandler) // Handles /irclink/?id and posts 144 + 143 145 mux.HandleFunc("/ogpreview.cgi", h.OGPreviewHandler) 144 146 mux.HandleFunc("/buttons/button.cgi", h.ButtonHandler) 145 147
+40 -6
internal/assets/css/screen.css
··· 102 102 } 103 103 104 104 #page { 105 - padding: 15px 0 50px 0; 105 + padding: 15px 20px 50px 100px; /* Added left padding for hanging elements */ 106 106 font-family: Helvetica, Arial, sans-serif; 107 107 font-size: 12px; 108 108 line-height: 18px; ··· 111 111 max-width: 1400px; /* Reasonable maximum */ 112 112 margin: 0 auto; 113 113 flex: 1; 114 + box-sizing: border-box; /* Include padding in width calculation */ 114 115 115 116 /* CSS Grid Layout */ 116 117 display: grid; ··· 251 252 color: var(--author-color); 252 253 } 253 254 255 + .author a { 256 + color: inherit; 257 + text-decoration: none; 258 + position: relative; 259 + } 260 + 261 + .author a:hover { 262 + text-decoration: underline; 263 + color: var(--link-color); 264 + } 265 + 266 + .author a[data-tooltip]:hover::after { 267 + content: attr(data-tooltip); 268 + position: absolute; 269 + bottom: 100%; 270 + left: 50%; 271 + transform: translateX(-50%); 272 + background-color: #333; 273 + color: #fff; 274 + padding: 5px 10px; 275 + border-radius: 4px; 276 + white-space: nowrap; 277 + font-size: 12px; 278 + z-index: 1000; 279 + pointer-events: none; 280 + opacity: 0.9; 281 + box-shadow: 0 2px 4px rgba(0,0,0,0.2); 282 + margin-bottom: 5px; 283 + } 284 + 254 285 .link a { 255 286 font-weight: bold; 256 287 text-decoration: none; ··· 344 375 345 376 .youtube-embed-wrapper { 346 377 position: relative; 347 - padding-bottom: 56.25%; /* 16:9 aspect ratio */ 348 - height: 0; 378 + /* Match OG Card properties */ 379 + max-width: 600px; 380 + margin-top: 8px; 381 + background: #000; 382 + 383 + /* Modern Aspect Ratio */ 384 + aspect-ratio: 16 / 9; 385 + height: auto; /* Let aspect-ratio drive height */ 349 386 overflow: hidden; 350 - max-width: 100%; 351 - margin-top: 10px; 352 - background: #000; 353 387 } 354 388 355 389 .youtube-embed-wrapper iframe {
+73
internal/data/mysql.go
··· 235 235 return err 236 236 } 237 237 238 + func (s *MySQLStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) { 239 + // Sort logic 240 + orderBy := "link_count DESC" 241 + switch sortBy { 242 + case "user": 243 + orderBy = "u.user ASC" 244 + case "quotes": 245 + orderBy = "quote_count DESC" 246 + case "links": 247 + orderBy = "link_count DESC" 248 + } 249 + 250 + query := fmt.Sprintf(` 251 + SELECT 252 + u.user, 253 + COALESCE(l.count, 0) as link_count, 254 + COALESCE(q.count, 0) as quote_count 255 + FROM 256 + (SELECT DISTINCT user FROM ircLink UNION SELECT DISTINCT author as user FROM quote) u 257 + LEFT JOIN 258 + (SELECT user, COUNT(*) as count FROM ircLink GROUP BY user) l ON u.user = l.user 259 + LEFT JOIN 260 + (SELECT author, COUNT(*) as count FROM quote GROUP BY author) q ON u.user = q.author 261 + ORDER BY %s 262 + LIMIT ? OFFSET ? 263 + `, orderBy) 264 + 265 + rows, err := s.db.QueryContext(ctx, query, limit, offset) 266 + if err != nil { 267 + return nil, err 268 + } 269 + defer rows.Close() 270 + 271 + var stats []UserStat 272 + for rows.Next() { 273 + var stat UserStat 274 + if err := rows.Scan(&stat.User, &stat.LinkCount, &stat.QuoteCount); err != nil { 275 + return nil, err 276 + } 277 + stats = append(stats, stat) 278 + } 279 + return stats, nil 280 + } 281 + 282 + func (s *MySQLStore) GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error) { 283 + query := ` 284 + SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 285 + FROM ircLink 286 + WHERE user = ? 287 + ORDER BY timestamp DESC 288 + LIMIT ? OFFSET ? 289 + ` 290 + rows, err := s.db.QueryContext(ctx, query, user, limit, offset) 291 + if err != nil { 292 + return nil, err 293 + } 294 + defer rows.Close() 295 + 296 + var links []IRCLink 297 + for rows.Next() { 298 + var l IRCLink 299 + var contentType sql.NullString 300 + if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil { 301 + return nil, err 302 + } 303 + if contentType.Valid { 304 + l.ContentType = contentType.String 305 + } 306 + links = append(links, l) 307 + } 308 + return links, nil 309 + } 310 + 238 311 func (s *MySQLStore) Bootstrap(ctx context.Context) error { 239 312 schema, err := SchemaFS.ReadFile("schema.mysql") 240 313 if err != nil {
+73
internal/data/sqlite.go
··· 231 231 return err 232 232 } 233 233 234 + func (s *SQLiteStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) { 235 + // Sort logic 236 + orderBy := "link_count DESC" 237 + switch sortBy { 238 + case "user": 239 + orderBy = "u.user ASC" 240 + case "quotes": 241 + orderBy = "quote_count DESC" 242 + case "links": 243 + orderBy = "link_count DESC" 244 + } 245 + 246 + query := fmt.Sprintf(` 247 + SELECT 248 + u.user, 249 + COALESCE(l.count, 0) as link_count, 250 + COALESCE(q.count, 0) as quote_count 251 + FROM 252 + (SELECT DISTINCT user FROM ircLink UNION SELECT DISTINCT author as user FROM quote) u 253 + LEFT JOIN 254 + (SELECT user, COUNT(*) as count FROM ircLink GROUP BY user) l ON u.user = l.user 255 + LEFT JOIN 256 + (SELECT author, COUNT(*) as count FROM quote GROUP BY author) q ON u.user = q.author 257 + ORDER BY %s 258 + LIMIT ? OFFSET ? 259 + `, orderBy) 260 + 261 + rows, err := s.db.QueryContext(ctx, query, limit, offset) 262 + if err != nil { 263 + return nil, err 264 + } 265 + defer rows.Close() 266 + 267 + var stats []UserStat 268 + for rows.Next() { 269 + var stat UserStat 270 + if err := rows.Scan(&stat.User, &stat.LinkCount, &stat.QuoteCount); err != nil { 271 + return nil, err 272 + } 273 + stats = append(stats, stat) 274 + } 275 + return stats, nil 276 + } 277 + 278 + func (s *SQLiteStore) GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error) { 279 + query := ` 280 + SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 281 + FROM ircLink 282 + WHERE user = ? 283 + ORDER BY timestamp DESC 284 + LIMIT ? OFFSET ? 285 + ` 286 + rows, err := s.db.QueryContext(ctx, query, user, limit, offset) 287 + if err != nil { 288 + return nil, err 289 + } 290 + defer rows.Close() 291 + 292 + var links []IRCLink 293 + for rows.Next() { 294 + var l IRCLink 295 + var contentType sql.NullString 296 + if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil { 297 + return nil, err 298 + } 299 + if contentType.Valid { 300 + l.ContentType = contentType.String 301 + } 302 + links = append(links, l) 303 + } 304 + return links, nil 305 + } 306 + 234 307 func (s *SQLiteStore) Bootstrap(ctx context.Context) error { 235 308 schema, err := SchemaFS.ReadFile("schema.sqlite") 236 309 if err != nil {
+10
internal/data/store.go
··· 31 31 Author string `json:"author"` 32 32 } 33 33 34 + type UserStat struct { 35 + User string `json:"user"` 36 + LinkCount int `json:"link_count"` 37 + QuoteCount int `json:"quote_count"` 38 + } 39 + 34 40 type Store interface { 35 41 GetRecentIRCLinks(ctx context.Context, days int, offsetDays int) ([]IRCLink, error) 36 42 GetRecentImages(ctx context.Context, days int, offsetDays int) ([]Image, error) ··· 43 49 IncrementClicks(ctx context.Context, id int) error 44 50 InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) 45 51 InsertQuote(ctx context.Context, quote, author string) error 52 + 53 + // Stats 54 + GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) 55 + GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error) 46 56 47 57 Bootstrap(ctx context.Context) error 48 58
+109 -10
internal/handler/handlers.go
··· 79 79 var images []data.Image 80 80 var quotes []data.Quote 81 81 82 - wg.Add(3) 83 - go func() { defer wg.Done(); ircLinks, errIrc = h.Store.GetRecentIRCLinks(ctx, startDays, endDays) }() 84 - go func() { defer wg.Done(); images, errImg = h.Store.GetRecentImages(ctx, startDays, endDays) }() 85 - go func() { defer wg.Done(); quotes, errQuote = h.Store.GetRecentQuotes(ctx, startDays, endDays) }() 86 - wg.Wait() 82 + poster := params.Get("poster") 83 + 84 + if poster != "" { 85 + // Filtered View: Only links by 'poster' 86 + // Pagination for poster view is 30 items 87 + limit := 30 88 + offset := (i - 1) * 30 89 + ircLinks, errIrc = h.Store.GetLinksByUser(ctx, poster, limit, offset) 90 + // No images or quotes in filtered view 91 + } else { 92 + // Standard View 93 + wg.Add(3) 94 + go func() { defer wg.Done(); ircLinks, errIrc = h.Store.GetRecentIRCLinks(ctx, startDays, endDays) }() 95 + go func() { defer wg.Done(); images, errImg = h.Store.GetRecentImages(ctx, startDays, endDays) }() 96 + go func() { defer wg.Done(); quotes, errQuote = h.Store.GetRecentQuotes(ctx, startDays, endDays) }() 97 + wg.Wait() 98 + } 87 99 88 100 if errIrc != nil || errImg != nil || errQuote != nil { 89 101 slog.Error("Error fetching data", "irc_error", errIrc, "img_error", errImg, "quote_error", errQuote) ··· 243 255 // Navigation 244 256 navP := "" 245 257 navN := "" 246 - if iParam != "" { 247 - navP = fmt.Sprintf(`<a href="?i=%d"><img src="/img/prev.png" border="0" alt="" /></a>`, i+1) 248 - navN = fmt.Sprintf(` &nbsp;<a href="?i=%d"><img src="/img/next.png" border="0" alt="" /></a>`, i-1) 258 + posterParam := "" 259 + if poster != "" { 260 + posterParam = fmt.Sprintf("&poster=%s", poster) 261 + } 262 + 263 + if iParam != "" || i > 1 { 264 + navP = fmt.Sprintf(`<a href="?i=%d%s"><img src="/img/prev.png" border="0" alt="" /></a>`, i+1, posterParam) 265 + navN = fmt.Sprintf(` &nbsp;<a href="?i=%d%s"><img src="/img/next.png" border="0" alt="" /></a>`, i-1, posterParam) 249 266 } else { 250 - navP = `<a href="?i=2"><img src="/img/prev.png" border="0" alt="" /></a>` 267 + navP = fmt.Sprintf(`<a href="?i=2%s"><img src="/img/prev.png" border="0" alt="" /></a>`, posterParam) 251 268 } 252 269 if i == 1 { 253 270 navN = "" // Perl: $nav->{'n'} = '' unless $self->{'arg'}->{'i'}; 254 271 } 255 272 256 273 // View Data 274 + pageTitle := "" 275 + if poster != "" { 276 + pageTitle = fmt.Sprintf(" &gt; Links by %s", poster) 277 + } 278 + 257 279 viewData := IndexPageData{ 258 - PageTitle: "", 280 + PageTitle: pageTitle, 259 281 Container: template.HTML(containerHTML), 260 282 Hot: template.HTML(hotHTML), 261 283 NavP: template.HTML(navP), ··· 359 381 w.Header().Set("Content-Type", "text/html; charset=UTF-8") 360 382 h.Renderer.Render(w, "index.html", viewData) 361 383 } 384 + 385 + func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) { 386 + ctx := r.Context() 387 + 388 + // Pagination 389 + page := 1 390 + pageParam := r.URL.Query().Get("page") 391 + if pageParam != "" { 392 + val, err := strconv.Atoi(pageParam) 393 + if err == nil && val > 0 { 394 + page = val 395 + } 396 + } 397 + limit := 50 398 + offset := (page - 1) * limit 399 + 400 + // Sorting 401 + sortBy := r.URL.Query().Get("sort") 402 + if sortBy == "" { 403 + sortBy = "links" 404 + } 405 + 406 + stats, err := h.Store.GetUserStats(ctx, sortBy, limit, offset) 407 + if err != nil { 408 + slog.Error("Error fetching stats", "error", err) 409 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 410 + return 411 + } 412 + 413 + // Prepare View Data with Ranks 414 + type StatViewItem struct { 415 + Rank int 416 + User string 417 + LinkCount int 418 + QuoteCount int 419 + } 420 + 421 + var statsView []StatViewItem 422 + for i, s := range stats { 423 + statsView = append(statsView, StatViewItem{ 424 + Rank: offset + i + 1, 425 + User: s.User, 426 + LinkCount: s.LinkCount, 427 + QuoteCount: s.QuoteCount, 428 + }) 429 + } 430 + 431 + // Navigation 432 + nextPage := page + 1 433 + prevPage := page - 1 434 + if prevPage < 1 { 435 + prevPage = 0 436 + } 437 + 438 + // Check if we need a next page (simplistic: if we got full limit, likely there's more) 439 + hasNext := len(stats) == limit 440 + 441 + // Determine Sort Order for links 442 + // Logic: If current sort is X, clicking X again should probably toggle or reset? 443 + // For simplicity, headers always sort descending by that column. 444 + 445 + data := map[string]interface{}{ 446 + "Stats": statsView, 447 + "PageTitle": " &gt; Stats", 448 + "GitCommit": version.CommitHash, 449 + "GitCommitURL": fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), 450 + "Page": page, 451 + "NextPage": nextPage, 452 + "PrevPage": prevPage, 453 + "HasNext": hasNext, 454 + "Sort": sortBy, 455 + } 456 + 457 + if err := h.Renderer.Render(w, "stats.html", data); err != nil { 458 + slog.Error("Error rendering stats", "error", err) 459 + } 460 + }
+2
internal/templates/views/index.html
··· 255 255 </div> 256 256 257 257 <div id="footer"> 258 + <a href="/stats">Stats</a> 259 + &nbsp;|&nbsp; 258 260 Source Code Available on 259 261 <a href="http://github.com/websages/tumble">GitHub</a> 260 262 <svg
+154
internal/templates/views/stats.html
··· 1 + <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> 2 + <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"> 3 + <head> 4 + <title>tumblefish.stats</title> 5 + <link rel="stylesheet" href="/css/screen.css" type="text/css" media="screen" /> 6 + <style> 7 + table.stats { 8 + width: 100%; 9 + border-collapse: collapse; 10 + } 11 + table.stats th, table.stats td { 12 + text-align: left; 13 + padding: 5px; 14 + border-bottom: 1px solid #ccc; 15 + font-size: 18px; 16 + } 17 + [data-theme="dark"] table.stats td { 18 + border-bottom: 1px solid #444; 19 + } 20 + table.stats th a { 21 + color: inherit; 22 + text-decoration: none; 23 + font-weight: bold; 24 + } 25 + table.stats th a:hover { 26 + color: var(--link-color); 27 + text-decoration: underline; 28 + } 29 + </style> 30 + <!-- Theme Init --> 31 + <script> 32 + (function () { 33 + var savedTheme = localStorage.getItem("theme"); 34 + var prefersDark = window.matchMedia( 35 + "(prefers-color-scheme: dark)" 36 + ).matches; 37 + if (savedTheme === "dark" || (!savedTheme && prefersDark)) { 38 + document.documentElement.setAttribute("data-theme", "dark"); 39 + } 40 + })(); 41 + </script> 42 + </head> 43 + <body> 44 + <div id="page"> 45 + <div id="masthead"> 46 + <a href="/">tumblefish.</a> 47 + <button id="theme-toggle" aria-label="Toggle Dark Mode"> 48 + <svg 49 + width="20" 50 + height="20" 51 + viewBox="0 0 24 24" 52 + fill="none" 53 + stroke="currentColor" 54 + stroke-width="2" 55 + stroke-linecap="round" 56 + stroke-linejoin="round" 57 + > 58 + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> 59 + </svg> 60 + </button> 61 + </div> 62 + 63 + <div id="sidebar"> 64 + <div class="item"> 65 + <div class="header">search it up</div> 66 + <form action="/search.cgi" id="search-form" method="get"> 67 + <input type="text" id="search" name="search" value="" size="15" /> 68 + </form> 69 + </div> 70 + <div class="item"> 71 + <div class="header">also</div> 72 + <div class="sm"> 73 + <div class="link"><a href="/">home</a></div> 74 + </div> 75 + </div> 76 + </div> 77 + 78 + <div id="content"> 79 + <div class="item"> 80 + <div class="header">User Stats</div> 81 + <table class="stats"> 82 + <thead> 83 + <tr> 84 + <th>Rank</th> 85 + <th><a href="?sort=user">User</a></th> 86 + <th><a href="?sort=links">Links Posted</a></th> 87 + <th><a href="?sort=quotes">Quotes Posted</a></th> 88 + </tr> 89 + </thead> 90 + <tbody> 91 + {{range .Stats}} 92 + <tr> 93 + <td>{{.Rank}}</td> 94 + <td class="author"><a href="/?poster={{.User}}">{{.User}}</a></td> 95 + <td>{{.LinkCount}}</td> 96 + <td>{{.QuoteCount}}</td> 97 + </tr> 98 + {{end}} 99 + </tbody> 100 + </table> 101 + </div> 102 + 103 + <div id="navigation" style="display: flex; justify-content: space-between; margin-top: 20px;"> 104 + {{if .PrevPage}} 105 + <a href="?page={{.PrevPage}}&sort={{$.Sort}}"><img src="/img/prev.png" border="0" alt="Previous" /></a> 106 + {{else}} 107 + <div></div> 108 + {{end}} 109 + 110 + {{if .HasNext}} 111 + <a href="?page={{.NextPage}}&sort={{$.Sort}}"><img src="/img/next.png" border="0" alt="Next" /></a> 112 + {{end}} 113 + </div> 114 + </div> 115 + 116 + </div> 117 + <div id="footer"> 118 + <a href="/stats">Stats</a> 119 + &nbsp;|&nbsp; 120 + Source Code Available on 121 + <a href="http://github.com/websages/tumble">GitHub</a> 122 + <svg 123 + width="16" 124 + height="16" 125 + viewBox="0 0 16 16" 126 + fill="#000000" 127 + style="vertical-align: text-bottom; display: inline-block" 128 + > 129 + <path 130 + fill-rule="evenodd" 131 + d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" 132 + ></path></svg 133 + >. {{if .GitCommit}} Revision: 134 + <a href="{{.GitCommitURL}}">{{.GitCommit}}</a>{{end}} 135 + </div> 136 + <!-- Theme Toggle Logic --> 137 + <script> 138 + (function () { 139 + var toggle = document.getElementById("theme-toggle"); 140 + var html = document.documentElement; 141 + 142 + toggle.addEventListener("click", function () { 143 + if (html.getAttribute("data-theme") === "dark") { 144 + html.removeAttribute("data-theme"); 145 + localStorage.setItem("theme", "light"); 146 + } else { 147 + html.setAttribute("data-theme", "dark"); 148 + localStorage.setItem("theme", "dark"); 149 + } 150 + }); 151 + })(); 152 + </script> 153 + </body> 154 + </html>
+1 -1
internal/templates/views/tumble_item_ircLink.html
··· 1 1 <div class="item" data-url="{{.URL}}" data-content-type="{{.ContentType}}" data-irc-link-id="{{.ID}}"> 2 2 <span class="link">{{.Content}}</span> 3 - <span class="author">{{.User}}</span> 3 + <span class="author"><a href="/?poster={{.User}}" data-tooltip="{{.FormattedDate}}">{{.User}}</a></span> 4 4 <div class="og-preview" id="og-preview-{{.ID}}"></div> 5 5 </div>