/** * user-profile.ts — Multi-domain MuseHub profile page. * * Renders: hero, domain stats bar, multi-domain heatmap, pinned repos, * achievements, and tabbed repo/followers/activity sections. * * All data fetched client-side from: * - /api/users/{username} → ProfileData * - /{username}?format=json → EnhancedData (badges, heatmap, domain_stats) */ export interface UserProfileData { page?: string; username?: string; [key: string]: unknown; } // ── Utilities ───────────────────────────────────────────────────────────────── function esc(s: unknown): string { if (!s && s !== 0) return ''; return String(s).replace(/&/g,'&').replace(//g,'>'); } function $(id: string): HTMLElement | null { return document.getElementById(id); } function timeAgo(ts: string | null | undefined): string { if (!ts) return ''; const s = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); if (s < 60) return 'just now'; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`; return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } // Domain viewer type → accent color function domainColor(viewerType: string): string { if (viewerType === 'symbol_graph') return '#58a6ff'; // code — blue if (viewerType === 'piano_roll') return '#bc8cff'; // MIDI — purple return '#8b949e'; // generic } function domainIcon(viewerType: string): string { if (viewerType === 'symbol_graph') return '⬡'; if (viewerType === 'piano_roll') return '♪'; return '◈'; } function domainLabel(viewerType: string, name: string): string { if (name && name !== 'Unknown') return name; if (viewerType === 'symbol_graph') return 'Code'; if (viewerType === 'piano_roll') return 'MIDI'; return 'Generic'; } // Derive avatar background from username hash function avatarColor(username: string): string { const COLORS = ['#1f6feb','#238636','#da3633','#9e6a03','#7d3a8a','#1a7f74','#cf5c37','#206c8f']; let h = 0; for (let i = 0; i < username.length; i++) h = (h * 31 + username.charCodeAt(i)) | 0; return COLORS[Math.abs(h) % COLORS.length]; } // ── Types ───────────────────────────────────────────────────────────────────── interface HeatmapDay { date: string; count: number; intensity: number; domainCounts?: Record; dominantDomain?: string | null; } interface HeatmapStats { days: HeatmapDay[]; totalContributions: number; longestStreak: number; currentStreak: number; } interface DomainStat { domainId: string | null; domainName: string; scopedId: string | null; viewerType: string; repoCount: number; commitCount: number; } interface Badge { id: string; name: string; description: string; icon: string; earned: boolean; } interface PinnedRepo { owner: string; slug: string; name: string; description?: string; forkCount?: number; commitCount?: number; domainId?: string; domainName?: string; domainViewerType?: string; language?: string; primaryGenre?: string; tags?: string[]; } interface ProfileData { username: string; displayName?: string; bio?: string; location?: string; websiteUrl?: string; avatarUrl?: string; avatarColor?: string; followersCount?: number; followingCount?: number; publicReposCount?: number; createdAt?: string; isFollowing?: boolean; isVerified?: boolean; repos?: RepoData[]; } interface EnhancedData { heatmap?: HeatmapStats; domainStats?: DomainStat[]; badges?: Badge[]; pinnedRepos?: PinnedRepo[]; } interface RepoData { owner: string; slug: string; name: string; description?: string; primaryGenre?: string; language?: string; forksCount?: number; updatedAt?: string; isPrivate?: boolean; domainId?: string; domainViewerType?: string; domainName?: string; } // ── Module state ────────────────────────────────────────────────────────────── let _username = ''; let _cachedRepos: RepoData[] = []; let _domainStats: DomainStat[] = []; let _currentTab = 'repos'; // ── Hero section ────────────────────────────────────────────────────────────── function renderHero(profile: ProfileData, enhanced: EnhancedData): void { const color = profile.avatarColor || avatarColor(profile.username); // Avatar const avatarEl = $('prof-avatar'); const glowEl = $('prof-avatar-glow'); const initialEl = $('prof-avatar-initial'); if (avatarEl) { if (profile.avatarUrl) { avatarEl.innerHTML = `${esc(profile.username)}`; } else { avatarEl.style.background = color; if (initialEl) initialEl.textContent = (profile.displayName || profile.username)[0].toUpperCase(); } } if (glowEl) glowEl.style.background = color; // Name const nameEl = $('prof-display-name'); const userEl = $('prof-username'); if (nameEl) nameEl.textContent = profile.displayName || profile.username; if (userEl) userEl.textContent = profile.displayName ? `@${profile.username}` : ''; const verifiedEl = $('prof-verified'); if (verifiedEl && profile.isVerified) verifiedEl.style.display = 'inline'; // Bio const bioEl = $('prof-bio'); if (bioEl && profile.bio) { bioEl.textContent = profile.bio; bioEl.style.display = 'block'; } // Meta const locationEl = $('prof-location'); if (locationEl && profile.location) { const spanEl = locationEl.querySelector('span'); if (spanEl) spanEl.textContent = profile.location; locationEl.style.display = 'inline-flex'; } const websiteEl = $('prof-website'); const websiteLinkEl = $('prof-website-link') as HTMLAnchorElement | null; if (websiteEl && websiteLinkEl && profile.websiteUrl) { websiteLinkEl.href = profile.websiteUrl; websiteLinkEl.textContent = profile.websiteUrl.replace(/^https?:\/\//, ''); websiteEl.style.display = 'inline-flex'; } // Social stats const followersCountEl = $('prof-followers-count'); const followingCountEl = $('prof-following-count'); const reposCountEl = $('prof-repos-count'); if (followersCountEl) followersCountEl.textContent = String(profile.followersCount ?? 0); if (followingCountEl) followingCountEl.textContent = String(profile.followingCount ?? 0); if (reposCountEl) reposCountEl.textContent = String(profile.publicReposCount ?? profile.repos?.length ?? 0); // Domain pills const pillsEl = $('prof-domain-pills'); if (pillsEl && enhanced.domainStats?.length) { pillsEl.innerHTML = enhanced.domainStats.map(ds => { const col = domainColor(ds.viewerType); const icon = domainIcon(ds.viewerType); const label = domainLabel(ds.viewerType, ds.domainName); const scopedId = ds.scopedId ?? `@unknown/${ds.domainId?.slice(0, 8)}`; return ` ${icon} ${esc(label)} `; }).join(''); } } // ── Domain stats bar ────────────────────────────────────────────────────────── function renderDomainBar(domainStats: DomainStat[]): void { const el = $('prof-domain-bar'); if (!el || !domainStats.length) return; const totalCommits = domainStats.reduce((s, d) => s + d.commitCount, 0) || 1; el.innerHTML = domainStats.map(ds => { const col = domainColor(ds.viewerType); const icon = domainIcon(ds.viewerType); const label = domainLabel(ds.viewerType, ds.domainName); const pct = Math.round(ds.commitCount / totalCommits * 100); const scoped = ds.scopedId ?? ds.domainId ?? ''; return `
${icon}
${esc(label)} ${scoped ? `${esc(scoped)}` : ''}
${ds.repoCount} repo${ds.repoCount !== 1 ? 's' : ''} · ${ds.commitCount.toLocaleString()} commits · ${pct}% of activity
`; }).join(''); el.style.display = 'grid'; } // ── Multi-domain heatmap ────────────────────────────────────────────────────── // Build a mapping from domain_id → color using the domain stats let _domainColorMap: Record = {}; function renderHeatmap(stats: HeatmapStats, domainStats: DomainStat[]): void { // Build domain color map _domainColorMap = {}; for (const ds of domainStats) { if (ds.domainId) _domainColorMap[ds.domainId] = domainColor(ds.viewerType); } const days = stats.days ?? []; // Group days into 7-day columns (Sun–Sat) const cols: HeatmapDay[][] = []; let col: HeatmapDay[] = []; for (const day of days) { col.push(day); if (col.length === 7) { cols.push(col); col = []; } } if (col.length) cols.push(col); // Month labels const monthsEl = $('prof-heatmap-months'); if (monthsEl) { const months: { name: string; colIdx: number }[] = []; let lastMonth = ''; cols.forEach((c, ci) => { const m = new Date(c[0]?.date + 'T00:00:00').toLocaleDateString(undefined, { month: 'short' }); if (m !== lastMonth) { months.push({ name: m, colIdx: ci }); lastMonth = m; } }); monthsEl.innerHTML = months.map(m => `${esc(m.name)}` ).join(''); } // Day labels (left side — Mon/Wed/Fri) const dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', ''].map((lbl, i) => `${lbl}` ).join(''); // Grid cells const colsHtml = cols.map(c => { const cells = c.map(d => { const bg = cellColor(d); const tip = buildTooltip(d, domainStats); return `
`; }).join(''); return `
${cells}
`; }).join(''); const gridEl = $('prof-heatmap-grid'); if (gridEl) { gridEl.innerHTML = `
${dayLabels}
${colsHtml}
`; } // Legend const legendEl = $('prof-heatmap-legend'); if (legendEl) { // Show a domain color swatch per domain const swatches = domainStats.map(ds => { const col = domainColor(ds.viewerType); const label = domainLabel(ds.viewerType, ds.domainName); return ` ${esc(label)} `; }).join(''); legendEl.innerHTML = swatches; } // Stats bar const statsEl = $('prof-heatmap-stats'); if (statsEl) { statsEl.innerHTML = ` ${stats.totalContributions.toLocaleString()} contributions in the last year · 🔥 Longest streak: ${stats.longestStreak} days · Current streak: ${stats.currentStreak} days `; } } function cellColor(d: HeatmapDay): string { if (d.count === 0) return 'var(--bg-overlay)'; // Color by dominant domain const domColor = d.dominantDomain ? (_domainColorMap[d.dominantDomain] ?? '#39d353') : '#39d353'; // Vary intensity const intensities = ['', '33', '66', 'bb', 'ff']; const alpha = intensities[Math.min(4, d.intensity + 1)] ?? 'ff'; // If it's a hex color like #58a6ff, append alpha if (domColor.startsWith('#') && domColor.length === 7) { return domColor + alpha; } return domColor; } function buildTooltip(d: HeatmapDay, domainStats: DomainStat[]): string { const dateStr = new Date(d.date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); if (d.count === 0) return `No contributions on ${dateStr}`; const dc = d.domainCounts ?? {}; const parts = Object.entries(dc).map(([did, cnt]) => { const ds = domainStats.find(s => s.domainId === did); const label = ds ? domainLabel(ds.viewerType, ds.domainName) : 'Unknown'; return `${cnt} ${label}`; }); const detail = parts.length ? ` (${parts.join(', ')})` : ''; return `${d.count} contribution${d.count !== 1 ? 's' : ''}${detail} on ${dateStr}`; } // ── Pinned repos ────────────────────────────────────────────────────────────── function renderPinned(pinnedRepos: PinnedRepo[]): void { const section = $('prof-pinned-section'); const grid = $('prof-pinned-grid'); const meta = $('prof-pinned-meta'); if (!section || !grid || !pinnedRepos?.length) return; if (meta) meta.textContent = `${pinnedRepos.length} of 6`; grid.innerHTML = pinnedRepos.map(r => { const col = domainColor(r.domainViewerType ?? 'generic'); const icon = domainIcon(r.domainViewerType ?? 'generic'); const label = domainLabel(r.domainViewerType ?? 'generic', r.domainName ?? ''); // Tag pills (first 4, strip prefix) const tagPills = (r.tags ?? []).slice(0, 4).map(t => { const display = t.includes(':') ? t.split(':').slice(1).join(':') : t; return `${esc(display)}`; }).join(''); return `
${icon} ${esc(label)}

