2
0
Files
gitcaddy-server/templates/blog/standalone_view.tmpl
logikonline a10dbda7ac
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m22s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m10s
Build and Release / Lint (push) Successful in 4m54s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m0s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h4m43s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 6m39s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 7m46s
Build and Release / Build Binary (linux/arm64) (push) Successful in 9m6s
feat(blog): add view count tracking to blog posts
Adds view_count field to blog_post table with database migration. Implements atomic increment on post views in both standalone and repo blog routes. Displays view count with eye icon in post templates.
2026-02-04 17:11:52 -05:00

891 lines
29 KiB
Handlebars

{{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.Owner}}{{Iif .BlogPost.Repo.Owner.FullName .BlogPost.Repo.Owner.FullName .BlogPost.Repo.Owner.Name}}/{{end}}{{Iif .BlogPost.Repo.DisplayTitle .BlogPost.Repo.DisplayTitle .BlogPost.Repo.Name}}</a>
</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">
{{if .BlogPost.Series}}
<a class="blog-view-series" href="{{AppSubUrl}}/blogs?series={{.BlogPost.Series}}">{{.BlogPost.Series}}</a>
{{end}}
<h1 class="blog-view-title">
{{.BlogPost.Title}}
{{if .BlogPost.SubscriptionOnly}}<span class="blog-sub-badge" data-tooltip-content="{{ctx.Locale.Tr "repo.blog.subscription_only"}}">{{svg "octicon-lock" 16}}</span>{{end}}
</h1>
{{if .BlogPost.Subtitle}}
<p class="blog-view-subtitle">{{.BlogPost.Subtitle}}</p>
{{end}}
<div class="blog-view-meta-row">
<div class="blog-view-meta">
{{if .BlogPost.Author}}
<img class="blog-avatar" src="{{.BlogPost.Author.AvatarLink ctx}}" alt="{{.BlogPost.Author.Name}}">
{{/* Only link to author if viewer can access their profile */}}
{{if or .BlogPost.Author.Visibility.IsPublic (and .IsSigned .BlogPost.Author.Visibility.IsLimited)}}
{{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}}
{{else}}
<span class="blog-author-name">{{.BlogPost.Author.DisplayName}}</span>
{{end}}
<span class="blog-meta-sep">&middot;</span>
{{end}}
{{if .BlogPost.PublishedUnix}}
<span>{{DateUtils.TimeSince .BlogPost.PublishedUnix}}</span>
{{else}}
<span>{{DateUtils.TimeSince .BlogPost.CreatedUnix}}</span>
{{end}}
</div>
<button class="ui small basic button blog-share-btn" id="blog-share-btn"
data-tooltip-content="{{ctx.Locale.Tr "repo.blog.share_link"}}"
data-link="{{.RepoLink}}/blog/{{.BlogPost.ID}}">
{{svg "octicon-link" 16}} {{ctx.Locale.Tr "repo.blog.share_link"}}
</button>
</div>
{{if .BlogTags}}
<div class="blog-view-tags">
{{range .BlogTags}}
<a class="ui small label" href="{{AppSubUrl}}/blogs?tag={{.}}">{{.}}</a>
{{end}}
</div>
{{end}}
</header>
{{if .SubscriptionGated}}
<div class="blog-subscription-gate">
<div class="blog-subscription-gate-icon">{{svg "octicon-lock" 32}}</div>
<h3>{{ctx.Locale.Tr "repo.blog.subscription_required"}}</h3>
<p>{{ctx.Locale.Tr "repo.blog.subscription_required_desc"}}</p>
<a href="{{.RepoLink}}/subscribe" class="ui primary button">
{{svg "octicon-unlock" 16}} {{ctx.Locale.Tr "repo.blog.subscribe_to_read"}}
</a>
</div>
{{else}}
<div class="blog-view-body markup markdown">
{{.BlogPost.RenderedContent | SafeHTML}}
</div>
{{end}}
</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}}
{{if not .SubscriptionGated}}
<!-- 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}}
<span class="blog-view-count">
{{svg "octicon-eye" 16}}
<span>{{.BlogPost.ViewCount}}</span>
</span>
</div>
<!-- Comments Section -->
{{if or .BlogPost.AllowComments .BlogComments}}
<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 not .BlogPost.AllowComments}}
<div class="ui message info">
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.blog.comments.disabled"}}
</div>
{{end}}
{{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">
<div class="blog-comment-reactions">
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (index $.UserCommentReactions .ID).IsLike}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="like" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsup" 14}}
<span class="comment-like-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Likes}}{{else}}0{{end}}</span>
</button>
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (not (index $.UserCommentReactions .ID).IsLike)}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="dislike" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsdown" 14}}
<span class="comment-dislike-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Dislikes}}{{else}}0{{end}}</span>
</button>
</div>
{{if $.BlogPost.AllowComments}}
<button class="blog-reply-btn" data-comment-id="{{.ID}}" type="button">
{{svg "octicon-reply" 14}} {{ctx.Locale.Tr "repo.blog.comment.reply"}}
</button>
{{end}}
{{if or $.IsWriter (and $.IsSigned (eq .UserID $.SignedUserID))}}
<form method="post" action="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/delete" class="tw-inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="redirect" value="standalone">
<button class="blog-delete-btn" type="submit" onclick="return confirm('{{ctx.Locale.Tr "repo.blog.comment.delete_confirm"}}')">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "repo.blog.comment.delete"}}
</button>
</form>
{{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>
<div class="blog-comment-actions">
<div class="blog-comment-reactions">
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (index $.UserCommentReactions .ID).IsLike}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="like" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsup" 14}}
<span class="comment-like-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Likes}}{{else}}0{{end}}</span>
</button>
<button class="blog-comment-reaction-btn{{if and (index $.UserCommentReactions .ID) (not (index $.UserCommentReactions .ID).IsLike)}} active{{end}}"
data-url="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/react" data-type="dislike" data-comment-id="{{.ID}}">
{{svg "octicon-thumbsdown" 14}}
<span class="comment-dislike-count" data-comment-id="{{.ID}}">{{if index $.CommentReactionCounts .ID}}{{(index $.CommentReactionCounts .ID).Dislikes}}{{else}}0{{end}}</span>
</button>
</div>
{{if or $.IsWriter (and $.IsSigned (eq .UserID $.SignedUserID))}}
<form method="post" action="{{$.RepoLink}}/blog/{{$.BlogPost.ID}}/comment/{{.ID}}/delete" class="tw-inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="redirect" value="standalone">
<button class="blog-delete-btn" type="submit" onclick="return confirm('{{ctx.Locale.Tr "repo.blog.comment.delete_confirm"}}')">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "repo.blog.comment.delete"}}
</button>
</form>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{if $.BlogPost.AllowComments}}
<!-- 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="redirect" value="standalone">
<input type="hidden" name="parent_id" value="{{.ID}}">
<textarea name="content" rows="3" class="blog-comment-textarea" placeholder="{{ctx.Locale.Tr "repo.blog.comment.reply_placeholder"}}" required></textarea>
<div class="tw-flex tw-gap-2 tw-mt-2">
<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>
{{end}}
</div>
{{end}}
</div>
{{else}}
{{if .BlogPost.AllowComments}}
<p class="blog-comments-empty">{{ctx.Locale.Tr "repo.blog.comments.empty"}}</p>
{{end}}
{{end}}
{{if .BlogPost.AllowComments}}
<!-- 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="redirect" value="standalone">
<input type="hidden" name="parent_id" value="0">
<textarea name="content" rows="4" class="blog-comment-textarea" placeholder="{{ctx.Locale.Tr "repo.blog.comment.placeholder"}}" required></textarea>
<div class="tw-mt-2">
<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="redirect" value="standalone">
<input type="hidden" name="parent_id" value="0">
<textarea name="content" rows="4" class="blog-comment-textarea" placeholder="{{ctx.Locale.Tr "repo.blog.comment.placeholder"}}" required></textarea>
<div class="tw-mt-2">
<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>
{{end}}
</div>
{{end}}
{{end}}{{/* end if not SubscriptionGated */}}
</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-series {
display: inline-block;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-primary);
text-decoration: none;
margin-bottom: 4px;
}
.blog-view-series:hover {
text-decoration: underline;
color: var(--color-primary);
}
.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-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.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-btn {
cursor: pointer;
flex-shrink: 0;
}
.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-btn.active[data-type="dislike"] {
background: var(--color-red-alpha-20, rgba(219, 40, 40, 0.1));
border-color: var(--color-red);
color: var(--color-red);
}
.blog-reaction-hint {
font-size: 12px;
color: var(--color-text-light-3);
}
.blog-view-count {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 14px;
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;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.blog-comment-reactions {
display: flex;
align-items: center;
gap: 4px;
}
.blog-comment-reaction-btn {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border: 1px solid var(--color-secondary-alpha-40);
border-radius: 12px;
background: transparent;
color: var(--color-text-light);
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.blog-comment-reaction-btn:hover {
background: var(--color-secondary-alpha-20);
color: var(--color-text);
}
.blog-comment-reaction-btn.active {
background: var(--color-primary-alpha-20);
border-color: var(--color-primary);
color: var(--color-primary);
}
.blog-comment-reaction-btn.active[data-type="dislike"] {
background: var(--color-red-alpha-20, rgba(219, 40, 40, 0.1));
border-color: var(--color-red);
color: var(--color-red);
}
.blog-reply-btn, .blog-delete-btn {
display: inline-flex;
align-items: center;
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);
}
/* Subscription gate */
.blog-subscription-gate {
text-align: center;
padding: 48px 24px;
border: 2px dashed var(--color-secondary-alpha-40);
border-radius: 12px;
background: var(--color-secondary-alpha-10);
margin-bottom: 32px;
}
.blog-subscription-gate-icon {
color: var(--color-text-light-3);
margin-bottom: 16px;
}
.blog-subscription-gate h3 {
margin: 0 0 8px;
font-size: 20px;
}
.blog-subscription-gate p {
color: var(--color-text-light);
margin: 0 0 20px;
font-size: 15px;
}
.blog-sub-badge {
display: inline-flex;
align-items: center;
vertical-align: middle;
color: var(--color-text-light-3);
margin-left: 6px;
}
</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);
}
});
});
// === Comment reaction buttons ===
document.querySelectorAll('.blog-comment-reaction-btn').forEach(function(btn) {
btn.addEventListener('click', async function() {
const url = this.dataset.url;
const type = this.dataset.type;
const commentId = this.dataset.commentId;
try {
const fd = new FormData();
fd.append('type', type);
const resp = await fetch(url, {
method: 'POST',
headers: {'X-Csrf-Token': csrfToken},
body: fd,
});
if (resp.ok) {
const data = await resp.json();
document.querySelectorAll('.comment-like-count[data-comment-id="' + commentId + '"]').forEach(el => el.textContent = data.likes);
document.querySelectorAll('.comment-dislike-count[data-comment-id="' + commentId + '"]').forEach(el => el.textContent = data.dislikes);
document.querySelectorAll('.blog-comment-reaction-btn[data-comment-id="' + commentId + '"]').forEach(b => {
b.classList.remove('active');
if (data.reacted && b.dataset.type === data.type) {
b.classList.add('active');
}
});
}
} catch (e) {
console.error('Comment reaction error:', e);
}
});
});
// === Reply toggle ===
document.querySelectorAll('.blog-reply-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
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" .}}