Files
hps-thunderbird-templates/web-editor/public/app.js
Kendrick Bollens 113bc1bc20 web-editor: TinyMCE-Editor, Verwaltung, schnelles Tree-Laden, CI-Design
- TinyMCE (selbst gehostet) mit Base64-Bildeinbettung statt contenteditable
- Kategorie-Tabs Vorlagen/Fußzeilen/Signaturen + Verwaltung
  (Übersicht, Abteilungen, E-Mail-Zuordnung, Schlagwörter)
- /api/tree über rekursive git/trees-API (1 statt ~17 Anfragen)
- Plus-Jakarta-Sans-Font, SVG-Icons, farbige Abteilungs-Badges
- Platzhalter-Hinweis (nur in Signatur-Vorlage _vorlage.html)
- LOCAL- und DEMO-Modus im Server

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

727 lines
48 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 + 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': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
'panel-bottom': '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 15h18"/>',
'pen-line': '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'globe': '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
'building': '<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M12 14h.01M16 10h.01M16 14h.01M8 10h.01M8 14h.01"/>',
'at-sign': '<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/>',
'plus': '<path d="M5 12h14"/><path d="M12 5v14"/>',
'refresh': '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
'reload': '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>',
'save': '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
'trash': '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
'search': '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
'dashboard': '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
'tag': '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
'link2': '<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
'chevron': '<path d="m6 9 6 6 6-6"/>',
'plug': '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
};
function icon(name, size) {
const s = size || 18;
return '<svg viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + (ICONS[name] || '') + '</svg>';
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
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 = '<span class="toast-icon">' + ic + '</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 }); }, 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 = '<span class="ti-icon">' + icon(ic, 16) + '</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;
}
// 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 =
'<span class="g-caret">' + icon('chevron', 14) + '</span>' +
'<span class="g-badge" style="background:' + badge.bg + ';color:' + badge.fg + fs + '">' + badgeInner + '</span>' +
'<span class="g-label"></span>' +
'<span class="g-count">' + files.length + '</span>' +
(onAdd ? '<button class="g-add" title="Neue Vorlage">' + icon('plus', 15) + '</button>' : '');
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 = '<span class="ni-icon">' + icon(iconName, 17) + '</span><span>' + esc(label) + '</span>';
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 =
'<span class="ph-lead">' + icon('tag', 15) + ' Platzhalter klick zum Einfügen:</span>' +
'<span class="ph-chips">' + PLACEHOLDERS.map((p) => '<button class="ph-chip" data-token="' + esc(p.t) + '" title="wird zu: ' + esc(p.d) + '">' + esc(p.t) + '</button>').join('') + '</span>' +
'<button class="ph-more" id="ph-more">Was ist das?</button>';
details.innerHTML =
'<p>Dies ist die zentrale <strong>Signatur-Vorlage</strong>. Klickt ein Mitarbeiter im Thunderbird-Plugin (Tab „Signaturen") auf <strong>„Vorlage laden"</strong>, ersetzt das Plugin diese Platzhalter <strong>einmalig</strong> durch seine eigenen Daten:</p>' +
'<table class="ph-table"><tbody>' + PLACEHOLDERS.map((p) =>
'<tr><td><code>' + esc(p.t) + '</code></td><td>→ ' + (p.link ? '<a href="#" id="ph-link">' + esc(p.d) + '</a>' : esc(p.d)) + '</td><td class="ph-src">' + esc(p.s) + '</td></tr>').join('') + '</tbody></table>' +
'<p class="ph-foot">Telefon &amp; Fax sind <strong>fest im Plugin-Code</strong> hinterlegt zum Ändern muss das Plugin angepasst werden, nicht diese Oberfläche. In normalen Vorlagen, Fußzeilen und bereits gespeicherten Signaturen werden Platzhalter <strong>nicht</strong> ersetzt.</p>';
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 = '<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><style>html,body{margin:0;padding:0;}' +
'body{font-family:Arial,Helvetica,sans-serif;color:#1f2a30;line-height:1.5;background:#e7ebee;padding:22px;}' +
'.email-card{max-width:640px;margin:0 auto;background:#fff;padding:26px 30px;border-radius:10px;box-shadow:0 1px 5px rgba(0,0,0,.12);}' +
'img{max-width:100%;height:auto;}table{border-collapse:collapse;}</style></head><body><div class="email-card">' + html + '</div></body></html>';
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: <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 (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: <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 (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 '<div class="admin-head"><h2>' + esc(title) + '</h2>' + (subtitle ? '<p>' + esc(subtitle) + '</p>' : '') + '</div>';
}
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.') +
'<div class="stat-grid">' + cards.map((k) =>
'<div class="stat-card"><span class="stat-ic">' + icon(k.icon, 22) + '</span><div class="stat-n">' + k.n + '</div><div class="stat-l">' + k.label + '</div></div>').join('') +
'</div>' +
'<div class="info-card"><div class="info-row"><span class="info-ic">' + icon('plug', 18) + '</span><div><div class="info-k">Verbindung</div>' +
'<div class="info-v">' + esc(mode) + ' · ' + esc((c.owner || '?') + '/' + (c.repo || '?') + '@' + (c.branch || 'main')) + '</div></div></div></div>';
}
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 += '<div class="adm-add"><input id="adm-dept-name" class="inp" placeholder="Neue Abteilung z. B. Spa" /><button id="adm-dept-add" class="btn btn-primary"><span class="ic">' + icon('plus', 16) + '</span><span>Anlegen</span></button></div>';
html += '<div class="adm-list">';
if (!depts.length) html += '<div class="tree-empty">Noch keine Abteilungen.</div>';
depts.forEach((d) => {
const count = (t.templates[d] || []).length;
html += '<div class="adm-row"><span class="adm-ic">' + icon('building', 18) + '</span><span class="adm-name">' + esc(d) + '</span>' +
'<span class="adm-meta">' + count + ' Vorlage' + (count === 1 ? '' : 'n') + '</span>' +
'<button class="icon-btn danger" data-del="' + esc(d) + '" title="Abteilung löschen">' + icon('trash', 16) + '</button></div>';
});
html += '</div>';
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)') + '<div class="tree-empty">Lädt…</div>';
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 '<select class="inp dept-sel">' + ['<option value="">— wählen —</option>'].concat(depts.map((d) => '<option' + (d === val ? ' selected' : '') + '>' + esc(d) + '</option>')).join('') +
(val && !depts.includes(val) ? '<option selected>' + esc(val) + '</option>' : '') + '</select>';
}
function rowHtml(r) {
return '<div class="map-row"><input class="inp map-email" value="' + esc(r.email) + '" placeholder="name@hotel-park-soltau.de" />' +
deptSelect(r.dept) + '<button class="icon-btn danger map-del" title="Zeile entfernen">' + icon('trash', 16) + '</button></div>';
}
el.adminPanel.innerHTML = adminHeader('E-Mail-Zuordnung', 'Welche Absender-Adresse gehört zu welcher Abteilung? (Datei _config/abteilungen.json)') +
'<div class="map-head"><span>E-Mail-Adresse</span><span>Abteilung</span><span></span></div>' +
'<div id="map-rows" class="map-rows">' + (rows.length ? rows.map(rowHtml).join('') : '') + '</div>' +
'<div class="adm-actions"><button id="map-addrow" class="btn btn-ghost"><span class="ic">' + icon('plus', 16) + '</span><span>Zeile hinzufügen</span></button>' +
'<button id="map-save" class="btn btn-primary"><span class="ic">' + icon('save', 16) + '</span><span>Speichern</span></button></div>';
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).') + '<div class="tree-empty">Lädt…</div>';
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 '<div class="tag-row"><span class="tag-swatch" style="background:' + esc(color) + '"></span>' +
'<input class="inp tag-name" value="' + esc(name) + '" placeholder="Name / Schlagwort" />' +
'<input type="color" class="tag-color" value="' + esc(color) + '" />' +
'<button class="icon-btn danger tag-del" title="Entfernen">' + icon('trash', 16) + '</button></div>';
}
el.adminPanel.innerHTML = adminHeader('Schlagwörter', 'Farbige Tags für Thunderbird (Datei _config/schlagwoerter.json).') +
'<div id="tag-rows" class="tag-rows">' + (tags.length ? tags.map(rowHtml).join('') : '') + '</div>' +
'<div class="adm-actions"><button id="tag-addrow" class="btn btn-ghost"><span class="ic">' + icon('plus', 16) + '</span><span>Schlagwort hinzufügen</span></button>' +
'<button id="tag-save" class="btn btn-primary"><span class="ic">' + icon('save', 16) + '</span><span>Speichern</span></button></div>';
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);
})();