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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -33,6 +34,39 @@ func HomePage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HomeArticlesAPI returns articles in JSON format for infinite scroll.
|
||||
func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page := 1
|
||||
if p, ok := c.GetQuery("page"); ok {
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(p, "%d", &parsed); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
|
||||
pageSize := 10
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var articles []models.Article
|
||||
db.Where("status = ?", models.ArticlePublished).
|
||||
Order(publishedArticleOrder).
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
Find(&articles)
|
||||
|
||||
var total int64
|
||||
db.Model(&models.Article{}).
|
||||
Where("status = ?", models.ArticlePublished).
|
||||
Count(&total)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"articles": articles,
|
||||
"hasMore": int64(offset+len(articles)) < total,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ArticleDetail renders a single published article by its slug.
|
||||
func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user