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