openvibers/openvibe.shared user-card.js

user-card.js

main 15.8 KB · 282 lines · javascript

1// ═══════════════════════════════════════════════════════════════
2// OpenVibe — User Card & Context Menu
3// Right-click any username to see profile card, quick actions,
4// linked services, name effects, and moderator tools.
5// Usage: OpenVibeUserCard.init({ apiBase, token, currentUserId })
6// OpenVibeUserCard.attach(element, userId)
7// ═══════════════════════════════════════════════════════════════
8
9(function (root) {
10 'use strict';
11
12 let _config = { apiBase: 'https://openvibe.network', token: null, currentUserId: null, currentRole: 'user' };
13 let _cardEl = null;
14 let _cache = new Map();
15 const _menuItems = new Map();
16 const escapeHtml = value => String(value == null ? '' : value).replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
17
18 function injectStyles() {
19 if (document.getElementById('openvibe-usercard-styles')) return;
20 const s = document.createElement('style');
21 s.id = 'openvibe-usercard-styles';
22 s.textContent = `
23 @keyframes openvibe-card-in { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
24 .openvibe-usercard-overlay { position: fixed; inset: 0; z-index: 99998; }
25 .openvibe-usercard {
26 position: fixed; z-index: 99999;
27 width: 300px; background: var(--bg-card, #22222c);
28 border: 1px solid var(--border, #333340); border-radius: 12px;
29 box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
30 font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
31 color: var(--text-primary, #e0e0e0);
32 animation: openvibe-card-in .2s ease; overflow: hidden;
33 }
34 .openvibe-usercard-banner {
35 height: 60px; background: linear-gradient(135deg, var(--accent-dark, #a07840), var(--accent, #3b82f6));
36 position: relative;
37 }
38 .openvibe-usercard-avatar {
39 width: 56px; height: 56px; border-radius: 50%;
40 border: 3px solid var(--bg-card, #22222c);
41 position: absolute; bottom: -28px; left: 16px;
42 background: var(--bg-tertiary, #2a2a38);
43 object-fit: cover;
44 }
45 .openvibe-usercard-body { padding: 36px 16px 12px; }
46 .openvibe-usercard-name {
47 font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 6px;
48 }
49 .openvibe-usercard-name .role-badge {
50 font-size: 9px; padding: 2px 6px; border-radius: 3px;
51 font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
52 }
53 .openvibe-usercard-name .role-admin { background: rgba(231,76,60,0.15); color: #e74c3c; }
54 .openvibe-usercard-name .role-global_mod { background: rgba(46,204,113,0.15); color: #2ecc71; }
55 .openvibe-usercard-name .role-streamer { background: rgba(155,89,182,0.15); color: #9b59b6; }
56 .openvibe-usercard-username { font-size: 12px; color: var(--text-muted, #707080); margin-bottom: 8px; }
57 .openvibe-usercard-bio { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; margin-bottom: 10px; max-height: 40px; overflow: hidden; }
58 .openvibe-usercard-services { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
59 .openvibe-usercard-services .svc {
60 font-size: 10px; padding: 3px 8px; border-radius: 4px;
61 background: var(--bg-tertiary, #2a2a38); color: var(--text-secondary, #b0b0b8);
62 display: flex; align-items: center; gap: 4px;
63 }
64 .openvibe-usercard-services .svc .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success, #2ecc71); }
65 .openvibe-usercard-divider { height: 1px; background: var(--border, #333340); margin: 8px -16px; }
66 .openvibe-usercard-actions { display: flex; flex-direction: column; gap: 2px; }
67 .openvibe-usercard-actions button {
68 display: flex; align-items: center; gap: 8px;
69 width: 100%; padding: 8px 12px; background: none;
70 border: none; border-radius: 6px; color: var(--text-primary, #e0e0e0);
71 font-size: 12px; cursor: pointer; text-align: left;
72 transition: background .12s;
73 }
74 .openvibe-usercard-actions button:hover { background: var(--bg-hover, #2f2f3d); }
75 .openvibe-usercard-actions button.danger { color: var(--live-red-text, var(--live-red, #e74c3c)); }
76 .openvibe-usercard-actions button .icon { font-size: 14px; width: 20px; text-align: center; }
77
78 /* Name effects (CSS classes applied to usernames) */
79 .openvibe-name-fx-rainbow { background: linear-gradient(90deg, #ff0000, #ff7700, #ffff00, #00ff00, #0000ff, #8b00ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; background-size: 200%; animation: openvibe-rainbow 3s linear infinite; }
80 @keyframes openvibe-rainbow { to { background-position: 200% center; } }
81 .openvibe-name-fx-glow { text-shadow: 0 0 8px currentColor, 0 0 16px currentColor; }
82 .openvibe-name-fx-fire { text-shadow: 0 0 4px #ff6600, 0 -2px 8px #ff3300, 0 -4px 12px #cc0000; }
83 .openvibe-name-fx-ice { text-shadow: 0 0 6px #00ccff, 0 0 12px #0088ff; color: #aaddff; }
84 .openvibe-name-fx-gold { background: linear-gradient(180deg, #ffd700, #daa520, #ffd700); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: none; }
85 .openvibe-name-fx-glitch { animation: openvibe-glitch .1s infinite; }
86 @keyframes openvibe-glitch { 0%,100%{transform:none} 25%{transform:translate(-1px,1px)} 50%{transform:translate(1px,-1px)} 75%{transform:translate(-1px,-1px)} }
87 .openvibe-name-fx-neon { color: #39ff14; text-shadow: 0 0 5px #39ff14, 0 0 10px #39ff14, 0 0 20px #39ff14; }
88 .openvibe-name-fx-shadow { text-shadow: 2px 2px 0 rgba(0,0,0,0.5); }
89 `;
90 document.head.appendChild(s);
91 }
92
93 // ── Fetch User Data ──────────────────────────────────────
94 async function fetchUser(userId) {
95 if (_cache.has(userId)) return _cache.get(userId);
96 try {
97 const headers = {};
98 if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
99 // At most eight seconds: a click never waits on a service that is restarting (11-03).
100 const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined;
101 const res = await fetch(`${_config.apiBase}/api/users/${userId}/card`, { headers, credentials: 'include', signal });
102 if (!res.ok) return null;
103 const data = await res.json();
104 _cache.set(userId, data);
105 setTimeout(() => _cache.delete(userId), 120_000); // 2min cache
106 return data;
107 } catch { return null; }
108 }
109
110 // ── Render Card ──────────────────────────────────────────
111 function renderCard(data, x, y) {
112 closeCard();
113 const user = data.user;
114 const linked = data.linked_accounts || [];
115
116 const roleBadge = user.role !== 'user'
117 ? `<span class="role-badge role-${user.role}">${user.role.replace('_', ' ')}</span>` : '';
118
119 const nameClass = data.name_effect ? `openvibe-name-fx-${data.name_effect}` : '';
120 const nameColor = user.profile_color ? `color:${user.profile_color}` : '';
121
122 const services = linked.map(l => {
123 const icons = { openvibelive: '📡', openvibegames: '⚔️', openvibemaps: '🗺️' };
124 return `<span class="svc"><span class="dot"></span>${icons[l.service] || '🔗'} ${l.service_username || l.service}</span>`;
125 }).join('');
126
127 const isSelf = _config.currentUserId && String(_config.currentUserId) === String(user.id);
128 const isMod = ['admin', 'global_mod'].includes(_config.currentRole);
129
130 let actions = '';
131 if (!isSelf) {
132 actions += `<button data-act="profile"><span class="icon">👤</span> View Profile</button>`;
133 actions += `<button data-act="message"><span class="icon">💬</span> Send Message</button>`;
134 actions += `<button data-act="follow"><span class="icon">➕</span> Follow</button>`;
135 if (isMod) {
136 actions += `<div class="openvibe-usercard-divider"></div>`;
137 actions += `<button data-act="warn"><span class="icon">⚠️</span> Warn User</button>`;
138 actions += `<button data-act="mute"><span class="icon">🔇</span> Mute</button>`;
139 actions += `<button data-act="ban" class="danger"><span class="icon">🚫</span> Ban User</button>`;
140 }
141 } else {
142 actions += `<button data-act="profile"><span class="icon">⚙️</span> Edit Profile</button>`;
143 actions += `<button data-act="accounts"><span class="icon">🔄</span> Switch Account</button>`;
144 }
145 for (const item of _menuItems.values()) {
146 actions += `<button data-act="site" data-site-id="${escapeHtml(item.id)}"><span class="icon">${escapeHtml(item.icon || '↗')}</span> ${escapeHtml(item.label)}</button>`;
147 }
148
149 const card = document.createElement('div');
150 card.className = 'openvibe-usercard';
151
152 const avatarUrl = user.avatar_url || `${_config.apiBase}/data/avatars/default.png`;
153 card.innerHTML = `
154 <div class="openvibe-usercard-banner" style="${user.profile_color ? `background: linear-gradient(135deg, ${user.profile_color}88, ${user.profile_color})` : ''}"></div>
155 <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" width="56" height="56" decoding="async" onerror="this.style.display='none'">
156 <div class="openvibe-usercard-body">
157 <div class="openvibe-usercard-name">
158 <span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>
159 ${roleBadge}
160 </div>
161 <div class="openvibe-usercard-username">@${user.username}${data.anon_number ? ` · Anon #${data.anon_number}` : ''}</div>
162 ${user.bio ? `<div class="openvibe-usercard-bio">${user.bio}</div>` : ''}
163 ${services ? `<div class="openvibe-usercard-services">${services}</div>` : ''}
164 <div class="openvibe-usercard-divider"></div>
165 <div class="openvibe-usercard-actions">${actions}</div>
166 </div>
167 `;
168
169 // Position
170 const vw = window.innerWidth, vh = window.innerHeight;
171 card.style.left = Math.min(x, vw - 320) + 'px';
172 card.style.top = Math.min(y, vh - 400) + 'px';
173
174 // Overlay to close on click-outside
175 const overlay = document.createElement('div');
176 overlay.className = 'openvibe-usercard-overlay';
177 overlay.addEventListener('click', closeCard);
178 overlay.addEventListener('contextmenu', e => { e.preventDefault(); closeCard(); });
179
180 document.body.appendChild(overlay);
181 document.body.appendChild(card);
182 _cardEl = { card, overlay };
183
184 // Action handlers
185 card.querySelectorAll('[data-act]').forEach(btn => {
186 btn.addEventListener('click', () => {
187 const act = btn.dataset.act;
188 const siteItem = act === 'site' ? _menuItems.get(btn.dataset.siteId) : null;
189 closeCard();
190 if (siteItem) {
191 try { siteItem.onSelect(user); } catch (err) { console.error('[OpenVibeUserMenu] item failed:', err); }
192 } else if (act === 'profile') {
193 const url = isSelf ? `${_config.apiBase}/my`
194 : `${_config.apiBase}/user/${user.username}`;
195 window.open(url, '_blank');
196 } else if (act === 'message') {
197 // Dispatch custom event for the host app to handle
198 document.dispatchEvent(new CustomEvent('openvibe-user-action', {
199 detail: { action: act, user }
200 }));
201 } else {
202 document.dispatchEvent(new CustomEvent('openvibe-user-action', {
203 detail: { action: act, user }
204 }));
205 }
206 });
207 });
208 }
209
210 function closeCard() {
211 if (_cardEl) {
212 _cardEl.card.remove();
213 _cardEl.overlay.remove();
214 _cardEl = null;
215 }
216 }
217
218 // ── Public API ───────────────────────────────────────────
219 const OpenVibeUserCard = {
220 init(opts = {}) {
221 Object.assign(_config, opts);
222 injectStyles();
223 },
224
225 /** Attach right-click card to an element. */
226 attach(el, userId) {
227 el.style.cursor = 'pointer';
228 el.addEventListener('contextmenu', async e => {
229 e.preventDefault();
230 const data = await fetchUser(userId);
231 if (data) renderCard(data, e.clientX, e.clientY);
232 });
233 // Also support click on mobile
234 el.addEventListener('click', async e => {
235 if (e.ctrlKey || e.metaKey) {
236 e.preventDefault();
237 const data = await fetchUser(userId);
238 if (data) renderCard(data, e.clientX, e.clientY);
239 }
240 });
241 },
242
243 /** Programmatically show a card at position. */
244 async show(userId, x, y) {
245 const data = await fetchUser(userId);
246 if (data) renderCard(data, x, y);
247 },
248
249 close: closeCard,
250 clearCache() { _cache.clear(); },
251
252 /** Apply name effect CSS class to an element. */
253 applyNameEffect(el, effectName) {
254 if (!effectName) return;
255 el.classList.add(`openvibe-name-fx-${effectName}`);
256 },
257 };
258
259 const OpenVibeUserMenu = {
260 /** Add a host site's safe action to the shared user card. Returns an unregister function. */
261 register({ id, label, icon, onSelect } = {}) {
262 const key = String(id || '');
263 if (!/^[a-zA-Z0-9_-]{1,48}$/.test(key) || typeof label !== 'string' || !label.trim() || typeof onSelect !== 'function') {
264 throw new TypeError('register requires an id, label and onSelect function');
265 }
266 const item = { id: key, label: label.trim().slice(0, 80), icon: typeof icon === 'string' ? icon.slice(0, 8) : '', onSelect };
267 _menuItems.set(key, item);
268 return () => { if (_menuItems.get(key) === item) _menuItems.delete(key); };
269 },
270 unregister(id) { return _menuItems.delete(String(id || '')); },
271 attach: (...args) => OpenVibeUserCard.attach(...args),
272 show: (...args) => OpenVibeUserCard.show(...args),
273 close: () => OpenVibeUserCard.close(),
274 };
275
276 if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeUserCard;
277 if (typeof module !== 'undefined' && module.exports) module.exports.OpenVibeUserMenu = OpenVibeUserMenu;
278 root.OpenVibeUserCard = OpenVibeUserCard;
279 root.OpenVibeUserMenu = OpenVibeUserMenu;
280
281})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
282