mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-06-25 15:37:42 +00:00
Verified changes: - modify frontend/src/components/ui/DesignSystemStyles.astro - modify frontend/src/pages/communities/[slug]/index.astro - modify frontend/src/pages/communities/[slug]/proposals/index.astro - modify frontend/src/pages/dashboard.astro - modify frontend/src/pages/notifications.astro - modify frontend/src/pages/proposals.astro - modify frontend/src/pages/proposals/[id].astro - modify frontend/src/pages/users/[username].astro Diffstat: - 8 files changed, 93 insertions(+), 116 deletions(-)
1227 lines
35 KiB
Text
1227 lines
35 KiB
Text
---
|
||
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 '&';
|
||
case '<': return '<';
|
||
case '>': return '>';
|
||
case '"': return '"';
|
||
case "'": return ''';
|
||
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="ui-btn ui-btn-secondary" id="cancel-edit">Cancel</button>
|
||
<button type="submit" class="ui-btn ui-btn-primary">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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.card {
|
||
padding: 2rem;
|
||
}
|
||
</style>
|