This commit is contained in:
retoor 2026-06-05 18:42:43 +02:00
parent d53449133c
commit 0ce8e43b05
9 changed files with 155 additions and 88 deletions

View File

@ -243,6 +243,29 @@ def get_user_votes(user_uid, target_uids):
return {r["target_uid"]: r["value"] for r in rows}
def _build_comment_items(raw, user=None):
uids = [c["user_uid"] for c in raw]
cids = [c["uid"] for c in raw]
users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids)
my_votes = get_user_votes(user["uid"], cids) if user else {}
from devplacepy.utils import time_ago
from devplacepy.attachments import get_attachments_batch as _gab
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
items = {}
for c in raw:
items[c["uid"]] = {
"comment": c,
"author": users.get(c["user_uid"]),
"time_ago": time_ago(c["created_at"]),
"votes": {"up": ups.get(c["uid"], 0), "down": downs.get(c["uid"], 0)},
"my_vote": my_votes.get(c["uid"], 0),
"children": [],
"attachments": atts_map.get(c["uid"], []),
}
return items
def load_comments(target_type, target_uid, user=None):
if "comments" not in db.tables:
return []
@ -252,25 +275,7 @@ def load_comments(target_type, target_uid, user=None):
raw = list(comments_table.find(post_uid=target_uid, order_by=["created_at"]))
if not raw:
return []
uids = [c["user_uid"] for c in raw]
cids = [c["uid"] for c in raw]
users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids)
my_votes = get_user_votes(user["uid"], cids) if user else {}
from devplacepy.utils import time_ago
from devplacepy.attachments import get_attachments_batch as _gab
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
cmap = {}
for c in raw:
cmap[c["uid"]] = {
"comment": c,
"author": users.get(c["user_uid"]),
"time_ago": time_ago(c["created_at"]),
"votes": {"up": ups.get(c["uid"], 0), "down": downs.get(c["uid"], 0)},
"my_vote": my_votes.get(c["uid"], 0),
"children": [],
"attachments": atts_map.get(c["uid"], []),
}
cmap = _build_comment_items(raw, user)
top = []
for item in cmap.values():
parent = item["comment"].get("parent_uid")
@ -281,6 +286,29 @@ def load_comments(target_type, target_uid, user=None):
return top
def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
if not post_uids or "comments" not in db.tables:
return {}
placeholders, params = _in_clause(post_uids)
params["lim"] = limit
raw = list(db.query(
f"SELECT * FROM ("
f" SELECT *, ROW_NUMBER() OVER ("
f" PARTITION BY target_uid ORDER BY created_at DESC, id DESC"
f" ) AS rn FROM comments"
f" WHERE target_type='post' AND target_uid IN ({placeholders})"
f") WHERE rn <= :lim ORDER BY target_uid, created_at ASC",
**params,
))
if not raw:
return {}
items = _build_comment_items(raw, user)
result = defaultdict(list)
for c in raw:
result[c["target_uid"]].append(items[c["uid"]])
return dict(result)
def get_attachments(resource_type: str, resource_uid: str) -> list:
if "attachments" not in db.tables:
return []

View File

