2
0

feat(blog): add standalone blog view and top nav option
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m20s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m41s
Build and Release / Lint (push) Successful in 4m56s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m49s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h4m28s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 6m39s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 8m53s
Build and Release / Build Binary (linux/arm64) (push) Successful in 8m25s

Adds standalone blog post view at /blog/:id with full content rendering, comments, reactions, and OpenGraph metadata. Implements optional "Blogs" link in site header navigation (configurable in admin settings). Enforces repository access permissions on standalone view. Loads repo owners for avatar fallback on explore page. Includes SEO-friendly URLs and social media sharing support.
This commit is contained in:
2026-02-02 00:19:17 -05:00
parent c274086c46
commit ddf87daa42
11 changed files with 920 additions and 6 deletions

View File

@@ -68,6 +68,7 @@ type ThemeStruct struct {
PinnedOrgDisplayFormat *config.Value[string]
ExploreOrgDisplayFormat *config.Value[string]
EnableBlogs *config.Value[bool]
BlogsInTopNav *config.Value[bool]
}
type ConfigStruct struct {
@@ -107,6 +108,7 @@ func initDefaultConfig() {
PinnedOrgDisplayFormat: config.ValueJSON[string]("theme.pinned_org_display_format").WithDefault("condensed"),
ExploreOrgDisplayFormat: config.ValueJSON[string]("theme.explore_org_display_format").WithDefault("list"),
EnableBlogs: config.ValueJSON[bool]("theme.enable_blogs").WithDefault(false),
BlogsInTopNav: config.ValueJSON[bool]("theme.blogs_in_top_nav").WithDefault(false),
},
}
}

View File

@@ -4093,6 +4093,8 @@
"admin.config.enable_explore_packages_desc": "Show a Packages tab in the Explore menu to browse public and global packages",
"admin.config.enable_blogs": "Enable Blogs",
"admin.config.enable_blogs_desc": "Enable the Blogs feature across the platform. Repos can publish blog posts visible under Explore > Blogs.",
"admin.config.blogs_in_top_nav": "Blogs in Top Navigation",
"admin.config.blogs_in_top_nav_desc": "Show a Blogs link in the site header navigation bar next to Explore",
"admin.config.custom_home_title": "Homepage Title",
"admin.config.custom_home_title_placeholder": "Leave empty to use app name",
"admin.config.custom_home_title_help": "Custom title displayed on the homepage. Leave empty to use the default app name.",

View File

@@ -250,6 +250,7 @@ func ChangeConfig(ctx *context.Context) {
cfg.Theme.PinnedOrgDisplayFormat.DynKey(): marshalString("condensed"),
cfg.Theme.ExploreOrgDisplayFormat.DynKey(): marshalString("list"),
cfg.Theme.EnableBlogs.DynKey(): marshalBool,
cfg.Theme.BlogsInTopNav.DynKey(): marshalBool,
}
_ = ctx.Req.ParseForm()

View File

