/* HPS Vorlagen & Signaturen — Web-Editor Frontend * Vanilla JS + TinyMCE (selbst gehostet). Spricht ausschließlich mit dem * Express-Backend (same origin) über die /api-Endpunkte. */ (function () { 'use strict'; // ── Backend-Konstanten ── const SHARED_FOLDER = '_gemeinsam'; const USER_FOLDER = '_benutzer'; const SIG_FOOTERS = 'signatures/footers'; const SIG_HEADERS = 'signatures/headers'; // ── Icon-Set (Lucide-Stil, currentColor) ── const ICONS = { 'file-text': '', 'panel-bottom': '', 'pen-line': '', 'settings': '', 'globe': '', 'building': '', 'at-sign': '', 'plus': '', 'refresh': '', 'reload': '', 'save': '', 'trash': '', 'search': '', 'dashboard': '', 'tag': '', 'link2': '', 'chevron': '', 'plug': '', }; function icon(name, size) { const s = size || 18; return '' + (ICONS[name] || '') + ''; } function hydrateIcons(root) { (root || document).querySelectorAll('[data-icon]').forEach((e) => { e.innerHTML = icon(e.dataset.icon); }); } // ── Zustand ── const state = { config: null, tree: null, category: 'templates', // templates | footers | headers | admin adminView: 'overview', // overview | departments | mapping | tags current: null, dirty: false, view: 'visual', html: '', groupsCollapsed: {}, }; let ed = null, edReady = false, suppressDirty = false, pendingNetwork = 0; // ── DOM ── const $ = (id) => document.getElementById(id); const el = { statusPill: $('status-pill'), statusText: $('status-text'), configBanner: $('config-banner'), catTabs: document.querySelectorAll('.cat-tab'), btnRefresh: $('btn-refresh'), btnListAdd: $('btn-list-add'), btnListAddLabel: $('btn-list-add-label'), treeSearch: $('tree-search'), listBody: $('list-body'), emptyState: $('empty-state'), editorPanel: $('editor-panel'), adminPanel: $('admin-panel'), fileFriendly: $('file-friendly'), filePath: $('file-path'), dirtyBadge: $('dirty-badge'), btnSave: $('btn-save'), btnReload: $('btn-reload'), btnDelete: $('btn-delete'), tabVisual: $('tab-visual'), tabHtml: $('tab-html'), tabPreview: $('tab-preview'), paneVisual: $('pane-visual'), paneHtml: $('pane-html'), panePreview: $('pane-preview'), htmlEditor: $('html-editor'), previewFrame: $('preview-frame'), toastStack: $('toast-stack'), loading: $('loading-overlay'), 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'), }; // ── Helfer ── function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function slugifyName(name) { return name.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss') .replace(/[/\\:*?"<>|]/g, '-').replace(/^[\s.-]+|[\s.-]+$/g, '').trim(); } function slugifyHeaderName(name) { return slugifyName(name).toLowerCase().replace(/\s+/g, '-'); } // ── Toasts ── function toast(message, type) { const t = document.createElement('div'); t.className = 'toast' + (type ? ' toast-' + type : ''); const ic = type === 'success' ? '✓' : type === 'error' ? '⚠' : 'ℹ'; t.innerHTML = '' + ic + ''; t.querySelector('.toast-msg').textContent = message; el.toastStack.appendChild(t); setTimeout(() => { t.classList.add('fade-out'); t.addEventListener('animationend', () => t.remove(), { once: true }); }, 4200); } // ── Loading + API ── function startLoading() { pendingNetwork++; el.loading.hidden = false; } function stopLoading() { pendingNetwork = Math.max(0, pendingNetwork - 1); if (pendingNetwork === 0) el.loading.hidden = true; } 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) throw new Error((data && data.error) ? data.error : ('HTTP ' + res.status + ' ' + res.statusText)); return data || {}; } finally { stopLoading(); } } // ── Modals ── 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(r) { el.confirmBackdrop.hidden = true; el.confirmOk.removeEventListener('click', onOk); el.confirmCancel.removeEventListener('click', onCancel); el.confirmBackdrop.removeEventListener('click', onBackdrop); resolve(r); } const onOk = () => cleanup(true), 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); }); } 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 h = document.createElement('div'); h.className = 'hint'; if (f.live) h.dataset.live = f.key; if (f.hint) h.textContent = f.hint; wrap.appendChild(h); } 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(r) { 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(r); } function onSubmit(e) { e.preventDefault(); const values = readValues(); 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); }); } // ── Anzeige-Namen ── function footerLabel(name) { return name === '_default.html' ? 'Gemeinsam (alle Abteilungen)' : name.replace(/\.html$/i, ''); } function headerLabel(name) { if (name === '_vorlage.html') return 'Vorlage (Standard-Kopf)'; const base = name.replace(/\.html$/i, ''); const at = base.indexOf('@'); if (at >= 0) { const dot = base.indexOf('.', at); if (dot > -1 && dot < base.length - 1) return base.slice(0, dot) + ' — ' + base.slice(dot + 1); } return base; } function templateLabel(name) { return name.replace(/\.html$/i, ''); } 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() { 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 nicht ladbar: ' + e.message, 'error'); return false; } try { const health = await api('/api/health'); if (health.ok) { const c = state.config; setStatus('ok', c.owner + '/' + c.repo + '@' + c.branch); } else setStatus('error', 'Nicht verbunden: ' + (health.error || 'unbekannt')); } catch (e) { setStatus('error', 'Nicht verbunden: ' + e.message); toast('Verbindung 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; } // ── Hauptbereich umschalten ── function showMain(kind) { el.emptyState.hidden = kind !== 'empty'; el.editorPanel.hidden = kind !== 'editor'; el.adminPanel.hidden = kind !== 'admin'; } // ── Kategorie ── function setCategory(cat) { state.category = cat; el.catTabs.forEach((t) => t.classList.toggle('is-active', t.dataset.cat === cat)); const isAdmin = cat === 'admin'; el.btnListAdd.style.display = isAdmin ? 'none' : ''; el.treeSearch.parentElement.style.display = isAdmin ? 'none' : ''; if (cat === 'templates') el.btnListAddLabel.textContent = 'Abteilung'; else if (cat === 'footers') el.btnListAddLabel.textContent = 'Fußzeile'; else if (cat === 'headers') el.btnListAddLabel.textContent = 'Signatur'; renderList(); if (isAdmin) setAdminView(state.adminView); else showMain(state.current ? 'editor' : 'empty'); } // ── Tree laden ── async function loadTree() { try { state.tree = await api('/api/tree'); renderList(); } catch (e) { toast('Liste nicht ladbar: ' + e.message, 'error'); } } // ── Listen-Spalte ── function fileItem(file, category) { const div = document.createElement('div'); div.className = 'tree-item'; div.dataset.path = file.path; const label = category === 'footer' ? footerLabel(file.name) : category === 'header' ? headerLabel(file.name) : templateLabel(file.name); const ic = category === 'footer' ? 'panel-bottom' : category === 'header' ? 'pen-line' : 'file-text'; div.innerHTML = '' + icon(ic, 16) + ''; 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; } // Gedämpfte, aber unterscheidbare Farbpalette für Abteilungs-Badges. const DEPT_PALETTE = [ { fg: '#647219', bg: '#eef2da' }, { fg: '#2f7d83', bg: '#dff0f0' }, { fg: '#b5683f', bg: '#f7e8df' }, { fg: '#6c5a90', bg: '#ece7f3' }, { fg: '#4a6488', bg: '#e5ecf5' }, { fg: '#9a7d1e', bg: '#f5efd6' }, { fg: '#a8527a', bg: '#f6e5ee' }, { fg: '#3f7d5a', bg: '#e2f0e8' }, ]; // Kürzeste eindeutige Abkürzung je Abteilung (min. 2 Zeichen): // Rezeption/Restaurant → REZ/RES, Buchhaltung → BU, IT → IT. function computeAbbrevs(names) { const clean = (n) => (n.replace(/[^a-z0-9äöüß]/gi, '') || n); const cleaned = names.map(clean); const map = {}; names.forEach((n, i) => { const cn = cleaned[i]; const maxLen = Math.min(3, cn.length); // Badge bleibt kurz; Rest unterscheidet die Farbe let len = Math.min(2, cn.length); while (len < maxLen) { const pre = cn.slice(0, len).toLowerCase(); const collide = cleaned.some((o, j) => j !== i && o.slice(0, len).toLowerCase() === pre); if (!collide) break; len++; } map[n] = cn.slice(0, len).toUpperCase(); }); return map; } function deptBadge(name, label) { const i = Math.abs([...name].reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0)) % DEPT_PALETTE.length; return { type: 'initial', value: label || (name.trim()[0] || '?').toUpperCase(), fg: DEPT_PALETTE[i].fg, bg: DEPT_PALETTE[i].bg }; } function neutralBadge(iconName) { return { type: 'icon', value: iconName, fg: 'var(--muted)', bg: 'var(--bg)' }; } function makeGroup(key, title, badge, files, onAdd) { const group = document.createElement('div'); group.className = 'tree-group'; const collapsed = !!state.groupsCollapsed[key]; const head = document.createElement('div'); head.className = 'group-head' + (collapsed ? ' collapsed' : ''); const badgeInner = badge.type === 'icon' ? icon(badge.value, 15) : esc(badge.value); const fs = badge.type === 'icon' ? '' : (badge.value.length >= 3 ? ';font-size:9.5px' : badge.value.length === 2 ? ';font-size:11px' : ''); head.innerHTML = '' + icon('chevron', 14) + '' + '' + badgeInner + '' + '' + '' + files.length + '' + (onAdd ? '' : ''); head.querySelector('.g-label').textContent = title; const body = document.createElement('div'); body.className = 'group-files' + (collapsed ? ' collapsed' : ''); if (files.length === 0) { const e = document.createElement('div'); e.className = 'tree-empty'; e.textContent = 'Noch leer'; body.appendChild(e); } else files.forEach((f) => body.appendChild(fileItem(f, 'template'))); head.addEventListener('click', (ev) => { if (ev.target.closest('.g-add')) return; state.groupsCollapsed[key] = !state.groupsCollapsed[key]; head.classList.toggle('collapsed'); body.classList.toggle('collapsed'); }); if (onAdd) head.querySelector('.g-add').addEventListener('click', (ev) => { ev.stopPropagation(); onAdd(); }); group.appendChild(head); group.appendChild(body); return group; } function adminNavItem(view, label, iconName) { const b = document.createElement('button'); b.className = 'nav-item' + (state.adminView === view ? ' is-active' : ''); b.innerHTML = '' + icon(iconName, 17) + '' + esc(label) + ''; b.addEventListener('click', () => setAdminView(view)); return b; } function renderList() { const c = el.listBody; c.innerHTML = ''; const t = state.tree; if (state.category === 'admin') { const wrap = document.createElement('div'); wrap.className = 'nav-list'; wrap.appendChild(adminNavItem('overview', 'Übersicht', 'dashboard')); wrap.appendChild(adminNavItem('departments', 'Abteilungen', 'building')); wrap.appendChild(adminNavItem('mapping', 'E-Mail-Zuordnung', 'at-sign')); wrap.appendChild(adminNavItem('tags', 'Schlagwörter', 'tag')); c.appendChild(wrap); return; } if (!t) return; if (state.category === 'templates') { c.appendChild(makeGroup('tmpl:' + SHARED_FOLDER, 'Alle Abteilungen', neutralBadge('globe'), t.templates[SHARED_FOLDER] || [], () => newTemplate(SHARED_FOLDER))); const abbr = computeAbbrevs(t.departments || []); (t.departments || []).forEach((d) => c.appendChild(makeGroup('tmpl:' + d, d, deptBadge(d, abbr[d]), t.templates[d] || [], () => newTemplate(d)))); const users = t.users || {}; Object.keys(users).sort((a, b) => a.localeCompare(b, 'de')).forEach((email) => c.appendChild(makeGroup('tmpl:user:' + email, email, neutralBadge('at-sign'), users[email] || [], () => newTemplate(USER_FOLDER + '/' + email)))); } else if (state.category === 'footers') { const footers = t.footers || []; if (!footers.length) c.appendChild(emptyHint('Keine Fußzeilen')); else footers.forEach((f) => c.appendChild(fileItem(f, 'footer'))); } else { const headers = t.headers || []; if (!headers.length) c.appendChild(emptyHint('Keine Signaturen')); else headers.forEach((f) => c.appendChild(fileItem(f, 'header'))); } highlightActive(); if (el.treeSearch.value.trim()) applyFilter(); } function emptyHint(text) { const e = document.createElement('div'); e.className = 'tree-empty'; e.textContent = text; return e; } function applyFilter() { const q = el.treeSearch.value.trim().toLowerCase(); document.querySelectorAll('.list-body .tree-empty').forEach((n) => { n.style.display = q ? 'none' : ''; }); document.querySelectorAll('.list-body .group-files').forEach((b) => { if (q) b.classList.remove('collapsed'); }); document.querySelectorAll('.list-body .group-head').forEach((tg) => { if (q) tg.classList.remove('collapsed'); }); document.querySelectorAll('.list-body .tree-item').forEach((it) => { const label = (it.querySelector('.ti-label')?.textContent || '').toLowerCase(); it.style.display = (!q || label.includes(q) || (it.dataset.path || '').toLowerCase().includes(q)) ? '' : 'none'; }); document.querySelectorAll('.list-body .tree-group').forEach((g) => { const items = g.querySelectorAll('.tree-item'); const any = Array.from(items).some((i) => i.style.display !== 'none'); g.style.display = (q && items.length && !any) ? 'none' : ''; }); } function highlightActive() { document.querySelectorAll('.tree-item').forEach((it) => it.classList.toggle('is-active', state.current && it.dataset.path === state.current.path)); } // ── TinyMCE ── function ensureEditor() { if (edReady) return Promise.resolve(); return tinymce.init({ target: $('visual-editor'), base_url: '/vendor/tinymce', license_key: 'gpl', menubar: false, branding: false, statusbar: false, height: '100%', plugins: 'link image lists table code autolink searchreplace visualblocks', toolbar: 'undo redo | blocks fontfamily fontsize | bold italic underline forecolor backcolor | alignleft aligncenter alignright | bullist numlist | link image table | removeformat | code', toolbar_mode: 'wrap', valid_elements: '*[*]', extended_valid_elements: '*[*]', valid_children: '+body[style]', verify_html: false, convert_urls: false, content_style: 'body{font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#1f2a30;line-height:1.6;padding:10px 12px;} img{max-width:100%;height:auto;} table{border-collapse:collapse;}', paste_data_images: true, automatic_uploads: false, file_picker_types: 'image', file_picker_callback: function (cb) { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.onchange = function () { const file = input.files[0]; if (!file) return; const r = new FileReader(); r.onload = function () { cb(r.result, { title: file.name }); }; r.readAsDataURL(file); }; input.click(); }, setup: function (editor) { ed = editor; editor.on('init', function () { edReady = true; }); editor.on('input ExecCommand Undo Redo SetContent paste', function () { if (!suppressDirty) markDirty(); }); }, }).then(() => { edReady = true; }); } // ── Platzhalter (nur sinnvoll in der Signatur-Vorlage _vorlage.html) ── const VORLAGE_PATH = SIG_HEADERS + '/_vorlage.html'; const PLACEHOLDERS = [ { t: '{{NAME}}', d: 'Name des Mitarbeiters', s: 'aus dem Feld „Name" in den Plugin-Einstellungen' }, { t: '{{EMAIL}}', d: 'E-Mail-Adresse', s: 'aus der gewählten Thunderbird-Identität' }, { t: '{{ABTEILUNG}}', d: 'Abteilung', s: 'automatisch erkannt über die E-Mail-Zuordnung', link: true }, { t: '{{TELEFON}}', d: '+49 (0) 5191 - 605-0', s: 'fest im Plugin-Code hinterlegt' }, { t: '{{FAX}}', d: '+49 (0) 5191 - 605-185', s: 'fest im Plugin-Code hinterlegt' }, ]; function buildPlaceholderBar() { const bar = $('ph-bar'), details = $('ph-details'); bar.innerHTML = '' + icon('tag', 15) + ' Platzhalter – klick zum Einfügen:' + '' + PLACEHOLDERS.map((p) => '').join('') + '' + ''; details.innerHTML = '

Dies ist die zentrale Signatur-Vorlage. Klickt ein Mitarbeiter im Thunderbird-Plugin (Tab „Signaturen") auf „Vorlage laden", ersetzt das Plugin diese Platzhalter einmalig durch seine eigenen Daten:

' + '' + PLACEHOLDERS.map((p) => '').join('') + '
' + esc(p.t) + '→ ' + (p.link ? '' + esc(p.d) + '' : esc(p.d)) + '' + esc(p.s) + '
' + '

Telefon & Fax sind fest im Plugin-Code hinterlegt – zum Ändern muss das Plugin angepasst werden, nicht diese Oberfläche. In normalen Vorlagen, Fußzeilen und bereits gespeicherten Signaturen werden Platzhalter nicht ersetzt.

'; bar.querySelectorAll('.ph-chip').forEach((b) => b.addEventListener('click', () => insertPlaceholder(b.dataset.token))); $('ph-more').addEventListener('click', () => { details.hidden = !details.hidden; $('ph-more').classList.toggle('is-open', !details.hidden); }); const link = $('ph-link'); if (link) link.addEventListener('click', (e) => { e.preventDefault(); setCategory('admin'); setAdminView('mapping'); }); } // Leiste nur in der Signatur-Vorlage zeigen – sonst sind Platzhalter wirkungslos. function updatePlaceholderBar() { const show = !!state.current && state.current.path === VORLAGE_PATH; $('ph-bar').hidden = !show; if (!show) { $('ph-details').hidden = true; const m = $('ph-more'); if (m) m.classList.remove('is-open'); } } function insertPlaceholder(token) { if (!state.current) return; if (state.view === 'preview') setView('visual'); if (state.view === 'visual' && edReady && ed) { ed.insertContent(token); markDirty(); } else if (state.view === 'html') { const ta = el.htmlEditor, s = ta.selectionStart, e = ta.selectionEnd; ta.value = ta.value.slice(0, s) + token + ta.value.slice(e); ta.selectionStart = ta.selectionEnd = s + token.length; ta.focus(); markDirty(); } } // ── Datei öffnen ── async function openFile(path, meta) { if (!(await guardUnsaved())) 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 }; await showEditor(data.content || ''); setDirty(false); highlightActive(); } catch (e) { toast('Datei nicht ladbar: ' + e.message, 'error'); } } async function openNewFile(path, friendly, category) { if (!(await guardUnsaved())) return; state.current = { path, friendly, sha: null, exists: false, isNew: true, category }; await showEditor(''); setDirty(true); highlightActive(); toast('Neuer Eintrag „' + friendly + '“ – jetzt bearbeiten und speichern.', 'success'); } async function guardUnsaved() { if (!state.dirty) return true; return await confirmModal('Es gibt ungespeicherte Änderungen. Trotzdem fortfahren? Die Änderungen gehen verloren.', { title: 'Ungespeicherte Änderungen', okLabel: 'Verwerfen', danger: true }); } async function showEditor(html) { state.html = html; showMain('editor'); el.fileFriendly.textContent = state.current.friendly; el.filePath.textContent = state.current.path; updatePlaceholderBar(); await ensureEditor(); setView('visual', true); } function hideEditor() { state.current = null; showMain('empty'); highlightActive(); } function syncFromActive() { if (state.view === 'visual' && edReady && ed) state.html = ed.getContent(); else if (state.view === 'html') state.html = el.htmlEditor.value; return state.html; } function setView(view, skipSync) { const html = skipSync ? state.html : syncFromActive(); state.html = html; state.view = view; el.paneVisual.hidden = view !== 'visual'; el.paneHtml.hidden = view !== 'html'; el.panePreview.hidden = view !== 'preview'; el.tabVisual.classList.toggle('is-active', view === 'visual'); el.tabHtml.classList.toggle('is-active', view === 'html'); el.tabPreview.classList.toggle('is-active', view === 'preview'); if (view === 'visual' && edReady && ed) { suppressDirty = true; ed.setContent(html); setTimeout(() => { suppressDirty = false; }, 0); } else if (view === 'html') el.htmlEditor.value = html; else if (view === 'preview') renderPreview(html); } function renderPreview(html) { const doc = ''; el.previewFrame.srcdoc = doc; } // ── Dirty ── function setDirty(d) { state.dirty = d; el.dirtyBadge.hidden = !d; 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); } // ── Speichern / Neu laden / Löschen ── async function saveCurrent() { if (!state.current) return; const content = syncFromActive(); const friendly = state.current.friendly; const wasNew = state.current.isNew; try { const res = await api('/api/file', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.current.path, content, message: friendly + ' bearbeitet (Web-Editor)' }) }); state.current.exists = true; state.current.isNew = false; if (res.sha) state.current.sha = res.sha; setDirty(false); toast(res.unchanged ? 'Keine Änderungen – nichts zu speichern.' : '„' + friendly + '“ gespeichert.', 'success'); // Baum nur neu laden, wenn eine NEUE Datei dazukam (sonst ändert sich die Liste nicht). if (wasNew) { await loadTree(); highlightActive(); } } catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); } } async function reloadCurrent() { if (!state.current) return; if (state.current.isNew) { toast('Dieser Eintrag wurde noch nicht gespeichert.', 'error'); return; } if (state.dirty && !(await confirmModal('Ungespeicherte Änderungen verwerfen und neu laden?', { title: 'Neu laden', okLabel: 'Verwerfen', danger: true }))) return; try { const data = await api('/api/file?path=' + encodeURIComponent(state.current.path)); state.current.sha = data.sha; state.current.exists = data.exists; state.html = data.content || ''; setView('visual', true); setDirty(false); toast('Neu geladen.', 'success'); } catch (e) { toast('Neu laden fehlgeschlagen: ' + e.message, 'error'); } } async function deleteCurrent() { if (!state.current) return; const friendly = state.current.friendly; if (state.current.isNew) { if (await confirmModal('Diesen neuen, noch nicht gespeicherten Eintrag verwerfen?', { title: 'Verwerfen', okLabel: 'Verwerfen', danger: true })) { setDirty(false); hideEditor(); } return; } if (!(await confirmModal('„' + friendly + '“ wirklich löschen? Das kann nicht rückgängig gemacht werden.', { title: 'Löschen', okLabel: 'Löschen', danger: true }))) 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 Einträge ── 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)); } 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 }], (values, inputs, root) => { const slug = slugifyName(values.name || ''); const h = root.querySelector('[data-live="name"]'); if (h) h.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 (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, required: true }]); if (!res) return; const file = (res.dept === '_default' ? '_default' : res.dept) + '.html'; const path = SIG_FOOTERS + '/' + file; if (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 h = root.querySelector('[data-live="name"]'); const file = (email && slug) ? (email + '.' + slug + '.html') : ''; if (h) h.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 (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 }) }); toast('Abteilung „' + (r.name || name) + '“ angelegt.', 'success'); await loadTree(); if (state.category === 'admin') setAdminView('departments'); } catch (e) { toast('Abteilung anlegen fehlgeschlagen: ' + e.message, 'error'); } } function listAddAction() { if (state.category === 'templates') newDepartment(); else if (state.category === 'footers') newFooter(); else if (state.category === 'headers') newHeader(); } // ════════════════════════════════════════════════════════════ // Verwaltung (Admin) // ════════════════════════════════════════════════════════════ function setAdminView(view) { state.adminView = view; document.querySelectorAll('.nav-item').forEach((n) => {}); renderList(); // aktualisiert aktive Markierung in der Nav showMain('admin'); if (view === 'overview') renderOverview(); else if (view === 'departments') renderDepartments(); else if (view === 'mapping') renderMapping(); else if (view === 'tags') renderTags(); } function adminHeader(title, subtitle) { return '

' + esc(title) + '

' + (subtitle ? '

' + esc(subtitle) + '

' : '') + '
'; } function renderOverview() { const t = state.tree || {}; const c = state.config || {}; const tmpl = Object.values(t.templates || {}).reduce((n, a) => n + a.length, 0) + Object.values(t.users || {}).reduce((n, a) => n + a.length, 0); const cards = [ { icon: 'file-text', n: tmpl, label: 'Vorlagen' }, { icon: 'building', n: (t.departments || []).length, label: 'Abteilungen' }, { icon: 'panel-bottom', n: (t.footers || []).length, label: 'Fußzeilen' }, { icon: 'pen-line', n: (t.headers || []).length, label: 'Signaturen' }, ]; const mode = c.demo ? 'Demo-Modus' : c.local ? 'Lokaler Ordner' : 'Gitea/Forgejo'; el.adminPanel.innerHTML = adminHeader('Übersicht', 'Auf einen Blick: was im Repository liegt und wie der Editor verbunden ist.') + '
' + cards.map((k) => '
' + icon(k.icon, 22) + '
' + k.n + '
' + k.label + '
').join('') + '
' + '
' + icon('plug', 18) + '
Verbindung
' + '
' + esc(mode) + ' · ' + esc((c.owner || '?') + '/' + (c.repo || '?') + '@' + (c.branch || 'main')) + '
'; } function renderDepartments() { const t = state.tree || {}; const depts = t.departments || []; let html = adminHeader('Abteilungen', 'Ordner im Repository. Jede Abteilung kann eigene Vorlagen und eine Fußzeile haben.'); html += '
'; html += '
'; if (!depts.length) html += '
Noch keine Abteilungen.
'; depts.forEach((d) => { const count = (t.templates[d] || []).length; html += '
' + icon('building', 18) + '' + esc(d) + '' + '' + count + ' Vorlage' + (count === 1 ? '' : 'n') + '' + '
'; }); html += '
'; el.adminPanel.innerHTML = html; $('adm-dept-add').addEventListener('click', newDepartment); $('adm-dept-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addDeptInline(); } }); el.adminPanel.querySelectorAll('[data-del]').forEach((b) => b.addEventListener('click', () => deleteDepartment(b.dataset.del))); } async function addDeptInline() { const name = ($('adm-dept-name').value || '').trim(); if (!name) return; if (/[\/\\:*?"<>|]/.test(name)) { toast('Ungültiger Abteilungsname.', 'error'); return; } try { await api('/api/departments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + name + '“ angelegt.', 'success'); await loadTree(); renderDepartments(); } catch (e) { toast('Anlegen fehlgeschlagen: ' + e.message, 'error'); } } async function deleteDepartment(name) { const count = ((state.tree && state.tree.templates[name]) || []).length; const msg = count ? 'Abteilung „' + name + '“ und alle ' + count + ' enthaltenen Vorlagen löschen?' : 'Leere Abteilung „' + name + '“ löschen?'; if (!(await confirmModal(msg, { title: 'Abteilung löschen', okLabel: 'Löschen', danger: true }))) return; try { const r = await api('/api/departments', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + name + '“ gelöscht (' + (r.deleted || 0) + ' Dateien).', 'success'); await loadTree(); renderDepartments(); } catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); } } async function renderMapping() { el.adminPanel.innerHTML = adminHeader('E-Mail-Zuordnung', 'Welche Absender-Adresse gehört zu welcher Abteilung? (Datei _config/abteilungen.json)') + '
Lädt…
'; let mapping = {}; try { const r = await api('/api/abteilungen'); mapping = r.mapping || {}; } catch (e) { toast('Zuordnung nicht ladbar: ' + e.message, 'error'); } const rows = Object.keys(mapping).map((email) => ({ email, dept: mapping[email] })); const depts = (state.tree && state.tree.departments) || []; function deptSelect(val) { return ''; } function rowHtml(r) { return '
' + deptSelect(r.dept) + '
'; } el.adminPanel.innerHTML = adminHeader('E-Mail-Zuordnung', 'Welche Absender-Adresse gehört zu welcher Abteilung? (Datei _config/abteilungen.json)') + '
E-Mail-AdresseAbteilung
' + '
' + (rows.length ? rows.map(rowHtml).join('') : '') + '
' + '
' + '
'; const rowsEl = $('map-rows'); function bindRow(row) { row.querySelector('.map-del').addEventListener('click', () => row.remove()); } rowsEl.querySelectorAll('.map-row').forEach(bindRow); $('map-addrow').addEventListener('click', () => { const div = document.createElement('div'); div.innerHTML = rowHtml({ email: '', dept: '' }); const row = div.firstChild; rowsEl.appendChild(row); bindRow(row); row.querySelector('.map-email').focus(); }); $('map-save').addEventListener('click', async () => { const out = {}; let bad = false; rowsEl.querySelectorAll('.map-row').forEach((row) => { const email = row.querySelector('.map-email').value.trim(); const dept = row.querySelector('.dept-sel').value.trim(); if (!email && !dept) return; if (!email || !dept) { bad = true; return; } out[email] = dept; }); if (bad) { toast('Bitte jede Zeile vollständig ausfüllen (E-Mail + Abteilung) oder leer lassen.', 'error'); return; } try { await api('/api/abteilungen', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mapping: out }) }); toast('Zuordnung gespeichert.', 'success'); } catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); } }); } async function renderTags() { el.adminPanel.innerHTML = adminHeader('Schlagwörter', 'Farbige Tags für Thunderbird (Datei _config/schlagwoerter.json).') + '
Lädt…
'; let tags = []; try { const r = await api('/api/schlagwoerter'); tags = r.tags || []; } catch (e) { toast('Schlagwörter nicht ladbar: ' + e.message, 'error'); } function rowHtml(t) { const color = (t && t.color) || '#95a322'; const name = (t && t.name) || ''; return '
' + '' + '' + '
'; } el.adminPanel.innerHTML = adminHeader('Schlagwörter', 'Farbige Tags für Thunderbird (Datei _config/schlagwoerter.json).') + '
' + (tags.length ? tags.map(rowHtml).join('') : '') + '
' + '
' + '
'; const rowsEl = $('tag-rows'); function bindRow(row) { row.querySelector('.tag-del').addEventListener('click', () => row.remove()); const color = row.querySelector('.tag-color'), sw = row.querySelector('.tag-swatch'); color.addEventListener('input', () => { sw.style.background = color.value; }); } rowsEl.querySelectorAll('.tag-row').forEach(bindRow); $('tag-addrow').addEventListener('click', () => { const div = document.createElement('div'); div.innerHTML = rowHtml({}); const row = div.firstChild; rowsEl.appendChild(row); bindRow(row); row.querySelector('.tag-name').focus(); }); $('tag-save').addEventListener('click', async () => { const out = []; let bad = false; rowsEl.querySelectorAll('.tag-row').forEach((row) => { const name = row.querySelector('.tag-name').value.trim(); const color = row.querySelector('.tag-color').value; if (!name) { if (row.querySelector('.tag-name').value !== '') bad = true; return; } out.push({ name, color }); }); try { await api('/api/schlagwoerter', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags: out }) }); toast('Schlagwörter gespeichert.', 'success'); } catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); } }); } // ── Events ── function bindEvents() { el.catTabs.forEach((t) => t.addEventListener('click', () => setCategory(t.dataset.cat))); el.btnRefresh.addEventListener('click', () => { loadTree(); if (state.category === 'admin') setAdminView(state.adminView); }); el.btnListAdd.addEventListener('click', listAddAction); el.treeSearch.addEventListener('input', applyFilter); el.btnSave.addEventListener('click', saveCurrent); el.btnReload.addEventListener('click', reloadCurrent); el.btnDelete.addEventListener('click', deleteCurrent); el.tabVisual.addEventListener('click', () => setView('visual')); el.tabHtml.addEventListener('click', () => setView('html')); el.tabPreview.addEventListener('click', () => setView('preview')); el.htmlEditor.addEventListener('input', () => { markDirty(); }); document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); if (state.current && !el.btnSave.disabled && !el.editorPanel.hidden) saveCurrent(); } if (e.key === 'Escape') { if (!el.promptBackdrop.hidden) el.promptCancel.click(); else if (!el.confirmBackdrop.hidden) el.confirmCancel.click(); } }); window.addEventListener('beforeunload', (e) => { if (state.dirty) { e.preventDefault(); e.returnValue = ''; return ''; } }); } // ── Start ── async function init() { hydrateIcons(document); buildPlaceholderBar(); bindEvents(); setDirty(false); setCategory('templates'); const ok = await loadConfigAndHealth(); if (ok) await loadTree(); } document.addEventListener('DOMContentLoaded', init); })();