170 lines
5.7 KiB
JavaScript
Raw Normal View History

2025-12-04 20:29:35 +01:00
/**
* @fileoverview Rant Card Component for Rantii
* @author retoor <retoor@molodetz.nl>
* @description Compact rant display for feed listings
* @keywords rant, card, feed, listing, preview
*/
import { BaseComponent } from './base-component.js';
import { formatRelativeTime } from '../utils/date.js';
import { buildDevrantImageUrl } from '../utils/url.js';
class RantCard extends BaseComponent {
static get observedAttributes() {
return ['rant-id'];
}
init() {
this.rantData = null;
this.render();
this.bindEvents();
}
setRant(rant) {
this.rantData = rant;
this.setAttr('rant-id', rant.id);
this.render();
}
render() {
if (!this.rantData) {
this.setHtml('<div class="rant-card-skeleton"></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-card');
this.setHtml(`
<article class="card-content">
<header class="card-header">
<user-avatar
avatar='${JSON.stringify(rant.user_avatar)}'
username="${rant.user_username}"
size="small">
</user-avatar>
<div class="card-meta">
<span class="card-username">${rant.user_username}</span>
<span class="card-score">+${rant.user_score}</span>
<span class="card-time">${formatRelativeTime(rant.created_time)}</span>
</div>
</header>
<div class="card-body">
<rant-content text="${this.escapeAttr(rant.text)}"></rant-content>
${imageUrl ? `
<div class="card-image">
<image-preview
src="${imageUrl}"
width="${rant.attached_image.width}"
height="${rant.attached_image.height}">
</image-preview>
</div>
` : ''}
</div>
<footer class="card-footer">
<vote-buttons
score="${rant.score}"
vote-state="${rant.vote_state}"
type="rant"
item-id="${rant.id}">
</vote-buttons>
<button class="card-comments" aria-label="${rant.num_comments} comments">
<svg viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"/>
</svg>
<span>${rant.num_comments}</span>
</button>
${rant.tags && rant.tags.length > 0 ? `
<div class="card-tags">
${rant.tags.slice(0, 3).map(tag => `
<span class="tag">${tag}</span>
`).join('')}
</div>
` : ''}
</footer>
</article>
`);
}
escapeAttr(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
bindEvents() {
this.on(this, 'click', this.handleClick);
this.on(this, 'vote', this.handleVote);
}
handleClick(e) {
const username = e.target.closest('.card-username');
const avatar = e.target.closest('user-avatar');
const commentsBtn = e.target.closest('.card-comments');
const voteBtn = e.target.closest('.vote-btn');
const tag = e.target.closest('.tag');
const imagePreview = e.target.closest('image-preview');
const youtubeEmbed = e.target.closest('youtube-embed');
const linkPreview = e.target.closest('link-preview');
if (voteBtn || imagePreview || youtubeEmbed || linkPreview) {
return;
}
if (username || avatar) {
e.stopPropagation();
this.getRouter()?.goToUser(this.rantData.user_username);
return;
}
if (tag) {
e.stopPropagation();
this.getRouter()?.goToSearch(tag.textContent);
return;
}
this.getRouter()?.goToRant(this.rantData.id);
}
async handleVote(e) {
e.stopPropagation();
const { vote, itemId } = e.detail;
const voteButtons = this.$('vote-buttons');
if (voteButtons) {
voteButtons.disable();
}
try {
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;
if (voteButtons) {
voteButtons.updateVote(result.rant.score, result.rant.vote_state);
voteButtons.enable();
}
}
} catch (error) {
if (voteButtons) {
voteButtons.enable();
}
}
}
getRantId() {
return this.rantData?.id || this.getAttr('rant-id');
}
}
customElements.define('rant-card', RantCard);
export { RantCard };