likwid/frontend/src/pages/proposals/[id].astro
Marco Allegretti 686daee158 frontend: modify 1 file
Verified changes:
- modify frontend/src/pages/proposals/[id].astro

Diffstat:
- 1 file changed, 106 insertions(+)
2026-01-29 18:24:39 +01:00

1256 lines
36 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
export const prerender = false;
import Layout from '../../layouts/Layout.astro';
import { API_BASE as apiBase } from '../../lib/api';
import VotingResultsChart from '../../components/voting/VotingResultsChart.astro';
const { id } = Astro.params;
const proposalId = id ?? '';
---
<Layout title="Proposal">
<section class="ui-page">
<div class="ui-container ui-proposal-container">
<div id="proposal-content">
<div class="state-card ui-card"><p class="loading">Loading proposal…</p></div>
</div>
<div id="results-chart-host" class="results-chart-host" aria-hidden="true">
<VotingResultsChart proposalId={proposalId} apiBase={apiBase} />
</div>
</div>
</section>
</Layout>
<script define:vars={{ id: proposalId, apiBase }}>
const token = localStorage.getItem('token');
let currentProposal = null;
let selectedOptions = new Set();
let rankedSelections = [];
let quadraticAllocations = {};
let starRatings = {};
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 normalizeStatus(status) {
const s = String(status || '').toLowerCase();
if (s === 'draft' || s === 'discussion' || s === 'voting' || s === 'closed') return s;
return 'draft';
}
function normalizeMethod(method) {
const m = String(method || '').toLowerCase();
if (m === 'approval' || m === 'ranked_choice' || m === 'quadratic' || m === 'star' || m === 'schulze') return m;
return 'approval';
}
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'];
}
function getVotingExplainer(method) {
const m = normalizeMethod(method);
if (m === 'ranked_choice') {
return {
hint: 'Ranked-choice voting',
lede: 'You rank options in order of preference. If your top choice cannot win, your vote transfers to your next choice.',
bullets: [
'Click options in order: 1st choice, then 2nd, then 3rd.',
'Click an already-ranked option to remove it and re-number the ranks.',
'Add as many ranks as you like—more ranks can make your intent clearer.',
],
note: 'Tip: if you only rank one option, your vote will not transfer.'
};
}
if (m === 'quadratic') {
return {
hint: 'Quadratic voting',
lede: 'You allocate credits across options. Extra support costs more (1 vote costs 1 credit, 2 votes cost 4, 3 votes cost 9, etc.).',
bullets: [
'Use + / to allocate votes (credits) per option.',
'Watch the total credits used so you do not exceed the limit.',
'Spread credits to express nuance, or concentrate them to signal priority.'
],
note: 'Tip: quadratic cost makes it expensive to “pile on” many votes for one option.'
};
}
if (m === 'star') {
return {
hint: 'Star voting',
lede: 'You rate each option from 0 to 5 stars. Higher ratings show stronger support across multiple options.',
bullets: [
'Click stars on each option to set your rating.',
'You can rate multiple options highly if you like several outcomes.',
'Use low ratings (or 0) to express opposition or disinterest.'
],
note: 'Tip: star voting captures intensity without forcing a single pick.'
};
}
if (m === 'schulze') {
return {
hint: 'Schulze (Condorcet) method',
lede: 'This method compares options pairwise to find the strongest overall winner based on collective preferences.',
bullets: [
'Think in terms of which option you prefer over another, not just a single favorite.',
'If you are asked to rank options, include as many as you can for clarity.',
'Results aim to reflect broad preference strength across matchups.'
],
note: 'Tip: ranking more options helps express your true preferences.'
};
}
return {
hint: 'Approval voting',
lede: 'You can approve multiple options. Each approved option gets one vote from you.',
bullets: [
'Click to select one or more options you can live with.',
'Approving several options signals you are open to compromise.',
'Submit when your selections match your intent.'
],
note: 'Tip: approval voting is great when there are several acceptable outcomes.'
};
}
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 statusKey = normalizeStatus(data.proposal.status);
const methodKey = normalizeMethod(data.proposal.voting_method);
const isDraft = statusKey === 'draft';
const isDiscussion = statusKey === 'discussion';
const isVoting = statusKey === 'voting';
const isClosed = statusKey === 'closed';
const canStartDiscussion = token && isDraft;
const canStartVoting = token && (isDraft || isDiscussion);
const canCloseVoting = token && isVoting;
const safeTitle = escapeHtml(data.proposal.title);
const safeAuthor = escapeHtml(data.author_name);
const safeDescription = escapeHtml(data.proposal.description);
const safeMethodLabel = escapeHtml(methodKey.replace('_', ' '));
container.innerHTML = `
<div class="proposal-hero ui-card ui-card-glass">
<div class="hero-top">
<div class="hero-title">
<h1>${safeTitle}</h1>
<div class="hero-meta">
<span class="ui-pill status-${statusKey}">${escapeHtml(statusKey)}</span>
<span class="meta">
by <a href="/users/${encodeURIComponent(String(data.author_name))}" class="author-link">${safeAuthor}</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>` : ''}
</span>
</div>
</div>
</div>
<div class="description">${safeDescription}</div>
</div>
<div class="proposal-panels">
<details class="panel ui-card" open>
<summary>
<span>Options</span>
<span class="panel-hint">Your choices</span>
</summary>
<div class="panel-body">
<div class="options-list" id="options-list">
${(() => {
const maxVotes = Math.max(...data.options.map(o => o.vote_count));
const hasVotes = maxVotes > 0;
const method = methodKey;
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>`;
}
}
const safeLabel = escapeHtml(opt.label);
const safeOptDesc = opt.description ? escapeHtml(opt.description) : '';
return `
<div class="option ${isVoting ? 'votable' : ''} ${isWinner ? 'winner' : ''} method-${method}" data-id="${opt.id}">
<div class="option-info">
<span class="option-label">${safeLabel} ${isWinner ? '🏆' : ''}</span>
${safeOptDesc ? `<span class="option-desc">${safeOptDesc}</span>` : ''}
</div>
${votingUI}
<div class="vote-count">${opt.vote_count} votes</div>
</div>
`;
}).join('');
})()}
</div>
${isVoting && token ? `
<div class="vote-actions">
<p class="vote-hint">${escapeHtml(getVotingHint(methodKey))}</p>
<p class="voting-method-label">Method: <strong>${safeMethodLabel}</strong></p>
${methodKey === 'quadratic' ? '<p class="credits-display">Credits used: <span id="credits-used">0</span>/100</p>' : ''}
<button id="submit-vote" class="ui-btn ui-btn-primary" disabled>Submit Vote</button>
</div>
` : ''}
${!token && isVoting ? `
<div class="login-prompt">
<a href="/login">Login</a> to vote on this proposal
</div>
` : ''}
</div>
</details>
<details class="panel ui-card" open>
<summary>
<span>How voting works</span>
<span class="panel-hint">${escapeHtml(getVotingExplainer(methodKey).hint)}</span>
</summary>
<div class="panel-body">
${(() => {
const explainer = getVotingExplainer(methodKey);
return `
<p class="explainer-lede">${escapeHtml(explainer.lede)}</p>
<ul class="explainer-list">
${explainer.bullets.map((b) => `<li>${escapeHtml(b)}</li>`).join('')}
</ul>
${explainer.note ? `<p class="explainer-note">${escapeHtml(explainer.note)}</p>` : ''}
`;
})()}
</div>
</details>
${canStartDiscussion ? `
<div class="author-actions ui-card">
<div class="author-actions-row">
<button id="edit-proposal" class="ui-btn ui-btn-secondary">Edit Proposal</button>
<button id="start-discussion" class="ui-btn ui-btn-info">Open for Discussion</button>
<button id="start-voting" class="ui-btn ui-btn-primary">Skip to Voting</button>
<button id="delete-proposal" class="ui-btn ui-btn-danger">Delete Proposal</button>
</div>
</div>
` : ''}
${isDiscussion && token ? `
<div class="author-actions ui-card">
<div class="author-actions-row">
<button id="edit-proposal" class="ui-btn ui-btn-secondary">Edit Proposal</button>
<button id="start-voting" class="ui-btn ui-btn-primary">Start Voting</button>
</div>
</div>
` : ''}
${canCloseVoting ? `
<div class="author-actions ui-card">
<div class="author-actions-row">
<button id="close-voting" class="ui-btn ui-btn-secondary">Close Voting</button>
</div>
</div>
` : ''}
${isClosed ? `
<details class="panel ui-card" open>
<summary>
<span>Voting Results</span>
<span class="panel-hint">Summary + method details</span>
</summary>
<div class="panel-body">
<div id="detailed-results" class="detailed-results">
<p class="loading-small">Loading results…</p>
</div>
</div>
</details>
` : ''}
<details class="panel ui-card" open>
<summary>
<span>Discussion</span>
<span class="panel-hint">Comments</span>
</summary>
<div class="panel-body">
${token ? `
<form id="comment-form" class="comment-form">
<textarea id="comment-content" placeholder="Add your comment..." required></textarea>
<div class="comment-actions">
<button type="submit" class="ui-btn ui-btn-primary">Post Comment</button>
</div>
</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>
</details>
</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 {
if (!token) {
container.innerHTML = '<p class="error">Login required to view results</p>';
return;
}
const host = document.getElementById('results-chart-host');
const chart = host?.querySelector('.results-chart');
if (!host || !chart) {
container.innerHTML = '<p class="error">Results chart unavailable</p>';
return;
}
host.style.display = 'block';
host.setAttribute('aria-hidden', 'false');
if (!container.contains(chart)) {
container.innerHTML = '';
container.appendChild(chart);
}
chart.dataset.enabled = '1';
} 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 }),
});
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-list');
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 => {
const authorName = escapeHtml(c.author_name);
const authorHref = `/users/${encodeURIComponent(String(c.author_name))}`;
const content = escapeHtml(c.content);
return `
<div class="comment">
<div class="comment-header">
<a href="${authorHref}" class="comment-author">${authorName}</a>
<span class="comment-date">${new Date(c.created_at).toLocaleString()}</span>
</div>
<div class="comment-content">${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>
.ui-proposal-container {
max-width: 880px;
}
.results-chart-host {
display: none;
}
.proposal-hero {
padding: 1.25rem;
margin-bottom: 1rem;
}
.hero-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.hero-title h1 {
margin: 0;
font-size: 2.125rem;
letter-spacing: -0.02em;
}
.hero-meta {
display: flex;
align-items: baseline;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.author-actions-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.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;
}
.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;
}
.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;
}
.vote-hint {
color: var(--color-text-muted);
margin-bottom: 1rem;
}
.explainer-lede {
margin: 0;
color: var(--color-text);
line-height: 1.6;
}
.explainer-list {
margin: 0.75rem 0 0;
padding-left: 1.1rem;
color: var(--color-text-muted);
line-height: 1.55;
display: grid;
gap: 0.35rem;
}
.explainer-note {
margin: 0.75rem 0 0;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.comment-actions {
margin-top: 0.5rem;
display: flex;
justify-content: flex-end;
}
.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;
}
.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>