233 lines
7.4 KiB
JavaScript
233 lines
7.4 KiB
JavaScript
|
|
/**
|
||
|
|
* @fileoverview Comment Item Component for Rantii
|
||
|
|
* @author retoor <retoor@molodetz.nl>
|
||
|
|
* @description Single comment display with voting
|
||
|
|
* @keywords comment, item, reply, discussion, vote
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { BaseComponent } from './base-component.js';
|
||
|
|
import { formatRelativeTime } from '../utils/date.js';
|
||
|
|
|
||
|
|
class CommentItem extends BaseComponent {
|
||
|
|
static get observedAttributes() {
|
||
|
|
return ['comment-id'];
|
||
|
|
}
|
||
|
|
|
||
|
|
init() {
|
||
|
|
this.commentData = null;
|
||
|
|
this.isEditing = false;
|
||
|
|
this.render();
|
||
|
|
this.bindEvents();
|
||
|
|
}
|
||
|
|
|
||
|
|
setComment(comment) {
|
||
|
|
this.commentData = comment;
|
||
|
|
this.setAttr('comment-id', comment.id);
|
||
|
|
this.render();
|
||
|
|
}
|
||
|
|
|
||
|
|
render() {
|
||
|
|
if (!this.commentData) {
|
||
|
|
this.setHtml('<div class="comment-skeleton"></div>');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const comment = this.commentData;
|
||
|
|
const isOwner = this.getCurrentUser()?.id === comment.user_id;
|
||
|
|
|
||
|
|
this.addClass('comment-item');
|
||
|
|
|
||
|
|
if (this.isEditing) {
|
||
|
|
this.setHtml(`
|
||
|
|
<div class="comment-edit">
|
||
|
|
<textarea class="comment-edit-input">${comment.body}</textarea>
|
||
|
|
<div class="comment-edit-actions">
|
||
|
|
<button class="btn btn-secondary cancel-edit-btn">Cancel</button>
|
||
|
|
<button class="btn btn-primary save-edit-btn">Save</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.setHtml(`
|
||
|
|
<article class="comment-content">
|
||
|
|
<header class="comment-header">
|
||
|
|
<user-avatar
|
||
|
|
avatar='${JSON.stringify(comment.user_avatar)}'
|
||
|
|
username="${comment.user_username}"
|
||
|
|
size="small">
|
||
|
|
</user-avatar>
|
||
|
|
<div class="comment-meta">
|
||
|
|
<span class="comment-username">${comment.user_username}</span>
|
||
|
|
<span class="comment-score">+${comment.user_score}</span>
|
||
|
|
<time class="comment-time">${formatRelativeTime(comment.created_time)}</time>
|
||
|
|
</div>
|
||
|
|
${isOwner ? `
|
||
|
|
<div class="comment-actions">
|
||
|
|
<button class="action-btn edit-btn" aria-label="Edit">
|
||
|
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
||
|
|
<path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
<button class="action-btn delete-btn" aria-label="Delete">
|
||
|
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
||
|
|
<path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
` : ''}
|
||
|
|
</header>
|
||
|
|
<div class="comment-body">
|
||
|
|
<rant-content text="${this.escapeAttr(comment.body)}"></rant-content>
|
||
|
|
</div>
|
||
|
|
<footer class="comment-footer">
|
||
|
|
<vote-buttons
|
||
|
|
score="${comment.score}"
|
||
|
|
vote-state="${comment.vote_state}"
|
||
|
|
type="comment"
|
||
|
|
item-id="${comment.id}">
|
||
|
|
</vote-buttons>
|
||
|
|
</footer>
|
||
|
|
</article>
|
||
|
|
`);
|
||
|
|
}
|
||
|
|
|
||
|
|
escapeAttr(str) {
|
||
|
|
if (!str) return '';
|
||
|
|
return str
|
||
|
|
.replace(/&/g, '&')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/'/g, ''')
|
||
|
|
.replace(/</g, '<')
|
||
|
|
.replace(/>/g, '>');
|
||
|
|
}
|
||
|
|
|
||
|
|
bindEvents() {
|
||
|
|
this.on(this, 'click', this.handleClick);
|
||
|
|
this.on(this, 'vote', this.handleVote);
|
||
|
|
}
|
||
|
|
|
||
|
|
handleClick(e) {
|
||
|
|
const editBtn = e.target.closest('.edit-btn');
|
||
|
|
const deleteBtn = e.target.closest('.delete-btn');
|
||
|
|
const cancelBtn = e.target.closest('.cancel-edit-btn');
|
||
|
|
const saveBtn = e.target.closest('.save-edit-btn');
|
||
|
|
const username = e.target.closest('.comment-username');
|
||
|
|
const avatar = e.target.closest('user-avatar');
|
||
|
|
|
||
|
|
if (editBtn) {
|
||
|
|
this.startEditing();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (deleteBtn) {
|
||
|
|
this.confirmDelete();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (cancelBtn) {
|
||
|
|
this.cancelEditing();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (saveBtn) {
|
||
|
|
this.saveEdit();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (username || avatar) {
|
||
|
|
this.getRouter()?.goToUser(this.commentData.user_username);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async handleVote(e) {
|
||
|
|
const { vote, itemId } = e.detail;
|
||
|
|
|
||
|
|
const voteButtons = this.$('vote-buttons');
|
||
|
|
if (voteButtons) {
|
||
|
|
voteButtons.disable();
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = await this.getApi()?.voteComment(itemId, vote);
|
||
|
|
if (result?.success && result.comment) {
|
||
|
|
this.commentData.score = result.comment.score;
|
||
|
|
this.commentData.vote_state = result.comment.vote_state;
|
||
|
|
if (voteButtons) {
|
||
|
|
voteButtons.updateVote(result.comment.score, result.comment.vote_state);
|
||
|
|
voteButtons.enable();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
if (voteButtons) {
|
||
|
|
voteButtons.enable();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
startEditing() {
|
||
|
|
this.isEditing = true;
|
||
|
|
this.render();
|
||
|
|
const textarea = this.$('.comment-edit-input');
|
||
|
|
if (textarea) {
|
||
|
|
textarea.focus();
|
||
|
|
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
cancelEditing() {
|
||
|
|
this.isEditing = false;
|
||
|
|
this.render();
|
||
|
|
}
|
||
|
|
|
||
|
|
async saveEdit() {
|
||
|
|
const textarea = this.$('.comment-edit-input');
|
||
|
|
if (!textarea) return;
|
||
|
|
|
||
|
|
const newBody = textarea.value.trim();
|
||
|
|
if (!newBody || newBody === this.commentData.body) {
|
||
|
|
this.cancelEditing();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = await this.getApi()?.updateComment(this.commentData.id, newBody);
|
||
|
|
if (result?.success) {
|
||
|
|
this.commentData.body = newBody;
|
||
|
|
this.isEditing = false;
|
||
|
|
this.render();
|
||
|
|
this.emit('comment-updated', { comment: this.commentData });
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
this.getApp()?.toast?.error('Failed to update comment');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
confirmDelete() {
|
||
|
|
if (confirm('Delete this comment?')) {
|
||
|
|
this.deleteComment();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async deleteComment() {
|
||
|
|
try {
|
||
|
|
const result = await this.getApi()?.deleteComment(this.commentData.id);
|
||
|
|
if (result?.success) {
|
||
|
|
this.emit('comment-deleted', { commentId: this.commentData.id });
|
||
|
|
this.remove();
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
this.getApp()?.toast?.error('Failed to delete comment');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
getCommentId() {
|
||
|
|
return this.commentData?.id || this.getAttr('comment-id');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
customElements.define('comment-item', CommentItem);
|
||
|
|
|
||
|
|
export { CommentItem };
|