2026-01-27 16:21:58 +00:00
|
|
|
---
|
|
|
|
|
export const prerender = false;
|
|
|
|
|
import Layout from '../../../../layouts/Layout.astro';
|
|
|
|
|
import { API_BASE as apiBase } from '../../../../lib/api';
|
|
|
|
|
const { slug } = Astro.params;
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
<Layout title="Proposals">
|
2026-01-29 12:13:46 +00:00
|
|
|
<section class="ui-page">
|
|
|
|
|
<div class="ui-container">
|
2026-01-29 20:40:25 +00:00
|
|
|
<div class="ui-hero ui-card ui-card-glass">
|
|
|
|
|
<div class="ui-hero-top">
|
2026-01-29 12:13:46 +00:00
|
|
|
<div>
|
2026-01-29 20:40:25 +00:00
|
|
|
<h1 class="ui-hero-title">Proposals</h1>
|
|
|
|
|
<p class="ui-hero-subtitle">Proposals and decisions</p>
|
2026-01-29 12:13:46 +00:00
|
|
|
</div>
|
|
|
|
|
<a href={`/communities/${slug}/proposals/new`} class="ui-btn ui-btn-primary" id="create-btn" style="display: none;">+ New Proposal</a>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="hero-kpis">
|
|
|
|
|
<div class="hero-kpis-label">At a glance</div>
|
|
|
|
|
<div class="ui-kpis" id="kpis">
|
|
|
|
|
<div class="ui-kpi">
|
|
|
|
|
<div class="ui-kpi-value" id="kpi-total">—</div>
|
|
|
|
|
<div class="ui-kpi-label">Total</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ui-kpi">
|
|
|
|
|
<div class="ui-kpi-value" id="kpi-voting">—</div>
|
|
|
|
|
<div class="ui-kpi-label">Voting</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ui-kpi">
|
|
|
|
|
<div class="ui-kpi-value" id="kpi-discussion">—</div>
|
|
|
|
|
<div class="ui-kpi-label">Discussion</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ui-kpi">
|
|
|
|
|
<div class="ui-kpi-value" id="kpi-draft">—</div>
|
|
|
|
|
<div class="ui-kpi-label">Drafts</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ui-kpi">
|
|
|
|
|
<div class="ui-kpi-value" id="kpi-closed">—</div>
|
|
|
|
|
<div class="ui-kpi-label">Closed</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-27 16:21:58 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-01-29 12:13:46 +00:00
|
|
|
<div class="toolbar ui-card">
|
|
|
|
|
<div class="toolbar-grid">
|
|
|
|
|
<div>
|
|
|
|
|
<label class="toolbar-label" for="search-input">Search</label>
|
|
|
|
|
<input type="text" id="search-input" class="ui-input" placeholder="Search proposals…" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label class="toolbar-label" for="status-filter">Status</label>
|
|
|
|
|
<select id="status-filter" class="ui-select">
|
|
|
|
|
<option value="">All statuses</option>
|
|
|
|
|
<option value="draft">Draft</option>
|
|
|
|
|
<option value="discussion">Discussion</option>
|
|
|
|
|
<option value="voting">Voting</option>
|
|
|
|
|
<option value="closed">Closed</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label class="toolbar-label" for="sort-select">Sort</label>
|
|
|
|
|
<select id="sort-select" class="ui-select">
|
|
|
|
|
<option value="newest">Newest</option>
|
|
|
|
|
<option value="oldest">Oldest</option>
|
|
|
|
|
<option value="status">Status</option>
|
|
|
|
|
<option value="title">Title</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="proposals-list" class="list">
|
|
|
|
|
<div class="list-skeleton">
|
|
|
|
|
<div class="skeleton-card"></div>
|
|
|
|
|
<div class="skeleton-card"></div>
|
|
|
|
|
<div class="skeleton-card"></div>
|
|
|
|
|
<div class="skeleton-card"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-27 16:21:58 +00:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</Layout>
|
|
|
|
|
|
|
|
|
|
<script define:vars={{ slug, apiBase }}>
|
|
|
|
|
let allProposals = [];
|
|
|
|
|
|
2026-01-29 12:13:46 +00:00
|
|
|
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 updateKpis(proposals) {
|
|
|
|
|
const total = proposals.length;
|
|
|
|
|
const byStatus = { draft: 0, discussion: 0, voting: 0, closed: 0 };
|
|
|
|
|
proposals.forEach((p) => {
|
|
|
|
|
const key = normalizeStatus(p.status);
|
|
|
|
|
byStatus[key] = (byStatus[key] || 0) + 1;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const setText = (id, value) => {
|
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
if (el) el.textContent = String(value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setText('kpi-total', total);
|
|
|
|
|
setText('kpi-draft', byStatus.draft);
|
|
|
|
|
setText('kpi-discussion', byStatus.discussion);
|
|
|
|
|
setText('kpi-voting', byStatus.voting);
|
|
|
|
|
setText('kpi-closed', byStatus.closed);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 16:21:58 +00:00
|
|
|
async function loadProposals() {
|
|
|
|
|
const container = document.getElementById('proposals-list');
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const commRes = await fetch(`${apiBase}/api/communities`);
|
|
|
|
|
const communities = await commRes.json();
|
|
|
|
|
const community = communities.find(c => c.slug === slug);
|
|
|
|
|
|
|
|
|
|
if (!community) {
|
|
|
|
|
container.innerHTML = '<div class="error">Community not found</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const res = await fetch(`${apiBase}/api/communities/${community.id}/proposals`);
|
|
|
|
|
allProposals = await res.json();
|
2026-01-29 12:13:46 +00:00
|
|
|
updateKpis(allProposals);
|
|
|
|
|
filterProposals();
|
2026-01-27 16:21:58 +00:00
|
|
|
} catch (error) {
|
2026-01-29 12:13:46 +00:00
|
|
|
container.innerHTML = '<div class="ui-card"><p class="error">Failed to load proposals.</p></div>';
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderProposals(proposals) {
|
|
|
|
|
const container = document.getElementById('proposals-list');
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
if (proposals.length === 0) {
|
2026-01-29 12:13:46 +00:00
|
|
|
container.innerHTML = '<div class="ui-card"><p class="empty">No proposals found.</p></div>';
|
2026-01-27 16:21:58 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:13:46 +00:00
|
|
|
container.innerHTML = proposals.map(p => {
|
|
|
|
|
const statusKey = normalizeStatus(p.status);
|
|
|
|
|
const safeTitle = escapeHtml(p.title);
|
|
|
|
|
const desc = String(p.description || '');
|
|
|
|
|
const safeDesc = escapeHtml(desc.substring(0, 160));
|
|
|
|
|
const suffix = desc.length > 160 ? '…' : '';
|
|
|
|
|
const createdAt = p.created_at ? new Date(p.created_at).toLocaleDateString() : '';
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<a href="/proposals/${encodeURIComponent(String(p.id))}" class="proposal-card ui-card">
|
|
|
|
|
<div class="proposal-header">
|
|
|
|
|
<h3>${safeTitle}</h3>
|
2026-01-29 16:45:49 +00:00
|
|
|
<span class="ui-pill status-${statusKey}">${escapeHtml(statusKey)}</span>
|
2026-01-29 12:13:46 +00:00
|
|
|
</div>
|
|
|
|
|
${createdAt ? `<div class="proposal-meta">Created ${escapeHtml(createdAt)}</div>` : ''}
|
|
|
|
|
<p class="description">${safeDesc}${suffix}</p>
|
|
|
|
|
</a>
|
|
|
|
|
`;
|
|
|
|
|
}).join('');
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function filterProposals() {
|
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
|
|
|
const statusFilter = document.getElementById('status-filter');
|
2026-01-29 12:13:46 +00:00
|
|
|
const sortSelect = document.getElementById('sort-select');
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
const query = searchInput?.value.toLowerCase().trim() || '';
|
|
|
|
|
const status = statusFilter?.value || '';
|
2026-01-29 12:13:46 +00:00
|
|
|
const sortKey = sortSelect?.value || 'newest';
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
let filtered = allProposals;
|
|
|
|
|
|
|
|
|
|
if (query) {
|
|
|
|
|
filtered = filtered.filter(p =>
|
|
|
|
|
p.title.toLowerCase().includes(query) ||
|
|
|
|
|
p.description.toLowerCase().includes(query)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (status) {
|
|
|
|
|
filtered = filtered.filter(p => p.status === status);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:13:46 +00:00
|
|
|
const getTime = (p) => {
|
|
|
|
|
const d = p.created_at ? Date.parse(p.created_at) : NaN;
|
|
|
|
|
if (!Number.isNaN(d)) return d;
|
|
|
|
|
const asNum = Number(p.id);
|
|
|
|
|
if (!Number.isNaN(asNum)) return asNum;
|
|
|
|
|
return 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
filtered = [...filtered];
|
|
|
|
|
if (sortKey === 'newest') {
|
|
|
|
|
filtered.sort((a, b) => getTime(b) - getTime(a));
|
|
|
|
|
} else if (sortKey === 'oldest') {
|
|
|
|
|
filtered.sort((a, b) => getTime(a) - getTime(b));
|
|
|
|
|
} else if (sortKey === 'title') {
|
|
|
|
|
filtered.sort((a, b) => String(a.title || '').localeCompare(String(b.title || '')));
|
|
|
|
|
} else if (sortKey === 'status') {
|
|
|
|
|
const order = { voting: 0, discussion: 1, draft: 2, closed: 3 };
|
|
|
|
|
filtered.sort((a, b) => (order[normalizeStatus(a.status)] ?? 99) - (order[normalizeStatus(b.status)] ?? 99));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 16:21:58 +00:00
|
|
|
renderProposals(filtered);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadProposals();
|
|
|
|
|
|
|
|
|
|
document.getElementById('search-input')?.addEventListener('input', filterProposals);
|
|
|
|
|
document.getElementById('status-filter')?.addEventListener('change', filterProposals);
|
2026-01-29 12:13:46 +00:00
|
|
|
document.getElementById('sort-select')?.addEventListener('change', filterProposals);
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
const token = localStorage.getItem('token');
|
|
|
|
|
const createBtn = document.getElementById('create-btn');
|
|
|
|
|
if (token && createBtn) {
|
2026-01-29 12:13:46 +00:00
|
|
|
createBtn.style.display = 'inline-flex';
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style>
|
2026-01-29 12:13:46 +00:00
|
|
|
.hero-kpis {
|
|
|
|
|
margin-top: 1rem;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:13:46 +00:00
|
|
|
.hero-kpis-label {
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
font-size: 0.8125rem;
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
letter-spacing: 0.02em;
|
|
|
|
|
text-transform: uppercase;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:13:46 +00:00
|
|
|
.toolbar {
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
margin-bottom: 1rem;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:13:46 +00:00
|
|
|
.toolbar-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1.5fr 1fr 1fr;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
align-items: end;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:13:46 +00:00
|
|
|
.toolbar-label {
|
|
|
|
|
display: block;
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
letter-spacing: 0.02em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
margin-bottom: 0.35rem;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:13:46 +00:00
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
.toolbar-grid {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.list {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.proposal-card {
|
|
|
|
|
display: block;
|
2026-01-29 12:13:46 +00:00
|
|
|
padding: 1.1rem;
|
2026-01-27 16:21:58 +00:00
|
|
|
color: var(--color-text);
|
2026-01-29 12:13:46 +00:00
|
|
|
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.proposal-card:hover {
|
2026-01-29 12:13:46 +00:00
|
|
|
border-color: var(--color-border-hover);
|
|
|
|
|
transform: translateY(-2px);
|
|
|
|
|
background: rgba(255, 255, 255, 0.03);
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.proposal-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-bottom: 0.5rem;
|
2026-01-29 12:13:46 +00:00
|
|
|
gap: 0.75rem;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.proposal-card h3 {
|
|
|
|
|
font-size: 1.25rem;
|
2026-01-29 12:13:46 +00:00
|
|
|
letter-spacing: -0.01em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.proposal-meta {
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
font-size: 0.8125rem;
|
|
|
|
|
margin-bottom: 0.5rem;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.description {
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
font-size: 0.875rem;
|
2026-01-29 12:13:46 +00:00
|
|
|
line-height: 1.5;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:13:46 +00:00
|
|
|
.empty, .error {
|
2026-01-27 16:21:58 +00:00
|
|
|
text-align: center;
|
2026-01-29 12:13:46 +00:00
|
|
|
padding: 2rem;
|
2026-01-27 16:21:58 +00:00
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:39:03 +00:00
|
|
|
.list-skeleton .skeleton-card {
|
|
|
|
|
--ui-skeleton-card-h: 92px;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
</style>
|