/** * Domain detail view */ class DomainDetailView { constructor() { this.currentDomain = null; this.data = null; this.chart = null; } /** * Render peer with profile (avatar + display name or peer ID) * @param {string} peerId - Peer ID * @param {object|null} profile - Profile data or null * @param {number} avatarSize - Avatar size in pixels (default: 32) * @returns {string} HTML string for peer display */ renderPeerWithProfile(peerId, profile, avatarSize = 32) { const displayName = profile?.displayName || null; const avatarHash = profile?.avatarHash || null; const avatarUrl = avatarHash ? `https://global.profile/api/profile/avatar/${peerId}/${avatarSize}` : null; const peerIdShort = window.utils.formatPeerId(peerId); const avatarHtml = avatarUrl ? `${displayName || peerIdShort} ` : `
${(displayName || peerIdShort).charAt(0).toUpperCase()}
`; const nameHtml = displayName ? `${window.utils.escapeHtml(displayName)}${peerIdShort}` : `${peerIdShort}`; // Create a unique ID for the avatar container const avatarId = `peer-avatar-${peerId.replace(/[^a-zA-Z0-9]/g, '-')}`; return `
${avatarHtml} ${nameHtml}
`; } /** * Render the domain detail view */ async render(domain) { const container = document.getElementById('domainDetailContent'); if (!container) return; this.currentDomain = domain; try { // Show loading state container.innerHTML = `

Loading domain details...

`; // Fetch data const data = await window.apiClient.getDomainDetail(domain); this.data = data; // Update sidebar stats await window.utils.updateSidebarStats(); // Render the view this.renderContent(container); } catch (error) { console.error('Error loading domain details:', error); container.innerHTML = `

Error loading domain details: ${error.message}

`; } } /** * Render the content */ renderContent(container) { const { domain, consensus, claims, votes } = this.data; const status = consensus.status || 'unknown'; const statusText = window.utils.getStatusText(status); const statusClass = window.utils.getStatusBadgeClass(status); const statusColor = window.utils.getStatusColor(status); container.innerHTML = `

${window.utils.escapeHtml(domain)}

${statusText}

Total Votes

${consensus.totalVotes || 0}

Minimum Required

${consensus.minVotes || 0}

Quorum Status

${consensus.quorumMet ? 'Met' : 'Not Met'}

Active Peers

${consensus.activePeers || 0}

Quorum Progress

${consensus.totalVotes || 0} / ${consensus.minVotes || 0} votes ${window.utils.calculateQuorumPercentage(consensus.totalVotes || 0, consensus.minVotes || 1)}%
${consensus.status === 'resolved' ? `

Resolved Information

Resolved Claimant:
${this.renderPeerWithProfile(consensus.resolvedClaimant, this.data.profiles?.[consensus.resolvedClaimant] || null, 32)}
Resolved Hash: ${window.utils.formatHash(consensus.hash)}
` : ''} ${this.getVoteCounts().length > 0 ? `

Vote Distribution

` : ''}

Claims (${claims.length})

${claims.length > 0 ? `
${claims.map(claim => { const voteCount = this.getVoteCountForClaimant(claim.claimant); const isResolved = consensus.resolvedClaimant === claim.claimant; const profile = claim.profile || this.data.profiles?.[claim.claimant] || null; return ` `; }).join('')}
Claimant Hash Votes Timestamp
${this.renderPeerWithProfile(claim.claimant, profile, 32)} ${isResolved ? '(resolved)' : ''}
${window.utils.formatHash(claim.hash)}
${voteCount} ${window.utils.formatTimestamp(claim.timestamp)}
` : `

No claims found

`}

Votes (${votes.length})

${votes.length > 0 ? `
${votes.map(vote => { const isResolved = consensus.resolvedClaimant === vote.claimant; const voterProfile = vote.voterProfile || this.data.profiles?.[vote.voter] || null; const claimantProfile = vote.claimantProfile || this.data.profiles?.[vote.claimant] || null; return ` `; }).join('')}
Voter Voted For
${this.renderPeerWithProfile(vote.voter, voterProfile, 32)} ${this.renderPeerWithProfile(vote.claimant, claimantProfile, 32)} ${isResolved ? '(resolved claimant)' : ''}
` : `

No votes found

`}
`; // Render chart after a brief delay if (this.getVoteCounts().length > 0) { setTimeout(() => { this.renderVoteChart(); }, 100); } // Make all peer avatars clickable after rendering setTimeout(() => { this.makeAvatarsClickable(); }, 200); } /** * Make all peer avatars clickable to open profile modal */ makeAvatarsClickable() { if (!window.ProfileModal) { console.warn('ProfileModal not available'); return; } const container = document.getElementById('domainDetailContent'); if (!container) return; // Get all peer IDs from the data const peerIds = new Set(); // Add resolved claimant if (this.data.consensus?.resolvedClaimant) { peerIds.add(this.data.consensus.resolvedClaimant); } // Add all claimants if (this.data.claims) { this.data.claims.forEach(claim => { if (claim.claimant) peerIds.add(claim.claimant); }); } // Add all voters and voted-for claimants if (this.data.votes) { this.data.votes.forEach(vote => { if (vote.voter) peerIds.add(vote.voter); if (vote.claimant) peerIds.add(vote.claimant); }); } // Make each peer avatar clickable peerIds.forEach(peerId => { const avatarId = `peer-avatar-${peerId.replace(/[^a-zA-Z0-9]/g, '-')}`; const avatarContainer = container.querySelector(`#${avatarId}`); if (avatarContainer) { // Find the img or placeholder div const avatarImg = avatarContainer.querySelector('img'); const avatarPlaceholder = avatarContainer.querySelector('div[style*="rounded-full"]'); const clickableElement = avatarImg || avatarPlaceholder || avatarContainer; if (clickableElement) { clickableElement.style.cursor = 'pointer'; clickableElement.onclick = (e) => { e.stopPropagation(); window.ProfileModal.open(peerId); }; } } }); } /** * Get vote counts per claimant */ getVoteCounts() { if (!this.data || !this.data.consensus) return []; const voteCounts = this.data.consensus.voteCounts || {}; return Object.entries(voteCounts).map(([claimant, count]) => ({ claimant, count })).sort((a, b) => b.count - a.count); } /** * Get vote count for a specific claimant */ getVoteCountForClaimant(claimant) { const voteCounts = this.data?.consensus?.voteCounts || {}; return voteCounts[claimant] || 0; } /** * Render vote distribution chart */ renderVoteChart() { const voteCounts = this.getVoteCounts(); if (voteCounts.length === 0) return; const ctx = document.getElementById('voteChart'); if (!ctx) return; // Destroy existing chart if (this.chart) { this.chart.destroy(); this.chart = null; } const isResolved = this.data.consensus.status === 'resolved'; const resolvedClaimant = this.data.consensus.resolvedClaimant; this.chart = new Chart(ctx, { type: 'bar', data: { labels: voteCounts.map(v => window.utils.formatPeerId(v.claimant)), datasets: [{ label: 'Votes', data: voteCounts.map(v => v.count), backgroundColor: voteCounts.map(v => isResolved && v.claimant === resolvedClaimant ? '#10b981' : '#3b82f6' ) }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1, color: '#d1d5db' }, grid: { color: '#374151' } }, x: { ticks: { color: '#d1d5db' }, grid: { color: '#374151' } } } } }); } /** * Update vote distribution chart with new data */ updateChart() { if (!this.chart || !this.data) return; const voteCounts = this.getVoteCounts(); if (voteCounts.length === 0) return; const isResolved = this.data.consensus.status === 'resolved'; const resolvedClaimant = this.data.consensus.resolvedClaimant; // Update chart data this.chart.data.labels = voteCounts.map(v => window.utils.formatPeerId(v.claimant)); this.chart.data.datasets[0].data = voteCounts.map(v => v.count); this.chart.data.datasets[0].backgroundColor = voteCounts.map(v => isResolved && v.claimant === resolvedClaimant ? '#10b981' : '#3b82f6' ); // Update chart smoothly without animation flash this.chart.update('none'); // 'none' means no animation for smoother updates } /** * Update content elements without full re-render */ updateContent() { if (!this.data) return; const { consensus } = this.data; const quorumPercentage = window.utils.calculateQuorumPercentage( consensus.totalVotes || 0, consensus.minVotes || 1 ); // Update vote count displays (scope to domain detail content to avoid conflicts) const container = document.getElementById('domainDetailContent'); if (!container) return; const totalVotesEl = container.querySelector('[data-votes-total]'); const minVotesEl = container.querySelector('[data-votes-min]'); const quorumStatusEl = container.querySelector('[data-quorum-status]'); const quorumPercentageEl = container.querySelector('[data-quorum-percentage]'); const quorumVotesEl = container.querySelector('[data-quorum-votes]'); const activePeersEl = container.querySelector('[data-active-peers]'); if (totalVotesEl) totalVotesEl.textContent = consensus.totalVotes || 0; if (minVotesEl) minVotesEl.textContent = consensus.minVotes || 0; if (quorumStatusEl) { quorumStatusEl.textContent = consensus.quorumMet ? 'Met' : 'Not Met'; quorumStatusEl.className = `text-2xl font-bold ${consensus.quorumMet ? 'text-green-400' : 'text-yellow-400'}`; } if (quorumPercentageEl) quorumPercentageEl.textContent = `${quorumPercentage}%`; if (quorumVotesEl) quorumVotesEl.textContent = `${consensus.totalVotes || 0} / ${consensus.minVotes || 0} votes`; if (activePeersEl) activePeersEl.textContent = consensus.activePeers || 0; // Update quorum progress bar const progressBar = container.querySelector('.quorum-progress-bar'); if (progressBar) { progressBar.style.width = `${Math.min(100, quorumPercentage)}%`; progressBar.className = `h-4 rounded-full transition-all quorum-progress-bar ${ consensus.quorumMet ? 'bg-green-500' : 'bg-yellow-500' }`; } // Update resolved information if status changed to resolved if (consensus.status === 'resolved') { const resolvedSection = container.querySelector('#resolved-info-section'); const resolvedClaimantEl = container.querySelector('[data-resolved-claimant]'); const resolvedHashEl = container.querySelector('[data-resolved-hash]'); // Show resolved section if it was hidden if (resolvedSection && resolvedSection.style.display === 'none') { resolvedSection.style.display = 'block'; } if (resolvedClaimantEl && consensus.resolvedClaimant) { // Update with profile if available const profile = this.data.profiles?.[consensus.resolvedClaimant] || null; resolvedClaimantEl.innerHTML = this.renderPeerWithProfile(consensus.resolvedClaimant, profile, 32); // Make avatar clickable setTimeout(() => { this.makeAvatarsClickable(); }, 100); } if (resolvedHashEl && consensus.hash) { resolvedHashEl.textContent = window.utils.formatHash(consensus.hash); } } else { // Hide resolved section if status is no longer resolved const resolvedSection = container.querySelector('#resolved-info-section'); if (resolvedSection) { resolvedSection.style.display = 'none'; } } } /** * Handle updates from WebSocket */ async handleUpdate(data) { if (!this.currentDomain) return; // Check if this domain is in the changed domains list or if we have full update const changedDomains = data.changedDomains; const shouldUpdate = !changedDomains || changedDomains.includes(this.currentDomain) || data.domains || data.consensus; if (shouldUpdate) { try { // Update sidebar stats await window.utils.updateSidebarStats(); // Fetch fresh data (invalidate cache first) window.apiClient.invalidateCache(`domain:${this.currentDomain}`); const freshData = await window.apiClient.getDomainDetail(this.currentDomain); // Check if consensus status changed (important for UI updates) const oldStatus = this.data?.consensus?.status; const newStatus = freshData.consensus?.status; const statusChanged = oldStatus !== newStatus; // Update the data this.data = freshData; // If status changed significantly, we might need a full re-render // Otherwise, just update the content if (statusChanged && (oldStatus === 'resolved' || newStatus === 'resolved')) { // Status changed to/from resolved - full re-render for better UX this.renderContent(document.getElementById('domainDetailContent')); } else { // Update chart if it exists, otherwise render it if (this.chart) { this.updateChart(); } else { // If chart doesn't exist, render it after a short delay setTimeout(() => { const ctx = document.getElementById('voteChart'); if (ctx) { this.renderVoteChart(); } }, 100); } // Update the content without full re-render this.updateContent(); } } catch (error) { console.error('Error updating domain detail:', error); } } } /** * Destroy chart */ destroy() { if (this.chart) { this.chart.destroy(); this.chart = null; } } } // Export window.domainDetailView = new DomainDetailView();