${esc(r.name)}

${esc(r.description ?? '')}

${tagPills ? `
${tagPills}
` : ''}
`; }).join(''); section.style.display = ''; } // ── Achievements ────────────────────────────────────────────────────────────── const BADGE_COLORS: Record = { first_commit: '#58a6ff', century: '#f0883e', domain_explorer: '#bc8cff', polymath: '#d2a8ff', collaborator: '#3fb950', pioneer: '#2dd4bf', release_engineer:'#fbbf24', community_star: '#ff9492', }; function renderAchievements(badges: Badge[]): void { const section = $('prof-achievements-section'); const row = $('prof-achievements-row'); const metaEl = $('prof-achievements-meta'); if (!section || !row) return; const earned = badges.filter(b => b.earned).length; if (metaEl) metaEl.textContent = `${earned} / ${badges.length} unlocked`; row.innerHTML = badges.map(b => { const col = BADGE_COLORS[b.id] ?? '#8b949e'; const cls = b.earned ? 'prof-badge--earned' : 'prof-badge--locked'; return `
${esc(b.icon)}
${esc(b.name)} ${esc(b.description)}
${b.earned ? '' : '🔒'}
`; }).join(''); section.style.display = ''; } // ── Repo list tab ───────────────────────────────────────────────────────────── function renderReposTab(repos: RepoData[]): void { const el = $('prof-tab-content'); if (!el) return; if (!repos.length) { el.innerHTML = '
No repositories yet.
'; return; } el.innerHTML = repos.map(r => { const col = domainColor(r.domainViewerType ?? 'generic'); const icon = domainIcon(r.domainViewerType ?? 'generic'); const label = domainLabel(r.domainViewerType ?? 'generic', r.domainName ?? ''); return `
${esc(r.name)} ${r.isPrivate ? 'Private' : ''} ${icon} ${esc(label)}
${r.description ? `

${esc(r.description)}

` : ''}
⑂ ${r.forksCount ?? 0} ${r.updatedAt ? `Updated ${timeAgo(r.updatedAt)}` : ''}
`; }).join(''); } // ── Social tab ──────────────────────────────────────────────────────────────── async function loadSocialTab(type: 'followers' | 'following'): Promise { const el = $('prof-tab-content'); if (!el) return; el.innerHTML = `
Loading ${type}…
`; try { const url = type === 'followers' ? `/api/users/${_username}/followers-list` : `/api/users/${_username}/following-list`; const data = await fetch(url).then(r => r.json()) as Array<{ username: string; displayName?: string; bio?: string; avatarColor?: string }>; if (!data.length) { el.innerHTML = `
No ${type} yet.
`; return; } el.innerHTML = data.map(u => { const col = u.avatarColor || avatarColor(u.username); const init = (u.displayName || u.username)[0].toUpperCase(); return `
${esc(init)}
${esc(u.displayName || u.username)} @${esc(u.username)} ${u.bio ? `

${esc(u.bio)}

` : ''}
`; }).join(''); } catch { el.innerHTML = `
Failed to load ${type}.
`; } } // ── Activity tab ────────────────────────────────────────────────────────────── const EVENT_ICONS: Record = { commit_pushed:'◎', proposal_opened:'⑂', proposal_merged:'✓', proposal_closed:'✕', issue_opened:'!', issue_closed:'✓', branch_created:'⑂', tag_pushed:'⬡', session_started:'▶', session_ended:'⏹', }; async function loadActivityTab(filter = 'all', page = 1): Promise { const el = $('prof-tab-content'); if (!el) return; el.innerHTML = '
Loading activity…
'; try { const data = await fetch(`/api/users/${_username}/activity?filter=${filter}&page=${page}&limit=20`) .then(r => r.json()) as { events: Array<{ type: string; timestamp: string; description?: string; repo?: string }>; total: number }; const events = data.events ?? []; if (!events.length) { el.innerHTML = '
No activity yet.
'; return; } const rows = events.map(e => { const icon = EVENT_ICONS[e.type] ?? '◈'; return `
${icon}
${esc(e.description ?? e.type)} ${timeAgo(e.timestamp)}${e.repo ? ` · ${esc(e.repo)}` : ''}
`; }).join(''); const totalPages = Math.ceil((data.total ?? 0) / 20); const pager = totalPages > 1 ? `
${page > 1 ? `` : ''} Page ${page} / ${totalPages} ${page < totalPages ? `` : ''}
` : ''; el.innerHTML = rows + pager; el.querySelectorAll('[data-apage]').forEach(btn => { btn.addEventListener('click', () => void loadActivityTab(btn.dataset.afilter ?? 'all', Number(btn.dataset.apage))); }); } catch { el.innerHTML = '
Failed to load activity.
'; } } // ── Tab switching ───────────────────────────────────────────────────────────── function switchTab(tab: string): void { _currentTab = tab; document.querySelectorAll('.prof-tab-btn').forEach(btn => { btn.classList.toggle('prof-tab-btn--active', btn.dataset.tab === tab); }); switch (tab) { case 'repos': renderReposTab(_cachedRepos); break; case 'followers': void loadSocialTab('followers'); break; case 'following': void loadSocialTab('following'); break; case 'activity': void loadActivityTab(); break; } } // ── Bootstrap ───────────────────────────────────────────────────────────────── export async function initUserProfile(data: UserProfileData): Promise { const username = data.username ?? ''; if (!username) return; _username = username; // Tab count badges const tabCountEl = $('tab-count-repos'); try { const [profileData, enhancedData] = await Promise.all([ fetch(`/api/users/${username}`).then(r => { if (!r.ok) throw new Error(r.status.toString()); return r.json(); }) as Promise, fetch(`/${username}?format=json`).then(r => { if (!r.ok) throw new Error(r.status.toString()); return r.json(); }) as Promise, ]); _domainStats = enhancedData.domainStats ?? []; _cachedRepos = profileData.repos ?? []; // Render sections renderHero(profileData, enhancedData); renderDomainBar(_domainStats); renderHeatmap( enhancedData.heatmap ?? { days: [], totalContributions: 0, longestStreak: 0, currentStreak: 0 }, _domainStats, ); renderPinned(enhancedData.pinnedRepos ?? []); renderAchievements(enhancedData.badges ?? []); // Tabs const tabsEl = $('prof-tabs'); if (tabsEl) { tabsEl.style.display = ''; tabsEl.querySelectorAll('.prof-tab-btn').forEach(btn => { btn.addEventListener('click', () => switchTab(btn.dataset.tab ?? 'repos')); }); // Wire follower/following links to tabs $('prof-followers')?.addEventListener('click', e => { e.preventDefault(); switchTab('followers'); tabsEl.scrollIntoView({ behavior: 'smooth' }); }); $('prof-following')?.addEventListener('click', e => { e.preventDefault(); switchTab('following'); tabsEl.scrollIntoView({ behavior: 'smooth' }); }); } if (tabCountEl && _cachedRepos.length) tabCountEl.textContent = String(_cachedRepos.length); renderReposTab(_cachedRepos); } catch (err) { const heroEl = $('prof-hero'); if (heroEl) heroEl.innerHTML = `
✕ Could not load profile for @${esc(username)}: ${esc(err instanceof Error ? err.message : String(err))}
`; } }