2
0

feat(blog): add comment reactions and improve UI
Some checks failed
Build and Release / Create Release (push) Successful in 1s
Build and Release / Unit Tests (push) Successful in 3m9s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m19s
Build and Release / Lint (push) Successful in 5m27s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m1s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h4m37s
Build and Release / Build Binary (linux/arm64) (push) Has been cancelled
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been cancelled
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been cancelled

Implements thumbs up/down reactions for blog comments with toggle functionality. Adds batch loading of reaction counts and user reactions for performance. Updates standalone view and repo blog view to display comment reactions. Improves explore blogs UI with better card layout and navigation. Includes guest IP tracking for anonymous reactions.
This commit is contained in:
2026-02-02 09:12:19 -05:00
parent ddf87daa42
commit 90f5fee237
9 changed files with 549 additions and 68 deletions

View File

@@ -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
}