2
0

feat(blog): add comments, reactions, and guest verification
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m10s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m5s
Build and Release / Lint (push) Successful in 5m20s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m54s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h4m29s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 8m14s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 8m19s
Build and Release / Build Binary (linux/arm64) (push) Successful in 8m27s

Implements threaded comment system with support for authenticated users and verified guests. Adds email verification flow for guest commenters with token-based sessions and 6-digit codes. Includes reaction system (like/love/laugh/etc) for posts and comments. Adds comment count to blog posts, user profile blog tab, and email notifications for comment verification. Implements nested reply support with parent-child relationships.
This commit is contained in:
2026-02-01 22:22:18 -05:00
parent 7b34e295eb
commit 9e6d1d63de
21 changed files with 1461 additions and 7 deletions

140
models/blog/blog_comment.go Normal file
View File

@@ -0,0 +1,140 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package blog
import (
"context"
"code.gitcaddy.com/server/v3/models/db"
user_model "code.gitcaddy.com/server/v3/models/user"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
// BlogComment represents a comment or reply on a blog post.
// ParentID = 0 means top-level comment; ParentID > 0 means a reply.
type BlogComment struct { //revive:disable-line:exported
ID int64 `xorm:"pk autoincr"`
BlogPostID int64 `xorm:"INDEX NOT NULL"`
ParentID int64 `xorm:"INDEX NOT NULL DEFAULT 0"`
UserID int64 `xorm:"INDEX NOT NULL DEFAULT 0"`
GuestName string `xorm:"VARCHAR(100)"`
GuestEmail string `xorm:"VARCHAR(255)"`
Content string `xorm:"LONGTEXT NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
// Not persisted
User *user_model.User `xorm:"-"`
Replies []*BlogComment `xorm:"-"`
}
func init() {
db.RegisterModel(new(BlogComment))
}
// IsGuest returns true if the comment was posted by an anonymous guest.
func (c *BlogComment) IsGuest() bool {
return c.UserID == 0
}
// DisplayName returns the commenter's display name.
func (c *BlogComment) DisplayName() string {
if c.User != nil {
return c.User.DisplayName()
}
if c.GuestName != "" {
return c.GuestName
}
return "Anonymous"
}
// LoadUser loads the user for non-guest comments.
func (c *BlogComment) LoadUser(ctx context.Context) error {
if c.UserID == 0 || c.User != nil {
return nil
}
u, err := user_model.GetUserByID(ctx, c.UserID)
if err != nil {
return err
}
c.User = u
return nil
}
// CreateBlogComment inserts a new blog comment.
func CreateBlogComment(ctx context.Context, c *BlogComment) error {
_, err := db.GetEngine(ctx).Insert(c)
return err
}
// GetBlogCommentByID returns a single comment by ID.
func GetBlogCommentByID(ctx context.Context, id int64) (*BlogComment, error) {
c := &BlogComment{}
has, err := db.GetEngine(ctx).ID(id).Get(c)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return c, nil
}
// GetBlogCommentsByPostID returns all comments for a post, organized as top-level with nested replies.
func GetBlogCommentsByPostID(ctx context.Context, blogPostID int64) ([]*BlogComment, error) {
var all []*BlogComment
err := db.GetEngine(ctx).Where("blog_post_id = ?", blogPostID).
OrderBy("created_unix ASC").
Find(&all)
if err != nil {
return nil, err
}
// Load users for non-guest comments
for _, c := range all {
if err := c.LoadUser(ctx); err != nil {
return nil, err
}
}
// Build tree: top-level + replies
commentMap := make(map[int64]*BlogComment, len(all))
topLevel := make([]*BlogComment, 0)
for _, c := range all {
commentMap[c.ID] = c
}
for _, c := range all {
if c.ParentID == 0 {
topLevel = append(topLevel, c)
} else if parent, ok := commentMap[c.ParentID]; ok {
parent.Replies = append(parent.Replies, c)
} else {
// Orphaned reply — show as top-level
topLevel = append(topLevel, c)
}
}
return topLevel, nil
}
// CountBlogComments returns the total count of comments for a blog post.
func CountBlogComments(ctx context.Context, blogPostID int64) (int64, error) {
return db.GetEngine(ctx).Where("blog_post_id = ?", blogPostID).Count(new(BlogComment))
}
// DeleteBlogComment deletes a comment and all its replies.
func DeleteBlogComment(ctx context.Context, commentID int64) error {
// Delete replies first
if _, err := db.GetEngine(ctx).Where("parent_id = ?", commentID).Delete(new(BlogComment)); err != nil {
return err
}
_, err := db.GetEngine(ctx).ID(commentID).Delete(new(BlogComment))
return err
}
// DeleteBlogCommentsByPostID removes all comments for a blog post.
func DeleteBlogCommentsByPostID(ctx context.Context, blogPostID int64) error {
_, err := db.GetEngine(ctx).Where("blog_post_id = ?", blogPostID).Delete(new(BlogComment))
return err
}

View File

@@ -0,0 +1,116 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package blog
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"math/big"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
// BlogGuestToken represents an email-verified anonymous commenter session.
type BlogGuestToken struct { //revive:disable-line:exported
ID int64 `xorm:"pk autoincr"`
Email string `xorm:"VARCHAR(255) NOT NULL"`
Name string `xorm:"VARCHAR(100) NOT NULL"`
Token string `xorm:"VARCHAR(64) UNIQUE NOT NULL"`
Code string `xorm:"VARCHAR(6) NOT NULL"`
Verified bool `xorm:"NOT NULL DEFAULT false"`
IP string `xorm:"VARCHAR(45)"`
ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
func init() {
db.RegisterModel(new(BlogGuestToken))
}
// GenerateToken creates a random hex token.
func GenerateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// GenerateVerificationCode creates a random 6-digit code.
func GenerateVerificationCode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
// CreateGuestToken inserts a new guest token.
func CreateGuestToken(ctx context.Context, t *BlogGuestToken) error {
_, err := db.GetEngine(ctx).Insert(t)
return err
}
// GetGuestTokenByToken returns a guest token by its token string.
func GetGuestTokenByToken(ctx context.Context, token string) (*BlogGuestToken, error) {
t := &BlogGuestToken{}
has, err := db.GetEngine(ctx).Where("token = ?", token).Get(t)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return t, nil
}
// GetGuestTokenByEmail returns an existing non-expired token for the given email.
func GetGuestTokenByEmail(ctx context.Context, email string) (*BlogGuestToken, error) {
t := &BlogGuestToken{}
has, err := db.GetEngine(ctx).
Where("email = ? AND expires_unix > ?", email, timeutil.TimeStampNow()).
OrderBy("created_unix DESC").
Get(t)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return t, nil
}
// VerifyGuestToken marks a token as verified if the code matches.
func VerifyGuestToken(ctx context.Context, token, code string) (*BlogGuestToken, error) {
t, err := GetGuestTokenByToken(ctx, token)
if err != nil {
return nil, err
}
if t == nil {
return nil, nil
}
if t.Code != code {
return nil, nil
}
if timeutil.TimeStampNow() > t.ExpiresUnix {
return nil, nil
}
t.Verified = true
if _, err := db.GetEngine(ctx).ID(t.ID).Cols("verified").Update(t); err != nil {
return nil, err
}
return t, nil
}
// CleanupExpiredGuestTokens removes expired guest tokens.
func CleanupExpiredGuestTokens(ctx context.Context) error {
_, err := db.GetEngine(ctx).Where("expires_unix < ?", timeutil.TimeStampNow()).Delete(new(BlogGuestToken))
return err
}

View File

@@ -48,6 +48,7 @@ type BlogPost struct { //revive:disable-line:exported
Tags string `xorm:"TEXT"`
FeaturedImageID int64 `xorm:"DEFAULT 0"`
Status BlogPostStatus `xorm:"SMALLINT NOT NULL DEFAULT 0"`
AllowComments bool `xorm:"NOT NULL DEFAULT true"`
PublishedUnix timeutil.TimeStamp `xorm:"INDEX"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
@@ -206,3 +207,32 @@ func DeleteBlogPost(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Delete(new(BlogPost))
return err
}
// CountPublishedBlogPostsByAuthorID returns the number of published/public blog posts by a user.
func CountPublishedBlogPostsByAuthorID(ctx context.Context, authorID int64) (int64, error) {
return db.GetEngine(ctx).Where("author_id = ? AND status >= ?", authorID, BlogPostPublic).Count(new(BlogPost))
}
// GetPublishedBlogPostsByAuthorID returns published/public blog posts by a user, ordered by published date descending.
func GetPublishedBlogPostsByAuthorID(ctx context.Context, authorID int64, page, pageSize int) ([]*BlogPost, int64, error) {
if pageSize <= 0 {
pageSize = 20
}
if page <= 0 {
page = 1
}
cond := "author_id = ? AND status >= ?"
count, err := db.GetEngine(ctx).Where(cond, authorID, BlogPostPublic).Count(new(BlogPost))
if err != nil {
return nil, 0, err
}
posts := make([]*BlogPost, 0, pageSize)
err = db.GetEngine(ctx).Where(cond, authorID, BlogPostPublic).
OrderBy("published_unix DESC").
Limit(pageSize, (page-1)*pageSize).
Find(&posts)
return posts, count, err
}

View File

@@ -0,0 +1,101 @@
// 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"
)
// BlogReaction represents a thumbs up/down reaction on a blog post.
type BlogReaction struct { //revive:disable-line:exported
ID int64 `xorm:"pk autoincr"`
BlogPostID 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(BlogReaction))
}
// BlogReactionCounts holds aggregated reaction counts for a blog post.
type BlogReactionCounts struct { //revive:disable-line:exported
Likes int64
Dislikes int64
}
// ToggleBlogReaction creates, updates, or removes a reaction.
// If the user already reacted with the same type, it removes the reaction (toggle off).
// If the user reacted with a different type, it switches.
// If no reaction exists, it creates one.
func ToggleBlogReaction(ctx context.Context, blogPostID, userID int64, guestIP string, isLike bool) (reacted bool, err error) {
existing, err := GetUserBlogReaction(ctx, blogPostID, 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(BlogReaction))
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(&BlogReaction{
BlogPostID: blogPostID,
UserID: userID,
GuestIP: guestIP,
IsLike: isLike,
})
return true, err
}
// GetBlogReactionCounts returns aggregated like/dislike counts for a blog post.
func GetBlogReactionCounts(ctx context.Context, blogPostID int64) (*BlogReactionCounts, error) {
likes, err := db.GetEngine(ctx).Where("blog_post_id = ? AND is_like = ?", blogPostID, true).Count(new(BlogReaction))
if err != nil {
return nil, err
}
dislikes, err := db.GetEngine(ctx).Where("blog_post_id = ? AND is_like = ?", blogPostID, false).Count(new(BlogReaction))
if err != nil {
return nil, err
}
return &BlogReactionCounts{Likes: likes, Dislikes: dislikes}, nil
}
// GetUserBlogReaction returns the existing reaction for a user/guest on a blog post, or nil.
func GetUserBlogReaction(ctx context.Context, blogPostID, userID int64, guestIP string) (*BlogReaction, error) {
r := &BlogReaction{}
var has bool
var err error
if userID > 0 {
has, err = db.GetEngine(ctx).Where("blog_post_id = ? AND user_id = ?", blogPostID, userID).Get(r)
} else {
has, err = db.GetEngine(ctx).Where("blog_post_id = ? AND user_id = 0 AND guest_ip = ?", blogPostID, guestIP).Get(r)
}
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return r, nil
}
// DeleteBlogReactionsByPostID removes all reactions for a blog post (used when deleting a post).
func DeleteBlogReactionsByPostID(ctx context.Context, blogPostID int64) error {
_, err := db.GetEngine(ctx).Where("blog_post_id = ?", blogPostID).Delete(new(BlogReaction))
return err
}