@ -1,7 +1,7 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats, get_top_authors, paginate
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_recent_comments_by_post_uids, get_site_stats, get_top_authors, paginate
from devplacepy.attachments import get_attachments_batch
from devplacepy.content import enrich_items
from devplacepy.templating import templates
@ -48,8 +48,11 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
post_uids_list = [item["post"]["uid"] for item in posts]
attachments_map = get_attachments_batch("post", post_uids_list)
recent_comments = get_recent_comments_by_post_uids(post_uids_list, 3, user)
for item in posts:
item["attachments"] = attachments_map.get(item["post"]["uid"], [])
uid = item["post"]["uid"]
item["attachments"] = attachments_map.get(uid, [])
item["recent_comments"] = recent_comments.get(uid, [])
seo_ctx = list_page_seo(
request,

View File

@ -711,6 +711,10 @@ img {
align-items: center;
}
.comment-reply-form {
margin-top: 0.75rem;
}
.comment-form > a {
flex-shrink: 0;
line-height: 0;

View File

@ -566,3 +566,12 @@
color: var(--text-muted);
font-size: 0.875rem;
}
.post-card-comments {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 0.75rem;
}

View File

@ -4,28 +4,80 @@ export class CommentManager {
}
initCommentReply() {
document.querySelectorAll("[data-action='reply']").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
const comment = btn.closest(".comment");
const commentForm = document.querySelector(".comment-form");
if (!comment || !commentForm) return;
const textarea = commentForm.querySelector("textarea");
if (!textarea) return;
let parentInput = commentForm.querySelector('input[name="parent_uid"]');
if (!parentInput) {
parentInput = document.createElement("input");
parentInput.type = "hidden";
parentInput.name = "parent_uid";
commentForm.appendChild(parentInput);
}
const commentBody = comment.querySelector(".comment-body");
if (commentBody && commentBody.dataset.commentUid) {
parentInput.value = commentBody.dataset.commentUid;
}
textarea.focus();
textarea.scrollIntoView({ behavior: "smooth" });
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-action='reply']");
if (!btn) return;
e.preventDefault();
this.toggleReplyForm(btn);
});
}
toggleReplyForm(btn) {
const comment = btn.closest(".comment");
if (!comment) return;
const body = comment.querySelector(".comment-body");
if (!body) return;
const existing = body.querySelector(":scope > .comment-reply-form");
if (existing) {
existing.remove();
return;
}
const template = document.getElementById("comment-reply-template");
if (!template) return;
const container = comment.closest(".post-card, .comments-section");
const source = container && container.querySelector(".comment-form:not(.comment-reply-form)");
if (!source) return;
const targetUid = source.querySelector('input[name="target_uid"]').value;
const targetType = source.querySelector('input[name="target_type"]').value;
const fragment = template.content.cloneNode(true);
const form = fragment.querySelector(".comment-form");
if (!form) return;
form.classList.add("comment-reply-form");
form.querySelector('input[name="target_uid"]').value = targetUid;
form.querySelector('input[name="target_type"]').value = targetType;
const parentInput = document.createElement("input");
parentInput.type = "hidden";
parentInput.name = "parent_uid";
parentInput.value = body.dataset.commentUid || "";
form.appendChild(parentInput);
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "comment-action-btn comment-reply-cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", () => form.remove());
form.querySelector(".comment-form-actions").appendChild(cancel);
const actions = body.querySelector(":scope > .comment-actions");
actions.insertAdjacentElement("afterend", form);
this.enhanceForm(form);
const textarea = form.querySelector("textarea");
if (textarea) textarea.focus();
}
enhanceForm(form) {
const enhancer = window.app && window.app.content;
if (enhancer) {
enhancer.initEmojiPickers();
enhancer.initMentionInputs();
enhancer.initAttachmentManagers();
}
const textarea = form.querySelector("textarea");
if (textarea) {
textarea.addEventListener("input", () => {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
});
}
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) btn.disabled = true;
});
}
}

View File

@ -1,52 +1,7 @@
<section class="comments-section">
<h3>Comments</h3>
{% macro render_comment(item, depth=0) %}
<div class="comment" style="--comment-depth: {{ depth }};" data-depth="{{ depth }}">
<div class="comment-votes">
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}">+</button>
</form>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}">-</button>
</form>
</div>
<div class="comment-body" id="comment-{{ item.comment['uid'] }}" data-comment-uid="{{ item.comment['uid'] }}">
<div class="comment-header">
<a href="/profile/{{ item.author['username'] if item.author else '#' }}">
<img src="{{ avatar_url('multiavatar', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}" loading="lazy">
</a>
{% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %}
<span class="comment-time">{{ item.time_ago }}</span>
</div>
<div class="comment-text rendered-content" data-render>{{ item.comment['content'] }}</div>
{% set attachments = item.get('attachments', []) %}
{% if attachments %}
{% include "_attachment_display.html" %}
{% endif %}
<div class="comment-actions">
<button class="comment-action-btn" data-action="reply"><span class="icon">&#x1F4AC;</span> Reply</button>
{% if user and item.comment['user_uid'] == user['uid'] %}
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form">
<button type="submit" class="comment-action-btn"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button>
</form>
{% endif %}
</div>
{% if item.children %}
<div class="comment-replies">
{% for child in item.children %}
{{ render_comment(child, depth + 1) }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endmacro %}
{% from "_comment.html" import render_comment with context %}
{% for item in comments %}
{{ render_comment(item, 0) }}

View File

@ -31,6 +31,15 @@
{% endif %}
</div>
{% if item.recent_comments %}
{% from "_comment.html" import render_comment with context %}
<div class="post-card-comments">
{% for c in item.recent_comments %}
{{ render_comment(c, 0) }}
{% endfor %}
</div>
{% endif %}
{% if _show_comment_form %}
{% set _comment_target_uid = item.post['uid'] %}{% set _comment_target_type = "post" %}{% include "_comment_form.html" %}
{% endif %}

View File

@ -167,6 +167,12 @@
</footer>
{% endblock %}
{% if user %}
<template id="comment-reply-template">
{% set _comment_target_uid = "" %}{% set _comment_target_type = "" %}{% include "_comment_form.html" %}
</template>
{% endif %}
<script defer src="/static/vendor/marked.umd.js"></script>
<script defer src="/static/vendor/highlight.min.js"></script>
<script defer src="/static/vendor/purify.min.js"></script>

View File

@ -3,6 +3,7 @@
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/sidebar.css">
<link rel="stylesheet" href="/static/css/post.css">
{% endblock %}
{% block content %}
<h1 class="sr-only">Feed</h1>