@@ -5,14 +5,25 @@ package explore
import (
"net/http"
"strings"
"time"
blog_model "code.gitcaddy.com/server/v3/models/blog"
access_model "code.gitcaddy.com/server/v3/models/perm/access"
"code.gitcaddy.com/server/v3/models/renderhelper"
"code.gitcaddy.com/server/v3/models/unit"
"code.gitcaddy.com/server/v3/modules/markup/markdown"
"code.gitcaddy.com/server/v3/modules/setting"
"code.gitcaddy.com/server/v3/modules/templates"
"code.gitcaddy.com/server/v3/services/context"
perm_model "code.gitcaddy.com/server/v3/models/perm"
)
const tplExploreBlogs templates.TplName = "explore/blogs"
const (
tplExploreBlogs templates.TplName = "explore/blogs"
tplStandaloneBlogView templates.TplName = "blog/standalone_view"
)
// Blogs renders the explore blogs page with published posts across all repos.
func Blogs(ctx *context.Context) {
@@ -59,10 +70,13 @@ func Blogs(ctx *context.Context) {
return
}
// Load authors, repos, and featured images
// Load authors, repos (with owners for avatar fallback), and featured images
for _, post := range posts {
_ = post.LoadAuthor(ctx)
_ = post.LoadRepo(ctx)
if post.Repo != nil {
_ = post.Repo.LoadOwner(ctx)
}
_ = post.LoadFeaturedImage(ctx)
}
@@ -96,3 +110,143 @@ func Blogs(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplExploreBlogs)
}
// StandaloneBlogView renders a blog post without the repo header/tabs.
func StandaloneBlogView(ctx *context.Context) {
if !setting.Config().Theme.EnableBlogs.Value(ctx) {
ctx.NotFound(nil)
return
}
post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
ctx.NotFound(err)
return
}
// Only public/published posts visible in standalone view
if post.Status < blog_model.BlogPostPublic {
ctx.NotFound(nil)
return
}
if err := post.LoadRepo(ctx); err != nil {
ctx.ServerError("LoadRepo", err)
return
}
// Check repo access for the current user
if post.Repo == nil {
ctx.NotFound(nil)
return
}
// Verify the user can read this repo
hasAccess, err := access_model.HasAccessUnit(ctx, ctx.Doer, post.Repo, unit.TypeCode, perm_model.AccessModeRead)
if err != nil {
ctx.ServerError("HasAccessUnit", err)
return
}
if !hasAccess {
// Also check if repo is public/limited
if post.Repo.IsPrivate {
ctx.NotFound(nil)
return
}
}
if err := post.LoadAuthor(ctx); err != nil {
ctx.ServerError("LoadAuthor", err)
return
}
if err := post.LoadFeaturedImage(ctx); err != nil {
ctx.ServerError("LoadFeaturedImage", err)
return
}
if err := post.Repo.LoadOwner(ctx); err != nil {
ctx.ServerError("LoadOwner", err)
return
}
// Render markdown content
rctx := renderhelper.NewRenderContextRepoComment(ctx, post.Repo)
rendered, err := markdown.RenderString(rctx, post.Content)
if err != nil {
ctx.ServerError("RenderString", err)
return
}
post.RenderedContent = string(rendered)
// Parse tags
if post.Tags != "" {
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
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
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
}
}
}
// Check if user is a writer on this repo
isWriter := false
if ctx.Doer != nil {
perm, permErr := access_model.GetUserRepoPermission(ctx, post.Repo, ctx.Doer)
if permErr == nil {
isWriter = perm.CanWrite(unit.TypeCode)
}
}
// RepoLink is needed for form actions (reactions, comments)
repoLink := post.Repo.Link()
ctx.Data["Title"] = post.Title
ctx.Data["BlogPost"] = post
ctx.Data["IsWriter"] = isWriter
ctx.Data["IsSigned"] = ctx.Doer != nil
ctx.Data["RepoLink"] = repoLink
if ctx.Doer != nil {
ctx.Data["SignedUserID"] = ctx.Doer.ID
}
// SEO: ISO 8601 published time for OpenGraph article:published_time
if post.PublishedUnix > 0 {
ctx.Data["BlogPublishedISO"] = post.PublishedUnix.AsTime().Format(time.RFC3339)
}
ctx.HTML(http.StatusOK, tplStandaloneBlogView)
}

View File

@@ -239,6 +239,11 @@ func BlogView(ctx *context.Context) {
ctx.Data["SignedUserID"] = ctx.Doer.ID
}
// SEO: ISO 8601 published time for OpenGraph article:published_time
if post.PublishedUnix > 0 {
ctx.Data["BlogPublishedISO"] = post.PublishedUnix.AsTime().Format(time.RFC3339)
}
ctx.HTML(http.StatusOK, tplBlogView)
}

View File