View File

@@ -426,6 +426,10 @@ func prepareMigrationTasks() []*migration {
newMigration(349, "Create blog_post table", v1_26.CreateBlogPostTable),
newMigration(350, "Create blog_subscription table", v1_26.CreateBlogSubscriptionTable),
newMigration(351, "Add blog_enabled to repository", v1_26.AddBlogEnabledToRepository),
newMigration(352, "Create blog_reaction table", v1_26.CreateBlogReactionTable),
newMigration(353, "Create blog_comment table", v1_26.CreateBlogCommentTable),
newMigration(354, "Add allow_comments to blog_post", v1_26.AddAllowCommentsToBlogPost),
newMigration(355, "Create blog_guest_token table", v1_26.CreateBlogGuestTokenTable),
}
return preparedMigrations
}

View File

@@ -0,0 +1,24 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
// CreateBlogReactionTable adds the blog_reaction table for thumbs up/down on blog posts.
func CreateBlogReactionTable(x *xorm.Engine) error {
type BlogReaction struct {
ID int64 `xorm:"pk autoincr"`
BlogPostID 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"`
}
return x.Sync(new(BlogReaction))
}

View File

@@ -0,0 +1,27 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
// CreateBlogCommentTable adds the blog_comment table for comments and replies on blog posts.
func CreateBlogCommentTable(x *xorm.Engine) error {
type BlogComment struct {
ID int64 `xorm:"pk autoincr"`
BlogPostID int64 `xorm:"INDEX NOT NULL"`
ParentID int64 `xorm:"INDEX NOT NULL DEFAULT 0"`
UserID int64 `xorm:"INDEX NOT NULL DEFAULT 0"`
GuestName string `xorm:"VARCHAR(100)"`
GuestEmail string `xorm:"VARCHAR(255)"`
Content string `xorm:"LONGTEXT NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}
return x.Sync(new(BlogComment))
}

View File

@@ -0,0 +1,15 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import "xorm.io/xorm"
// AddAllowCommentsToBlogPost adds a flag to control commenting per blog post.
func AddAllowCommentsToBlogPost(x *xorm.Engine) error {
type BlogPost struct {
AllowComments bool `xorm:"NOT NULL DEFAULT true"`
}
return x.Sync(new(BlogPost))
}

View File

@@ -0,0 +1,27 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"code.gitcaddy.com/server/v3/modules/timeutil"
"xorm.io/xorm"
)
// CreateBlogGuestTokenTable adds the blog_guest_token table for anonymous commenter email verification.
func CreateBlogGuestTokenTable(x *xorm.Engine) error {
type BlogGuestToken struct {
ID int64 `xorm:"pk autoincr"`
Email string `xorm:"VARCHAR(255) NOT NULL"`
Name string `xorm:"VARCHAR(100) NOT NULL"`
Token string `xorm:"VARCHAR(64) UNIQUE NOT NULL"`
Code string `xorm:"VARCHAR(6) NOT NULL"`
Verified bool `xorm:"NOT NULL DEFAULT false"`
IP string `xorm:"VARCHAR(45)"`
ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
return x.Sync(new(BlogGuestToken))
}

View File

@@ -495,6 +495,10 @@
"mail.blog.published_subject": "New blog post: \"%[1]s\" in %[2]s",
"mail.blog.published_body": "<b>@%[1]s</b> published a new blog post %[2]s in %[3]s",
"mail.blog.read_more": "Read More",
"mail.blog_comment.verify_subject": "Verify your comment on \"%s\"",
"mail.blog_comment.verify_greeting": "Hi %s,",
"mail.blog_comment.verify_body": "You requested to comment on the blog post \"%s\". Please use the following code to verify your email address:",
"mail.blog_comment.verify_expires": "This code expires in 24 hours.",
"mail.repo.transfer.subject_to": "%s would like to transfer \"%s\" to %s",
"mail.repo.transfer.subject_to_you": "%s would like to transfer \"%s\" to you",
"mail.repo.transfer.to_you": "you",
@@ -617,6 +621,7 @@
"user.code": "Code",
"user.projects": "Projects",
"user.overview": "Overview",
"user.blogs": "Blogs",
"user.following": "Following",
"user.follow": "Follow",
"user.unfollow": "Unfollow",
@@ -2006,6 +2011,30 @@
"repo.blog.editor_image_hint": "Drag & drop or paste images directly into the editor. Use > for pull quotes.",
"repo.blog.content_placeholder": "Write your blog post content here...",
"repo.blog.tags_help": "Separate tags with commas.",
"repo.blog.share_link": "Share Link",
"repo.blog.link_copied": "Link Copied!",
"repo.blog.allow_comments": "Allow Comments",
"repo.blog.allow_comments_help": "When enabled, visitors can leave comments on this post.",
"repo.blog.reactions.admin_hint": "Thumbs down counts are only visible to repo admins.",
"repo.blog.comments": "Comments",
"repo.blog.comments.empty": "No comments yet. Be the first to share your thoughts!",
"repo.blog.comment.posted": "Comment posted successfully.",
"repo.blog.comment.deleted": "Comment deleted.",
"repo.blog.comment.delete_confirm": "Are you sure you want to delete this comment?",
"repo.blog.comment.reply": "Reply",
"repo.blog.comment.reply_placeholder": "Write a reply...",
"repo.blog.comment.delete": "Delete",
"repo.blog.comment.guest": "Guest",
"repo.blog.comment.leave": "Leave a Comment",
"repo.blog.comment.placeholder": "Write your comment...",
"repo.blog.comment.submit": "Post Comment",
"repo.blog.comment.guest_commenting_as": "Commenting as %s",
"repo.blog.comment.guest_intro": "Enter your name and email to verify before commenting.",
"repo.blog.comment.guest_name": "Your name",
"repo.blog.comment.guest_email": "Your email address",
"repo.blog.comment.guest_send_code": "Send verification code",
"repo.blog.comment.guest_enter_code": "Enter the 6-digit code sent to your email.",
"repo.blog.comment.guest_verify": "Verify",
"repo.settings.blog": "Blog",
"repo.settings.blog.enable": "Enable Blog",
"repo.settings.blog.enable_desc": "Allow blog posts to be created and published from this repository.",

View File

@@ -189,10 +189,55 @@ func BlogView(ctx *context.Context) {
ctx.Data["BlogTags"] = strings.Split(post.Tags, ",")
}
// Load reaction counts and user's current reaction
reactionCounts, err := blog_model.GetBlogReactionCounts(ctx, post.ID)
if err != nil {
ctx.ServerError("GetBlogReactionCounts", err)
return
}
ctx.Data["ReactionCounts"] = reactionCounts
// Determine user's current reaction
var userID int64
guestIP := ctx.Req.RemoteAddr
if ctx.Doer != nil {
userID = ctx.Doer.ID
guestIP = ""
}
userReaction, _ := blog_model.GetUserBlogReaction(ctx, post.ID, userID, guestIP)
ctx.Data["UserReaction"] = userReaction
// Load comments if allowed
if post.AllowComments {
comments, err := blog_model.GetBlogCommentsByPostID(ctx, post.ID)
if err != nil {
ctx.ServerError("GetBlogCommentsByPostID", err)
return
}
ctx.Data["BlogComments"] = comments
commentCount, _ := blog_model.CountBlogComments(ctx, post.ID)
ctx.Data["CommentCount"] = commentCount
}
// Check guest token cookie for anonymous commenters
if ctx.Doer == nil {
if tokenStr, err := ctx.Req.Cookie("blog_guest_token"); err == nil {
guestToken, _ := blog_model.GetGuestTokenByToken(ctx, tokenStr.Value)
if guestToken != nil && guestToken.Verified {
ctx.Data["GuestToken"] = guestToken
}
}
}
ctx.Data["Title"] = post.Title
ctx.Data["PageIsRepoBlog"] = true
ctx.Data["BlogPost"] = post
ctx.Data["IsWriter"] = isWriter
ctx.Data["IsSigned"] = ctx.Doer != nil
if ctx.Doer != nil {
ctx.Data["SignedUserID"] = ctx.Doer.ID
}
ctx.HTML(http.StatusOK, tplBlogView)
}
@@ -222,13 +267,14 @@ func BlogNewPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.BlogPostForm)
post := &blog_model.BlogPost{
RepoID: ctx.Repo.Repository.ID,
AuthorID: ctx.Doer.ID,
Title: form.Title,
Subtitle: form.Subtitle,
Content: form.Content,
Tags: strings.TrimSpace(form.Tags),
Status: blog_model.BlogPostStatus(form.Status),
RepoID: ctx.Repo.Repository.ID,
AuthorID: ctx.Doer.ID,
Title: form.Title,
Subtitle: form.Subtitle,
Content: form.Content,
Tags: strings.TrimSpace(form.Tags),
Status: blog_model.BlogPostStatus(form.Status),
AllowComments: form.AllowComments,
}
// Link featured image if provided
@@ -316,6 +362,7 @@ func BlogEditPost(ctx *context.Context) {
post.Content = form.Content
post.Tags = strings.TrimSpace(form.Tags)
post.Status = blog_model.BlogPostStatus(form.Status)
post.AllowComments = form.AllowComments
// Link featured image if provided
if form.FeaturedImage != "" {
@@ -597,6 +644,267 @@ func BlogUnsplashSelect(ctx *context.Context) {
})
}
// BlogReact handles thumbs up/down reactions on blog posts.
func BlogReact(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
}
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.ToggleBlogReaction(ctx, post.ID, userID, guestIP, isLike)
if err != nil {
ctx.ServerError("ToggleBlogReaction", err)
return
}
counts, err := blog_model.GetBlogReactionCounts(ctx, post.ID)
if err != nil {
ctx.ServerError("GetBlogReactionCounts", 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) {
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
}
if !post.AllowComments {
ctx.JSON(http.StatusForbidden, map[string]string{"error": "comments are disabled"})
return
}
content := strings.TrimSpace(ctx.FormString("content"))
if content == "" {
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "content is required"})
return
}
parentID := ctx.FormInt64("parent_id")
comment := &blog_model.BlogComment{
BlogPostID: post.ID,
ParentID: parentID,
Content: content,
}
if ctx.Doer != nil {
comment.UserID = ctx.Doer.ID
} else {
// Anonymous: verify guest token from cookie
tokenCookie, cookieErr := ctx.Req.Cookie("blog_guest_token")
if cookieErr != nil {
ctx.JSON(http.StatusUnauthorized, map[string]string{"error": "guest verification required"})
return
}
guestToken, _ := blog_model.GetGuestTokenByToken(ctx, tokenCookie.Value)
if guestToken == nil || !guestToken.Verified {
ctx.JSON(http.StatusUnauthorized, map[string]string{"error": "guest verification required"})
return
}
comment.GuestName = guestToken.Name
comment.GuestEmail = guestToken.Email
}
if err := blog_model.CreateBlogComment(ctx, comment); err != nil {
ctx.ServerError("CreateBlogComment", err)
return
}
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))
}
// BlogCommentDelete deletes a comment (writer or own comment).
func BlogCommentDelete(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
}
// Permission check: writer can delete any, user can delete own
isWriter := ctx.Repo.CanWrite(unit.TypeCode)
canDelete := isWriter
if !canDelete && ctx.Doer != nil && comment.UserID > 0 && comment.UserID == ctx.Doer.ID {
canDelete = true
}
if !canDelete {
ctx.JSON(http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}
if err := blog_model.DeleteBlogComment(ctx, commentID); err != nil {
ctx.ServerError("DeleteBlogComment", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.blog.comment.deleted"))
ctx.Redirect(fmt.Sprintf("%s/blog/%d", ctx.Repo.RepoLink, post.ID))
}
// BlogGuestVerifyRequest handles a guest providing name + email for verification.
func BlogGuestVerifyRequest(ctx *context.Context) {
if !blogEnabled(ctx) {
ctx.NotFound(nil)
return
}
email := strings.TrimSpace(ctx.FormString("email"))
name := strings.TrimSpace(ctx.FormString("name"))
if email == "" || name == "" {
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "name and email are required"})
return
}
// Check for existing valid token
existing, _ := blog_model.GetGuestTokenByEmail(ctx, email)
if existing != nil && existing.Verified {
// Already verified, return the token
ctx.JSON(http.StatusOK, map[string]any{
"token": existing.Token,
"verified": true,
})
return
}
token, err := blog_model.GenerateToken()
if err != nil {
ctx.ServerError("GenerateToken", err)
return
}
code, err := blog_model.GenerateVerificationCode()
if err != nil {
ctx.ServerError("GenerateVerificationCode", err)
return
}
guestToken := &blog_model.BlogGuestToken{
Email: email,
Name: name,
Token: token,
Code: code,
IP: ctx.Req.RemoteAddr,
ExpiresUnix: timeutil.TimeStampNow().Add(24 * 60 * 60), // 24 hours
}
if err := blog_model.CreateGuestToken(ctx, guestToken); err != nil {
ctx.ServerError("CreateGuestToken", err)
return
}
// Get blog post title for the email
postTitle := "Blog Post"
if post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")); err == nil {
postTitle = post.Title
}
// Send verification email
if err := mailer.SendBlogCommentVerification(email, name, code, postTitle); err != nil {
log.Error("SendBlogCommentVerification: %v", err)
}
ctx.JSON(http.StatusOK, map[string]any{
"token": token,
"verified": false,
})
}
// BlogGuestVerifyCode handles the code verification step.
func BlogGuestVerifyCode(ctx *context.Context) {
if !blogEnabled(ctx) {
ctx.NotFound(nil)
return
}
token := ctx.FormString("token")
code := ctx.FormString("code")
if token == "" || code == "" {
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "token and code are required"})
return
}
verified, err := blog_model.VerifyGuestToken(ctx, token, code)
if err != nil {
ctx.ServerError("VerifyGuestToken", err)
return
}
if verified == nil {
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid or expired code"})
return
}
// Set cookie
http.SetCookie(ctx.Resp, &http.Cookie{
Name: "blog_guest_token",
Value: token,
Path: "/",
MaxAge: 24 * 60 * 60,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
ctx.JSON(http.StatusOK, map[string]any{
"verified": true,
"name": verified.Name,
})
}
// notifyBlogPublished sends notifications to repo watchers and blog subscribers.
func notifyBlogPublished(ctx *context.Context, post *blog_model.BlogPost) {
if err := post.LoadRepo(ctx); err != nil {

View File

@@ -6,6 +6,7 @@ package user
import (
"net/url"
blog_model "code.gitcaddy.com/server/v3/models/blog"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/models/organization"
access_model "code.gitcaddy.com/server/v3/models/perm/access"
@@ -192,5 +193,13 @@ func loadHeaderCount(ctx *context.Context) error {
}
ctx.Data["ProjectCount"] = projectCount
if !ctx.ContextUser.IsOrganization() {
blogCount, err := blog_model.CountPublishedBlogPostsByAuthorID(ctx, ctx.ContextUser.ID)
if err != nil {
return err
}
ctx.Data["BlogCount"] = blogCount
}
return nil
}

View File

@@ -11,6 +11,7 @@ import (
"strings"
activities_model "code.gitcaddy.com/server/v3/models/activities"
blog_model "code.gitcaddy.com/server/v3/models/blog"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/models/organization"
"code.gitcaddy.com/server/v3/models/renderhelper"
@@ -289,6 +290,18 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
}
}
}
case "blogs":
blogPosts, blogCount, err := blog_model.GetPublishedBlogPostsByAuthorID(ctx, ctx.ContextUser.ID, page, pagingNum)
if err != nil {
ctx.ServerError("GetPublishedBlogPostsByAuthorID", err)
return
}
for _, post := range blogPosts {
_ = post.LoadRepo(ctx)
_ = post.LoadFeaturedImage(ctx)
}
ctx.Data["BlogPosts"] = blogPosts
total = int(blogCount)
case "organizations":
orgs, count, err := db.FindAndCount[organization.Organization](ctx, organization.FindOrgOptions{
UserID: ctx.ContextUser.ID,

View File

@@ -1714,6 +1714,11 @@ func registerWebRoutes(m *web.Router) {
m.Group("/{username}/{reponame}/blog", func() {
m.Get("", repo.BlogList)
m.Get("/{id}", repo.BlogView)
m.Post("/{id}/react", repo.BlogReact)
m.Post("/{id}/comment", repo.BlogCommentPost)
m.Post("/{id}/comment/{commentID}/delete", repo.BlogCommentDelete)
m.Post("/{id}/guest/verify", repo.BlogGuestVerifyRequest)
m.Post("/{id}/guest/confirm", repo.BlogGuestVerifyCode)
m.Group("", func() {
m.Get("/new", repo.BlogNew)
m.Post("/new", web.Bind(forms.BlogPostForm{}), repo.BlogNewPost)

View File

@@ -20,6 +20,7 @@ type BlogPostForm struct {
Tags string `binding:"MaxSize(1000)"`
FeaturedImage string // attachment UUID
Status int `binding:"Range(0,2)"` // 0=draft, 1=public, 2=published
AllowComments bool
}
// Validate validates the fields

View File

@@ -0,0 +1,48 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package mailer
import (
"bytes"
"fmt"
"code.gitcaddy.com/server/v3/modules/log"
"code.gitcaddy.com/server/v3/modules/setting"
"code.gitcaddy.com/server/v3/modules/templates"
"code.gitcaddy.com/server/v3/modules/translation"
sender_service "code.gitcaddy.com/server/v3/services/mailer/sender"
)
const tplBlogCommentVerifyMail templates.TplName = "repo/blog_comment_verify"
// SendBlogCommentVerification sends a 6-digit verification code to a guest commenter.
func SendBlogCommentVerification(email, name, code, blogPostTitle string) error {
if setting.MailService == nil {
return nil
}
locale := translation.NewLocale("en-US")
subject := locale.TrString("mail.blog_comment.verify_subject", blogPostTitle)
mailMeta := map[string]any{
"locale": locale,
"Subject": subject,
"Name": name,
"Code": code,
"PostTitle": blogPostTitle,
"Language": locale.Language(),
}
var mailBody bytes.Buffer
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplBlogCommentVerifyMail), mailMeta); err != nil {
log.Error("ExecuteTemplate [%s]: %v", string(tplBlogCommentVerifyMail)+"/body", err)
return fmt.Errorf("execute template: %w", err)
}
msg := sender_service.NewMessage(email, subject, mailBody.String())
msg.Info = subject
SendAsync(msg)
return nil
}

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>{{.Subject}}</title>
</head>
<body>
<p>{{.locale.Tr "mail.blog_comment.verify_greeting" .Name}}</p>
<p>{{.locale.Tr "mail.blog_comment.verify_body" .PostTitle}}</p>
<div style="text-align: center; margin: 24px 0;">
<span style="display: inline-block; padding: 12px 32px; background-color: #4183c4; color: #ffffff; font-size: 24px; letter-spacing: 6px; font-weight: bold; border-radius: 6px;">
{{.Code}}
</span>
</div>
<p style="color: #666; font-size: small;">{{.locale.Tr "mail.blog_comment.verify_expires"}}</p>
</body>
</html>

View File

@@ -123,6 +123,18 @@
<div class="ui divider"></div>
<!-- Allow Comments -->
<div class="field">
<div class="ui checkbox">
<input type="checkbox" name="allow_comments"
{{if .IsNewPost}}checked{{else if .BlogPost.AllowComments}}checked{{end}}>
<label><b>{{ctx.Locale.Tr "repo.blog.allow_comments"}}</b></label>
</div>
<div class="help">{{ctx.Locale.Tr "repo.blog.allow_comments_help"}}</div>
</div>
<div class="ui divider"></div>
<!-- Status indicator (edit only) -->
{{if and .BlogPost (not .IsNewPost)}}
<div class="field">

View File

@@ -41,6 +41,13 @@
{{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">
@@ -61,6 +68,164 @@
</form>
</div>
{{end}}
<!-- Reaction Bar -->
<div class="blog-reactions" id="blog-reactions">
<button class="blog-reaction-btn{{if and .UserReaction .UserReaction.IsLike}} active{{end}}" id="btn-like"
data-url="{{.RepoLink}}/blog/{{.BlogPost.ID}}/react" data-type="like">
{{svg "octicon-thumbsup" 18}}
<span id="like-count">{{.ReactionCounts.Likes}}</span>
</button>
<button class="blog-reaction-btn{{if and .UserReaction (not .UserReaction.IsLike)}} active{{end}}" id="btn-dislike"
data-url="{{.RepoLink}}/blog/{{.BlogPost.ID}}/react" data-type="dislike"
{{if not .IsWriter}}style="display:none;"{{end}}>
{{svg "octicon-thumbsdown" 18}}
<span id="dislike-count">{{.ReactionCounts.Dislikes}}</span>
</button>
{{if .IsWriter}}
<span class="blog-reaction-hint">{{ctx.Locale.Tr "repo.blog.reactions.admin_hint"}}</span>
{{end}}
</div>
<!-- Comments Section -->
{{if .BlogPost.AllowComments}}
<div class="blog-comments" id="blog-comments">
<h3 class="blog-comments-header">
{{svg "octicon-comment" 20}}
{{ctx.Locale.Tr "repo.blog.comments"}}
{{if .CommentCount}}<span class="ui small label">{{.CommentCount}}</span>{{end}}
</h3>
{{if .BlogComments}}
<div class="blog-comments-list">
{{range .BlogComments}}
<div class="blog-comment" id="comment-{{.ID}}">
<div class="blog-comment-header">
{{if and (not .IsGuest) .User}}
<img class="blog-comment-avatar" src="{{.User.AvatarLink ctx}}" alt="{{.User.Name}}">
<a href="{{.User.HomeLink}}" class="blog-comment-author">{{.User.DisplayName}}</a>
{{else}}
<div class="blog-comment-avatar blog-comment-avatar-guest">{{svg "octicon-person" 16}}</div>
<span class="blog-comment-author">{{.DisplayName}}</span>
<span class="ui mini label">{{ctx.Locale.Tr "repo.blog.comment.guest"}}</span>
{{end}}
<span class="blog-comment-time">{{DateUtils.TimeSince .CreatedUnix}}</span>
</div>
<div class="blog-comment-content">{{.Content}}</div>
<div class="blog-comment-actions">
<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}}
<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>
{{end}}
</div>
<!-- Replies -->
{{if .Replies}}
<div class="blog-comment-replies">
{{range .Replies}}
<div class="blog-comment blog-comment-reply" id="comment-{{.ID}}">
<div class="blog-comment-header">
{{if and (not .IsGuest) .User}}
<img class="blog-comment-avatar blog-comment-avatar-sm" src="{{.User.AvatarLink ctx}}" alt="{{.User.Name}}">
<a href="{{.User.HomeLink}}" class="blog-comment-author">{{.User.DisplayName}}</a>
{{else}}
<div class="blog-comment-avatar blog-comment-avatar-sm blog-comment-avatar-guest">{{svg "octicon-person" 12}}</div>
<span class="blog-comment-author">{{.DisplayName}}</span>
<span class="ui mini label">{{ctx.Locale.Tr "repo.blog.comment.guest"}}</span>
{{end}}
<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">
<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}}
</div>
{{end}}
</div>
{{end}}
<!-- Inline reply form (hidden by default) -->
<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="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">
<button class="ui small primary button" type="submit">{{ctx.Locale.Tr "repo.blog.comment.reply"}}</button>
<button class="ui small button blog-reply-cancel" type="button" data-comment-id="{{.ID}}">{{ctx.Locale.Tr "cancel"}}</button>
</div>
</form>
</div>
</div>
{{end}}
</div>
{{else}}
<p class="blog-comments-empty">{{ctx.Locale.Tr "repo.blog.comments.empty"}}</p>
{{end}}
<!-- New comment form -->
<div class="blog-comment-form-wrapper">
{{if .IsSigned}}
<h4>{{ctx.Locale.Tr "repo.blog.comment.leave"}}</h4>
<form method="post" action="{{.RepoLink}}/blog/{{.BlogPost.ID}}/comment">
{{.CsrfTokenHtml}}
<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">
<button class="ui small primary button" type="submit">{{ctx.Locale.Tr "repo.blog.comment.submit"}}</button>
</div>
</form>
{{else}}
<h4>{{ctx.Locale.Tr "repo.blog.comment.leave"}}</h4>
{{if .GuestToken}}
<!-- Guest is verified, show comment form -->
<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="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">
<button class="ui small primary button" type="submit">{{ctx.Locale.Tr "repo.blog.comment.submit"}}</button>
</div>
</form>
{{else}}
<!-- Guest verification flow -->
<div id="guest-verify-step1">
<p class="tw-text-sm tw-mb-2">{{ctx.Locale.Tr "repo.blog.comment.guest_intro"}}</p>
<div class="tw-flex tw-flex-col tw-gap-2" style="max-width:400px;">
<input type="text" id="guest-name" placeholder="{{ctx.Locale.Tr "repo.blog.comment.guest_name"}}" maxlength="100" required>
<input type="email" id="guest-email" placeholder="{{ctx.Locale.Tr "repo.blog.comment.guest_email"}}" maxlength="255" required>
<button class="ui small primary button" type="button" id="guest-verify-btn">
{{svg "octicon-mail" 14}} {{ctx.Locale.Tr "repo.blog.comment.guest_send_code"}}
</button>
</div>
</div>
<div id="guest-verify-step2" class="tw-hidden">
<p class="tw-text-sm tw-mb-2">{{ctx.Locale.Tr "repo.blog.comment.guest_enter_code"}}</p>
<div class="tw-flex tw-gap-2" style="max-width:300px;">
<input type="text" id="guest-code" placeholder="000000" maxlength="6" style="letter-spacing:4px;text-align:center;font-size:18px;" required>
<button class="ui small primary button" type="button" id="guest-confirm-btn">{{ctx.Locale.Tr "repo.blog.comment.guest_verify"}}</button>
</div>
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
</div>
</div>
</div>
@@ -130,6 +295,16 @@
gap: 6px;
margin-top: 12px;
}
.blog-share {
margin-top: 12px;
}
.blog-share-btn {
cursor: pointer;
}
.blog-share-copied {
color: var(--color-success) !important;
border-color: var(--color-success) !important;
}
.blog-view-body {
font-size: 16px;
line-height: 1.7;
@@ -182,5 +357,313 @@
border-top: 1px solid var(--color-secondary-alpha-40);
margin-bottom: 24px;
}
/* Reactions */
.blog-reactions {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
border-top: 1px solid var(--color-secondary-alpha-40);
border-bottom: 1px solid var(--color-secondary-alpha-40);
margin-bottom: 32px;
}
.blog-reaction-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1px solid var(--color-secondary-alpha-40);
border-radius: 20px;
background: transparent;
color: var(--color-text-light);
cursor: pointer;
font-size: 14px;
transition: all 0.15s;
}
.blog-reaction-btn:hover {
background: var(--color-secondary-alpha-20);
color: var(--color-text);
}
.blog-reaction-btn.active {
background: var(--color-primary-alpha-20);
border-color: var(--color-primary);
color: var(--color-primary);
}
.blog-reaction-hint {
font-size: 12px;
color: var(--color-text-light-3);
}
/* Comments */
.blog-comments {
margin-bottom: 32px;
}
.blog-comments-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
font-size: 20px;
}
.blog-comments-list {
display: flex;
flex-direction: column;
gap: 0;
}
.blog-comment {
padding: 16px 0;
border-bottom: 1px solid var(--color-secondary-alpha-20);
}
.blog-comment-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 14px;
}
.blog-comment-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
}
.blog-comment-avatar-sm {
width: 24px;
height: 24px;
}
.blog-comment-avatar-guest {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-secondary-alpha-40);
color: var(--color-text-light);
}
.blog-comment-author {
font-weight: 600;
color: var(--color-text);
text-decoration: none;
}
.blog-comment-author:hover {
text-decoration: underline;
}
.blog-comment-time {
color: var(--color-text-light-3);
font-size: 12px;
}
.blog-comment-content {
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.blog-comment-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.blog-reply-btn, .blog-delete-btn {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
color: var(--color-text-light);
cursor: pointer;
font-size: 12px;
padding: 2px 0;
}
.blog-reply-btn:hover {
color: var(--color-primary);
}
.blog-delete-btn:hover {
color: var(--color-red);
}
.blog-comment-replies {
margin-left: 40px;
border-left: 2px solid var(--color-secondary-alpha-30);
padding-left: 16px;
}
.blog-comment-reply {
padding: 12px 0;
}
.blog-reply-form {
margin-top: 12px;
margin-left: 40px;
}
.blog-comment-textarea {
width: 100%;
padding: 10px;
border: 1px solid var(--color-secondary-alpha-40);
border-radius: 6px;
background: var(--color-input-background);
color: var(--color-input-text);
font-family: inherit;
font-size: 14px;
resize: vertical;
}
.blog-comment-textarea:focus {
outline: none;
border-color: var(--color-primary);
}
.blog-comments-empty {
color: var(--color-text-light);
font-style: italic;
padding: 16px 0;
}
.blog-comment-form-wrapper {
padding-top: 20px;
border-top: 1px solid var(--color-secondary-alpha-40);
}
</style>
<script>
(function() {
const csrfToken = document.querySelector('meta[name=_csrf]')?.content || '';
// === Share/Copy link button ===
const shareBtn = document.getElementById('blog-share-btn');
if (shareBtn) {
shareBtn.addEventListener('click', function() {
const link = window.location.origin + this.dataset.link;
navigator.clipboard.writeText(link).then(() => {
const orig = this.dataset.tooltipContent;
this.dataset.tooltipContent = '{{ctx.Locale.Tr "repo.blog.link_copied"}}';
this.classList.add('blog-share-copied');
setTimeout(() => {
this.dataset.tooltipContent = orig;
this.classList.remove('blog-share-copied');
}, 2000);
});
});
}
// === Reaction buttons ===
document.querySelectorAll('.blog-reaction-btn').forEach(function(btn) {
btn.addEventListener('click', async function() {
const url = this.dataset.url;
const type = this.dataset.type;
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.getElementById('like-count').textContent = data.likes;
document.getElementById('dislike-count').textContent = data.dislikes;
// Update active states
const likeBtn = document.getElementById('btn-like');
const dislikeBtn = document.getElementById('btn-dislike');
likeBtn.classList.remove('active');
dislikeBtn.classList.remove('active');
if (data.reacted) {
if (data.type === 'like') likeBtn.classList.add('active');
else dislikeBtn.classList.add('active');
}
}
} catch (e) {
console.error('Reaction error:', e);
}
});
});
// === Reply toggle ===
document.querySelectorAll('.blog-reply-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const id = this.dataset.commentId;
const form = document.getElementById('reply-form-' + id);
if (form) {
form.classList.toggle('tw-hidden');
if (!form.classList.contains('tw-hidden')) {
form.querySelector('textarea')?.focus();
}
}
});
});
document.querySelectorAll('.blog-reply-cancel').forEach(function(btn) {
btn.addEventListener('click', function() {
const id = this.dataset.commentId;
const form = document.getElementById('reply-form-' + id);
if (form) form.classList.add('tw-hidden');
});
});
// === Guest verification ===
const verifyBtn = document.getElementById('guest-verify-btn');
const confirmBtn = document.getElementById('guest-confirm-btn');
let guestToken = '';
if (verifyBtn) {
verifyBtn.addEventListener('click', async function() {
const name = document.getElementById('guest-name').value.trim();
const email = document.getElementById('guest-email').value.trim();
if (!name || !email) return;
this.disabled = true;
this.textContent = '...';
try {
const fd = new FormData();
fd.append('name', name);
fd.append('email', email);
const postID = window.location.pathname.split('/blog/')[1]?.split('/')[0] || '';
const resp = await fetch(window.location.pathname.replace(/\/blog\/\d+.*/, '/blog/' + postID + '/guest/verify'), {
method: 'POST',
headers: {'X-Csrf-Token': csrfToken},
body: fd,
});
if (resp.ok) {
const data = await resp.json();
guestToken = data.token;
if (data.verified) {
// Already verified, reload
window.location.reload();
} else {
document.getElementById('guest-verify-step1').classList.add('tw-hidden');
document.getElementById('guest-verify-step2').classList.remove('tw-hidden');
}
}
} catch (e) {
console.error('Verify error:', e);
} finally {
this.disabled = false;
this.innerHTML = '{{svg "octicon-mail" 14}} {{ctx.Locale.Tr "repo.blog.comment.guest_send_code"}}';
}
});
}
if (confirmBtn) {
confirmBtn.addEventListener('click', async function() {
const code = document.getElementById('guest-code').value.trim();
if (!code || !guestToken) return;
this.disabled = true;
try {
const fd = new FormData();
fd.append('token', guestToken);
fd.append('code', code);
const postID = window.location.pathname.split('/blog/')[1]?.split('/')[0] || '';
const resp = await fetch(window.location.pathname.replace(/\/blog\/\d+.*/, '/blog/' + postID + '/guest/confirm'), {
method: 'POST',
headers: {'X-Csrf-Token': csrfToken},
body: fd,
});
if (resp.ok) {
const data = await resp.json();
if (data.verified) {
window.location.reload();
} else {
alert('Invalid code. Please try again.');
}
} else {
alert('Invalid or expired code.');
}
} catch (e) {
console.error('Confirm error:', e);
} finally {
this.disabled = false;
}
});
}
})();
</script>
{{template "base/footer" .}}

View File

@@ -11,6 +11,12 @@
<div class="ui small label">{{.RepoCount}}</div>
{{end}}
</a>
{{if and .ContextUser.IsIndividual (gt .BlogCount 0)}}
<a class="{{if eq .TabName "blogs"}}active {{end}}item" href="{{.ContextUser.HomeLink}}?tab=blogs">
{{svg "octicon-note"}} {{ctx.Locale.Tr "user.blogs"}}
<div class="ui small label">{{.BlogCount}}</div>
</a>
{{end}}
{{if or .ContextUser.IsIndividual .CanReadProjects}}
<a href="{{.ContextUser.HomeLink}}/-/projects" class="{{if .PageIsViewProjects}}active {{end}}item">
{{svg "octicon-project-symlink"}} {{ctx.Locale.Tr "user.projects"}}

View File

@@ -107,6 +107,45 @@
{{if .ProfileReadmeContent}}
<div id="readme_profile" class="render-content markup">{{.ProfileReadmeContent}}</div>
{{end}}
{{else if eq .TabName "blogs"}}
<div class="user-profile-blog-list">
{{range .BlogPosts}}
<div class="ui segment tw-flex tw-gap-4 tw-items-start">
{{if .FeaturedImage}}
<a href="{{.Repo.Link}}/blog/{{.ID}}" class="tw-shrink-0">
<img src="{{.FeaturedImage.DownloadURL}}" alt="{{.Title}}" loading="lazy" style="width: 120px; height: 80px; object-fit: cover; border-radius: 4px;">
</a>
{{end}}
<div class="tw-flex-1">
<a href="{{.Repo.Link}}/blog/{{.ID}}" class="tw-font-semibold tw-text-lg">{{.Title}}</a>
{{if .Subtitle}}
<p class="text grey tw-text-sm tw-mt-1">{{.Subtitle}}</p>
{{end}}
<div class="text grey tw-text-sm tw-mt-2 tw-flex tw-items-center tw-gap-2">
{{if .Repo}}
<a href="{{.Repo.Link}}" class="text grey">{{svg "octicon-repo" 14}} {{.Repo.FullName}}</a>
<span>&middot;</span>
{{end}}
{{if .PublishedUnix}}
<span>{{DateUtils.TimeSince .PublishedUnix}}</span>
{{else}}
<span>{{DateUtils.TimeSince .CreatedUnix}}</span>
{{end}}
</div>
{{if .Tags}}
<div class="tw-mt-2">
{{range (StringUtils.Split .Tags ",")}}
{{if .}}
<span class="ui small label">{{.}}</span>
{{end}}
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
{{template "base/paginate" .}}
</div>
{{else if eq .TabName "organizations"}}
{{template "repo/user_cards" .}}
{{else}}