Shinhati2023 commited on
Commit
542ded2
·
verified ·
1 Parent(s): 177c3f4

Create components/postcards.js

Browse files
Files changed (1) hide show
  1. 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, '&amp;')
188
+ .replace(/</g, '&lt;')
189
+ .replace(/>/g, '&gt;')
190
+ .replace(/"/g, '&quot;')
191
+ .replace(/'/g, '&#39;');
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>`; }