openvibers/openvibe.shared user-card.js
user-card.js
| 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 | ; |
| 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 |