Files
hps-thunderbird-templates/web-editor/public/app.js
Kendrick Bollens eff90e9517 Auto-Update über Gitea einrichten + Web-Editor + Sync-Verbesserungen
- Thunderbird Auto-Update: update_url im Manifest, updates.json, release.sh
- .xpi neu gebaut (mit update_url, ohne defaults.local.json/Token)
- README + CLAUDE.md: Auto-Update-Doku, Repo muss public bleiben
- web-editor/ (Node/Docker WYSIWYG-Editor) hinzugefügt
- gitea-sync.js + templates_options: bestehende Anpassungen

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:12:33 +02:00

982 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// 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 = '<span class="toast-icon">' + icon + '</span><span class="toast-msg"></span>';
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<boolean> ──
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)';
// <email>.<slug>.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 = '<span class="ti-icon">' + icon + '</span><span class="ti-label"></span>';
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 = '<span class="g-caret">▾</span><span class="group-icon">' + icon + '</span><span class="g-label"></span>';
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 = '<span class="g-caret">▾</span><span class="group-icon">👤</span><span class="g-label">Persönlich</span>';
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/<email>
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 = '<!DOCTYPE html><html lang="de"><head><meta charset="utf-8">' +
'<style>html,body{margin:0;padding:0;}' +
'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;' +
'color:#1f2a30;line-height:1.5;background:#eef1f4;padding:18px;}' +
'.email-card{max-width:640px;margin:0 auto;background:#fff;padding:24px 28px;' +
'border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.12);}' +
'img{max-width:100%;height:auto;}</style></head>' +
'<body><div class="email-card">' + html + '</div></body></html>';
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: <span class="preview-name">' + esc(slug) + '.html</span>' : '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: <span class="preview-name">' + esc(file) + '</span>' : '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);
})();