feat: Service-Editor mit vollem CRUD und Server-Speichern
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 16s

This commit is contained in:
Kroonk
2026-05-22 00:43:25 +02:00
parent 072fc9b9f9
commit fb3c7a99f1
4 changed files with 304 additions and 39 deletions

242
admin.js
View File

@@ -17,7 +17,9 @@ const loginMessage = document.querySelector('#loginMessage');
const adminUser = document.querySelector('#adminUser');
const serviceEditor = document.querySelector('#serviceEditor');
const checkAllButton = document.querySelector('#checkAllButton');
const saveLocalButton = document.querySelector('#saveLocalButton');
const addServiceButton = document.querySelector('#addServiceButton');
const saveServerButton = document.querySelector('#saveServerButton');
const editorFeedback = document.querySelector('#editorFeedback');
const resetLocalButton = document.querySelector('#resetLocalButton');
const downloadConfigButton = document.querySelector('#downloadConfigButton');
const refreshOpsButton = document.querySelector('#refreshOpsButton');
@@ -193,41 +195,128 @@ function categoryOptions(selected) {
)).join('');
}
function iconOptions(selected) {
const icons = ['shield', 'folder', 'lock', 'film', 'headphones', 'book', 'git', 'chess', 'user'];
return icons.map((icon) => (
`<option value="${icon}" ${icon === selected ? 'selected' : ''}>${icon}</option>`
)).join('');
}
function renderEditor() {
serviceEditor.innerHTML = config.services.map((service, index) => `
<article class="admin-row" data-index="${index}">
<div>
<strong>${service.name}</strong>
<span>${service.domain}</span>
<!-- Group 1: Identitaet & Kategorie -->
<div class="admin-form-group-row">
<label style="flex: 1 1 120px;">
Dienst ID (Eindeutig)
<input data-field="id" value="${service.id || ''}" placeholder="z.B. passwort-manager">
</label>
<label style="flex: 2 2 180px;">
Anzeigename
<input data-field="name" value="${service.name || ''}" placeholder="z.B. Passwords">
</label>
<label style="flex: 2 2 200px;">
Subdomain / Domain
<input data-field="domain" value="${service.domain || ''}" placeholder="z.B. password.mischlabs.de">
</label>
<label style="flex: 1.5 1.5 150px;">
Kategorie
<select data-field="category">${categoryOptions(service.category)}</select>
</label>
</div>
<!-- Group 2: URLs, Aesthetics & Icon -->
<div class="admin-form-group-row">
<label style="flex: 3 3 250px;">
Dienst-URL (Zieladresse)
<input data-field="url" value="${service.url || ''}" placeholder="z.B. https://password.mischlabs.de">
</label>
<label style="flex: 2 2 180px;">
Mobile URL Scheme (Optional)
<input data-field="mobileUrl" value="${service.mobileUrl || ''}" placeholder="z.B. bitwarden://">
</label>
<label style="flex: 1.5 1.5 140px;">
Accent-Farbe
<div style="display: flex; gap: 6px; align-items: center;">
<input type="color" class="accent-color-picker" value="${service.accent || '#38bdf8'}" style="width: 42px; min-width: 42px; height: 38px; padding: 2px; cursor: pointer; border: 1px solid var(--border); border-radius: var(--radius); background: rgba(17,22,36,0.92);">
<input data-field="accent" value="${service.accent || '#38bdf8'}" placeholder="#ffffff" style="font-family: monospace;">
</div>
</label>
<label style="flex: 1.2 1.2 120px;">
Icon
<select data-field="icon">${iconOptions(service.icon)}</select>
</label>
</div>
<!-- Group 3: Description, Keywords & Actions -->
<div class="admin-form-group-row">
<label style="flex: 2 2 250px;">
Beschreibung
<input data-field="description" value="${service.description || ''}" placeholder="Kurze Dienstbeschreibung...">
</label>
<label style="flex: 2 2 250px;">
Keywords (Suche)
<input data-field="keywords" value="${service.keywords || ''}" placeholder="Schluesselwoerter (durch Leerzeichen getrennt)...">
</label>
</div>
<div class="admin-row-actions">
<button class="ghost-button check-one mini" type="button">Verbindung Pruefen</button>
<button class="ghost-button danger-button delete-one mini" type="button" style="border-color: rgba(251, 113, 133, 0.3); color: #fb7185;">Loeschen</button>
</div>
<label>
Kategorie
<select data-field="category">${categoryOptions(service.category)}</select>
</label>
<label>
URL
<input data-field="url" value="${service.url}">
</label>
<label>
Mobile URL (App Scheme)
<input data-field="mobileUrl" value="${service.mobileUrl || ''}" placeholder="z.B. nextcloud://">
</label>
<button class="ghost-button check-one" type="button">Pruefen</button>
<div class="diagnostic">Noch nicht geprueft.</div>
</article>
`).join('');
serviceEditor.querySelectorAll('select,input').forEach((input) => {
input.addEventListener('change', () => {
const row = input.closest('.admin-row');
const service = config.services[Number(row.dataset.index)];
service[input.dataset.field] = input.value;
// Wire up two-way sync for color pickers and text inputs, and event listeners
serviceEditor.querySelectorAll('.admin-row').forEach((row) => {
const index = Number(row.dataset.index);
const service = config.services[index];
// Wire standard input changes
row.querySelectorAll('select, input:not(.accent-color-picker)').forEach((input) => {
input.addEventListener('input', () => {
service[input.dataset.field] = input.value;
});
input.addEventListener('change', () => {
service[input.dataset.field] = input.value;
});
});
// Wire color picker sync
const textInput = row.querySelector('input[data-field="accent"]');
const colorPicker = row.querySelector('.accent-color-picker');
if (textInput && colorPicker) {
colorPicker.addEventListener('input', () => {
textInput.value = colorPicker.value;
service.accent = colorPicker.value;
});
textInput.addEventListener('input', () => {
const val = textInput.value;
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
colorPicker.value = val;
}
service.accent = val;
});
}
});
serviceEditor.querySelectorAll('.check-one').forEach((button) => {
button.addEventListener('click', () => checkRow(button.closest('.admin-row')));
});
serviceEditor.querySelectorAll('.delete-one').forEach((button) => {
button.addEventListener('click', () => {
const row = button.closest('.admin-row');
const index = Number(row.dataset.index);
const service = config.services[index];
const confirmed = window.confirm(`Dienst "${service.name || service.id || 'unbekannt'}" wirklich loeschen?`);
if (!confirmed) return;
config.services.splice(index, 1);
renderEditor();
});
});
}
async function probe(url) {
@@ -282,8 +371,102 @@ async function checkRow(row) {
diagnostic.textContent = `${label} - ${result.ms} ms - ${result.detail}`;
}
function saveLocal() {
localStorage.setItem(LOCAL_CONFIG_KEY, JSON.stringify(config, null, 2));
function addService() {
const newService = {
id: `dienst-${Date.now()}`,
name: 'Neuer Dienst',
description: 'Beschreibung des Dienstes',
domain: 'neu.mischlabs.de',
url: 'https://neu.mischlabs.de',
category: config.categories[0]?.id || 'access',
accent: '#38bdf8',
icon: 'shield',
keywords: 'neu dienst'
};
config.services.push(newService);
renderEditor();
// Scroll new row into view smoothly
const rows = serviceEditor.querySelectorAll('.admin-row');
const lastRow = rows[rows.length - 1];
if (lastRow) {
lastRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
lastRow.classList.add('is-new');
}
}
function showFeedback(type, message) {
if (!editorFeedback) return;
editorFeedback.style.display = 'block';
editorFeedback.textContent = message;
if (type === 'success') {
editorFeedback.style.background = 'rgba(52, 211, 153, 0.12)';
editorFeedback.style.borderColor = 'rgba(52, 211, 153, 0.35)';
editorFeedback.style.color = '#34d399';
} else {
editorFeedback.style.background = 'rgba(251, 113, 133, 0.12)';
editorFeedback.style.borderColor = 'rgba(251, 113, 133, 0.35)';
editorFeedback.style.color = '#fb7185';
}
if (type === 'success') {
setTimeout(() => {
editorFeedback.style.display = 'none';
}, 5000);
}
}
async function saveServer() {
if (!tokenSet?.id_token) {
showFeedback('error', 'Nicht angemeldet oder Sitzung abgelaufen.');
return;
}
if (!config || !Array.isArray(config.services) || !Array.isArray(config.categories)) {
showFeedback('error', 'Ungueltiges Konfigurationsformat.');
return;
}
for (const service of config.services) {
if (!service.id || !service.name || !service.domain || !service.url || !service.category) {
showFeedback('error', 'Bitte füllen Sie ID, Name, Domain, URL und Kategorie für alle Dienste aus.');
return;
}
}
const originalText = saveServerButton.textContent;
saveServerButton.disabled = true;
saveServerButton.textContent = 'Speichere...';
try {
const response = await fetch('/api/services/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tokenSet.id_token}`
},
body: JSON.stringify(config)
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
showFeedback('success', 'Konfiguration erfolgreich auf dem Server gespeichert!');
localStorage.removeItem(LOCAL_CONFIG_KEY);
window.setTimeout(() => {
refreshOps();
}, 500);
} catch (error) {
showFeedback('error', `Speichern fehlgeschlagen: ${error.message}`);
} finally {
saveServerButton.disabled = false;
saveServerButton.textContent = originalText;
}
}
function downloadConfig() {
@@ -1278,10 +1461,13 @@ checkAllButton.addEventListener('click', () => {
[...serviceEditor.querySelectorAll('.admin-row')].forEach((row) => checkRow(row));
});
saveLocalButton.addEventListener('click', () => {
saveLocal();
window.alert('Lokal gespeichert. Das Dashboard in diesem Browser nutzt diese Aenderungen.');
});
if (addServiceButton) {
addServiceButton.addEventListener('click', addService);
}
if (saveServerButton) {
saveServerButton.addEventListener('click', saveServer);
}
resetLocalButton.addEventListener('click', () => {
localStorage.removeItem(LOCAL_CONFIG_KEY);