Files
blog.pyaqa.ru/app/presentation/templates/pages/post_detail.html
Sergey Vanyushkin 7ff3fa0992
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
feat: add comments feature with nested replies and recursive rendering
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.
2026-05-11 15:34:20 +03:00

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> &mdash; <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 %}