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>
This commit is contained in:
Kendrick Bollens
2026-06-18 00:12:33 +02:00
parent edb979a1b2
commit eff90e9517
23 changed files with 3437 additions and 41 deletions

981
web-editor/public/app.js Normal file
View File

@@ -0,0 +1,981 @@
/* 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);
})();