mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-02-09 21:13:09 +00:00
frontend: improve routing, admin UX, and API base handling
This commit is contained in:
parent
89a6e9eaa7
commit
d8b2b0af14
26 changed files with 424 additions and 126 deletions
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: '🔐' },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>© 2026 Likwid. Free and open source software under AGPLv3.</p>
|
||||
<p>© 2026 Likwid. Free and open source software under EUPL-1.2.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 => `
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = '/';
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue