package handlers import ( "fmt" "net/http" "strings" "time" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "go_blog/models" "gorm.io/gorm" ) // publishedArticleOrder is the ordering used to list published articles: // pinned first, then by most recent publish/creation time. const publishedArticleOrder = "articles.is_top DESC, articles.published_at DESC, articles.created_at DESC" // HomePage renders the public home page with the latest published articles. func HomePage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) data := DefaultData(c) data["Title"] = tr["page_home"] // Get tag filter if present tagSlug := c.Query("tag") // Build query query := db.Where("status = ?", models.ArticlePublished) if tagSlug != "" { // Join with article_tags to filter by tag query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id"). Joins("JOIN tags ON tags.id = article_tags.tag_id"). Where("tags.slug = ?", tagSlug) // Get tag info for display var tag models.Tag if err := db.Where("slug = ?", tagSlug).First(&tag).Error; err == nil { data["FilterTag"] = tag } } var articles []models.Article query.Preload("Tags"). Order(publishedArticleOrder). Limit(10). Find(&articles) // Load all tags for sidebar var tags []models.Tag db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags) // Get comment counts for all articles articleIDs := make([]uint, len(articles)) for i, article := range articles { articleIDs[i] = article.ID } type CommentCount struct { ArticleID uint Count int64 } var commentCounts []CommentCount if len(articleIDs) > 0 { db.Model(&models.Comment{}). Select("article_id, COUNT(*) as count"). Where("article_id IN ?", articleIDs). Where("status = ?", models.CommentApproved). Group("article_id"). Scan(&commentCounts) } // Create a map for quick lookup commentCountMap := make(map[uint]int64) for _, cc := range commentCounts { commentCountMap[cc.ArticleID] = cc.Count } // Add data to template data["Articles"] = articles data["Tags"] = tags data["CommentCounts"] = commentCountMap c.HTML(http.StatusOK, "home", data) } } // 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 // Get tag filter if present tagSlug := c.Query("tag") // Build query query := db.Where("status = ?", models.ArticlePublished) countQuery := db.Model(&models.Article{}).Where("status = ?", models.ArticlePublished) if tagSlug != "" { // Join with article_tags to filter by tag query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id"). Joins("JOIN tags ON tags.id = article_tags.tag_id"). Where("tags.slug = ?", tagSlug) countQuery = countQuery.Joins("JOIN article_tags ON article_tags.article_id = articles.id"). Joins("JOIN tags ON tags.id = article_tags.tag_id"). Where("tags.slug = ?", tagSlug) } var articles []models.Article query.Preload("Tags"). Order(publishedArticleOrder). Limit(pageSize). Offset(offset). Find(&articles) var total int64 countQuery.Count(&total) // Get comment counts for these articles articleIDs := make([]uint, len(articles)) for i, article := range articles { articleIDs[i] = article.ID } type CommentCount struct { ArticleID uint Count int64 } var commentCounts []CommentCount if len(articleIDs) > 0 { db.Model(&models.Comment{}). Select("article_id, COUNT(*) as count"). Where("article_id IN ?", articleIDs). Where("status = ?", models.CommentApproved). Group("article_id"). Scan(&commentCounts) } // Create a map for quick lookup commentCountMap := make(map[uint]int64) for _, cc := range commentCounts { commentCountMap[cc.ArticleID] = cc.Count } // Build response with comment counts type ArticleResponse struct { models.Article CommentCount int64 `json:"comment_count"` } articlesWithCounts := make([]ArticleResponse, len(articles)) for i, article := range articles { articlesWithCounts[i] = ArticleResponse{ Article: article, CommentCount: commentCountMap[article.ID], } } c.JSON(http.StatusOK, gin.H{ "articles": articlesWithCounts, "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) { tr := getTr(c) slug := c.Param("slug") var article models.Article err := db.Where("slug = ? AND status = ?", slug, models.ArticlePublished). Preload("Author"). First(&article).Error if err != nil { data := DefaultData(c) data["Title"] = tr["article_not_found"] c.HTML(http.StatusNotFound, "article_not_found", data) return } // Increment view count; ignore errors so a view never breaks the page. db.Model(&models.Article{}).Where("id = ?", article.ID). UpdateColumn("view_count", gorm.Expr("view_count + 1")) // Record unique article view asynchronously (doesn't block page response). go recordArticleView(db, article.ID, c) // One-time flash notice (set by PostComment on success/pending). Reading // consumes the flash, so refreshing the page no longer re-shows it. notice := readCommentFlash(c) renderArticleDetail(c, db, &article, commentForm{}, "", notice) } } // renderArticleDetail renders the article page, including the comment section. // formErr refills the form with an error banner; notice is a one-time // success/pending banner (already consumed from the session by the caller). func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, form commentForm, formErr, notice string) { tr := getTr(c) authorName := article.Author.Username if article.Author.DisplayName != "" { authorName = article.Author.DisplayName } viewer := viewerFromContext(c) var comments []models.Comment db.Where("article_id = ?", article.ID).Order("created_at ASC").Find(&comments) cfg := models.GetCommentConfig() useGravatar := cfg != nil && cfg.UseGravatar tree := buildCommentTree(comments, viewer, tr, useGravatar) data := DefaultData(c) data["Title"] = article.Title data["Article"] = article data["AuthorName"] = authorName data["PublishedAt"] = formatPublishTime(article.PublishedAt) data["UpdatedAt"] = formatUpdateTime(article.UpdatedAt) data["Comments"] = tree data["CommentConfig"] = models.GetCommentConfig() data["CommentForm"] = form data["CommentError"] = formErr data["CommentNotice"] = notice data["MaxCommentLength"] = MaxCommentLength c.HTML(http.StatusOK, "article", data) } // formatPublishTime returns the publication time as a readable string, falling // back to the creation time when the publish timestamp is unset. func formatPublishTime(publishedAt *time.Time) string { if publishedAt != nil { return publishedAt.Format("2006-01-02 15:04") } return "" } // formatUpdateTime returns the last update time as a readable string. func formatUpdateTime(updatedAt time.Time) string { return updatedAt.Format("2006-01-02 15:04") } // commentFlashKey is the session key for the one-time comment notice. const commentFlashKey = "comment_flash" // setCommentFlash stores a one-time comment notice in the session so the // following GET /article/:slug (after the POST/redirect) can show it once and // never again on refresh. func setCommentFlash(c *gin.Context, value string) { session := sessions.Default(c) session.Set(commentFlashKey, value) session.Save() } // readCommentFlash returns and clears the one-time comment notice, if any. func readCommentFlash(c *gin.Context) string { session := sessions.Default(c) v, ok := session.Get(commentFlashKey).(string) if ok && v != "" { session.Delete(commentFlashKey) session.Save() return v } return "" } // recordArticleView records a unique article view in the database. // This function is designed to be called asynchronously (via goroutine) to avoid // blocking the page response. It checks for existing records to ensure each // user/IP combination only records one view per article. func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) { session := sessions.Default(c) // Extract user ID from session if logged in var userID *uint if uid := session.Get("user_id"); uid != nil { switch v := uid.(type) { case uint: userID = &v case int: u := uint(v) userID = &u case int64: u := uint(v) userID = &u case float64: u := uint(v) userID = &u } } // Get client IP and User-Agent ip := GetClientIP(c) userAgent := c.Request.UserAgent() isBot := models.IsBot(userAgent) // Check if this view already exists (deduplication) var count int64 query := db.Model(&models.ArticleView{}). Where("article_id = ? AND ip = ?", articleID, ip) if userID != nil { query = query.Where("user_id = ?", *userID) } else { query = query.Where("user_id IS NULL") } if err := query.Count(&count).Error; err != nil { // Silently fail - don't break the user experience return } if count > 0 { // Already recorded return } // Create new view record using INSERT IGNORE pattern view := models.ArticleView{ ArticleID: articleID, UserID: userID, IP: ip, UserAgent: userAgent, IsBot: isBot, } // Create the view record (BeforeCreate hook in model handles deduplication) db.Create(&view) // Ignore errors - this is a best-effort tracking system } // SearchPage handles article search by keyword. func SearchPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) data := DefaultData(c) keyword := strings.TrimSpace(c.Query("q")) data["SearchKeyword"] = keyword data["Title"] = tr["search_title"] if keyword == "" { data["Articles"] = []models.Article{} data["Tags"] = []models.Tag{} data["CommentCounts"] = make(map[uint]int64) c.HTML(http.StatusOK, "search", data) return } // Search in title, summary, and content searchPattern := "%" + keyword + "%" var articles []models.Article db.Where("status = ?", models.ArticlePublished). Where("title LIKE ? OR summary LIKE ? OR content LIKE ?", searchPattern, searchPattern, searchPattern). Preload("Tags"). Order(publishedArticleOrder). Limit(50). // Limit search results Find(&articles) // Load all tags for sidebar var tags []models.Tag db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags) // Get comment counts articleIDs := make([]uint, len(articles)) for i, article := range articles { articleIDs[i] = article.ID } type CommentCount struct { ArticleID uint Count int64 } var commentCounts []CommentCount if len(articleIDs) > 0 { db.Model(&models.Comment{}). Select("article_id, COUNT(*) as count"). Where("article_id IN ?", articleIDs). Where("status = ?", models.CommentApproved). Group("article_id"). Scan(&commentCounts) } commentCountMap := make(map[uint]int64) for _, cc := range commentCounts { commentCountMap[cc.ArticleID] = cc.Count } data["Articles"] = articles data["Tags"] = tags data["CommentCounts"] = commentCountMap c.HTML(http.StatusOK, "search", data) } }