···220220}
221221222222func (s *MySQLStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) {
223223- query := `INSERT INTO ircLink (user, title, url, content_type) VALUES (?, ?, ?, ?)`
223223+ query := `INSERT INTO ircLink (user, title, url, content_type, clicks) VALUES (?, ?, ?, ?, 0)`
224224 res, err := s.db.ExecContext(ctx, query, user, title, url, contentType)
225225 if err != nil {
226226 return 0, err
···357357 links = append(links, l)
358358 }
359359 return links, nil
360360+}
361361+362362+func (s *MySQLStore) GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) {
363363+ query := `
364364+ SELECT
365365+ 'link' as type, ircLinkID as id, timestamp, title, url, '' as content, user as author, '' as md5sum
366366+ FROM ircLink
367367+ UNION ALL
368368+ SELECT
369369+ 'quote' as type, quoteID as id, timestamp, '' as title, '' as url, quote as content, author as author, '' as md5sum
370370+ FROM quote
371371+ UNION ALL
372372+ SELECT
373373+ 'image' as type, imageID as id, timestamp, title, url, '' as content, '' as author, md5sum
374374+ FROM image
375375+ ORDER BY timestamp DESC
376376+ LIMIT ? OFFSET ?
377377+ `
378378+ rows, err := s.db.QueryContext(ctx, query, limit, offset)
379379+ if err != nil {
380380+ return nil, err
381381+ }
382382+ defer rows.Close()
383383+384384+ var items []TimelineItem
385385+ for rows.Next() {
386386+ var i TimelineItem
387387+ // Scan matches SELECT order: Type, ID, Timestamp, Title, URL, Content, Author, MD5Sum
388388+ if err := rows.Scan(&i.Type, &i.ID, &i.Timestamp, &i.Title, &i.URL, &i.Content, &i.Author, &i.MD5Sum); err != nil {
389389+ return nil, err
390390+ }
391391+ items = append(items, i)
392392+ }
393393+ return items, nil
360394}
361395362396func (s *MySQLStore) Bootstrap(ctx context.Context) error {
+36-1
internal/data/sqlite.go
···216216}
217217218218func (s *SQLiteStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) {
219219- query := `INSERT INTO ircLink (user, title, url, content_type) VALUES (?, ?, ?, ?)`
219219+ query := `INSERT INTO ircLink (user, title, url, content_type, clicks) VALUES (?, ?, ?, ?, 0)`
220220 res, err := s.db.ExecContext(ctx, query, user, title, url, contentType)
221221 if err != nil {
222222 return 0, err
···353353 links = append(links, l)
354354 }
355355 return links, nil
356356+}
357357+358358+func (s *SQLiteStore) GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) {
359359+ // SQLite queries for literal strings sometimes need quotes or casting.
360360+ query := `
361361+ SELECT
362362+ 'link' as type, ircLinkID as id, timestamp, title, url, '' as content, user as author, '' as md5sum
363363+ FROM ircLink
364364+ UNION ALL
365365+ SELECT
366366+ 'quote' as type, quoteID as id, timestamp, '' as title, '' as url, quote as content, author as author, '' as md5sum
367367+ FROM quote
368368+ UNION ALL
369369+ SELECT
370370+ 'image' as type, imageID as id, timestamp, title, url, '' as content, '' as author, md5sum
371371+ FROM image
372372+ ORDER BY timestamp DESC
373373+ LIMIT ? OFFSET ?
374374+ `
375375+ rows, err := s.db.QueryContext(ctx, query, limit, offset)
376376+ if err != nil {
377377+ return nil, err
378378+ }
379379+ defer rows.Close()
380380+381381+ var items []TimelineItem
382382+ for rows.Next() {
383383+ var i TimelineItem
384384+ // Scan matches SELECT order: Type, ID, Timestamp, Title, URL, Content, Author, MD5Sum
385385+ if err := rows.Scan(&i.Type, &i.ID, &i.Timestamp, &i.Title, &i.URL, &i.Content, &i.Author, &i.MD5Sum); err != nil {
386386+ return nil, err
387387+ }
388388+ items = append(items, i)
389389+ }
390390+ return items, nil
356391}
357392358393func (s *SQLiteStore) Bootstrap(ctx context.Context) error {
+6-3
internal/data/store.go
···3838}
39394040type TimelineItem struct {
4141- Type string `json:"type"` // "link" or "quote"
4141+ Type string `json:"type"` // "link", "quote", or "image"
4242 ID int `json:"id"`
4343 Timestamp time.Time `json:"timestamp"`
4444- Title string `json:"title"` // For links
4545- URL string `json:"url"` // For links
4444+ Title string `json:"title"` // For links and images
4545+ URL string `json:"url"` // For links and images
4646 Content string `json:"content"` // For quotes
4747+ Author string `json:"author"` // For quotes (and links/images as User)
4848+ MD5Sum string `json:"md5sum"` // For images
4749}
48504951type Store interface {
···6466 GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error)
6567 GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error)
6668 GetUserTimeline(ctx context.Context, user string, filterType string, limit int, offset int) ([]TimelineItem, error)
6969+ GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error)
67706871 Bootstrap(ctx context.Context) error
6972
+68-37
internal/handler/handlers.go
···4141 GitCommit string // Placeholder
4242 GitCommitURL string // Placeholder
4343 // For XML
4444- BaseURL template.HTML
4545- Poster string
4646- FilterType string
4444+ BaseURL template.HTML
4545+ Poster string
4646+ FilterType string
4747+ IsFallbackContent bool
4748}
48494950func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
···83848485 poster := params.Get("poster")
8586 filterType := params.Get("type") // "links", "quotes", or empty/all
8787+ isFallback := false
86888789 if poster != "" {
8890 // Filtered View: Only links/quotes by 'poster'
···103105 User: poster,
104106 Title: item.Title,
105107 URL: item.URL,
106106- // Clicks/ContentType are skipped/nulled here as the query didn't select them or we don't display them in timeline similarly
107107- // Wait, current templates MIGHT need content_type for icon?
108108- // My GetUserTimeline SELECT didn't include clicks or content_type for complexity.
109109- // Let's check IRCLink struct usage. content_type used for icon.
110110- // If I need it, I should update the SELECT.
111111- // For now, let's assume empty defaulting is acceptable or update query if needed.
112112- // Actually, better to fetch them if possible.
113113- // But the union makes it tricky if columns differ.
114114- // Let's stick to basics.
115108 })
116109 } else if item.Type == "quote" {
117110 quotes = append(quotes, data.Quote{
···138131 return
139132 }
140133141141- // Combine and Sort
142142- // Since we fetched by specific intervals, we just need to merge and sort by timestamp.
143143- // OR we can process them all and then sort.
144144- // But simply rendering them in memory and then concatenating is easier if we process them in time order.
145145- // Perl simply assumes they are ordered by key timestamp in the hash?
146146- // Actually Perl: `foreach my $item_id ( reverse sort { $a cmp $b } keys %{$data} )`
147147- // The keys are timestamps (Wait, `key => 'timestamp'` in fetch means keys are timestamps).
148148- // So items are sorted by timestamp.
134134+ // Check for empty state on front page (standard view, page 1)
135135+ if poster == "" && i == 1 && len(ircLinks) == 0 && len(images) == 0 && len(quotes) == 0 {
136136+ slog.Info("No recent content found, fetching global timeline fallback")
137137+ fallbackItems, err := h.Store.GetGlobalTimeline(ctx, 20, 0)
138138+ if err == nil {
139139+ isFallback = true
140140+ for _, item := range fallbackItems {
141141+ switch item.Type {
142142+ case "link":
143143+ ircLinks = append(ircLinks, data.IRCLink{
144144+ ID: item.ID,
145145+ Timestamp: item.Timestamp,
146146+ User: item.Author,
147147+ Title: item.Title,
148148+ URL: item.URL,
149149+ })
150150+ case "quote":
151151+ quotes = append(quotes, data.Quote{
152152+ ID: item.ID,
153153+ Timestamp: item.Timestamp,
154154+ Author: item.Author,
155155+ Quote: item.Content,
156156+ })
157157+ case "image":
158158+ images = append(images, data.Image{
159159+ ID: item.ID,
160160+ Timestamp: item.Timestamp,
161161+ Title: item.Title,
162162+ Link: item.URL, // In GetGlobalTimeline, we mapped URL to URL, but Image struct has Link and URL.
163163+ // Looking at mysql select: 'image' as type... url ...
164164+ // In Image struct: Link is usually the click-through, URL is the src.
165165+ // Let's re-verify image struct usage.
166166+ // Image struct: Link string `json:"link"`, URL string `json:"url"`
167167+ // In GetRecentImages: Scan(&i.Link, &i.URL...)
168168+ // In GetGlobalTimeline: SELECT ... url ...
169169+ // We might be missing the 'link' field in global timeline for images if we just select one 'url' column.
170170+ // TimelineItem has 'URL'.
171171+ // For now, let's map URL to URL and assume Link is same or empty?
172172+ // Revisiting GetGlobalTimeline query:
173173+ // SELECT 'image', ..., url, ...
174174+ // It seems we only selected URL. We might want to fix GetGlobalTimeline to include Link if essential.
175175+ // Assuming URL is the main thing for display.
176176+ URL: item.URL,
177177+ MD5Sum: item.MD5Sum,
178178+ })
179179+ }
180180+ }
181181+ } else {
182182+ slog.Error("Error fetching global timeline fallback", "error", err)
183183+ }
184184+ }
149185150186 type ProcessedItem struct {
151187 Timestamp string // for sorting
···228264 }
229265230266 // Sort items (descending)
231231- // Simple bubble sort or whatever for small lists, or sort packages.
232232- // For "100% compatibility" I must sort descending.
233267 for j := 0; j < len(processedItems); j++ {
234268 for k := j + 1; k < len(processedItems); k++ {
235269 if processedItems[j].Timestamp < processedItems[k].Timestamp {
···269303 topLinks, err := h.Store.GetTopIRCLinks(ctx, 12, 6, 5)
270304 if err == nil {
271305 for _, l := range topLinks {
272272- // Link content: <a href...>Title</a>
273306 if len(l.Title) > 30 {
274307 l.Title = l.Title[:30] + "..."
275308 }
276276- // Link content: <a href...>Title</a>
277309 content := fmt.Sprintf(`<a href="http://%s/irclink/?%d">%s</a>`, h.Config.BaseURL, l.ID, l.Title)
278310279279- // Render item
280280- // Using map for flexibility
281311 data := map[string]interface{}{
282312 "Content": template.HTML(content),
283313 }
···305335 navP = fmt.Sprintf(`<a href="?i=2%s"><img src="/img/prev.png" border="0" alt="" /></a>`, posterParam)
306336 }
307337 if i == 1 {
308308- navN = "" // Perl: $nav->{'n'} = '' unless $self->{'arg'}->{'i'};
338338+ navN = ""
309339 }
310340311341 // View Data
···315345 }
316346317347 viewData := IndexPageData{
318318- PageTitle: pageTitle,
319319- Container: template.HTML(containerHTML),
320320- Hot: template.HTML(hotHTML),
321321- NavP: template.HTML(navP),
322322- NavN: template.HTML(navN),
323323- BaseURL: template.HTML(h.Config.BaseURL),
324324- Poster: poster,
325325- FilterType: filterType,
326326- GitCommit: version.CommitHash,
327327- GitCommitURL: fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash),
348348+ PageTitle: pageTitle,
349349+ Container: template.HTML(containerHTML),
350350+ Hot: template.HTML(hotHTML),
351351+ NavP: template.HTML(navP),
352352+ NavN: template.HTML(navN),
353353+ BaseURL: template.HTML(h.Config.BaseURL),
354354+ Poster: poster,
355355+ FilterType: filterType,
356356+ GitCommit: version.CommitHash,
357357+ GitCommitURL: fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash),
358358+ IsFallbackContent: isFallback,
328359 }
329360330361 templateName := "index.html"