this repo has no description
1
fork

Configure Feed

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

feat: if no new content, display older stuff

+153 -43
+35 -1
internal/data/mysql.go
··· 220 220 } 221 221 222 222 func (s *MySQLStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { 223 - query := `INSERT INTO ircLink (user, title, url, content_type) VALUES (?, ?, ?, ?)` 223 + query := `INSERT INTO ircLink (user, title, url, content_type, clicks) VALUES (?, ?, ?, ?, 0)` 224 224 res, err := s.db.ExecContext(ctx, query, user, title, url, contentType) 225 225 if err != nil { 226 226 return 0, err ··· 357 357 links = append(links, l) 358 358 } 359 359 return links, nil 360 + } 361 + 362 + func (s *MySQLStore) GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) { 363 + query := ` 364 + SELECT 365 + 'link' as type, ircLinkID as id, timestamp, title, url, '' as content, user as author, '' as md5sum 366 + FROM ircLink 367 + UNION ALL 368 + SELECT 369 + 'quote' as type, quoteID as id, timestamp, '' as title, '' as url, quote as content, author as author, '' as md5sum 370 + FROM quote 371 + UNION ALL 372 + SELECT 373 + 'image' as type, imageID as id, timestamp, title, url, '' as content, '' as author, md5sum 374 + FROM image 375 + ORDER BY timestamp DESC 376 + LIMIT ? OFFSET ? 377 + ` 378 + rows, err := s.db.QueryContext(ctx, query, limit, offset) 379 + if err != nil { 380 + return nil, err 381 + } 382 + defer rows.Close() 383 + 384 + var items []TimelineItem 385 + for rows.Next() { 386 + var i TimelineItem 387 + // Scan matches SELECT order: Type, ID, Timestamp, Title, URL, Content, Author, MD5Sum 388 + if err := rows.Scan(&i.Type, &i.ID, &i.Timestamp, &i.Title, &i.URL, &i.Content, &i.Author, &i.MD5Sum); err != nil { 389 + return nil, err 390 + } 391 + items = append(items, i) 392 + } 393 + return items, nil 360 394 } 361 395 362 396 func (s *MySQLStore) Bootstrap(ctx context.Context) error {
+36 -1
internal/data/sqlite.go
··· 216 216 } 217 217 218 218 func (s *SQLiteStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { 219 - query := `INSERT INTO ircLink (user, title, url, content_type) VALUES (?, ?, ?, ?)` 219 + query := `INSERT INTO ircLink (user, title, url, content_type, clicks) VALUES (?, ?, ?, ?, 0)` 220 220 res, err := s.db.ExecContext(ctx, query, user, title, url, contentType) 221 221 if err != nil { 222 222 return 0, err ··· 353 353 links = append(links, l) 354 354 } 355 355 return links, nil 356 + } 357 + 358 + func (s *SQLiteStore) GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) { 359 + // SQLite queries for literal strings sometimes need quotes or casting. 360 + query := ` 361 + SELECT 362 + 'link' as type, ircLinkID as id, timestamp, title, url, '' as content, user as author, '' as md5sum 363 + FROM ircLink 364 + UNION ALL 365 + SELECT 366 + 'quote' as type, quoteID as id, timestamp, '' as title, '' as url, quote as content, author as author, '' as md5sum 367 + FROM quote 368 + UNION ALL 369 + SELECT 370 + 'image' as type, imageID as id, timestamp, title, url, '' as content, '' as author, md5sum 371 + FROM image 372 + ORDER BY timestamp DESC 373 + LIMIT ? OFFSET ? 374 + ` 375 + rows, err := s.db.QueryContext(ctx, query, limit, offset) 376 + if err != nil { 377 + return nil, err 378 + } 379 + defer rows.Close() 380 + 381 + var items []TimelineItem 382 + for rows.Next() { 383 + var i TimelineItem 384 + // Scan matches SELECT order: Type, ID, Timestamp, Title, URL, Content, Author, MD5Sum 385 + if err := rows.Scan(&i.Type, &i.ID, &i.Timestamp, &i.Title, &i.URL, &i.Content, &i.Author, &i.MD5Sum); err != nil { 386 + return nil, err 387 + } 388 + items = append(items, i) 389 + } 390 + return items, nil 356 391 } 357 392 358 393 func (s *SQLiteStore) Bootstrap(ctx context.Context) error {
+6 -3
internal/data/store.go
··· 38 38 } 39 39 40 40 type TimelineItem struct { 41 - Type string `json:"type"` // "link" or "quote" 41 + Type string `json:"type"` // "link", "quote", or "image" 42 42 ID int `json:"id"` 43 43 Timestamp time.Time `json:"timestamp"` 44 - Title string `json:"title"` // For links 45 - URL string `json:"url"` // For links 44 + Title string `json:"title"` // For links and images 45 + URL string `json:"url"` // For links and images 46 46 Content string `json:"content"` // For quotes 47 + Author string `json:"author"` // For quotes (and links/images as User) 48 + MD5Sum string `json:"md5sum"` // For images 47 49 } 48 50 49 51 type Store interface { ··· 64 66 GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) 65 67 GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error) 66 68 GetUserTimeline(ctx context.Context, user string, filterType string, limit int, offset int) ([]TimelineItem, error) 69 + GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) 67 70 68 71 Bootstrap(ctx context.Context) error 69 72
+68 -37
internal/handler/handlers.go
··· 41 41 GitCommit string // Placeholder 42 42 GitCommitURL string // Placeholder 43 43 // For XML 44 - BaseURL template.HTML 45 - Poster string 46 - FilterType string 44 + BaseURL template.HTML 45 + Poster string 46 + FilterType string 47 + IsFallbackContent bool 47 48 } 48 49 49 50 func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { ··· 83 84 84 85 poster := params.Get("poster") 85 86 filterType := params.Get("type") // "links", "quotes", or empty/all 87 + isFallback := false 86 88 87 89 if poster != "" { 88 90 // Filtered View: Only links/quotes by 'poster' ··· 103 105 User: poster, 104 106 Title: item.Title, 105 107 URL: item.URL, 106 - // Clicks/ContentType are skipped/nulled here as the query didn't select them or we don't display them in timeline similarly 107 - // Wait, current templates MIGHT need content_type for icon? 108 - // My GetUserTimeline SELECT didn't include clicks or content_type for complexity. 109 - // Let's check IRCLink struct usage. content_type used for icon. 110 - // If I need it, I should update the SELECT. 111 - // For now, let's assume empty defaulting is acceptable or update query if needed. 112 - // Actually, better to fetch them if possible. 113 - // But the union makes it tricky if columns differ. 114 - // Let's stick to basics. 115 108 }) 116 109 } else if item.Type == "quote" { 117 110 quotes = append(quotes, data.Quote{ ··· 138 131 return 139 132 } 140 133 141 - // Combine and Sort 142 - // Since we fetched by specific intervals, we just need to merge and sort by timestamp. 143 - // OR we can process them all and then sort. 144 - // But simply rendering them in memory and then concatenating is easier if we process them in time order. 145 - // Perl simply assumes they are ordered by key timestamp in the hash? 146 - // Actually Perl: `foreach my $item_id ( reverse sort { $a cmp $b } keys %{$data} )` 147 - // The keys are timestamps (Wait, `key => 'timestamp'` in fetch means keys are timestamps). 148 - // So items are sorted by timestamp. 134 + // Check for empty state on front page (standard view, page 1) 135 + if poster == "" && i == 1 && len(ircLinks) == 0 && len(images) == 0 && len(quotes) == 0 { 136 + slog.Info("No recent content found, fetching global timeline fallback") 137 + fallbackItems, err := h.Store.GetGlobalTimeline(ctx, 20, 0) 138 + if err == nil { 139 + isFallback = true 140 + for _, item := range fallbackItems { 141 + switch item.Type { 142 + case "link": 143 + ircLinks = append(ircLinks, data.IRCLink{ 144 + ID: item.ID, 145 + Timestamp: item.Timestamp, 146 + User: item.Author, 147 + Title: item.Title, 148 + URL: item.URL, 149 + }) 150 + case "quote": 151 + quotes = append(quotes, data.Quote{ 152 + ID: item.ID, 153 + Timestamp: item.Timestamp, 154 + Author: item.Author, 155 + Quote: item.Content, 156 + }) 157 + case "image": 158 + images = append(images, data.Image{ 159 + ID: item.ID, 160 + Timestamp: item.Timestamp, 161 + Title: item.Title, 162 + Link: item.URL, // In GetGlobalTimeline, we mapped URL to URL, but Image struct has Link and URL. 163 + // Looking at mysql select: 'image' as type... url ... 164 + // In Image struct: Link is usually the click-through, URL is the src. 165 + // Let's re-verify image struct usage. 166 + // Image struct: Link string `json:"link"`, URL string `json:"url"` 167 + // In GetRecentImages: Scan(&i.Link, &i.URL...) 168 + // In GetGlobalTimeline: SELECT ... url ... 169 + // We might be missing the 'link' field in global timeline for images if we just select one 'url' column. 170 + // TimelineItem has 'URL'. 171 + // For now, let's map URL to URL and assume Link is same or empty? 172 + // Revisiting GetGlobalTimeline query: 173 + // SELECT 'image', ..., url, ... 174 + // It seems we only selected URL. We might want to fix GetGlobalTimeline to include Link if essential. 175 + // Assuming URL is the main thing for display. 176 + URL: item.URL, 177 + MD5Sum: item.MD5Sum, 178 + }) 179 + } 180 + } 181 + } else { 182 + slog.Error("Error fetching global timeline fallback", "error", err) 183 + } 184 + } 149 185 150 186 type ProcessedItem struct { 151 187 Timestamp string // for sorting ··· 228 264 } 229 265 230 266 // Sort items (descending) 231 - // Simple bubble sort or whatever for small lists, or sort packages. 232 - // For "100% compatibility" I must sort descending. 233 267 for j := 0; j < len(processedItems); j++ { 234 268 for k := j + 1; k < len(processedItems); k++ { 235 269 if processedItems[j].Timestamp < processedItems[k].Timestamp { ··· 269 303 topLinks, err := h.Store.GetTopIRCLinks(ctx, 12, 6, 5) 270 304 if err == nil { 271 305 for _, l := range topLinks { 272 - // Link content: <a href...>Title</a> 273 306 if len(l.Title) > 30 { 274 307 l.Title = l.Title[:30] + "..." 275 308 } 276 - // Link content: <a href...>Title</a> 277 309 content := fmt.Sprintf(`<a href="http://%s/irclink/?%d">%s</a>`, h.Config.BaseURL, l.ID, l.Title) 278 310 279 - // Render item 280 - // Using map for flexibility 281 311 data := map[string]interface{}{ 282 312 "Content": template.HTML(content), 283 313 } ··· 305 335 navP = fmt.Sprintf(`<a href="?i=2%s"><img src="/img/prev.png" border="0" alt="" /></a>`, posterParam) 306 336 } 307 337 if i == 1 { 308 - navN = "" // Perl: $nav->{'n'} = '' unless $self->{'arg'}->{'i'}; 338 + navN = "" 309 339 } 310 340 311 341 // View Data ··· 315 345 } 316 346 317 347 viewData := IndexPageData{ 318 - PageTitle: pageTitle, 319 - Container: template.HTML(containerHTML), 320 - Hot: template.HTML(hotHTML), 321 - NavP: template.HTML(navP), 322 - NavN: template.HTML(navN), 323 - BaseURL: template.HTML(h.Config.BaseURL), 324 - Poster: poster, 325 - FilterType: filterType, 326 - GitCommit: version.CommitHash, 327 - GitCommitURL: fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), 348 + PageTitle: pageTitle, 349 + Container: template.HTML(containerHTML), 350 + Hot: template.HTML(hotHTML), 351 + NavP: template.HTML(navP), 352 + NavN: template.HTML(navN), 353 + BaseURL: template.HTML(h.Config.BaseURL), 354 + Poster: poster, 355 + FilterType: filterType, 356 + GitCommit: version.CommitHash, 357 + GitCommitURL: fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), 358 + IsFallbackContent: isFallback, 328 359 } 329 360 330 361 templateName := "index.html"
+2 -1
internal/handler/irclink.go
··· 47 47 // Insert 48 48 id, err := h.Store.InsertIRCLink(ctx, user, title, url, contentType) 49 49 if err != nil { 50 - http.Error(w, "Database Error", http.StatusInternalServerError) 50 + log.Printf("InsertIRCLink error: %v", err) 51 + http.Error(w, fmt.Sprintf("Database Error: %v", err), http.StatusInternalServerError) 51 52 return 52 53 } 53 54
+6
internal/templates/views/index.html
··· 258 258 <a href="/?poster={{.Poster}}&type=quotes" style="{{if eq .FilterType "quotes"}}font-weight: bold; color: var(--text-main);{{else}}color: var(--link-color);{{end}}">Quotes</a> 259 259 </div> 260 260 {{end}} 261 + {{if .IsFallbackContent}} 262 + <div class="item" style="border: 1px solid var(--og-border); padding: 15px; margin-bottom: 20px; background-color: var(--footer-bg); border-radius: 4px; text-align: center;"> 263 + <div style="font-weight: bold; margin-bottom: 5px; font-size: 18px;">It's been a bit quiet lately...</div> 264 + <div style="font-size: 14px;">Here is some content from the archives. Why not <a href="/buttons/" style="font-weight: bold; color: var(--link-color);">share something new</a>?</div> 265 + </div> 266 + {{end}} 261 267 {{.Container}} 262 268 </div> 263 269