ui: refresh community detail page

This commit is contained in:
Marco Allegretti 2026-01-29 12:23:34 +01:00
parent 11621339d9
commit 387746aafa
2 changed files with 262 additions and 178 deletions

View file

@ -429,6 +429,42 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
color: var(--color-text); color: var(--color-text);
} }
.ui-btn-success {
background: var(--color-success);
color: var(--color-on-primary);
}
.ui-btn-success:hover {
background: var(--color-success-hover);
color: var(--color-on-primary);
transform: translateY(-1px);
box-shadow: 0 10px 30px rgba(34, 197, 94, 0.18);
}
.ui-btn-danger {
background: var(--color-error);
color: var(--color-on-primary);
}
.ui-btn-danger:hover {
background: var(--color-error-hover);
color: var(--color-on-primary);
transform: translateY(-1px);
box-shadow: 0 10px 30px rgba(239, 68, 68, 0.18);
}
.ui-btn-info {
background: var(--color-info);
color: var(--color-on-primary);
}
.ui-btn-info:hover {
background: var(--color-info-hover);
color: var(--color-on-primary);
transform: translateY(-1px);
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.18);
}
:where(input.ui-input, select.ui-select) { :where(input.ui-input, select.ui-select) {
width: 100%; width: 100%;
border-radius: var(--radius-md); border-radius: var(--radius-md);

View file

@ -6,9 +6,11 @@ const { slug } = Astro.params;
--- ---
<Layout title="Community"> <Layout title="Community">
<section class="community-page"> <section class="ui-page">
<div id="community-content"> <div class="ui-container">
<p class="loading">Loading community...</p> <div id="community-content">
<div class="state-card ui-card"><p class="loading">Loading community…</p></div>
</div>
</div> </div>
</section> </section>
</Layout> </Layout>
@ -16,6 +18,19 @@ const { slug } = Astro.params;
<script define:vars={{ slug, apiBase }}> <script define:vars={{ slug, apiBase }}>
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
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;
}
});
}
async function loadCommunity() { async function loadCommunity() {
const container = document.getElementById('community-content'); const container = document.getElementById('community-content');
if (!container) return; if (!container) return;
@ -26,59 +41,87 @@ const { slug } = Astro.params;
const community = communities.find(c => c.slug === slug); const community = communities.find(c => c.slug === slug);
if (!community) { if (!community) {
container.innerHTML = '<div class="error">Community not found</div>'; container.innerHTML = '<div class="state-card ui-card"><p class="error">Community not found</p></div>';
return; return;
} }
const communityName = escapeHtml(community.name);
const communitySlug = escapeHtml(community.slug);
const communityDesc = community.description ? escapeHtml(community.description) : '';
container.innerHTML = ` container.innerHTML = `
<div class="community-header"> <div class="community-hero ui-card ui-card-glass">
<h1>${community.name}</h1> <div class="hero-top">
<span class="slug">/${community.slug}</span> <div class="hero-title">
</div> <h1>${communityName}</h1>
<div class="hero-slug">/${communitySlug}</div>
${community.description ? `<p class="description">${community.description}</p>` : ''} </div>
<div class="hero-actions">
<div class="stats" id="stats"> <a href="/communities/${communitySlug}/proposals" class="ui-btn ui-btn-primary">View Proposals</a>
<div class="stat"> ${token ? `<a href="/communities/${communitySlug}/proposals/new" class="ui-btn ui-btn-secondary">Create Proposal</a>` : ''}
<span class="stat-value" id="member-count">-</span> ${token ? `<a href="/communities/${communitySlug}/plugins" id="plugins-btn" class="ui-btn ui-btn-secondary" style="display:none;">Plugins</a>` : ''}
<span class="stat-label">Members</span> ${token ? `<button id="join-btn" class="ui-btn ui-btn-success" data-id="${community.id}" style="display:none;">Join</button>` : ''}
</div> ${token ? `<button id="leave-btn" class="ui-btn ui-btn-danger" data-id="${community.id}" style="display:none;">Leave</button>` : ''}
<div class="stat"> ${token ? `<button id="edit-community-btn" class="ui-btn ui-btn-info" data-id="${community.id}" style="display:none;">Edit</button>` : ''}
<span class="stat-value" id="proposal-count">-</span>
<span class="stat-label">Proposals</span>
</div>
</div>
<div class="actions">
<a href="/communities/${slug}/proposals" class="btn-primary">View Proposals</a>
${token ? `<a href="/communities/${slug}/proposals/new" class="btn-secondary">Create Proposal</a>` : ''}
${token ? `<a href="/communities/${slug}/plugins" id="plugins-btn" class="btn-secondary" style="display:none;">Plugins</a>` : ''}
${token ? `<button id="join-btn" class="btn-join" data-id="${community.id}" style="display:none;">Join Community</button>` : ''}
${token ? `<button id="leave-btn" class="btn-leave" data-id="${community.id}" style="display:none;">Leave Community</button>` : ''}
${token ? `<button id="edit-community-btn" class="btn-edit" data-id="${community.id}" style="display:none;">Edit Community</button>` : ''}
</div>
<div class="sections">
<div class="section">
<h2>Recent Proposals</h2>
<div id="recent-proposals" class="proposals-list">
<p class="loading-small">Loading...</p>
</div> </div>
</div> </div>
<div class="section"> ${communityDesc ? `<p class="hero-desc">${communityDesc}</p>` : ''}
<h2>Members</h2>
<div id="members-list" class="members-list"> <div class="hero-kpis">
<p class="loading-small">Loading...</p> <div class="hero-kpis-label">At a glance</div>
<div class="ui-kpis" id="stats">
<div class="ui-kpi">
<div class="ui-kpi-value" id="member-count">—</div>
<div class="ui-kpi-label">Members</div>
</div>
<div class="ui-kpi">
<div class="ui-kpi-value" id="proposal-count">—</div>
<div class="ui-kpi-label">Proposals</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="section full-width"> <div class="panels">
<h2>Moderation Log</h2> <details class="panel ui-card" open>
<div id="mod-log" class="mod-log"> <summary>
<p class="empty-small">No moderation actions yet</p> <span>Recent Proposals</span>
</div> <span class="panel-hint">Latest activity</span>
</summary>
<div class="panel-body">
<div id="recent-proposals" class="proposals-list">
<p class="loading-small">Loading…</p>
</div>
<div class="panel-actions">
<a href="/communities/${communitySlug}/proposals" class="ui-btn ui-btn-secondary">All proposals</a>
</div>
</div>
</details>
<details class="panel ui-card">
<summary>
<span>Members</span>
<span class="panel-hint">People and roles</span>
</summary>
<div class="panel-body">
<div id="members-list" class="members-list">
<p class="loading-small">Loading…</p>
</div>
</div>
</details>
<details class="panel ui-card">
<summary>
<span>Moderation Log</span>
<span class="panel-hint">For moderators</span>
</summary>
<div class="panel-body">
<div id="mod-log" class="mod-log">
<p class="empty-small">No moderation actions yet</p>
</div>
</div>
</details>
</div> </div>
`; `;
@ -91,7 +134,7 @@ const { slug } = Astro.params;
// Setup join button // Setup join button
document.getElementById('join-btn')?.addEventListener('click', async (e) => { document.getElementById('join-btn')?.addEventListener('click', async (e) => {
var btn = e.target; var btn = e.currentTarget;
const communityId = btn.dataset.id; const communityId = btn.dataset.id;
try { try {
@ -117,7 +160,7 @@ const { slug } = Astro.params;
document.getElementById('leave-btn')?.addEventListener('click', async (e) => { document.getElementById('leave-btn')?.addEventListener('click', async (e) => {
if (!confirm('Are you sure you want to leave this community?')) return; if (!confirm('Are you sure you want to leave this community?')) return;
var btn = e.target; var btn = e.currentTarget;
const communityId = btn.dataset.id; const communityId = btn.dataset.id;
try { try {
@ -168,12 +211,20 @@ const { slug } = Astro.params;
return; return;
} }
container.innerHTML = proposals.slice(0, 5).map(p => ` container.innerHTML = proposals.slice(0, 5).map(p => {
<a href="/proposals/${p.id}" class="proposal-item"> var pid = encodeURIComponent(String(p.id));
<span class="proposal-title">${p.title}</span> var title = escapeHtml(p.title);
<span class="proposal-status status-${p.status}">${p.status}</span> var rawStatus = String(p.status || '').toLowerCase();
</a> var statusKey = (rawStatus === 'draft' || rawStatus === 'discussion' || rawStatus === 'voting' || rawStatus === 'closed') ? rawStatus : 'unknown';
`).join(''); var statusLabel = escapeHtml(rawStatus || 'unknown');
return `
<a href="/proposals/${pid}" class="proposal-item">
<span class="proposal-title">${title}</span>
<span class="proposal-status status-${statusKey}">${statusLabel}</span>
</a>
`;
}).join('');
} catch (e) { } catch (e) {
container.innerHTML = '<p class="error-small">Failed to load</p>'; container.innerHTML = '<p class="error-small">Failed to load</p>';
@ -193,12 +244,20 @@ const { slug } = Astro.params;
return; return;
} }
container.innerHTML = members.slice(0, 10).map(m => ` container.innerHTML = members.slice(0, 10).map(m => {
<a href="/users/${m.username}" class="member-item"> var username = escapeHtml(m.username);
<span class="member-name">${m.display_name || m.username}</span> var displayName = escapeHtml(m.display_name || m.username);
<span class="member-role role-${m.role}">${m.role}</span> var rawRole = String(m.role || '').toLowerCase();
</a> var roleKey = (rawRole === 'admin' || rawRole === 'moderator' || rawRole === 'member') ? rawRole : 'member';
`).join(''); var roleLabel = escapeHtml(roleKey);
return `
<a href="/users/${encodeURIComponent(String(m.username))}" class="member-item">
<span class="member-name">${displayName}</span>
<span class="member-role role-${roleKey}">${roleLabel}</span>
</a>
`;
}).join('');
} catch (e) { } catch (e) {
container.innerHTML = '<p class="error-small">Failed to load</p>'; container.innerHTML = '<p class="error-small">Failed to load</p>';
} }
@ -271,11 +330,11 @@ const { slug } = Astro.params;
if (data.is_member) { if (data.is_member) {
if (joinBtn) joinBtn.style.display = 'none'; if (joinBtn) joinBtn.style.display = 'none';
if (leaveBtn) leaveBtn.style.display = 'inline-block'; if (leaveBtn) leaveBtn.style.display = 'inline-flex';
if (editBtn && data.role === 'admin') editBtn.style.display = 'inline-block'; if (editBtn && data.role === 'admin') editBtn.style.display = 'inline-flex';
if (pluginsBtn && (data.role === 'admin' || data.role === 'moderator')) pluginsBtn.style.display = 'inline-block'; if (pluginsBtn && (data.role === 'admin' || data.role === 'moderator')) pluginsBtn.style.display = 'inline-flex';
} else { } else {
if (joinBtn) joinBtn.style.display = 'inline-block'; if (joinBtn) joinBtn.style.display = 'inline-flex';
if (leaveBtn) leaveBtn.style.display = 'none'; if (leaveBtn) leaveBtn.style.display = 'none';
if (pluginsBtn) pluginsBtn.style.display = 'none'; if (pluginsBtn) pluginsBtn.style.display = 'none';
} }
@ -316,17 +375,25 @@ const { slug } = Astro.params;
return; return;
} }
container.innerHTML = entries.slice(0, 5).map(e => ` container.innerHTML = entries.slice(0, 5).map(e => {
<div class="mod-entry"> var action = escapeHtml(e.action_type);
<div class="mod-action">${e.action_type}</div> var modUser = escapeHtml(e.moderator_username || 'System');
<div class="mod-details"> var targetUser = e.target_username ? escapeHtml(e.target_username) : '';
<span class="mod-by">by ${e.moderator_username || 'System'}</span> var reason = escapeHtml(e.reason || '');
${e.target_username ? `<span class="mod-target">→ ${e.target_username}</span>` : ''} var when = new Date(e.created_at).toLocaleString();
return `
<div class="mod-entry">
<div class="mod-action">${action}</div>
<div class="mod-details">
<span class="mod-by">by ${modUser}</span>
${targetUser ? `<span class="mod-target">→ ${targetUser}</span>` : ''}
</div>
<div class="mod-reason">${reason}</div>
<div class="mod-time">${when}</div>
</div> </div>
<div class="mod-reason">${e.reason}</div> `;
<div class="mod-time">${new Date(e.created_at).toLocaleString()}</div> }).join('');
</div>
`).join('');
} catch (e) { } catch (e) {
container.innerHTML = '<p class="error-small">Failed to load</p>'; container.innerHTML = '<p class="error-small">Failed to load</p>';
} }
@ -336,135 +403,116 @@ const { slug } = Astro.params;
</script> </script>
<style> <style>
.community-page { .community-hero {
padding: 2rem 0; padding: 1.25rem;
margin-bottom: 1.25rem;
} }
.community-header { .hero-top {
margin-bottom: 1rem;
}
h1 {
font-size: 2.5rem;
margin-bottom: 0.25rem;
}
.slug {
color: var(--color-primary);
font-family: monospace;
}
.description {
color: var(--color-text-muted);
font-size: 1.125rem;
margin-bottom: 2rem;
max-width: 600px;
}
.stats {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
}
.stat {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem 1.5rem;
text-align: center;
}
.stat-value {
display: block;
font-size: 2rem;
font-weight: 700;
color: var(--color-primary);
}
.stat-label {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.actions {
display: flex; display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem; gap: 1rem;
margin-bottom: 3rem; flex-wrap: wrap;
} }
.btn-primary, .btn-secondary { .hero-title h1 {
padding: 0.75rem 1.5rem; margin: 0;
border-radius: 8px; font-size: 2.125rem;
font-weight: 600; letter-spacing: -0.02em;
} }
.btn-primary { .hero-slug {
background: var(--color-primary); margin-top: 0.25rem;
color: var(--color-on-primary); font-family: 'SF Mono', Monaco, 'Cascadia Code', ui-monospace, monospace;
} font-size: 0.875rem;
.btn-primary:hover {
background: var(--color-primary-hover);
color: var(--color-on-primary);
}
.btn-secondary {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text);
}
.btn-secondary:hover {
border-color: var(--color-primary);
color: var(--color-primary); color: var(--color-primary);
opacity: 0.9;
} }
.btn-join { .hero-actions {
background: var(--color-success); display: flex;
color: var(--color-on-primary); align-items: center;
border: none; justify-content: flex-end;
padding: 0.75rem 1.5rem; gap: 0.5rem;
border-radius: 8px; flex-wrap: wrap;
font-weight: 600; }
.hero-desc {
margin: 0.75rem 0 0;
color: var(--color-text-muted);
font-size: 1.02rem;
max-width: 72ch;
}
.hero-kpis {
margin-top: 1rem;
}
.hero-kpis-label {
color: var(--color-text-muted);
font-size: 0.8125rem;
margin-bottom: 0.5rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.panels {
display: grid;
gap: 1rem;
}
.panel {
overflow: hidden;
}
.panel summary {
cursor: pointer; 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);
} }
.btn-join:hover { .panel summary::-webkit-details-marker {
background: var(--color-success-hover); display: none;
} }
.btn-join:disabled { .panel[open] summary {
background: var(--color-neutral); background: rgba(255, 255, 255, 0.03);
cursor: default;
} }
.btn-leave { .panel-hint {
background: var(--color-error); color: var(--color-text-muted);
color: var(--color-on-primary); font-size: 0.8125rem;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
} }
.btn-leave:hover { .panel-body {
background: var(--color-error-hover); padding: 1rem 1.25rem 1.25rem;
} }
.btn-edit { .panel-actions {
background: var(--color-info); margin-top: 0.75rem;
color: var(--color-on-primary); display: flex;
border: none; justify-content: flex-end;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
} }
.btn-edit:hover { @media (max-width: 640px) {
background: var(--color-info-hover); .hero-title h1 {
font-size: 1.75rem;
}
.panel summary {
flex-direction: column;
align-items: flex-start;
}
.panel-actions {
justify-content: flex-start;
}
} }
.edit-modal { .edit-modal {