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
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.
891 lines
29 KiB
Handlebars
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">·</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" .}}
|