Spaces:
Running
Running
Create components/postcards.js
Browse files- components/postcards.js +215 -0
components/postcards.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
GLASSGRID — POST CARD COMPONENT
|
| 3 |
+
|
| 4 |
+
⚠️ STRUCTURE IS FIXED. Do not modify class names.
|
| 5 |
+
⚠️ All visual changes through tokens/components.css only.
|
| 6 |
+
⚠️ Schema-validated post objects only. No raw HTML.
|
| 7 |
+
============================================================ */
|
| 8 |
+
|
| 9 |
+
'use strict';
|
| 10 |
+
|
| 11 |
+
import { store, showToast } from '../lib/store.js';
|
| 12 |
+
import { api } from '../lib/api.js';
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Render a post card element.
|
| 16 |
+
* @param {Object} post - Schema.Post validated object
|
| 17 |
+
* @param {Object} user - Schema.User for the post author
|
| 18 |
+
* @returns {HTMLElement}
|
| 19 |
+
*/
|
| 20 |
+
export function PostCard(post, user) {
|
| 21 |
+
const liked = store.get('liked')?.[post.id] ?? false;
|
| 22 |
+
const saved = store.get('saved')?.[post.id] ?? false;
|
| 23 |
+
const config = store.get('config') ?? {};
|
| 24 |
+
const features = store.get('features') ?? {};
|
| 25 |
+
|
| 26 |
+
const el = document.createElement('article');
|
| 27 |
+
el.className = 'post-card glass animate-fadeInUp';
|
| 28 |
+
el.dataset.postId = post.id;
|
| 29 |
+
el.setAttribute('role', 'article');
|
| 30 |
+
|
| 31 |
+
el.innerHTML = `
|
| 32 |
+
<!-- PostCard__header -->
|
| 33 |
+
<div class="post-card__header">
|
| 34 |
+
<div class="avatar avatar--md ${user.hasStory ? 'avatar--ring gradient-border' : ''}">
|
| 35 |
+
<img
|
| 36 |
+
src="${escHtml(user.avatarUrl || '/assets/images/default-avatar.png')}"
|
| 37 |
+
alt="${escHtml(user.displayName)}'s avatar"
|
| 38 |
+
class="avatar__img"
|
| 39 |
+
loading="lazy"
|
| 40 |
+
width="40" height="40"
|
| 41 |
+
/>
|
| 42 |
+
</div>
|
| 43 |
+
<div style="flex:1;min-width:0;">
|
| 44 |
+
<div class="post-card__username">${escHtml(user.displayName)}</div>
|
| 45 |
+
<div class="post-card__handle">
|
| 46 |
+
@${escHtml(user.username)}
|
| 47 |
+
${post.location ? `<span class="post-card__handle"> · ${escHtml(post.location)}</span>` : ''}
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
<span class="post-card__timestamp">${formatRelativeTime(post.createdAt)}</span>
|
| 51 |
+
<button class="post-card__more-btn" aria-label="More options" data-action="more">
|
| 52 |
+
${IconMore()}
|
| 53 |
+
</button>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<!-- PostCard__media -->
|
| 57 |
+
<div class="post-card__media post-card__media--${post.aspectRatio}">
|
| 58 |
+
<img
|
| 59 |
+
src="${escHtml(post.mediaUrl)}"
|
| 60 |
+
alt="${escHtml(post.altText || post.caption || 'Post image')}"
|
| 61 |
+
loading="lazy"
|
| 62 |
+
decoding="async"
|
| 63 |
+
/>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<!-- PostCard__actions -->
|
| 67 |
+
<div class="post-card__actions" ${!features.likes && !features.comments ? 'style="display:none"' : ''}>
|
| 68 |
+
${features.likes ? `
|
| 69 |
+
<button
|
| 70 |
+
class="post-card__like-btn ${liked ? 'liked' : ''}"
|
| 71 |
+
data-action="like"
|
| 72 |
+
aria-label="${liked ? 'Unlike post' : 'Like post'}"
|
| 73 |
+
aria-pressed="${liked}"
|
| 74 |
+
>
|
| 75 |
+
${liked ? IconHeartFilled() : IconHeart()}
|
| 76 |
+
<span class="post-card__like-count">${formatCount(post.likeCount)}</span>
|
| 77 |
+
</button>
|
| 78 |
+
` : ''}
|
| 79 |
+
|
| 80 |
+
${features.comments ? `
|
| 81 |
+
<button class="post-card__comment-btn" data-action="comment" aria-label="Comment on post">
|
| 82 |
+
${IconComment()}
|
| 83 |
+
<span>${formatCount(post.commentCount)}</span>
|
| 84 |
+
</button>
|
| 85 |
+
` : ''}
|
| 86 |
+
|
| 87 |
+
${features.saves ? `
|
| 88 |
+
<button
|
| 89 |
+
class="post-card__save-btn ${saved ? 'saved' : ''}"
|
| 90 |
+
data-action="save"
|
| 91 |
+
aria-label="${saved ? 'Unsave post' : 'Save post'}"
|
| 92 |
+
>
|
| 93 |
+
${saved ? IconBookmarkFilled() : IconBookmark()}
|
| 94 |
+
</button>
|
| 95 |
+
` : ''}
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<!-- PostCard__caption -->
|
| 99 |
+
${post.caption ? `
|
| 100 |
+
<div class="post-card__caption">
|
| 101 |
+
<p class="post-card__caption-text">
|
| 102 |
+
<span class="post-card__caption-username">${escHtml(user.username)}</span>${escHtml(post.caption)}
|
| 103 |
+
</p>
|
| 104 |
+
</div>
|
| 105 |
+
` : ''}
|
| 106 |
+
|
| 107 |
+
<!-- PostCard__comments-preview -->
|
| 108 |
+
${post.commentCount > 0 && features.comments ? `
|
| 109 |
+
<div class="post-card__comments-preview">
|
| 110 |
+
<button class="post-card__view-comments" data-action="comment">
|
| 111 |
+
View all ${formatCount(post.commentCount)} comments
|
| 112 |
+
</button>
|
| 113 |
+
</div>
|
| 114 |
+
` : ''}
|
| 115 |
+
`;
|
| 116 |
+
|
| 117 |
+
// ── Event Delegation ──
|
| 118 |
+
el.addEventListener('click', async (e) => {
|
| 119 |
+
const btn = e.target.closest('[data-action]');
|
| 120 |
+
if (!btn) return;
|
| 121 |
+
|
| 122 |
+
switch (btn.dataset.action) {
|
| 123 |
+
case 'like': await handleLike(el, post); break;
|
| 124 |
+
case 'comment': handleComment(post.id); break;
|
| 125 |
+
case 'save': await handleSave(el, post); break;
|
| 126 |
+
case 'more': handleMore(post, user); break;
|
| 127 |
+
}
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
return el;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// ── Handlers ──
|
| 134 |
+
|
| 135 |
+
async function handleLike(el, post) {
|
| 136 |
+
const liked = store.get('liked') ?? {};
|
| 137 |
+
const isLiked = liked[post.id] ?? false;
|
| 138 |
+
const likeBtn = el.querySelector('[data-action="like"]');
|
| 139 |
+
const countEl = el.querySelector('.post-card__like-count');
|
| 140 |
+
|
| 141 |
+
// Optimistic update
|
| 142 |
+
const newLiked = !isLiked;
|
| 143 |
+
store.update('liked', prev => ({ ...prev, [post.id]: newLiked }));
|
| 144 |
+
likeBtn?.classList.toggle('liked', newLiked);
|
| 145 |
+
likeBtn?.setAttribute('aria-pressed', String(newLiked));
|
| 146 |
+
if (likeBtn) likeBtn.innerHTML = (newLiked ? IconHeartFilled() : IconHeart()) +
|
| 147 |
+
`<span class="post-card__like-count">${formatCount(post.likeCount + (newLiked ? 1 : -1))}</span>`;
|
| 148 |
+
|
| 149 |
+
if (newLiked) likeBtn?.classList.add('animate-heartPop');
|
| 150 |
+
setTimeout(() => likeBtn?.classList.remove('animate-heartPop'), 600);
|
| 151 |
+
|
| 152 |
+
try {
|
| 153 |
+
if (newLiked) await api.likePost(post.id);
|
| 154 |
+
else await api.unlikePost(post.id);
|
| 155 |
+
} catch {
|
| 156 |
+
// Revert on failure
|
| 157 |
+
store.update('liked', prev => ({ ...prev, [post.id]: isLiked }));
|
| 158 |
+
likeBtn?.classList.toggle('liked', isLiked);
|
| 159 |
+
showToast('Could not update like. Try again.');
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
async function handleSave(el, post) {
|
| 164 |
+
const saved = store.get('saved') ?? {};
|
| 165 |
+
const isSaved = saved[post.id] ?? false;
|
| 166 |
+
const saveBtn = el.querySelector('[data-action="save"]');
|
| 167 |
+
|
| 168 |
+
store.update('saved', prev => ({ ...prev, [post.id]: !isSaved }));
|
| 169 |
+
saveBtn?.classList.toggle('saved', !isSaved);
|
| 170 |
+
showToast(!isSaved ? 'Saved to collection' : 'Removed from collection');
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function handleComment(postId) {
|
| 174 |
+
const ui = store.get('ui');
|
| 175 |
+
store.set('ui', { ...ui, commentModal: postId });
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function handleMore(post, user) {
|
| 179 |
+
document.dispatchEvent(new CustomEvent('gg:post:more', { detail: { post, user } }));
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// ── Helpers ──
|
| 183 |
+
|
| 184 |
+
function escHtml(str) {
|
| 185 |
+
if (!str) return '';
|
| 186 |
+
return String(str)
|
| 187 |
+
.replace(/&/g, '&')
|
| 188 |
+
.replace(/</g, '<')
|
| 189 |
+
.replace(/>/g, '>')
|
| 190 |
+
.replace(/"/g, '"')
|
| 191 |
+
.replace(/'/g, ''');
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
function formatRelativeTime(timestamp) {
|
| 195 |
+
const diff = (Date.now() - new Date(timestamp)) / 1000;
|
| 196 |
+
if (diff < 60) return 'now';
|
| 197 |
+
if (diff < 3600) return `${Math.floor(diff/60)}m`;
|
| 198 |
+
if (diff < 86400) return `${Math.floor(diff/3600)}h`;
|
| 199 |
+
if (diff < 604800)return `${Math.floor(diff/86400)}d`;
|
| 200 |
+
return new Date(timestamp).toLocaleDateString();
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
function formatCount(n = 0) {
|
| 204 |
+
if (n >= 1_000_000) return `${(n/1_000_000).toFixed(1)}M`;
|
| 205 |
+
if (n >= 1_000) return `${(n/1_000).toFixed(1)}K`;
|
| 206 |
+
return String(n);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// ── SVG Icons ──
|
| 210 |
+
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>`; }
|
| 211 |
+
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>`; }
|
| 212 |
+
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>`; }
|
| 213 |
+
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>`; }
|
| 214 |
+
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>`; }
|
| 215 |
+
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>`; }
|