mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-06-25 07:27:42 +00:00
1215 lines
34 KiB
Text
1215 lines
34 KiB
Text
|
|
---
|
|||
|
|
export const prerender = false;
|
|||
|
|
import Layout from '../../layouts/Layout.astro';
|
|||
|
|
import { API_BASE as apiBase } from '../../lib/api';
|
|||
|
|
const { id } = Astro.params;
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
<Layout title="Proposal">
|
|||
|
|
<section class="proposal-page">
|
|||
|
|
<div id="proposal-content">
|
|||
|
|
<p class="loading">Loading proposal...</p>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
</Layout>
|
|||
|
|
|
|||
|
|
<script define:vars={{ id, apiBase }}>
|
|||
|
|
const token = localStorage.getItem('token');
|
|||
|
|
let currentProposal = null;
|
|||
|
|
let selectedOptions = new Set();
|
|||
|
|
let rankedSelections = [];
|
|||
|
|
let quadraticAllocations = {};
|
|||
|
|
let starRatings = {};
|
|||
|
|
|
|||
|
|
function getVotingHint(method) {
|
|||
|
|
const hints = {
|
|||
|
|
'approval': 'Click options to select, then submit your vote',
|
|||
|
|
'ranked_choice': 'Click options in order of preference (1st, 2nd, 3rd...)',
|
|||
|
|
'quadratic': 'Use + and - to allocate credits (cost = votes²)',
|
|||
|
|
'star': 'Click to rate each option from 0-5 stars',
|
|||
|
|
};
|
|||
|
|
return hints[method] || hints['approval'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadProposal() {
|
|||
|
|
const container = document.getElementById('proposal-content');
|
|||
|
|
if (!container) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`${apiBase}/api/proposals/${id}`);
|
|||
|
|
if (!res.ok) throw new Error('Not found');
|
|||
|
|
|
|||
|
|
const data = await res.json();
|
|||
|
|
currentProposal = data;
|
|||
|
|
|
|||
|
|
const isDraft = data.proposal.status === 'draft';
|
|||
|
|
const isDiscussion = data.proposal.status === 'discussion';
|
|||
|
|
const isVoting = data.proposal.status === 'voting';
|
|||
|
|
const isClosed = data.proposal.status === 'closed';
|
|||
|
|
const canStartDiscussion = token && isDraft;
|
|||
|
|
const canStartVoting = token && (isDraft || isDiscussion);
|
|||
|
|
const canCloseVoting = token && isVoting;
|
|||
|
|
|
|||
|
|
container.innerHTML = `
|
|||
|
|
<div class="proposal-header">
|
|||
|
|
<h1>${data.proposal.title}</h1>
|
|||
|
|
<span class="status status-${data.proposal.status}">${data.proposal.status}</span>
|
|||
|
|
</div>
|
|||
|
|
<p class="meta">
|
|||
|
|
by <a href="/users/${data.author_name}" class="author-link">${data.author_name}</a>
|
|||
|
|
on ${new Date(data.proposal.created_at).toLocaleDateString()}
|
|||
|
|
${data.proposal.voting_starts_at ? `<span class="voting-dates"> | Voting started: ${new Date(data.proposal.voting_starts_at).toLocaleDateString()}</span>` : ''}
|
|||
|
|
${data.proposal.voting_ends_at ? `<span class="voting-dates"> | Ends: ${new Date(data.proposal.voting_ends_at).toLocaleDateString()}</span>` : ''}
|
|||
|
|
</p>
|
|||
|
|
<div class="description">${data.proposal.description}</div>
|
|||
|
|
|
|||
|
|
<div class="options-section">
|
|||
|
|
<h2>Options</h2>
|
|||
|
|
<div class="options-list" id="options-list">
|
|||
|
|
${(() => {
|
|||
|
|
const maxVotes = Math.max(...data.options.map(o => o.vote_count));
|
|||
|
|
const hasVotes = maxVotes > 0;
|
|||
|
|
const method = data.proposal.voting_method;
|
|||
|
|
return data.options.map(opt => {
|
|||
|
|
const isWinner = isClosed && hasVotes && opt.vote_count === maxVotes;
|
|||
|
|
let votingUI = '';
|
|||
|
|
if (isVoting && token) {
|
|||
|
|
if (method === 'quadratic') {
|
|||
|
|
votingUI = `<div class="quadratic-controls">
|
|||
|
|
<button type="button" class="qv-btn" data-action="minus" data-id="${opt.id}">−</button>
|
|||
|
|
<span class="qv-value" data-id="${opt.id}">0</span>
|
|||
|
|
<button type="button" class="qv-btn" data-action="plus" data-id="${opt.id}">+</button>
|
|||
|
|
</div>`;
|
|||
|
|
} else if (method === 'star') {
|
|||
|
|
votingUI = `<div class="star-rating" data-id="${opt.id}">
|
|||
|
|
${[1,2,3,4,5].map(s => `<span class="star" data-stars="${s}">☆</span>`).join('')}
|
|||
|
|
</div>`;
|
|||
|
|
} else if (method === 'ranked_choice') {
|
|||
|
|
votingUI = `<span class="rank-badge" data-id="${opt.id}"></span>`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return `
|
|||
|
|
<div class="option ${isVoting ? 'votable' : ''} ${isWinner ? 'winner' : ''} method-${method}" data-id="${opt.id}">
|
|||
|
|
<div class="option-info">
|
|||
|
|
<span class="option-label">${opt.label} ${isWinner ? '🏆' : ''}</span>
|
|||
|
|
${opt.description ? `<span class="option-desc">${opt.description}</span>` : ''}
|
|||
|
|
</div>
|
|||
|
|
${votingUI}
|
|||
|
|
<div class="vote-count">${opt.vote_count} votes</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
})()}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
${isVoting && token ? `
|
|||
|
|
<div class="vote-actions">
|
|||
|
|
<p class="vote-hint">${getVotingHint(data.proposal.voting_method)}</p>
|
|||
|
|
<p class="voting-method-label">Method: <strong>${data.proposal.voting_method.replace('_', ' ')}</strong></p>
|
|||
|
|
${data.proposal.voting_method === 'quadratic' ? '<p class="credits-display">Credits used: <span id="credits-used">0</span>/100</p>' : ''}
|
|||
|
|
<button id="submit-vote" class="btn-vote" disabled>Submit Vote</button>
|
|||
|
|
</div>
|
|||
|
|
` : ''}
|
|||
|
|
|
|||
|
|
${canStartDiscussion ? `
|
|||
|
|
<div class="author-actions">
|
|||
|
|
<button id="edit-proposal" class="btn-edit">Edit Proposal</button>
|
|||
|
|
<button id="start-discussion" class="btn-discussion">Open for Discussion</button>
|
|||
|
|
<button id="start-voting" class="btn-start">Skip to Voting</button>
|
|||
|
|
<button id="delete-proposal" class="btn-delete">Delete Proposal</button>
|
|||
|
|
</div>
|
|||
|
|
` : ''}
|
|||
|
|
|
|||
|
|
${isDiscussion && token ? `
|
|||
|
|
<div class="author-actions">
|
|||
|
|
<button id="edit-proposal" class="btn-edit">Edit Proposal</button>
|
|||
|
|
<button id="start-voting" class="btn-start">Start Voting</button>
|
|||
|
|
</div>
|
|||
|
|
` : ''}
|
|||
|
|
|
|||
|
|
${canCloseVoting ? `
|
|||
|
|
<div class="author-actions">
|
|||
|
|
<button id="close-voting" class="btn-close">Close Voting</button>
|
|||
|
|
</div>
|
|||
|
|
` : ''}
|
|||
|
|
|
|||
|
|
${isClosed ? `
|
|||
|
|
<div class="results-section">
|
|||
|
|
<h3>📊 Voting Results</h3>
|
|||
|
|
<div id="detailed-results" class="detailed-results">
|
|||
|
|
<p class="loading-small">Loading detailed results...</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
` : ''}
|
|||
|
|
|
|||
|
|
${!token && isVoting ? `
|
|||
|
|
<div class="login-prompt">
|
|||
|
|
<a href="/login">Login</a> to vote on this proposal
|
|||
|
|
</div>
|
|||
|
|
` : ''}
|
|||
|
|
|
|||
|
|
<div class="comments-section">
|
|||
|
|
<h2>Discussion</h2>
|
|||
|
|
${token ? `
|
|||
|
|
<form id="comment-form" class="comment-form">
|
|||
|
|
<textarea id="comment-content" placeholder="Add your comment..." required></textarea>
|
|||
|
|
<button type="submit">Post Comment</button>
|
|||
|
|
</form>
|
|||
|
|
` : `<p class="login-prompt"><a href="/login">Login</a> to comment</p>`}
|
|||
|
|
<div id="comments-list" class="comments-list">
|
|||
|
|
<p class="loading-small">Loading comments...</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// Setup voting interaction based on method
|
|||
|
|
if (isVoting && token) {
|
|||
|
|
const options = container.querySelectorAll('.option.votable');
|
|||
|
|
const submitBtn = document.getElementById('submit-vote');
|
|||
|
|
const method = data.proposal.voting_method;
|
|||
|
|
|
|||
|
|
if (method === 'approval') {
|
|||
|
|
options.forEach(opt => {
|
|||
|
|
opt.addEventListener('click', () => {
|
|||
|
|
const optId = opt.dataset.id;
|
|||
|
|
if (selectedOptions.has(optId)) {
|
|||
|
|
selectedOptions.delete(optId);
|
|||
|
|
opt.classList.remove('selected');
|
|||
|
|
} else {
|
|||
|
|
selectedOptions.add(optId);
|
|||
|
|
opt.classList.add('selected');
|
|||
|
|
}
|
|||
|
|
submitBtn.disabled = selectedOptions.size === 0;
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
} else if (method === 'ranked_choice') {
|
|||
|
|
options.forEach(opt => {
|
|||
|
|
opt.addEventListener('click', () => {
|
|||
|
|
const optId = opt.dataset.id;
|
|||
|
|
const badge = opt.querySelector('.rank-badge');
|
|||
|
|
const existing = rankedSelections.findIndex(r => r.option_id === optId);
|
|||
|
|
if (existing >= 0) {
|
|||
|
|
rankedSelections.splice(existing, 1);
|
|||
|
|
rankedSelections.forEach((r, i) => r.rank = i + 1);
|
|||
|
|
updateRankBadges();
|
|||
|
|
} else {
|
|||
|
|
rankedSelections.push({ option_id: optId, rank: rankedSelections.length + 1 });
|
|||
|
|
updateRankBadges();
|
|||
|
|
}
|
|||
|
|
submitBtn.disabled = rankedSelections.length === 0;
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
} else if (method === 'quadratic') {
|
|||
|
|
document.querySelectorAll('.qv-btn').forEach(btn => {
|
|||
|
|
btn.addEventListener('click', (e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
const action = btn.dataset.action;
|
|||
|
|
const optId = btn.dataset.id;
|
|||
|
|
const current = quadraticAllocations[optId] || 0;
|
|||
|
|
if (action === 'plus') quadraticAllocations[optId] = current + 1;
|
|||
|
|
else if (action === 'minus' && current > 0) quadraticAllocations[optId] = current - 1;
|
|||
|
|
updateQuadraticDisplay();
|
|||
|
|
submitBtn.disabled = Object.values(quadraticAllocations).every(v => v === 0);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
} else if (method === 'star') {
|
|||
|
|
document.querySelectorAll('.star-rating').forEach(rating => {
|
|||
|
|
const optId = rating.dataset.id;
|
|||
|
|
rating.querySelectorAll('.star').forEach(star => {
|
|||
|
|
star.addEventListener('click', (e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
const stars = parseInt(star.dataset.stars);
|
|||
|
|
starRatings[optId] = stars;
|
|||
|
|
updateStarDisplay(optId, stars);
|
|||
|
|
submitBtn.disabled = Object.keys(starRatings).length === 0;
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
submitBtn?.addEventListener('click', () => submitVote(method));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Setup action buttons
|
|||
|
|
document.getElementById('start-discussion')?.addEventListener('click', startDiscussion);
|
|||
|
|
document.getElementById('start-voting')?.addEventListener('click', startVoting);
|
|||
|
|
document.getElementById('close-voting')?.addEventListener('click', closeVoting);
|
|||
|
|
document.getElementById('delete-proposal')?.addEventListener('click', deleteProposal);
|
|||
|
|
document.getElementById('edit-proposal')?.addEventListener('click', () => showEditModal(data));
|
|||
|
|
|
|||
|
|
// Load and setup comments
|
|||
|
|
loadComments();
|
|||
|
|
document.getElementById('comment-form')?.addEventListener('submit', postComment);
|
|||
|
|
|
|||
|
|
// Load detailed results if closed
|
|||
|
|
if (isClosed) {
|
|||
|
|
loadDetailedResults();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
container.innerHTML = '<div class="error">Proposal not found</div>';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadDetailedResults() {
|
|||
|
|
const container = document.getElementById('detailed-results');
|
|||
|
|
if (!container) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`${apiBase}/api/proposals/${id}/results`);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
container.innerHTML = '<p class="error">Could not load detailed results</p>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const data = await res.json();
|
|||
|
|
const method = currentProposal?.proposal?.voting_method || 'approval';
|
|||
|
|
|
|||
|
|
let resultsHTML = `
|
|||
|
|
<div class="results-summary">
|
|||
|
|
<div class="result-stat">
|
|||
|
|
<span class="stat-value">${data.total_voters || 0}</span>
|
|||
|
|
<span class="stat-label">Total Voters</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="result-stat">
|
|||
|
|
<span class="stat-value">${data.total_votes || 0}</span>
|
|||
|
|
<span class="stat-label">Total Votes</span>
|
|||
|
|
</div>
|
|||
|
|
${data.winner ? `
|
|||
|
|
<div class="result-stat winner-stat">
|
|||
|
|
<span class="stat-value">🏆 ${data.winner.label}</span>
|
|||
|
|
<span class="stat-label">Winner</span>
|
|||
|
|
</div>
|
|||
|
|
` : ''}
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// Method-specific details
|
|||
|
|
if (method === 'ranked_choice' && data.rounds) {
|
|||
|
|
resultsHTML += `
|
|||
|
|
<div class="method-details">
|
|||
|
|
<h4>Instant Runoff Rounds</h4>
|
|||
|
|
<div class="rounds-list">
|
|||
|
|
${data.rounds.map((round, i) => `
|
|||
|
|
<div class="round">
|
|||
|
|
<strong>Round ${i + 1}</strong>
|
|||
|
|
<ul>
|
|||
|
|
${round.counts.map((c) => `
|
|||
|
|
<li>${c.label}: ${c.votes} votes ${c.eliminated ? '(eliminated)' : ''}</li>
|
|||
|
|
`).join('')}
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
`).join('')}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
} else if (method === 'schulze' && data.pairwise_matrix) {
|
|||
|
|
resultsHTML += `
|
|||
|
|
<div class="method-details">
|
|||
|
|
<h4>Schulze Method - Pairwise Comparison</h4>
|
|||
|
|
<p class="method-explanation">Winner determined by Condorcet-consistent pairwise comparison</p>
|
|||
|
|
<div class="ranking-list">
|
|||
|
|
${data.ranking?.map((r, i) => `
|
|||
|
|
<div class="ranking-item">
|
|||
|
|
<span class="rank">#${i + 1}</span>
|
|||
|
|
<span class="label">${r.label}</span>
|
|||
|
|
<span class="score">${r.score} wins</span>
|
|||
|
|
</div>
|
|||
|
|
`).join('') || ''}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
} else if (method === 'star' && data.scores) {
|
|||
|
|
resultsHTML += `
|
|||
|
|
<div class="method-details">
|
|||
|
|
<h4>STAR Voting Results</h4>
|
|||
|
|
<p class="method-explanation">Score Then Automatic Runoff</p>
|
|||
|
|
<div class="star-results">
|
|||
|
|
${data.scores.map((s) => `
|
|||
|
|
<div class="score-bar">
|
|||
|
|
<span class="label">${s.label}</span>
|
|||
|
|
<div class="bar-container">
|
|||
|
|
<div class="bar" style="width: ${(s.average / 5) * 100}%"></div>
|
|||
|
|
</div>
|
|||
|
|
<span class="score">${s.average.toFixed(2)} avg (${s.total} total)</span>
|
|||
|
|
</div>
|
|||
|
|
`).join('')}
|
|||
|
|
</div>
|
|||
|
|
${data.runoff ? `
|
|||
|
|
<div class="runoff-details">
|
|||
|
|
<h5>Automatic Runoff</h5>
|
|||
|
|
<p>${data.runoff.winner} vs ${data.runoff.runner_up}: ${data.runoff.winner_votes} to ${data.runoff.runner_up_votes}</p>
|
|||
|
|
</div>
|
|||
|
|
` : ''}
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
} else if (method === 'quadratic' && data.allocations) {
|
|||
|
|
resultsHTML += `
|
|||
|
|
<div class="method-details">
|
|||
|
|
<h4>Quadratic Voting Results</h4>
|
|||
|
|
<p class="method-explanation">Credits spent: cost = votes² (intensity-weighted)</p>
|
|||
|
|
<div class="quadratic-results">
|
|||
|
|
${data.allocations.map((a) => `
|
|||
|
|
<div class="allocation-bar">
|
|||
|
|
<span class="label">${a.label}</span>
|
|||
|
|
<div class="bar-container">
|
|||
|
|
<div class="bar" style="width: ${(a.effective_votes / (data.max_votes || 1)) * 100}%"></div>
|
|||
|
|
</div>
|
|||
|
|
<span class="score">${a.effective_votes} effective votes (${a.total_credits} credits)</span>
|
|||
|
|
</div>
|
|||
|
|
`).join('')}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
} else {
|
|||
|
|
// Default approval voting display
|
|||
|
|
resultsHTML += `
|
|||
|
|
<div class="method-details">
|
|||
|
|
<h4>Approval Voting Results</h4>
|
|||
|
|
<div class="approval-results">
|
|||
|
|
${(data.options || currentProposal?.options || []).sort((a, b) => b.vote_count - a.vote_count).map((o) => {
|
|||
|
|
const maxVotes = Math.max(...(data.options || currentProposal?.options || []).map((x) => x.vote_count));
|
|||
|
|
const pct = maxVotes > 0 ? (o.vote_count / maxVotes) * 100 : 0;
|
|||
|
|
return `
|
|||
|
|
<div class="approval-bar">
|
|||
|
|
<span class="label">${o.label}</span>
|
|||
|
|
<div class="bar-container">
|
|||
|
|
<div class="bar" style="width: ${pct}%"></div>
|
|||
|
|
</div>
|
|||
|
|
<span class="score">${o.vote_count} votes</span>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('')}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = resultsHTML;
|
|||
|
|
} catch (error) {
|
|||
|
|
container.innerHTML = '<p class="error">Error loading results</p>';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateRankBadges() {
|
|||
|
|
document.querySelectorAll('.rank-badge').forEach(badge => {
|
|||
|
|
const optId = badge.dataset.id;
|
|||
|
|
const ranking = rankedSelections.find(r => r.option_id === optId);
|
|||
|
|
badge.textContent = ranking ? `#${ranking.rank}` : '';
|
|||
|
|
badge.parentElement?.classList.toggle('selected', !!ranking);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateQuadraticDisplay() {
|
|||
|
|
let totalCost = 0;
|
|||
|
|
Object.entries(quadraticAllocations).forEach(([optId, credits]) => {
|
|||
|
|
const display = document.querySelector(`.qv-value[data-id="${optId}"]`);
|
|||
|
|
if (display) display.textContent = String(credits);
|
|||
|
|
totalCost += credits * credits;
|
|||
|
|
});
|
|||
|
|
const creditsEl = document.getElementById('credits-used');
|
|||
|
|
if (creditsEl) {
|
|||
|
|
creditsEl.textContent = String(totalCost);
|
|||
|
|
const err = getComputedStyle(document.documentElement).getPropertyValue('--color-error');
|
|||
|
|
creditsEl.style.color = totalCost > 100 ? err : 'inherit';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateStarDisplay(optId, stars) {
|
|||
|
|
const container = document.querySelector(`.star-rating[data-id="${optId}"]`);
|
|||
|
|
if (!container) return;
|
|||
|
|
container.querySelectorAll('.star').forEach(star => {
|
|||
|
|
const s = parseInt(star.dataset.stars);
|
|||
|
|
star.textContent = s <= stars ? '★' : '☆';
|
|||
|
|
star.classList.toggle('filled', s <= stars);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function submitVote(method) {
|
|||
|
|
let endpoint = `/api/proposals/${id}/vote`;
|
|||
|
|
let body;
|
|||
|
|
|
|||
|
|
if (method === 'approval') {
|
|||
|
|
body = { option_ids: Array.from(selectedOptions) };
|
|||
|
|
} else if (method === 'ranked_choice') {
|
|||
|
|
endpoint = `/api/proposals/${id}/vote/ranked`;
|
|||
|
|
body = { rankings: rankedSelections };
|
|||
|
|
} else if (method === 'quadratic') {
|
|||
|
|
endpoint = `/api/proposals/${id}/vote/quadratic`;
|
|||
|
|
const totalCost = Object.values(quadraticAllocations).reduce((sum, c) => sum + c * c, 0);
|
|||
|
|
if (totalCost > 100) {
|
|||
|
|
alert('Total credits exceed 100!');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
body = { allocations: Object.entries(quadraticAllocations).map(([option_id, credits]) => ({ option_id, credits })) };
|
|||
|
|
} else if (method === 'star') {
|
|||
|
|
endpoint = `/api/proposals/${id}/vote/star`;
|
|||
|
|
body = { ratings: Object.entries(starRatings).map(([option_id, stars]) => ({ option_id, stars })) };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`${apiBase}${endpoint}`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'Authorization': `Bearer ${token}`,
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify(body),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (res.ok) {
|
|||
|
|
alert('Vote submitted!');
|
|||
|
|
location.reload();
|
|||
|
|
} else {
|
|||
|
|
const err = await res.text();
|
|||
|
|
alert(err || 'Failed to submit vote');
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Error submitting vote');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function startDiscussion() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`${apiBase}/api/proposals/${id}/start-discussion`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Authorization': `Bearer ${token}`,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (res.ok) {
|
|||
|
|
location.reload();
|
|||
|
|
} else {
|
|||
|
|
alert('Failed to start discussion');
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Error starting discussion');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showEditModal(data) {
|
|||
|
|
const modal = document.createElement('div');
|
|||
|
|
modal.className = 'edit-modal';
|
|||
|
|
modal.innerHTML = `
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<h2>Edit Proposal</h2>
|
|||
|
|
<form id="edit-form">
|
|||
|
|
<div class="form-group">
|
|||
|
|
<label for="edit-title">Title</label>
|
|||
|
|
<input type="text" id="edit-title" value="${data.proposal.title}" required />
|
|||
|
|
</div>
|
|||
|
|
<div class="form-group">
|
|||
|
|
<label for="edit-description">Description</label>
|
|||
|
|
<textarea id="edit-description" rows="6" required>${data.proposal.description}</textarea>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-actions">
|
|||
|
|
<button type="button" class="btn-cancel" id="cancel-edit">Cancel</button>
|
|||
|
|
<button type="submit" class="btn-save">Save Changes</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
document.body.appendChild(modal);
|
|||
|
|
|
|||
|
|
document.getElementById('cancel-edit')?.addEventListener('click', () => modal.remove());
|
|||
|
|
document.getElementById('edit-form')?.addEventListener('submit', async (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const title = document.getElementById('edit-title').value;
|
|||
|
|
const description = document.getElementById('edit-description').value;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`${apiBase}/api/proposals/${id}`, {
|
|||
|
|
method: 'PUT',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'Authorization': `Bearer ${token}`,
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({ title, description, voting_method: method }),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (res.ok) {
|
|||
|
|
modal.remove();
|
|||
|
|
location.reload();
|
|||
|
|
} else {
|
|||
|
|
alert('Failed to update proposal');
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Error updating proposal');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadComments() {
|
|||
|
|
const container = document.getElementById('comments-container');
|
|||
|
|
if (!container) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`${apiBase}/api/proposals/${id}/comments`);
|
|||
|
|
const comments = await res.json();
|
|||
|
|
|
|||
|
|
if (comments.length === 0) {
|
|||
|
|
container.innerHTML = '<p class="empty-comments">No comments yet. Be the first to share your thoughts!</p>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.innerHTML = comments.map(c => `
|
|||
|
|
<div class="comment">
|
|||
|
|
<div class="comment-header">
|
|||
|
|
<a href="/users/${c.author_name}" class="comment-author">${c.author_name}</a>
|
|||
|
|
<span class="comment-date">${new Date(c.created_at).toLocaleString()}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="comment-content">${c.content}</div>
|
|||
|
|
</div>
|
|||
|
|
`).join('');
|
|||
|
|
} catch (e) {
|
|||
|
|
container.innerHTML = '<p class="error-small">Failed to load comments</p>';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function postComment(e) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const textarea = document.getElementById('comment-content');
|
|||
|
|
const content = textarea.value.trim();
|
|||
|
|
|
|||
|
|
if (!content) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`${apiBase}/api/proposals/${id}/comments`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'Authorization': `Bearer ${token}`,
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({ content }),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (res.ok) {
|
|||
|
|
textarea.value = '';
|
|||
|
|
loadComments();
|
|||
|
|
} else {
|
|||
|
|
alert('Failed to post comment');
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
alert('Error posting comment');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
loadProposal();
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style>
|
|||
|
|
.proposal-page {
|
|||
|
|
padding: 2rem 0;
|
|||
|
|
max-width: 800px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.proposal-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: flex-start;
|
|||
|
|
gap: 1rem;
|
|||
|
|
margin-bottom: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
h1 {
|
|||
|
|
font-size: 2rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status {
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
padding: 0.25rem 0.75rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
font-weight: 600;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-draft { background: var(--color-neutral-muted); color: var(--color-on-primary); }
|
|||
|
|
.status-discussion { background: var(--color-info); color: var(--color-on-primary); }
|
|||
|
|
.status-voting { background: var(--color-success); color: var(--color-on-primary); }
|
|||
|
|
.status-closed { background: var(--color-neutral); color: var(--color-on-primary); }
|
|||
|
|
|
|||
|
|
.meta {
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
margin-bottom: 1.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.author-link {
|
|||
|
|
color: var(--color-primary);
|
|||
|
|
text-decoration: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.author-link:hover {
|
|||
|
|
text-decoration: underline;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.description {
|
|||
|
|
line-height: 1.7;
|
|||
|
|
margin-bottom: 2rem;
|
|||
|
|
white-space: pre-wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
h2 {
|
|||
|
|
font-size: 1.25rem;
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.options-list {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.option {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
background: var(--color-surface);
|
|||
|
|
border: 2px solid var(--color-border);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
padding: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.option.votable {
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: border-color 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.option.votable:hover {
|
|||
|
|
border-color: var(--color-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.option.selected {
|
|||
|
|
border-color: var(--color-primary);
|
|||
|
|
background: var(--color-primary-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.option.winner {
|
|||
|
|
border-color: var(--color-success);
|
|||
|
|
background: var(--color-success-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.option.winner .vote-count {
|
|||
|
|
color: var(--color-success);
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.option-label {
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.option-desc {
|
|||
|
|
display: block;
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vote-count {
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vote-actions, .author-actions {
|
|||
|
|
margin-top: 2rem;
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vote-hint {
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-vote, .btn-start, .btn-close, .btn-discussion {
|
|||
|
|
background: var(--color-primary);
|
|||
|
|
color: var(--color-on-primary);
|
|||
|
|
border: none;
|
|||
|
|
padding: 0.75rem 2rem;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-size: 1rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
cursor: pointer;
|
|||
|
|
margin: 0 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-discussion {
|
|||
|
|
background: var(--color-info);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-discussion:hover {
|
|||
|
|
background: var(--color-info-hover);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-close {
|
|||
|
|
background: var(--color-neutral);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-close:hover {
|
|||
|
|
background: var(--color-neutral-hover);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-delete {
|
|||
|
|
background: var(--color-error);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-delete:hover {
|
|||
|
|
background: var(--color-error-hover);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-edit {
|
|||
|
|
background: var(--color-info);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-edit:hover {
|
|||
|
|
background: var(--color-info-hover);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.edit-modal {
|
|||
|
|
position: fixed;
|
|||
|
|
inset: 0;
|
|||
|
|
background: var(--color-overlay);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
z-index: 1000;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-content {
|
|||
|
|
background: var(--color-surface);
|
|||
|
|
border: 1px solid var(--color-border);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
padding: 2rem;
|
|||
|
|
width: 100%;
|
|||
|
|
max-width: 500px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-content h2 {
|
|||
|
|
margin-bottom: 1.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-content .form-group {
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-content label {
|
|||
|
|
display: block;
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
margin-bottom: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-content input, .modal-content textarea {
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 0.75rem;
|
|||
|
|
border: 1px solid var(--color-border);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
background: var(--color-bg);
|
|||
|
|
color: var(--color-text);
|
|||
|
|
font-family: inherit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-content input:focus, .modal-content textarea:focus {
|
|||
|
|
outline: none;
|
|||
|
|
border-color: var(--color-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-actions {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 1rem;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
margin-top: 1.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-cancel {
|
|||
|
|
background: transparent;
|
|||
|
|
border: 1px solid var(--color-border);
|
|||
|
|
color: var(--color-text);
|
|||
|
|
padding: 0.75rem 1.5rem;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-save {
|
|||
|
|
background: var(--color-primary);
|
|||
|
|
color: var(--color-on-primary);
|
|||
|
|
border: none;
|
|||
|
|
padding: 0.75rem 1.5rem;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-vote:disabled {
|
|||
|
|
opacity: 0.5;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-vote:not(:disabled):hover, .btn-start:hover {
|
|||
|
|
background: var(--color-primary-hover);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.voting-method-label {
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
text-transform: capitalize;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.credits-display {
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
color: var(--color-primary);
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.quadratic-controls {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qv-btn {
|
|||
|
|
width: 28px;
|
|||
|
|
height: 28px;
|
|||
|
|
border: 1px solid var(--color-border);
|
|||
|
|
border-radius: 4px;
|
|||
|
|
background: var(--color-bg);
|
|||
|
|
color: var(--color-text);
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qv-btn:hover {
|
|||
|
|
border-color: var(--color-primary);
|
|||
|
|
color: var(--color-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qv-value {
|
|||
|
|
min-width: 24px;
|
|||
|
|
text-align: center;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.star-rating {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.25rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.star {
|
|||
|
|
font-size: 1.25rem;
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
transition: color 0.1s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.star:hover, .star.filled {
|
|||
|
|
color: var(--color-warning);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rank-badge {
|
|||
|
|
background: var(--color-primary);
|
|||
|
|
color: var(--color-on-primary);
|
|||
|
|
padding: 0.25rem 0.5rem;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
min-width: 32px;
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rank-badge:empty {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.option.method-ranked_choice.selected .rank-badge,
|
|||
|
|
.option.method-star.selected,
|
|||
|
|
.option.method-quadratic {
|
|||
|
|
border-color: var(--color-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.results-section {
|
|||
|
|
margin-top: 2rem;
|
|||
|
|
padding: 1.5rem;
|
|||
|
|
background: var(--color-surface);
|
|||
|
|
border: 1px solid var(--color-border);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.results-section h3 {
|
|||
|
|
color: var(--color-primary);
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
font-size: 1.25rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.results-summary {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 1.5rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
margin-bottom: 1.5rem;
|
|||
|
|
padding-bottom: 1rem;
|
|||
|
|
border-bottom: 1px solid var(--color-border);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-stat {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 1rem;
|
|||
|
|
background: var(--color-bg);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
min-width: 100px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-stat .stat-value {
|
|||
|
|
font-size: 1.5rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: var(--color-text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-stat .stat-label {
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.winner-stat {
|
|||
|
|
background: linear-gradient(135deg, var(--color-primary-bg) 0%, var(--color-surface) 100%);
|
|||
|
|
border: 1px solid var(--color-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.winner-stat .stat-value {
|
|||
|
|
color: var(--color-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.method-details {
|
|||
|
|
margin-top: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.method-details h4 {
|
|||
|
|
font-size: 1rem;
|
|||
|
|
margin-bottom: 0.75rem;
|
|||
|
|
color: var(--color-text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.method-explanation {
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rounds-list {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.round {
|
|||
|
|
padding: 1rem;
|
|||
|
|
background: var(--color-bg);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.round strong {
|
|||
|
|
display: block;
|
|||
|
|
margin-bottom: 0.5rem;
|
|||
|
|
color: var(--color-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.round ul {
|
|||
|
|
margin: 0;
|
|||
|
|
padding-left: 1.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.round li {
|
|||
|
|
margin: 0.25rem 0;
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ranking-list, .star-results, .quadratic-results, .approval-results {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ranking-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 1rem;
|
|||
|
|
padding: 0.75rem;
|
|||
|
|
background: var(--color-bg);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ranking-item .rank {
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: var(--color-primary);
|
|||
|
|
min-width: 40px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ranking-item .label {
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ranking-item .score {
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.score-bar, .allocation-bar, .approval-bar {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 1rem;
|
|||
|
|
padding: 0.5rem;
|
|||
|
|
background: var(--color-bg);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.score-bar .label, .allocation-bar .label, .approval-bar .label {
|
|||
|
|
min-width: 120px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.bar-container {
|
|||
|
|
flex: 1;
|
|||
|
|
height: 24px;
|
|||
|
|
background: var(--color-border);
|
|||
|
|
border-radius: 4px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.bar-container .bar {
|
|||
|
|
height: 100%;
|
|||
|
|
background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-primary-hover) 100%);
|
|||
|
|
border-radius: 4px;
|
|||
|
|
transition: width 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.score-bar .score, .allocation-bar .score, .approval-bar .score {
|
|||
|
|
min-width: 150px;
|
|||
|
|
text-align: right;
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.runoff-details {
|
|||
|
|
margin-top: 1rem;
|
|||
|
|
padding: 1rem;
|
|||
|
|
background: var(--color-bg);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
border-left: 3px solid var(--color-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.runoff-details h5 {
|
|||
|
|
margin-bottom: 0.5rem;
|
|||
|
|
color: var(--color-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.login-prompt {
|
|||
|
|
margin-top: 2rem;
|
|||
|
|
text-align: center;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading, .error {
|
|||
|
|
text-align: center;
|
|||
|
|
padding: 3rem;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comments-section {
|
|||
|
|
margin-top: 3rem;
|
|||
|
|
padding-top: 2rem;
|
|||
|
|
border-top: 1px solid var(--color-border);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comments-section h2 {
|
|||
|
|
font-size: 1.25rem;
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-form {
|
|||
|
|
margin-bottom: 1.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-form textarea {
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 0.75rem;
|
|||
|
|
border: 1px solid var(--color-border);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
background: var(--color-surface);
|
|||
|
|
color: var(--color-text);
|
|||
|
|
font-family: inherit;
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
resize: vertical;
|
|||
|
|
min-height: 80px;
|
|||
|
|
margin-bottom: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-form textarea:focus {
|
|||
|
|
outline: none;
|
|||
|
|
border-color: var(--color-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-form button {
|
|||
|
|
background: var(--color-primary);
|
|||
|
|
color: var(--color-on-primary);
|
|||
|
|
border: none;
|
|||
|
|
padding: 0.5rem 1rem;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-form button:hover {
|
|||
|
|
background: var(--color-primary-hover);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comments-list {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment {
|
|||
|
|
background: var(--color-surface);
|
|||
|
|
border: 1px solid var(--color-border);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
padding: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-author {
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--color-primary);
|
|||
|
|
text-decoration: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-author:hover {
|
|||
|
|
text-decoration: underline;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-date {
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.comment-content {
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
white-space: pre-wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.empty-comments {
|
|||
|
|
text-align: center;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
padding: 2rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.loading-small {
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
}
|
|||
|
|
</style>
|