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