This commit is contained in:
Raven Scott
2025-12-17 20:05:50 -05:00
commit 742e27d3f7
276 changed files with 89838 additions and 0 deletions
@@ -0,0 +1,591 @@
/**
* 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
? `<img src="${avatarUrl}" alt="${displayName || peerIdShort}" class="inline-block rounded-full mr-2" style="width: ${avatarSize}px; height: ${avatarSize}px; vertical-align: middle;" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="inline-flex items-center justify-center rounded-full mr-2" style="width: ${avatarSize}px; height: ${avatarSize}px; vertical-align: middle; display: none; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.75rem; font-weight: 600;">
${(displayName || peerIdShort).charAt(0).toUpperCase()}
</div>`
: `<div class="inline-flex items-center justify-center rounded-full mr-2" style="width: ${avatarSize}px; height: ${avatarSize}px; vertical-align: middle; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.75rem; font-weight: 600;">
${(displayName || peerIdShort).charAt(0).toUpperCase()}
</div>`;
const nameHtml = displayName
? `<span class="font-medium">${window.utils.escapeHtml(displayName)}</span><span class="text-tertiary text-xs ml-2 font-mono">${peerIdShort}</span>`
: `<span class="font-mono text-sm">${peerIdShort}</span>`;
// Create a unique ID for the avatar container
const avatarId = `peer-avatar-${peerId.replace(/[^a-zA-Z0-9]/g, '-')}`;
return `
<div class="flex items-center" id="${avatarId}">
${avatarHtml}
<span>${nameHtml}</span>
</div>
`;
}
/**
* 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 = `
<div class="text-center py-12">
<div class="spinner"></div>
<p class="mt-4 text-tertiary">Loading domain details...</p>
</div>
`;
// 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 = `
<div class="text-center py-12">
<p class="text-red-400">Error loading domain details: ${error.message}</p>
<button onclick="window.app.showView('domain-list')" class="btn btn-primary mt-4">
Back to Domain List
</button>
</div>
`;
}
}
/**
* 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 = `
<!-- Header -->
<div class="mb-6">
<button
onclick="window.app.showView('domain-list')"
class="btn btn-secondary mb-4"
>
← Back to Domain List
</button>
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold mb-2">
<span class="font-mono">${window.utils.escapeHtml(domain)}</span>
</h2>
<span class="status-badge ${statusClass}">${statusText}</span>
</div>
</div>
</div>
<!-- Overview Cards -->
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="glass-card">
<h3 class="text-sm text-secondary mb-1">Total Votes</h3>
<p class="text-2xl font-bold text-blue-400" data-votes-total>${consensus.totalVotes || 0}</p>
</div>
<div class="glass-card">
<h3 class="text-sm text-secondary mb-1">Minimum Required</h3>
<p class="text-2xl font-bold text-indigo-400" data-votes-min>${consensus.minVotes || 0}</p>
</div>
<div class="glass-card">
<h3 class="text-sm text-secondary mb-1">Quorum Status</h3>
<p class="text-2xl font-bold ${consensus.quorumMet ? 'text-green-400' : 'text-yellow-400'}" data-quorum-status>
${consensus.quorumMet ? 'Met' : 'Not Met'}
</p>
</div>
<div class="glass-card">
<h3 class="text-sm text-secondary mb-1">Active Peers</h3>
<p class="text-2xl font-bold text-purple-400" data-active-peers>${consensus.activePeers || 0}</p>
</div>
</div>
<!-- Quorum Progress -->
<div class="glass-card mb-6">
<h3 class="text-sm font-semibold text-secondary mb-3">Quorum Progress</h3>
<div class="mb-2 flex justify-between text-sm text-tertiary">
<span data-quorum-votes>${consensus.totalVotes || 0} / ${consensus.minVotes || 0} votes</span>
<span data-quorum-percentage>${window.utils.calculateQuorumPercentage(consensus.totalVotes || 0, consensus.minVotes || 1)}%</span>
</div>
<div class="w-full progress-bar-container" style="height: 1rem;">
<div
class="progress-bar ${consensus.quorumMet ? 'success' : 'warning'} quorum-progress-bar"
style="width: ${Math.min(100, window.utils.calculateQuorumPercentage(consensus.totalVotes || 0, consensus.minVotes || 1))}%; height: 100%;"
></div>
</div>
</div>
<!-- Resolved Information -->
${consensus.status === 'resolved' ? `
<div class="glass-card mb-6" id="resolved-info-section">
<h3 class="text-sm font-semibold text-secondary mb-3">Resolved Information</h3>
<div style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
<div>
<span class="text-tertiary">Resolved Claimant:</span>
<div class="ml-2 inline-block" data-resolved-claimant>${this.renderPeerWithProfile(consensus.resolvedClaimant, this.data.profiles?.[consensus.resolvedClaimant] || null, 32)}</div>
<button
onclick="window.utils.copyToClipboard('${consensus.resolvedClaimant}').then(() => alert('Copied!'))"
class="btn btn-secondary ml-2 text-xs"
>
Copy
</button>
</div>
<div>
<span class="text-tertiary">Resolved Hash:</span>
<span class="ml-2 font-mono" data-resolved-hash>${window.utils.formatHash(consensus.hash)}</span>
<button
onclick="window.utils.copyToClipboard('${consensus.hash}').then(() => alert('Copied!'))"
class="btn btn-secondary ml-2 text-xs"
>
Copy
</button>
</div>
</div>
</div>
` : ''}
<!-- Vote Distribution Chart -->
${this.getVoteCounts().length > 0 ? `
<div class="glass-card mb-6">
<h3 class="text-sm font-semibold text-secondary mb-4">Vote Distribution</h3>
<div style="height: 300px; position: relative;">
<canvas id="voteChart"></canvas>
</div>
</div>
` : ''}
<!-- Claims Section -->
<div class="glass-card mb-6">
<h3 class="text-sm font-semibold text-secondary mb-4">Claims (${claims.length})</h3>
${claims.length > 0 ? `
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr>
<th>Claimant</th>
<th>Hash</th>
<th>Votes</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
${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 `
<tr class="${isResolved ? 'bg-green-900/20' : ''}">
<td>
${this.renderPeerWithProfile(claim.claimant, profile, 32)}
${isResolved ? '<span class="text-xs text-green-400 ml-2">(resolved)</span>' : ''}
</td>
<td>
<div class="font-mono text-xs">${window.utils.formatHash(claim.hash)}</div>
</td>
<td>${voteCount}</td>
<td class="text-sm">${window.utils.formatTimestamp(claim.timestamp)}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
` : `
<p class="text-tertiary text-center py-4">No claims found</p>
`}
</div>
<!-- Votes Section -->
<div class="glass-card">
<h3 class="text-sm font-semibold text-secondary mb-4">Votes (${votes.length})</h3>
${votes.length > 0 ? `
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr>
<th>Voter</th>
<th>Voted For</th>
</tr>
</thead>
<tbody>
${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 `
<tr class="${isResolved ? 'bg-green-900/20' : ''}">
<td>${this.renderPeerWithProfile(vote.voter, voterProfile, 32)}</td>
<td>
${this.renderPeerWithProfile(vote.claimant, claimantProfile, 32)}
${isResolved ? '<span class="text-xs text-green-400 ml-2">(resolved claimant)</span>' : ''}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
` : `
<p class="text-tertiary text-center py-4">No votes found</p>
`}
</div>
`;
// 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();