Files
kevinandClaude Fable 5 5ec783e471 feat: implement waterfall layout with smart image positioning and infinite scroll
- Add single-column layout for homepage articles
- Implement smart image positioning based on aspect ratio:
  * Landscape images (ratio > 1.2) displayed on top
  * Portrait/square images (ratio ≤ 1.2) displayed on left side
- Add infinite scroll with lazy loading
- Create new API endpoint /api/articles for pagination
- Add loading indicator and 'no more articles' message
- Optimize performance with image lazy loading and scroll throttling
- Add responsive layout for mobile devices

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 19:31:18 +08:00

182 lines
4.6 KiB
Go

package handlers
import (
"encoding/xml"
"fmt"
"html"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go_blog/models"
"gorm.io/gorm"
)
// RSS 2.0 XML structure definitions.
// RSS is the root element of an RSS 2.0 feed.
type RSS struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Channel *Channel `xml:"channel"`
}
// Channel represents the RSS channel containing feed metadata and items.
type Channel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Language string `xml:"language"`
LastBuildDate string `xml:"lastBuildDate,omitempty"`
Items []Item `xml:"item"`
}
// Item represents a single article in the RSS feed.
type Item struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Author string `xml:"author,omitempty"`
PubDate string `xml:"pubDate"`
GUID string `xml:"guid"`
}
// RSSFeed generates an RSS 2.0 feed of the latest published articles.
func RSSFeed(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Determine the current language for site metadata.
lang, exists := c.Get("lang")
if !exists {
lang = "en"
}
langStr := lang.(string)
// Get site settings for feed metadata.
siteSetting := &models.SiteSetting{}
db.First(siteSetting)
// Construct the base URL from the request.
scheme := "http"
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
scheme = "https"
}
baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host)
// Get the latest 20 published articles.
var articles []models.Article
db.Where("status = ?", models.ArticlePublished).
Preload("Author").
Order(publishedArticleOrder).
Limit(20).
Find(&articles)
// Build channel metadata.
channel := &Channel{
Title: siteSetting.LogoText(langStr),
Link: baseURL,
Description: siteSetting.HomeSubtitle(langStr),
Language: getRSSLanguage(langStr),
Items: make([]Item, 0, len(articles)),
}
// Set lastBuildDate to the most recent article's publish date.
if len(articles) > 0 && articles[0].PublishedAt != nil {
channel.LastBuildDate = formatRSSTime(*articles[0].PublishedAt)
}
// Convert articles to RSS items.
for _, article := range articles {
item := Item{
Title: article.Title,
Link: fmt.Sprintf("%s/article/%s", baseURL, article.Slug),
Description: getArticleDescription(&article),
PubDate: formatRSSTime(getArticlePubDate(&article)),
GUID: fmt.Sprintf("%s/article/%s", baseURL, article.Slug),
}
// Add author information.
if article.Author.DisplayName != "" {
item.Author = article.Author.DisplayName
} else {
item.Author = article.Author.Username
}
channel.Items = append(channel.Items, item)
}
// Build the RSS feed.
feed := &RSS{
Version: "2.0",
Channel: channel,
}
// Set the correct content type and return XML.
c.Header("Content-Type", "application/rss+xml; charset=utf-8")
c.XML(http.StatusOK, feed)
}
}
// getRSSLanguage converts the internal language code to RSS language format.
func getRSSLanguage(lang string) string {
switch lang {
case "zh":
return "zh-CN"
case "en":
return "en-US"
default:
return "en-US"
}
}
// formatRSSTime formats a time.Time to RFC1123Z format required by RSS 2.0.
func formatRSSTime(t time.Time) string {
return t.Format(time.RFC1123Z)
}
// getArticlePubDate returns the article's publish date, falling back to created date.
func getArticlePubDate(article *models.Article) time.Time {
if article.PublishedAt != nil {
return *article.PublishedAt
}
return article.CreatedAt
}
// getArticleDescription returns the article description for RSS.
// Prefers the summary field; falls back to truncated content.
func getArticleDescription(article *models.Article) string {
if article.Summary != "" {
return html.EscapeString(article.Summary)
}
// Strip HTML tags and truncate content to 200 characters.
content := stripHTMLTags(article.Content)
if len(content) > 200 {
content = content[:200] + "..."
}
return html.EscapeString(content)
}
// stripHTMLTags removes HTML tags from a string (basic implementation).
func stripHTMLTags(s string) string {
// Remove HTML tags by finding < and > pairs.
var result strings.Builder
inTag := false
for _, r := range s {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
result.WriteRune(r)
}
}
// Clean up multiple spaces and trim.
cleaned := strings.Join(strings.Fields(result.String()), " ")
return cleaned
}