- 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>
727 lines
48 KiB
JavaScript
727 lines
48 KiB
JavaScript
/* 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||
function slugifyName(name) {
|
||
return name.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss')
|
||
.replace(/[/\\:*?"<>|]/g, '-').replace(/^[\s.-]+|[\s.-]+$/g, '').trim();
|
||
}
|
||
function slugifyHeaderName(name) { return slugifyName(name).toLowerCase().replace(/\s+/g, '-'); }
|
||
|
||
// ── Toasts ──
|
||
function toast(message, type) {
|
||
const t = document.createElement('div');
|
||
t.className = 'toast' + (type ? ' toast-' + type : '');
|
||
const ic = type === 'success' ? '✓' : type === 'error' ? '⚠' : 'ℹ';
|
||
t.innerHTML = '<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 & 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);
|
||
})();
|