244 lines
8.1 KiB
JavaScript
Raw Normal View History

2025-12-04 20:29:35 +01:00
/**
* @fileoverview Rant Detail Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Full rant view with comments
* @keywords rant, detail, view, full, comments
*/
import { BaseComponent } from './base-component.js';
import { formatRelativeTime, formatFullDate } from '../utils/date.js';
import { buildDevrantImageUrl } from '../utils/url.js';
class RantDetail extends BaseComponent {
static get observedAttributes() {
return ['rant-id'];
}
init() {
this.rantData = null;
this.comments = [];
this.isLoading = false;
this.render();
this.bindEvents();
}
async load(rantId) {
this.setAttr('rant-id', rantId);
this.isLoading = true;
this.render();
try {
const result = await this.getApi()?.getRant(rantId);
if (result?.success) {
this.rantData = result.rant;
this.comments = result.comments || [];
}
} catch (error) {
this.rantData = null;
this.comments = [];
} finally {
this.isLoading = false;
this.render();
}
}
setRant(rant, comments = []) {
this.rantData = rant;
this.comments = comments;
this.setAttr('rant-id', rant.id);
this.render();
}
render() {
if (this.isLoading) {
this.setHtml(`
<div class="rant-detail-loading">
<loading-spinner text="Loading rant..."></loading-spinner>
</div>
`);
return;
}
if (!this.rantData) {
this.setHtml(`
<div class="rant-detail-error">
<p>Rant not found</p>
<button class="btn btn-primary back-btn">Go Back</button>
</div>
`);
return;
}
const rant = this.rantData;
const hasImage = rant.attached_image && typeof rant.attached_image === 'object';
const imageUrl = hasImage ? buildDevrantImageUrl(rant.attached_image.url) : null;
this.addClass('rant-detail');
this.setHtml(`
<article class="detail-content">
<header class="detail-header">
<button class="back-btn" aria-label="Go back">
<svg viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
</button>
<h1 class="detail-title">Rant</h1>
</header>
<div class="detail-author">
<user-avatar
avatar='${JSON.stringify(rant.user_avatar)}'
username="${rant.user_username}"
size="medium">
</user-avatar>
<div class="author-info">
<span class="author-username">${rant.user_username}</span>
<span class="author-score">+${rant.user_score}</span>
</div>
<time class="detail-time" datetime="${new Date(rant.created_time * 1000).toISOString()}">
${formatFullDate(rant.created_time)}
</time>
</div>
<div class="detail-body">
<rant-content text="${this.escapeAttr(rant.text)}"></rant-content>
${imageUrl ? `
<div class="detail-image">
<image-preview
src="${imageUrl}"
width="${rant.attached_image.width}"
height="${rant.attached_image.height}">
</image-preview>
</div>
` : ''}
</div>
${rant.tags && rant.tags.length > 0 ? `
<div class="detail-tags">
${rant.tags.map(tag => `
<button class="tag" data-tag="${tag}">${tag}</button>
`).join('')}
</div>
` : ''}
<footer class="detail-footer">
<vote-buttons
score="${rant.score}"
vote-state="${rant.vote_state}"
type="rant"
item-id="${rant.id}">
</vote-buttons>
<span class="detail-comments-count">${this.comments.length} comments</span>
</footer>
</article>
<section class="comments-section">
<comment-form rant-id="${rant.id}"></comment-form>
<div class="comments-list">
${this.comments.map(comment => `
<comment-item
comment-id="${comment.id}"
data-comment='${JSON.stringify(comment).replace(/'/g, '&#39;')}'>
</comment-item>
`).join('')}
</div>
</section>
`);
this.initComments();
}
escapeAttr(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
initComments() {
const commentItems = this.$$('comment-item');
commentItems.forEach(item => {
const commentData = item.dataset.comment;
if (commentData) {
try {
const comment = JSON.parse(commentData.replace(/&#39;/g, "'"));
item.setComment(comment);
} catch (e) {}
}
});
}
bindEvents() {
this.on(this, 'click', this.handleClick);
this.on(this, 'vote', this.handleVote);
this.on(this, 'comment-posted', this.handleCommentPosted);
}
handleClick(e) {
const backBtn = e.target.closest('.back-btn');
const username = e.target.closest('.author-username');
const avatar = e.target.closest('user-avatar');
const tag = e.target.closest('.tag');
if (backBtn) {
e.preventDefault();
window.history.back();
return;
}
if (username || avatar) {
this.getRouter()?.goToUser(this.rantData.user_username);
return;
}
if (tag) {
const tagText = tag.dataset.tag || tag.textContent;
this.getRouter()?.goToSearch(tagText);
}
}
async handleVote(e) {
const { vote, itemId, type } = e.detail;
if (type === 'rant') {
const result = await this.getApi()?.voteRant(itemId, vote);
if (result?.success && result.rant) {
this.rantData.score = result.rant.score;
this.rantData.vote_state = result.rant.vote_state;
const voteButtons = this.$('vote-buttons[type="rant"]');
if (voteButtons) {
voteButtons.updateVote(result.rant.score, result.rant.vote_state);
}
}
}
}
async handleCommentPosted(e) {
await this.load(this.rantData.id);
this.scrollToComments();
}
scrollToComment(commentId) {
const commentEl = this.$(`comment-item[comment-id="${commentId}"]`);
if (commentEl) {
commentEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
commentEl.classList.add('highlight');
setTimeout(() => commentEl.classList.remove('highlight'), 2000);
}
}
scrollToComments() {
const commentsSection = this.$('.comments-section');
if (commentsSection) {
commentsSection.scrollIntoView({ behavior: 'smooth' });
}
}
getRantId() {
return this.rantData?.id || this.getAttr('rant-id');
}
}
customElements.define('rant-detail', RantDetail);
export { RantDetail };