Holded_Blog / components /postcards.js
Shinhati2023's picture
Create components/postcards.js
542ded2 verified
/* ============================================================
GLASSGRID — POST CARD COMPONENT
⚠️ STRUCTURE IS FIXED. Do not modify class names.
⚠️ All visual changes through tokens/components.css only.
⚠️ Schema-validated post objects only. No raw HTML.
============================================================ */
'use strict';
import { store, showToast } from '../lib/store.js';
import { api } from '../lib/api.js';
/**
* Render a post card element.
* @param {Object} post - Schema.Post validated object
* @param {Object} user - Schema.User for the post author
* @returns {HTMLElement}
*/
export function PostCard(post, user) {
const liked = store.get('liked')?.[post.id] ?? false;
const saved = store.get('saved')?.[post.id] ?? false;
const config = store.get('config') ?? {};
const features = store.get('features') ?? {};
const el = document.createElement('article');
el.className = 'post-card glass animate-fadeInUp';
el.dataset.postId = post.id;
el.setAttribute('role', 'article');
el.innerHTML = `
<!-- PostCard__header -->
<div class="post-card__header">
<div class="avatar avatar--md ${user.hasStory ? 'avatar--ring gradient-border' : ''}">
<img
src="${escHtml(user.avatarUrl || '/assets/images/default-avatar.png')}"
alt="${escHtml(user.displayName)}'s avatar"
class="avatar__img"
loading="lazy"
width="40" height="40"
/>
</div>
<div style="flex:1;min-width:0;">
<div class="post-card__username">${escHtml(user.displayName)}</div>
<div class="post-card__handle">
@${escHtml(user.username)}
${post.location ? `<span class="post-card__handle"> · ${escHtml(post.location)}</span>` : ''}
</div>
</div>
<span class="post-card__timestamp">${formatRelativeTime(post.createdAt)}</span>
<button class="post-card__more-btn" aria-label="More options" data-action="more">
${IconMore()}
</button>
</div>
<!-- PostCard__media -->
<div class="post-card__media post-card__media--${post.aspectRatio}">
<img
src="${escHtml(post.mediaUrl)}"
alt="${escHtml(post.altText || post.caption || 'Post image')}"
loading="lazy"
decoding="async"
/>
</div>
<!-- PostCard__actions -->
<div class="post-card__actions" ${!features.likes && !features.comments ? 'style="display:none"' : ''}>
${features.likes ? `
<button
class="post-card__like-btn ${liked ? 'liked' : ''}"
data-action="like"
aria-label="${liked ? 'Unlike post' : 'Like post'}"
aria-pressed="${liked}"
>
${liked ? IconHeartFilled() : IconHeart()}
<span class="post-card__like-count">${formatCount(post.likeCount)}</span>
</button>
` : ''}
${features.comments ? `
<button class="post-card__comment-btn" data-action="comment" aria-label="Comment on post">
${IconComment()}
<span>${formatCount(post.commentCount)}</span>
</button>
` : ''}
${features.saves ? `
<button
class="post-card__save-btn ${saved ? 'saved' : ''}"
data-action="save"
aria-label="${saved ? 'Unsave post' : 'Save post'}"
>
${saved ? IconBookmarkFilled() : IconBookmark()}
</button>
` : ''}
</div>
<!-- PostCard__caption -->
${post.caption ? `
<div class="post-card__caption">
<p class="post-card__caption-text">
<span class="post-card__caption-username">${escHtml(user.username)}</span>${escHtml(post.caption)}
</p>
</div>
` : ''}
<!-- PostCard__comments-preview -->
${post.commentCount > 0 && features.comments ? `
<div class="post-card__comments-preview">
<button class="post-card__view-comments" data-action="comment">
View all ${formatCount(post.commentCount)} comments
</button>
</div>
` : ''}
`;
// ── Event Delegation ──
el.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
switch (btn.dataset.action) {
case 'like': await handleLike(el, post); break;
case 'comment': handleComment(post.id); break;
case 'save': await handleSave(el, post); break;
case 'more': handleMore(post, user); break;
}
});
return el;
}
// ── Handlers ──
async function handleLike(el, post) {
const liked = store.get('liked') ?? {};
const isLiked = liked[post.id] ?? false;
const likeBtn = el.querySelector('[data-action="like"]');
const countEl = el.querySelector('.post-card__like-count');
// Optimistic update
const newLiked = !isLiked;
store.update('liked', prev => ({ ...prev, [post.id]: newLiked }));
likeBtn?.classList.toggle('liked', newLiked);
likeBtn?.setAttribute('aria-pressed', String(newLiked));
if (likeBtn) likeBtn.innerHTML = (newLiked ? IconHeartFilled() : IconHeart()) +
`<span class="post-card__like-count">${formatCount(post.likeCount + (newLiked ? 1 : -1))}</span>`;
if (newLiked) likeBtn?.classList.add('animate-heartPop');
setTimeout(() => likeBtn?.classList.remove('animate-heartPop'), 600);
try {
if (newLiked) await api.likePost(post.id);
else await api.unlikePost(post.id);
} catch {
// Revert on failure
store.update('liked', prev => ({ ...prev, [post.id]: isLiked }));
likeBtn?.classList.toggle('liked', isLiked);
showToast('Could not update like. Try again.');
}
}
async function handleSave(el, post) {
const saved = store.get('saved') ?? {};
const isSaved = saved[post.id] ?? false;
const saveBtn = el.querySelector('[data-action="save"]');
store.update('saved', prev => ({ ...prev, [post.id]: !isSaved }));
saveBtn?.classList.toggle('saved', !isSaved);
showToast(!isSaved ? 'Saved to collection' : 'Removed from collection');
}
function handleComment(postId) {
const ui = store.get('ui');
store.set('ui', { ...ui, commentModal: postId });
}
function handleMore(post, user) {
document.dispatchEvent(new CustomEvent('gg:post:more', { detail: { post, user } }));
}
// ── Helpers ──
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatRelativeTime(timestamp) {
const diff = (Date.now() - new Date(timestamp)) / 1000;
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff/60)}m`;
if (diff < 86400) return `${Math.floor(diff/3600)}h`;
if (diff < 604800)return `${Math.floor(diff/86400)}d`;
return new Date(timestamp).toLocaleDateString();
}
function formatCount(n = 0) {
if (n >= 1_000_000) return `${(n/1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n/1_000).toFixed(1)}K`;
return String(n);
}
// ── SVG Icons ──
function IconHeart() { return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`; }
function IconHeartFilled() { return `<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`; }
function IconComment() { return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`; }
function IconBookmark() { return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>`; }
function IconBookmarkFilled(){ return `<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>`; }
function IconMore() { return `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>`; }