All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Implement full comments system: domain entities (Comment, CommentLike), value objects (CommentContent), use cases (CRUD, like toggle), SQLAlchemy repository, API v1 endpoints, web UI with comment form and nested replies, i18n translations (EN/RU/FR/DE), and E2E tests. Fix nested reply (reply-to-reply) not displaying — the flat reply_comments dict was only queried for top-level comment IDs, so deeply nested replies were saved to DB (incrementing comment count) but never rendered. Switch to a recursive Jinja2 macro that renders any nesting depth.
361 lines
16 KiB
HTML
361 lines
16 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ post.title }} - Blog{% endblock %}
|
|
{% block meta_description %}{{ post.content[:160] }}{% endblock %}
|
|
{% block meta_keywords %}{{ post.tags|join(', ') }}{% endblock %}
|
|
{% block meta_author %}{{ post.author_id }}{% endblock %}
|
|
|
|
{% block canonical_url %}{{ request.base_url }}web/posts/{{ post.slug }}{% endblock %}
|
|
|
|
{% block og_type %}article{% endblock %}
|
|
{% block og_url %}{{ request.base_url }}web/posts/{{ post.slug }}{% endblock %}
|
|
{% block og_title %}{{ post.title }}{% endblock %}
|
|
{% block og_description %}{{ post.content[:160] }}{% endblock %}
|
|
|
|
{% block twitter_title %}{{ post.title }}{% endblock %}
|
|
{% block twitter_description %}{{ post.content[:160] }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<article class="post-detail" data-testid="post-detail">
|
|
<header class="post-detail-header" data-testid="post-detail-header">
|
|
<h1 class="post-detail-title" data-testid="post-detail-title">{{ post.title }}</h1>
|
|
|
|
<div class="post-detail-meta" data-testid="post-detail-meta">
|
|
<span class="post-card-meta-item" data-testid="post-detail-author">
|
|
<span class="avatar avatar-sm" data-testid="post-detail-author-avatar">{{ post.author_id[0]|upper }}</span>
|
|
<span data-testid="post-detail-author-name">{{ post.author_id }}</span>
|
|
</span>
|
|
<span class="post-card-meta-item" data-testid="post-detail-date">
|
|
{{ post.created_at.strftime('%B %d, %Y') }}
|
|
</span>
|
|
{% if post.published %}
|
|
<span class="badge badge-success" data-testid="post-detail-status">{{ _('post.status_published', current_locale) }}</span>
|
|
{% else %}
|
|
<span class="badge" data-testid="post-detail-status">{{ _('post.status_draft', current_locale) }}</span>
|
|
{% endif %}
|
|
<span class="post-card-meta-item" data-testid="post-detail-like-count">
|
|
<button id="like-button" class="btn-like" data-testid="like-button"
|
|
data-post-slug="{{ post.slug }}"
|
|
data-liked="false">
|
|
👍 <span id="like-count">{{ post.like_count }}</span>
|
|
</button>
|
|
</span>
|
|
<span class="post-card-meta-item" data-testid="post-detail-comment-count">
|
|
💬 {{ post.comment_count }}
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="post-detail-content markdown-body" data-testid="post-detail-content">
|
|
{{ post.content|markdown|safe }}
|
|
</div>
|
|
|
|
<footer class="post-detail-footer" data-testid="post-detail-footer">
|
|
<div class="post-detail-tags" data-testid="post-detail-tags">
|
|
{% for tag in post.tags %}
|
|
<span class="tag" data-testid="post-detail-tag-{{ loop.index }}">{{ tag }}</span>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<div class="divider" data-testid="post-detail-divider"></div>
|
|
|
|
<div class="flex justify-between items-center" data-testid="post-detail-actions">
|
|
<a href="/" class="btn" data-testid="btn-back-to-posts">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
|
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
{{ _('post.back_to_posts', current_locale) }}
|
|
</a>
|
|
|
|
{% if can_edit or can_delete %}
|
|
<div class="flex gap-2" data-testid="post-detail-edit-actions">
|
|
{% if can_edit %}
|
|
<a href="/web/posts/{{ post.slug }}/edit" class="btn" data-testid="btn-edit-post">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
|
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
{{ _('post.edit', current_locale) }}
|
|
</a>
|
|
{% endif %}
|
|
{% if can_delete %}
|
|
<form action="/web/posts/{{ post.slug }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
|
|
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('{{ _('post.delete_confirm', current_locale) }}');">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
|
<path d="M2 4h12M6 4V2a2 2 0 012-2h0a2 2 0 012 2v2m3 0v10a2 2 0 01-2 2H5a2 2 0 01-2-2V4h9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
{{ _('post.delete', current_locale) }}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</footer>
|
|
</article>
|
|
|
|
<section class="comments-section" data-testid="comments-section">
|
|
<div class="comments-header" data-testid="comments-header">
|
|
<h2 class="comments-title" data-testid="comments-title">
|
|
💬 {{ _('post.comments', current_locale) }}
|
|
<span class="comments-count" data-testid="comments-count">({{ post.comment_count }})</span>
|
|
</h2>
|
|
{% if user %}
|
|
<button id="btn-show-comment-form" class="btn btn-primary" data-testid="btn-show-comment-form">
|
|
{{ _('post.write_comment', current_locale) }}
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if user %}
|
|
<div id="comment-form-wrapper" class="comment-form-wrapper" data-testid="comment-form-wrapper" style="display: none;">
|
|
<form id="comment-form" class="comment-form" data-testid="form-create-comment" data-post-slug="{{ post.slug }}">
|
|
<div class="form-group">
|
|
<textarea id="comment-content" class="form-textarea" data-testid="input-comment-content"
|
|
rows="4" placeholder="{{ _('post.comment_placeholder', current_locale) }}"
|
|
required minlength="1" maxlength="5000"></textarea>
|
|
<input type="hidden" id="comment-parent-id" name="parent_id" value="">
|
|
<p class="form-help" data-testid="comment-form-help" id="reply-info" style="display: none;">
|
|
{{ _('post.replying_to', current_locale) }}
|
|
<button type="button" class="btn-cancel-reply" data-testid="btn-cancel-reply">{{ _('post.cancel_reply', current_locale) }}</button>
|
|
</p>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn btn-primary" data-testid="submit-comment">
|
|
{{ _('post.submit_comment', current_locale) }}
|
|
</button>
|
|
<button type="button" class="btn btn-cancel-comment" data-testid="btn-cancel-comment" style="display: none;">
|
|
{{ _('post.cancel', current_locale) }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<div id="comment-error" class="comment-error" data-testid="comment-error" style="display: none;"></div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% macro render_comment(comment, depth) %}
|
|
<div class="comment{% if depth > 0 %} comment-reply{% endif %}" data-testid="comment-{{ comment.id }}" data-comment-id="{{ comment.id }}">
|
|
<div class="comment-avatar" data-testid="comment-avatar-{{ comment.id }}">
|
|
<span class="avatar avatar-sm">{{ comment.author_id[0]|upper }}</span>
|
|
</div>
|
|
<div class="comment-body" data-testid="comment-body-{{ comment.id }}">
|
|
<div class="comment-meta" data-testid="comment-meta-{{ comment.id }}">
|
|
<span class="comment-author" data-testid="comment-author-{{ comment.id }}">{{ comment.author_id }}</span>
|
|
<span class="comment-date" data-testid="comment-date-{{ comment.id }}">
|
|
{% if comment.created_at %}{{ comment.created_at.strftime('%B %d, %Y') }}{% endif %}
|
|
</span>
|
|
</div>
|
|
<div class="comment-content" data-testid="comment-content-{{ comment.id }}">{{ comment.content }}</div>
|
|
<div class="comment-actions" data-testid="comment-actions-{{ comment.id }}">
|
|
{% if user %}
|
|
<button class="btn-comment-reply btn btn-sm" data-testid="btn-comment-reply-{{ comment.id }}"
|
|
data-comment-id="{{ comment.id }}" data-comment-author="{{ comment.author_id }}">
|
|
{{ _('post.reply', current_locale) }}
|
|
</button>
|
|
{% endif %}
|
|
<button class="btn-comment-like btn btn-sm" data-testid="btn-comment-like-{{ comment.id }}"
|
|
data-comment-id="{{ comment.id }}">
|
|
👍 <span class="comment-like-count" data-testid="comment-like-count-{{ comment.id }}">{{ comment.like_count }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{% set key = comment.id|string %}
|
|
{% if key in reply_comments %}
|
|
<div class="comment-replies" data-testid="comment-replies-{{ comment.id }}">
|
|
{% for child in reply_comments[key] %}
|
|
{{ render_comment(child, depth + 1) }}
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
<div class="comments-list" data-testid="comments-list">
|
|
{% if top_level_comments %}
|
|
{% for comment in top_level_comments %}
|
|
{{ render_comment(comment, 0) }}
|
|
{% endfor %}
|
|
{% else %}
|
|
<div class="comments-empty" data-testid="comments-empty">
|
|
<p>{{ _('post.no_comments', current_locale) }}</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</section>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script data-testid="comment-script">
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var likeButton = document.getElementById('like-button');
|
|
if (likeButton) {
|
|
likeButton.addEventListener('click', function() {
|
|
var slug = this.getAttribute('data-post-slug');
|
|
var countSpan = document.getElementById('like-count');
|
|
|
|
fetch('/web/posts/' + slug + '/like', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/json'
|
|
}
|
|
})
|
|
.then(function(response) {
|
|
if (response.status === 401) {
|
|
window.location.href = '/auth/dev-login';
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error('Like request failed');
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(function(data) {
|
|
if (data && data.like_count !== undefined) {
|
|
countSpan.textContent = data.like_count;
|
|
}
|
|
})
|
|
.catch(function(error) {
|
|
console.error('Like error:', error);
|
|
});
|
|
});
|
|
}
|
|
|
|
var showFormBtn = document.getElementById('btn-show-comment-form');
|
|
var formWrapper = document.getElementById('comment-form-wrapper');
|
|
var cancelBtn = document.querySelector('.btn-cancel-comment');
|
|
var commentForm = document.getElementById('comment-form');
|
|
var commentContent = document.getElementById('comment-content');
|
|
var commentParentId = document.getElementById('comment-parent-id');
|
|
var replyInfo = document.getElementById('reply-info');
|
|
var commentError = document.getElementById('comment-error');
|
|
|
|
function showCommentForm(parentId, authorName) {
|
|
commentParentId.value = parentId || '';
|
|
if (parentId && authorName) {
|
|
replyInfo.style.display = 'block';
|
|
replyInfo.innerHTML = '{{ _("post.replying_to", current_locale) }} <strong>' + authorName + '</strong> — <button type="button" class="btn-cancel-reply" id="btn-cancel-reply">{{ _("post.cancel_reply", current_locale) }}</button>';
|
|
document.getElementById('btn-cancel-reply').addEventListener('click', function() {
|
|
commentParentId.value = '';
|
|
replyInfo.style.display = 'none';
|
|
});
|
|
} else {
|
|
replyInfo.style.display = 'none';
|
|
}
|
|
formWrapper.style.display = 'block';
|
|
if (showFormBtn) showFormBtn.style.display = 'none';
|
|
if (cancelBtn) cancelBtn.style.display = 'inline-flex';
|
|
commentContent.focus();
|
|
commentError.style.display = 'none';
|
|
}
|
|
|
|
function hideCommentForm() {
|
|
formWrapper.style.display = 'none';
|
|
if (showFormBtn) showFormBtn.style.display = 'inline-flex';
|
|
if (cancelBtn) cancelBtn.style.display = 'none';
|
|
commentContent.value = '';
|
|
commentParentId.value = '';
|
|
replyInfo.style.display = 'none';
|
|
commentError.style.display = 'none';
|
|
}
|
|
|
|
if (showFormBtn) {
|
|
showFormBtn.addEventListener('click', function() {
|
|
showCommentForm(null, null);
|
|
});
|
|
}
|
|
|
|
if (cancelBtn) {
|
|
cancelBtn.addEventListener('click', hideCommentForm);
|
|
}
|
|
|
|
if (commentForm) {
|
|
commentForm.addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
var content = commentContent.value.trim();
|
|
if (!content) return;
|
|
|
|
var slug = this.getAttribute('data-post-slug');
|
|
var parentId = commentParentId.value || null;
|
|
|
|
var payload = {content: content};
|
|
if (parentId) {
|
|
payload.parent_id = parentId;
|
|
}
|
|
|
|
fetch('/web/posts/' + slug + '/comments', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify(payload)
|
|
})
|
|
.then(function(response) {
|
|
if (response.status === 401) {
|
|
window.location.href = '/auth/dev-login';
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error('Comment creation failed');
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(function(data) {
|
|
if (data) {
|
|
location.reload();
|
|
}
|
|
})
|
|
.catch(function(error) {
|
|
commentError.textContent = '{{ _("post.comment_error", current_locale) }}';
|
|
commentError.style.display = 'block';
|
|
console.error('Comment error:', error);
|
|
});
|
|
});
|
|
}
|
|
|
|
var replyButtons = document.querySelectorAll('.btn-comment-reply');
|
|
replyButtons.forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
var commentId = this.getAttribute('data-comment-id');
|
|
var author = this.getAttribute('data-comment-author');
|
|
showCommentForm(commentId, author);
|
|
});
|
|
});
|
|
|
|
var commentLikeButtons = document.querySelectorAll('.btn-comment-like');
|
|
commentLikeButtons.forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
var commentId = this.getAttribute('data-comment-id');
|
|
var countSpan = this.querySelector('.comment-like-count');
|
|
|
|
fetch('/web/comments/' + commentId + '/like', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/json'
|
|
}
|
|
})
|
|
.then(function(response) {
|
|
if (response.status === 401) {
|
|
window.location.href = '/auth/dev-login';
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error('Comment like failed');
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(function(data) {
|
|
if (data && data.like_count !== undefined) {
|
|
countSpan.textContent = data.like_count;
|
|
}
|
|
})
|
|
.catch(function(error) {
|
|
console.error('Comment like error:', error);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
</script>
|
|
{% endblock %}
|