Spaces:
Running
Running
| /* ============================================================ | |
| 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. | |
| ============================================================ */ | |
| ; | |
| 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, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| 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>`; } | |