@@ -577,6 +577,9 @@ func registerWebRoutes(m *web.Router) {
m.Get("/topics/search", explore.TopicSearch)
}, optExploreSignIn, exploreAnonymousGuard)
// Standalone blog view (no repo header)
m.Get("/blog/{id}", optSignIn, explore.StandaloneBlogView)
m.Group("/issues", func() {
m.Get("", user.Issues)
m.Get("/search", repo.SearchIssues)

View File

@@ -38,6 +38,13 @@
</div>
</dd>
<div class="divider"></div>
<dt>{{ctx.Locale.Tr "admin.config.blogs_in_top_nav"}}</dt>
<dd>
<div class="ui toggle checkbox" data-tooltip-content="{{ctx.Locale.Tr "admin.config.blogs_in_top_nav_desc"}}">
<input type="checkbox" data-config-dyn-key="theme.blogs_in_top_nav" {{if .SystemConfig.Theme.BlogsInTopNav.Value ctx}}checked{{end}}><label></label>
</div>
</dd>
<div class="divider"></div>
<dt>{{ctx.Locale.Tr "admin.config.help_url"}}</dt>
<dd>
<form class="ui form form-fetch-action" method="post" action="{{AppSubUrl}}/-/admin/config">

View File

@@ -31,6 +31,9 @@
{{end}}
{{end}}
<a class="item{{if .PageIsExplore}} active{{end}}" href="{{AppSubUrl}}/explore/repos">{{ctx.Locale.Tr "explore_title"}}</a>
{{if and (.SystemConfig.Theme.EnableBlogs.Value ctx) (.SystemConfig.Theme.BlogsInTopNav.Value ctx)}}
<a class="item{{if .PageIsExploreBlogs}} active{{end}}" href="{{AppSubUrl}}/explore/blogs">{{ctx.Locale.Tr "explore.blogs"}}</a>
{{end}}
{{if .SystemConfig.Theme.APIHeaderURL.Value ctx}}
<a class="item" href="{{.SystemConfig.Theme.APIHeaderURL.Value ctx}}">{{ctx.Locale.Tr "api"}}</a>
{{end}}
@@ -38,10 +41,16 @@
{{if not (.SystemConfig.Theme.HideExploreButton.Value ctx)}}
<a class="item{{if .PageIsExplore}} active{{end}}" href="{{AppSubUrl}}/explore/organizations">{{ctx.Locale.Tr "explore_title"}}</a>
{{end}}
{{if and (.SystemConfig.Theme.EnableBlogs.Value ctx) (.SystemConfig.Theme.BlogsInTopNav.Value ctx)}}
<a class="item{{if .PageIsExploreBlogs}} active{{end}}" href="{{AppSubUrl}}/explore/blogs">{{ctx.Locale.Tr "explore.blogs"}}</a>
{{end}}
{{else}}
{{if not (.SystemConfig.Theme.HideExploreButton.Value ctx)}}
<a class="item{{if .PageIsExplore}} active{{end}}" href="{{AppSubUrl}}/explore/repos">{{ctx.Locale.Tr "explore_title"}}</a>
{{end}}
{{if and (.SystemConfig.Theme.EnableBlogs.Value ctx) (.SystemConfig.Theme.BlogsInTopNav.Value ctx)}}
<a class="item{{if .PageIsExploreBlogs}} active{{end}}" href="{{AppSubUrl}}/explore/blogs">{{ctx.Locale.Tr "explore.blogs"}}</a>
{{end}}
{{end}}
{{if .SystemConfig.Theme.APIHeaderURL.Value ctx}}
<a class="item" href="{{.SystemConfig.Theme.APIHeaderURL.Value ctx}}">{{ctx.Locale.Tr "api"}}</a>

View File

@@ -1,5 +1,34 @@
{{- /* og:description - a one to two sentence description of your object, maybe it only needs at most 300 bytes */ -}}
{{if .PageIsUserProfile}}
{{if .BlogPost}}
<meta property="og:title" content="{{.BlogPost.Title}}">
<meta property="og:type" content="article">
{{if .BlogPost.Subtitle}}
<meta property="og:description" content="{{StringUtils.EllipsisString .BlogPost.Subtitle 300}}">
<meta name="description" content="{{StringUtils.EllipsisString .BlogPost.Subtitle 300}}">
{{end}}
{{if .BlogPost.FeaturedImage}}
<meta property="og:image" content="{{.BlogPost.FeaturedImage.DownloadURL}}">
<meta name="twitter:image" content="{{.BlogPost.FeaturedImage.DownloadURL}}">
<meta name="twitter:card" content="summary_large_image">
{{else if .BlogPost.Author}}
<meta property="og:image" content="{{.BlogPost.Author.AvatarLink ctx}}">
<meta name="twitter:card" content="summary">
{{end}}
{{if .BlogPost.Author}}
<meta property="article:author" content="{{.BlogPost.Author.DisplayName}}">
<meta name="author" content="{{.BlogPost.Author.DisplayName}}">
{{end}}
{{if .BlogPublishedISO}}
<meta property="article:published_time" content="{{.BlogPublishedISO}}">
{{end}}
{{if .BlogTags}}
{{range .BlogTags}}<meta property="article:tag" content="{{.}}">
{{end}}
{{end}}
{{if .RepoLink}}
<meta property="og:url" content="{{AppUrl}}blog/{{.BlogPost.ID}}">
{{end}}
{{else if .PageIsUserProfile}}
<meta property="og:title" content="{{.ContextUser.DisplayName}}">
<meta property="og:type" content="profile">
<meta property="og:image" content="{{.ContextUser.AvatarLink ctx}}">

View File

@@ -0,0 +1,698 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content blog">
<div class="ui container">
<div class="blog-view">
{{if .BlogPost.Repo}}
<div class="blog-standalone-breadcrumb">
{{if .BlogPost.Repo.RelAvatarLink ctx}}
<img class="blog-avatar-sm" src="{{.BlogPost.Repo.RelAvatarLink ctx}}" alt="">
{{else if .BlogPost.Repo.Owner}}
<img class="blog-avatar-sm" src="{{.BlogPost.Repo.Owner.AvatarLink ctx}}" alt="">
{{end}}
<a href="{{.BlogPost.Repo.Link}}">{{if .BlogPost.Repo.DisplayTitle}}{{.BlogPost.Repo.DisplayTitle}}{{else}}{{.BlogPost.Repo.FullName}}{{end}}</a>
<span class="blog-meta-sep">/</span>
<span>{{ctx.Locale.Tr "repo.blog"}}</span>
</div>
{{end}}
{{if .BlogPost.FeaturedImage}}
<div class="blog-view-hero">
<img src="{{.BlogPost.FeaturedImage.DownloadURL}}" alt="{{.BlogPost.Title}}">
</div>
{{end}}
<article class="blog-view-article">
<header class="blog-view-header">
<h1 class="blog-view-title">{{.BlogPost.Title}}</h1>
{{if .BlogPost.Subtitle}}
<p class="blog-view-subtitle">{{.BlogPost.Subtitle}}</p>
{{end}}
<div class="blog-view-meta">
{{if .BlogPost.Author}}
<img class="blog-avatar" src="{{.BlogPost.Author.AvatarLink ctx}}" alt="{{.BlogPost.Author.Name}}">
{{if not .BlogPost.Author.KeepEmailPrivate}}
<a href="mailto:{{.BlogPost.Author.Email}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{else}}
<a href="{{.BlogPost.Author.HomeLink}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{end}}
<span class="blog-meta-sep">&middot;</span>
{{end}}
{{if .BlogPost.PublishedUnix}}
<span>{{DateUtils.TimeSince .BlogPost.PublishedUnix}}</span>
{{else}}
<span>{{DateUtils.TimeSince .BlogPost.CreatedUnix}}</span>
{{end}}
</div>
{{if .BlogTags}}
<div class="blog-view-tags">
{{range .BlogTags}}
<span class="ui small label">{{.}}</span>
{{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">
{{.BlogPost.RenderedContent | SafeHTML}}
</div>
</article>
{{if .IsWriter}}
<div class="blog-view-actions">
<a href="{{.RepoLink}}/blog/{{.BlogPost.ID}}/edit" class="ui small primary button">
{{svg "octicon-pencil" 16}} {{ctx.Locale.Tr "repo.blog.edit"}}
</a>
<form method="post" action="{{.RepoLink}}/blog/{{.BlogPost.ID}}/delete" class="tw-inline">
{{.CsrfTokenHtml}}
<button class="ui small red button" onclick="return confirm('{{ctx.Locale.Tr "repo.blog.delete_confirm"}}')">
{{svg "octicon-trash" 16}} {{ctx.Locale.Tr "repo.blog.delete"}}
</button>
</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>
<style>
.blog-standalone-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--color-text-light);
margin-bottom: 16px;
}
.blog-standalone-breadcrumb a {
color: var(--color-text);
font-weight: 600;
text-decoration: none;
}
.blog-standalone-breadcrumb a:hover {
color: var(--color-primary);
}
.blog-avatar-sm {
width: 18px;
height: 18px;
border-radius: 50%;
}
.blog-view {
max-width: 800px;
margin: 0 auto;
}
.blog-view-hero {
width: 100%;
max-height: 400px;
border-radius: 12px;
overflow: hidden;
margin-bottom: 24px;
background: var(--color-secondary-alpha-20);
}
.blog-view-hero img {
width: 100%;
height: 100%;
max-height: 400px;
object-fit: cover;
}
.blog-view-header {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--color-secondary-alpha-40);
}
.blog-view-title {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
margin: 8px 0;
}
.blog-view-subtitle {
font-size: 18px;
color: var(--color-text-light);
line-height: 1.5;
margin: 0 0 16px;
}
.blog-view-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--color-text-light);
}
.blog-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
}
.blog-author-link {
color: var(--color-text);
font-weight: 600;
text-decoration: none;
}
.blog-author-link:hover {
text-decoration: underline;
}
.blog-meta-sep {
color: var(--color-text-light-3);
}
.blog-view-tags {
display: flex;
flex-wrap: wrap;
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;
margin-bottom: 32px;
}
.blog-view-body blockquote {
border-left: 4px solid var(--color-primary);
margin: 32px 0;
padding: 20px 24px;
background: var(--color-secondary-alpha-10);
border-radius: 0 8px 8px 0;
font-size: 1.2em;
font-style: italic;
line-height: 1.6;
color: var(--color-text);
position: relative;
}
.blog-view-body blockquote::before {
content: "\201C";
font-size: 3em;
line-height: 1;
position: absolute;
top: 8px;
left: 12px;
color: var(--color-primary);
opacity: 0.3;
font-style: normal;
}
.blog-view-body blockquote p {
margin: 0;
padding-left: 24px;
}
.blog-view-body blockquote p:not(:last-child) {
margin-bottom: 8px;
}
.blog-view-body img {
max-width: 100%;
border-radius: 8px;
margin: 16px 0;
}
.blog-view-body hr {
border: none;
border-top: 1px solid var(--color-secondary-alpha-40);
margin: 32px 0;
}
.blog-view-actions {
display: flex;
gap: 8px;
padding-top: 24px;
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 || '';
const repoLink = '{{.RepoLink}}';
// === 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;
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 = '{{.BlogPost.ID}}';
const resp = await fetch(repoLink + '/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) {
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 = '{{.BlogPost.ID}}';
const resp = await fetch(repoLink + '/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

@@ -4,7 +4,7 @@
<div class="ui container">
{{if .FeaturedPost}}
<div class="blog-featured">
<a href="{{.FeaturedPost.Repo.Link}}/blog/{{.FeaturedPost.ID}}" class="blog-featured-link">
<a href="{{AppSubUrl}}/blog/{{.FeaturedPost.ID}}" class="blog-featured-link">
{{if .FeaturedPost.FeaturedImage}}
<div class="blog-featured-image">
<img src="{{.FeaturedPost.FeaturedImage.DownloadURL}}" alt="{{.FeaturedPost.Title}}">
@@ -20,6 +20,7 @@
<img class="blog-avatar" src="{{.FeaturedPost.Author.AvatarLink ctx}}" alt="{{.FeaturedPost.Author.Name}}">
{{end}}
{{if .FeaturedPost.Repo}}
<img class="blog-avatar-sm" src="{{if .FeaturedPost.Repo.RelAvatarLink ctx}}{{.FeaturedPost.Repo.RelAvatarLink ctx}}{{else if .FeaturedPost.Repo.Owner}}{{.FeaturedPost.Repo.Owner.AvatarLink ctx}}{{end}}" alt="">
<span class="blog-featured-repo">{{if .FeaturedPost.Repo.DisplayTitle}}{{.FeaturedPost.Repo.DisplayTitle}}{{else}}{{.FeaturedPost.Repo.Name}}{{end}}</span>
<span class="blog-meta-sep">&middot;</span>
{{end}}
@@ -70,7 +71,7 @@
<div class="blog-post-list">
{{range .Posts}}
<div class="blog-list-item">
<a href="{{.Repo.Link}}/blog/{{.ID}}" class="blog-list-item-link">
<a href="{{AppSubUrl}}/blog/{{.ID}}" class="blog-list-item-link">
{{if .FeaturedImage}}
<div class="blog-list-item-image">
<img src="{{.FeaturedImage.DownloadURL}}" alt="{{.Title}}" loading="lazy">
@@ -92,7 +93,10 @@
{{end}}
{{if .Repo}}
<span class="blog-meta-sep">&middot;</span>
<a href="{{.Repo.Link}}" class="blog-list-item-repo">{{if .Repo.DisplayTitle}}{{.Repo.DisplayTitle}}{{else}}{{.Repo.FullName}}{{end}}</a>
<a href="{{.Repo.Link}}" class="blog-list-item-repo">
<img class="blog-avatar-sm" src="{{if .Repo.RelAvatarLink ctx}}{{.Repo.RelAvatarLink ctx}}{{else if .Repo.Owner}}{{.Repo.Owner.AvatarLink ctx}}{{end}}" alt="">
{{if .Repo.DisplayTitle}}{{.Repo.DisplayTitle}}{{else}}{{.Repo.FullName}}{{end}}
</a>
{{end}}
<span class="blog-meta-sep">&middot;</span>
{{if .PublishedUnix}}