user-profile.ts
typescript
| 1 | /** |
| 2 | * user-profile.ts — Multi-domain MuseHub profile page. |
| 3 | * |
| 4 | * Renders: hero, domain stats bar, multi-domain heatmap, pinned repos, |
| 5 | * achievements, and tabbed repo/stars/followers/activity sections. |
| 6 | * |
| 7 | * All data fetched client-side from: |
| 8 | * - /api/v1/users/{username} → ProfileData |
| 9 | * - /{username}?format=json → EnhancedData (badges, heatmap, domain_stats) |
| 10 | */ |
| 11 | |
| 12 | export interface UserProfileData { page?: string; username?: string; [key: string]: unknown; } |
| 13 | |
| 14 | // ── Utilities ───────────────────────────────────────────────────────────────── |
| 15 | |
| 16 | function esc(s: unknown): string { |
| 17 | if (!s && s !== 0) return ''; |
| 18 | return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); |
| 19 | } |
| 20 | function $(id: string): HTMLElement | null { return document.getElementById(id); } |
| 21 | |
| 22 | function timeAgo(ts: string | null | undefined): string { |
| 23 | if (!ts) return ''; |
| 24 | const s = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); |
| 25 | if (s < 60) return 'just now'; |
| 26 | if (s < 3600) return `${Math.floor(s / 60)}m ago`; |
| 27 | if (s < 86400) return `${Math.floor(s / 3600)}h ago`; |
| 28 | if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`; |
| 29 | return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); |
| 30 | } |
| 31 | |
| 32 | // Domain viewer type → accent color |
| 33 | function domainColor(viewerType: string): string { |
| 34 | if (viewerType === 'symbol_graph') return '#58a6ff'; // code — blue |
| 35 | if (viewerType === 'piano_roll') return '#bc8cff'; // MIDI — purple |
| 36 | return '#8b949e'; // generic |
| 37 | } |
| 38 | function domainIcon(viewerType: string): string { |
| 39 | if (viewerType === 'symbol_graph') return '⬡'; |
| 40 | if (viewerType === 'piano_roll') return '♪'; |
| 41 | return '◈'; |
| 42 | } |
| 43 | function domainLabel(viewerType: string, name: string): string { |
| 44 | if (name && name !== 'Unknown') return name; |
| 45 | if (viewerType === 'symbol_graph') return 'Code'; |
| 46 | if (viewerType === 'piano_roll') return 'MIDI'; |
| 47 | return 'Generic'; |
| 48 | } |
| 49 | |
| 50 | // Derive avatar background from username hash |
| 51 | function avatarColor(username: string): string { |
| 52 | const COLORS = ['#1f6feb','#238636','#da3633','#9e6a03','#7d3a8a','#1a7f74','#cf5c37','#206c8f']; |
| 53 | let h = 0; |
| 54 | for (let i = 0; i < username.length; i++) h = (h * 31 + username.charCodeAt(i)) | 0; |
| 55 | return COLORS[Math.abs(h) % COLORS.length]; |
| 56 | } |
| 57 | |
| 58 | // ── Types ───────────────────────────────────────────────────────────────────── |
| 59 | |
| 60 | interface HeatmapDay { |
| 61 | date: string; count: number; intensity: number; |
| 62 | domainCounts?: Record<string, number>; |
| 63 | dominantDomain?: string | null; |
| 64 | } |
| 65 | interface HeatmapStats { |
| 66 | days: HeatmapDay[]; |
| 67 | totalContributions: number; |
| 68 | longestStreak: number; |
| 69 | currentStreak: number; |
| 70 | } |
| 71 | interface DomainStat { |
| 72 | domainId: string | null; |
| 73 | domainName: string; |
| 74 | scopedId: string | null; |
| 75 | viewerType: string; |
| 76 | repoCount: number; |
| 77 | commitCount: number; |
| 78 | } |
| 79 | interface Badge { id: string; name: string; description: string; icon: string; earned: boolean; } |
| 80 | interface PinnedRepo { |
| 81 | owner: string; slug: string; name: string; description?: string; |
| 82 | starCount?: number; forkCount?: number; commitCount?: number; |
| 83 | domainId?: string; domainName?: string; domainViewerType?: string; |
| 84 | language?: string; primaryGenre?: string; tags?: string[]; |
| 85 | } |
| 86 | interface ProfileData { |
| 87 | username: string; displayName?: string; bio?: string; location?: string; |
| 88 | websiteUrl?: string; avatarUrl?: string; avatarColor?: string; |
| 89 | followersCount?: number; followingCount?: number; starsCount?: number; |
| 90 | publicReposCount?: number; createdAt?: string; isFollowing?: boolean; |
| 91 | repos?: RepoData[]; |
| 92 | } |
| 93 | interface EnhancedData { |
| 94 | heatmap?: HeatmapStats; |
| 95 | domainStats?: DomainStat[]; |
| 96 | badges?: Badge[]; |
| 97 | pinnedRepos?: PinnedRepo[]; |
| 98 | } |
| 99 | interface RepoData { |
| 100 | owner: string; slug: string; name: string; description?: string; |
| 101 | primaryGenre?: string; language?: string; |
| 102 | starsCount?: number; forksCount?: number; updatedAt?: string; |
| 103 | isPrivate?: boolean; |
| 104 | domainId?: string; domainViewerType?: string; domainName?: string; |
| 105 | } |
| 106 | |
| 107 | // ── Module state ────────────────────────────────────────────────────────────── |
| 108 | |
| 109 | let _username = ''; |
| 110 | let _cachedRepos: RepoData[] = []; |
| 111 | let _domainStats: DomainStat[] = []; |
| 112 | let _currentTab = 'repos'; |
| 113 | |
| 114 | // ── Hero section ────────────────────────────────────────────────────────────── |
| 115 | |
| 116 | function renderHero(profile: ProfileData, enhanced: EnhancedData): void { |
| 117 | const color = profile.avatarColor || avatarColor(profile.username); |
| 118 | |
| 119 | // Avatar |
| 120 | const avatarEl = $('prof-avatar'); |
| 121 | const glowEl = $('prof-avatar-glow'); |
| 122 | const initialEl = $('prof-avatar-initial'); |
| 123 | if (avatarEl) { |
| 124 | if (profile.avatarUrl) { |
| 125 | avatarEl.innerHTML = `<img src="${esc(profile.avatarUrl)}" alt="${esc(profile.username)}" />`; |
| 126 | } else { |
| 127 | avatarEl.style.background = color; |
| 128 | if (initialEl) initialEl.textContent = (profile.displayName || profile.username)[0].toUpperCase(); |
| 129 | } |
| 130 | } |
| 131 | if (glowEl) glowEl.style.background = color; |
| 132 | |
| 133 | // Name |
| 134 | const nameEl = $('prof-display-name'); |
| 135 | const userEl = $('prof-username'); |
| 136 | if (nameEl) nameEl.textContent = profile.displayName || profile.username; |
| 137 | if (userEl) userEl.textContent = profile.displayName ? `@${profile.username}` : ''; |
| 138 | |
| 139 | const verifiedEl = $('prof-verified'); |
| 140 | if (verifiedEl && (profile as any).isVerified) verifiedEl.style.display = 'inline'; |
| 141 | |
| 142 | // Bio |
| 143 | const bioEl = $('prof-bio'); |
| 144 | if (bioEl && profile.bio) { bioEl.textContent = profile.bio; bioEl.style.display = 'block'; } |
| 145 | |
| 146 | // Meta |
| 147 | const locationEl = $('prof-location'); |
| 148 | if (locationEl && profile.location) { |
| 149 | locationEl.querySelector('span')!.textContent = profile.location; |
| 150 | locationEl.style.display = 'inline-flex'; |
| 151 | } |
| 152 | const websiteEl = $('prof-website'); |
| 153 | const websiteLinkEl = $('prof-website-link') as HTMLAnchorElement | null; |
| 154 | if (websiteEl && websiteLinkEl && profile.websiteUrl) { |
| 155 | websiteLinkEl.href = profile.websiteUrl; |
| 156 | websiteLinkEl.textContent = profile.websiteUrl.replace(/^https?:\/\//, ''); |
| 157 | websiteEl.style.display = 'inline-flex'; |
| 158 | } |
| 159 | |
| 160 | // Social stats |
| 161 | const followersCountEl = $('prof-followers-count'); |
| 162 | const followingCountEl = $('prof-following-count'); |
| 163 | const reposCountEl = $('prof-repos-count'); |
| 164 | if (followersCountEl) followersCountEl.textContent = String(profile.followersCount ?? 0); |
| 165 | if (followingCountEl) followingCountEl.textContent = String(profile.followingCount ?? 0); |
| 166 | if (reposCountEl) reposCountEl.textContent = String(profile.publicReposCount ?? profile.repos?.length ?? 0); |
| 167 | |
| 168 | // Domain pills |
| 169 | const pillsEl = $('prof-domain-pills'); |
| 170 | if (pillsEl && enhanced.domainStats?.length) { |
| 171 | pillsEl.innerHTML = enhanced.domainStats.map(ds => { |
| 172 | const col = domainColor(ds.viewerType); |
| 173 | const icon = domainIcon(ds.viewerType); |
| 174 | const label = domainLabel(ds.viewerType, ds.domainName); |
| 175 | const scopedId = ds.scopedId ?? `@unknown/${ds.domainId?.slice(0, 8)}`; |
| 176 | return `<a class="prof-domain-pill" href="/domains/${esc(scopedId)}" |
| 177 | style="--dpill-color:${col}" title="${esc(scopedId)}"> |
| 178 | <span class="prof-domain-pill__icon">${icon}</span> |
| 179 | <span class="prof-domain-pill__name">${esc(label)}</span> |
| 180 | </a>`; |
| 181 | }).join(''); |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | // ── Domain stats bar ────────────────────────────────────────────────────────── |
| 186 | |
| 187 | function renderDomainBar(domainStats: DomainStat[]): void { |
| 188 | const el = $('prof-domain-bar'); |
| 189 | if (!el || !domainStats.length) return; |
| 190 | const totalCommits = domainStats.reduce((s, d) => s + d.commitCount, 0) || 1; |
| 191 | el.innerHTML = domainStats.map(ds => { |
| 192 | const col = domainColor(ds.viewerType); |
| 193 | const icon = domainIcon(ds.viewerType); |
| 194 | const label = domainLabel(ds.viewerType, ds.domainName); |
| 195 | const pct = Math.round(ds.commitCount / totalCommits * 100); |
| 196 | const scoped = ds.scopedId ?? ds.domainId ?? ''; |
| 197 | return `<div class="prof-dstat-card" style="--dstat-color:${col}"> |
| 198 | <div class="prof-dstat-icon">${icon}</div> |
| 199 | <div class="prof-dstat-body"> |
| 200 | <div class="prof-dstat-name"> |
| 201 | <span class="prof-dstat-label">${esc(label)}</span> |
| 202 | ${scoped ? `<code class="prof-dstat-scoped">${esc(scoped)}</code>` : ''} |
| 203 | </div> |
| 204 | <div class="prof-dstat-nums"> |
| 205 | <span><strong>${ds.repoCount}</strong> repo${ds.repoCount !== 1 ? 's' : ''}</span> |
| 206 | <span class="prof-dstat-dot">·</span> |
| 207 | <span><strong>${ds.commitCount.toLocaleString()}</strong> commits</span> |
| 208 | <span class="prof-dstat-dot">·</span> |
| 209 | <span class="prof-dstat-pct">${pct}% of activity</span> |
| 210 | </div> |
| 211 | <div class="prof-dstat-bar-track"> |
| 212 | <div class="prof-dstat-bar" style="width:${pct}%;background:${col}"></div> |
| 213 | </div> |
| 214 | </div> |
| 215 | </div>`; |
| 216 | }).join(''); |
| 217 | el.style.display = 'grid'; |
| 218 | } |
| 219 | |
| 220 | // ── Multi-domain heatmap ────────────────────────────────────────────────────── |
| 221 | |
| 222 | // Build a mapping from domain_id → color using the domain stats |
| 223 | let _domainColorMap: Record<string, string> = {}; |
| 224 | |
| 225 | function renderHeatmap(stats: HeatmapStats, domainStats: DomainStat[]): void { |
| 226 | // Build domain color map |
| 227 | _domainColorMap = {}; |
| 228 | for (const ds of domainStats) { |
| 229 | if (ds.domainId) _domainColorMap[ds.domainId] = domainColor(ds.viewerType); |
| 230 | } |
| 231 | |
| 232 | const days = stats.days ?? []; |
| 233 | |
| 234 | // Group days into 7-day columns (Sun–Sat) |
| 235 | const cols: HeatmapDay[][] = []; |
| 236 | let col: HeatmapDay[] = []; |
| 237 | for (const day of days) { |
| 238 | col.push(day); |
| 239 | if (col.length === 7) { cols.push(col); col = []; } |
| 240 | } |
| 241 | if (col.length) cols.push(col); |
| 242 | |
| 243 | // Month labels |
| 244 | const monthsEl = $('prof-heatmap-months'); |
| 245 | if (monthsEl) { |
| 246 | const months: { name: string; colIdx: number }[] = []; |
| 247 | let lastMonth = ''; |
| 248 | cols.forEach((c, ci) => { |
| 249 | const m = new Date(c[0]?.date + 'T00:00:00').toLocaleDateString(undefined, { month: 'short' }); |
| 250 | if (m !== lastMonth) { months.push({ name: m, colIdx: ci }); lastMonth = m; } |
| 251 | }); |
| 252 | monthsEl.innerHTML = months.map(m => |
| 253 | `<span class="prof-heatmap-month" style="left:${m.colIdx * 14}px">${esc(m.name)}</span>` |
| 254 | ).join(''); |
| 255 | } |
| 256 | |
| 257 | // Day labels (left side — Mon/Wed/Fri) |
| 258 | const dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', ''].map((lbl, i) => |
| 259 | `<span class="prof-heatmap-daylbl">${lbl}</span>` |
| 260 | ).join(''); |
| 261 | |
| 262 | // Grid cells |
| 263 | const colsHtml = cols.map(c => { |
| 264 | const cells = c.map(d => { |
| 265 | const bg = cellColor(d); |
| 266 | const tip = buildTooltip(d, domainStats); |
| 267 | return `<div class="prof-heatmap-cell" style="background:${bg}" title="${esc(tip)}" data-date="${esc(d.date)}" data-count="${d.count}"></div>`; |
| 268 | }).join(''); |
| 269 | return `<div class="prof-heatmap-col">${cells}</div>`; |
| 270 | }).join(''); |
| 271 | |
| 272 | const gridEl = $('prof-heatmap-grid'); |
| 273 | if (gridEl) { |
| 274 | gridEl.innerHTML = `<div class="prof-heatmap-days">${dayLabels}</div><div class="prof-heatmap-cols">${colsHtml}</div>`; |
| 275 | } |
| 276 | |
| 277 | // Legend |
| 278 | const legendEl = $('prof-heatmap-legend'); |
| 279 | if (legendEl) { |
| 280 | // Show a domain color swatch per domain |
| 281 | const swatches = domainStats.map(ds => { |
| 282 | const col = domainColor(ds.viewerType); |
| 283 | const label = domainLabel(ds.viewerType, ds.domainName); |
| 284 | return `<span class="prof-heatmap-legend-item"> |
| 285 | <span class="prof-heatmap-swatch" style="background:${col}"></span> |
| 286 | <span>${esc(label)}</span> |
| 287 | </span>`; |
| 288 | }).join(''); |
| 289 | legendEl.innerHTML = swatches; |
| 290 | } |
| 291 | |
| 292 | // Stats bar |
| 293 | const statsEl = $('prof-heatmap-stats'); |
| 294 | if (statsEl) { |
| 295 | statsEl.innerHTML = ` |
| 296 | <span><strong>${stats.totalContributions.toLocaleString()}</strong> contributions in the last year</span> |
| 297 | <span class="prof-hm-stat-dot">·</span> |
| 298 | <span>🔥 Longest streak: <strong>${stats.longestStreak}</strong> days</span> |
| 299 | <span class="prof-hm-stat-dot">·</span> |
| 300 | <span>Current streak: <strong>${stats.currentStreak}</strong> days</span> |
| 301 | `; |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | function cellColor(d: HeatmapDay): string { |
| 306 | if (d.count === 0) return 'var(--bg-overlay)'; |
| 307 | // Color by dominant domain |
| 308 | const domColor = d.dominantDomain ? (_domainColorMap[d.dominantDomain] ?? '#39d353') : '#39d353'; |
| 309 | // Vary intensity |
| 310 | const intensities = ['', '33', '66', 'bb', 'ff']; |
| 311 | const alpha = intensities[Math.min(4, d.intensity + 1)] ?? 'ff'; |
| 312 | // If it's a hex color like #58a6ff, append alpha |
| 313 | if (domColor.startsWith('#') && domColor.length === 7) { |
| 314 | return domColor + alpha; |
| 315 | } |
| 316 | return domColor; |
| 317 | } |
| 318 | |
| 319 | function buildTooltip(d: HeatmapDay, domainStats: DomainStat[]): string { |
| 320 | const dateStr = new Date(d.date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); |
| 321 | if (d.count === 0) return `No contributions on ${dateStr}`; |
| 322 | const dc = d.domainCounts ?? {}; |
| 323 | const parts = Object.entries(dc).map(([did, cnt]) => { |
| 324 | const ds = domainStats.find(s => s.domainId === did); |
| 325 | const label = ds ? domainLabel(ds.viewerType, ds.domainName) : 'Unknown'; |
| 326 | return `${cnt} ${label}`; |
| 327 | }); |
| 328 | const detail = parts.length ? ` (${parts.join(', ')})` : ''; |
| 329 | return `${d.count} contribution${d.count !== 1 ? 's' : ''}${detail} on ${dateStr}`; |
| 330 | } |
| 331 | |
| 332 | // ── Pinned repos ────────────────────────────────────────────────────────────── |
| 333 | |
| 334 | function renderPinned(pinnedRepos: PinnedRepo[]): void { |
| 335 | const section = $('prof-pinned-section'); |
| 336 | const grid = $('prof-pinned-grid'); |
| 337 | const meta = $('prof-pinned-meta'); |
| 338 | if (!section || !grid || !pinnedRepos?.length) return; |
| 339 | |
| 340 | if (meta) meta.textContent = `${pinnedRepos.length} of 6`; |
| 341 | |
| 342 | grid.innerHTML = pinnedRepos.map(r => { |
| 343 | const col = domainColor(r.domainViewerType ?? 'generic'); |
| 344 | const icon = domainIcon(r.domainViewerType ?? 'generic'); |
| 345 | const label = domainLabel(r.domainViewerType ?? 'generic', r.domainName ?? ''); |
| 346 | |
| 347 | // Tag pills (first 4, strip prefix) |
| 348 | const tagPills = (r.tags ?? []).slice(0, 4).map(t => { |
| 349 | const display = t.includes(':') ? t.split(':').slice(1).join(':') : t; |
| 350 | return `<span class="prof-repo-tag">${esc(display)}</span>`; |
| 351 | }).join(''); |
| 352 | |
| 353 | return `<a class="prof-pinned-card" href="/${esc(r.owner)}/${esc(r.slug)}" style="--card-accent:${col}"> |
| 354 | <div class="prof-pinned-card__header"> |
| 355 | <span class="prof-pinned-domain-badge" style="background:color-mix(in srgb,${col} 15%,transparent);color:${col};border-color:color-mix(in srgb,${col} 30%,transparent)"> |
| 356 | ${icon} ${esc(label)} |
| 357 | </span> |
| 358 | <span class="prof-pinned-privacy"></span> |
| 359 | </div> |
| 360 | <div class="prof-pinned-card__body"> |
| 361 | <h3 class="prof-pinned-name">${esc(r.name)}</h3> |
| 362 | <p class="prof-pinned-desc">${esc(r.description ?? '')}</p> |
| 363 | </div> |
| 364 | ${tagPills ? `<div class="prof-pinned-tags">${tagPills}</div>` : ''} |
| 365 | <div class="prof-pinned-card__footer"> |
| 366 | <span class="prof-pinned-stat" title="Stars"> |
| 367 | <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> |
| 368 | ${r.starCount ?? 0} |
| 369 | </span> |
| 370 | <span class="prof-pinned-stat" title="Forks"> |
| 371 | <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg> |
| 372 | ${r.forkCount ?? 0} |
| 373 | </span> |
| 374 | <span class="prof-pinned-stat" title="Commits"> |
| 375 | <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="3" y1="12" x2="9" y2="12"/><line x1="15" y1="12" x2="21" y2="12"/></svg> |
| 376 | ${(r.commitCount ?? 0).toLocaleString()} |
| 377 | </span> |
| 378 | <span class="prof-pinned-view-arrow">→</span> |
| 379 | </div> |
| 380 | </a>`; |
| 381 | }).join(''); |
| 382 | |
| 383 | section.style.display = ''; |
| 384 | } |
| 385 | |
| 386 | // ── Achievements ────────────────────────────────────────────────────────────── |
| 387 | |
| 388 | const BADGE_COLORS: Record<string, string> = { |
| 389 | first_commit: '#58a6ff', |
| 390 | century: '#f0883e', |
| 391 | domain_explorer: '#bc8cff', |
| 392 | polymath: '#d2a8ff', |
| 393 | collaborator: '#3fb950', |
| 394 | pioneer: '#2dd4bf', |
| 395 | release_engineer:'#fbbf24', |
| 396 | community_star: '#ff9492', |
| 397 | }; |
| 398 | |
| 399 | function renderAchievements(badges: Badge[]): void { |
| 400 | const section = $('prof-achievements-section'); |
| 401 | const row = $('prof-achievements-row'); |
| 402 | const metaEl = $('prof-achievements-meta'); |
| 403 | if (!section || !row) return; |
| 404 | |
| 405 | const earned = badges.filter(b => b.earned).length; |
| 406 | if (metaEl) metaEl.textContent = `${earned} / ${badges.length} unlocked`; |
| 407 | |
| 408 | row.innerHTML = badges.map(b => { |
| 409 | const col = BADGE_COLORS[b.id] ?? '#8b949e'; |
| 410 | const cls = b.earned ? 'prof-badge--earned' : 'prof-badge--locked'; |
| 411 | return `<div class="prof-badge ${cls}" title="${esc(b.description)}" style="--badge-color:${col}"> |
| 412 | <div class="prof-badge__icon">${esc(b.icon)}</div> |
| 413 | <div class="prof-badge__body"> |
| 414 | <span class="prof-badge__name">${esc(b.name)}</span> |
| 415 | <span class="prof-badge__desc">${esc(b.description)}</span> |
| 416 | </div> |
| 417 | ${b.earned ? '<span class="prof-badge__check">✓</span>' : '<span class="prof-badge__lock">🔒</span>'} |
| 418 | </div>`; |
| 419 | }).join(''); |
| 420 | |
| 421 | section.style.display = ''; |
| 422 | } |
| 423 | |
| 424 | // ── Repo list tab ───────────────────────────────────────────────────────────── |
| 425 | |
| 426 | function renderReposTab(repos: RepoData[]): void { |
| 427 | const el = $('prof-tab-content'); |
| 428 | if (!el) return; |
| 429 | if (!repos.length) { |
| 430 | el.innerHTML = '<div class="prof-tab-empty">No repositories yet.</div>'; |
| 431 | return; |
| 432 | } |
| 433 | el.innerHTML = repos.map(r => { |
| 434 | const col = domainColor(r.domainViewerType ?? 'generic'); |
| 435 | const icon = domainIcon(r.domainViewerType ?? 'generic'); |
| 436 | const label = domainLabel(r.domainViewerType ?? 'generic', r.domainName ?? ''); |
| 437 | return `<div class="prof-repo-row"> |
| 438 | <div class="prof-repo-row__main"> |
| 439 | <a class="prof-repo-row__name" href="/${esc(r.owner)}/${esc(r.slug)}">${esc(r.name)}</a> |
| 440 | ${r.isPrivate ? '<span class="prof-repo-private">Private</span>' : ''} |
| 441 | <span class="prof-repo-domain-pill" style="background:color-mix(in srgb,${col} 15%,transparent);color:${col}"> |
| 442 | ${icon} ${esc(label)} |
| 443 | </span> |
| 444 | </div> |
| 445 | ${r.description ? `<p class="prof-repo-row__desc">${esc(r.description)}</p>` : ''} |
| 446 | <div class="prof-repo-row__meta"> |
| 447 | <span>⭐ ${r.starsCount ?? 0}</span> |
| 448 | <span>⑂ ${r.forksCount ?? 0}</span> |
| 449 | ${r.updatedAt ? `<span>Updated ${timeAgo(r.updatedAt)}</span>` : ''} |
| 450 | </div> |
| 451 | </div>`; |
| 452 | }).join(''); |
| 453 | } |
| 454 | |
| 455 | // ── Stars tab ───────────────────────────────────────────────────────────────── |
| 456 | |
| 457 | async function loadStarsTab(): Promise<void> { |
| 458 | const el = $('prof-tab-content'); |
| 459 | if (!el) return; |
| 460 | el.innerHTML = '<div class="prof-loading">Loading starred repos…</div>'; |
| 461 | try { |
| 462 | const data = await fetch(`/api/v1/users/${_username}/starred`).then(r => r.json()) as RepoData[]; |
| 463 | if (!data.length) { el.innerHTML = '<div class="prof-tab-empty">No starred repos yet.</div>'; return; } |
| 464 | renderReposTab(data); |
| 465 | } catch { el.innerHTML = '<div class="prof-tab-error">Failed to load starred repos.</div>'; } |
| 466 | } |
| 467 | |
| 468 | // ── Social tab ──────────────────────────────────────────────────────────────── |
| 469 | |
| 470 | async function loadSocialTab(type: 'followers' | 'following'): Promise<void> { |
| 471 | const el = $('prof-tab-content'); |
| 472 | if (!el) return; |
| 473 | el.innerHTML = `<div class="prof-loading">Loading ${type}…</div>`; |
| 474 | try { |
| 475 | const url = type === 'followers' |
| 476 | ? `/api/v1/users/${_username}/followers-list` |
| 477 | : `/api/v1/users/${_username}/following-list`; |
| 478 | const data = await fetch(url).then(r => r.json()) as Array<{ username: string; displayName?: string; bio?: string; avatarColor?: string }>; |
| 479 | if (!data.length) { el.innerHTML = `<div class="prof-tab-empty">No ${type} yet.</div>`; return; } |
| 480 | el.innerHTML = data.map(u => { |
| 481 | const col = u.avatarColor || avatarColor(u.username); |
| 482 | const init = (u.displayName || u.username)[0].toUpperCase(); |
| 483 | return `<div class="prof-social-row-item"> |
| 484 | <a href="/${esc(u.username)}" class="prof-social-avatar" style="background:${col}">${esc(init)}</a> |
| 485 | <div class="prof-social-info"> |
| 486 | <a href="/${esc(u.username)}" class="prof-social-name">${esc(u.displayName || u.username)}</a> |
| 487 | <span class="prof-social-handle">@${esc(u.username)}</span> |
| 488 | ${u.bio ? `<p class="prof-social-bio">${esc(u.bio)}</p>` : ''} |
| 489 | </div> |
| 490 | </div>`; |
| 491 | }).join(''); |
| 492 | } catch { el.innerHTML = `<div class="prof-tab-error">Failed to load ${type}.</div>`; } |
| 493 | } |
| 494 | |
| 495 | // ── Activity tab ────────────────────────────────────────────────────────────── |
| 496 | |
| 497 | const EVENT_ICONS: Record<string, string> = { |
| 498 | commit_pushed:'◎', pr_opened:'⑂', pr_merged:'✓', pr_closed:'✕', |
| 499 | issue_opened:'!', issue_closed:'✓', branch_created:'⑂', tag_pushed:'⬡', |
| 500 | session_started:'▶', session_ended:'⏹', |
| 501 | }; |
| 502 | |
| 503 | async function loadActivityTab(filter = 'all', page = 1): Promise<void> { |
| 504 | const el = $('prof-tab-content'); |
| 505 | if (!el) return; |
| 506 | el.innerHTML = '<div class="prof-loading">Loading activity…</div>'; |
| 507 | try { |
| 508 | const data = await fetch(`/api/v1/users/${_username}/activity?filter=${filter}&page=${page}&limit=20`) |
| 509 | .then(r => r.json()) as { events: Array<{ type: string; timestamp: string; description?: string; repo?: string }>; total: number }; |
| 510 | const events = data.events ?? []; |
| 511 | if (!events.length) { el.innerHTML = '<div class="prof-tab-empty">No activity yet.</div>'; return; } |
| 512 | const rows = events.map(e => { |
| 513 | const icon = EVENT_ICONS[e.type] ?? '◈'; |
| 514 | return `<div class="prof-activity-row"> |
| 515 | <span class="prof-activity-icon">${icon}</span> |
| 516 | <div class="prof-activity-body"> |
| 517 | <span class="prof-activity-desc">${esc(e.description ?? e.type)}</span> |
| 518 | <span class="prof-activity-meta">${timeAgo(e.timestamp)}${e.repo ? ` · <a href="/${esc(e.repo)}">${esc(e.repo)}</a>` : ''}</span> |
| 519 | </div> |
| 520 | </div>`; |
| 521 | }).join(''); |
| 522 | const totalPages = Math.ceil((data.total ?? 0) / 20); |
| 523 | const pager = totalPages > 1 ? `<div class="prof-pager"> |
| 524 | ${page > 1 ? `<button class="btn btn-secondary btn-sm" data-apage="${page-1}" data-afilter="${filter}">← Prev</button>` : ''} |
| 525 | <span class="prof-pager-label">Page ${page} / ${totalPages}</span> |
| 526 | ${page < totalPages ? `<button class="btn btn-secondary btn-sm" data-apage="${page+1}" data-afilter="${filter}">Next →</button>` : ''} |
| 527 | </div>` : ''; |
| 528 | el.innerHTML = rows + pager; |
| 529 | el.querySelectorAll<HTMLButtonElement>('[data-apage]').forEach(btn => { |
| 530 | btn.addEventListener('click', () => void loadActivityTab(btn.dataset.afilter ?? 'all', Number(btn.dataset.apage))); |
| 531 | }); |
| 532 | } catch { el.innerHTML = '<div class="prof-tab-error">Failed to load activity.</div>'; } |
| 533 | } |
| 534 | |
| 535 | // ── Tab switching ───────────────────────────────────────────────────────────── |
| 536 | |
| 537 | function switchTab(tab: string): void { |
| 538 | _currentTab = tab; |
| 539 | document.querySelectorAll<HTMLElement>('.prof-tab-btn').forEach(btn => { |
| 540 | btn.classList.toggle('prof-tab-btn--active', btn.dataset.tab === tab); |
| 541 | }); |
| 542 | switch (tab) { |
| 543 | case 'repos': renderReposTab(_cachedRepos); break; |
| 544 | case 'stars': void loadStarsTab(); break; |
| 545 | case 'followers': void loadSocialTab('followers'); break; |
| 546 | case 'following': void loadSocialTab('following'); break; |
| 547 | case 'activity': void loadActivityTab(); break; |
| 548 | } |
| 549 | } |
| 550 | |
| 551 | // ── Bootstrap ───────────────────────────────────────────────────────────────── |
| 552 | |
| 553 | export async function initUserProfile(data: UserProfileData): Promise<void> { |
| 554 | const username = data.username ?? ''; |
| 555 | if (!username) return; |
| 556 | _username = username; |
| 557 | |
| 558 | // Tab count badges |
| 559 | const tabCountEl = $('tab-count-repos'); |
| 560 | |
| 561 | try { |
| 562 | const [profileData, enhancedData] = await Promise.all([ |
| 563 | fetch(`/api/v1/users/${username}`).then(r => { if (!r.ok) throw new Error(r.status.toString()); return r.json(); }) as Promise<ProfileData>, |
| 564 | fetch(`/${username}?format=json`).then(r => { if (!r.ok) throw new Error(r.status.toString()); return r.json(); }) as Promise<EnhancedData>, |
| 565 | ]); |
| 566 | |
| 567 | _domainStats = enhancedData.domainStats ?? []; |
| 568 | _cachedRepos = profileData.repos ?? []; |
| 569 | |
| 570 | // Render sections |
| 571 | renderHero(profileData, enhancedData); |
| 572 | renderDomainBar(_domainStats); |
| 573 | renderHeatmap( |
| 574 | enhancedData.heatmap ?? { days: [], totalContributions: 0, longestStreak: 0, currentStreak: 0 }, |
| 575 | _domainStats, |
| 576 | ); |
| 577 | renderPinned(enhancedData.pinnedRepos ?? []); |
| 578 | renderAchievements(enhancedData.badges ?? []); |
| 579 | |
| 580 | // Tabs |
| 581 | const tabsEl = $('prof-tabs'); |
| 582 | if (tabsEl) { |
| 583 | tabsEl.style.display = ''; |
| 584 | tabsEl.querySelectorAll<HTMLElement>('.prof-tab-btn').forEach(btn => { |
| 585 | btn.addEventListener('click', () => switchTab(btn.dataset.tab ?? 'repos')); |
| 586 | }); |
| 587 | // Wire follower/following links to tabs |
| 588 | $('prof-followers')?.addEventListener('click', e => { e.preventDefault(); switchTab('followers'); tabsEl.scrollIntoView({ behavior: 'smooth' }); }); |
| 589 | $('prof-following')?.addEventListener('click', e => { e.preventDefault(); switchTab('following'); tabsEl.scrollIntoView({ behavior: 'smooth' }); }); |
| 590 | } |
| 591 | |
| 592 | if (tabCountEl && _cachedRepos.length) tabCountEl.textContent = String(_cachedRepos.length); |
| 593 | renderReposTab(_cachedRepos); |
| 594 | |
| 595 | } catch (err) { |
| 596 | const heroEl = $('prof-hero'); |
| 597 | if (heroEl) heroEl.innerHTML = `<div class="prof-error">✕ Could not load profile for @${esc(username)}: ${esc(err instanceof Error ? err.message : String(err))}</div>`; |
| 598 | } |
| 599 | } |