mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-06-25 07:27: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(-)
662 lines
19 KiB
Text
662 lines
19 KiB
Text
---
|
|
export const prerender = false;
|
|
import Layout from '../../../layouts/Layout.astro';
|
|
import { API_BASE as apiBase } from '../../../lib/api';
|
|
const { slug } = Astro.params;
|
|
---
|
|
|
|
<Layout title="Community">
|
|
<section class="ui-page">
|
|
<div class="ui-container">
|
|
<div id="community-content">
|
|
<div class="state-card ui-card"><p class="loading">Loading community…</p></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</Layout>
|
|
|
|
<script define:vars={{ slug, apiBase }}>
|
|
const token = localStorage.getItem('token');
|
|
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadCommunity() {
|
|
const container = document.getElementById('community-content');
|
|
if (!container) return;
|
|
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/communities`);
|
|
const communities = await res.json();
|
|
const community = communities.find(c => c.slug === slug);
|
|
|
|
if (!community) {
|
|
container.innerHTML = '<div class="state-card ui-card"><p class="error">Community not found</p></div>';
|
|
return;
|
|
}
|
|
|
|
const communityName = escapeHtml(community.name);
|
|
const communitySlug = escapeHtml(community.slug);
|
|
const communityDesc = community.description ? escapeHtml(community.description) : '';
|
|
|
|
container.innerHTML = `
|
|
<div class="community-hero ui-card ui-card-glass">
|
|
<div class="hero-top">
|
|
<div class="hero-title">
|
|
<h1>${communityName}</h1>
|
|
<div class="hero-slug">/${communitySlug}</div>
|
|
</div>
|
|
<div class="hero-actions">
|
|
<a href="/communities/${communitySlug}/proposals" class="ui-btn ui-btn-primary">View Proposals</a>
|
|
${token ? `<a href="/communities/${communitySlug}/proposals/new" class="ui-btn ui-btn-secondary">Create Proposal</a>` : ''}
|
|
${token ? `<a href="/communities/${communitySlug}/plugins" id="plugins-btn" class="ui-btn ui-btn-secondary" style="display:none;">Plugins</a>` : ''}
|
|
${token ? `<button id="join-btn" class="ui-btn ui-btn-success" data-id="${community.id}" style="display:none;">Join</button>` : ''}
|
|
${token ? `<button id="leave-btn" class="ui-btn ui-btn-danger" data-id="${community.id}" style="display:none;">Leave</button>` : ''}
|
|
${token ? `<button id="edit-community-btn" class="ui-btn ui-btn-info" data-id="${community.id}" style="display:none;">Edit</button>` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
${communityDesc ? `<p class="hero-desc">${communityDesc}</p>` : ''}
|
|
|
|
<div class="hero-kpis">
|
|
<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 class="panels">
|
|
<details class="panel ui-card" open>
|
|
<summary>
|
|
<span>Recent Proposals</span>
|
|
<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>
|
|
`;
|
|
|
|
// Load data
|
|
loadDetails(community.id);
|
|
loadProposals(community.id);
|
|
loadMembers(community.id);
|
|
loadModerationLog(community.id);
|
|
if (token) checkMembership(community.id);
|
|
|
|
// Setup join button
|
|
document.getElementById('join-btn')?.addEventListener('click', async (e) => {
|
|
var btn = e.currentTarget;
|
|
const communityId = btn.dataset.id;
|
|
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/communities/${communityId}/join`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
});
|
|
|
|
if (res.ok) {
|
|
location.reload();
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to join', e);
|
|
}
|
|
});
|
|
|
|
// Setup edit community button
|
|
document.getElementById('edit-community-btn')?.addEventListener('click', () => {
|
|
showEditCommunityModal(community);
|
|
});
|
|
|
|
// Setup leave button
|
|
document.getElementById('leave-btn')?.addEventListener('click', async (e) => {
|
|
if (!confirm('Are you sure you want to leave this community?')) return;
|
|
|
|
var btn = e.currentTarget;
|
|
const communityId = btn.dataset.id;
|
|
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/communities/${communityId}/leave`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
});
|
|
|
|
if (res.ok) {
|
|
location.reload();
|
|
} else {
|
|
const err = await res.text();
|
|
alert(err || 'Failed to leave community');
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to leave', e);
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="error">Failed to load community</div>';
|
|
}
|
|
}
|
|
|
|
async function loadDetails(communityId) {
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/communities/${communityId}/details`);
|
|
if (res.ok) {
|
|
const details = await res.json();
|
|
document.getElementById('member-count').textContent = details.member_count;
|
|
document.getElementById('proposal-count').textContent = details.proposal_count;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load details', e);
|
|
}
|
|
}
|
|
|
|
async function loadProposals(communityId) {
|
|
const container = document.getElementById('recent-proposals');
|
|
if (!container) return;
|
|
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/communities/${communityId}/proposals`);
|
|
const proposals = await res.json();
|
|
|
|
if (proposals.length === 0) {
|
|
container.innerHTML = '<p class="empty-small">No proposals yet</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = proposals.slice(0, 5).map(p => {
|
|
var pid = encodeURIComponent(String(p.id));
|
|
var title = escapeHtml(p.title);
|
|
var rawStatus = String(p.status || '').toLowerCase();
|
|
var statusKey = (rawStatus === 'draft' || rawStatus === 'discussion' || rawStatus === 'voting' || rawStatus === 'closed') ? rawStatus : 'unknown';
|
|
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) {
|
|
container.innerHTML = '<p class="error-small">Failed to load</p>';
|
|
}
|
|
}
|
|
|
|
async function loadMembers(communityId) {
|
|
const container = document.getElementById('members-list');
|
|
if (!container) return;
|
|
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/communities/${communityId}/members`);
|
|
const members = await res.json();
|
|
|
|
if (members.length === 0) {
|
|
container.innerHTML = '<p class="empty-small">No members yet</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = members.slice(0, 10).map(m => {
|
|
var username = escapeHtml(m.username);
|
|
var displayName = escapeHtml(m.display_name || m.username);
|
|
var rawRole = String(m.role || '').toLowerCase();
|
|
var roleKey = (rawRole === 'admin' || rawRole === 'moderator' || rawRole === 'member') ? rawRole : 'member';
|
|
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) {
|
|
container.innerHTML = '<p class="error-small">Failed to load</p>';
|
|
}
|
|
}
|
|
|
|
function showEditCommunityModal(community) {
|
|
const modal = document.createElement('div');
|
|
modal.className = 'edit-modal';
|
|
modal.innerHTML = `
|
|
<div class="modal-content">
|
|
<h2>Edit Community</h2>
|
|
<form id="edit-community-form">
|
|
<div class="form-group">
|
|
<label for="edit-name">Name</label>
|
|
<input type="text" id="edit-name" value="${community.name}" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="edit-description">Description</label>
|
|
<textarea id="edit-description" rows="4">${community.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-community-form')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
var name = document.getElementById('edit-name').value;
|
|
var description = document.getElementById('edit-description').value;
|
|
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/communities/${community.id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ name, description: description || null }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
modal.remove();
|
|
location.reload();
|
|
} else {
|
|
const err = await res.text();
|
|
alert(err || 'Failed to update community');
|
|
}
|
|
} catch (e) {
|
|
alert('Error updating community');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function checkMembership(communityId) {
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/communities/${communityId}/membership`, {
|
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const joinBtn = document.getElementById('join-btn');
|
|
const leaveBtn = document.getElementById('leave-btn');
|
|
const editBtn = document.getElementById('edit-community-btn');
|
|
const pluginsBtn = document.getElementById('plugins-btn');
|
|
|
|
if (data.is_member) {
|
|
if (joinBtn) joinBtn.style.display = 'none';
|
|
if (leaveBtn) leaveBtn.style.display = 'inline-flex';
|
|
if (editBtn && data.role === 'admin') editBtn.style.display = 'inline-flex';
|
|
if (pluginsBtn && (data.role === 'admin' || data.role === 'moderator')) pluginsBtn.style.display = 'inline-flex';
|
|
} else {
|
|
if (joinBtn) joinBtn.style.display = 'inline-flex';
|
|
if (leaveBtn) leaveBtn.style.display = 'none';
|
|
if (pluginsBtn) pluginsBtn.style.display = 'none';
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to check membership', e);
|
|
}
|
|
}
|
|
|
|
async function loadModerationLog(communityId) {
|
|
const container = document.getElementById('mod-log');
|
|
if (!container) return;
|
|
|
|
try {
|
|
if (!token) {
|
|
container.innerHTML = '<p class="empty-small"><a href="/login">Login</a> to view moderation history</p>';
|
|
return;
|
|
}
|
|
|
|
const res = await fetch(`${apiBase}/api/communities/${communityId}/moderation`, {
|
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
container.innerHTML = '<p class="error-small"><a href="/login">Login</a> to view moderation history</p>';
|
|
return;
|
|
}
|
|
|
|
if (res.status === 403) {
|
|
container.innerHTML = '<p class="error-small">You do not have permission to view moderation history</p>';
|
|
return;
|
|
}
|
|
|
|
const entries = await res.json();
|
|
|
|
if (entries.length === 0) {
|
|
container.innerHTML = '<p class="empty-small">No moderation actions yet</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = entries.slice(0, 5).map(e => {
|
|
var action = escapeHtml(e.action_type);
|
|
var modUser = escapeHtml(e.moderator_username || 'System');
|
|
var targetUser = e.target_username ? escapeHtml(e.target_username) : '';
|
|
var reason = escapeHtml(e.reason || '');
|
|
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>
|
|
`;
|
|
}).join('');
|
|
} catch (e) {
|
|
container.innerHTML = '<p class="error-small">Failed to load</p>';
|
|
}
|
|
}
|
|
|
|
loadCommunity();
|
|
</script>
|
|
|
|
<style>
|
|
.community-hero {
|
|
padding: 1.25rem;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.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-slug {
|
|
margin-top: 0.25rem;
|
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', ui-monospace, monospace;
|
|
font-size: 0.875rem;
|
|
color: var(--color-primary);
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.hero-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.hero-title h1 {
|
|
font-size: 1.75rem;
|
|
}
|
|
}
|
|
|
|
.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-actions {
|
|
display: flex;
|
|
gap: 1rem;
|
|
justify-content: flex-end;
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
.sections {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 2rem;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.sections {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.section {
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 12px;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.section h2 {
|
|
font-size: 1.125rem;
|
|
margin-bottom: 1rem;
|
|
padding-bottom: 0.75rem;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.proposals-list {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.proposal-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.75rem;
|
|
background: var(--color-bg);
|
|
border-radius: 6px;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.proposal-item:hover {
|
|
background: var(--color-border);
|
|
}
|
|
|
|
.proposal-title {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.proposal-status {
|
|
font-size: 0.625rem;
|
|
padding: 0.125rem 0.5rem;
|
|
border-radius: 999px;
|
|
text-transform: uppercase;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.loading, .error {
|
|
text-align: center;
|
|
padding: 3rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.loading-small, .empty-small, .error-small {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.875rem;
|
|
padding: 1rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.mod-log {
|
|
display: grid;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.mod-entry {
|
|
background: var(--color-bg);
|
|
border-radius: 6px;
|
|
padding: 0.75rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.mod-action {
|
|
font-weight: 600;
|
|
color: var(--color-primary);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.mod-details {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.75rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.mod-reason {
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.mod-time {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.625rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.full-width {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.members-list {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.member-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.5rem 0;
|
|
border-bottom: 1px solid var(--color-border);
|
|
text-decoration: none;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.member-item:hover .member-name {
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.member-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.member-name {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.member-role {
|
|
font-size: 0.625rem;
|
|
padding: 0.125rem 0.5rem;
|
|
border-radius: 999px;
|
|
text-transform: uppercase;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.role-admin { background: var(--color-secondary); color: var(--color-on-primary); }
|
|
.role-moderator { background: var(--color-info); color: var(--color-on-primary); }
|
|
.role-member { background: var(--color-neutral-muted); color: var(--color-on-primary); }
|
|
</style>
|