ui: refresh community proposals list

This commit is contained in:
Marco Allegretti 2026-01-29 13:13:46 +01:00
parent 826ffd9022
commit bdf6fa6c2f

View file

@ -6,28 +6,80 @@ const { slug } = Astro.params;
--- ---
<Layout title="Proposals"> <Layout title="Proposals">
<section class="proposals-page"> <section class="ui-page">
<div class="header-row"> <div class="ui-container">
<div> <div class="hero ui-card ui-card-glass">
<h1>Proposals</h1> <div class="hero-top">
<p class="subtitle">Proposals and decisions</p> <div>
<h1 class="hero-title">Proposals</h1>
<p class="hero-subtitle">Proposals and decisions</p>
</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>
</div> </div>
<a href={`/communities/${slug}/proposals/new`} class="btn-create" id="create-btn" style="display: none;">+ New Proposal</a>
</div>
<div class="filters"> <div class="toolbar ui-card">
<input type="text" id="search-input" placeholder="Search proposals..." /> <div class="toolbar-grid">
<select id="status-filter"> <div>
<option value="">All Statuses</option> <label class="toolbar-label" for="search-input">Search</label>
<option value="draft">Draft</option> <input type="text" id="search-input" class="ui-input" placeholder="Search proposals…" />
<option value="discussion">Discussion</option> </div>
<option value="voting">Voting</option> <div>
<option value="closed">Closed</option> <label class="toolbar-label" for="status-filter">Status</label>
</select> <select id="status-filter" class="ui-select">
</div> <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 id="proposals-list" class="list">
<p class="loading">Loading proposals...</p> <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>
</div> </div>
</section> </section>
</Layout> </Layout>
@ -35,6 +87,45 @@ const { slug } = Astro.params;
<script define:vars={{ slug, apiBase }}> <script define:vars={{ slug, apiBase }}>
let allProposals = []; let allProposals = [];
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 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);
}
async function loadProposals() { async function loadProposals() {
const container = document.getElementById('proposals-list'); const container = document.getElementById('proposals-list');
if (!container) return; if (!container) return;
@ -51,9 +142,10 @@ const { slug } = Astro.params;
const res = await fetch(`${apiBase}/api/communities/${community.id}/proposals`); const res = await fetch(`${apiBase}/api/communities/${community.id}/proposals`);
allProposals = await res.json(); allProposals = await res.json();
renderProposals(allProposals); updateKpis(allProposals);
filterProposals();
} catch (error) { } catch (error) {
container.innerHTML = '<div class="error"><p>Failed to load proposals.</p></div>'; container.innerHTML = '<div class="ui-card"><p class="error">Failed to load proposals.</p></div>';
} }
} }
@ -62,27 +154,39 @@ const { slug } = Astro.params;
if (!container) return; if (!container) return;
if (proposals.length === 0) { if (proposals.length === 0) {
container.innerHTML = '<div class="empty"><p>No proposals found.</p></div>'; container.innerHTML = '<div class="ui-card"><p class="empty">No proposals found.</p></div>';
return; return;
} }
container.innerHTML = proposals.map(p => ` container.innerHTML = proposals.map(p => {
<a href="/proposals/${p.id}" class="proposal-card"> const statusKey = normalizeStatus(p.status);
<div class="proposal-header"> const safeTitle = escapeHtml(p.title);
<h3>${p.title}</h3> const desc = String(p.description || '');
<span class="status status-${p.status}">${p.status}</span> const safeDesc = escapeHtml(desc.substring(0, 160));
</div> const suffix = desc.length > 160 ? '…' : '';
<p class="description">${p.description.substring(0, 150)}${p.description.length > 150 ? '...' : ''}</p> const createdAt = p.created_at ? new Date(p.created_at).toLocaleDateString() : '';
</a>
`).join(''); return `
<a href="/proposals/${encodeURIComponent(String(p.id))}" class="proposal-card ui-card">
<div class="proposal-header">
<h3>${safeTitle}</h3>
<span class="status status-${statusKey}">${escapeHtml(statusKey)}</span>
</div>
${createdAt ? `<div class="proposal-meta">Created ${escapeHtml(createdAt)}</div>` : ''}
<p class="description">${safeDesc}${suffix}</p>
</a>
`;
}).join('');
} }
function filterProposals() { function filterProposals() {
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
const statusFilter = document.getElementById('status-filter'); const statusFilter = document.getElementById('status-filter');
const sortSelect = document.getElementById('sort-select');
const query = searchInput?.value.toLowerCase().trim() || ''; const query = searchInput?.value.toLowerCase().trim() || '';
const status = statusFilter?.value || ''; const status = statusFilter?.value || '';
const sortKey = sortSelect?.value || 'newest';
let filtered = allProposals; let filtered = allProposals;
@ -97,6 +201,26 @@ const { slug } = Astro.params;
filtered = filtered.filter(p => p.status === status); filtered = filtered.filter(p => p.status === status);
} }
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));
}
renderProposals(filtered); renderProposals(filtered);
} }
@ -104,75 +228,77 @@ const { slug } = Astro.params;
document.getElementById('search-input')?.addEventListener('input', filterProposals); document.getElementById('search-input')?.addEventListener('input', filterProposals);
document.getElementById('status-filter')?.addEventListener('change', filterProposals); document.getElementById('status-filter')?.addEventListener('change', filterProposals);
document.getElementById('sort-select')?.addEventListener('change', filterProposals);
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const createBtn = document.getElementById('create-btn'); const createBtn = document.getElementById('create-btn');
if (token && createBtn) { if (token && createBtn) {
createBtn.style.display = 'block'; createBtn.style.display = 'inline-flex';
} }
</script> </script>
<style> <style>
.proposals-page { .hero {
padding: 2rem 0; padding: 1.25rem;
margin-bottom: 1rem;
} }
.header-row { .hero-top {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: 2rem; gap: 1rem;
flex-wrap: wrap;
} }
h1 { .hero-title {
font-size: 2.5rem; margin: 0;
margin-bottom: 0.5rem; font-size: 2.125rem;
letter-spacing: -0.02em;
} }
.subtitle { .hero-subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.btn-create { .hero-kpis {
background: var(--color-primary); margin-top: 1rem;
color: var(--color-on-primary);
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
} }
.btn-create:hover { .hero-kpis-label {
background: var(--color-primary-hover); color: var(--color-text-muted);
color: var(--color-on-primary); font-size: 0.8125rem;
margin-bottom: 0.5rem;
letter-spacing: 0.02em;
text-transform: uppercase;
} }
.filters { .toolbar {
display: flex; padding: 1rem;
gap: 1rem; margin-bottom: 1rem;
margin-bottom: 1.5rem;
} }
.filters input, .filters select { .toolbar-grid {
padding: 0.75rem 1rem; display: grid;
border: 1px solid var(--color-border); grid-template-columns: 1.5fr 1fr 1fr;
border-radius: 8px; gap: 0.75rem;
background: var(--color-surface); align-items: end;
color: var(--color-text);
font-size: 1rem;
} }
.filters input { .toolbar-label {
flex: 1; display: block;
max-width: 300px; font-size: 0.75rem;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--color-text-muted);
margin-bottom: 0.35rem;
} }
.filters select { @media (max-width: 768px) {
min-width: 150px; .toolbar-grid {
} grid-template-columns: 1fr;
}
.filters input:focus, .filters select:focus {
outline: none;
border-color: var(--color-primary);
} }
.list { .list {
@ -182,16 +308,15 @@ const { slug } = Astro.params;
.proposal-card { .proposal-card {
display: block; display: block;
background: var(--color-surface); padding: 1.1rem;
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1.5rem;
color: var(--color-text); color: var(--color-text);
transition: border-color 0.2s; transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
} }
.proposal-card:hover { .proposal-card:hover {
border-color: var(--color-primary); border-color: var(--color-border-hover);
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.03);
} }
.proposal-header { .proposal-header {
@ -199,10 +324,18 @@ const { slug } = Astro.params;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
gap: 0.75rem;
} }
.proposal-card h3 { .proposal-card h3 {
font-size: 1.25rem; font-size: 1.25rem;
letter-spacing: -0.01em;
}
.proposal-meta {
color: var(--color-text-muted);
font-size: 0.8125rem;
margin-bottom: 0.5rem;
} }
.status { .status {
@ -221,16 +354,36 @@ const { slug } = Astro.params;
.description { .description {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5;
} }
.loading, .empty, .error { .empty, .error {
text-align: center; text-align: center;
padding: 3rem; padding: 2rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.hint { .list-skeleton {
font-size: 0.875rem; display: grid;
margin-top: 0.5rem; gap: 1rem;
}
.skeleton-card {
height: 92px;
border-radius: 14px;
border: 1px solid var(--color-border);
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.06) 50%,
rgba(255, 255, 255, 0.03) 100%
);
background-size: 200% 100%;
animation: shimmer 1.2s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 0% 0%; }
100% { background-position: 200% 0%; }
} }
</style> </style>