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
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:
168
models/blog/blog_comment_reaction.go
Normal file
168
models/blog/blog_comment_reaction.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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">·</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">·</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() {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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">·</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">·</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() {
|
||||
|
||||
Reference in New Issue
Block a user