/* HPS Vorlagen & Signaturen — Web-Editor Frontend * Vanilla JS, keine Abhängigkeiten. Spricht ausschließlich mit dem * gleichnamigen Express-Backend (same origin) über die /api-Endpunkte. */ (function () { 'use strict'; // ── Konstanten (müssen mit dem Backend übereinstimmen) ── const SHARED_FOLDER = '_gemeinsam'; const USER_FOLDER = '_benutzer'; const SIG_FOOTERS = 'signatures/footers'; const SIG_HEADERS = 'signatures/headers'; // ── Globaler Zustand ── const state = { config: null, // /api/config tree: null, // /api/tree current: null, // { path, friendly, sha, exists, isNew, category } dirty: false, view: 'visual', // 'visual' | 'html' collapsed: { templates: false, footers: false, headers: false }, groupsCollapsed: {}, // key -> bool (Vorlagen-Untergruppen) pendingNetwork: 0, }; // ── DOM-Referenzen ── const $ = (id) => document.getElementById(id); const el = { statusPill: $('status-pill'), statusText: $('status-text'), configBanner: $('config-banner'), treeTemplates:$('tree-templates'), treeFooters: $('tree-footers'), treeHeaders: $('tree-headers'), emptyState: $('empty-state'), editorPanel: $('editor-panel'), fileFriendly: $('file-friendly'), filePath: $('file-path'), dirtyBadge: $('dirty-badge'), btnSave: $('btn-save'), btnReload: $('btn-reload'), btnDelete: $('btn-delete'), btnRefresh: $('btn-refresh-tree'), treeSearch: $('tree-search'), btnNewDept: $('btn-new-department'), btnNewFooter: $('btn-new-footer'), btnNewHeader: $('btn-new-header'), tabVisual: $('tab-visual'), tabHtml: $('tab-html'), formatToolbar:$('format-toolbar'), visualEditor: $('visual-editor'), htmlEditor: $('html-editor'), visualWrap: $('visual-wrap'), htmlWrap: $('html-wrap'), previewFrame: $('preview-frame'), toastStack: $('toast-stack'), loading: $('loading-overlay'), fmtColor: $('fmt-color'), fmtColorSwatch: $('fmt-color-swatch'), fmtFontSize: $('fmt-fontsize'), fmtLink: $('fmt-link'), fmtImage: $('fmt-image'), // Modals confirmBackdrop: $('confirm-backdrop'), confirmTitle: $('confirm-title'), confirmMessage: $('confirm-message'), confirmOk: $('confirm-ok'), confirmCancel:$('confirm-cancel'), promptBackdrop: $('prompt-backdrop'), promptForm: $('prompt-form'), promptTitle: $('prompt-title'), promptFields: $('prompt-fields'), promptOk: $('prompt-ok'), promptCancel: $('prompt-cancel'), }; // ──────────────────────────────────────────────────────────── // Hilfsfunktionen // ──────────────────────────────────────────────────────────── // HTML-escapen für sichere Anzeige in Attributen/Text. function esc(s) { return String(s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } // Dateiname aus Vorlagennamen ableiten (exakt laut Vorgabe), .html ergänzt der Aufrufer. function slugifyName(name) { return name .replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss') .replace(/[/\\:*?"<>|]/g, '-') .replace(/^[\s.-]+|[\s.-]+$/g, '') .trim(); } // Für Signatur-Köpfe: zusätzlich klein + Leerzeichen → '-'. function slugifyHeaderName(name) { return slugifyName(name).toLowerCase().replace(/\s+/g, '-'); } // Debounce-Helfer. function debounce(fn, ms) { let t; return function (...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), ms); }; } // ── Toasts ── function toast(message, type) { const t = document.createElement('div'); t.className = 'toast' + (type ? ' toast-' + type : ''); const icon = type === 'success' ? '✓' : type === 'error' ? '⚠' : 'ℹ'; t.innerHTML = '' + icon + ''; t.querySelector('.toast-msg').textContent = message; el.toastStack.appendChild(t); setTimeout(() => { t.classList.add('fade-out'); t.addEventListener('animationend', () => t.remove(), { once: true }); }, 4000); } // ── Lade-Overlay (zählt verschachtelte Netzwerkaufrufe) ── function startLoading() { state.pendingNetwork++; el.loading.hidden = false; } function stopLoading() { state.pendingNetwork = Math.max(0, state.pendingNetwork - 1); if (state.pendingNetwork === 0) el.loading.hidden = true; } // ── Zentraler fetch-Wrapper: JSON, Fehler→toast, Overlay ── async function api(path, options) { startLoading(); try { const res = await fetch(path, options); let data = null; const text = await res.text(); if (text) { try { data = JSON.parse(text); } catch (_) { data = { error: text }; } } if (!res.ok) { const msg = (data && data.error) ? data.error : ('HTTP ' + res.status + ' ' + res.statusText); throw new Error(msg); } return data || {}; } finally { stopLoading(); } } // ── Custom confirm-Modal → Promise ── function confirmModal(message, opts) { opts = opts || {}; el.confirmTitle.textContent = opts.title || 'Bestätigen'; el.confirmMessage.textContent = message; el.confirmOk.textContent = opts.okLabel || 'Bestätigen'; el.confirmOk.className = 'btn ' + (opts.danger ? 'btn-danger' : 'btn-primary'); el.confirmBackdrop.hidden = false; return new Promise((resolve) => { function cleanup(result) { el.confirmBackdrop.hidden = true; el.confirmOk.removeEventListener('click', onOk); el.confirmCancel.removeEventListener('click', onCancel); el.confirmBackdrop.removeEventListener('click', onBackdrop); resolve(result); } const onOk = () => cleanup(true); const onCancel = () => cleanup(false); const onBackdrop = (e) => { if (e.target === el.confirmBackdrop) cleanup(false); }; el.confirmOk.addEventListener('click', onOk); el.confirmCancel.addEventListener('click', onCancel); el.confirmBackdrop.addEventListener('click', onBackdrop); }); } // ── Custom prompt-Modal mit beliebigen Feldern → Promise<{}|null> ── // fields: [{ key, label, type='text', placeholder, value, options:[{value,label}], required, hint }] // onChange(values, fieldEls) optional für Live-Vorschau. function promptModal(title, fields, onChange) { el.promptTitle.textContent = title; el.promptFields.innerHTML = ''; const inputs = {}; fields.forEach((f) => { const wrap = document.createElement('div'); wrap.className = 'field'; const label = document.createElement('label'); label.textContent = f.label; wrap.appendChild(label); let input; if (f.type === 'select') { input = document.createElement('select'); (f.options || []).forEach((o) => { const opt = document.createElement('option'); opt.value = o.value; opt.textContent = o.label; input.appendChild(opt); }); if (f.value != null) input.value = f.value; } else { input = document.createElement('input'); input.type = f.type || 'text'; if (f.placeholder) input.placeholder = f.placeholder; if (f.value != null) input.value = f.value; } input.dataset.key = f.key; wrap.appendChild(input); if (f.hint || f.live) { const hint = document.createElement('div'); hint.className = 'hint'; if (f.live) hint.dataset.live = f.key; if (f.hint) hint.textContent = f.hint; wrap.appendChild(hint); } el.promptFields.appendChild(wrap); inputs[f.key] = input; }); const readValues = () => { const v = {}; Object.keys(inputs).forEach((k) => { v[k] = inputs[k].value; }); return v; }; el.promptBackdrop.hidden = false; const first = el.promptFields.querySelector('input, select'); if (first) setTimeout(() => first.focus(), 30); return new Promise((resolve) => { function cleanup(result) { el.promptBackdrop.hidden = true; el.promptForm.removeEventListener('submit', onSubmit); el.promptCancel.removeEventListener('click', onCancel); el.promptBackdrop.removeEventListener('click', onBackdrop); el.promptForm.removeEventListener('input', onInput); resolve(result); } function onSubmit(e) { e.preventDefault(); const values = readValues(); // Pflichtfelder prüfen. for (const f of fields) { if (f.required && !String(values[f.key] || '').trim()) { toast('Bitte „' + f.label + '“ ausfüllen.', 'error'); inputs[f.key].focus(); return; } } cleanup(values); } const onCancel = () => cleanup(null); const onBackdrop = (e) => { if (e.target === el.promptBackdrop) cleanup(null); }; const onInput = () => { if (onChange) onChange(readValues(), inputs, el.promptFields); }; el.promptForm.addEventListener('submit', onSubmit); el.promptCancel.addEventListener('click', onCancel); el.promptBackdrop.addEventListener('click', onBackdrop); el.promptForm.addEventListener('input', onInput); if (onChange) onChange(readValues(), inputs, el.promptFields); // initial }); } // ──────────────────────────────────────────────────────────── // Anzeige-Namen (friendly labels) // ──────────────────────────────────────────────────────────── function footerLabel(name) { if (name === '_default.html') return 'Gemeinsam (alle Abteilungen)'; return name.replace(/\.html$/i, ''); } function headerLabel(name) { if (name === '_vorlage.html') return 'Vorlage (Standard-Kopf)'; // ..html → "email — slug" // E-Mail enthält genau ein '@'; alles bis zum ersten '.' NACH dem '@' ist die E-Mail. const base = name.replace(/\.html$/i, ''); const at = base.indexOf('@'); if (at >= 0) { const firstDotAfterAt = base.indexOf('.', at); if (firstDotAfterAt > -1 && firstDotAfterAt < base.length - 1) { const email = base.slice(0, firstDotAfterAt); const slug = base.slice(firstDotAfterAt + 1); return email + ' — ' + slug; } } return base; // Fallback } function templateLabel(name) { return name.replace(/\.html$/i, ''); } // Liefert eine friendly-Bezeichnung anhand Kategorie + Dateiname. function friendlyFor(category, name) { if (category === 'footer') return 'Fußzeile: ' + footerLabel(name); if (category === 'header') return 'Signatur: ' + headerLabel(name); return templateLabel(name); } // ──────────────────────────────────────────────────────────── // Verbindungsstatus // ──────────────────────────────────────────────────────────── async function loadConfigAndHealth() { // Config (best effort, ohne Overlay-Spam → eigener leichter Aufruf) try { const cfg = await api('/api/config'); state.config = cfg; if (!cfg.configured) { el.configBanner.hidden = false; setStatus('error', 'Nicht konfiguriert'); return false; } el.configBanner.hidden = true; } catch (e) { setStatus('error', 'Nicht verbunden'); toast('Konfiguration konnte nicht geladen werden: ' + e.message, 'error'); return false; } // Health try { const health = await api('/api/health'); if (health.ok) { const c = state.config; setStatus('ok', 'Verbunden: ' + c.owner + '/' + c.repo + '@' + c.branch); } else { setStatus('error', 'Nicht verbunden: ' + (health.error || 'unbekannt')); } } catch (e) { setStatus('error', 'Nicht verbunden: ' + e.message); toast('Verbindung zum Repository fehlgeschlagen: ' + e.message, 'error'); return false; } return true; } function setStatus(kind, text) { el.statusPill.className = 'status-pill status-' + (kind === 'ok' ? 'ok' : kind === 'error' ? 'error' : 'unknown'); el.statusText.textContent = text; el.statusPill.title = text; } // ──────────────────────────────────────────────────────────── // Baum (Sidebar) laden & rendern // ──────────────────────────────────────────────────────────── async function loadTree() { try { state.tree = await api('/api/tree'); renderTree(); } catch (e) { toast('Liste konnte nicht geladen werden: ' + e.message, 'error'); } } function renderTree() { renderTemplates(); renderFooters(); renderHeaders(); applySectionCollapse(); highlightActive(); // Aktiven Filter nach Neuaufbau erneut anwenden. if (el.treeSearch && el.treeSearch.value.trim()) applyTreeFilter(); } // Live-Filter über alle Dateien (Label + Pfad). Leere Suche = voller Baum. function applyTreeFilter() { const q = (el.treeSearch.value || '').trim().toLowerCase(); if (!q) { renderTree(); return; } // pristinen Baum (inkl. Klappzustand) wiederherstellen document.querySelectorAll('.tree-section').forEach((s) => s.classList.remove('collapsed')); document.querySelectorAll('.sidebar .group-files').forEach((b) => b.classList.remove('collapsed')); document.querySelectorAll('.sidebar .group-title').forEach((t) => t.classList.remove('collapsed')); document.querySelectorAll('.sidebar .add-item, .sidebar .tree-empty').forEach((n) => { n.style.display = 'none'; }); document.querySelectorAll('.sidebar .tree-item').forEach((it) => { const label = (it.querySelector('.ti-label')?.textContent || '').toLowerCase(); const path = (it.dataset.path || '').toLowerCase(); it.style.display = (label.includes(q) || path.includes(q)) ? '' : 'none'; }); // Gruppen ohne sichtbaren Treffer ausblenden. document.querySelectorAll('.sidebar .tree-group').forEach((g) => { const items = g.querySelectorAll('.tree-item'); const anyVisible = Array.from(items).some((i) => i.style.display !== 'none'); g.style.display = (items.length && !anyVisible) ? 'none' : ''; }); } // Datei-Item-Element bauen. function fileItem(file, category) { const div = document.createElement('div'); div.className = 'tree-item'; div.dataset.path = file.path; div.dataset.sha = file.sha || ''; div.dataset.category = category; div.dataset.name = file.name; const label = category === 'footer' ? footerLabel(file.name) : category === 'header' ? headerLabel(file.name) : templateLabel(file.name); const icon = category === 'footer' ? '📜' : category === 'header' ? '✍️' : '📄'; div.innerHTML = '' + icon + ''; div.querySelector('.ti-label').textContent = label; div.title = file.path; div.addEventListener('click', () => openFile(file.path, { friendly: friendlyFor(category, file.name), sha: file.sha, category, exists: true })); return div; } // „+ Neue …“-Button. function addButton(text, onClick) { const b = document.createElement('button'); b.className = 'add-item'; b.textContent = text; b.addEventListener('click', (e) => { e.stopPropagation(); onClick(); }); return b; } // Eine ein-/ausklappbare Gruppe (für Vorlagen). function makeGroup(key, title, icon, files, category, onAdd, isSub) { const group = document.createElement('div'); group.className = 'tree-group' + (isSub ? ' subgroup' : ''); const head = document.createElement('div'); head.className = 'group-head'; const collapsed = !!state.groupsCollapsed[key]; const toggle = document.createElement('button'); toggle.className = 'group-title' + (collapsed ? ' collapsed' : ''); toggle.innerHTML = '' + icon + ''; toggle.querySelector('.g-label').textContent = title; head.appendChild(toggle); group.appendChild(head); const body = document.createElement('div'); body.className = 'group-files' + (collapsed ? ' collapsed' : ''); if (files.length === 0) { const empty = document.createElement('div'); empty.className = 'tree-empty'; empty.textContent = 'Keine Dateien'; body.appendChild(empty); } else { files.forEach((f) => body.appendChild(fileItem(f, category))); } if (onAdd) body.appendChild(addButton('+ Neue Vorlage', onAdd)); group.appendChild(body); toggle.addEventListener('click', () => { state.groupsCollapsed[key] = !state.groupsCollapsed[key]; toggle.classList.toggle('collapsed'); body.classList.toggle('collapsed'); }); return group; } function renderTemplates() { const c = el.treeTemplates; c.innerHTML = ''; const t = state.tree; if (!t) return; // _gemeinsam c.appendChild(makeGroup( 'tmpl:' + SHARED_FOLDER, 'Alle Abteilungen (_gemeinsam)', '🌐', t.templates[SHARED_FOLDER] || [], 'template', () => newTemplate(SHARED_FOLDER) )); // Abteilungen (t.departments || []).forEach((dept) => { c.appendChild(makeGroup( 'tmpl:' + dept, dept, '🏢', t.templates[dept] || [], 'template', () => newTemplate(dept) )); }); // Persönlich → übergeordnete Gruppe mit Untergruppen je Benutzer const users = t.users || {}; const userKeys = Object.keys(users).sort((a, b) => a.localeCompare(b, 'de')); const persGroup = document.createElement('div'); persGroup.className = 'tree-group'; const persKey = 'tmpl:__pers'; const persCollapsed = !!state.groupsCollapsed[persKey]; const persToggle = document.createElement('button'); persToggle.className = 'group-title' + (persCollapsed ? ' collapsed' : ''); persToggle.innerHTML = '👤Persönlich'; const persHead = document.createElement('div'); persHead.className = 'group-head'; persHead.appendChild(persToggle); persGroup.appendChild(persHead); const persBody = document.createElement('div'); persBody.className = 'group-files' + (persCollapsed ? ' collapsed' : ''); if (userKeys.length === 0) { const empty = document.createElement('div'); empty.className = 'tree-empty'; empty.textContent = 'Keine persönlichen Ordner'; persBody.appendChild(empty); } else { userKeys.forEach((email) => { // Persönlicher Ordnerpfad: _benutzer/ persBody.appendChild(makeGroup( 'tmpl:user:' + email, email, '✉️', users[email] || [], 'template', () => newTemplate(USER_FOLDER + '/' + email), true )); }); } persGroup.appendChild(persBody); persToggle.addEventListener('click', () => { state.groupsCollapsed[persKey] = !state.groupsCollapsed[persKey]; persToggle.classList.toggle('collapsed'); persBody.classList.toggle('collapsed'); }); c.appendChild(persGroup); } function renderFooters() { const c = el.treeFooters; c.innerHTML = ''; const footers = (state.tree && state.tree.footers) || []; if (footers.length === 0) { const empty = document.createElement('div'); empty.className = 'tree-empty'; empty.textContent = 'Keine Fußzeilen'; c.appendChild(empty); } else { footers.forEach((f) => c.appendChild(fileItem(f, 'footer'))); } c.appendChild(addButton('+ Neue Fußzeile', newFooter)); } function renderHeaders() { const c = el.treeHeaders; c.innerHTML = ''; const headers = (state.tree && state.tree.headers) || []; if (headers.length === 0) { const empty = document.createElement('div'); empty.className = 'tree-empty'; empty.textContent = 'Keine Signatur-Köpfe'; c.appendChild(empty); } else { headers.forEach((f) => c.appendChild(fileItem(f, 'header'))); } c.appendChild(addButton('+ Neue Signatur', newHeader)); } function applySectionCollapse() { document.querySelectorAll('.tree-section').forEach((sec) => { const key = sec.dataset.section; const toggle = sec.querySelector('.section-toggle'); const collapsed = !!state.collapsed[key]; sec.classList.toggle('collapsed', collapsed); toggle.setAttribute('aria-expanded', String(!collapsed)); }); } function highlightActive() { document.querySelectorAll('.tree-item').forEach((item) => { item.classList.toggle('is-active', state.current && item.dataset.path === state.current.path); }); } // ──────────────────────────────────────────────────────────── // Datei öffnen / laden // ──────────────────────────────────────────────────────────── async function openFile(path, meta) { // Ungespeicherte Änderungen? if (state.dirty) { const ok = await confirmModal('Es gibt ungespeicherte Änderungen. Trotzdem eine andere Datei öffnen? Die Änderungen gehen verloren.', { title: 'Ungespeicherte Änderungen', okLabel: 'Verwerfen', danger: true }); if (!ok) return; } try { const data = await api('/api/file?path=' + encodeURIComponent(path)); state.current = { path: data.path, friendly: meta.friendly, sha: data.sha, exists: data.exists, isNew: false, category: meta.category, }; setEditorContent(data.content || ''); setDirty(false); showEditor(); highlightActive(); } catch (e) { toast('Datei konnte nicht geladen werden: ' + e.message, 'error'); } } // Neue (noch nicht gespeicherte) Datei direkt im Editor öffnen. function openNewFile(path, friendly, category) { state.current = { path, friendly, sha: null, exists: false, isNew: true, category }; setEditorContent(''); setDirty(true); // neu = ungespeichert showEditor(); highlightActive(); el.visualEditor.focus(); toast('Neue Datei „' + friendly + '“ – jetzt bearbeiten und speichern.', 'success'); } function showEditor() { el.emptyState.hidden = true; el.editorPanel.hidden = false; el.fileFriendly.textContent = state.current.friendly; el.filePath.textContent = state.current.path; setView('visual'); } function hideEditor() { state.current = null; el.editorPanel.hidden = true; el.emptyState.hidden = false; highlightActive(); } // Inhalt in beide Editoren + Vorschau setzen. function setEditorContent(html) { el.visualEditor.innerHTML = html; el.htmlEditor.value = html; updatePreview(); } // Aktuellen HTML-Inhalt aus dem gerade aktiven View lesen. function currentHtml() { return state.view === 'html' ? el.htmlEditor.value : el.visualEditor.innerHTML; } // ──────────────────────────────────────────────────────────── // Dirty-State // ──────────────────────────────────────────────────────────── function setDirty(d) { state.dirty = d; el.dirtyBadge.hidden = !d; // Speichern aktiv, wenn: keine Datei → aus; neue (ungespeicherte) Datei → immer an; // bestehende Datei → nur bei ungespeicherten Änderungen. if (!state.current) el.btnSave.disabled = true; else if (!state.current.exists) el.btnSave.disabled = false; else el.btnSave.disabled = !d; } function markDirty() { if (!state.dirty) setDirty(true); } // ──────────────────────────────────────────────────────────── // View-Umschaltung (Visuell ↔ HTML) – synchron halten // ──────────────────────────────────────────────────────────── function setView(view) { if (view === state.view && el.editorPanel.hidden === false) { // trotzdem Tabs/Anzeige korrekt setzen } if (view === 'html') { // Visuell → HTML: innerHTML in Textarea schreiben el.htmlEditor.value = el.visualEditor.innerHTML; el.visualWrap.hidden = true; el.htmlWrap.hidden = false; el.formatToolbar.classList.add('disabled'); } else { // HTML → Visuell: Textarea-Wert in contenteditable schreiben el.visualEditor.innerHTML = el.htmlEditor.value; el.htmlWrap.hidden = true; el.visualWrap.hidden = false; el.formatToolbar.classList.remove('disabled'); } state.view = view; el.tabVisual.classList.toggle('is-active', view === 'visual'); el.tabHtml.classList.toggle('is-active', view === 'html'); updatePreview(); } // ──────────────────────────────────────────────────────────── // Vorschau (sandboxed iframe via srcdoc), debounced // ──────────────────────────────────────────────────────────── const updatePreview = debounce(function () { const html = currentHtml(); const doc = '' + '' + ''; el.previewFrame.srcdoc = doc; }, 250); // ──────────────────────────────────────────────────────────── // Formatierungs-Toolbar (document.execCommand) // ──────────────────────────────────────────────────────────── function exec(cmd, value) { el.visualEditor.focus(); try { document.execCommand(cmd, false, value); } catch (e) { /* alte Browser */ } afterVisualEdit(); } function afterVisualEdit() { el.htmlEditor.value = el.visualEditor.innerHTML; markDirty(); updatePreview(); } function bindToolbar() { el.formatToolbar.querySelectorAll('.fmt-btn[data-cmd]').forEach((btn) => { btn.addEventListener('mousedown', (e) => e.preventDefault()); // Auswahl im Editor behalten btn.addEventListener('click', () => exec(btn.dataset.cmd)); }); el.fmtFontSize.addEventListener('change', () => { if (el.fmtFontSize.value) exec('fontSize', el.fmtFontSize.value); el.fmtFontSize.value = ''; }); el.fmtColor.addEventListener('input', () => { el.fmtColorSwatch.style.background = el.fmtColor.value; }); el.fmtColor.addEventListener('change', () => { exec('foreColor', el.fmtColor.value); }); el.fmtLink.addEventListener('mousedown', (e) => e.preventDefault()); el.fmtLink.addEventListener('click', async () => { const res = await promptModal('Link einfügen', [ { key: 'url', label: 'Adresse (URL)', placeholder: 'https://…', required: true, value: 'https://' }, ]); if (res) exec('createLink', res.url.trim()); }); el.fmtImage.addEventListener('mousedown', (e) => e.preventDefault()); el.fmtImage.addEventListener('click', async () => { const res = await promptModal('Bild einfügen', [ { key: 'url', label: 'Bild-URL', placeholder: 'https://…/bild.png', required: true, value: 'https://' }, ]); if (res) exec('insertImage', res.url.trim()); }); } // ──────────────────────────────────────────────────────────── // Speichern / Neu laden / Löschen // ──────────────────────────────────────────────────────────── async function saveCurrent() { if (!state.current) return; // Sicherstellen, dass beide Editoren synchron sind (aus aktivem View lesen). const content = currentHtml(); const friendly = state.current.friendly; const message = friendly + ' bearbeitet (Web-Editor)'; try { const res = await api('/api/file', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.current.path, content: content, message: message }), }); state.current.exists = true; state.current.isNew = false; if (res.sha) state.current.sha = res.sha; setDirty(false); if (res.unchanged) toast('Keine Änderungen – nichts zu speichern.', 'success'); else toast('„' + friendly + '“ gespeichert.', 'success'); await loadTree(); // neue Dateien auftauchen lassen / SHAs aktualisieren highlightActive(); } catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); } } async function reloadCurrent() { if (!state.current) return; if (state.current.isNew) { toast('Diese Datei wurde noch nicht gespeichert.', 'error'); return; } if (state.dirty) { const ok = await confirmModal('Ungespeicherte Änderungen verwerfen und Datei neu laden?', { title: 'Neu laden', okLabel: 'Verwerfen', danger: true }); if (!ok) return; } try { const data = await api('/api/file?path=' + encodeURIComponent(state.current.path)); state.current.sha = data.sha; state.current.exists = data.exists; setEditorContent(data.content || ''); setDirty(false); setView('visual'); toast('Datei neu geladen.', 'success'); } catch (e) { toast('Neu laden fehlgeschlagen: ' + e.message, 'error'); } } async function deleteCurrent() { if (!state.current) return; const friendly = state.current.friendly; // Noch nicht gespeicherte Datei → nur lokal verwerfen. if (state.current.isNew) { const ok = await confirmModal('Diese neue, noch nicht gespeicherte Datei verwerfen?', { title: 'Verwerfen', okLabel: 'Verwerfen', danger: true }); if (!ok) return; setDirty(false); hideEditor(); return; } const ok = await confirmModal('„' + friendly + '“ wirklich löschen? Dies kann nicht rückgängig gemacht werden.', { title: 'Löschen', okLabel: 'Löschen', danger: true }); if (!ok) return; try { await api('/api/file', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.current.path, message: friendly + ' gelöscht (Web-Editor)' }), }); toast('„' + friendly + '“ gelöscht.', 'success'); setDirty(false); hideEditor(); await loadTree(); } catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); } } // ──────────────────────────────────────────────────────────── // Neue Dateien anlegen // ──────────────────────────────────────────────────────────── async function newTemplate(folder) { const res = await promptModal('Neue Vorlage in „' + folder + '“', [ { key: 'name', label: 'Vorlagenname', placeholder: 'z. B. Angebot Doppelzimmer', required: true, live: true, hint: '' }, ], (values, inputs, root) => { const slug = slugifyName(values.name || ''); const liveHint = root.querySelector('[data-live="name"]'); if (liveHint) liveHint.innerHTML = slug ? 'Datei: ' + esc(slug) + '.html' : 'Bitte einen Namen eingeben.'; }); if (!res) return; const slug = slugifyName(res.name); if (!slug) { toast('Ungültiger Name.', 'error'); return; } const path = folder + '/' + slug + '.html'; if (await existsInTree(path)) { toast('Eine Vorlage mit diesem Namen existiert bereits.', 'error'); return; } openNewFile(path, slug, 'template'); } async function newFooter() { const t = state.tree || {}; const options = [{ value: '_default', label: 'Gemeinsam (alle Abteilungen)' }] .concat((t.departments || []).map((d) => ({ value: d, label: d }))); const res = await promptModal('Neue Fußzeile', [ { key: 'dept', label: 'Für welche Abteilung?', type: 'select', options: options, required: true }, ]); if (!res) return; const file = (res.dept === '_default' ? '_default' : res.dept) + '.html'; const path = SIG_FOOTERS + '/' + file; if (await existsInTree(path)) { toast('Diese Fußzeile existiert bereits.', 'error'); return; } openNewFile(path, 'Fußzeile: ' + footerLabel(file), 'footer'); } async function newHeader() { const res = await promptModal('Neue Signatur', [ { key: 'email', label: 'E-Mail-Adresse', placeholder: 'name@hotel-park-soltau.de', required: true, live: true }, { key: 'name', label: 'Name', placeholder: 'Max Mustermann', required: true, live: true }, ], (values, inputs, root) => { const email = (values.email || '').trim(); const slug = slugifyHeaderName(values.name || ''); const liveHint = root.querySelector('[data-live="name"]'); const file = (email && slug) ? (email + '.' + slug + '.html') : ''; if (liveHint) liveHint.innerHTML = file ? 'Datei: ' + esc(file) + '' : 'E-Mail und Name eingeben.'; }); if (!res) return; const email = res.email.trim(); const slug = slugifyHeaderName(res.name); if (!email || !slug) { toast('E-Mail und Name erforderlich.', 'error'); return; } const file = email + '.' + slug + '.html'; const path = SIG_HEADERS + '/' + file; if (await existsInTree(path)) { toast('Diese Signatur existiert bereits.', 'error'); return; } openNewFile(path, 'Signatur: ' + headerLabel(file), 'header'); } async function newDepartment() { const res = await promptModal('Neue Abteilung', [ { key: 'name', label: 'Abteilungsname', placeholder: 'z. B. Rezeption', required: true, hint: 'Wird als Ordner im Repository angelegt.' }, ]); if (!res) return; const name = res.name.trim(); if (/[\/\\:*?"<>|]/.test(name)) { toast('Ungültiger Abteilungsname.', 'error'); return; } try { const r = await api('/api/departments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name }), }); toast('Abteilung „' + (r.name || name) + '“ angelegt.', 'success'); await loadTree(); } catch (e) { toast('Abteilung anlegen fehlgeschlagen: ' + e.message, 'error'); } } // Prüfen, ob ein Pfad schon im aktuellen Baum vorkommt. function existsInTree(path) { const t = state.tree; if (!t) return false; const lists = []; Object.keys(t.templates || {}).forEach((k) => lists.push(t.templates[k])); Object.keys(t.users || {}).forEach((k) => lists.push(t.users[k])); lists.push(t.footers || [], t.headers || []); return lists.some((arr) => (arr || []).some((f) => f.path === path)); } // ──────────────────────────────────────────────────────────── // Event-Bindungen // ──────────────────────────────────────────────────────────── function bindEvents() { // Sektionen ein-/ausklappen document.querySelectorAll('.section-toggle').forEach((btn) => { btn.addEventListener('click', () => { const key = btn.dataset.toggle; state.collapsed[key] = !state.collapsed[key]; applySectionCollapse(); }); }); // Sidebar-Aktionen el.btnRefresh.addEventListener('click', loadTree); if (el.treeSearch) el.treeSearch.addEventListener('input', applyTreeFilter); el.btnNewDept.addEventListener('click', (e) => { e.stopPropagation(); newDepartment(); }); el.btnNewFooter.addEventListener('click', (e) => { e.stopPropagation(); newFooter(); }); el.btnNewHeader.addEventListener('click', (e) => { e.stopPropagation(); newHeader(); }); // Editor-Aktionen el.btnSave.addEventListener('click', saveCurrent); el.btnReload.addEventListener('click', reloadCurrent); el.btnDelete.addEventListener('click', deleteCurrent); // View-Tabs el.tabVisual.addEventListener('click', () => setView('visual')); el.tabHtml.addEventListener('click', () => setView('html')); // Visuell editieren el.visualEditor.addEventListener('input', afterVisualEdit); // HTML editieren el.htmlEditor.addEventListener('input', () => { markDirty(); updatePreview(); }); // Toolbar bindToolbar(); el.fmtColorSwatch.style.background = el.fmtColor.value; // Tastenkürzel: Strg/Cmd+S = Speichern document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); if (state.current && !el.btnSave.disabled) saveCurrent(); } // Escape schließt offene Modals if (e.key === 'Escape') { if (!el.promptBackdrop.hidden) el.promptCancel.click(); else if (!el.confirmBackdrop.hidden) el.confirmCancel.click(); } }); // Vor Verlassen warnen, wenn ungespeichert window.addEventListener('beforeunload', (e) => { if (state.dirty) { e.preventDefault(); e.returnValue = ''; return ''; } }); } // ──────────────────────────────────────────────────────────── // Start // ──────────────────────────────────────────────────────────── async function init() { bindEvents(); setDirty(false); const ok = await loadConfigAndHealth(); if (ok) { await loadTree(); } else if (state.config && !state.config.configured) { // Banner ist sichtbar; kein Baum-Laden möglich. } } document.addEventListener('DOMContentLoaded', init); })();