gabriel / musehub public
user-profile.ts typescript
599 lines 27.0 KB
7f1d07e8 feat: domains, MCP expansion, MIDI player, and production hardening (#8) Gabriel Cardona <cgcardona@gmail.com> 4d ago
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 }