Simplify blog header to show owner/repo format without redundant "Blog" label. Add fallback to CreatedUnix when PublishedUnix is not available for featured posts.
879 lines
28 KiB
Handlebars
879 lines
28 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}}
|
|
</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);
|
|
}
|
|
/* 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" .}}
|