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}} -
- {{if .BlogPost.Author}} - {{.BlogPost.Author.Name}} - {{if not .BlogPost.Author.KeepEmailPrivate}} - {{.BlogPost.Author.DisplayName}} - {{else}} - {{.BlogPost.Author.DisplayName}} - {{end}} - · - {{end}} - {{if .BlogPost.PublishedUnix}} - {{DateUtils.TimeSince .BlogPost.PublishedUnix}} - {{else}} - {{DateUtils.TimeSince .BlogPost.CreatedUnix}} - {{end}} +
+
+ {{if .BlogPost.Author}} + {{.BlogPost.Author.Name}} + {{if not .BlogPost.Author.KeepEmailPrivate}} + {{.BlogPost.Author.DisplayName}} + {{else}} + {{.BlogPost.Author.DisplayName}} + {{end}} + · + {{end}} + {{if .BlogPost.PublishedUnix}} + {{DateUtils.TimeSince .BlogPost.PublishedUnix}} + {{else}} + {{DateUtils.TimeSince .BlogPost.CreatedUnix}} + {{end}} +
+
{{if .BlogTags}}
@@ -50,13 +57,6 @@ {{end}}
{{end}} -
- -
@@ -122,12 +122,25 @@
{{.Content}}
+
+ + +
{{if or $.IsWriter (and $.IsSigned (eq .UserID $.SignedUserID))}}
{{$.CsrfTokenHtml}} + @@ -152,16 +165,28 @@ {{DateUtils.TimeSince .CreatedUnix}}
{{.Content}}
- {{if or $.IsWriter (and $.IsSigned (eq .UserID $.SignedUserID))}}
+
+ + +
+ {{if or $.IsWriter (and $.IsSigned (eq .UserID $.SignedUserID))}} {{$.CsrfTokenHtml}} + -
- {{end}} + {{end}}
{{end}} @@ -171,6 +196,7 @@
{{$.CsrfTokenHtml}} +
@@ -192,6 +218,7 @@

{{ctx.Locale.Tr "repo.blog.comment.leave"}}

{{.CsrfTokenHtml}} +
@@ -205,6 +232,7 @@

{{ctx.Locale.Tr "repo.blog.comment.guest_commenting_as" .GuestToken.Name}}

{{.CsrfTokenHtml}} +
@@ -296,6 +324,12 @@ line-height: 1.5; margin: 0 0 16px; } +.blog-view-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} .blog-view-meta { display: flex; align-items: center; @@ -325,11 +359,9 @@ gap: 6px; margin-top: 12px; } -.blog-share { - margin-top: 12px; -} .blog-share-btn { cursor: pointer; + flex-shrink: 0; } .blog-share-copied { color: var(--color-success) !important; @@ -419,6 +451,11 @@ border-color: var(--color-primary); color: var(--color-primary); } +.blog-reaction-btn.active[data-type="dislike"] { + background: var(--color-red-alpha-20, rgba(219, 40, 40, 0.1)); + border-color: var(--color-red); + color: var(--color-red); +} .blog-reaction-hint { font-size: 12px; color: var(--color-text-light-3); @@ -486,9 +523,42 @@ } .blog-comment-actions { display: flex; + align-items: center; gap: 12px; margin-top: 8px; } +.blog-comment-reactions { + display: flex; + align-items: center; + gap: 4px; +} +.blog-comment-reaction-btn { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 8px; + border: 1px solid var(--color-secondary-alpha-40); + border-radius: 12px; + background: transparent; + color: var(--color-text-light); + cursor: pointer; + font-size: 12px; + transition: all 0.15s; +} +.blog-comment-reaction-btn:hover { + background: var(--color-secondary-alpha-20); + color: var(--color-text); +} +.blog-comment-reaction-btn.active { + background: var(--color-primary-alpha-20); + border-color: var(--color-primary); + color: var(--color-primary); +} +.blog-comment-reaction-btn.active[data-type="dislike"] { + background: var(--color-red-alpha-20, rgba(219, 40, 40, 0.1)); + border-color: var(--color-red); + color: var(--color-red); +} .blog-reply-btn, .blog-delete-btn { display: inline-flex; align-items: center; @@ -597,6 +667,37 @@ }); }); + // === Comment reaction buttons === + document.querySelectorAll('.blog-comment-reaction-btn').forEach(function(btn) { + btn.addEventListener('click', async function() { + const url = this.dataset.url; + const type = this.dataset.type; + const commentId = this.dataset.commentId; + try { + const fd = new FormData(); + fd.append('type', type); + const resp = await fetch(url, { + method: 'POST', + headers: {'X-Csrf-Token': csrfToken}, + body: fd, + }); + if (resp.ok) { + const data = await resp.json(); + document.querySelectorAll('.comment-like-count[data-comment-id="' + commentId + '"]').forEach(el => el.textContent = data.likes); + document.querySelectorAll('.comment-dislike-count[data-comment-id="' + commentId + '"]').forEach(el => el.textContent = data.dislikes); + document.querySelectorAll('.blog-comment-reaction-btn[data-comment-id="' + commentId + '"]').forEach(b => { + b.classList.remove('active'); + if (data.reacted && b.dataset.type === data.type) { + b.classList.add('active'); + } + }); + } + } catch (e) { + console.error('Comment reaction error:', e); + } + }); + }); + // === Reply toggle === document.querySelectorAll('.blog-reply-btn').forEach(function(btn) { btn.addEventListener('click', function() { diff --git a/templates/explore/blogs.tmpl b/templates/explore/blogs.tmpl index 563534009d..52fb69977b 100644 --- a/templates/explore/blogs.tmpl +++ b/templates/explore/blogs.tmpl @@ -16,9 +16,6 @@ {{end}}