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
}

View File

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

View File

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

View File

@@ -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() {

View File

@@ -32,7 +32,7 @@
{{end}}
<a class="item{{if .PageIsExplore}} active{{end}}" href="{{AppSubUrl}}/explore/repos">{{ctx.Locale.Tr "explore_title"}}</a>
{{if and (.SystemConfig.Theme.EnableBlogs.Value ctx) (.SystemConfig.Theme.BlogsInTopNav.Value ctx)}}
<a class="item{{if .PageIsExploreBlogs}} active{{end}}" href="{{AppSubUrl}}/explore/blogs">{{ctx.Locale.Tr "explore.blogs"}}</a>
<a class="item{{if .PageIsExploreBlogs}} active{{end}}" href="{{AppSubUrl}}/blogs">{{ctx.Locale.Tr "explore.blogs"}}</a>
{{end}}
{{if .SystemConfig.Theme.APIHeaderURL.Value ctx}}
<a class="item" href="{{.SystemConfig.Theme.APIHeaderURL.Value ctx}}">{{ctx.Locale.Tr "api"}}</a>
@@ -42,14 +42,14 @@
<a class="item{{if .PageIsExplore}} active{{end}}" href="{{AppSubUrl}}/explore/organizations">{{ctx.Locale.Tr "explore_title"}}</a>
{{end}}
{{if and (.SystemConfig.Theme.EnableBlogs.Value ctx) (.SystemConfig.Theme.BlogsInTopNav.Value ctx)}}
<a class="item{{if .PageIsExploreBlogs}} active{{end}}" href="{{AppSubUrl}}/explore/blogs">{{ctx.Locale.Tr "explore.blogs"}}</a>
<a class="item{{if .PageIsExploreBlogs}} active{{end}}" href="{{AppSubUrl}}/blogs">{{ctx.Locale.Tr "explore.blogs"}}</a>
{{end}}
{{else}}
{{if not (.SystemConfig.Theme.HideExploreButton.Value ctx)}}
<a class="item{{if .PageIsExplore}} active{{end}}" href="{{AppSubUrl}}/explore/repos">{{ctx.Locale.Tr "explore_title"}}</a>
{{end}}
{{if and (.SystemConfig.Theme.EnableBlogs.Value ctx) (.SystemConfig.Theme.BlogsInTopNav.Value ctx)}}
<a class="item{{if .PageIsExploreBlogs}} active{{end}}" href="{{AppSubUrl}}/explore/blogs">{{ctx.Locale.Tr "explore.blogs"}}</a>
<a class="item{{if .PageIsExploreBlogs}} active{{end}}" href="{{AppSubUrl}}/blogs">{{ctx.Locale.Tr "explore.blogs"}}</a>
{{end}}
{{end}}
{{if .SystemConfig.Theme.APIHeaderURL.Value ctx}}

View File

@@ -27,21 +27,28 @@
{{if .BlogPost.Subtitle}}
<p class="blog-view-subtitle">{{.BlogPost.Subtitle}}</p>
{{end}}
<div class="blog-view-meta">
{{if .BlogPost.Author}}
<img class="blog-avatar" src="{{.BlogPost.Author.AvatarLink ctx}}" alt="{{.BlogPost.Author.Name}}">
{{if not .BlogPost.Author.KeepEmailPrivate}}
<a href="mailto:{{.BlogPost.Author.Email}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{else}}
<a href="{{.BlogPost.Author.HomeLink}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{end}}
<span class="blog-meta-sep">&middot;</span>
{{end}}
{{if .BlogPost.PublishedUnix}}
<span>{{DateUtils.TimeSince .BlogPost.PublishedUnix}}</span>
{{else}}
<span>{{DateUtils.TimeSince .BlogPost.CreatedUnix}}</span>
{{end}}
<div class="blog-view-meta-row">
<div class="blog-view-meta">
{{if .BlogPost.Author}}
<img class="blog-avatar" src="{{.BlogPost.Author.AvatarLink ctx}}" alt="{{.BlogPost.Author.Name}}">
{{if not .BlogPost.Author.KeepEmailPrivate}}
<a href="mailto:{{.BlogPost.Author.Email}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{else}}
<a href="{{.BlogPost.Author.HomeLink}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{end}}
<span class="blog-meta-sep">&middot;</span>
{{end}}
{{if .BlogPost.PublishedUnix}}
<span>{{DateUtils.TimeSince .BlogPost.PublishedUnix}}</span>
{{else}}
<span>{{DateUtils.TimeSince .BlogPost.CreatedUnix}}</span>
{{end}}
</div>
<button class="ui small basic button blog-share-btn" id="blog-share-btn"
data-tooltip-content="{{ctx.Locale.Tr "repo.blog.share_link"}}"
data-link="{{.RepoLink}}/blog/{{.BlogPost.ID}}">
{{svg "octicon-link" 16}} {{ctx.Locale.Tr "repo.blog.share_link"}}
</button>
</div>
{{if .BlogTags}}
<div class="blog-view-tags">
@@ -50,13 +57,6 @@
{{end}}
</div>
{{end}}
<div class="blog-share">
<button class="ui small basic button blog-share-btn" id="blog-share-btn"
data-tooltip-content="{{ctx.Locale.Tr "repo.blog.share_link"}}"
data-link="{{.RepoLink}}/blog/{{.BlogPost.ID}}">
{{svg "octicon-link" 16}} {{ctx.Locale.Tr "repo.blog.share_link"}}
</button>
</div>
</header>
<div class="blog-view-body markup markdown">
@@ -122,12 +122,25 @@
</div>
<div class="blog-comment-content">{{.Content}}</div>
<div class="blog-comment-actions">
<div class="blog-comment-reactions">
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (index $.UserCommentReactions .ID).IsLike}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="like" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsup" 14}}
<span class="comment-like-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Likes}}{{else}}0{{end}}</span>
</button>
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (not (index $.UserCommentReactions .ID).IsLike)}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="dislike" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsdown" 14}}
<span class="comment-dislike-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Dislikes}}{{else}}0{{end}}</span>
</button>
</div>
<button class="blog-reply-btn" data-comment-id="{{.ID}}" type="button">
{{svg "octicon-reply" 14}} {{ctx.Locale.Tr "repo.blog.comment.reply"}}
</button>
{{if or $.IsWriter (and $.IsSigned (eq .UserID $.SignedUserID))}}
<form method="post" action="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/delete" class="tw-inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="redirect" value="standalone">
<button class="blog-delete-btn" type="submit" onclick="return confirm('{{ctx.Locale.Tr "repo.blog.comment.delete_confirm"}}')">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "repo.blog.comment.delete"}}
</button>
@@ -152,16 +165,28 @@
<span class="blog-comment-time">{{DateUtils.TimeSince .CreatedUnix}}</span>
</div>
<div class="blog-comment-content">{{.Content}}</div>
{{if or $.IsWriter (and $.IsSigned (eq .UserID $.SignedUserID))}}
<div class="blog-comment-actions">
<div class="blog-comment-reactions">
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (index $.UserCommentReactions .ID).IsLike}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="like" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsup" 14}}
<span class="comment-like-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Likes}}{{else}}0{{end}}</span>
</button>
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (not (index $.UserCommentReactions .ID).IsLike)}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="dislike" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsdown" 14}}
<span class="comment-dislike-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Dislikes}}{{else}}0{{end}}</span>
</button>
</div>
{{if or $.IsWriter (and $.IsSigned (eq .UserID $.SignedUserID))}}
<form method="post" action="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/delete" class="tw-inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="redirect" value="standalone">
<button class="blog-delete-btn" type="submit" onclick="return confirm('{{ctx.Locale.Tr "repo.blog.comment.delete_confirm"}}')">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "repo.blog.comment.delete"}}
</button>
</form>
</div>
{{end}}
{{end}}
</div>
{{end}}
</div>
@@ -171,6 +196,7 @@
<div class="blog-reply-form tw-hidden" id="reply-form-{{.ID}}">
<form method="post" action="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment">
{{$.CsrfTokenHtml}}
<input type="hidden" name="redirect" value="standalone">
<input type="hidden" name="parent_id" value="{{.ID}}">
<textarea name="content" rows="3" class="blog-comment-textarea" placeholder="{{ctx.Locale.Tr "repo.blog.comment.reply_placeholder"}}" required></textarea>
<div class="tw-flex tw-gap-2 tw-mt-2">
@@ -192,6 +218,7 @@
<h4>{{ctx.Locale.Tr "repo.blog.comment.leave"}}</h4>
<form method="post" action="{{.RepoLink}}/blog/{{.BlogPost.ID}}/comment">
{{.CsrfTokenHtml}}
<input type="hidden" name="redirect" value="standalone">
<input type="hidden" name="parent_id" value="0">
<textarea name="content" rows="4" class="blog-comment-textarea" placeholder="{{ctx.Locale.Tr "repo.blog.comment.placeholder"}}" required></textarea>
<div class="tw-mt-2">
@@ -205,6 +232,7 @@
<p class="tw-text-sm tw-mb-2">{{ctx.Locale.Tr "repo.blog.comment.guest_commenting_as" .GuestToken.Name}}</p>
<form method="post" action="{{.RepoLink}}/blog/{{.BlogPost.ID}}/comment">
{{.CsrfTokenHtml}}
<input type="hidden" name="redirect" value="standalone">
<input type="hidden" name="parent_id" value="0">
<textarea name="content" rows="4" class="blog-comment-textarea" placeholder="{{ctx.Locale.Tr "repo.blog.comment.placeholder"}}" required></textarea>
<div class="tw-mt-2">
@@ -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() {

View File

@@ -16,9 +16,6 @@
<p class="blog-featured-subtitle">{{.FeaturedPost.Subtitle}}</p>
{{end}}
<div class="blog-featured-meta">
{{if .FeaturedPost.Author}}
<img class="blog-avatar" src="{{.FeaturedPost.Author.AvatarLink ctx}}" alt="{{.FeaturedPost.Author.Name}}">
{{end}}
{{if .FeaturedPost.Repo}}
<img class="blog-avatar-sm" src="{{if .FeaturedPost.Repo.RelAvatarLink ctx}}{{.FeaturedPost.Repo.RelAvatarLink ctx}}{{else if .FeaturedPost.Repo.Owner}}{{.FeaturedPost.Repo.Owner.AvatarLink ctx}}{{end}}" alt="">
<span class="blog-featured-repo">{{if .FeaturedPost.Repo.DisplayTitle}}{{.FeaturedPost.Repo.DisplayTitle}}{{else}}{{.FeaturedPost.Repo.Name}}{{end}}</span>
@@ -87,7 +84,6 @@
<div class="blog-list-item-footer">
{{if .Author}}
<a href="{{.Author.HomeLink}}" class="blog-list-item-author">
<img class="blog-avatar-sm" src="{{.Author.AvatarLink ctx}}" alt="{{.Author.Name}}">
<span>{{.Author.DisplayName}}</span>
</a>
{{end}}

View File

@@ -18,8 +18,8 @@
{{svg "octicon-package"}} {{ctx.Locale.Tr "explore.packages"}}
</a>
{{end}}
{{if .BlogsPageIsEnabled}}
<a class="{{if .PageIsExploreBlogs}}active {{end}}item" href="{{AppSubUrl}}/explore/blogs">
{{if and .BlogsPageIsEnabled (not (.SystemConfig.Theme.BlogsInTopNav.Value ctx))}}
<a class="{{if .PageIsExploreBlogs}}active {{end}}item" href="{{AppSubUrl}}/blogs">
{{svg "octicon-note" 16}} {{ctx.Locale.Tr "explore.blogs"}}
</a>
{{end}}

View File

@@ -18,21 +18,28 @@
{{if .BlogPost.Subtitle}}
<p class="blog-view-subtitle">{{.BlogPost.Subtitle}}</p>
{{end}}
<div class="blog-view-meta">
{{if .BlogPost.Author}}
<img class="blog-avatar" src="{{.BlogPost.Author.AvatarLink ctx}}" alt="{{.BlogPost.Author.Name}}">
{{if not .BlogPost.Author.KeepEmailPrivate}}
<a href="mailto:{{.BlogPost.Author.Email}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{else}}
<a href="{{.BlogPost.Author.HomeLink}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{end}}
<span class="blog-meta-sep">&middot;</span>
{{end}}
{{if .BlogPost.PublishedUnix}}
<span>{{DateUtils.TimeSince .BlogPost.PublishedUnix}}</span>
{{else}}
<span>{{DateUtils.TimeSince .BlogPost.CreatedUnix}}</span>
{{end}}
<div class="blog-view-meta-row">
<div class="blog-view-meta">
{{if .BlogPost.Author}}
<img class="blog-avatar" src="{{.BlogPost.Author.AvatarLink ctx}}" alt="{{.BlogPost.Author.Name}}">
{{if not .BlogPost.Author.KeepEmailPrivate}}
<a href="mailto:{{.BlogPost.Author.Email}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{else}}
<a href="{{.BlogPost.Author.HomeLink}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{end}}
<span class="blog-meta-sep">&middot;</span>
{{end}}
{{if .BlogPost.PublishedUnix}}
<span>{{DateUtils.TimeSince .BlogPost.PublishedUnix}}</span>
{{else}}
<span>{{DateUtils.TimeSince .BlogPost.CreatedUnix}}</span>
{{end}}
</div>
<button class="ui small basic button blog-share-btn" id="blog-share-btn"
data-tooltip-content="{{ctx.Locale.Tr "repo.blog.share_link"}}"
data-link="{{.RepoLink}}/blog/{{.BlogPost.ID}}">
{{svg "octicon-link" 16}} {{ctx.Locale.Tr "repo.blog.share_link"}}
</button>
</div>
{{if .BlogTags}}
<div class="blog-view-tags">
@@ -41,13 +48,6 @@
{{end}}
</div>
{{end}}
<div class="blog-share">
<button class="ui small basic button blog-share-btn" id="blog-share-btn"
data-tooltip-content="{{ctx.Locale.Tr "repo.blog.share_link"}}"
data-link="{{.RepoLink}}/blog/{{.BlogPost.ID}}">
{{svg "octicon-link" 16}} {{ctx.Locale.Tr "repo.blog.share_link"}}
</button>
</div>
</header>
<div class="blog-view-body markup markdown">
@@ -113,6 +113,18 @@
</div>
<div class="blog-comment-content">{{.Content}}</div>
<div class="blog-comment-actions">
<div class="blog-comment-reactions">
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (index $.UserCommentReactions .ID).IsLike}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="like" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsup" 14}}
<span class="comment-like-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Likes}}{{else}}0{{end}}</span>
</button>
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (not (index $.UserCommentReactions .ID).IsLike)}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="dislike" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsdown" 14}}
<span class="comment-dislike-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Dislikes}}{{else}}0{{end}}</span>
</button>
</div>
<button class="blog-reply-btn" data-comment-id="{{.ID}}" type="button">
{{svg "octicon-reply" 14}} {{ctx.Locale.Tr "repo.blog.comment.reply"}}
</button>
@@ -143,16 +155,27 @@
<span class="blog-comment-time">{{DateUtils.TimeSince .CreatedUnix}}</span>
</div>
<div class="blog-comment-content">{{.Content}}</div>
{{if or $.IsWriter (and $.IsSigned (eq .UserID $.SignedUserID))}}
<div class="blog-comment-actions">
<div class="blog-comment-reactions">
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (index $.UserCommentReactions .ID).IsLike}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="like" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsup" 14}}
<span class="comment-like-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Likes}}{{else}}0{{end}}</span>
</button>
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (not (index $.UserCommentReactions .ID).IsLike)}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="dislike" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsdown" 14}}
<span class="comment-dislike-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Dislikes}}{{else}}0{{end}}</span>
</button>
</div>
{{if or $.IsWriter (and $.IsSigned (eq .UserID $.SignedUserID))}}
<form method="post" action="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/delete" class="tw-inline">
{{$.CsrfTokenHtml}}
<button class="blog-delete-btn" type="submit" onclick="return confirm('{{ctx.Locale.Tr "repo.blog.comment.delete_confirm"}}')">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "repo.blog.comment.delete"}}
</button>
</form>
</div>
{{end}}
{{end}}
</div>
{{end}}
</div>
@@ -266,6 +289,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;
@@ -295,11 +324,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;
@@ -389,6 +416,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);
@@ -456,9 +488,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;
@@ -567,6 +632,38 @@
});
});
// === 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);
// Update active states for this comment's buttons
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() {