Quelltext des aktuellen Standes
/*
* Seiten-Editor der Dokumentation.
*
* Wird nur auf den Editor-Seiten geladen. Die serverseitigen Angaben - ob
* bearbeitet oder neu angelegt wird, ob TinyMCE vorliegt, ob der KI-Assistent
* eingeschaltet ist, die Seiten-ID und ein gefundener Entwurf - stehen als
* JSON im Markup; dieses Skript liest sie einmal und arbeitet damit.
*
* Die vier Abschnitte laufen unabhaengig voneinander: Jeder ist fuer sich
* abgeschirmt, damit ein Fehler in einem die uebrigen nicht mitreisst -
* so, wie es vier getrennte Skriptbloecke taeten. Ein Fehler bleibt in der
* Konsole sichtbar.
*
* `deletePageAction` haengt bewusst am `window`: Der Loeschknopf ruft sie
* ueber ein onclick-Attribut auf.
*/
(function () {
var _daten = document.getElementById('page-editor-data');
var cfg;
try {
cfg = _daten ? JSON.parse(_daten.textContent) : {};
} catch (e) {
cfg = {};
}
cfg.isEdit = !!cfg.isEdit;
cfg.tinymce = !!cfg.tinymce;
cfg.aiEnabled = !!cfg.aiEnabled;
cfg.canDelete = !!cfg.canDelete;
cfg.pageId = parseInt(cfg.pageId, 10) || 0;
cfg.draftContent = cfg.draftContent || null;
/**
* Der KI-Dialog wird im Abschnitt "KI-Assistent" gebaut und aus der
* TinyMCE-Schaltflaeche im Abschnitt "Editor" gerufen - er braucht daher
* einen Platz, den beide sehen.
*/
var openAiEditorModal = function () {};
/** Fuehrt einen Abschnitt aus und laesst einen Fehler dort enden. */
function abschnitt(name, fn) {
try {
fn();
} catch (e) {
if (window.console && console.error) {
console.error('Seiten-Editor, Abschnitt "' + name + '":', e);
}
}
}
abschnitt('Editor', function () {
// CSRF-Token Heartbeat: Alle 15 Minuten erneuern, damit beim Speichern kein Fehler auftritt
(function() {
function refreshCsrf() {
fetch('/api/csrf-token', {credentials: 'same-origin'})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.token) {
var csrfInputs = document.querySelectorAll('input[name="_csrf"]');
csrfInputs.forEach(function(el) { el.value = d.token; });
}
})
.catch(function() {});
}
// Alle 15 Minuten Token erneuern
setInterval(refreshCsrf, 15 * 60 * 1000);
})();
document.addEventListener('DOMContentLoaded', function() {
// Sidebar Toggle
(function() {
var layout = document.querySelector('.editor-layout');
var toggleBtn = document.getElementById('sidebarToggleBtn');
if (!layout || !toggleBtn) return;
var storageKey = 'editor-sidebar-collapsed';
if (localStorage.getItem(storageKey) === '1') {
layout.classList.add('sidebar-collapsed');
}
function toggleSidebar() {
layout.classList.toggle('sidebar-collapsed');
localStorage.setItem(storageKey, layout.classList.contains('sidebar-collapsed') ? '1' : '0');
}
toggleBtn.addEventListener('click', toggleSidebar);
layout.querySelectorAll('.sidebar-section .section-header').forEach(function(header) {
header.addEventListener('click', function() {
if (layout.classList.contains('sidebar-collapsed')) {
toggleSidebar();
}
});
});
})();
var _formReadyToSubmit = false;
function doSubmit(form) {
var submitBtn = form.querySelector('.btn-save');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Wird gespeichert…';
}
if (typeof tinymce !== 'undefined' && tinymce.activeEditor) {
tinymce.activeEditor.uploadImages().then(function() {
tinymce.activeEditor.save();
form.submit();
}).catch(function() {
tinymce.activeEditor.save();
form.submit();
});
} else {
if (window._fallbackEditor) {
var ta = document.getElementById('content-editor');
if (ta) ta.value = window._fallbackEditor.getContent();
}
form.submit();
}
}
// Form-Submit-Handler
var form = document.getElementById('page-form');
var isEditMode = cfg.isEdit;
if (form) {
form.addEventListener('submit', function(e) {
var title = document.getElementById('title');
var topicId = document.querySelector('select[name="topic_id"], input[name="topic_id"]');
if (!title || !title.value.trim()) {
e.preventDefault();
title && title.focus();
alert('Bitte einen Titel eingeben');
return false;
}
if (topicId && !topicId.value) {
e.preventDefault();
alert(window.onitText('Bitte ein Themengebiet auswählen'));
return false;
}
// Bei Edit-Modus: Änderungsgrund-Modal anzeigen (außer wenn schon bereit)
if (isEditMode && !_formReadyToSubmit) {
e.preventDefault();
openChangeReasonModal(form);
return false;
}
// Immer über doSubmit gehen: dort wird der Editor-Inhalt in das
// Textfeld übernommen – bei TinyMCE ebenso wie beim einfachen
// Editor. Ohne diesen Weg würde beim Anlegen einer Seite mit dem
// einfachen Editor der ungefüllte Ausgangswert gespeichert.
if (!_formReadyToSubmit) {
e.preventDefault();
doSubmit(form);
return false;
}
});
}
// Änderungsgrund-Modal
function openChangeReasonModal(form) {
var modal = document.getElementById('change-reason-modal');
var input = document.getElementById('cr-modal-input');
var errorEl = document.getElementById('cr-modal-error');
if (!modal) return;
// Vorherigen Wert wiederherstellen falls vorhanden
var existing = document.getElementById('change_reason');
if (existing && existing.value) input.value = existing.value;
errorEl.style.display = 'none';
modal.style.display = 'flex';
setTimeout(function() { input.focus(); }, 50);
document.getElementById('cr-modal-confirm').onclick = function() {
var reason = input.value.trim();
// Dieselbe Untergrenze wie serverseitig – sonst nimmt das Modal
// eine Eingabe an, die beim Speichern abgelehnt wird.
if (reason.length < 3) {
errorEl.style.display = 'flex';
input.focus();
return;
}
document.getElementById('change_reason').value = reason;
modal.style.display = 'none';
_formReadyToSubmit = true;
doSubmit(form);
};
document.getElementById('cr-modal-cancel').onclick = function() {
modal.style.display = 'none';
};
// Klick auf den Hintergrund schließt den Dialog ebenfalls.
modal.onclick = function(e) {
if (e.target === modal) { modal.style.display = 'none'; }
};
// ESC schließt Modal
input.onkeydown = function(e) {
if (e.key === 'Enter') {
document.getElementById('cr-modal-confirm').click();
} else if (e.key === 'Escape') {
modal.style.display = 'none';
}
};
}
function calcEditorHeight() {
var topbar = document.querySelector('.editor-topbar');
var breadcrumb = document.querySelector('.editor-breadcrumb');
var navbar = document.querySelector('.main-header') || document.querySelector('.navbar');
var navH = navbar ? navbar.offsetHeight : 57;
var topH = topbar ? topbar.offsetHeight : 58;
var bcH = breadcrumb ? breadcrumb.offsetHeight : 40;
return Math.max(400, window.innerHeight - navH - topH - bcH - 10);
}
if (cfg.tinymce) {
tinymce.init({
selector: '#content-editor',
height: calcEditorHeight(),
min_height: 400,
language: 'de',
language_url: '/assets/tinymce/langs/de.js',
plugins: [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'help', 'wordcount', 'codesample',
'emoticons', 'pagebreak', 'nonbreaking', 'quickbars'
],
toolbar: [
'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor removeformat',
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media table',
'codesample blockquote hr pagebreak | charmap emoticons | searchreplace | fullscreen code help' + (cfg.aiEnabled ? ' | ai_editor' : '')
],
menubar: 'file edit view insert format tools table help',
contextmenu: 'link image table',
quickbars_selection_toolbar: 'bold italic | quicklink h2 h3 blockquote',
quickbars_insert_toolbar: 'quickimage quicktable',
font_family_formats: 'Arial=arial,helvetica,sans-serif; Courier New=courier new,courier,monospace; Georgia=georgia,palatino; Tahoma=tahoma,arial,helvetica,sans-serif; Times New Roman=times new roman,times; Verdana=verdana,geneva; Impact=impact,chicago',
font_size_formats: '8pt 9pt 10pt 11pt 12pt 14pt 16pt 18pt 20pt 24pt 30pt 36pt 48pt',
block_formats: 'Paragraph=p; Heading 1=h1; Heading 2=h2; Heading 3=h3; Heading 4=h4; Heading 5=h5; Heading 6=h6; Preformatted=pre; Blockquote=blockquote',
table_toolbar: 'tableprops tabledelete | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol',
table_appearance_options: true,
table_advtab: true,
table_cell_advtab: true,
table_row_advtab: true,
codesample_languages: [
{ text: 'HTML/XML', value: 'markup' },
{ text: 'JavaScript', value: 'javascript' },
{ text: 'CSS', value: 'css' },
{ text: 'PHP', value: 'php' },
{ text: 'Python', value: 'python' },
{ text: 'Java', value: 'java' },
{ text: 'C#', value: 'csharp' },
{ text: 'SQL', value: 'sql' },
{ text: 'Bash', value: 'bash' },
{ text: 'JSON', value: 'json' },
],
content_css: '/assets/css/fonts.css',
content_style: `
body {
font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
line-height: 1.7;
/* Dieser Block gestaltet den Editor-Rahmen (eigenes Dokument),
der die Design-Variablen der Seite nicht sieht – daher hier
bewusst feste Farbwerte. */
color: #1f2937;
padding: 1.5rem 2rem;
}
h1 { font-size: 2rem; font-weight: 700; margin: 2rem 0 1rem; color: #111827; }
h2 { font-size: 1.5rem; font-weight: 600; margin: 1.75rem 0 0.75rem; color: #111827; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.5rem; }
h3 { font-size: 1.25rem; font-weight: 600; margin: 1.5rem 0 0.5rem; color: #1f2937; }
p { margin: 0 0 1rem; }
ul, ol { margin: 0 0 1rem; padding-left: 1.5rem; }
a { color: #2563eb; }
blockquote { border-left: 4px solid #2563eb; margin: 1.5rem 0; padding: 0.75rem 1.25rem; background: #f0f7ff; color: #1e40af; }
pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
code { background: #f1f5f9; color: #0f172a; padding: 0.15em 0.4em; border-radius: 0.25rem; }
pre code { background: transparent; color: inherit; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
th, td { border: 1px solid #d1d5db; padding: 0.75rem 1rem; text-align: left; }
th { background: #f3f4f6; font-weight: 600; }
img { max-width: 100%; height: auto; border-radius: 0.5rem; }
`,
promotion: false,
branding: false,
license_key: 'gpl',
resize: true,
statusbar: true,
elementpath: true,
// Bild-Upload - WICHTIG: images_upload_handler fuer mehr Kontrolle
automatic_uploads: true,
images_upload_url: '/upload/image',
images_upload_credentials: true,
images_reuse_filename: true,
// Custom upload handler fuer bessere Fehlerbehandlung
images_upload_handler: function(blobInfo, progress) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/upload/image');
xhr.withCredentials = true;
xhr.upload.onprogress = function(e) {
progress(e.loaded / e.total * 100);
};
xhr.onload = function() {
if (xhr.status === 403) {
reject({ message: 'Keine Berechtigung zum Hochladen', remove: true });
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
reject('HTTP Error: ' + xhr.status);
return;
}
var json;
try {
json = JSON.parse(xhr.responseText);
} catch (e) {
reject('Ungueltige Server-Antwort');
return;
}
if (!json || typeof json.location !== 'string') {
reject('Ungueltige JSON-Antwort: ' + xhr.responseText);
return;
}
resolve(json.location);
};
xhr.onerror = function() {
reject('Bild-Upload fehlgeschlagen. Netzwerkfehler.');
};
var formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
// CSRF Token hinzufuegen
var csrfToken = document.querySelector('input[name="_csrf"]');
if (csrfToken) {
formData.append('_csrf', csrfToken.value);
}
xhr.send(formData);
});
},
paste_data_images: true,
setup: function(editor) {
editor.on('change input', function() {
var btn = document.querySelector('.btn-save');
if (btn) btn.classList.add('has-changes');
var hint = document.getElementById('change-reason-hint');
if (hint) hint.style.display = 'inline-flex';
});
editor.on('init', function() {
// Höhe nach Init nochmal anpassen (Toolbar kann sich verschoben haben)
setTimeout(function() {
var newH = calcEditorHeight();
editor.getContainer().style.height = newH + 'px';
editor.getWin().frameElement && (editor.getWin().frameElement.style.height = '');
}, 100);
});
if (cfg.aiEnabled) {
// KI-Assistent Button registrieren
editor.ui.registry.addButton('ai_editor', {
text: '✨ KI',
tooltip: window.onitText('KI-Assistent: Inhalt erstellen, bearbeiten oder ergänzen'),
onAction: function() {
openAiEditorModal(editor);
}
});
}
}
});
} else {
// wysiwyg.js Fallback
(function() {
var ta = document.getElementById('content-editor');
if (ta && typeof OpenNIT !== 'undefined' && OpenNIT.WysiwygEditor) {
var wrap = document.createElement('div');
wrap.id = 'content-editor-wrap';
ta.parentNode.insertBefore(wrap, ta);
wrap.appendChild(ta);
var csrfToken = (document.querySelector('input[name="_csrf"]') || {value: ''}).value;
window._fallbackEditor = new OpenNIT.WysiwygEditor(wrap, {
placeholder: 'Inhalt eingeben...',
uploadUrl: '/upload/image',
csrfToken: csrfToken,
onChange: function() {
var btn = document.querySelector('.btn-save');
if (btn) btn.classList.add('has-changes');
var hint = document.getElementById('change-reason-hint');
if (hint) hint.style.display = 'inline-flex';
}
});
}
})();
}
// Editor bei Fenster-Resize anpassen
var _resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(_resizeTimer);
_resizeTimer = setTimeout(function() {
if (typeof tinymce !== 'undefined' && tinymce.activeEditor) {
var newH = calcEditorHeight();
tinymce.activeEditor.getContainer().style.height = newH + 'px';
}
}, 150);
});
// ========================================
// Datei-Upload Handler
// ========================================
// Immer frischen CSRF-Token aus dem DOM lesen (nie stale)
function getCsrf() {
var el = document.querySelector('input[name="_csrf"]');
return el ? el.value : '';
}
if (cfg.isEdit) {
var pageId = cfg.pageId;
var fileInput = document.getElementById('file-input');
var fileList = document.getElementById('file-list');
var dropzone = document.getElementById('file-dropzone');
var progressContainer = document.getElementById('upload-progress');
var progressFill = document.getElementById('progress-fill');
var progressText = document.getElementById('progress-text');
if (fileInput) {
fileInput.addEventListener('change', function(e) {
var files = e.target.files;
if (files.length > 0) {
uploadFiles(files);
}
});
}
// Drag & Drop
if (dropzone) {
dropzone.addEventListener('dragover', function(e) {
e.preventDefault();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', function(e) {
e.preventDefault();
dropzone.classList.remove('dragover');
});
dropzone.addEventListener('drop', function(e) {
e.preventDefault();
dropzone.classList.remove('dragover');
var files = e.dataTransfer.files;
if (files.length > 0) {
uploadFiles(files);
}
});
}
function uploadFiles(files) {
var totalFiles = files.length;
var uploadedCount = 0;
var filesArray = Array.from(files);
progressContainer.style.display = 'block';
dropzone.style.display = 'none';
progressText.textContent = 'Hochladen... 0 von ' + totalFiles;
progressFill.style.width = '0%';
function uploadNext(index) {
if (index >= filesArray.length) {
// Alle Dateien verarbeitet
progressFill.style.width = '100%';
if (uploadedCount === totalFiles) {
progressText.textContent = 'Fertig! ' + totalFiles + ' Datei(en) hochgeladen';
} else {
progressText.textContent = 'Fertig! ' + uploadedCount + ' von ' + totalFiles + ' Datei(en) hochgeladen';
}
// Dateiliste immer vom Server neu laden nach Abschluss
refreshFileList();
setTimeout(function() {
progressContainer.style.display = 'none';
dropzone.style.display = 'block';
progressFill.style.width = '0%';
fileInput.value = '';
}, 2000);
return;
}
var currentFile = filesArray[index];
progressText.textContent = 'Hochladen: ' + currentFile.name + ' (' + (index + 1) + '/' + totalFiles + ')';
uploadFile(currentFile, function(success) {
if (success) {
uploadedCount++;
}
var percent = Math.round(((index + 1) / totalFiles) * 100);
progressFill.style.width = percent + '%';
// Naechste Datei hochladen
uploadNext(index + 1);
});
}
uploadNext(0);
}
function uploadFile(file, callback) {
var formData = new FormData();
formData.append('file', file);
formData.append('_csrf', getCsrf());
var xhr = new XMLHttpRequest();
xhr.open('POST', '/pages/' + pageId + '/files');
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
var responseText = xhr.responseText;
try {
// Manchmal gibt PHP Notices/Warnings vor dem JSON aus
var jsonStart = responseText.indexOf('{');
if (jsonStart > 0) {
responseText = responseText.substring(jsonStart);
}
var response = JSON.parse(responseText);
if (response.success || response.file) {
if (response.file) {
addFileToList(response.file);
}
callback(true);
return;
}
} catch(e) {
console.error('Parse error:', e, responseText);
}
// Status 2xx aber unerwartete Antwort - trotzdem als Erfolg werten
callback(true);
} else {
var error = 'Upload fehlgeschlagen';
try {
var resp = JSON.parse(xhr.responseText);
error = resp.error || error;
} catch(e) {}
alert(error + ' (' + file.name + ')');
callback(false);
}
};
xhr.onerror = function() {
alert('Netzwerkfehler beim Upload von ' + file.name);
callback(false);
};
xhr.ontimeout = function() {
alert('Timeout beim Upload von ' + file.name);
callback(false);
};
xhr.timeout = 60000; // 60 Sekunden Timeout
xhr.send(formData);
}
function updateFileCounter() {
var counter = document.getElementById('file-counter');
if (counter) {
counter.textContent = '(' + fileList.querySelectorAll('.file-item').length + ')';
}
}
function addFileToList(file) {
// Duplikat verhindern
if (fileList.querySelector('[data-file-id="' + file.id + '"]')) {
return;
}
var li = document.createElement('li');
li.className = 'file-item file-item-new';
li.setAttribute('data-file-id', file.id);
li.innerHTML = '<span class="file-icon"><i class="fas fa-file" aria-hidden="true"></i></span>' +
'<a href="/files/' + file.id + '" class="file-name" target="_blank">' + escapeHtml(file.name) + '</a>' +
'<span class="file-size">' + formatFileSize(file.size) + '</span>' +
'<button type="button" class="file-delete" data-file-id="' + file.id + '" title="Loeschen">×</button>';
fileList.appendChild(li);
updateFileCounter();
// Highlight-Animation fuer neue Datei
requestAnimationFrame(function() {
li.classList.add('file-item-highlight');
setTimeout(function() {
li.classList.remove('file-item-highlight', 'file-item-new');
}, 2000);
});
// Zur neuen Datei scrollen
li.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// Dateiliste komplett vom Server neu laden
function refreshFileList() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/pages/' + pageId + '/files/list');
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
try {
var response = JSON.parse(xhr.responseText);
if (response.files && Array.isArray(response.files)) {
// Bestehende Liste leeren und neu aufbauen
fileList.innerHTML = '';
response.files.forEach(function(file) {
var li = document.createElement('li');
li.className = 'file-item';
li.setAttribute('data-file-id', file.id);
li.innerHTML = '<span class="file-icon"><i class="fas fa-file" aria-hidden="true"></i></span>' +
'<a href="/files/' + file.id + '" class="file-name" target="_blank">' + escapeHtml(file.name) + '</a>' +
'<span class="file-size">' + formatFileSize(file.size) + '</span>' +
'<button type="button" class="file-delete" data-file-id="' + file.id + '" title="Loeschen">×</button>';
fileList.appendChild(li);
});
updateFileCounter();
}
} catch(e) {
console.error('Dateiliste konnte nicht geladen werden:', e);
}
}
};
xhr.onerror = function() {
console.error('Netzwerkfehler beim Laden der Dateiliste');
};
xhr.send();
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Datei loeschen
document.addEventListener('click', function(e) {
if (e.target.classList.contains('file-delete')) {
var fileId = e.target.getAttribute('data-file-id');
if (confirm('Datei wirklich loeschen?')) {
deleteFile(fileId, e.target.closest('.file-item'));
}
}
});
function deleteFile(fileId, listItem) {
// Lade-Indikator
listItem.style.opacity = '0.5';
listItem.style.pointerEvents = 'none';
var formData = new FormData();
formData.append('_method', 'DELETE');
formData.append('_csrf', getCsrf());
var xhr = new XMLHttpRequest();
xhr.open('POST', '/files/' + fileId);
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
listItem.remove();
updateFileCounter();
} else {
listItem.style.opacity = '1';
listItem.style.pointerEvents = 'auto';
var error = 'Loeschen fehlgeschlagen';
try {
var resp = JSON.parse(xhr.responseText);
error = resp.error || error;
} catch(e) {}
alert(error);
}
};
xhr.onerror = function() {
listItem.style.opacity = '1';
listItem.style.pointerEvents = 'auto';
alert('Netzwerkfehler beim Loeschen');
};
xhr.send(formData);
}
}
});
if (cfg.isEdit && cfg.canDelete) {
window.deletePageAction = function () {
if (!confirm('Seite wirklich archivieren? Die Seite kann im Admin-Bereich wiederhergestellt werden.')) return;
var form = document.createElement('form');
form.method = 'POST';
form.action = '/pages/' + cfg.pageId;
// Frischen CSRF-Token aus dem DOM holen (nie den PHP-hardcodierten verwenden)
form.innerHTML = '<input type="hidden" name="_csrf" value="' + (document.querySelector('input[name="_csrf"]') ? document.querySelector('input[name="_csrf"]').value : '') + '">' +
'<input type="hidden" name="_method" value="DELETE">';
document.body.appendChild(form);
form.submit();
};
}
// === Ansprechpartner-Suche ===
(function() {
var searchInput = document.getElementById('ap-search');
var resultsDiv = document.getElementById('ap-search-results');
var typeField = document.getElementById('ap-type');
var idField = document.getElementById('ap-id');
var currentDiv = document.getElementById('ap-current');
var searchWrapper = document.getElementById('ap-search-wrapper');
var selectedName = document.getElementById('ap-selected-name');
var removeBtn = document.getElementById('ap-remove');
if (!searchInput) return;
var timer = null;
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
searchInput.addEventListener('input', function() {
clearTimeout(timer);
var q = this.value.trim();
if (q.length < 2) {
resultsDiv.style.display = 'none';
return;
}
timer = setTimeout(function() {
fetch('/api/ansprechpartner/search?q=' + encodeURIComponent(q))
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.length) {
resultsDiv.innerHTML = '<div class="ap-result-empty">Keine Ergebnisse</div>';
} else {
resultsDiv.innerHTML = data.map(function(item) {
var icon = item.type === 'company'
? '<i class="fas fa-building" aria-hidden="true"></i>'
: '<i class="fas fa-user" aria-hidden="true"></i>';
var imgHtml = item.image
? '<img src="' + escHtml(item.image) + '" class="ap-result-img">'
: '<span class="ap-result-icon">' + icon + '</span>';
return '<div class="ap-result-item" data-type="' + escHtml(item.type) + '" data-id="' + item.id + '" data-label="' + escHtml(item.label) + '" data-sublabel="' + escHtml(item.sublabel) + '">' +
imgHtml +
'<div class="ap-result-info">' +
'<span class="ap-result-name">' + escHtml(item.label) + '</span>' +
(item.sublabel ? '<span class="ap-result-sub">' + escHtml(item.sublabel) + '</span>' : '') +
'</div>' +
'<span class="ap-result-type">' + (item.type === 'company' ? 'Firma' : item.type === 'directory' ? 'Verzeichnis' : item.type === 'user' ? 'Intern' : 'Kontakt') + '</span>' +
'</div>';
}).join('');
resultsDiv.querySelectorAll('.ap-result-item').forEach(function(el) {
el.addEventListener('click', function() {
typeField.value = this.getAttribute('data-type');
idField.value = this.getAttribute('data-id');
var label = this.getAttribute('data-label');
var sub = this.getAttribute('data-sublabel');
selectedName.innerHTML = escHtml(label);
if (sub) {
selectedName.innerHTML += ' <small>(' + escHtml(sub) + ')</small>';
}
currentDiv.style.display = '';
searchWrapper.style.display = 'none';
searchInput.value = '';
resultsDiv.style.display = 'none';
});
});
}
resultsDiv.style.display = 'block';
});
}, 300);
});
if (removeBtn) {
removeBtn.addEventListener('click', function() {
typeField.value = '';
idField.value = '0';
selectedName.textContent = '';
currentDiv.style.display = 'none';
searchWrapper.style.display = '';
});
}
document.addEventListener('click', function(e) {
if (!searchInput.contains(e.target) && !resultsDiv.contains(e.target)) {
resultsDiv.style.display = 'none';
}
});
})();
});
abschnitt('KI-Assistent', function () {
// Tag-Auswahl
(function() {
var input = document.getElementById('tagSearchInput');
var dropdown = document.getElementById('tagDropdown');
var selectedList = document.getElementById('tagSelectedList');
if (!input || !dropdown) return;
var allItems = Array.from(dropdown.querySelectorAll('.tag-dropdown-item'));
function getSelectedIds() {
return Array.from(selectedList.querySelectorAll('input[name="tags[]"]')).map(function(i) {
return parseInt(i.value);
});
}
function isSelected(id) {
return getSelectedIds().indexOf(parseInt(id)) !== -1;
}
// Nur Hex-Farben in das style-Attribut übernehmen – ein freier Wert könnte
// sonst weitere CSS-Eigenschaften einschleusen.
function safeColor(value) {
return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(String(value || '').trim())
? String(value).trim()
: '#9ca3af';
}
function addTag(id, name, color) {
if (isSelected(id)) return;
color = safeColor(color);
var chip = document.createElement('span');
chip.className = 'tag-chip';
chip.dataset.id = id;
chip.style.cssText = 'background:' + color + '20;color:' + color + ';border:1px solid ' + color + '40;';
chip.innerHTML = escHtml(name) +
'<button type="button" class="tag-chip-remove" data-id="' + id + '">×</button>' +
'<input type="hidden" name="tags[]" value="' + id + '">';
selectedList.appendChild(chip);
updateDropdownState();
}
function removeTag(id) {
var chip = selectedList.querySelector('.tag-chip[data-id="' + id + '"]');
if (chip) chip.remove();
updateDropdownState();
}
function updateDropdownState() {
allItems.forEach(function(item) {
if (isSelected(item.dataset.id)) {
item.classList.add('selected');
} else {
item.classList.remove('selected');
}
});
}
function filterDropdown(query) {
var q = query.toLowerCase();
var hasMatch = false;
allItems.forEach(function(item) {
var name = (item.dataset.name || '').toLowerCase();
var show = !q || name.indexOf(q) !== -1;
item.style.display = show ? '' : 'none';
if (show) hasMatch = true;
});
// "Erstellen" Option
var createBtn = dropdown.querySelector('.tag-dropdown-create');
if (createBtn) createBtn.remove();
if (q && !hasMatch) {
var btn = document.createElement('div');
btn.className = 'tag-dropdown-create';
btn.textContent = '+ "' + query + '" erstellen';
btn.addEventListener('click', function() {
createNewTag(query);
});
dropdown.appendChild(btn);
}
}
function createNewTag(name) {
fetch('/api/tags', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': (document.querySelector('input[name="_csrf"]') || {value: ''}).value,
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ name: name })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.tag) {
// Neuen Tag zum Dropdown hinzufuegen
var item = document.createElement('div');
item.className = 'tag-dropdown-item';
item.dataset.id = data.tag.id;
item.dataset.name = data.tag.name;
item.dataset.color = safeColor(data.tag.color);
item.innerHTML = '<span class="tag-dropdown-color" style="background:' + safeColor(data.tag.color) + ';"></span>' +
escHtml(data.tag.name) +
'<span class="tag-dropdown-count">0</span>';
dropdown.insertBefore(item, dropdown.firstChild);
allItems = Array.from(dropdown.querySelectorAll('.tag-dropdown-item'));
item.addEventListener('click', function() {
toggleTag(this);
});
addTag(data.tag.id, data.tag.name, data.tag.color);
input.value = '';
dropdown.style.display = 'none';
}
});
}
function toggleTag(item) {
var id = item.dataset.id;
if (isSelected(id)) {
removeTag(id);
} else {
addTag(id, item.dataset.name, item.dataset.color);
}
}
function escHtml(str) {
var div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
// Events
input.addEventListener('focus', function() {
filterDropdown(this.value);
updateDropdownState();
dropdown.style.display = '';
});
input.addEventListener('input', function() {
filterDropdown(this.value);
dropdown.style.display = '';
});
allItems.forEach(function(item) {
item.addEventListener('click', function() {
toggleTag(this);
});
});
selectedList.addEventListener('click', function(e) {
var btn = e.target.closest('.tag-chip-remove');
if (btn) {
removeTag(btn.dataset.id);
}
});
document.addEventListener('click', function(e) {
if (!input.contains(e.target) && !dropdown.contains(e.target) && !selectedList.contains(e.target)) {
dropdown.style.display = 'none';
}
});
updateDropdownState();
})();
// === Vorlagen-Auswahl (Sidebar-Dropdown) ===
(function() {
var select = document.getElementById('template-select');
if (!select) return;
select.addEventListener('change', function() {
var templateId = this.value;
if (!templateId) return;
if (typeof tinymce !== 'undefined' && tinymce.activeEditor && tinymce.activeEditor.getContent().trim()) {
if (!confirm('Vorhandenen Inhalt mit Vorlage ersetzen?')) {
this.value = '';
return;
}
} else if (window._fallbackEditor && window._fallbackEditor.getContent().trim()) {
if (!confirm('Vorhandenen Inhalt mit Vorlage ersetzen?')) {
this.value = '';
return;
}
}
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/page-templates/' + templateId);
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
try {
var data = JSON.parse(xhr.responseText);
if (data.content_html !== undefined) {
if (typeof tinymce !== 'undefined' && tinymce.activeEditor) {
tinymce.activeEditor.setContent(data.content_html);
} else if (window._fallbackEditor) {
window._fallbackEditor.setContent(data.content_html);
}
}
} catch(e) {
console.error('Vorlagen-Parse-Fehler:', e);
}
}
};
xhr.send();
});
})();
if (cfg.aiEnabled) {
// === KI-Assistent Modal ===
openAiEditorModal = function (editor) {
var modal = document.getElementById('ai-editor-modal');
if (!modal) return;
var promptEl = document.getElementById('ai-prompt');
var errorEl = document.getElementById('ai-modal-error');
var submitBtn = document.getElementById('ai-modal-submit');
var submitText = document.getElementById('ai-modal-submit-text');
var loadingEl = document.getElementById('ai-modal-loading');
var cancelBtn = document.getElementById('ai-modal-cancel');
var closeBtn = document.getElementById('ai-modal-close');
errorEl.style.display = 'none';
promptEl.value = '';
modal.style.display = 'flex';
setTimeout(function() { promptEl.focus(); }, 50);
function closeModal() {
modal.style.display = 'none';
}
function setLoading(on) {
submitBtn.disabled = on;
submitText.style.display = on ? 'none' : '';
loadingEl.style.display = on ? '' : 'none';
}
function handleSubmit() {
var instruction = promptEl.value.trim();
if (!instruction) {
errorEl.textContent = 'Bitte eine Anweisung eingeben.';
errorEl.style.display = '';
promptEl.focus();
return;
}
errorEl.style.display = 'none';
setLoading(true);
// Gesamter Editor-Inhalt als Kontext
var currentContent = editor.getContent();
var csrf = (document.querySelector('input[name="_csrf"]') || {value: ''}).value;
fetch('/api/ai-chat/page-content', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: 'instruction=' + encodeURIComponent(instruction)
+ '¤t_content='+ encodeURIComponent(currentContent)
+ '&_csrf=' + encodeURIComponent(csrf)
})
.then(function(r) { return r.json(); })
.then(function(data) {
setLoading(false);
if (data.error) {
errorEl.textContent = data.error;
errorEl.style.display = '';
return;
}
if (data.content) {
editor.setContent(data.content);
// Änderungsmarkierung setzen
var btn = document.querySelector('.btn-save');
if (btn) btn.classList.add('has-changes');
var hint = document.getElementById('change-reason-hint');
if (hint) hint.style.display = 'inline-flex';
closeModal();
}
})
.catch(function(err) {
setLoading(false);
errorEl.textContent = 'Netzwerkfehler: ' + err.message;
errorEl.style.display = '';
});
}
submitBtn.onclick = handleSubmit;
cancelBtn.onclick = closeModal;
closeBtn.onclick = closeModal;
// ESC schließt Modal, Ctrl+Enter sendet
promptEl.onkeydown = function(e) {
if (e.key === 'Escape') closeModal();
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSubmit();
};
// Klick auf Backdrop schließt Modal
modal.onclick = function(e) {
if (e.target === modal) closeModal();
};
}
}
});
abschnitt('Sperre', function () {
(function() {
var pageId = cfg.pageId;
var timer = null;
var released = false;
function post(url) {
// Immer frischen CSRF-Token lesen (nie stale nach langer Session)
var csrf = (document.querySelector('input[name="_csrf"]') || {value: ''}).value;
return fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: '_csrf=' + encodeURIComponent(csrf),
keepalive: true,
});
}
function showBanner(text) {
var b = document.getElementById('lock-banner');
if (!b) return;
document.getElementById('lock-banner-text').textContent = text;
b.style.display = 'flex';
document.body.style.paddingTop = b.offsetHeight + 'px';
}
function hideBanner() {
var b = document.getElementById('lock-banner');
if (b) b.style.display = 'none';
document.body.style.paddingTop = '';
}
function keepalive() {
post('/pages/' + pageId + '/lock/keepalive')
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.ok) {
showBanner('⚠ Sperre verloren – ' + (d.locked_by || 'anderer Benutzer') + ' bearbeitet diese Seite jetzt.');
clearInterval(timer);
} else {
hideBanner();
}
})
.catch(function() { /* netzwerkfehler ignorieren */ });
}
function release() {
if (released) return;
released = true;
clearInterval(timer);
// sendBeacon ist zuverlässiger beim Seitenwechsel
var data = new FormData();
data.append('_csrf', (document.querySelector('input[name="_csrf"]') || {value: ''}).value);
data.append('_method', 'DELETE');
navigator.sendBeacon('/pages/' + pageId + '/lock', data);
}
// Heartbeat alle 60 Sekunden
timer = setInterval(keepalive, 60000);
// Lock freigeben wenn Seite verlassen wird
window.addEventListener('pagehide', release);
window.addEventListener('beforeunload', release);
// Beim Absenden des Formulars: Lock wird serverseitig gelöscht, kein manueller Release nötig
document.getElementById('page-form')?.addEventListener('submit', function() {
clearInterval(timer);
released = true;
});
})();
});
abschnitt('Entwurf', function () {
// === Autosave ===
if (cfg.isEdit) {
(function() {
var pageId = cfg.pageId;
var draftContent = cfg.draftContent;
var inactivityTimer = null;
var intervalTimer = null;
var statusEl = document.getElementById('autosave-status');
function setStatus(text, type) {
if (!statusEl) return;
statusEl.textContent = text;
statusEl.className = 'autosave-status autosave-status-' + (type || 'idle');
statusEl.style.display = text ? 'inline-flex' : 'none';
}
// Einheitlicher Zugriff auf den Inhalt, unabhängig von der Editor-Variante:
// TinyMCE, der einfache Editor oder – solange keiner bereit ist – das
// Textfeld selbst. Ohne diese Abstraktion liefe das Zwischenspeichern nur
// mit TinyMCE.
function editorApi() {
if (typeof tinymce !== 'undefined' && tinymce.activeEditor) {
var ed = tinymce.activeEditor;
return {
get: function () { return ed.getContent(); },
set: function (html) { ed.setContent(html); },
onChange: function (cb) { ed.on('change input keyup', cb); }
};
}
if (window._fallbackEditor) {
var fe = window._fallbackEditor;
return {
get: function () { return fe.getContent(); },
set: function (html) { fe.setContent(html); },
onChange: function (cb) {
// Der einfache Editor kennt genau einen Änderungs-Rückruf –
// den vorhandenen erhalten und den eigenen anhängen.
var prev = fe.options && fe.options.onChange;
if (fe.options) {
fe.options.onChange = function (content) {
if (typeof prev === 'function') { prev(content); }
cb();
};
}
}
};
}
var ta = document.getElementById('content-editor');
if (ta) {
return {
get: function () { return ta.value; },
set: function (html) { ta.value = html; },
onChange: function (cb) { ta.addEventListener('input', cb); }
};
}
return null;
}
function doAutosave() {
var api = editorApi();
if (!api) return;
var content = api.get();
var formData = new FormData();
formData.append('content', content);
formData.append('_csrf', (document.querySelector('input[name="_csrf"]') || {value: ''}).value);
setStatus('Speichert…', 'saving');
fetch('/pages/' + pageId + '/autosave', {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
var now = new Date();
var time = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0');
setStatus('Entwurf gespeichert ' + time, 'saved');
}
})
.catch(function() {
setStatus('Autosave fehlgeschlagen', 'error');
});
}
// Warten bis ein Editor bereit ist (TinyMCE initialisiert asynchron).
var initAttempts = 0;
var checkInit = setInterval(function() {
var api = editorApi();
if (!api) {
// Nach rund 15 Sekunden aufgeben, damit der Timer nicht dauerhaft läuft.
if (++initAttempts > 50) { clearInterval(checkInit); }
return;
}
clearInterval(checkInit);
// Bei jeder Änderung: nach 10s Inaktivität speichern
api.onChange(function() {
clearTimeout(inactivityTimer);
inactivityTimer = setTimeout(doAutosave, 10000);
});
// Zusätzlich alle 60s
intervalTimer = setInterval(doAutosave, 60000);
}, 300);
// Entwurf-Dialog sofort verdrahten – unabhängig davon, ob der Editor schon
// bereit ist. Ein früher Klick landete sonst ins Leere.
(function() {
var draftModal = document.getElementById('draft-modal');
var restoreBtn = document.getElementById('draft-restore-btn');
var discardBtn = document.getElementById('draft-discard-btn');
function closeDraftModal() {
if (draftModal) { draftModal.style.display = 'none'; }
}
if (restoreBtn && draftContent) {
restoreBtn.addEventListener('click', function() {
var api = editorApi();
if (api) { api.set(draftContent); }
closeDraftModal();
var saveBtn = document.querySelector('.btn-save');
if (saveBtn) saveBtn.classList.add('has-changes');
var hint = document.getElementById('change-reason-hint');
if (hint) hint.style.display = 'inline-flex';
setStatus('Entwurf wiederhergestellt', 'saved');
});
}
if (discardBtn) {
discardBtn.addEventListener('click', function() {
closeDraftModal();
// Draft serverseitig leeren
var fd = new FormData();
fd.append('content', '');
fd.append('_csrf', (document.querySelector('input[name="_csrf"]') || {value: ''}).value);
fetch('/pages/' + pageId + '/autosave', {method: 'POST', body: fd, credentials: 'same-origin'});
});
}
if (draftModal) {
draftModal.addEventListener('click', function(e) {
if (e.target === draftModal) { closeDraftModal(); }
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && draftModal.style.display !== 'none') { closeDraftModal(); }
});
}
})();
// Autosave-Timer beim Absenden stoppen
document.getElementById('page-form') && document.getElementById('page-form').addEventListener('submit', function() {
clearTimeout(inactivityTimer);
clearInterval(intervalTimer);
});
})();
}
});
})();