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
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 16s
This commit is contained in:
242
admin.js
242
admin.js
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user