likwid/frontend/src/pages/communities/[slug]/plugins.astro

594 lines
16 KiB
Text
Raw Normal View History

---
export const prerender = false;
import Layout from '../../../layouts/Layout.astro';
import { API_BASE as apiBase } from '../../../lib/api';
const { slug } = Astro.params;
---
<Layout title="Community Plugins">
2026-01-29 16:52:30 +00:00
<section class="ui-page">
<div class="ui-container">
2026-01-29 20:40:25 +00:00
<div class="ui-hero ui-card ui-card-glass">
<div class="ui-hero-top">
2026-01-29 16:52:30 +00:00
<div>
2026-01-29 20:40:25 +00:00
<h1 class="ui-hero-title">Plugins</h1>
<p class="ui-hero-subtitle">Manage plugins for this community</p>
2026-01-29 16:52:30 +00:00
</div>
<a class="ui-btn ui-btn-secondary" href={`/communities/${slug}`}>Back</a>
</div>
</div>
2026-01-29 16:52:30 +00:00
<div id="plugins-content">
<div class="state-card ui-card"><p class="loading">Loading...</p></div>
</div>
</div>
</section>
</Layout>
<script define:vars={{ slug, apiBase }}>
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
}
function escapeHtml(str) {
return String(str)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
async function fetchCommunityBySlug() {
const res = await fetch(`${apiBase}/api/communities`);
if (!res.ok) throw new Error('Failed to load communities');
const communities = await res.json();
return communities.find(c => c.slug === slug);
}
async function fetchMembership(communityId) {
const res = await fetch(`${apiBase}/api/communities/${communityId}/membership`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) throw new Error('Failed to check membership');
return res.json();
}
async function fetchPlugins(communityId) {
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugins`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.text();
throw new Error(err || 'Failed to load plugins');
}
return res.json();
}
function renderSchemaField(pluginName, key, propSchema, currentValue) {
const fieldId = `field-${pluginName}-${key}`;
const title = propSchema.title || key;
const desc = propSchema.description || '';
const type = propSchema.type || 'string';
const defaultVal = propSchema.default;
const value = currentValue !== undefined ? currentValue : defaultVal;
let input = '';
if (type === 'boolean') {
input = `
<label class="checkbox-label">
<input type="checkbox" id="${fieldId}" data-key="${key}" data-type="boolean" ${value ? 'checked' : ''} />
<span>${escapeHtml(title)}</span>
</label>
`;
} else if (type === 'integer' || type === 'number') {
const min = propSchema.minimum !== undefined ? `min="${propSchema.minimum}"` : '';
const max = propSchema.maximum !== undefined ? `max="${propSchema.maximum}"` : '';
input = `
<label for="${fieldId}">${escapeHtml(title)}</label>
<input type="number" id="${fieldId}" data-key="${key}" data-type="${type}" value="${value ?? ''}" ${min} ${max} />
`;
} else if (type === 'string' && propSchema.enum) {
const options = propSchema.enum.map(opt =>
`<option value="${escapeHtml(opt)}" ${value === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`
).join('');
input = `
<label for="${fieldId}">${escapeHtml(title)}</label>
<select id="${fieldId}" data-key="${key}" data-type="string">${options}</select>
`;
} else {
input = `
<label for="${fieldId}">${escapeHtml(title)}</label>
<input type="text" id="${fieldId}" data-key="${key}" data-type="string" value="${escapeHtml(value ?? '')}" />
`;
}
return `
<div class="schema-field">
${input}
${desc ? `<p class="field-hint">${escapeHtml(desc)}</p>` : ''}
</div>
`;
}
function renderSettingsForm(pluginName, schema, settings) {
if (!schema || !schema.properties) {
const settingsText = JSON.stringify(settings ?? {}, null, 2);
return `
<div class="settings-body ui-form" style="--ui-form-group-mb: 0.75rem;">
<p class="settings-hint">No schema defined. Edit raw JSON:</p>
<textarea class="settings-json" spellcheck="false" data-name="${escapeHtml(pluginName)}">${escapeHtml(settingsText)}</textarea>
<div class="settings-actions">
2026-01-29 19:12:18 +00:00
<button class="ui-btn ui-btn-primary js-save-json" data-name="${escapeHtml(pluginName)}">Save Settings</button>
<span class="status" id="status-${escapeHtml(pluginName)}"></span>
</div>
</div>
`;
}
const fields = Object.entries(schema.properties).map(([key, propSchema]) => {
return renderSchemaField(pluginName, key, propSchema, settings?.[key]);
}).join('');
return `
<form class="settings-form ui-form" data-plugin="${escapeHtml(pluginName)}" style="--ui-form-group-mb: 1rem;">
${fields}
<div class="settings-actions">
2026-01-29 16:52:30 +00:00
<button type="submit" class="ui-btn ui-btn-primary">Save Settings</button>
<span class="status" id="status-${escapeHtml(pluginName)}"></span>
</div>
</form>
`;
}
function renderPlugins(community, membership, plugins) {
const container = document.getElementById('plugins-content');
if (!container) return;
const allowed = membership?.role === 'admin' || membership?.role === 'moderator';
if (!allowed) {
container.innerHTML = `
<div class="error">
<h2>Forbidden</h2>
<p>You must be an admin or moderator to manage plugins for ${escapeHtml(community.name)}.</p>
</div>
`;
return;
}
container.innerHTML = `
2026-01-29 16:52:30 +00:00
<div class="community-badge ui-card">
<div class="community-badge-title">
<span class="name">${escapeHtml(community.name)}</span>
<span class="slug">/${escapeHtml(community.slug)}</span>
</div>
<div class="community-badge-subtitle">Community plugin settings</div>
</div>
<div class="plugins-list">
${plugins.map(p => {
const disabledGlobally = !p.global_is_active;
const isCore = !!p.is_core;
const checked = !!p.community_is_active;
const toggleDisabled = disabledGlobally || isCore;
const hasSchema = p.settings_schema && p.settings_schema.properties;
return `
2026-01-29 16:52:30 +00:00
<div class="plugin-card ui-card" data-name="${escapeHtml(p.name)}">
<div class="plugin-head">
<div class="plugin-title">
<h3>${escapeHtml(p.name)}</h3>
<span class="version">v${escapeHtml(p.version)}</span>
2026-01-29 16:52:30 +00:00
${isCore ? `<span class="ui-pill tag-core">core</span>` : ''}
${disabledGlobally ? `<span class="ui-pill tag-disabled">disabled globally</span>` : ''}
</div>
<div class="plugin-controls">
<label class="switch" title="Enable/disable">
<input class="plugin-toggle" type="checkbox" data-name="${escapeHtml(p.name)}" ${checked ? 'checked' : ''} ${toggleDisabled ? 'disabled' : ''} />
<span class="slider"></span>
</label>
</div>
</div>
${p.description ? `<p class="desc">${escapeHtml(p.description)}</p>` : ''}
2026-01-29 16:52:30 +00:00
<details class="panel ui-card settings-panel">
<summary class="panel-summary">
<span class="panel-title">Settings</span>
<span class="panel-meta">${hasSchema ? 'Schema' : '<span class="ui-badge">JSON</span>'}</span>
</summary>
<div class="panel-body settings-body-wrap">
${renderSettingsForm(p.name, p.settings_schema, p.settings)}
</div>
</details>
</div>
`;
}).join('')}
</div>
`;
document.querySelectorAll('.plugin-toggle').forEach(el => {
el.addEventListener('change', async (e) => {
const input = e.target;
const pluginName = input.dataset.name;
const next = input.checked;
input.disabled = true;
try {
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugins/${pluginName}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ is_active: next }),
});
if (!res.ok) {
const err = await res.text();
input.checked = !next;
alert(err || 'Failed to update plugin');
}
} catch (err) {
input.checked = !next;
alert('Failed to update plugin');
} finally {
input.disabled = false;
}
});
});
2026-01-29 19:12:18 +00:00
document.querySelectorAll('.js-save-json').forEach(el => {
el.addEventListener('click', async (e) => {
const btn = e.target;
const pluginName = btn.dataset.name;
const textarea = document.querySelector(`textarea.settings-json[data-name="${pluginName}"]`);
const statusEl = document.getElementById(`status-${pluginName}`);
if (!textarea) return;
let settings;
try {
settings = JSON.parse(textarea.value || '{}');
} catch (err) {
if (statusEl) statusEl.textContent = 'Invalid JSON';
return;
}
btn.disabled = true;
if (statusEl) statusEl.textContent = 'Saving...';
try {
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugins/${pluginName}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ settings }),
});
if (res.ok) {
if (statusEl) statusEl.textContent = 'Saved';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
} else {
const err = await res.text();
if (statusEl) statusEl.textContent = 'Error';
alert(err || 'Failed to save settings');
}
} catch (err) {
if (statusEl) statusEl.textContent = 'Error';
alert('Failed to save settings');
} finally {
btn.disabled = false;
}
});
});
document.querySelectorAll('.settings-form').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const pluginName = form.dataset.plugin;
const statusEl = document.getElementById(`status-${pluginName}`);
const btn = form.querySelector('button[type="submit"]');
const settings = {};
form.querySelectorAll('[data-key]').forEach(input => {
const key = input.dataset.key;
const type = input.dataset.type;
if (type === 'boolean') {
settings[key] = input.checked;
} else if (type === 'integer') {
settings[key] = input.value ? parseInt(input.value, 10) : null;
} else if (type === 'number') {
settings[key] = input.value ? parseFloat(input.value) : null;
} else {
settings[key] = input.value;
}
});
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Saving...';
try {
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugins/${pluginName}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ settings }),
});
if (res.ok) {
if (statusEl) statusEl.textContent = 'Saved';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
} else {
const err = await res.text();
if (statusEl) statusEl.textContent = 'Error';
alert(err || 'Failed to save settings');
}
} catch (err) {
if (statusEl) statusEl.textContent = 'Error';
alert('Failed to save settings');
} finally {
if (btn) btn.disabled = false;
}
});
});
}
async function load() {
const container = document.getElementById('plugins-content');
if (!container) return;
try {
const community = await fetchCommunityBySlug();
if (!community) {
container.innerHTML = '<div class="error">Community not found</div>';
return;
}
const membership = await fetchMembership(community.id);
const plugins = await fetchPlugins(community.id);
renderPlugins(community, membership, plugins);
} catch (err) {
container.innerHTML = `<div class="error">${escapeHtml(err?.message || 'Failed to load')}</div>`;
}
}
load();
</script>
<style>
.community-badge {
2026-01-29 16:52:30 +00:00
padding: 1rem 1.1rem;
margin-bottom: 1.5rem;
}
.community-badge-title {
display: flex;
gap: 0.75rem;
align-items: baseline;
2026-01-29 16:52:30 +00:00
flex-wrap: wrap;
}
.community-badge-subtitle {
margin-top: 0.35rem;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.community-badge .name {
font-weight: 700;
}
.community-badge .slug {
color: var(--color-primary);
font-family: monospace;
}
.plugins-list {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.plugin-card {
padding: 1rem;
}
.plugin-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.plugin-title {
display: flex;
align-items: baseline;
gap: 0.75rem;
flex-wrap: wrap;
}
.plugin-title h3 {
font-size: 1.1rem;
}
.version {
color: var(--color-text-muted);
font-family: monospace;
}
2026-01-29 16:52:30 +00:00
.tag-core {
background: var(--color-info-muted);
color: var(--color-info);
}
2026-01-29 16:52:30 +00:00
.tag-disabled {
background: var(--color-error-muted);
color: var(--color-error);
}
.desc {
color: var(--color-text-muted);
margin-bottom: 0.75rem;
}
2026-01-29 16:52:30 +00:00
.settings-panel {
margin-top: 0.75rem;
}
.settings-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
textarea.settings-json {
min-height: 140px;
border-radius: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9rem;
resize: vertical;
}
.settings-actions {
display: flex;
align-items: center;
gap: 0.75rem;
2026-01-29 16:52:30 +00:00
flex-wrap: wrap;
}
.status {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-neutral);
transition: 0.2s;
border-radius: 999px;
}
.slider:before {
position: absolute;
content: '';
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: var(--color-on-primary);
transition: 0.2s;
border-radius: 999px;
}
input:checked + .slider {
background-color: var(--color-primary);
}
input:checked + .slider:before {
transform: translateX(20px);
}
input:disabled + .slider {
opacity: 0.5;
cursor: default;
}
.loading {
color: var(--color-text-muted);
}
.error {
background: var(--color-error-muted);
border: 1px solid var(--color-error);
padding: 1rem;
border-radius: 12px;
}
.settings-body-wrap {
padding-top: 0.5rem;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.schema-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.schema-field label {
font-weight: 500;
font-size: 0.9rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
}
.field-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
margin: 0;
}
.settings-hint {
font-size: 0.85rem;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
2026-01-29 16:52:30 +00:00
@media (max-width: 768px) {
.plugin-head {
flex-direction: column;
}
}
</style>