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
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:
140
models/blog/blog_comment.go
Normal file
140
models/blog/blog_comment.go
Normal 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
|
||||
}
|
||||
116
models/blog/blog_guest_token.go
Normal file
116
models/blog/blog_guest_token.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
101
models/blog/blog_reaction.go
Normal file
101
models/blog/blog_reaction.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
24
models/migrations/v1_26/v352.go
Normal file
24
models/migrations/v1_26/v352.go
Normal 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))
|
||||
}
|
||||
27
models/migrations/v1_26/v353.go
Normal file
27
models/migrations/v1_26/v353.go
Normal 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))
|
||||
}
|
||||
15
models/migrations/v1_26/v354.go
Normal file
15
models/migrations/v1_26/v354.go
Normal 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))
|
||||
}
|
||||
27
models/migrations/v1_26/v355.go
Normal file
27
models/migrations/v1_26/v355.go
Normal 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))
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
48
services/mailer/mail_blog_comment.go
Normal file
48
services/mailer/mail_blog_comment.go
Normal 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
|
||||
}
|
||||
17
templates/mail/repo/blog_comment_verify.tmpl
Normal file
17
templates/mail/repo/blog_comment_verify.tmpl
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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" .}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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>·</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}}
|
||||
|
||||
Reference in New Issue
Block a user