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
export default defineConfig({
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 {
currentPage;
currentPage: string;
}
const { currentPage } = Astro.props;
const navItems = [
{ 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/invitations', label: 'Invitations', icon: '📨' },
{ href: '/admin/roles', label: 'Roles & Permissions', icon: '🔐' },

View file

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

View file

@ -1,6 +1,6 @@
---
interface Props {
title;
title: string;
}
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-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-md: 12px;
--radius-lg: 16px;

View file

@ -1,7 +1,7 @@
---
interface Props {
title;
description?;
title: string;
description?: string;
}
import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes';
@ -123,7 +123,7 @@ const defaultTheme = DEFAULT_THEME;
</div>
</div>
<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>
</footer>
</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 {
status: string;

View file

@ -141,7 +141,7 @@ import PublicLayout from '../layouts/PublicLayout.astro';
<section class="content-section">
<h2>Technical Foundation</h2>
<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>
<div class="tech-table">
<div class="tech-row">
@ -162,12 +162,12 @@ import PublicLayout from '../layouts/PublicLayout.astro';
</div>
<div class="tech-row">
<span class="tech-label">License</span>
<span class="tech-value">AGPLv3</span>
<span class="tech-value">EUPL-1.2</span>
</div>
</div>
<p class="tech-note">
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.
</p>
</section>

View file

@ -62,7 +62,17 @@ import { API_BASE as apiBase } from '../../lib/api';
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();
@ -102,7 +112,17 @@ import { API_BASE as apiBase } from '../../lib/api';
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();
@ -167,9 +187,25 @@ import { API_BASE as apiBase } from '../../lib/api';
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);
// Remove item from list
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>`;
}
} else {
alert('Error: ' + (data.message || 'Failed to process'));
alert('Error: ' + ((data && (data.message || data.error)) || raw || 'Failed to process'));
}
} catch (error) {
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 })
});
if (res.status === 401) {
window.location.href = '/login?redirect=/admin/invitations';
return;
}
if (res.status === 403) {
alert('Admin access required');
return;
}
if (res.ok) {
const data = await res.json();
alert(`Invitation created!\nCode: ${data.code}`);
@ -110,7 +120,17 @@ import { API_BASE as apiBase } from '../../lib/api';
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();
@ -158,10 +178,21 @@ import { API_BASE as apiBase } from '../../lib/api';
method: 'DELETE',
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) {
loadInvitations();
} else {
alert('Error revoking invitation');
alert('Error: ' + (await res.text()));
}
} catch (error) {
alert('Error revoking invitation');

View file

@ -1,17 +1,13 @@
---
export const prerender = false;
import Layout from '../../layouts/Layout.astro';
import AdminNav from '../../components/AdminNav.astro';
import { API_BASE } from '../../lib/api';
---
<Layout title="Plugin Management - Admin">
<div class="admin-container">
<nav class="admin-nav">
<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>
<AdminNav currentPage="/admin/plugins" />
<main class="admin-content">
<header class="page-header">
@ -240,13 +236,37 @@ import { API_BASE } from '../../lib/api';
<script define:vars={{ API_BASE }}>
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() {
try {
const [defaultsRes, instanceRes] = await Promise.all([
fetch(`${API_BASE}/api/plugins/defaults`),
fetch(`${API_BASE}/api/plugins/instance`)
fetch(`${API_BASE}/api/plugins/defaults`, {
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 instance = await instanceRes.json();
@ -333,6 +353,17 @@ import { API_BASE } from '../../lib/api';
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) {
const err = await res.text();
alert('Failed to update plugin: ' + err);

View file

@ -1,17 +1,13 @@
---
export const prerender = false;
import Layout from '../../layouts/Layout.astro';
import AdminNav from '../../components/AdminNav.astro';
import { API_BASE } from '../../lib/api';
---
<Layout title="Role Management - Admin">
<div class="admin-container">
<nav class="admin-nav">
<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>
<AdminNav currentPage="/admin/roles" />
<main class="admin-content">
<header class="page-header">
@ -208,6 +204,12 @@ import { API_BASE } from '../../lib/api';
</style>
<script define:vars={{ API_BASE }}>
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login?redirect=/admin/roles';
}
async function loadData() {
await Promise.all([loadRoles(), loadPermissions()]);
}
@ -216,7 +218,20 @@ import { API_BASE } from '../../lib/api';
const container = document.getElementById('platform-roles');
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();
container.innerHTML = roles.map(r => `
@ -246,7 +261,20 @@ import { API_BASE } from '../../lib/api';
const container = document.getElementById('permissions-list');
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 byCategory = {};

View file

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

View file

@ -574,6 +574,10 @@ import { API_BASE } from '../../lib/api';
<script define:vars={{ API_BASE }}>
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login?redirect=/admin/voting';
}
const methodIcons = {
'approval': { icon: 'icon-approval', color: '#22c55e' },
'ranked_choice': { icon: 'icon-ranked-choice', color: '#3b82f6' },
@ -586,7 +590,20 @@ import { API_BASE } from '../../lib/api';
const container = document.getElementById('voting-methods');
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();
container.innerHTML = methods.map(m => {
@ -636,6 +653,17 @@ import { API_BASE } from '../../lib/api';
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) {
e.target.checked = !isActive;
} else {
@ -663,6 +691,16 @@ import { API_BASE } from '../../lib/api';
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) {
loadVotingMethods();
}

View file

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

View file

@ -290,7 +290,25 @@ const { slug } = Astro.params;
if (!container) return;
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();
if (entries.length === 0) {

View file

@ -18,16 +18,6 @@ const { slug } = Astro.params;
<label for="description">Description</label>
<textarea id="description" name="description" rows="5" required></textarea>
</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">
<label>Options</label>
<div id="options-container">
@ -59,20 +49,6 @@ const { slug } = Astro.params;
const addOptionBtn = document.getElementById('add-option');
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', () => {
optionCount++;
const div = document.createElement('div');
@ -109,7 +85,6 @@ const { slug } = Astro.params;
const data = {
title: formData.get('title'),
description: formData.get('description'),
voting_method: formData.get('voting_method'),
options: options,
};

View file

@ -1,6 +1,7 @@
---
export const prerender = false;
import Layout from '../../../layouts/Layout.astro';
import { API_BASE as apiBase } from '../../../lib/api';
const { slug } = Astro.params;
---
@ -230,8 +231,8 @@ const { slug } = Astro.params;
}
</style>
<script define:vars={{ slug }}>
const API_BASE = 'http://127.0.0.1:3000';
<script define:vars={{ slug, apiBase }}>
const API_BASE = apiBase;
const form = document.getElementById('settings-form');
const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error');

View file

@ -184,16 +184,28 @@ const { slug } = Astro.params;
const token = localStorage.getItem('token');
let communityId = null;
if (!token) {
window.location.href = `/login?redirect=/communities/${slug}/voting-config`;
}
async function init() {
// First get community ID from slug
try {
const res = await fetch(`${API_BASE}/api/communities/${slug}`);
const res = await fetch(`${API_BASE}/api/communities`);
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 =
'<p class="error-message">Community not found</p>';
return;
}
const community = await res.json();
communityId = community.id;
await loadVotingMethods();
} catch (e) {
@ -206,7 +218,20 @@ const { slug } = Astro.params;
const container = document.getElementById('voting-methods');
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();
container.innerHTML = methods.map(m => `

View file

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

View file

@ -74,7 +74,7 @@ import PublicLayout from '../layouts/PublicLayout.astro';
</a>
<a href="/docs/governance/delegation" class="doc-link">
<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 href="/docs/governance/deliberation" class="doc-link">
<h4>Structured Deliberation</h4>

View file

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

View file

@ -1,5 +1,39 @@
---
export const prerender = false;
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">
@ -101,7 +135,7 @@ import PublicLayout from '../layouts/PublicLayout.astro';
</div>
<div class="capability">
<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 class="capability">
<h4>Structured Deliberation</h4>
@ -175,7 +209,7 @@ import PublicLayout from '../layouts/PublicLayout.astro';
</div>
<div class="tech-item">
<strong>License</strong>
<span>AGPLv3</span>
<span>EUPL-1.2</span>
</div>
</div>
<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>
<p>
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.
</p>
<p>
This creates a spectrum between direct democracy (everyone votes on everything) and
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>
</div>

View file

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

View file

@ -31,11 +31,15 @@ import { API_BASE as apiBase } from '../lib/api';
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
if (res.status === 401) {
window.location.href = '/login';
return;
}
if (!res.ok) {
throw new Error('Failed to load settings');
}
const user = await res.json();
const currentTheme = loadSavedTheme();
@ -115,6 +119,11 @@ import { API_BASE as apiBase } from '../lib/api';
body: JSON.stringify({ display_name: displayName || null }),
});
if (res.status === 401) {
window.location.href = '/login';
return;
}
if (res.ok) {
btn.textContent = 'Saved!';
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', () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/';
});

View file

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