frontend: improve routing, admin UX, and API base handling

This commit is contained in:
Marco Allegretti 2026-01-29 00:47:14 +01:00
parent 89a6e9eaa7
commit d8b2b0af14
26 changed files with 424 additions and 126 deletions

View file

@ -5,4 +5,12 @@ import node from '@astrojs/node';
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
adapter: node({ mode: 'standalone' }), adapter: node({ mode: 'standalone' }),
vite: {
server: {
proxy: {
'/api': 'http://127.0.0.1:3000',
'/health': 'http://127.0.0.1:3000',
},
},
},
}); });

View file

@ -1,14 +1,12 @@
--- ---
interface Props { interface Props {
currentPage; currentPage: string;
} }
const { currentPage } = Astro.props; const { currentPage } = Astro.props;
const navItems = [ const navItems = [
{ href: '/admin/settings', label: 'Instance Settings', icon: '⚙️' }, { href: '/admin/settings', label: 'Instance Settings', icon: '⚙️' },
{ href: '/admin/users', label: 'Users', icon: '👥' },
{ href: '/admin/communities', label: 'Communities', icon: '🏘️' },
{ href: '/admin/approvals', label: 'Approvals', icon: '✅' }, { href: '/admin/approvals', label: 'Approvals', icon: '✅' },
{ href: '/admin/invitations', label: 'Invitations', icon: '📨' }, { href: '/admin/invitations', label: 'Invitations', icon: '📨' },
{ href: '/admin/roles', label: 'Roles & Permissions', icon: '🔐' }, { href: '/admin/roles', label: 'Roles & Permissions', icon: '🔐' },

View file

@ -3,10 +3,11 @@
* DelegationGraph - Visual representation of delegation chains * DelegationGraph - Visual representation of delegation chains
* Shows who delegates to whom with interactive exploration * Shows who delegates to whom with interactive exploration
*/ */
import { API_BASE as apiBase } from '../../lib/api';
interface Props { interface Props {
userId?; userId?: string;
communityId?; communityId?: string;
compact?; compact?: boolean;
} }
const { userId, communityId, compact = false } = Astro.props; const { userId, communityId, compact = false } = Astro.props;
@ -54,35 +55,36 @@ const { userId, communityId, compact = false } = Astro.props;
</div> </div>
</div> </div>
<script> <script define:vars={{ apiBase }}>
class DelegationGraph { class DelegationGraph {
private container: HTMLElement; constructor(container) {
private userId;
private communityId;
private compact;
private outgoing = [];
private incoming = [];
private currentView: 'tree' | 'list' = 'tree';
constructor(container: HTMLElement) {
this.container = container; this.container = container;
this.userId = container.dataset.userId || ''; this.userId = container.dataset.userId || '';
this.communityId = container.dataset.communityId || ''; this.communityId = container.dataset.communityId || '';
this.compact = container.dataset.compact === 'true'; this.compact = container.dataset.compact === 'true';
this.outgoing = [];
this.incoming = [];
this.currentView = 'tree';
this.setupControls(); this.setupControls();
this.loadData(); this.loadData();
window.addEventListener('delegations:changed', () => {
this.loadData();
});
} }
setupControls() { setupControls() {
this.container.querySelectorAll('.view-btn').forEach(btn => { this.container.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const view = (btn).dataset.view as 'tree' | 'list'; const view = (btn).dataset.view;
this.switchView(view); if (view) {
this.switchView(view);
}
}); });
}); });
} }
switchView(view: 'tree' | 'list') { switchView(view) {
this.currentView = view; this.currentView = view;
this.container.querySelectorAll('.view-btn').forEach(btn => { this.container.querySelectorAll('.view-btn').forEach(btn => {
btn.classList.toggle('active', (btn).dataset.view === view); btn.classList.toggle('active', (btn).dataset.view === view);
@ -102,12 +104,14 @@ const { userId, communityId, compact = false } = Astro.props;
return; return;
} }
const API_BASE = apiBase;
try { try {
const [outRes, inRes] = await Promise.all([ const [outRes, inRes] = await Promise.all([
fetch('/api/delegations/my', { fetch(`${API_BASE}/api/delegations`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}), }),
fetch('/api/delegations/to-me', { fetch(`${API_BASE}/api/delegations/to-me`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}) })
]); ]);
@ -204,7 +208,7 @@ const { userId, communityId, compact = false } = Astro.props;
treeView.innerHTML = html; treeView.innerHTML = html;
} }
renderNode(delegation, type: 'incoming' | 'outgoing') { renderNode(delegation, type) {
const name = delegation.delegate_username || delegation.delegator_username || 'Unknown'; const name = delegation.delegate_username || delegation.delegator_username || 'Unknown';
const scope = this.getScopeLabel(delegation.scope); const scope = this.getScopeLabel(delegation.scope);
const weight = delegation.weight || 1; const weight = delegation.weight || 1;
@ -277,7 +281,7 @@ const { userId, communityId, compact = false } = Astro.props;
} }
getScopeLabel(scope) { getScopeLabel(scope) {
const labels: Record<string, string> = { const labels = {
'global': '🌐 Global', 'global': '🌐 Global',
'community': '🏘️ Community', 'community': '🏘️ Community',
'topic': '📁 Topic', 'topic': '📁 Topic',

View file

@ -1,6 +1,6 @@
--- ---
interface Props { interface Props {
title; title: string;
} }
import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes'; import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes';
@ -207,6 +207,16 @@ const { title } = Astro.props;
--color-field-bg: rgba(0, 0, 0, 0.25); --color-field-bg: rgba(0, 0, 0, 0.25);
--color-on-primary: #ffffff; --color-on-primary: #ffffff;
--color-primary-bg: var(--color-primary-muted);
--bg-primary: var(--color-bg);
--bg-secondary: var(--color-surface);
--bg-hover: var(--color-surface-hover);
--border-color: var(--color-border);
--text-primary: var(--color-text);
--text-secondary: var(--color-text-muted);
--accent-color: var(--color-primary);
--radius-sm: 8px; --radius-sm: 8px;
--radius-md: 12px; --radius-md: 12px;
--radius-lg: 16px; --radius-lg: 16px;

View file

@ -1,7 +1,7 @@
--- ---
interface Props { interface Props {
title; title: string;
description?; description?: string;
} }
import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes'; import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes';
@ -123,7 +123,7 @@ const defaultTheme = DEFAULT_THEME;
</div> </div>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">
<p>&copy; 2026 Likwid. Free and open source software under AGPLv3.</p> <p>&copy; 2026 Likwid. Free and open source software under EUPL-1.2.</p>
</div> </div>
</footer> </footer>
</div> </div>

View file

@ -1,4 +1,16 @@
export const API_BASE = 'http://localhost:3000'; const envApiBase =
(import.meta as any).env?.PUBLIC_API_BASE ||
(import.meta as any).env?.API_BASE ||
((globalThis as any).process?.env?.PUBLIC_API_BASE || (globalThis as any).process?.env?.API_BASE);
export const API_BASE = envApiBase || '';
const serverEnvApiBase =
(globalThis as any).process?.env?.INTERNAL_API_BASE ||
(globalThis as any).process?.env?.PUBLIC_API_BASE ||
(globalThis as any).process?.env?.API_BASE;
export const SERVER_API_BASE = serverEnvApiBase || API_BASE;
export interface HealthResponse { export interface HealthResponse {
status: string; status: string;

View file

@ -141,7 +141,7 @@ import PublicLayout from '../layouts/PublicLayout.astro';
<section class="content-section"> <section class="content-section">
<h2>Technical Foundation</h2> <h2>Technical Foundation</h2>
<p> <p>
Likwid is free and open source software (AGPLv3), built with modern, auditable technology: Likwid is free and open source software (EUPL-1.2), built with modern, auditable technology:
</p> </p>
<div class="tech-table"> <div class="tech-table">
<div class="tech-row"> <div class="tech-row">
@ -162,12 +162,12 @@ import PublicLayout from '../layouts/PublicLayout.astro';
</div> </div>
<div class="tech-row"> <div class="tech-row">
<span class="tech-label">License</span> <span class="tech-label">License</span>
<span class="tech-value">AGPLv3</span> <span class="tech-value">EUPL-1.2</span>
</div> </div>
</div> </div>
<p class="tech-note"> <p class="tech-note">
The choice of Rust provides memory safety and performance. PostgreSQL ensures data integrity The choice of Rust provides memory safety and performance. PostgreSQL ensures data integrity
for critical governance records. The AGPLv3 license guarantees that improvements to Likwid for critical governance records. The EUPL-1.2 license guarantees that improvements to Likwid
remain available to the community. remain available to the community.
</p> </p>
</section> </section>

View file

@ -62,7 +62,17 @@ import { API_BASE as apiBase } from '../../lib/api';
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
if (!res.ok) throw new Error('Failed to load'); if (res.status === 401) {
window.location.href = '/login?redirect=/admin/approvals';
return;
}
if (res.status === 403) {
container.innerHTML = '<p class="error">Admin access required</p>';
return;
}
if (!res.ok) throw new Error(await res.text());
const data = await res.json(); const data = await res.json();
@ -102,7 +112,17 @@ import { API_BASE as apiBase } from '../../lib/api';
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
if (!res.ok) throw new Error('Failed to load'); if (res.status === 401) {
window.location.href = '/login?redirect=/admin/approvals';
return;
}
if (res.status === 403) {
container.innerHTML = '<p class="error">Admin access required</p>';
return;
}
if (!res.ok) throw new Error(await res.text());
const data = await res.json(); const data = await res.json();
@ -167,9 +187,25 @@ import { API_BASE as apiBase } from '../../lib/api';
body: JSON.stringify({ approve, reason }) body: JSON.stringify({ approve, reason })
}); });
const data = await res.json(); if (res.status === 401) {
window.location.href = '/login?redirect=/admin/approvals';
return;
}
if (res.ok && data.success) { if (res.status === 403) {
alert('Admin access required');
return;
}
const raw = await res.text();
let data = null;
try {
data = raw ? JSON.parse(raw) : null;
} catch (e) {
data = null;
}
if (res.ok && data && data.success) {
alert(data.message); alert(data.message);
// Remove item from list // Remove item from list
document.querySelector(`.pending-item[data-id="${id}"]`)?.remove(); document.querySelector(`.pending-item[data-id="${id}"]`)?.remove();
@ -183,7 +219,7 @@ import { API_BASE as apiBase } from '../../lib/api';
container.innerHTML = `<p class="empty">No pending ${type} requests</p>`; container.innerHTML = `<p class="empty">No pending ${type} requests</p>`;
} }
} else { } else {
alert('Error: ' + (data.message || 'Failed to process')); alert('Error: ' + ((data && (data.message || data.error)) || raw || 'Failed to process'));
} }
} catch (error) { } catch (error) {
alert('Error processing request'); alert('Error processing request');

View file

@ -83,6 +83,16 @@ import { API_BASE as apiBase } from '../../lib/api';
body: JSON.stringify({ email, max_uses, expires_in_hours }) body: JSON.stringify({ email, max_uses, expires_in_hours })
}); });
if (res.status === 401) {
window.location.href = '/login?redirect=/admin/invitations';
return;
}
if (res.status === 403) {
alert('Admin access required');
return;
}
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
alert(`Invitation created!\nCode: ${data.code}`); alert(`Invitation created!\nCode: ${data.code}`);
@ -110,7 +120,17 @@ import { API_BASE as apiBase } from '../../lib/api';
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
if (!res.ok) throw new Error('Failed to load'); if (res.status === 401) {
window.location.href = '/login?redirect=/admin/invitations';
return;
}
if (res.status === 403) {
container.innerHTML = '<p class="error">Admin access required</p>';
return;
}
if (!res.ok) throw new Error(await res.text());
const data = await res.json(); const data = await res.json();
@ -158,10 +178,21 @@ import { API_BASE as apiBase } from '../../lib/api';
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
if (res.status === 401) {
window.location.href = '/login?redirect=/admin/invitations';
return;
}
if (res.status === 403) {
alert('Admin access required');
return;
}
if (res.ok) { if (res.ok) {
loadInvitations(); loadInvitations();
} else { } else {
alert('Error revoking invitation'); alert('Error: ' + (await res.text()));
} }
} catch (error) { } catch (error) {
alert('Error revoking invitation'); alert('Error revoking invitation');

View file

@ -1,17 +1,13 @@
--- ---
export const prerender = false; export const prerender = false;
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import AdminNav from '../../components/AdminNav.astro';
import { API_BASE } from '../../lib/api'; import { API_BASE } from '../../lib/api';
--- ---
<Layout title="Plugin Management - Admin"> <Layout title="Plugin Management - Admin">
<div class="admin-container"> <div class="admin-container">
<nav class="admin-nav"> <AdminNav currentPage="/admin/plugins" />
<a href="/admin/settings">Settings</a>
<a href="/admin/plugins" class="active">Plugins</a>
<a href="/admin/voting">Voting Methods</a>
<a href="/admin/roles">Roles</a>
</nav>
<main class="admin-content"> <main class="admin-content">
<header class="page-header"> <header class="page-header">
@ -240,13 +236,37 @@ import { API_BASE } from '../../lib/api';
<script define:vars={{ API_BASE }}> <script define:vars={{ API_BASE }}>
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login?redirect=/admin/plugins';
}
function showError(msg) {
document.querySelectorAll('.plugins-list').forEach(el => {
el.innerHTML = `<p class="loading">${msg}</p>`;
});
}
async function loadPlugins() { async function loadPlugins() {
try { try {
const [defaultsRes, instanceRes] = await Promise.all([ const [defaultsRes, instanceRes] = await Promise.all([
fetch(`${API_BASE}/api/plugins/defaults`), fetch(`${API_BASE}/api/plugins/defaults`, {
fetch(`${API_BASE}/api/plugins/instance`) headers: { 'Authorization': `Bearer ${token}` },
}),
fetch(`${API_BASE}/api/plugins/instance`, {
headers: { 'Authorization': `Bearer ${token}` },
})
]); ]);
if (defaultsRes.status === 401 || instanceRes.status === 401) {
window.location.href = '/login?redirect=/admin/plugins';
return;
}
if (defaultsRes.status === 403 || instanceRes.status === 403) {
showError('Admin access required');
return;
}
const defaults = await defaultsRes.json(); const defaults = await defaultsRes.json();
const instance = await instanceRes.json(); const instance = await instanceRes.json();
@ -333,6 +353,17 @@ import { API_BASE } from '../../lib/api';
body: JSON.stringify({ is_enabled: isEnabled }) body: JSON.stringify({ is_enabled: isEnabled })
}); });
if (res.status === 401) {
window.location.href = '/login?redirect=/admin/plugins';
return;
}
if (res.status === 403) {
alert('Admin access required');
e.target.checked = !isEnabled;
return;
}
if (!res.ok) { if (!res.ok) {
const err = await res.text(); const err = await res.text();
alert('Failed to update plugin: ' + err); alert('Failed to update plugin: ' + err);

View file

@ -1,17 +1,13 @@
--- ---
export const prerender = false; export const prerender = false;
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import AdminNav from '../../components/AdminNav.astro';
import { API_BASE } from '../../lib/api'; import { API_BASE } from '../../lib/api';
--- ---
<Layout title="Role Management - Admin"> <Layout title="Role Management - Admin">
<div class="admin-container"> <div class="admin-container">
<nav class="admin-nav"> <AdminNav currentPage="/admin/roles" />
<a href="/admin/settings">Settings</a>
<a href="/admin/plugins">Plugins</a>
<a href="/admin/voting">Voting Methods</a>
<a href="/admin/roles" class="active">Roles</a>
</nav>
<main class="admin-content"> <main class="admin-content">
<header class="page-header"> <header class="page-header">
@ -208,6 +204,12 @@ import { API_BASE } from '../../lib/api';
</style> </style>
<script define:vars={{ API_BASE }}> <script define:vars={{ API_BASE }}>
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login?redirect=/admin/roles';
}
async function loadData() { async function loadData() {
await Promise.all([loadRoles(), loadPermissions()]); await Promise.all([loadRoles(), loadPermissions()]);
} }
@ -216,7 +218,20 @@ import { API_BASE } from '../../lib/api';
const container = document.getElementById('platform-roles'); const container = document.getElementById('platform-roles');
try { try {
const res = await fetch(`${API_BASE}/api/roles`); const res = await fetch(`${API_BASE}/api/roles`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.status === 401) {
window.location.href = '/login?redirect=/admin/roles';
return;
}
if (res.status === 403) {
container.innerHTML = '<p class="loading">Admin access required</p>';
return;
}
const roles = await res.json(); const roles = await res.json();
container.innerHTML = roles.map(r => ` container.innerHTML = roles.map(r => `
@ -246,7 +261,20 @@ import { API_BASE } from '../../lib/api';
const container = document.getElementById('permissions-list'); const container = document.getElementById('permissions-list');
try { try {
const res = await fetch(`${API_BASE}/api/permissions`); const res = await fetch(`${API_BASE}/api/permissions`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.status === 401) {
window.location.href = '/login?redirect=/admin/roles';
return;
}
if (res.status === 403) {
container.innerHTML = '<p class="loading">Admin access required</p>';
return;
}
const permissions = await res.json(); const permissions = await res.json();
const byCategory = {}; const byCategory = {};

View file

@ -1,17 +1,13 @@
--- ---
export const prerender = false;
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import AdminNav from '../../components/AdminNav.astro';
import { API_BASE as apiBase } from '../../lib/api';
--- ---
<Layout title="Admin Settings - Likwid"> <Layout title="Admin Settings - Likwid">
<div class="admin-container"> <div class="admin-container">
<aside class="admin-sidebar"> <AdminNav currentPage="/admin/settings" />
<h2>Admin</h2>
<nav>
<a href="/admin/settings" class="active">Instance Settings</a>
<a href="/admin/users">Users</a>
<a href="/admin/communities">Communities</a>
</nav>
</aside>
<main class="admin-main"> <main class="admin-main">
<header class="admin-header"> <header class="admin-header">
@ -282,13 +278,16 @@ import Layout from '../../layouts/Layout.astro';
} }
</style> </style>
<script> <script define:vars={{ apiBase }}>
const API_BASE = 'http://127.0.0.1:3000'; (function() {
const form = document.getElementById('settings-form'); const API_BASE = apiBase;
const loadingEl = document.getElementById('loading')!; const form = document.getElementById('settings-form');
const errorEl = document.getElementById('error')!; const loadingEl = document.getElementById('loading');
const saveBtn = document.getElementById('save-btn'); const errorEl = document.getElementById('error');
const saveStatus = document.getElementById('save-status')!; const saveBtn = document.getElementById('save-btn');
const saveStatus = document.getElementById('save-status');
if (!form || !loadingEl || !errorEl || !saveStatus) return;
async function loadSettings() { async function loadSettings() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@ -344,7 +343,7 @@ import Layout from '../../layouts/Layout.astro';
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) return; if (!token) return;
saveBtn.disabled = true; if (saveBtn) saveBtn.disabled = true;
saveStatus.textContent = 'Saving...'; saveStatus.textContent = 'Saving...';
const data = { const data = {
@ -374,9 +373,10 @@ import Layout from '../../layouts/Layout.astro';
saveStatus.textContent = 'Error: ' + err.message; saveStatus.textContent = 'Error: ' + err.message;
saveStatus.style.color = '#c62828'; saveStatus.style.color = '#c62828';
} finally { } finally {
saveBtn.disabled = false; if (saveBtn) saveBtn.disabled = false;
} }
}); });
loadSettings(); loadSettings();
})();
</script> </script>

View file

@ -574,6 +574,10 @@ import { API_BASE } from '../../lib/api';
<script define:vars={{ API_BASE }}> <script define:vars={{ API_BASE }}>
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login?redirect=/admin/voting';
}
const methodIcons = { const methodIcons = {
'approval': { icon: 'icon-approval', color: '#22c55e' }, 'approval': { icon: 'icon-approval', color: '#22c55e' },
'ranked_choice': { icon: 'icon-ranked-choice', color: '#3b82f6' }, 'ranked_choice': { icon: 'icon-ranked-choice', color: '#3b82f6' },
@ -586,7 +590,20 @@ import { API_BASE } from '../../lib/api';
const container = document.getElementById('voting-methods'); const container = document.getElementById('voting-methods');
try { try {
const res = await fetch(`${API_BASE}/api/voting-methods`); const res = await fetch(`${API_BASE}/api/voting-methods`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.status === 401) {
window.location.href = '/login?redirect=/admin/voting';
return;
}
if (res.status === 403) {
container.innerHTML = '<div class="loading-state"><span>Admin access required</span></div>';
return;
}
const methods = await res.json(); const methods = await res.json();
container.innerHTML = methods.map(m => { container.innerHTML = methods.map(m => {
@ -636,6 +653,17 @@ import { API_BASE } from '../../lib/api';
body: JSON.stringify({ is_active: isActive }) body: JSON.stringify({ is_active: isActive })
}); });
if (res.status === 401) {
window.location.href = '/login?redirect=/admin/voting';
return;
}
if (res.status === 403) {
alert('Admin access required');
e.target.checked = !isActive;
return;
}
if (!res.ok) { if (!res.ok) {
e.target.checked = !isActive; e.target.checked = !isActive;
} else { } else {
@ -663,6 +691,16 @@ import { API_BASE } from '../../lib/api';
body: JSON.stringify({ is_default: true }) body: JSON.stringify({ is_default: true })
}); });
if (res.status === 401) {
window.location.href = '/login?redirect=/admin/voting';
return;
}
if (res.status === 403) {
alert('Admin access required');
return;
}
if (res.ok) { if (res.ok) {
loadVotingMethods(); loadVotingMethods();
} }

View file

@ -1,5 +1,6 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { API_BASE as apiBase } from '../lib/api';
--- ---
<Layout title="Communities"> <Layout title="Communities">
@ -20,9 +21,9 @@ import Layout from '../layouts/Layout.astro';
<p class="loading">Loading communities...</p> <p class="loading">Loading communities...</p>
</div> </div>
<script is:inline> <script is:inline define:vars={{ apiBase }}>
(function() { (function() {
var API_BASE = 'http://localhost:3000'; var API_BASE = apiBase;
var allCommunities = []; var allCommunities = [];
function renderCommunities(communities) { function renderCommunities(communities) {

View file

@ -290,7 +290,25 @@ const { slug } = Astro.params;
if (!container) return; if (!container) return;
try { try {
const res = await fetch(`${apiBase}/api/communities/${communityId}/moderation`); 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(); const entries = await res.json();
if (entries.length === 0) { if (entries.length === 0) {

View file

@ -18,16 +18,6 @@ const { slug } = Astro.params;
<label for="description">Description</label> <label for="description">Description</label>
<textarea id="description" name="description" rows="5" required></textarea> <textarea id="description" name="description" rows="5" required></textarea>
</div> </div>
<div class="field">
<label for="voting_method">Voting Method</label>
<select id="voting_method" name="voting_method">
<option value="approval">Approval Voting (select multiple)</option>
<option value="ranked_choice">Ranked Choice (rank options)</option>
<option value="quadratic">Quadratic Voting (allocate credits)</option>
<option value="star">STAR Voting (rate 0-5 stars)</option>
</select>
<p class="method-hint" id="method-hint">Select all options you approve of</p>
</div>
<div class="field"> <div class="field">
<label>Options</label> <label>Options</label>
<div id="options-container"> <div id="options-container">
@ -59,20 +49,6 @@ const { slug } = Astro.params;
const addOptionBtn = document.getElementById('add-option'); const addOptionBtn = document.getElementById('add-option');
let optionCount = 2; let optionCount = 2;
const votingMethodSelect = document.getElementById('voting_method');
const methodHint = document.getElementById('method-hint');
const hints = {
'approval': 'Select all options you approve of',
'ranked_choice': 'Rank options from most to least preferred',
'quadratic': 'Allocate voice credits (cost = votes²)',
'star': 'Rate each option from 0 to 5 stars',
};
votingMethodSelect?.addEventListener('change', () => {
if (methodHint) methodHint.textContent = hints[votingMethodSelect.value] || '';
});
addOptionBtn?.addEventListener('click', () => { addOptionBtn?.addEventListener('click', () => {
optionCount++; optionCount++;
const div = document.createElement('div'); const div = document.createElement('div');
@ -109,7 +85,6 @@ const { slug } = Astro.params;
const data = { const data = {
title: formData.get('title'), title: formData.get('title'),
description: formData.get('description'), description: formData.get('description'),
voting_method: formData.get('voting_method'),
options: options, options: options,
}; };

View file

@ -1,6 +1,7 @@
--- ---
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';
const { slug } = Astro.params; const { slug } = Astro.params;
--- ---
@ -230,8 +231,8 @@ const { slug } = Astro.params;
} }
</style> </style>
<script define:vars={{ slug }}> <script define:vars={{ slug, apiBase }}>
const API_BASE = 'http://127.0.0.1:3000'; const API_BASE = apiBase;
const form = document.getElementById('settings-form'); const form = document.getElementById('settings-form');
const loadingEl = document.getElementById('loading'); const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error'); const errorEl = document.getElementById('error');

View file

@ -184,16 +184,28 @@ const { slug } = Astro.params;
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
let communityId = null; let communityId = null;
if (!token) {
window.location.href = `/login?redirect=/communities/${slug}/voting-config`;
}
async function init() { async function init() {
// First get community ID from slug
try { try {
const res = await fetch(`${API_BASE}/api/communities/${slug}`); const res = await fetch(`${API_BASE}/api/communities`);
if (!res.ok) { if (!res.ok) {
document.getElementById('voting-methods').innerHTML =
'<p class="error-message">Failed to load community</p>';
return;
}
const communities = await res.json();
const community = (communities || []).find(c => c.slug === slug);
if (!community) {
document.getElementById('voting-methods').innerHTML = document.getElementById('voting-methods').innerHTML =
'<p class="error-message">Community not found</p>'; '<p class="error-message">Community not found</p>';
return; return;
} }
const community = await res.json();
communityId = community.id; communityId = community.id;
await loadVotingMethods(); await loadVotingMethods();
} catch (e) { } catch (e) {
@ -206,7 +218,20 @@ const { slug } = Astro.params;
const container = document.getElementById('voting-methods'); const container = document.getElementById('voting-methods');
try { try {
const res = await fetch(`${API_BASE}/api/communities/${communityId}/voting-methods`); const res = await fetch(`${API_BASE}/api/communities/${communityId}/voting-methods`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.status === 401) {
window.location.href = `/login?redirect=/communities/${slug}/voting-config`;
return;
}
if (res.status === 403) {
container.innerHTML = '<p class="error-message">Admin/moderator access required</p>';
return;
}
const methods = await res.json(); const methods = await res.json();
container.innerHTML = methods.map(m => ` container.innerHTML = methods.map(m => `

View file

@ -1,5 +1,7 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { API_BASE as apiBase } from '../lib/api';
import DelegationGraph from '../components/voting/DelegationGraph.astro';
--- ---
<Layout title="Delegations - Likwid"> <Layout title="Delegations - Likwid">
@ -14,6 +16,10 @@ import Layout from '../layouts/Layout.astro';
</div> </div>
<div class="delegations-content" id="delegations-content" style="display: none;"> <div class="delegations-content" id="delegations-content" style="display: none;">
<section class="graph-section">
<DelegationGraph />
</section>
<div class="tabs"> <div class="tabs">
<button class="tab active" data-tab="outgoing">My Delegations</button> <button class="tab active" data-tab="outgoing">My Delegations</button>
<button class="tab" data-tab="incoming">Delegated to Me</button> <button class="tab" data-tab="incoming">Delegated to Me</button>
@ -65,7 +71,6 @@ import Layout from '../layouts/Layout.astro';
<select id="scope-select" required> <select id="scope-select" required>
<option value="global">Global (all votes)</option> <option value="global">Global (all votes)</option>
<option value="community">Specific Community</option> <option value="community">Specific Community</option>
<option value="topic">Specific Topic</option>
</select> </select>
</div> </div>
<div class="form-group" id="community-select-group" style="display: none;"> <div class="form-group" id="community-select-group" style="display: none;">
@ -74,6 +79,11 @@ import Layout from '../layouts/Layout.astro';
<option value="">Select community...</option> <option value="">Select community...</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="weight-input">Delegation weight</label>
<input id="weight-input" type="range" min="0.1" max="1" step="0.1" value="1" />
<div class="help-text" id="weight-label">100% of your voting power</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancel-delegation">Cancel</button> <button type="button" class="btn btn-secondary" id="cancel-delegation">Cancel</button>
<button type="submit" class="btn btn-primary">Create Delegation</button> <button type="submit" class="btn btn-primary">Create Delegation</button>
@ -187,6 +197,10 @@ import Layout from '../layouts/Layout.astro';
font-size: 0.9rem; font-size: 0.9rem;
} }
.graph-section {
margin-bottom: 1.5rem;
}
.delegation-card .actions { .delegation-card .actions {
margin-top: 1rem; margin-top: 1rem;
display: flex; display: flex;
@ -312,8 +326,8 @@ import Layout from '../layouts/Layout.astro';
} }
</style> </style>
<script> <script define:vars={{ apiBase }}>
const API_URL = 'http://127.0.0.1:3000'; const API_URL = apiBase;
let token = localStorage.getItem('token'); let token = localStorage.getItem('token');
async function init() { async function init() {
@ -353,6 +367,8 @@ import Layout from '../layouts/Layout.astro';
const form = document.getElementById('delegation-form'); const form = document.getElementById('delegation-form');
const scopeSelect = document.getElementById('scope-select'); const scopeSelect = document.getElementById('scope-select');
const communityGroup = document.getElementById('community-select-group'); const communityGroup = document.getElementById('community-select-group');
const weightInput = document.getElementById('weight-input');
const weightLabel = document.getElementById('weight-label');
newBtn?.addEventListener('click', async () => { newBtn?.addEventListener('click', async () => {
await loadDelegatesForSelect(); await loadDelegatesForSelect();
@ -368,6 +384,11 @@ import Layout from '../layouts/Layout.astro';
communityGroup.style.display = scopeSelect.value === 'community' ? 'block' : 'none'; communityGroup.style.display = scopeSelect.value === 'community' ? 'block' : 'none';
}); });
weightInput?.addEventListener('input', () => {
const v = parseFloat(weightInput.value || '1');
if (weightLabel) weightLabel.textContent = `${Math.round(v * 100)}% of your voting power`;
});
form?.addEventListener('submit', async (e) => { form?.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
await createDelegation(); await createDelegation();
@ -394,6 +415,7 @@ import Layout from '../layouts/Layout.astro';
<span class="scope-badge">${d.scope}</span> <span class="scope-badge">${d.scope}</span>
</header> </header>
<div class="meta"> <div class="meta">
Weight: ${Math.round((d.weight || 1) * 100)}%<br/>
Created: ${new Date(d.created_at).toLocaleDateString()} Created: ${new Date(d.created_at).toLocaleDateString()}
</div> </div>
<div class="actions"> <div class="actions">
@ -426,6 +448,7 @@ import Layout from '../layouts/Layout.astro';
<span class="scope-badge">${d.scope}</span> <span class="scope-badge">${d.scope}</span>
</header> </header>
<div class="meta"> <div class="meta">
Weight: ${Math.round((d.weight || 1) * 100)}%<br/>
Since: ${new Date(d.created_at).toLocaleDateString()} Since: ${new Date(d.created_at).toLocaleDateString()}
</div> </div>
</div> </div>
@ -491,10 +514,12 @@ import Layout from '../layouts/Layout.astro';
const delegateId = document.getElementById('delegate-select').value; const delegateId = document.getElementById('delegate-select').value;
const scope = document.getElementById('scope-select').value; const scope = document.getElementById('scope-select').value;
const communityId = document.getElementById('community-select').value; const communityId = document.getElementById('community-select').value;
const weight = parseFloat(document.getElementById('weight-input').value || '1');
const body = { const body = {
delegate_id: delegateId, delegate_id: delegateId,
scope: scope, scope: scope,
weight: weight,
}; };
if (scope === 'community' && communityId) { if (scope === 'community' && communityId) {
body.community_id = communityId; body.community_id = communityId;
@ -513,6 +538,7 @@ import Layout from '../layouts/Layout.astro';
if (res.ok) { if (res.ok) {
document.getElementById('delegation-modal').classList.remove('active'); document.getElementById('delegation-modal').classList.remove('active');
await loadMyDelegations(); await loadMyDelegations();
window.dispatchEvent(new Event('delegations:changed'));
} else { } else {
const err = await res.text(); const err = await res.text();
alert('Failed to create delegation: ' + err); alert('Failed to create delegation: ' + err);
@ -533,6 +559,7 @@ import Layout from '../layouts/Layout.astro';
if (res.ok) { if (res.ok) {
await loadMyDelegations(); await loadMyDelegations();
window.dispatchEvent(new Event('delegations:changed'));
} else { } else {
alert('Failed to revoke delegation'); alert('Failed to revoke delegation');
} }

View file

@ -74,7 +74,7 @@ import PublicLayout from '../layouts/PublicLayout.astro';
</a> </a>
<a href="/docs/governance/delegation" class="doc-link"> <a href="/docs/governance/delegation" class="doc-link">
<h4>Liquid Delegation</h4> <h4>Liquid Delegation</h4>
<p>Set up topic-based delegation, trust networks, and delegation policies.</p> <p>Set up delegation, trust networks, and delegation policies.</p>
</a> </a>
<a href="/docs/governance/deliberation" class="doc-link"> <a href="/docs/governance/deliberation" class="doc-link">
<h4>Structured Deliberation</h4> <h4>Structured Deliberation</h4>

View file

@ -106,10 +106,10 @@ import PublicLayout from '../layouts/PublicLayout.astro';
<div class="feature-grid"> <div class="feature-grid">
<div class="feature-item"> <div class="feature-item">
<h4>Topic-Based Delegation</h4> <h4>Scoped Delegation</h4>
<p> <p>
Delegate on specific topics (technical decisions, policy, budget) to people Delegate your voice globally or within a specific community to people you trust.
you trust in those areas. Keep direct control over topics you care about personally. You can always vote directly and override your delegation.
</p> </p>
</div> </div>
<div class="feature-item"> <div class="feature-item">

View file

@ -1,5 +1,39 @@
--- ---
export const prerender = false;
import PublicLayout from '../layouts/PublicLayout.astro'; import PublicLayout from '../layouts/PublicLayout.astro';
import { SERVER_API_BASE as serverApiBase } from '../lib/api';
let setupCompleted = false;
let platformMode: string | null = null;
let singleCommunitySlug: string | null = null;
let backendReachable = false;
const resolvedServerApiBase = serverApiBase || Astro.url.origin;
try {
const res = await fetch(`${resolvedServerApiBase}/api/settings/public`);
if (res.ok) {
const data = await res.json();
backendReachable = true;
setupCompleted = !!data.setup_completed;
platformMode = data.platform_mode || null;
singleCommunitySlug = data.single_community_slug || null;
}
} catch (e) {
// Backend not available
}
if (backendReachable) {
if (!setupCompleted) {
return Astro.redirect('/setup');
}
if (platformMode === 'single_community' && singleCommunitySlug) {
return Astro.redirect(`/communities/${singleCommunitySlug}`);
}
return Astro.redirect('/communities');
}
--- ---
<PublicLayout title="Modular Governance Engine"> <PublicLayout title="Modular Governance Engine">
@ -101,7 +135,7 @@ import PublicLayout from '../layouts/PublicLayout.astro';
</div> </div>
<div class="capability"> <div class="capability">
<h4>Liquid Delegation</h4> <h4>Liquid Delegation</h4>
<p>Topic-based, time-limited, and revocable delegation. Delegate your voice on specific topics to trusted members.</p> <p>Global and community-scoped delegation that is always revocable. Delegate your voice to trusted members while retaining control.</p>
</div> </div>
<div class="capability"> <div class="capability">
<h4>Structured Deliberation</h4> <h4>Structured Deliberation</h4>
@ -175,7 +209,7 @@ import PublicLayout from '../layouts/PublicLayout.astro';
</div> </div>
<div class="tech-item"> <div class="tech-item">
<strong>License</strong> <strong>License</strong>
<span>AGPLv3</span> <span>EUPL-1.2</span>
</div> </div>
</div> </div>
<p class="tech-note"> <p class="tech-note">

View file

@ -130,13 +130,13 @@ import PublicLayout from '../layouts/PublicLayout.astro';
<h3>4. Delegation Must Be Flexible and Revocable</h3> <h3>4. Delegation Must Be Flexible and Revocable</h3>
<p> <p>
Not everyone can participate in every decision. Liquid delegation allows members Not everyone can participate in every decision. Liquid delegation allows members
to delegate their voice on specific topics to trusted representatives—while retaining to delegate their voice to trusted representatives—while retaining
the ability to vote directly or revoke delegation at any time. the ability to vote directly or revoke delegation at any time.
</p> </p>
<p> <p>
This creates a spectrum between direct democracy (everyone votes on everything) and This creates a spectrum between direct democracy (everyone votes on everything) and
representative democracy (elected delegates decide). Members choose their level of representative democracy (elected delegates decide). Members choose their level of
engagement per topic, creating natural expertise networks while maintaining individual sovereignty. engagement, creating natural expertise networks while maintaining individual sovereignty.
</p> </p>
</div> </div>

View file

@ -257,7 +257,14 @@ const { id } = Astro.params;
if (!container) return; if (!container) return;
try { try {
const res = await fetch(`${apiBase}/api/proposals/${id}/results`); if (!token) {
container.innerHTML = '<p class="error">Login required to view detailed results</p>';
return;
}
const res = await fetch(`${apiBase}/api/proposals/${id}/results`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) { if (!res.ok) {
container.innerHTML = '<p class="error">Could not load detailed results</p>'; container.innerHTML = '<p class="error">Could not load detailed results</p>';
return; return;
@ -526,7 +533,7 @@ const { id } = Astro.params;
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
}, },
body: JSON.stringify({ title, description, voting_method: method }), body: JSON.stringify({ title, description }),
}); });
if (res.ok) { if (res.ok) {
@ -542,7 +549,7 @@ const { id } = Astro.params;
} }
async function loadComments() { async function loadComments() {
const container = document.getElementById('comments-container'); const container = document.getElementById('comments-list');
if (!container) return; if (!container) return;
try { try {

View file

@ -31,11 +31,15 @@ import { API_BASE as apiBase } from '../lib/api';
headers: { 'Authorization': `Bearer ${token}` }, headers: { 'Authorization': `Bearer ${token}` },
}); });
if (!res.ok) { if (res.status === 401) {
window.location.href = '/login'; window.location.href = '/login';
return; return;
} }
if (!res.ok) {
throw new Error('Failed to load settings');
}
const user = await res.json(); const user = await res.json();
const currentTheme = loadSavedTheme(); const currentTheme = loadSavedTheme();
@ -115,6 +119,11 @@ import { API_BASE as apiBase } from '../lib/api';
body: JSON.stringify({ display_name: displayName || null }), body: JSON.stringify({ display_name: displayName || null }),
}); });
if (res.status === 401) {
window.location.href = '/login';
return;
}
if (res.ok) { if (res.ok) {
btn.textContent = 'Saved!'; btn.textContent = 'Saved!';
setTimeout(() => { btn.textContent = 'Save Changes'; btn.disabled = false; }, 2000); setTimeout(() => { btn.textContent = 'Save Changes'; btn.disabled = false; }, 2000);
@ -129,6 +138,7 @@ import { API_BASE as apiBase } from '../lib/api';
document.getElementById('logout-btn')?.addEventListener('click', () => { document.getElementById('logout-btn')?.addEventListener('click', () => {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/'; window.location.href = '/';
}); });

View file

@ -1,12 +1,16 @@
--- ---
export const prerender = false;
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { API_BASE as apiBase, SERVER_API_BASE as serverApiBase } from '../lib/api';
// Check if setup is needed // Check if setup is needed
let setupRequired = true; let setupRequired = true;
let instanceName = null; let instanceName = null;
const resolvedServerApiBase = serverApiBase || Astro.url.origin;
try { try {
const res = await fetch('http://127.0.0.1:3000/api/settings/setup/status'); const res = await fetch(`${resolvedServerApiBase}/api/settings/setup/status`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setupRequired = data.setup_required; setupRequired = data.setup_required;
@ -283,7 +287,7 @@ if (!setupRequired) {
} }
</style> </style>
<script> <script define:vars={{ apiBase }}>
const form = document.getElementById('setup-form'); const form = document.getElementById('setup-form');
const submitBtn = document.getElementById('submit-btn'); const submitBtn = document.getElementById('submit-btn');
const authStatus = document.getElementById('auth-status'); const authStatus = document.getElementById('auth-status');
@ -311,7 +315,7 @@ if (!setupRequired) {
} }
try { try {
const res = await fetch('http://127.0.0.1:3000/api/users/me', { const res = await fetch(`${apiBase}/api/auth/me`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
@ -322,7 +326,7 @@ if (!setupRequired) {
const user = await res.json(); const user = await res.json();
// Check if user is admin // Check if user is admin
const adminRes = await fetch(`http://127.0.0.1:3000/api/settings/instance`, { const adminRes = await fetch(`${apiBase}/api/settings/instance`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
@ -368,7 +372,7 @@ if (!setupRequired) {
submitBtn.textContent = 'Setting up...'; submitBtn.textContent = 'Setting up...';
try { try {
const res = await fetch('http://127.0.0.1:3000/api/settings/setup', { const res = await fetch(`${apiBase}/api/settings/setup`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',