diff --git a/models/blog/blog_comment_reaction.go b/models/blog/blog_comment_reaction.go new file mode 100644 index 0000000000..1b61a1a7fa --- /dev/null +++ b/models/blog/blog_comment_reaction.go @@ -0,0 +1,168 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package blog + +import ( + "context" + + "code.gitcaddy.com/server/v3/models/db" + "code.gitcaddy.com/server/v3/modules/timeutil" +) + +// BlogCommentReaction represents a thumbs up/down reaction on a blog comment. +type BlogCommentReaction struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + CommentID int64 `xorm:"INDEX NOT NULL"` + UserID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + GuestIP string `xorm:"VARCHAR(45) NOT NULL DEFAULT ''"` + IsLike bool `xorm:"NOT NULL DEFAULT true"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +func init() { + db.RegisterModel(new(BlogCommentReaction)) +} + +// BlogCommentReactionCounts holds aggregated reaction counts for a comment. +type BlogCommentReactionCounts struct { //revive:disable-line:exported + Likes int64 + Dislikes int64 +} + +// ToggleBlogCommentReaction creates, updates, or removes a reaction on a comment. +func ToggleBlogCommentReaction(ctx context.Context, commentID, userID int64, guestIP string, isLike bool) (reacted bool, err error) { + existing, err := GetUserBlogCommentReaction(ctx, commentID, userID, guestIP) + if err != nil { + return false, err + } + + if existing != nil { + if existing.IsLike == isLike { + // Same type — toggle off (remove) + _, err = db.GetEngine(ctx).ID(existing.ID).Delete(new(BlogCommentReaction)) + return false, err + } + // Different type — switch + existing.IsLike = isLike + _, err = db.GetEngine(ctx).ID(existing.ID).Cols("is_like").Update(existing) + return true, err + } + + // No existing reaction — create + _, err = db.GetEngine(ctx).Insert(&BlogCommentReaction{ + CommentID: commentID, + UserID: userID, + GuestIP: guestIP, + IsLike: isLike, + }) + return true, err +} + +// GetBlogCommentReactionCounts returns aggregated like/dislike counts for a comment. +func GetBlogCommentReactionCounts(ctx context.Context, commentID int64) (*BlogCommentReactionCounts, error) { + likes, err := db.GetEngine(ctx).Where("comment_id = ? AND is_like = ?", commentID, true).Count(new(BlogCommentReaction)) + if err != nil { + return nil, err + } + dislikes, err := db.GetEngine(ctx).Where("comment_id = ? AND is_like = ?", commentID, false).Count(new(BlogCommentReaction)) + if err != nil { + return nil, err + } + return &BlogCommentReactionCounts{Likes: likes, Dislikes: dislikes}, nil +} + +// GetBlogCommentReactionCountsBatch returns reaction counts for multiple comments. +func GetBlogCommentReactionCountsBatch(ctx context.Context, commentIDs []int64) (map[int64]*BlogCommentReactionCounts, error) { + result := make(map[int64]*BlogCommentReactionCounts, len(commentIDs)) + for _, id := range commentIDs { + result[id] = &BlogCommentReactionCounts{} + } + if len(commentIDs) == 0 { + return result, nil + } + + type countRow struct { + CommentID int64 `xorm:"comment_id"` + IsLike bool `xorm:"is_like"` + Cnt int64 `xorm:"cnt"` + } + var rows []countRow + err := db.GetEngine(ctx).Table("blog_comment_reaction"). + Select("comment_id, is_like, COUNT(*) AS cnt"). + In("comment_id", commentIDs). + GroupBy("comment_id, is_like"). + Find(&rows) + if err != nil { + return nil, err + } + for _, r := range rows { + counts, ok := result[r.CommentID] + if !ok { + counts = &BlogCommentReactionCounts{} + result[r.CommentID] = counts + } + if r.IsLike { + counts.Likes = r.Cnt + } else { + counts.Dislikes = r.Cnt + } + } + return result, nil +} + +// GetUserBlogCommentReaction returns the existing reaction for a user/guest on a comment, or nil. +func GetUserBlogCommentReaction(ctx context.Context, commentID, userID int64, guestIP string) (*BlogCommentReaction, error) { + r := &BlogCommentReaction{} + var has bool + var err error + if userID > 0 { + has, err = db.GetEngine(ctx).Where("comment_id = ? AND user_id = ?", commentID, userID).Get(r) + } else { + has, err = db.GetEngine(ctx).Where("comment_id = ? AND user_id = 0 AND guest_ip = ?", commentID, guestIP).Get(r) + } + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return r, nil +} + +// GetUserBlogCommentReactionsBatch returns the user's reactions for multiple comments. +func GetUserBlogCommentReactionsBatch(ctx context.Context, commentIDs []int64, userID int64, guestIP string) (map[int64]*BlogCommentReaction, error) { + result := make(map[int64]*BlogCommentReaction, len(commentIDs)) + if len(commentIDs) == 0 { + return result, nil + } + + var reactions []*BlogCommentReaction + sess := db.GetEngine(ctx).In("comment_id", commentIDs) + if userID > 0 { + sess = sess.Where("user_id = ?", userID) + } else { + sess = sess.Where("user_id = 0 AND guest_ip = ?", guestIP) + } + if err := sess.Find(&reactions); err != nil { + return nil, err + } + for _, r := range reactions { + result[r.CommentID] = r + } + return result, nil +} + +// DeleteBlogCommentReactionsByCommentID removes all reactions for a comment. +func DeleteBlogCommentReactionsByCommentID(ctx context.Context, commentID int64) error { + _, err := db.GetEngine(ctx).Where("comment_id = ?", commentID).Delete(new(BlogCommentReaction)) + return err +} + +// DeleteBlogCommentReactionsByPostID removes all comment reactions for a blog post's comments. +func DeleteBlogCommentReactionsByPostID(ctx context.Context, blogPostID int64) error { + _, err := db.GetEngine(ctx). + Where("comment_id IN (SELECT id FROM blog_comment WHERE blog_post_id = ?)", blogPostID). + Delete(new(BlogCommentReaction)) + return err +} diff --git a/routers/web/explore/blog.go b/routers/web/explore/blog.go index 13f15081db..72427ba798 100644 --- a/routers/web/explore/blog.go +++ b/routers/web/explore/blog.go @@ -210,6 +210,19 @@ func StandaloneBlogView(ctx *context.Context) { commentCount, _ := blog_model.CountBlogComments(ctx, post.ID) ctx.Data["CommentCount"] = commentCount + + // Load comment reaction counts and user reactions + commentIDs := collectCommentIDs(comments) + if len(commentIDs) > 0 { + commentReactionCounts, err := blog_model.GetBlogCommentReactionCountsBatch(ctx, commentIDs) + if err == nil { + ctx.Data["CommentReactionCounts"] = commentReactionCounts + } + userCommentReactions, err := blog_model.GetUserBlogCommentReactionsBatch(ctx, commentIDs, userID, guestIP) + if err == nil { + ctx.Data["UserCommentReactions"] = userCommentReactions + } + } } // Check guest token cookie @@ -250,3 +263,15 @@ func StandaloneBlogView(ctx *context.Context) { ctx.HTML(http.StatusOK, tplStandaloneBlogView) } + +// collectCommentIDs returns all comment IDs from top-level comments and their replies. +func collectCommentIDs(comments []*blog_model.BlogComment) []int64 { + var ids []int64 + for _, c := range comments { + ids = append(ids, c.ID) + for _, r := range c.Replies { + ids = append(ids, r.ID) + } + } + return ids +} diff --git a/routers/web/repo/blog.go b/routers/web/repo/blog.go index 51fed3cc11..97d68a3dfb 100644 --- a/routers/web/repo/blog.go +++ b/routers/web/repo/blog.go @@ -41,6 +41,18 @@ func blogEnabled(ctx *context.Context) bool { return ctx.Repo.Repository.BlogEnabled && setting.Config().Theme.EnableBlogs.Value(ctx) } +// collectCommentIDs returns all comment IDs from top-level comments and their replies. +func collectCommentIDs(comments []*blog_model.BlogComment) []int64 { + var ids []int64 + for _, c := range comments { + ids = append(ids, c.ID) + for _, r := range c.Replies { + ids = append(ids, r.ID) + } + } + return ids +} + // BlogList renders the repo blog listing page. func BlogList(ctx *context.Context) { if !blogEnabled(ctx) { @@ -218,6 +230,19 @@ func BlogView(ctx *context.Context) { commentCount, _ := blog_model.CountBlogComments(ctx, post.ID) ctx.Data["CommentCount"] = commentCount + + // Load comment reaction counts and user reactions + commentIDs := collectCommentIDs(comments) + if len(commentIDs) > 0 { + commentReactionCounts, err := blog_model.GetBlogCommentReactionCountsBatch(ctx, commentIDs) + if err == nil { + ctx.Data["CommentReactionCounts"] = commentReactionCounts + } + userCommentReactions, err := blog_model.GetUserBlogCommentReactionsBatch(ctx, commentIDs, userID, guestIP) + if err == nil { + ctx.Data["UserCommentReactions"] = userCommentReactions + } + } } // Check guest token cookie for anonymous commenters @@ -701,6 +726,65 @@ func BlogReact(ctx *context.Context) { }) } +// BlogCommentReact handles thumbs up/down reactions on blog comments. +func BlogCommentReact(ctx *context.Context) { + if !blogEnabled(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil || post.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(err) + return + } + + commentID := ctx.PathParamInt64("commentID") + comment, err := blog_model.GetBlogCommentByID(ctx, commentID) + if err != nil || comment == nil || comment.BlogPostID != post.ID { + ctx.NotFound(err) + return + } + + reactionType := ctx.FormString("type") + var isLike bool + switch reactionType { + case "like": + isLike = true + case "dislike": + isLike = false + default: + ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid reaction type"}) + return + } + + var userID int64 + guestIP := ctx.Req.RemoteAddr + if ctx.Doer != nil { + userID = ctx.Doer.ID + guestIP = "" + } + + reacted, err := blog_model.ToggleBlogCommentReaction(ctx, comment.ID, userID, guestIP, isLike) + if err != nil { + ctx.ServerError("ToggleBlogCommentReaction", err) + return + } + + counts, err := blog_model.GetBlogCommentReactionCounts(ctx, comment.ID) + if err != nil { + ctx.ServerError("GetBlogCommentReactionCounts", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "reacted": reacted, + "type": reactionType, + "likes": counts.Likes, + "dislikes": counts.Dislikes, + }) +} + // BlogCommentPost creates a new comment or reply on a blog post. func BlogCommentPost(ctx *context.Context) { if !blogEnabled(ctx) { @@ -757,7 +841,11 @@ func BlogCommentPost(ctx *context.Context) { } ctx.Flash.Success(ctx.Tr("repo.blog.comment.posted")) - ctx.Redirect(fmt.Sprintf("%s/blog/%d#comment-%d", ctx.Repo.RepoLink, post.ID, comment.ID)) + if redirect := ctx.FormString("redirect"); redirect == "standalone" { + ctx.Redirect(fmt.Sprintf("%s/blog/%d#comment-%d", setting.AppSubURL, post.ID, comment.ID)) + } else { + ctx.Redirect(fmt.Sprintf("%s/blog/%d#comment-%d", ctx.Repo.RepoLink, post.ID, comment.ID)) + } } // BlogCommentDelete deletes a comment (writer or own comment). @@ -797,7 +885,11 @@ func BlogCommentDelete(ctx *context.Context) { } ctx.Flash.Success(ctx.Tr("repo.blog.comment.deleted")) - ctx.Redirect(fmt.Sprintf("%s/blog/%d", ctx.Repo.RepoLink, post.ID)) + if redirect := ctx.FormString("redirect"); redirect == "standalone" { + ctx.Redirect(fmt.Sprintf("%s/blog/%d", setting.AppSubURL, post.ID)) + } else { + ctx.Redirect(fmt.Sprintf("%s/blog/%d", ctx.Repo.RepoLink, post.ID)) + } } // BlogGuestVerifyRequest handles a guest providing name + email for verification. diff --git a/routers/web/web.go b/routers/web/web.go index 201a5fa02f..f46c94a481 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -577,7 +577,8 @@ func registerWebRoutes(m *web.Router) { m.Get("/topics/search", explore.TopicSearch) }, optExploreSignIn, exploreAnonymousGuard) - // Standalone blog view (no repo header) + // Top-level blogs listing and standalone blog view + m.Get("/blogs", optSignIn, explore.Blogs) m.Get("/blog/{id}", optSignIn, explore.StandaloneBlogView) m.Group("/issues", func() { @@ -1720,6 +1721,7 @@ func registerWebRoutes(m *web.Router) { m.Post("/{id}/react", repo.BlogReact) m.Post("/{id}/comment", repo.BlogCommentPost) m.Post("/{id}/comment/{commentID}/delete", repo.BlogCommentDelete) + m.Post("/{id}/comment/{commentID}/react", repo.BlogCommentReact) m.Post("/{id}/guest/verify", repo.BlogGuestVerifyRequest) m.Post("/{id}/guest/confirm", repo.BlogGuestVerifyCode) m.Group("", func() { diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index a991772cf1..ed0b6261f7 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -32,7 +32,7 @@ {{end}} {{ctx.Locale.Tr "explore_title"}} {{if and (.SystemConfig.Theme.EnableBlogs.Value ctx) (.SystemConfig.Theme.BlogsInTopNav.Value ctx)}} - {{ctx.Locale.Tr "explore.blogs"}} + {{ctx.Locale.Tr "explore.blogs"}} {{end}} {{if .SystemConfig.Theme.APIHeaderURL.Value ctx}} {{ctx.Locale.Tr "api"}} @@ -42,14 +42,14 @@ {{ctx.Locale.Tr "explore_title"}} {{end}} {{if and (.SystemConfig.Theme.EnableBlogs.Value ctx) (.SystemConfig.Theme.BlogsInTopNav.Value ctx)}} - {{ctx.Locale.Tr "explore.blogs"}} + {{ctx.Locale.Tr "explore.blogs"}} {{end}} {{else}} {{if not (.SystemConfig.Theme.HideExploreButton.Value ctx)}} {{ctx.Locale.Tr "explore_title"}} {{end}} {{if and (.SystemConfig.Theme.EnableBlogs.Value ctx) (.SystemConfig.Theme.BlogsInTopNav.Value ctx)}} - {{ctx.Locale.Tr "explore.blogs"}} + {{ctx.Locale.Tr "explore.blogs"}} {{end}} {{end}} {{if .SystemConfig.Theme.APIHeaderURL.Value ctx}} diff --git a/templates/blog/standalone_view.tmpl b/templates/blog/standalone_view.tmpl index f828725571..9250ff23c6 100644 --- a/templates/blog/standalone_view.tmpl +++ b/templates/blog/standalone_view.tmpl @@ -27,21 +27,28 @@ {{if .BlogPost.Subtitle}}
{{.BlogPost.Subtitle}}
{{end}} - {{end}} @@ -171,6 +196,7 @@