mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-02-09 21:13:09 +00:00
ui: refactor proposal detail and results
This commit is contained in:
parent
387746aafa
commit
5adce4a066
2 changed files with 368 additions and 347 deletions
|
|
@ -4,13 +4,14 @@
|
||||||
* Supports all voting methods with method-specific visualizations
|
* Supports all voting methods with method-specific visualizations
|
||||||
*/
|
*/
|
||||||
interface Props {
|
interface Props {
|
||||||
proposalId;
|
proposalId: string | number;
|
||||||
|
apiBase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { proposalId } = Astro.props;
|
const { proposalId, apiBase = '' } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="results-chart" data-proposal-id={proposalId}>
|
<div class="results-chart" data-proposal-id={proposalId} data-api-base={apiBase} data-enabled="0">
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<div id="results-loading" class="loading-state">
|
<div id="results-loading" class="loading-state">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
|
|
@ -36,18 +37,60 @@ const { proposalId } = Astro.props;
|
||||||
<script>
|
<script>
|
||||||
class VotingResultsChart {
|
class VotingResultsChart {
|
||||||
private container: HTMLElement;
|
private container: HTMLElement;
|
||||||
private proposalId;
|
private proposalId: string;
|
||||||
private data;
|
private data: any;
|
||||||
|
private apiBase: string;
|
||||||
|
private enabled = false;
|
||||||
|
|
||||||
constructor(container: HTMLElement) {
|
constructor(container: HTMLElement) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.proposalId = container.dataset.proposalId || '';
|
this.proposalId = container.dataset.proposalId || '';
|
||||||
|
this.apiBase = container.dataset.apiBase || '';
|
||||||
|
this.enabled = container.dataset.enabled === '1';
|
||||||
|
|
||||||
|
if (this.enabled) {
|
||||||
this.init();
|
this.init();
|
||||||
|
} else {
|
||||||
|
this.watchEnable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEnable() {
|
||||||
|
const obs = new MutationObserver(() => {
|
||||||
|
if (this.enabled) return;
|
||||||
|
if (this.container.dataset.enabled === '1') {
|
||||||
|
this.enabled = true;
|
||||||
|
obs.disconnect();
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
obs.observe(this.container, { attributes: true, attributeFilter: ['data-enabled'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(value: unknown): string {
|
||||||
|
return String(value || '').replace(/[&<>"']/g, function(ch) {
|
||||||
|
switch (ch) {
|
||||||
|
case '&': return '&';
|
||||||
|
case '<': return '<';
|
||||||
|
case '>': return '>';
|
||||||
|
case '"': return '"';
|
||||||
|
case "'": return ''';
|
||||||
|
default: return ch;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/proposals/${this.proposalId}/results`);
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
this.showError('Login required to view voting results');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${this.apiBase}/api/proposals/${this.proposalId}/results`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error('Failed to load results');
|
if (!res.ok) throw new Error('Failed to load results');
|
||||||
this.data = await res.json();
|
this.data = await res.json();
|
||||||
this.render();
|
this.render();
|
||||||
|
|
@ -56,16 +99,16 @@ const { proposalId } = Astro.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showError(message) {
|
showError(message: string): void {
|
||||||
const loading = this.container.querySelector('#results-loading');
|
const loading = this.container.querySelector<HTMLElement>('#results-loading');
|
||||||
if (loading) {
|
if (loading) {
|
||||||
loading.innerHTML = `<span class="error">${message}</span>`;
|
loading.innerHTML = `<span class="error">${message}</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render(): void {
|
||||||
const loading = this.container.querySelector('#results-loading');
|
const loading = this.container.querySelector<HTMLElement>('#results-loading');
|
||||||
const content = this.container.querySelector('#results-content');
|
const content = this.container.querySelector<HTMLElement>('#results-content');
|
||||||
|
|
||||||
if (loading) loading.style.display = 'none';
|
if (loading) loading.style.display = 'none';
|
||||||
if (content) content.style.display = 'block';
|
if (content) content.style.display = 'block';
|
||||||
|
|
@ -75,8 +118,8 @@ const { proposalId } = Astro.props;
|
||||||
this.renderDetails();
|
this.renderDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStats() {
|
renderStats(): void {
|
||||||
const statsRow = this.container.querySelector('#stats-row');
|
const statsRow = this.container.querySelector<HTMLElement>('#stats-row');
|
||||||
if (!statsRow) return;
|
if (!statsRow) return;
|
||||||
|
|
||||||
const winner = this.data.winner;
|
const winner = this.data.winner;
|
||||||
|
|
@ -97,32 +140,33 @@ const { proposalId } = Astro.props;
|
||||||
${winner ? `
|
${winner ? `
|
||||||
<div class="stat-card winner">
|
<div class="stat-card winner">
|
||||||
<svg class="icon icon-md"><use href="#icon-winner"/></svg>
|
<svg class="icon icon-md"><use href="#icon-winner"/></svg>
|
||||||
<div class="stat-value">${winner.label || 'Winner'}</div>
|
<div class="stat-value">${this.escapeHtml(winner.label || 'Winner')}</div>
|
||||||
<div class="stat-label">Winner</div>
|
<div class="stat-label">Winner</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderChart() {
|
renderChart(): void {
|
||||||
const chartArea = this.container.querySelector('#chart-area');
|
const chartArea = this.container.querySelector<HTMLElement>('#chart-area');
|
||||||
if (!chartArea || !this.data.results) return;
|
if (!chartArea || !this.data.results) return;
|
||||||
|
|
||||||
const method = this.data.voting_method;
|
const method = this.data.voting_method;
|
||||||
const results = this.data.results;
|
const results = this.data.results;
|
||||||
const maxScore = Math.max(...results.map((r) => r.votes || r.score || 0));
|
const maxScore = Math.max(...results.map((r: any) => r.votes || r.score || 0));
|
||||||
|
|
||||||
let barsHtml = results.map((result, index) => {
|
let barsHtml = results.map((result: any, index: number) => {
|
||||||
const score = result.votes || result.score || 0;
|
const score = result.votes || result.score || 0;
|
||||||
const pct = maxScore > 0 ? (score / maxScore) * 100 : 0;
|
const pct = maxScore > 0 ? (score / maxScore) * 100 : 0;
|
||||||
const isWinner = index === 0;
|
const isWinner = index === 0;
|
||||||
const methodIcon = this.getMethodIcon(method);
|
|
||||||
|
const safeLabel = this.escapeHtml(result.label);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="result-bar ${isWinner ? 'winner' : ''}" style="--bar-delay: ${index * 0.1}s">
|
<div class="result-bar ${isWinner ? 'winner' : ''}" style="--bar-delay: ${index * 0.1}s">
|
||||||
<div class="bar-header">
|
<div class="bar-header">
|
||||||
<span class="bar-rank">#${result.rank || index + 1}</span>
|
<span class="bar-rank">#${result.rank || index + 1}</span>
|
||||||
<span class="bar-label">${result.label}</span>
|
<span class="bar-label">${safeLabel}</span>
|
||||||
${isWinner ? '<svg class="icon winner-icon"><use href="#icon-winner"/></svg>' : ''}
|
${isWinner ? '<svg class="icon winner-icon"><use href="#icon-winner"/></svg>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="bar-track">
|
<div class="bar-track">
|
||||||
|
|
@ -147,15 +191,15 @@ const { proposalId } = Astro.props;
|
||||||
|
|
||||||
// Trigger animation
|
// Trigger animation
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
chartArea.querySelectorAll('.bar-fill').forEach(bar => {
|
chartArea.querySelectorAll<HTMLElement>('.bar-fill').forEach((bar) => {
|
||||||
(bar).classList.add('animate');
|
bar.classList.add('animate');
|
||||||
});
|
});
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderDetails() {
|
renderDetails(): void {
|
||||||
const detailsArea = this.container.querySelector('#details-area');
|
const detailsArea = this.container.querySelector<HTMLElement>('#details-area');
|
||||||
const roundsArea = this.container.querySelector('#rounds-area');
|
const roundsArea = this.container.querySelector<HTMLElement>('#rounds-area');
|
||||||
if (!detailsArea) return;
|
if (!detailsArea) return;
|
||||||
|
|
||||||
const method = this.data.voting_method;
|
const method = this.data.voting_method;
|
||||||
|
|
@ -184,14 +228,15 @@ const { proposalId } = Astro.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderRankedChoiceDetails(detailsArea: Element, roundsArea: Element | null, details) {
|
renderRankedChoiceDetails(detailsArea: HTMLElement, roundsArea: HTMLElement | null, details: any): void {
|
||||||
if (!details.rounds || !roundsArea) return;
|
if (!details.rounds || !roundsArea) return;
|
||||||
|
|
||||||
(roundsArea).style.display = 'block';
|
roundsArea.style.display = 'block';
|
||||||
|
|
||||||
const roundsHtml = details.rounds.map((round, i) => {
|
const roundsHtml = details.rounds.map((round: any, i: number) => {
|
||||||
const countsHtml = round.vote_counts.map(([id, count]: [string, number]) => {
|
const countsHtml = round.vote_counts.map(([id, count]: [string, number]) => {
|
||||||
const label = this.data.results?.find((r) => r.option_id === id)?.label || 'Option';
|
const labelRaw = this.data.results?.find((r: any) => r.option_id === id)?.label || 'Option';
|
||||||
|
const label = this.escapeHtml(labelRaw);
|
||||||
const isEliminated = round.eliminated === id;
|
const isEliminated = round.eliminated === id;
|
||||||
return `
|
return `
|
||||||
<div class="round-option ${isEliminated ? 'eliminated' : ''}">
|
<div class="round-option ${isEliminated ? 'eliminated' : ''}">
|
||||||
|
|
@ -222,7 +267,7 @@ const { proposalId } = Astro.props;
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSchulzeDetails(detailsArea: Element, details) {
|
renderSchulzeDetails(detailsArea: HTMLElement, details: any): void {
|
||||||
if (!details.pairwise_matrix) return;
|
if (!details.pairwise_matrix) return;
|
||||||
|
|
||||||
detailsArea.innerHTML = `
|
detailsArea.innerHTML = `
|
||||||
|
|
@ -239,11 +284,13 @@ const { proposalId } = Astro.props;
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStarDetails(detailsArea: Element, details) {
|
renderStarDetails(detailsArea: HTMLElement, details: any): void {
|
||||||
if (!details.finalists) return;
|
if (!details.finalists) return;
|
||||||
|
|
||||||
const finalist1 = this.data.results?.find((r) => r.option_id === details.finalists[0])?.label || 'Option A';
|
const finalist1Raw = this.data.results?.find((r: any) => r.option_id === details.finalists[0])?.label || 'Option A';
|
||||||
const finalist2 = this.data.results?.find((r) => r.option_id === details.finalists[1])?.label || 'Option B';
|
const finalist2Raw = this.data.results?.find((r: any) => r.option_id === details.finalists[1])?.label || 'Option B';
|
||||||
|
const finalist1 = this.escapeHtml(finalist1Raw);
|
||||||
|
const finalist2 = this.escapeHtml(finalist2Raw);
|
||||||
|
|
||||||
detailsArea.innerHTML = `
|
detailsArea.innerHTML = `
|
||||||
<div class="star-details">
|
<div class="star-details">
|
||||||
|
|
@ -266,7 +313,7 @@ const { proposalId } = Astro.props;
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderQuadraticDetails(detailsArea: Element, details) {
|
renderQuadraticDetails(detailsArea: HTMLElement, details: any): void {
|
||||||
detailsArea.innerHTML = `
|
detailsArea.innerHTML = `
|
||||||
<div class="quadratic-details">
|
<div class="quadratic-details">
|
||||||
<h4>
|
<h4>
|
||||||
|
|
@ -282,7 +329,7 @@ const { proposalId } = Astro.props;
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMethodIcon(method) {
|
getMethodIcon(method: string): string {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
'approval': 'icon-approval',
|
'approval': 'icon-approval',
|
||||||
'ranked_choice': 'icon-ranked-choice',
|
'ranked_choice': 'icon-ranked-choice',
|
||||||
|
|
@ -293,7 +340,7 @@ const { proposalId } = Astro.props;
|
||||||
return icons[method] || 'icon-results';
|
return icons[method] || 'icon-results';
|
||||||
}
|
}
|
||||||
|
|
||||||
getMethodName(method) {
|
getMethodName(method: string): string {
|
||||||
const names: Record<string, string> = {
|
const names: Record<string, string> = {
|
||||||
'approval': 'Approval Voting',
|
'approval': 'Approval Voting',
|
||||||
'ranked_choice': 'Ranked Choice',
|
'ranked_choice': 'Ranked Choice',
|
||||||
|
|
@ -304,7 +351,7 @@ const { proposalId } = Astro.props;
|
||||||
return names[method] || 'Voting';
|
return names[method] || 'Voting';
|
||||||
}
|
}
|
||||||
|
|
||||||
formatScore(score, method) {
|
formatScore(score: any, method: string): string {
|
||||||
if (method === 'quadratic') return `${score} effective votes`;
|
if (method === 'quadratic') return `${score} effective votes`;
|
||||||
if (method === 'star') return `${score} points`;
|
if (method === 'star') return `${score} points`;
|
||||||
return `${score} votes`;
|
return `${score} votes`;
|
||||||
|
|
@ -312,7 +359,7 @@ const { proposalId } = Astro.props;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize all charts on page
|
// Initialize all charts on page
|
||||||
document.querySelectorAll('.results-chart').forEach(el => {
|
document.querySelectorAll<HTMLElement>('.results-chart').forEach((el) => {
|
||||||
new VotingResultsChart(el);
|
new VotingResultsChart(el);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,19 @@
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
import { API_BASE as apiBase } from '../../lib/api';
|
import { API_BASE as apiBase } from '../../lib/api';
|
||||||
|
import VotingResultsChart from '../../components/voting/VotingResultsChart.astro';
|
||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Proposal">
|
<Layout title="Proposal">
|
||||||
<section class="proposal-page">
|
<section class="ui-page">
|
||||||
|
<div class="ui-container ui-proposal-container">
|
||||||
<div id="proposal-content">
|
<div id="proposal-content">
|
||||||
<p class="loading">Loading proposal...</p>
|
<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={id} apiBase={apiBase} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
@ -21,6 +27,31 @@ const { id } = Astro.params;
|
||||||
let quadraticAllocations = {};
|
let quadraticAllocations = {};
|
||||||
let starRatings = {};
|
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) {
|
function getVotingHint(method) {
|
||||||
const hints = {
|
const hints = {
|
||||||
'approval': 'Click options to select, then submit your vote',
|
'approval': 'Click options to select, then submit your vote',
|
||||||
|
|
@ -42,34 +73,52 @@ const { id } = Astro.params;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
currentProposal = data;
|
currentProposal = data;
|
||||||
|
|
||||||
const isDraft = data.proposal.status === 'draft';
|
const statusKey = normalizeStatus(data.proposal.status);
|
||||||
const isDiscussion = data.proposal.status === 'discussion';
|
const methodKey = normalizeMethod(data.proposal.voting_method);
|
||||||
const isVoting = data.proposal.status === 'voting';
|
const isDraft = statusKey === 'draft';
|
||||||
const isClosed = data.proposal.status === 'closed';
|
const isDiscussion = statusKey === 'discussion';
|
||||||
|
const isVoting = statusKey === 'voting';
|
||||||
|
const isClosed = statusKey === 'closed';
|
||||||
const canStartDiscussion = token && isDraft;
|
const canStartDiscussion = token && isDraft;
|
||||||
const canStartVoting = token && (isDraft || isDiscussion);
|
const canStartVoting = token && (isDraft || isDiscussion);
|
||||||
const canCloseVoting = token && isVoting;
|
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 = `
|
container.innerHTML = `
|
||||||
<div class="proposal-header">
|
<div class="proposal-hero ui-card ui-card-glass">
|
||||||
<h1>${data.proposal.title}</h1>
|
<div class="hero-top">
|
||||||
<span class="status status-${data.proposal.status}">${data.proposal.status}</span>
|
<div class="hero-title">
|
||||||
</div>
|
<h1>${safeTitle}</h1>
|
||||||
<p class="meta">
|
<div class="hero-meta">
|
||||||
by <a href="/users/${data.author_name}" class="author-link">${data.author_name}</a>
|
<span class="status 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()}
|
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_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>` : ''}
|
${data.proposal.voting_ends_at ? `<span class="voting-dates"> | Ends: ${new Date(data.proposal.voting_ends_at).toLocaleDateString()}</span>` : ''}
|
||||||
</p>
|
</span>
|
||||||
<div class="description">${data.proposal.description}</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="description">${safeDescription}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="options-section">
|
<div class="proposal-panels">
|
||||||
<h2>Options</h2>
|
<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">
|
<div class="options-list" id="options-list">
|
||||||
${(() => {
|
${(() => {
|
||||||
const maxVotes = Math.max(...data.options.map(o => o.vote_count));
|
const maxVotes = Math.max(...data.options.map(o => o.vote_count));
|
||||||
const hasVotes = maxVotes > 0;
|
const hasVotes = maxVotes > 0;
|
||||||
const method = data.proposal.voting_method;
|
const method = methodKey;
|
||||||
return data.options.map(opt => {
|
return data.options.map(opt => {
|
||||||
const isWinner = isClosed && hasVotes && opt.vote_count === maxVotes;
|
const isWinner = isClosed && hasVotes && opt.vote_count === maxVotes;
|
||||||
let votingUI = '';
|
let votingUI = '';
|
||||||
|
|
@ -88,11 +137,15 @@ const { id } = Astro.params;
|
||||||
votingUI = `<span class="rank-badge" data-id="${opt.id}"></span>`;
|
votingUI = `<span class="rank-badge" data-id="${opt.id}"></span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeLabel = escapeHtml(opt.label);
|
||||||
|
const safeOptDesc = opt.description ? escapeHtml(opt.description) : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="option ${isVoting ? 'votable' : ''} ${isWinner ? 'winner' : ''} method-${method}" data-id="${opt.id}">
|
<div class="option ${isVoting ? 'votable' : ''} ${isWinner ? 'winner' : ''} method-${method}" data-id="${opt.id}">
|
||||||
<div class="option-info">
|
<div class="option-info">
|
||||||
<span class="option-label">${opt.label} ${isWinner ? '🏆' : ''}</span>
|
<span class="option-label">${safeLabel} ${isWinner ? '🏆' : ''}</span>
|
||||||
${opt.description ? `<span class="option-desc">${opt.description}</span>` : ''}
|
${safeOptDesc ? `<span class="option-desc">${safeOptDesc}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${votingUI}
|
${votingUI}
|
||||||
<div class="vote-count">${opt.vote_count} votes</div>
|
<div class="vote-count">${opt.vote_count} votes</div>
|
||||||
|
|
@ -101,45 +154,13 @@ const { id } = Astro.params;
|
||||||
}).join('');
|
}).join('');
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
${isVoting && token ? `
|
${isVoting && token ? `
|
||||||
<div class="vote-actions">
|
<div class="vote-actions">
|
||||||
<p class="vote-hint">${getVotingHint(data.proposal.voting_method)}</p>
|
<p class="vote-hint">${escapeHtml(getVotingHint(methodKey))}</p>
|
||||||
<p class="voting-method-label">Method: <strong>${data.proposal.voting_method.replace('_', ' ')}</strong></p>
|
<p class="voting-method-label">Method: <strong>${safeMethodLabel}</strong></p>
|
||||||
${data.proposal.voting_method === 'quadratic' ? '<p class="credits-display">Credits used: <span id="credits-used">0</span>/100</p>' : ''}
|
${methodKey === '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>
|
<button id="submit-vote" class="ui-btn ui-btn-primary" 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>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
|
@ -148,19 +169,71 @@ const { id } = Astro.params;
|
||||||
<a href="/login">Login</a> to vote on this proposal
|
<a href="/login">Login</a> to vote on this proposal
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="comments-section">
|
${canStartDiscussion ? `
|
||||||
<h2>Discussion</h2>
|
<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 ? `
|
${token ? `
|
||||||
<form id="comment-form" class="comment-form">
|
<form id="comment-form" class="comment-form">
|
||||||
<textarea id="comment-content" placeholder="Add your comment..." required></textarea>
|
<textarea id="comment-content" placeholder="Add your comment..." required></textarea>
|
||||||
<button type="submit">Post Comment</button>
|
<div class="comment-actions">
|
||||||
|
<button type="submit" class="ui-btn ui-btn-primary">Post Comment</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
` : `<p class="login-prompt"><a href="/login">Login</a> to comment</p>`}
|
` : `<p class="login-prompt"><a href="/login">Login</a> to comment</p>`}
|
||||||
<div id="comments-list" class="comments-list">
|
<div id="comments-list" class="comments-list">
|
||||||
<p class="loading-small">Loading comments...</p>
|
<p class="loading-small">Loading comments...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Setup voting interaction based on method
|
// Setup voting interaction based on method
|
||||||
|
|
@ -258,142 +331,26 @@ const { id } = Astro.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
container.innerHTML = '<p class="error">Login required to view detailed results</p>';
|
container.innerHTML = '<p class="error">Login required to view results</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${apiBase}/api/proposals/${id}/results`, {
|
const host = document.getElementById('results-chart-host');
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
const chart = host?.querySelector('.results-chart');
|
||||||
});
|
if (!host || !chart) {
|
||||||
if (!res.ok) {
|
container.innerHTML = '<p class="error">Results chart unavailable</p>';
|
||||||
container.innerHTML = '<p class="error">Could not load detailed results</p>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
host.style.display = 'block';
|
||||||
const method = currentProposal?.proposal?.voting_method || 'approval';
|
host.setAttribute('aria-hidden', 'false');
|
||||||
|
|
||||||
let resultsHTML = `
|
if (!container.contains(chart)) {
|
||||||
<div class="results-summary">
|
container.innerHTML = '';
|
||||||
<div class="result-stat">
|
container.appendChild(chart);
|
||||||
<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;
|
chart.dataset.enabled = '1';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
container.innerHTML = '<p class="error">Error loading results</p>';
|
container.innerHTML = '<p class="error">Error loading results</p>';
|
||||||
}
|
}
|
||||||
|
|
@ -561,15 +518,20 @@ const { id } = Astro.params;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = comments.map(c => `
|
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">
|
||||||
<div class="comment-header">
|
<div class="comment-header">
|
||||||
<a href="/users/${c.author_name}" class="comment-author">${c.author_name}</a>
|
<a href="${authorHref}" class="comment-author">${authorName}</a>
|
||||||
<span class="comment-date">${new Date(c.created_at).toLocaleString()}</span>
|
<span class="comment-date">${new Date(c.created_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content">${c.content}</div>
|
<div class="comment-content">${content}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
container.innerHTML = '<p class="error-small">Failed to load comments</p>';
|
container.innerHTML = '<p class="error-small">Failed to load comments</p>';
|
||||||
}
|
}
|
||||||
|
|
@ -607,21 +569,82 @@ const { id } = Astro.params;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.proposal-page {
|
.ui-proposal-container {
|
||||||
padding: 2rem 0;
|
max-width: 880px;
|
||||||
max-width: 800px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.proposal-header {
|
.results-chart-host {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-hero {
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 0.5rem;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.hero-title h1 {
|
||||||
font-size: 2rem;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-panels {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel[open] summary {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-hint {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
|
|
@ -641,7 +664,6 @@ const { id } = Astro.params;
|
||||||
.meta {
|
.meta {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.author-link {
|
.author-link {
|
||||||
|
|
@ -659,11 +681,6 @@ const { id } = Astro.params;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options-list {
|
.options-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|
@ -720,7 +737,6 @@ const { id } = Astro.params;
|
||||||
|
|
||||||
.vote-actions, .author-actions {
|
.vote-actions, .author-actions {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vote-hint {
|
.vote-hint {
|
||||||
|
|
@ -728,48 +744,10 @@ const { id } = Astro.params;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-vote, .btn-start, .btn-close, .btn-discussion {
|
.comment-actions {
|
||||||
background: var(--color-primary);
|
margin-top: 0.5rem;
|
||||||
color: var(--color-on-primary);
|
display: flex;
|
||||||
border: none;
|
justify-content: flex-end;
|
||||||
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 {
|
.edit-modal {
|
||||||
|
|
@ -850,10 +828,6 @@ const { id } = Astro.params;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-vote:not(:disabled):hover, .btn-start:hover {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-method-label {
|
.voting-method-label {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue