ui: refresh delegations page

This commit is contained in:
Marco Allegretti 2026-01-29 13:50:25 +01:00
parent 5f52ddb94f
commit 365e205be7
2 changed files with 637 additions and 229 deletions

View file

@ -37,6 +37,14 @@ const { userId, communityId, compact = false } = Astro.props;
<div id="delegation-tree" class="tree-view" style="display: none;"></div>
<div id="delegation-list" class="list-view" style="display: none;"></div>
<div class="inspector" style="display: none;">
<div class="inspector-header">
<div class="inspector-title">Selection</div>
<button type="button" class="inspector-close" aria-label="Close selection">×</button>
</div>
<div class="inspector-body"></div>
</div>
</div>
<div class="graph-legend">
@ -65,6 +73,9 @@ const { userId, communityId, compact = false } = Astro.props;
this.outgoing = [];
this.incoming = [];
this.currentView = 'tree';
this.inspector = this.container.querySelector('.inspector');
this.inspectorBody = this.container.querySelector('.inspector-body');
this.treeClickHandler = null;
this.setupControls();
this.loadData();
@ -82,6 +93,11 @@ const { userId, communityId, compact = false } = Astro.props;
}
});
});
const closeBtn = this.container.querySelector('.inspector-close');
closeBtn?.addEventListener('click', () => {
this.hideInspector();
});
}
switchView(view) {
@ -125,6 +141,31 @@ const { userId, communityId, compact = false } = Astro.props;
}
}
escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, function(ch) {
switch (ch) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case "'": return '&#39;';
default: return ch;
}
});
}
showInspector(html) {
if (!this.inspector || !this.inspectorBody) return;
this.inspectorBody.innerHTML = html;
this.inspector.style.display = 'block';
}
hideInspector() {
if (!this.inspector || !this.inspectorBody) return;
this.inspectorBody.innerHTML = '';
this.inspector.style.display = 'none';
}
showError(message) {
const loading = this.container.querySelector('#delegation-loading');
if (loading) {
@ -136,6 +177,8 @@ const { userId, communityId, compact = false } = Astro.props;
const loading = this.container.querySelector('#delegation-loading');
if (loading) loading.style.display = 'none';
this.hideInspector();
this.renderTreeView();
this.renderListView();
this.switchView(this.currentView);
@ -154,7 +197,7 @@ const { userId, communityId, compact = false } = Astro.props;
<svg class="icon icon-xl"><use href="#icon-delegation"/></svg>
<h4>No Active Delegations</h4>
<p>You haven't delegated your vote to anyone, and no one has delegated to you.</p>
<a href="/delegations" class="btn-primary">Explore Delegates</a>
<a href="/delegations" class="btn-primary ui-btn ui-btn-primary">Explore Delegates</a>
</div>
`;
return;
@ -171,7 +214,7 @@ const { userId, communityId, compact = false } = Astro.props;
<span>${this.incoming.length} delegate${this.incoming.length > 1 ? 's' : ''} to you</span>
</div>
<div class="delegation-nodes">
${this.incoming.map(d => this.renderNode(d, 'incoming')).join('')}
${this.incoming.map((d, idx) => this.renderNode(d, 'incoming', idx)).join('')}
</div>
</div>
`;
@ -198,7 +241,7 @@ const { userId, communityId, compact = false } = Astro.props;
<span>You delegate to ${this.outgoing.length}</span>
</div>
<div class="delegation-nodes">
${this.outgoing.map(d => this.renderNode(d, 'outgoing')).join('')}
${this.outgoing.map((d, idx) => this.renderNode(d, 'outgoing', idx)).join('')}
</div>
</div>
`;
@ -206,16 +249,83 @@ const { userId, communityId, compact = false } = Astro.props;
html += '</div>';
treeView.innerHTML = html;
if (this.treeClickHandler) {
treeView.removeEventListener('click', this.treeClickHandler);
}
this.treeClickHandler = (e) => {
const target = e.target;
if (!(target instanceof Element)) return;
const node = target.closest('.delegation-node');
if (!node) return;
const direction = node.getAttribute('data-direction') || '';
const idxStr = node.getAttribute('data-index') || '';
const idx = Number(idxStr);
if (Number.isNaN(idx)) return;
const source = direction === 'incoming' ? this.incoming : this.outgoing;
const d = source[idx];
if (!d) return;
const name = this.escapeHtml(d.delegate_username || d.delegator_username || 'Unknown');
const scope = this.escapeHtml(this.getScopeLabel(d.scope));
const weightPct = Math.round((Number(d.weight) || 1) * 100);
const status = d.is_active ? 'Active' : 'Inactive';
const createdAt = d.created_at ? new Date(d.created_at).toLocaleString() : '';
this.showInspector(`
<div class="inspector-grid">
<div class="inspector-kv">
<div class="k">User</div>
<div class="v">${name}</div>
</div>
<div class="inspector-kv">
<div class="k">Direction</div>
<div class="v">${this.escapeHtml(direction)}</div>
</div>
<div class="inspector-kv">
<div class="k">Scope</div>
<div class="v">${scope}</div>
</div>
<div class="inspector-kv">
<div class="k">Weight</div>
<div class="v">${weightPct}%</div>
</div>
<div class="inspector-kv">
<div class="k">Status</div>
<div class="v">${this.escapeHtml(status)}</div>
</div>
${createdAt ? `
<div class="inspector-kv">
<div class="k">Created</div>
<div class="v">${this.escapeHtml(createdAt)}</div>
</div>
` : ''}
</div>
`);
};
treeView.addEventListener('click', this.treeClickHandler);
treeView.querySelectorAll('.delegation-node[tabindex="0"]').forEach((el) => {
el.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
el.click();
});
});
}
renderNode(delegation, type) {
const name = delegation.delegate_username || delegation.delegator_username || 'Unknown';
const scope = this.getScopeLabel(delegation.scope);
const weight = delegation.weight || 1;
const isActive = delegation.is_active;
renderNode(delegation, type, index) {
const name = this.escapeHtml(delegation.delegate_username || delegation.delegator_username || 'Unknown');
const scope = this.escapeHtml(this.getScopeLabel(delegation.scope));
const weight = Number(delegation.weight) || 1;
const isActive = Boolean(delegation.is_active);
return `
<div class="delegation-node ${type} ${isActive ? '' : 'inactive'}">
<div class="delegation-node ${type} ${isActive ? '' : 'inactive'}" data-direction="${this.escapeHtml(type)}" data-index="${this.escapeHtml(index)}" tabindex="0" role="button" aria-label="Inspect delegation">
<div class="node-avatar">
<svg class="icon"><use href="#icon-user"/></svg>
</div>
@ -258,23 +368,33 @@ const { userId, communityId, compact = false } = Astro.props;
</tr>
</thead>
<tbody>
${allDelegations.map(d => `
<tr class="${d.is_active ? '' : 'inactive'}">
<td>
<span class="direction-badge ${d.direction}">
${d.direction === 'outgoing' ? '→ To' : '← From'}
</span>
</td>
<td>${d.delegate_username || d.delegator_username || 'Unknown'}</td>
<td>${this.getScopeLabel(d.scope)}</td>
<td>${((d.weight || 1) * 100).toFixed(0)}%</td>
<td>
<span class="status-dot ${d.is_active ? 'active' : 'inactive'}"></span>
${d.is_active ? 'Active' : 'Revoked'}
</td>
<td>${new Date(d.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
${allDelegations.map(d => {
const direction = String(d.direction || '').toLowerCase();
const safeDirection = direction === 'outgoing' ? 'outgoing' : 'incoming';
const user = this.escapeHtml(d.delegate_username || d.delegator_username || 'Unknown');
const scope = this.escapeHtml(this.getScopeLabel(d.scope));
const weightPct = ((Number(d.weight) || 1) * 100).toFixed(0);
const statusLabel = d.is_active ? 'Active' : 'Revoked';
const dateLabel = d.created_at ? new Date(d.created_at).toLocaleDateString() : '';
return `
<tr class="${d.is_active ? '' : 'inactive'}">
<td>
<span class="direction-badge ${safeDirection}">
${safeDirection === 'outgoing' ? '→ To' : '← From'}
</span>
</td>
<td>${user}</td>
<td>${scope}</td>
<td>${this.escapeHtml(weightPct)}%</td>
<td>
<span class="status-dot ${d.is_active ? 'active' : 'inactive'}"></span>
${this.escapeHtml(statusLabel)}
</td>
<td>${this.escapeHtml(dateLabel)}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
@ -304,6 +424,87 @@ const { userId, communityId, compact = false } = Astro.props;
border: 1px solid var(--color-border);
}
.graph-content {
position: relative;
}
.inspector {
position: absolute;
right: 0.75rem;
bottom: 0.75rem;
width: min(360px, calc(100% - 1.5rem));
background: rgba(15, 23, 42, 0.92);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 14px;
padding: 0.85rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.inspector-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
margin-bottom: 0.6rem;
}
.inspector-title {
font-size: 0.8125rem;
letter-spacing: 0.02em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.75);
font-weight: 700;
}
.inspector-close {
appearance: none;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.85);
width: 28px;
height: 28px;
border-radius: 10px;
cursor: pointer;
line-height: 1;
}
.inspector-close:hover {
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.08);
}
.inspector-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.inspector-kv {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 0.55rem 0.6rem;
}
.inspector-kv .k {
font-size: 0.7rem;
letter-spacing: 0.02em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.65);
margin-bottom: 0.15rem;
font-weight: 700;
}
.inspector-kv .v {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.92);
word-break: break-word;
}
.graph-header {
display: flex;
justify-content: space-between;

View file

@ -5,143 +5,226 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
---
<Layout title="Delegations - Likwid">
<div class="container">
<header class="page-header">
<h1>Vote Delegations</h1>
<p class="subtitle">Delegate your voting power to trusted members</p>
</header>
<div class="auth-required" id="auth-check">
<p>Please <a href="/login">log in</a> to manage delegations.</p>
</div>
<div class="delegations-content" id="delegations-content" style="display: none;">
<section class="graph-section">
<DelegationGraph />
</section>
<div class="tabs">
<button class="tab active" data-tab="outgoing">My Delegations</button>
<button class="tab" data-tab="incoming">Delegated to Me</button>
<button class="tab" data-tab="delegates">Find Delegates</button>
</div>
<!-- My Delegations Tab -->
<div class="tab-content active" id="tab-outgoing">
<div class="section-header">
<h2>Active Delegations</h2>
<button class="btn btn-primary" id="new-delegation-btn">New Delegation</button>
<section class="ui-page">
<div class="ui-container">
<div class="hero ui-card ui-card-glass">
<div class="hero-top">
<div>
<h1 class="hero-title">Vote Delegations</h1>
<p class="hero-subtitle">Delegate your voting power to trusted members</p>
</div>
<div class="hero-actions">
<a href="/communities" class="ui-btn ui-btn-secondary">Browse</a>
<button class="ui-btn ui-btn-primary" id="new-delegation-btn" type="button" style="display:none;">New Delegation</button>
</div>
</div>
<div class="delegations-list" id="my-delegations">
<p class="loading">Loading delegations...</p>
<div class="hero-kpis">
<div class="hero-kpis-label">At a glance</div>
<div class="ui-kpis">
<div class="ui-kpi">
<div class="ui-kpi-value" id="kpi-outgoing">—</div>
<div class="ui-kpi-label">My delegations</div>
</div>
<div class="ui-kpi">
<div class="ui-kpi-value" id="kpi-incoming">—</div>
<div class="ui-kpi-label">Delegated to me</div>
</div>
<div class="ui-kpi">
<div class="ui-kpi-value" id="kpi-delegates">—</div>
<div class="ui-kpi-label">Available delegates</div>
</div>
</div>
</div>
</div>
<!-- Delegated to Me Tab -->
<div class="tab-content" id="tab-incoming">
<h2>Votes Delegated to Me</h2>
<div class="delegations-list" id="incoming-delegations">
<p class="loading">Loading...</p>
<div class="ui-card auth-required" id="auth-check">
<p>Please <a href="/login">log in</a> to manage delegations.</p>
</div>
<div class="delegations-content" id="delegations-content" style="display: none;">
<details class="panel ui-card" open>
<summary class="panel-summary">
<span class="panel-title">Delegation network</span>
<span class="panel-meta">Explorable view</span>
</summary>
<div class="panel-body">
<DelegationGraph />
</div>
</details>
<div class="tabs ui-card">
<button class="tab active" data-tab="outgoing" type="button">My Delegations</button>
<button class="tab" data-tab="incoming" type="button">Delegated to Me</button>
<button class="tab" data-tab="delegates" type="button">Find Delegates</button>
</div>
<div class="tab-content active" id="tab-outgoing">
<div class="section-header ui-card">
<div>
<h2>Active Delegations</h2>
<p class="help-text">Your current outgoing delegations (what you delegate to others).</p>
</div>
<button class="ui-btn ui-btn-primary" id="new-delegation-btn-secondary" type="button">New Delegation</button>
</div>
<div class="delegations-list" id="my-delegations">
<p class="loading">Loading delegations...</p>
</div>
</div>
<div class="tab-content" id="tab-incoming">
<div class="section-header ui-card">
<div>
<h2>Votes Delegated to Me</h2>
<p class="help-text">Incoming delegations (people who trust you to vote on their behalf).</p>
</div>
</div>
<div class="delegations-list" id="incoming-delegations">
<p class="loading">Loading...</p>
</div>
</div>
<div class="tab-content" id="tab-delegates">
<div class="section-header ui-card">
<div>
<h2>Available Delegates</h2>
<p class="help-text">Find trusted community members to delegate your votes to.</p>
</div>
</div>
<div class="delegates-list" id="delegates-list">
<p class="loading">Loading delegates...</p>
</div>
</div>
</div>
<!-- Find Delegates Tab -->
<div class="tab-content" id="tab-delegates">
<h2>Available Delegates</h2>
<p class="help-text">Find trusted community members to delegate your votes to.</p>
<div class="delegates-list" id="delegates-list">
<p class="loading">Loading delegates...</p>
<div class="modal" id="delegation-modal">
<div class="modal-content ui-card">
<div class="modal-header">
<h2>Create Delegation</h2>
<button type="button" class="ui-btn ui-btn-secondary" id="cancel-delegation">Close</button>
</div>
<form id="delegation-form">
<div class="form-grid">
<div class="form-group">
<label for="delegate-select">Delegate to</label>
<select id="delegate-select" required class="ui-select">
<option value="">Select a delegate...</option>
</select>
</div>
<div class="form-group">
<label for="scope-select">Scope</label>
<select id="scope-select" required class="ui-select">
<option value="global">Global (all votes)</option>
<option value="community">Specific Community</option>
</select>
</div>
<div class="form-group" id="community-select-group" style="display: none;">
<label for="community-select">Community</label>
<select id="community-select" class="ui-select">
<option value="">Select community...</option>
</select>
</div>
<div class="form-group">
<label for="weight-input">Delegation weight</label>
<input id="weight-input" type="range" min="0.1" max="1" step="0.1" value="1" />
<div class="help-text" id="weight-label">100% of your voting power</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="ui-btn ui-btn-primary">Create Delegation</button>
</div>
</form>
</div>
</div>
</div>
<!-- New Delegation Modal -->
<div class="modal" id="delegation-modal">
<div class="modal-content">
<h2>Create Delegation</h2>
<form id="delegation-form">
<div class="form-group">
<label for="delegate-select">Delegate to</label>
<select id="delegate-select" required>
<option value="">Select a delegate...</option>
</select>
</div>
<div class="form-group">
<label for="scope-select">Scope</label>
<select id="scope-select" required>
<option value="global">Global (all votes)</option>
<option value="community">Specific Community</option>
</select>
</div>
<div class="form-group" id="community-select-group" style="display: none;">
<label for="community-select">Community</label>
<select id="community-select">
<option value="">Select community...</option>
</select>
</div>
<div class="form-group">
<label for="weight-input">Delegation weight</label>
<input id="weight-input" type="range" min="0.1" max="1" step="0.1" value="1" />
<div class="help-text" id="weight-label">100% of your voting power</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancel-delegation">Cancel</button>
<button type="submit" class="btn btn-primary">Create Delegation</button>
</div>
</form>
</div>
</div>
</div>
</section>
</Layout>
<style>
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
.hero {
padding: 1.25rem;
margin-bottom: 1rem;
}
.page-header {
margin-bottom: 2rem;
.hero-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.page-header h1 {
.hero-title {
margin: 0;
color: var(--text-primary);
font-size: 2.125rem;
letter-spacing: -0.02em;
}
.subtitle {
color: var(--text-secondary);
margin-top: 0.5rem;
.hero-subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.hero-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.hero-kpis {
margin-top: 1rem;
}
.hero-kpis-label {
color: var(--color-text-muted);
font-size: 0.8125rem;
margin-bottom: 0.5rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.auth-required {
padding: 1rem 1.1rem;
margin-bottom: 1rem;
}
.auth-required a {
color: var(--color-primary);
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
margin: 1rem 0;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
}
.tab {
padding: 0.75rem 1.5rem;
flex: 1;
padding: 0.65rem 0.9rem;
background: transparent;
border: none;
color: var(--text-secondary);
border: 1px solid transparent;
color: var(--color-text-muted);
cursor: pointer;
border-radius: 0.5rem 0.5rem 0 0;
transition: all 0.2s;
border-radius: 12px;
transition: transform 140ms ease, background 140ms ease, color 140ms ease, border-color 140ms ease;
font-weight: 700;
}
.tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
color: var(--color-text);
background: rgba(255, 255, 255, 0.05);
}
.tab.active {
color: var(--accent-color);
background: var(--bg-secondary);
font-weight: 600;
color: var(--color-text);
background: rgba(255, 255, 255, 0.07);
border-color: var(--color-border);
}
.tab-content {
@ -155,8 +238,16 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
padding: 1rem 1.1rem;
}
.section-header h2 {
margin: 0;
font-size: 1rem;
letter-spacing: -0.01em;
}
.delegations-list, .delegates-list {
@ -166,10 +257,17 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
}
.delegation-card, .delegate-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
padding: 1.25rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 1.1rem;
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
}
.delegation-card:hover, .delegate-card:hover {
transform: translateY(-2px);
border-color: var(--color-border-hover);
background: rgba(255, 255, 255, 0.05);
}
.delegation-card header {
@ -181,26 +279,23 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
.delegation-card .delegate-name {
font-weight: 600;
color: var(--text-primary);
color: var(--color-text);
}
.delegation-card .scope-badge {
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.85rem;
background: var(--accent-color);
color: white;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.delegation-card .meta {
color: var(--text-secondary);
color: var(--color-text-muted);
font-size: 0.9rem;
}
.graph-section {
margin-bottom: 1.5rem;
}
.delegation-card .actions {
margin-top: 1rem;
display: flex;
@ -215,51 +310,21 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
.delegate-info h3 {
margin: 0 0 0.25rem 0;
color: var(--text-primary);
color: var(--color-text);
}
.delegate-info .bio {
color: var(--text-secondary);
color: var(--color-text-muted);
font-size: 0.9rem;
}
.delegate-stats {
display: flex;
gap: 1.5rem;
color: var(--text-secondary);
color: var(--color-text-muted);
font-size: 0.9rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent-color);
color: white;
border: none;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-danger {
background: #dc3545;
color: white;
border: none;
}
.modal {
display: none;
position: fixed;
@ -268,6 +333,7 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
z-index: 1000;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal.active {
@ -275,13 +341,26 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
}
.modal-content {
background: var(--bg-primary);
padding: 2rem;
border-radius: 1rem;
padding: 1.25rem;
border-radius: 16px;
width: 100%;
max-width: 500px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
letter-spacing: -0.01em;
}
.form-group {
margin-bottom: 1rem;
}
@ -292,29 +371,25 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
font-weight: 500;
}
.form-group select, .form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background: var(--bg-secondary);
color: var(--text-primary);
.form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
margin-top: 1rem;
}
.help-text {
color: var(--text-secondary);
margin-bottom: 1rem;
color: var(--color-text-muted);
margin: 0.35rem 0 0;
}
.loading {
color: var(--text-secondary);
color: var(--color-text-muted);
text-align: center;
padding: 2rem;
}
@ -322,7 +397,55 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
color: var(--color-text-muted);
}
.panel {
padding: 0;
overflow: hidden;
}
.panel-summary {
list-style: none;
cursor: pointer;
padding: 1rem 1.1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
user-select: none;
}
.panel-summary::-webkit-details-marker {
display: none;
}
.panel-title {
font-weight: 700;
letter-spacing: -0.01em;
}
.panel-meta {
color: var(--color-text-muted);
font-size: 0.875rem;
}
.panel-body {
border-top: 1px solid var(--color-border);
padding: 1rem 1.1rem 1.1rem;
}
@media (max-width: 640px) {
.delegate-card {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.delegate-stats {
width: 100%;
justify-content: space-between;
}
}
</style>
@ -330,6 +453,41 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
const API_URL = apiBase;
let token = localStorage.getItem('token');
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, function(ch) {
switch (ch) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case "'": return '&#39;';
default: return ch;
}
});
}
function normalizeScope(scope) {
const s = String(scope || '').toLowerCase();
if (s === 'global' || s === 'community' || s === 'topic' || s === 'proposal') return s;
return 'global';
}
function getScopeLabel(scope) {
const s = normalizeScope(scope);
const labels = {
global: '🌐 Global',
community: '🏘️ Community',
topic: '📁 Topic',
proposal: '📄 Proposal'
};
return labels[s] || s;
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = String(value);
}
async function init() {
if (!token) {
document.getElementById('auth-check').style.display = 'block';
@ -339,6 +497,8 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
document.getElementById('auth-check').style.display = 'none';
document.getElementById('delegations-content').style.display = 'block';
document.getElementById('new-delegation-btn')?.setAttribute('style', 'display:inline-flex;');
await Promise.all([
loadMyDelegations(),
loadIncomingDelegations(),
@ -363,6 +523,7 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
function setupModal() {
const modal = document.getElementById('delegation-modal');
const newBtn = document.getElementById('new-delegation-btn');
const newBtnSecondary = document.getElementById('new-delegation-btn-secondary');
const cancelBtn = document.getElementById('cancel-delegation');
const form = document.getElementById('delegation-form');
const scopeSelect = document.getElementById('scope-select');
@ -370,11 +531,14 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
const weightInput = document.getElementById('weight-input');
const weightLabel = document.getElementById('weight-label');
newBtn?.addEventListener('click', async () => {
const openModal = async () => {
await loadDelegatesForSelect();
await loadCommunitiesForSelect();
modal.classList.add('active');
});
};
newBtn?.addEventListener('click', openModal);
newBtnSecondary?.addEventListener('click', openModal);
cancelBtn?.addEventListener('click', () => {
modal.classList.remove('active');
@ -403,26 +567,43 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
});
const delegations = await res.json();
setText('kpi-outgoing', delegations.length);
if (delegations.length === 0) {
container.innerHTML = '<div class="empty-state">No active delegations. Create one to delegate your voting power.</div>';
return;
}
container.innerHTML = delegations.map(d => `
<div class="delegation-card">
<header>
<span class="delegate-name">→ ${d.delegate_username || 'Unknown'}</span>
<span class="scope-badge">${d.scope}</span>
</header>
<div class="meta">
Weight: ${Math.round((d.weight || 1) * 100)}%<br/>
Created: ${new Date(d.created_at).toLocaleDateString()}
container.innerHTML = delegations.map(d => {
const safeName = escapeHtml(d.delegate_username || 'Unknown');
const scopeLabel = escapeHtml(getScopeLabel(d.scope));
const weightPct = Math.round((Number(d.weight) || 1) * 100);
const createdAt = d.created_at ? new Date(d.created_at).toLocaleDateString() : '';
const safeId = escapeHtml(d.id);
return `
<div class="delegation-card">
<header>
<span class="delegate-name">→ ${safeName}</span>
<span class="scope-badge">${scopeLabel}</span>
</header>
<div class="meta">
Weight: ${weightPct}%<br/>
${createdAt ? `Created: ${escapeHtml(createdAt)}` : ''}
</div>
<div class="actions">
<button class="ui-btn ui-btn-danger" type="button" data-revoke-id="${safeId}">Revoke</button>
</div>
</div>
<div class="actions">
<button class="btn btn-danger btn-sm" onclick="revokeDelegation('${d.id}')">Revoke</button>
</div>
</div>
`).join('');
`;
}).join('');
container.querySelectorAll('[data-revoke-id]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-revoke-id') || '';
await window.revokeDelegation(id);
});
});
} catch (e) {
container.innerHTML = '<div class="error">Failed to load delegations</div>';
}
@ -436,23 +617,32 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
});
const delegations = await res.json();
setText('kpi-incoming', delegations.length);
if (delegations.length === 0) {
container.innerHTML = '<div class="empty-state">No one has delegated votes to you yet.</div>';
return;
}
container.innerHTML = delegations.map(d => `
<div class="delegation-card">
<header>
<span class="delegate-name">← ${d.delegate_username || 'Unknown'}</span>
<span class="scope-badge">${d.scope}</span>
</header>
<div class="meta">
Weight: ${Math.round((d.weight || 1) * 100)}%<br/>
Since: ${new Date(d.created_at).toLocaleDateString()}
container.innerHTML = delegations.map(d => {
const safeName = escapeHtml(d.delegator_username || d.delegate_username || 'Unknown');
const scopeLabel = escapeHtml(getScopeLabel(d.scope));
const weightPct = Math.round((Number(d.weight) || 1) * 100);
const createdAt = d.created_at ? new Date(d.created_at).toLocaleDateString() : '';
return `
<div class="delegation-card">
<header>
<span class="delegate-name">← ${safeName}</span>
<span class="scope-badge">${scopeLabel}</span>
</header>
<div class="meta">
Weight: ${weightPct}%<br/>
${createdAt ? `Since: ${escapeHtml(createdAt)}` : ''}
</div>
</div>
</div>
`).join('');
`;
}).join('');
} catch (e) {
container.innerHTML = '<div class="error">Failed to load incoming delegations</div>';
}
@ -464,23 +654,32 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
const res = await fetch(`${API_URL}/api/delegates`);
const delegates = await res.json();
setText('kpi-delegates', delegates.length);
if (delegates.length === 0) {
container.innerHTML = '<div class="empty-state">No delegates available yet.</div>';
return;
}
container.innerHTML = delegates.map(d => `
<div class="delegate-card">
<div class="delegate-info">
<h3>${d.display_name || d.username}</h3>
<p class="bio">${d.bio || 'No bio provided'}</p>
container.innerHTML = delegates.map(d => {
const safeName = escapeHtml(d.display_name || d.username || 'Unknown');
const safeBio = escapeHtml(d.bio || 'No bio provided');
const delegators = Number(d.total_delegators) || 0;
const votes = Number(d.total_votes_cast) || 0;
return `
<div class="delegate-card">
<div class="delegate-info">
<h3>${safeName}</h3>
<p class="bio">${safeBio}</p>
</div>
<div class="delegate-stats">
<span>${delegators} delegators</span>
<span>${votes} votes cast</span>
</div>
</div>
<div class="delegate-stats">
<span>${d.total_delegators} delegators</span>
<span>${d.total_votes_cast} votes cast</span>
</div>
</div>
`).join('');
`;
}).join('');
} catch (e) {
container.innerHTML = '<div class="error">Failed to load delegates</div>';
}
@ -492,7 +691,11 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
const res = await fetch(`${API_URL}/api/delegates`);
const delegates = await res.json();
select.innerHTML = '<option value="">Select a delegate...</option>' +
delegates.map(d => `<option value="${d.user_id}">${d.display_name || d.username}</option>`).join('');
delegates.map(d => {
const value = escapeHtml(d.user_id);
const label = escapeHtml(d.display_name || d.username || 'Unknown');
return `<option value="${value}">${label}</option>`;
}).join('');
} catch (e) {
console.error('Failed to load delegates for select');
}
@ -504,7 +707,11 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
const res = await fetch(`${API_URL}/api/communities`);
const communities = await res.json();
select.innerHTML = '<option value="">Select community...</option>' +
communities.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
communities.map(c => {
const value = escapeHtml(c.id);
const label = escapeHtml(c.name);
return `<option value="${value}">${label}</option>`;
}).join('');
} catch (e) {
console.error('Failed to load communities');
}