mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 14:51:08 -05:00
Add comment and reply functionality with preview and notification templates
This commit is contained in:
285
static/js/components/inline-comment-panel.js
Normal file
285
static/js/components/inline-comment-panel.js
Normal file
@@ -0,0 +1,285 @@
|
||||
// Inline Comment Panel Component
|
||||
|
||||
class InlineCommentPanel {
|
||||
constructor(options = {}) {
|
||||
this.container = null;
|
||||
this.thread = null;
|
||||
this.canResolve = options.canResolve || false;
|
||||
this.onReply = options.onReply || (() => {});
|
||||
this.onResolve = options.onResolve || (() => {});
|
||||
this.currentUser = options.currentUser;
|
||||
}
|
||||
|
||||
initialize(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) {
|
||||
throw new Error(`Container element with id "${containerId}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
setThread(thread) {
|
||||
this.thread = thread;
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.container || !this.thread) return;
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="comment-panel">
|
||||
<div class="comment-header">
|
||||
<div class="thread-info">
|
||||
<span class="anchor-info">${this.formatAnchor(this.thread.anchor)}</span>
|
||||
${this.thread.is_resolved ?
|
||||
`<span class="resolution-badge">
|
||||
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Resolved
|
||||
</span>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
${this.canResolve && !this.thread.is_resolved ?
|
||||
`<button class="resolve-button" onclick="this.resolveThread()">
|
||||
Resolve Thread
|
||||
</button>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<div class="comments-container">
|
||||
${this.renderComments(this.thread.comments)}
|
||||
</div>
|
||||
${!this.thread.is_resolved ?
|
||||
`<div class="reply-form">
|
||||
<textarea
|
||||
class="reply-input"
|
||||
placeholder="Write a reply..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="form-actions">
|
||||
<button class="reply-button" onclick="this.submitReply()">
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachEventListeners();
|
||||
}
|
||||
|
||||
renderComments(comments) {
|
||||
return comments.map(comment => `
|
||||
<div class="comment ${comment.parent_comment ? 'reply' : ''}" data-comment-id="${comment.id}">
|
||||
<div class="comment-author">
|
||||
<img src="${comment.author.avatar_url || '/static/images/default-avatar.png'}"
|
||||
alt="${comment.author.username}"
|
||||
class="author-avatar"
|
||||
/>
|
||||
<span class="author-name">${comment.author.username}</span>
|
||||
<span class="comment-date">${this.formatDate(comment.created_at)}</span>
|
||||
</div>
|
||||
<div class="comment-content">
|
||||
${this.formatCommentContent(comment.content)}
|
||||
</div>
|
||||
${this.renderCommentActions(comment)}
|
||||
${comment.replies ?
|
||||
`<div class="replies">
|
||||
${this.renderComments(comment.replies)}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
renderCommentActions(comment) {
|
||||
if (this.thread.is_resolved) return '';
|
||||
|
||||
return `
|
||||
<div class="comment-actions">
|
||||
<button class="action-button" onclick="this.showReplyForm('${comment.id}')">
|
||||
Reply
|
||||
</button>
|
||||
${comment.author.id === this.currentUser.id ?
|
||||
`<button class="action-button" onclick="this.editComment('${comment.id}')">
|
||||
Edit
|
||||
</button>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
formatCommentContent(content) {
|
||||
// Replace @mentions with styled spans
|
||||
content = content.replace(/@(\w+)/g, '<span class="mention">@$1</span>');
|
||||
|
||||
// Convert URLs to links
|
||||
content = content.replace(
|
||||
/(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g,
|
||||
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
formatAnchor(anchor) {
|
||||
const start = anchor.line_start;
|
||||
const end = anchor.line_end;
|
||||
const file = anchor.file_path.split('/').pop();
|
||||
|
||||
return end > start ?
|
||||
`${file}:${start}-${end}` :
|
||||
`${file}:${start}`;
|
||||
}
|
||||
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
const replyInput = this.container.querySelector('.reply-input');
|
||||
if (replyInput) {
|
||||
replyInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
this.submitReply();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async submitReply() {
|
||||
const input = this.container.querySelector('.reply-input');
|
||||
const content = input.value.trim();
|
||||
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
await this.onReply(content);
|
||||
input.value = '';
|
||||
} catch (error) {
|
||||
console.error('Failed to submit reply:', error);
|
||||
// Show error message to user
|
||||
}
|
||||
}
|
||||
|
||||
async resolveThread() {
|
||||
try {
|
||||
await this.onResolve();
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve thread:', error);
|
||||
// Show error message to user
|
||||
}
|
||||
}
|
||||
|
||||
showReplyForm(commentId) {
|
||||
const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
|
||||
if (!comment) return;
|
||||
|
||||
const replyForm = document.createElement('div');
|
||||
replyForm.className = 'reply-form nested';
|
||||
replyForm.innerHTML = `
|
||||
<textarea
|
||||
class="reply-input"
|
||||
placeholder="Write a reply..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="form-actions">
|
||||
<button class="cancel-button" onclick="this.hideReplyForm('${commentId}')">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="reply-button" onclick="this.submitNestedReply('${commentId}')">
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
comment.appendChild(replyForm);
|
||||
replyForm.querySelector('.reply-input').focus();
|
||||
}
|
||||
|
||||
hideReplyForm(commentId) {
|
||||
const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
|
||||
if (!comment) return;
|
||||
|
||||
const replyForm = comment.querySelector('.reply-form');
|
||||
if (replyForm) {
|
||||
replyForm.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async submitNestedReply(parentId) {
|
||||
const comment = this.container.querySelector(`[data-comment-id="${parentId}"]`);
|
||||
if (!comment) return;
|
||||
|
||||
const input = comment.querySelector('.reply-input');
|
||||
const content = input.value.trim();
|
||||
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
await this.onReply(content, parentId);
|
||||
this.hideReplyForm(parentId);
|
||||
} catch (error) {
|
||||
console.error('Failed to submit reply:', error);
|
||||
// Show error message to user
|
||||
}
|
||||
}
|
||||
|
||||
editComment(commentId) {
|
||||
const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
|
||||
if (!comment) return;
|
||||
|
||||
const contentDiv = comment.querySelector('.comment-content');
|
||||
const content = contentDiv.textContent;
|
||||
|
||||
contentDiv.innerHTML = `
|
||||
<textarea class="edit-input" rows="2">${content}</textarea>
|
||||
<div class="form-actions">
|
||||
<button class="cancel-button" onclick="this.cancelEdit('${commentId}')">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="save-button" onclick="this.saveEdit('${commentId}')">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
cancelEdit(commentId) {
|
||||
// Refresh the entire thread to restore original content
|
||||
this.render();
|
||||
}
|
||||
|
||||
async saveEdit(commentId) {
|
||||
const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
|
||||
if (!comment) return;
|
||||
|
||||
const input = comment.querySelector('.edit-input');
|
||||
const content = input.value.trim();
|
||||
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
// Emit edit event
|
||||
const event = new CustomEvent('comment-edited', {
|
||||
detail: { commentId, content }
|
||||
});
|
||||
this.container.dispatchEvent(event);
|
||||
|
||||
// Refresh the thread
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to edit comment:', error);
|
||||
// Show error message to user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default InlineCommentPanel;
|
||||
Reference in New Issue
Block a user