feat: add comments feature with nested replies and recursive rendering
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
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.
This commit is contained in:
@@ -11,11 +11,15 @@ from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.application import (
|
||||
CreateCommentUseCase,
|
||||
CreatePostUseCase,
|
||||
DeleteCommentUseCase,
|
||||
DeletePostUseCase,
|
||||
GetPostUseCase,
|
||||
ListCommentsUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
ToggleCommentLikeUseCase,
|
||||
TogglePostLikeUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
@@ -31,6 +35,11 @@ ListPostsDep = FromDishka[ListPostsUseCase]
|
||||
PublishPostDep = FromDishka[PublishPostUseCase]
|
||||
ToggleLikeDep = FromDishka[TogglePostLikeUseCase]
|
||||
|
||||
CreateCommentDep = FromDishka[CreateCommentUseCase]
|
||||
DeleteCommentDep = FromDishka[DeleteCommentUseCase]
|
||||
ListCommentsDep = FromDishka[ListCommentsUseCase]
|
||||
ToggleCommentLikeDep = FromDishka[ToggleCommentLikeUseCase]
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ all v1 endpoint routers.
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.presentation.api.v1.comments import router as comments_router
|
||||
from app.presentation.api.v1.posts import router as posts_router
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
router.include_router(posts_router)
|
||||
router.include_router(comments_router)
|
||||
|
||||
131
app/presentation/api/v1/comments.py
Normal file
131
app/presentation/api/v1/comments.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Comments API routes.
|
||||
|
||||
This module defines FastAPI routes for comment operations including
|
||||
CRUD and like/unlike toggle.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from dishka.integrations.fastapi import DishkaRoute
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.presentation.api.deps import (
|
||||
CreateCommentDep,
|
||||
CurrentRoleDep,
|
||||
CurrentUserDep,
|
||||
DeleteCommentDep,
|
||||
ListCommentsDep,
|
||||
ToggleCommentLikeDep,
|
||||
)
|
||||
from app.presentation.schemas import (
|
||||
CommentCreateSchema,
|
||||
CommentLikeResponseSchema,
|
||||
CommentResponseSchema,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["comments"], route_class=DishkaRoute)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/posts/{post_id}/comments",
|
||||
response_model=CommentResponseSchema,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a comment on a post",
|
||||
)
|
||||
async def create_comment(
|
||||
post_id: UUID,
|
||||
schema: CommentCreateSchema,
|
||||
use_case: CreateCommentDep,
|
||||
current_user_id: CurrentUserDep,
|
||||
) -> CommentResponseSchema:
|
||||
"""Create a comment on a blog post.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post to comment on.
|
||||
schema: Comment creation data.
|
||||
use_case: CreateCommentUseCase dependency.
|
||||
current_user_id: Authenticated user ID.
|
||||
|
||||
Returns:
|
||||
CommentResponseSchema with created comment data.
|
||||
"""
|
||||
result = await use_case.execute(
|
||||
post_id=post_id,
|
||||
author_id=current_user_id,
|
||||
content=schema.content,
|
||||
parent_id=schema.parent_id,
|
||||
)
|
||||
return CommentResponseSchema(**result.__dict__)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/posts/{post_id}/comments",
|
||||
response_model=list[CommentResponseSchema],
|
||||
summary="List comments for a post",
|
||||
)
|
||||
async def list_comments(
|
||||
post_id: UUID,
|
||||
use_case: ListCommentsDep,
|
||||
) -> list[CommentResponseSchema]:
|
||||
"""Get all comments for a blog post.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
use_case: ListCommentsUseCase dependency.
|
||||
|
||||
Returns:
|
||||
List of CommentResponseSchema for the post.
|
||||
"""
|
||||
results = await use_case.execute(post_id=post_id)
|
||||
return [CommentResponseSchema(**r.__dict__) for r in results]
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/comments/{comment_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a comment",
|
||||
)
|
||||
async def delete_comment(
|
||||
comment_id: UUID,
|
||||
use_case: DeleteCommentDep,
|
||||
current_user_id: CurrentUserDep,
|
||||
role: CurrentRoleDep,
|
||||
) -> None:
|
||||
"""Delete a comment.
|
||||
|
||||
Users can delete their own comments.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment to delete.
|
||||
use_case: DeleteCommentUseCase dependency.
|
||||
current_user_id: Authenticated user ID.
|
||||
role: Current user role.
|
||||
"""
|
||||
await use_case.execute(comment_id=comment_id, user_id=current_user_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/comments/{comment_id}/like",
|
||||
response_model=CommentLikeResponseSchema,
|
||||
summary="Toggle like on a comment",
|
||||
)
|
||||
async def toggle_comment_like(
|
||||
comment_id: UUID,
|
||||
use_case: ToggleCommentLikeDep,
|
||||
current_user_id: CurrentUserDep,
|
||||
) -> CommentLikeResponseSchema:
|
||||
"""Toggle like/unlike on a comment.
|
||||
|
||||
If the user already liked the comment, the like is removed (unlike).
|
||||
Otherwise, a new like is added.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment.
|
||||
use_case: ToggleCommentLikeUseCase dependency.
|
||||
current_user_id: Authenticated user ID.
|
||||
|
||||
Returns:
|
||||
CommentLikeResponseSchema with updated like_count.
|
||||
"""
|
||||
result = await use_case.execute(comment_id, current_user_id)
|
||||
return CommentLikeResponseSchema(id=result.id, like_count=result.like_count)
|
||||
@@ -4,6 +4,11 @@ This module re-exports all Pydantic schemas used for
|
||||
request/response validation in the API layer.
|
||||
"""
|
||||
|
||||
from app.presentation.schemas.comment import (
|
||||
CommentCreateSchema,
|
||||
CommentLikeResponseSchema,
|
||||
CommentResponseSchema,
|
||||
)
|
||||
from app.presentation.schemas.post import (
|
||||
PostBaseSchema,
|
||||
PostCreateSchema,
|
||||
@@ -22,4 +27,7 @@ __all__ = [
|
||||
"PostListResponseSchema",
|
||||
"PostSearchSchema",
|
||||
"PostPublishSchema",
|
||||
"CommentCreateSchema",
|
||||
"CommentResponseSchema",
|
||||
"CommentLikeResponseSchema",
|
||||
]
|
||||
|
||||
58
app/presentation/schemas/comment.py
Normal file
58
app/presentation/schemas/comment.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Pydantic schemas for comments.
|
||||
|
||||
This module defines Pydantic models for comment request/response
|
||||
validation in the API layer.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CommentCreateSchema(BaseModel):
|
||||
"""Schema for creating a comment.
|
||||
|
||||
Attributes:
|
||||
content: Comment text content (Markdown supported).
|
||||
parent_id: Optional parent comment ID for replies.
|
||||
"""
|
||||
|
||||
content: str = Field(..., min_length=1, max_length=5000, description="Comment content")
|
||||
parent_id: UUID | None = Field(default=None, description="Parent comment ID for replies")
|
||||
|
||||
|
||||
class CommentResponseSchema(BaseModel):
|
||||
"""Schema for comment response.
|
||||
|
||||
Attributes:
|
||||
id: Unique comment identifier.
|
||||
post_id: UUID of the parent post.
|
||||
author_id: Comment author identifier.
|
||||
content: Comment content text.
|
||||
parent_id: Optional parent comment ID.
|
||||
like_count: Number of likes on this comment.
|
||||
created_at: Creation timestamp.
|
||||
updated_at: Last update timestamp.
|
||||
"""
|
||||
|
||||
id: UUID
|
||||
post_id: UUID
|
||||
author_id: str
|
||||
content: str
|
||||
parent_id: UUID | None = None
|
||||
like_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class CommentLikeResponseSchema(BaseModel):
|
||||
"""Schema for comment like response.
|
||||
|
||||
Attributes:
|
||||
id: Comment identifier.
|
||||
like_count: Updated like count.
|
||||
"""
|
||||
|
||||
id: UUID
|
||||
like_count: int
|
||||
@@ -55,6 +55,9 @@
|
||||
<span class="post-card-meta-item" data-testid="like-count-{{ post.id }}">
|
||||
👍 {{ post.like_count }}
|
||||
</span>
|
||||
<span class="post-card-meta-item" data-testid="comment-count-{{ post.id }}">
|
||||
💬 {{ post.comment_count }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="post-card-content" data-testid="post-content-preview-{{ post.id }}">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<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>
|
||||
@@ -40,22 +40,25 @@
|
||||
👍 <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;">
|
||||
@@ -63,7 +66,7 @@
|
||||
</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 %}
|
||||
@@ -89,41 +92,267 @@
|
||||
</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="like-script">
|
||||
<script data-testid="comment-script">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var likeButton = document.getElementById('like-button');
|
||||
if (!likeButton) return;
|
||||
if (likeButton) {
|
||||
likeButton.addEventListener('click', function() {
|
||||
var slug = this.getAttribute('data-post-slug');
|
||||
var countSpan = document.getElementById('like-count');
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fetch('/web/posts/' + slug + '/like', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
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;
|
||||
}
|
||||
})
|
||||
.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);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ integration with the application's use cases and domain layer.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
@@ -19,11 +20,14 @@ from pygments.util import ClassNotFound
|
||||
|
||||
from app.application.dtos import CreatePostDTO, UpdatePostDTO
|
||||
from app.application.use_cases import (
|
||||
CreateCommentUseCase,
|
||||
CreatePostUseCase,
|
||||
DeletePostUseCase,
|
||||
GetPostUseCase,
|
||||
ListCommentsUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
ToggleCommentLikeUseCase,
|
||||
TogglePostLikeUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
@@ -32,6 +36,7 @@ from app.domain.exceptions import (
|
||||
NotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.domain.repositories import CommentRepository
|
||||
from app.domain.roles import Role, get_effective_role
|
||||
from app.infrastructure.auth import TokenInfo
|
||||
from app.infrastructure.config.settings import settings
|
||||
@@ -177,6 +182,7 @@ async def home(
|
||||
request: Request,
|
||||
user: OptionalUserDep,
|
||||
list_use_case: FromDishka[ListPostsUseCase],
|
||||
comment_repo: FromDishka[CommentRepository],
|
||||
) -> HTMLResponse:
|
||||
"""Render the home page with list of posts.
|
||||
|
||||
@@ -184,10 +190,13 @@ async def home(
|
||||
request: The HTTP request object for template context.
|
||||
user: Current user from dependency.
|
||||
list_use_case: Use case for listing posts.
|
||||
comment_repo: Repository for fetching comment counts.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered posts list template.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
page_str = request.query_params.get("page", "1")
|
||||
page = max(1, int(page_str) if page_str.isdigit() else 1)
|
||||
offset = (page - 1) * _DEFAULT_PAGE_SIZE
|
||||
@@ -196,6 +205,10 @@ async def home(
|
||||
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
|
||||
)
|
||||
|
||||
for i, post in enumerate(visible_posts):
|
||||
count = await comment_repo.count_by_post(post.id)
|
||||
visible_posts[i] = replace(post, comment_count=count)
|
||||
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
return templates.TemplateResponse(
|
||||
@@ -217,6 +230,7 @@ async def list_posts(
|
||||
request: Request,
|
||||
user: OptionalUserDep,
|
||||
list_use_case: FromDishka[ListPostsUseCase],
|
||||
comment_repo: FromDishka[CommentRepository],
|
||||
) -> HTMLResponse:
|
||||
"""Render the posts listing page.
|
||||
|
||||
@@ -224,10 +238,13 @@ async def list_posts(
|
||||
request: The HTTP request object for template context.
|
||||
user: Current user from dependency.
|
||||
list_use_case: Use case for listing posts.
|
||||
comment_repo: Repository for fetching comment counts.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered posts list template.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
page_str = request.query_params.get("page", "1")
|
||||
page = max(1, int(page_str) if page_str.isdigit() else 1)
|
||||
offset = (page - 1) * _DEFAULT_PAGE_SIZE
|
||||
@@ -236,6 +253,10 @@ async def list_posts(
|
||||
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
|
||||
)
|
||||
|
||||
for i, post in enumerate(visible_posts):
|
||||
count = await comment_repo.count_by_post(post.id)
|
||||
visible_posts[i] = replace(post, comment_count=count)
|
||||
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
return templates.TemplateResponse(
|
||||
@@ -339,14 +360,18 @@ async def post_detail(
|
||||
post_slug: str,
|
||||
user: OptionalUserDep,
|
||||
get_use_case: FromDishka[GetPostUseCase],
|
||||
list_comments_use_case: FromDishka[ListCommentsUseCase],
|
||||
comment_repo: FromDishka[CommentRepository],
|
||||
) -> HTMLResponse:
|
||||
"""Render a single post detail page.
|
||||
"""Render a single post detail page with comments.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object for template context.
|
||||
post_slug: The URL-friendly slug of the post to display.
|
||||
user: Current user from dependency.
|
||||
get_use_case: Use case for retrieving posts.
|
||||
list_comments_use_case: Use case for listing comments.
|
||||
comment_repo: Repository for fetching comment count.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered post detail template.
|
||||
@@ -354,6 +379,8 @@ async def post_detail(
|
||||
Raises:
|
||||
HTTPException: If post not found or not visible to user.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
try:
|
||||
post = await get_use_case.by_slug(post_slug)
|
||||
except NotFoundException:
|
||||
@@ -362,6 +389,19 @@ async def post_detail(
|
||||
if not post.published and not can_see_draft(user, post.author_id):
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
comments = await list_comments_use_case.execute(post.id)
|
||||
comment_count = await comment_repo.count_by_post(post.id)
|
||||
post = replace(post, comment_count=comment_count)
|
||||
|
||||
children: dict[str, list[Any]] = {}
|
||||
for c in comments:
|
||||
pid = str(c.parent_id) if c.parent_id else ""
|
||||
if pid not in children:
|
||||
children[pid] = []
|
||||
children[pid].append(c)
|
||||
|
||||
top_level = children.pop("", [])
|
||||
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
|
||||
@@ -371,6 +411,8 @@ async def post_detail(
|
||||
{
|
||||
**context,
|
||||
"post": post,
|
||||
"top_level_comments": top_level,
|
||||
"reply_comments": children,
|
||||
"active_page": "posts",
|
||||
"can_edit": can_edit_post(user, post.author_id),
|
||||
"can_delete": can_delete_post(user, post.author_id),
|
||||
@@ -378,6 +420,89 @@ async def post_detail(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/posts/{post_slug}/comments")
|
||||
async def create_comment_web(
|
||||
request: Request,
|
||||
post_slug: str,
|
||||
user: OptionalUserDep,
|
||||
get_use_case: FromDishka[GetPostUseCase],
|
||||
create_use_case: FromDishka[CreateCommentUseCase],
|
||||
) -> dict[str, object]:
|
||||
"""Create a comment on a post via web UI.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object with JSON body.
|
||||
post_slug: The URL-friendly slug of the post.
|
||||
user: Current user from cookie or None.
|
||||
get_use_case: Use case for retrieving post.
|
||||
create_use_case: Use case for creating comments.
|
||||
|
||||
Returns:
|
||||
JSON dict with created comment data.
|
||||
|
||||
Raises:
|
||||
HTTPException: If user not authenticated or post not found.
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
post = await get_use_case.by_slug(post_slug)
|
||||
except NotFoundException:
|
||||
raise HTTPException(status_code=404, detail="Post not found") from None
|
||||
|
||||
body = await request.json()
|
||||
content = str(body.get("content", "")).strip()
|
||||
parent_id_str = body.get("parent_id")
|
||||
|
||||
parent_id: UUID | None = None
|
||||
if parent_id_str:
|
||||
parent_id = UUID(parent_id_str)
|
||||
|
||||
result = await create_use_case.execute(
|
||||
post_id=post.id,
|
||||
author_id=user.user_id,
|
||||
content=content,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(result.id),
|
||||
"post_id": str(result.post_id),
|
||||
"author_id": result.author_id,
|
||||
"content": result.content,
|
||||
"parent_id": str(result.parent_id) if result.parent_id else None,
|
||||
"like_count": result.like_count,
|
||||
"created_at": result.created_at.isoformat() if result.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/comments/{comment_id}/like")
|
||||
async def toggle_comment_like_web(
|
||||
comment_id: UUID,
|
||||
user: OptionalUserDep,
|
||||
toggle_use_case: FromDishka[ToggleCommentLikeUseCase],
|
||||
) -> dict[str, object]:
|
||||
"""Toggle like on a comment via web UI.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment.
|
||||
user: Current user from cookie or None.
|
||||
toggle_use_case: Use case for toggling comment likes.
|
||||
|
||||
Returns:
|
||||
JSON dict with updated like_count.
|
||||
|
||||
Raises:
|
||||
HTTPException: If user not authenticated.
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
result = await toggle_use_case.execute(comment_id, user.user_id)
|
||||
return {"like_count": result.like_count, "id": str(result.id)}
|
||||
|
||||
|
||||
@router.get("/posts/{post_slug}/edit", response_class=HTMLResponse)
|
||||
async def edit_post_form(
|
||||
request: Request,
|
||||
|
||||
Reference in New Issue
Block a user