diff --git a/web-editor/public/app.js b/web-editor/public/app.js index 3099e4c..4d7b75e 100644 --- a/web-editor/public/app.js +++ b/web-editor/public/app.js @@ -1,147 +1,110 @@ /* HPS Vorlagen & Signaturen — Web-Editor Frontend - * Vanilla JS, keine Abhängigkeiten. Spricht ausschließlich mit dem - * gleichnamigen Express-Backend (same origin) über die /api-Endpunkte. + * Vanilla JS + TinyMCE (selbst gehostet). Spricht ausschließlich mit dem + * Express-Backend (same origin) über die /api-Endpunkte. */ (function () { 'use strict'; - // ── Konstanten (müssen mit dem Backend übereinstimmen) ── + // ── Backend-Konstanten ── const SHARED_FOLDER = '_gemeinsam'; const USER_FOLDER = '_benutzer'; const SIG_FOOTERS = 'signatures/footers'; const SIG_HEADERS = 'signatures/headers'; - // ── Globaler Zustand ── - const state = { - config: null, // /api/config - tree: null, // /api/tree - current: null, // { path, friendly, sha, exists, isNew, category } - dirty: false, - view: 'visual', // 'visual' | 'html' - collapsed: { templates: false, footers: false, headers: false }, - groupsCollapsed: {}, // key -> bool (Vorlagen-Untergruppen) - pendingNetwork: 0, + // ── Icon-Set (Lucide-Stil, currentColor) ── + const ICONS = { + 'file-text': '', + 'panel-bottom': '', + 'pen-line': '', + 'settings': '', + 'globe': '', + 'building': '', + 'at-sign': '', + 'plus': '', + 'refresh': '', + 'reload': '', + 'save': '', + 'trash': '', + 'search': '', + 'dashboard': '', + 'tag': '', + 'link2': '', + 'chevron': '', + 'plug': '', }; + function icon(name, size) { + const s = size || 18; + return '' + (ICONS[name] || '') + ''; + } + function hydrateIcons(root) { + (root || document).querySelectorAll('[data-icon]').forEach((e) => { e.innerHTML = icon(e.dataset.icon); }); + } - // ── DOM-Referenzen ── + // ── 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'), - treeTemplates:$('tree-templates'), - treeFooters: $('tree-footers'), - treeHeaders: $('tree-headers'), - emptyState: $('empty-state'), - editorPanel: $('editor-panel'), - fileFriendly: $('file-friendly'), - filePath: $('file-path'), - dirtyBadge: $('dirty-badge'), - btnSave: $('btn-save'), - btnReload: $('btn-reload'), - btnDelete: $('btn-delete'), - btnRefresh: $('btn-refresh-tree'), - treeSearch: $('tree-search'), - btnNewDept: $('btn-new-department'), - btnNewFooter: $('btn-new-footer'), - btnNewHeader: $('btn-new-header'), - tabVisual: $('tab-visual'), - tabHtml: $('tab-html'), - formatToolbar:$('format-toolbar'), - visualEditor: $('visual-editor'), - htmlEditor: $('html-editor'), - visualWrap: $('visual-wrap'), - htmlWrap: $('html-wrap'), - previewFrame: $('preview-frame'), - toastStack: $('toast-stack'), - loading: $('loading-overlay'), - fmtColor: $('fmt-color'), - fmtColorSwatch: $('fmt-color-swatch'), - fmtFontSize: $('fmt-fontsize'), - fmtLink: $('fmt-link'), - fmtImage: $('fmt-image'), - // Modals - confirmBackdrop: $('confirm-backdrop'), - confirmTitle: $('confirm-title'), - confirmMessage: $('confirm-message'), - confirmOk: $('confirm-ok'), - confirmCancel:$('confirm-cancel'), - promptBackdrop: $('prompt-backdrop'), - promptForm: $('prompt-form'), - promptTitle: $('prompt-title'), - promptFields: $('prompt-fields'), - promptOk: $('prompt-ok'), - promptCancel: $('prompt-cancel'), + 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'), }; - // ──────────────────────────────────────────────────────────── - // Hilfsfunktionen - // ──────────────────────────────────────────────────────────── - - // HTML-escapen für sichere Anzeige in Attributen/Text. - function esc(s) { - return String(s) - .replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, '''); - } - - // Dateiname aus Vorlagennamen ableiten (exakt laut Vorgabe), .html ergänzt der Aufrufer. + // ── Helfer ── + function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function slugifyName(name) { - return name - .replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss') - .replace(/[/\\:*?"<>|]/g, '-') - .replace(/^[\s.-]+|[\s.-]+$/g, '') - .trim(); - } - - // Für Signatur-Köpfe: zusätzlich klein + Leerzeichen → '-'. - function slugifyHeaderName(name) { - return slugifyName(name).toLowerCase().replace(/\s+/g, '-'); - } - - // Debounce-Helfer. - function debounce(fn, ms) { - let t; - return function (...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), ms); }; + 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 icon = type === 'success' ? '✓' : type === 'error' ? '⚠' : 'ℹ'; - t.innerHTML = '' + icon + ''; + const ic = type === 'success' ? '✓' : type === 'error' ? '⚠' : 'ℹ'; + t.innerHTML = '' + ic + ''; t.querySelector('.toast-msg').textContent = message; el.toastStack.appendChild(t); - setTimeout(() => { - t.classList.add('fade-out'); - t.addEventListener('animationend', () => t.remove(), { once: true }); - }, 4000); + setTimeout(() => { t.classList.add('fade-out'); t.addEventListener('animationend', () => t.remove(), { once: true }); }, 4200); } - // ── Lade-Overlay (zählt verschachtelte Netzwerkaufrufe) ── - function startLoading() { state.pendingNetwork++; el.loading.hidden = false; } - function stopLoading() { state.pendingNetwork = Math.max(0, state.pendingNetwork - 1); if (state.pendingNetwork === 0) el.loading.hidden = true; } - - // ── Zentraler fetch-Wrapper: JSON, Fehler→toast, Overlay ── + // ── 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(); + let data = null; const text = await res.text(); if (text) { try { data = JSON.parse(text); } catch (_) { data = { error: text }; } } - if (!res.ok) { - const msg = (data && data.error) ? data.error : ('HTTP ' + res.status + ' ' + res.statusText); - throw new Error(msg); - } + if (!res.ok) throw new Error((data && data.error) ? data.error : ('HTTP ' + res.status + ' ' + res.statusText)); return data || {}; - } finally { - stopLoading(); - } + } finally { stopLoading(); } } - // ── Custom confirm-Modal → Promise ── + // ── Modals ── function confirmModal(message, opts) { opts = opts || {}; el.confirmTitle.textContent = opts.title || 'Bestätigen'; @@ -150,832 +113,614 @@ el.confirmOk.className = 'btn ' + (opts.danger ? 'btn-danger' : 'btn-primary'); el.confirmBackdrop.hidden = false; return new Promise((resolve) => { - function cleanup(result) { + function cleanup(r) { el.confirmBackdrop.hidden = true; - el.confirmOk.removeEventListener('click', onOk); - el.confirmCancel.removeEventListener('click', onCancel); - el.confirmBackdrop.removeEventListener('click', onBackdrop); - resolve(result); + el.confirmOk.removeEventListener('click', onOk); el.confirmCancel.removeEventListener('click', onCancel); el.confirmBackdrop.removeEventListener('click', onBackdrop); + resolve(r); } - const onOk = () => cleanup(true); - const onCancel = () => cleanup(false); + 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); + el.confirmOk.addEventListener('click', onOk); el.confirmCancel.addEventListener('click', onCancel); el.confirmBackdrop.addEventListener('click', onBackdrop); }); } - - // ── Custom prompt-Modal mit beliebigen Feldern → Promise<{}|null> ── - // fields: [{ key, label, type='text', placeholder, value, options:[{value,label}], required, hint }] - // onChange(values, fieldEls) optional für Live-Vorschau. function promptModal(title, fields, onChange) { - el.promptTitle.textContent = title; - el.promptFields.innerHTML = ''; - const inputs = {}; - + 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); - + 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); - }); + (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 = document.createElement('input'); input.type = f.type || 'text'; + if (f.placeholder) input.placeholder = f.placeholder; if (f.value != null) input.value = f.value; } - input.dataset.key = f.key; - wrap.appendChild(input); - - if (f.hint || f.live) { - const hint = document.createElement('div'); - hint.className = 'hint'; - if (f.live) hint.dataset.live = f.key; - if (f.hint) hint.textContent = f.hint; - wrap.appendChild(hint); - } - el.promptFields.appendChild(wrap); - inputs[f.key] = input; + 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; - }; - + 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); - + const first = el.promptFields.querySelector('input, select'); if (first) setTimeout(() => first.focus(), 30); return new Promise((resolve) => { - function cleanup(result) { + 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(result); + 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(); - // Pflichtfelder prüfen. - for (const f of fields) { - if (f.required && !String(values[f.key] || '').trim()) { - toast('Bitte „' + f.label + '“ ausfüllen.', 'error'); - inputs[f.key].focus(); - return; - } - } + 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); // initial + 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 (friendly labels) - // ──────────────────────────────────────────────────────────── - - function footerLabel(name) { - if (name === '_default.html') return 'Gemeinsam (alle Abteilungen)'; - return name.replace(/\.html$/i, ''); - } - + // ── 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)'; - // ..html → "email — slug" - // E-Mail enthält genau ein '@'; alles bis zum ersten '.' NACH dem '@' ist die E-Mail. - const base = name.replace(/\.html$/i, ''); - const at = base.indexOf('@'); - if (at >= 0) { - const firstDotAfterAt = base.indexOf('.', at); - if (firstDotAfterAt > -1 && firstDotAfterAt < base.length - 1) { - const email = base.slice(0, firstDotAfterAt); - const slug = base.slice(firstDotAfterAt + 1); - return email + ' — ' + slug; - } - } - return base; // Fallback + 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, ''); - } - - // Liefert eine friendly-Bezeichnung anhand Kategorie + Dateiname. + 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 - // ──────────────────────────────────────────────────────────── - + // ── Verbindungsstatus ── async function loadConfigAndHealth() { - // Config (best effort, ohne Overlay-Spam → eigener leichter Aufruf) try { - const cfg = await api('/api/config'); - state.config = cfg; - if (!cfg.configured) { - el.configBanner.hidden = false; - setStatus('error', 'Nicht konfiguriert'); - return false; - } + const cfg = await api('/api/config'); state.config = cfg; + if (!cfg.configured) { el.configBanner.hidden = false; setStatus('error', 'Nicht konfiguriert'); return false; } el.configBanner.hidden = true; - } catch (e) { - setStatus('error', 'Nicht verbunden'); - toast('Konfiguration konnte nicht geladen werden: ' + e.message, 'error'); - return false; - } - // Health + } 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', 'Verbunden: ' + c.owner + '/' + c.repo + '@' + c.branch); - } else { - setStatus('error', 'Nicht verbunden: ' + (health.error || 'unbekannt')); - } - } catch (e) { - setStatus('error', 'Nicht verbunden: ' + e.message); - toast('Verbindung zum Repository fehlgeschlagen: ' + e.message, 'error'); - return false; - } + 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; + el.statusText.textContent = text; el.statusPill.title = text; } - // ──────────────────────────────────────────────────────────── - // Baum (Sidebar) laden & rendern - // ──────────────────────────────────────────────────────────── - - async function loadTree() { - try { - state.tree = await api('/api/tree'); - renderTree(); - } catch (e) { - toast('Liste konnte nicht geladen werden: ' + e.message, 'error'); - } + // ── Hauptbereich umschalten ── + function showMain(kind) { + el.emptyState.hidden = kind !== 'empty'; + el.editorPanel.hidden = kind !== 'editor'; + el.adminPanel.hidden = kind !== 'admin'; } - function renderTree() { - renderTemplates(); - renderFooters(); - renderHeaders(); - applySectionCollapse(); - highlightActive(); - // Aktiven Filter nach Neuaufbau erneut anwenden. - if (el.treeSearch && el.treeSearch.value.trim()) applyTreeFilter(); + // ── 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'); } - // Live-Filter über alle Dateien (Label + Pfad). Leere Suche = voller Baum. - function applyTreeFilter() { - const q = (el.treeSearch.value || '').trim().toLowerCase(); - if (!q) { renderTree(); return; } // pristinen Baum (inkl. Klappzustand) wiederherstellen + // ── Tree laden ── + async function loadTree() { try { state.tree = await api('/api/tree'); renderList(); } catch (e) { toast('Liste nicht ladbar: ' + e.message, 'error'); } } - document.querySelectorAll('.tree-section').forEach((s) => s.classList.remove('collapsed')); - document.querySelectorAll('.sidebar .group-files').forEach((b) => b.classList.remove('collapsed')); - document.querySelectorAll('.sidebar .group-title').forEach((t) => t.classList.remove('collapsed')); - document.querySelectorAll('.sidebar .add-item, .sidebar .tree-empty').forEach((n) => { n.style.display = 'none'; }); - - document.querySelectorAll('.sidebar .tree-item').forEach((it) => { - const label = (it.querySelector('.ti-label')?.textContent || '').toLowerCase(); - const path = (it.dataset.path || '').toLowerCase(); - it.style.display = (label.includes(q) || path.includes(q)) ? '' : 'none'; - }); - - // Gruppen ohne sichtbaren Treffer ausblenden. - document.querySelectorAll('.sidebar .tree-group').forEach((g) => { - const items = g.querySelectorAll('.tree-item'); - const anyVisible = Array.from(items).some((i) => i.style.display !== 'none'); - g.style.display = (items.length && !anyVisible) ? 'none' : ''; - }); - } - - // Datei-Item-Element bauen. + // ── Listen-Spalte ── function fileItem(file, category) { const div = document.createElement('div'); - div.className = 'tree-item'; - div.dataset.path = file.path; - div.dataset.sha = file.sha || ''; - div.dataset.category = category; - div.dataset.name = file.name; - const label = category === 'footer' ? footerLabel(file.name) - : category === 'header' ? headerLabel(file.name) - : templateLabel(file.name); - const icon = category === 'footer' ? '📜' : category === 'header' ? '✍️' : '📄'; - div.innerHTML = '' + icon + ''; - div.querySelector('.ti-label').textContent = label; - div.title = file.path; + div.className = 'tree-item'; div.dataset.path = file.path; + const label = category === 'footer' ? footerLabel(file.name) : category === 'header' ? headerLabel(file.name) : templateLabel(file.name); + const ic = category === 'footer' ? 'panel-bottom' : category === 'header' ? 'pen-line' : 'file-text'; + div.innerHTML = '' + icon(ic, 16) + ''; + div.querySelector('.ti-label').textContent = label; div.title = file.path; div.addEventListener('click', () => openFile(file.path, { friendly: friendlyFor(category, file.name), sha: file.sha, category, exists: true })); return div; } + // Gedämpfte, aber unterscheidbare Farbpalette für Abteilungs-Badges. + const DEPT_PALETTE = [ + { fg: '#647219', bg: '#eef2da' }, { fg: '#2f7d83', bg: '#dff0f0' }, + { fg: '#b5683f', bg: '#f7e8df' }, { fg: '#6c5a90', bg: '#ece7f3' }, + { fg: '#4a6488', bg: '#e5ecf5' }, { fg: '#9a7d1e', bg: '#f5efd6' }, + { fg: '#a8527a', bg: '#f6e5ee' }, { fg: '#3f7d5a', bg: '#e2f0e8' }, + ]; + // Kürzeste eindeutige Abkürzung je Abteilung (min. 2 Zeichen): + // Rezeption/Restaurant → REZ/RES, Buchhaltung → BU, IT → IT. + function computeAbbrevs(names) { + const clean = (n) => (n.replace(/[^a-z0-9äöüß]/gi, '') || n); + const cleaned = names.map(clean); + const map = {}; + names.forEach((n, i) => { + const cn = cleaned[i]; + const maxLen = Math.min(3, cn.length); // Badge bleibt kurz; Rest unterscheidet die Farbe + let len = Math.min(2, cn.length); + while (len < maxLen) { + const pre = cn.slice(0, len).toLowerCase(); + const collide = cleaned.some((o, j) => j !== i && o.slice(0, len).toLowerCase() === pre); + if (!collide) break; + len++; + } + map[n] = cn.slice(0, len).toUpperCase(); + }); + return map; + } + function deptBadge(name, label) { + const i = Math.abs([...name].reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0)) % DEPT_PALETTE.length; + return { type: 'initial', value: label || (name.trim()[0] || '?').toUpperCase(), fg: DEPT_PALETTE[i].fg, bg: DEPT_PALETTE[i].bg }; + } + function neutralBadge(iconName) { return { type: 'icon', value: iconName, fg: 'var(--muted)', bg: 'var(--bg)' }; } - // „+ Neue …“-Button. - function addButton(text, onClick) { + function makeGroup(key, title, badge, files, onAdd) { + const group = document.createElement('div'); group.className = 'tree-group'; + const collapsed = !!state.groupsCollapsed[key]; + const head = document.createElement('div'); head.className = 'group-head' + (collapsed ? ' collapsed' : ''); + const badgeInner = badge.type === 'icon' ? icon(badge.value, 15) : esc(badge.value); + const fs = badge.type === 'icon' ? '' : (badge.value.length >= 3 ? ';font-size:9.5px' : badge.value.length === 2 ? ';font-size:11px' : ''); + head.innerHTML = + '' + icon('chevron', 14) + '' + + '' + badgeInner + '' + + '' + + '' + files.length + '' + + (onAdd ? '' : ''); + head.querySelector('.g-label').textContent = title; + const body = document.createElement('div'); body.className = 'group-files' + (collapsed ? ' collapsed' : ''); + if (files.length === 0) { const e = document.createElement('div'); e.className = 'tree-empty'; e.textContent = 'Noch leer'; body.appendChild(e); } + else files.forEach((f) => body.appendChild(fileItem(f, 'template'))); + head.addEventListener('click', (ev) => { + if (ev.target.closest('.g-add')) return; + state.groupsCollapsed[key] = !state.groupsCollapsed[key]; + head.classList.toggle('collapsed'); body.classList.toggle('collapsed'); + }); + if (onAdd) head.querySelector('.g-add').addEventListener('click', (ev) => { ev.stopPropagation(); onAdd(); }); + group.appendChild(head); group.appendChild(body); return group; + } + + function adminNavItem(view, label, iconName) { const b = document.createElement('button'); - b.className = 'add-item'; - b.textContent = text; - b.addEventListener('click', (e) => { e.stopPropagation(); onClick(); }); + b.className = 'nav-item' + (state.adminView === view ? ' is-active' : ''); + b.innerHTML = '' + icon(iconName, 17) + '' + esc(label) + ''; + b.addEventListener('click', () => setAdminView(view)); return b; } - // Eine ein-/ausklappbare Gruppe (für Vorlagen). - function makeGroup(key, title, icon, files, category, onAdd, isSub) { - const group = document.createElement('div'); - group.className = 'tree-group' + (isSub ? ' subgroup' : ''); - - const head = document.createElement('div'); - head.className = 'group-head'; - - const collapsed = !!state.groupsCollapsed[key]; - const toggle = document.createElement('button'); - toggle.className = 'group-title' + (collapsed ? ' collapsed' : ''); - toggle.innerHTML = '' + icon + ''; - toggle.querySelector('.g-label').textContent = title; - head.appendChild(toggle); - group.appendChild(head); - - const body = document.createElement('div'); - body.className = 'group-files' + (collapsed ? ' collapsed' : ''); - if (files.length === 0) { - const empty = document.createElement('div'); - empty.className = 'tree-empty'; - empty.textContent = 'Keine Dateien'; - body.appendChild(empty); - } else { - files.forEach((f) => body.appendChild(fileItem(f, category))); + 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 (onAdd) body.appendChild(addButton('+ Neue Vorlage', onAdd)); - group.appendChild(body); - - toggle.addEventListener('click', () => { - state.groupsCollapsed[key] = !state.groupsCollapsed[key]; - toggle.classList.toggle('collapsed'); - body.classList.toggle('collapsed'); - }); - return group; - } - - function renderTemplates() { - const c = el.treeTemplates; - c.innerHTML = ''; - const t = state.tree; if (!t) return; + 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; } - // _gemeinsam - c.appendChild(makeGroup( - 'tmpl:' + SHARED_FOLDER, 'Alle Abteilungen (_gemeinsam)', '🌐', - t.templates[SHARED_FOLDER] || [], 'template', - () => newTemplate(SHARED_FOLDER) - )); - - // Abteilungen - (t.departments || []).forEach((dept) => { - c.appendChild(makeGroup( - 'tmpl:' + dept, dept, '🏢', - t.templates[dept] || [], 'template', - () => newTemplate(dept) - )); + 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'; }); - - // Persönlich → übergeordnete Gruppe mit Untergruppen je Benutzer - const users = t.users || {}; - const userKeys = Object.keys(users).sort((a, b) => a.localeCompare(b, 'de')); - const persGroup = document.createElement('div'); - persGroup.className = 'tree-group'; - const persKey = 'tmpl:__pers'; - const persCollapsed = !!state.groupsCollapsed[persKey]; - const persToggle = document.createElement('button'); - persToggle.className = 'group-title' + (persCollapsed ? ' collapsed' : ''); - persToggle.innerHTML = '👤Persönlich'; - const persHead = document.createElement('div'); - persHead.className = 'group-head'; - persHead.appendChild(persToggle); - persGroup.appendChild(persHead); - const persBody = document.createElement('div'); - persBody.className = 'group-files' + (persCollapsed ? ' collapsed' : ''); - if (userKeys.length === 0) { - const empty = document.createElement('div'); - empty.className = 'tree-empty'; - empty.textContent = 'Keine persönlichen Ordner'; - persBody.appendChild(empty); - } else { - userKeys.forEach((email) => { - // Persönlicher Ordnerpfad: _benutzer/ - persBody.appendChild(makeGroup( - 'tmpl:user:' + email, email, '✉️', - users[email] || [], 'template', - () => newTemplate(USER_FOLDER + '/' + email), - true - )); - }); - } - persGroup.appendChild(persBody); - persToggle.addEventListener('click', () => { - state.groupsCollapsed[persKey] = !state.groupsCollapsed[persKey]; - persToggle.classList.toggle('collapsed'); - persBody.classList.toggle('collapsed'); - }); - c.appendChild(persGroup); - } - - function renderFooters() { - const c = el.treeFooters; - c.innerHTML = ''; - const footers = (state.tree && state.tree.footers) || []; - if (footers.length === 0) { - const empty = document.createElement('div'); - empty.className = 'tree-empty'; - empty.textContent = 'Keine Fußzeilen'; - c.appendChild(empty); - } else { - footers.forEach((f) => c.appendChild(fileItem(f, 'footer'))); - } - c.appendChild(addButton('+ Neue Fußzeile', newFooter)); - } - - function renderHeaders() { - const c = el.treeHeaders; - c.innerHTML = ''; - const headers = (state.tree && state.tree.headers) || []; - if (headers.length === 0) { - const empty = document.createElement('div'); - empty.className = 'tree-empty'; - empty.textContent = 'Keine Signatur-Köpfe'; - c.appendChild(empty); - } else { - headers.forEach((f) => c.appendChild(fileItem(f, 'header'))); - } - c.appendChild(addButton('+ Neue Signatur', newHeader)); - } - - function applySectionCollapse() { - document.querySelectorAll('.tree-section').forEach((sec) => { - const key = sec.dataset.section; - const toggle = sec.querySelector('.section-toggle'); - const collapsed = !!state.collapsed[key]; - sec.classList.toggle('collapsed', collapsed); - toggle.setAttribute('aria-expanded', String(!collapsed)); + 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((item) => { - item.classList.toggle('is-active', state.current && item.dataset.path === state.current.path); - }); + document.querySelectorAll('.tree-item').forEach((it) => it.classList.toggle('is-active', state.current && it.dataset.path === state.current.path)); } - // ──────────────────────────────────────────────────────────── - // Datei öffnen / laden - // ──────────────────────────────────────────────────────────── + // ── 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; }); + } - async function openFile(path, meta) { - // Ungespeicherte Änderungen? - if (state.dirty) { - const ok = await confirmModal('Es gibt ungespeicherte Änderungen. Trotzdem eine andere Datei öffnen? Die Änderungen gehen verloren.', { title: 'Ungespeicherte Änderungen', okLabel: 'Verwerfen', danger: true }); - if (!ok) return; + // ── Platzhalter (nur sinnvoll in der Signatur-Vorlage _vorlage.html) ── + const VORLAGE_PATH = SIG_HEADERS + '/_vorlage.html'; + const PLACEHOLDERS = [ + { t: '{{NAME}}', d: 'Name des Mitarbeiters', s: 'aus dem Feld „Name" in den Plugin-Einstellungen' }, + { t: '{{EMAIL}}', d: 'E-Mail-Adresse', s: 'aus der gewählten Thunderbird-Identität' }, + { t: '{{ABTEILUNG}}', d: 'Abteilung', s: 'automatisch erkannt über die E-Mail-Zuordnung', link: true }, + { t: '{{TELEFON}}', d: '+49 (0) 5191 - 605-0', s: 'fest im Plugin-Code hinterlegt' }, + { t: '{{FAX}}', d: '+49 (0) 5191 - 605-185', s: 'fest im Plugin-Code hinterlegt' }, + ]; + function buildPlaceholderBar() { + const bar = $('ph-bar'), details = $('ph-details'); + bar.innerHTML = + '' + icon('tag', 15) + ' Platzhalter – klick zum Einfügen:' + + '' + PLACEHOLDERS.map((p) => '').join('') + '' + + ''; + details.innerHTML = + '

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

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

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

'; + + bar.querySelectorAll('.ph-chip').forEach((b) => b.addEventListener('click', () => insertPlaceholder(b.dataset.token))); + $('ph-more').addEventListener('click', () => { details.hidden = !details.hidden; $('ph-more').classList.toggle('is-open', !details.hidden); }); + const link = $('ph-link'); + if (link) link.addEventListener('click', (e) => { e.preventDefault(); setCategory('admin'); setAdminView('mapping'); }); + } + // Leiste nur in der Signatur-Vorlage zeigen – sonst sind Platzhalter wirkungslos. + function updatePlaceholderBar() { + const show = !!state.current && state.current.path === VORLAGE_PATH; + $('ph-bar').hidden = !show; + if (!show) { $('ph-details').hidden = true; const m = $('ph-more'); if (m) m.classList.remove('is-open'); } + } + function insertPlaceholder(token) { + if (!state.current) return; + if (state.view === 'preview') setView('visual'); + if (state.view === 'visual' && edReady && ed) { ed.insertContent(token); markDirty(); } + else if (state.view === 'html') { + const ta = el.htmlEditor, s = ta.selectionStart, e = ta.selectionEnd; + ta.value = ta.value.slice(0, s) + token + ta.value.slice(e); + ta.selectionStart = ta.selectionEnd = s + token.length; ta.focus(); markDirty(); } + } + + // ── Datei öffnen ── + async function openFile(path, meta) { + if (!(await guardUnsaved())) return; try { const data = await api('/api/file?path=' + encodeURIComponent(path)); - state.current = { - path: data.path, - friendly: meta.friendly, - sha: data.sha, - exists: data.exists, - isNew: false, - category: meta.category, - }; - setEditorContent(data.content || ''); - setDirty(false); - showEditor(); - highlightActive(); - } catch (e) { - toast('Datei konnte nicht geladen werden: ' + e.message, 'error'); - } + 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'); } } - - // Neue (noch nicht gespeicherte) Datei direkt im Editor öffnen. - function openNewFile(path, friendly, category) { + async function openNewFile(path, friendly, category) { + if (!(await guardUnsaved())) return; state.current = { path, friendly, sha: null, exists: false, isNew: true, category }; - setEditorContent(''); - setDirty(true); // neu = ungespeichert - showEditor(); - highlightActive(); - el.visualEditor.focus(); - toast('Neue Datei „' + friendly + '“ – jetzt bearbeiten und speichern.', 'success'); + await showEditor(''); setDirty(true); highlightActive(); + toast('Neuer Eintrag „' + friendly + '“ – jetzt bearbeiten und speichern.', 'success'); + } + async function guardUnsaved() { + if (!state.dirty) return true; + return await confirmModal('Es gibt ungespeicherte Änderungen. Trotzdem fortfahren? Die Änderungen gehen verloren.', { title: 'Ungespeicherte Änderungen', okLabel: 'Verwerfen', danger: true }); + } + async function showEditor(html) { + state.html = html; showMain('editor'); + el.fileFriendly.textContent = state.current.friendly; el.filePath.textContent = state.current.path; + updatePlaceholderBar(); + await ensureEditor(); setView('visual', true); + } + function hideEditor() { state.current = null; showMain('empty'); highlightActive(); } + + function syncFromActive() { + if (state.view === 'visual' && edReady && ed) state.html = ed.getContent(); + else if (state.view === 'html') state.html = el.htmlEditor.value; + return state.html; + } + function setView(view, skipSync) { + const html = skipSync ? state.html : syncFromActive(); + state.html = html; state.view = view; + el.paneVisual.hidden = view !== 'visual'; el.paneHtml.hidden = view !== 'html'; el.panePreview.hidden = view !== 'preview'; + el.tabVisual.classList.toggle('is-active', view === 'visual'); + el.tabHtml.classList.toggle('is-active', view === 'html'); + el.tabPreview.classList.toggle('is-active', view === 'preview'); + if (view === 'visual' && edReady && ed) { suppressDirty = true; ed.setContent(html); setTimeout(() => { suppressDirty = false; }, 0); } + else if (view === 'html') el.htmlEditor.value = html; + else if (view === 'preview') renderPreview(html); + } + function renderPreview(html) { + const doc = ''; + el.previewFrame.srcdoc = doc; } - function showEditor() { - el.emptyState.hidden = true; - el.editorPanel.hidden = false; - el.fileFriendly.textContent = state.current.friendly; - el.filePath.textContent = state.current.path; - setView('visual'); - } - - function hideEditor() { - state.current = null; - el.editorPanel.hidden = true; - el.emptyState.hidden = false; - highlightActive(); - } - - // Inhalt in beide Editoren + Vorschau setzen. - function setEditorContent(html) { - el.visualEditor.innerHTML = html; - el.htmlEditor.value = html; - updatePreview(); - } - - // Aktuellen HTML-Inhalt aus dem gerade aktiven View lesen. - function currentHtml() { - return state.view === 'html' ? el.htmlEditor.value : el.visualEditor.innerHTML; - } - - // ──────────────────────────────────────────────────────────── - // Dirty-State - // ──────────────────────────────────────────────────────────── - + // ── Dirty ── function setDirty(d) { - state.dirty = d; - el.dirtyBadge.hidden = !d; - // Speichern aktiv, wenn: keine Datei → aus; neue (ungespeicherte) Datei → immer an; - // bestehende Datei → nur bei ungespeicherten Änderungen. + 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); } - function markDirty() { - if (!state.dirty) setDirty(true); - } - - // ──────────────────────────────────────────────────────────── - // View-Umschaltung (Visuell ↔ HTML) – synchron halten - // ──────────────────────────────────────────────────────────── - - function setView(view) { - if (view === state.view && el.editorPanel.hidden === false) { - // trotzdem Tabs/Anzeige korrekt setzen - } - if (view === 'html') { - // Visuell → HTML: innerHTML in Textarea schreiben - el.htmlEditor.value = el.visualEditor.innerHTML; - el.visualWrap.hidden = true; - el.htmlWrap.hidden = false; - el.formatToolbar.classList.add('disabled'); - } else { - // HTML → Visuell: Textarea-Wert in contenteditable schreiben - el.visualEditor.innerHTML = el.htmlEditor.value; - el.htmlWrap.hidden = true; - el.visualWrap.hidden = false; - el.formatToolbar.classList.remove('disabled'); - } - state.view = view; - el.tabVisual.classList.toggle('is-active', view === 'visual'); - el.tabHtml.classList.toggle('is-active', view === 'html'); - updatePreview(); - } - - // ──────────────────────────────────────────────────────────── - // Vorschau (sandboxed iframe via srcdoc), debounced - // ──────────────────────────────────────────────────────────── - - const updatePreview = debounce(function () { - const html = currentHtml(); - const doc = '' + - '' + - ''; - el.previewFrame.srcdoc = doc; - }, 250); - - // ──────────────────────────────────────────────────────────── - // Formatierungs-Toolbar (document.execCommand) - // ──────────────────────────────────────────────────────────── - - function exec(cmd, value) { - el.visualEditor.focus(); - try { document.execCommand(cmd, false, value); } catch (e) { /* alte Browser */ } - afterVisualEdit(); - } - - function afterVisualEdit() { - el.htmlEditor.value = el.visualEditor.innerHTML; - markDirty(); - updatePreview(); - } - - function bindToolbar() { - el.formatToolbar.querySelectorAll('.fmt-btn[data-cmd]').forEach((btn) => { - btn.addEventListener('mousedown', (e) => e.preventDefault()); // Auswahl im Editor behalten - btn.addEventListener('click', () => exec(btn.dataset.cmd)); - }); - el.fmtFontSize.addEventListener('change', () => { - if (el.fmtFontSize.value) exec('fontSize', el.fmtFontSize.value); - el.fmtFontSize.value = ''; - }); - el.fmtColor.addEventListener('input', () => { - el.fmtColorSwatch.style.background = el.fmtColor.value; - }); - el.fmtColor.addEventListener('change', () => { - exec('foreColor', el.fmtColor.value); - }); - el.fmtLink.addEventListener('mousedown', (e) => e.preventDefault()); - el.fmtLink.addEventListener('click', async () => { - const res = await promptModal('Link einfügen', [ - { key: 'url', label: 'Adresse (URL)', placeholder: 'https://…', required: true, value: 'https://' }, - ]); - if (res) exec('createLink', res.url.trim()); - }); - el.fmtImage.addEventListener('mousedown', (e) => e.preventDefault()); - el.fmtImage.addEventListener('click', async () => { - const res = await promptModal('Bild einfügen', [ - { key: 'url', label: 'Bild-URL', placeholder: 'https://…/bild.png', required: true, value: 'https://' }, - ]); - if (res) exec('insertImage', res.url.trim()); - }); - } - - // ──────────────────────────────────────────────────────────── - // Speichern / Neu laden / Löschen - // ──────────────────────────────────────────────────────────── - + // ── Speichern / Neu laden / Löschen ── async function saveCurrent() { if (!state.current) return; - // Sicherstellen, dass beide Editoren synchron sind (aus aktivem View lesen). - const content = currentHtml(); - const friendly = state.current.friendly; - const message = friendly + ' bearbeitet (Web-Editor)'; + 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: content, message: message }), - }); - state.current.exists = true; - state.current.isNew = false; - if (res.sha) state.current.sha = res.sha; + 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); - if (res.unchanged) toast('Keine Änderungen – nichts zu speichern.', 'success'); - else toast('„' + friendly + '“ gespeichert.', 'success'); - await loadTree(); // neue Dateien auftauchen lassen / SHAs aktualisieren - highlightActive(); - } catch (e) { - toast('Speichern fehlgeschlagen: ' + e.message, 'error'); - } + 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('Diese Datei wurde noch nicht gespeichert.', 'error'); - return; - } - if (state.dirty) { - const ok = await confirmModal('Ungespeicherte Änderungen verwerfen und Datei neu laden?', { title: 'Neu laden', okLabel: 'Verwerfen', danger: true }); - if (!ok) return; - } + 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; - setEditorContent(data.content || ''); - setDirty(false); - setView('visual'); - toast('Datei neu geladen.', 'success'); - } catch (e) { - toast('Neu laden fehlgeschlagen: ' + e.message, 'error'); - } + 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; - // Noch nicht gespeicherte Datei → nur lokal verwerfen. - if (state.current.isNew) { - const ok = await confirmModal('Diese neue, noch nicht gespeicherte Datei verwerfen?', { title: 'Verwerfen', okLabel: 'Verwerfen', danger: true }); - if (!ok) return; - setDirty(false); - hideEditor(); - return; - } - const ok = await confirmModal('„' + friendly + '“ wirklich löschen? Dies kann nicht rückgängig gemacht werden.', { title: 'Löschen', okLabel: 'Löschen', danger: true }); - if (!ok) return; + 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'); - } + await api('/api/file', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.current.path, message: friendly + ' gelöscht (Web-Editor)' }) }); + toast('„' + friendly + '“ gelöscht.', 'success'); setDirty(false); hideEditor(); await loadTree(); + } catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); } } - // ──────────────────────────────────────────────────────────── - // Neue Dateien anlegen - // ──────────────────────────────────────────────────────────── - - async function newTemplate(folder) { - const res = await promptModal('Neue Vorlage in „' + folder + '“', [ - { key: 'name', label: 'Vorlagenname', placeholder: 'z. B. Angebot Doppelzimmer', required: true, live: true, hint: '' }, - ], (values, inputs, root) => { - const slug = slugifyName(values.name || ''); - const liveHint = root.querySelector('[data-live="name"]'); - if (liveHint) liveHint.innerHTML = slug ? 'Datei: ' + esc(slug) + '.html' : 'Bitte einen Namen eingeben.'; - }); - if (!res) return; - const slug = slugifyName(res.name); - if (!slug) { toast('Ungültiger Name.', 'error'); return; } - const path = folder + '/' + slug + '.html'; - if (await existsInTree(path)) { toast('Eine Vorlage mit diesem Namen existiert bereits.', 'error'); return; } - openNewFile(path, slug, 'template'); - } - - async function newFooter() { - const t = state.tree || {}; - const options = [{ value: '_default', label: 'Gemeinsam (alle Abteilungen)' }] - .concat((t.departments || []).map((d) => ({ value: d, label: d }))); - const res = await promptModal('Neue Fußzeile', [ - { key: 'dept', label: 'Für welche Abteilung?', type: 'select', options: options, required: true }, - ]); - if (!res) return; - const file = (res.dept === '_default' ? '_default' : res.dept) + '.html'; - const path = SIG_FOOTERS + '/' + file; - if (await existsInTree(path)) { toast('Diese Fußzeile existiert bereits.', 'error'); return; } - openNewFile(path, 'Fußzeile: ' + footerLabel(file), 'footer'); - } - - async function newHeader() { - const res = await promptModal('Neue Signatur', [ - { key: 'email', label: 'E-Mail-Adresse', placeholder: 'name@hotel-park-soltau.de', required: true, live: true }, - { key: 'name', label: 'Name', placeholder: 'Max Mustermann', required: true, live: true }, - ], (values, inputs, root) => { - const email = (values.email || '').trim(); - const slug = slugifyHeaderName(values.name || ''); - const liveHint = root.querySelector('[data-live="name"]'); - const file = (email && slug) ? (email + '.' + slug + '.html') : ''; - if (liveHint) liveHint.innerHTML = file ? 'Datei: ' + esc(file) + '' : 'E-Mail und Name eingeben.'; - }); - if (!res) return; - const email = res.email.trim(); - const slug = slugifyHeaderName(res.name); - if (!email || !slug) { toast('E-Mail und Name erforderlich.', 'error'); return; } - const file = email + '.' + slug + '.html'; - const path = SIG_HEADERS + '/' + file; - if (await existsInTree(path)) { toast('Diese Signatur existiert bereits.', 'error'); return; } - openNewFile(path, 'Signatur: ' + headerLabel(file), 'header'); - } - - async function newDepartment() { - const res = await promptModal('Neue Abteilung', [ - { key: 'name', label: 'Abteilungsname', placeholder: 'z. B. Rezeption', required: true, hint: 'Wird als Ordner im Repository angelegt.' }, - ]); - if (!res) return; - const name = res.name.trim(); - if (/[\/\\:*?"<>|]/.test(name)) { toast('Ungültiger Abteilungsname.', 'error'); return; } - try { - const r = await api('/api/departments', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: name }), - }); - toast('Abteilung „' + (r.name || name) + '“ angelegt.', 'success'); - await loadTree(); - } catch (e) { - toast('Abteilung anlegen fehlgeschlagen: ' + e.message, 'error'); - } - } - - // Prüfen, ob ein Pfad schon im aktuellen Baum vorkommt. + // ── Neue Einträge ── function existsInTree(path) { - const t = state.tree; - if (!t) return false; - const lists = []; + const t = state.tree; if (!t) return false; const lists = []; Object.keys(t.templates || {}).forEach((k) => lists.push(t.templates[k])); Object.keys(t.users || {}).forEach((k) => lists.push(t.users[k])); lists.push(t.footers || [], t.headers || []); return lists.some((arr) => (arr || []).some((f) => f.path === path)); } + async function newTemplate(folder) { + const res = await promptModal('Neue Vorlage in „' + folder + '“', [{ key: 'name', label: 'Vorlagenname', placeholder: 'z. B. Angebot Doppelzimmer', required: true, live: true }], + (values, inputs, root) => { const slug = slugifyName(values.name || ''); const h = root.querySelector('[data-live="name"]'); if (h) h.innerHTML = slug ? 'Datei: ' + esc(slug) + '.html' : 'Bitte einen Namen eingeben.'; }); + if (!res) return; const slug = slugifyName(res.name); if (!slug) { toast('Ungültiger Name.', 'error'); return; } + const path = folder + '/' + slug + '.html'; if (existsInTree(path)) { toast('Eine Vorlage mit diesem Namen existiert bereits.', 'error'); return; } + openNewFile(path, slug, 'template'); + } + async function newFooter() { + const t = state.tree || {}; + const options = [{ value: '_default', label: 'Gemeinsam (alle Abteilungen)' }].concat((t.departments || []).map((d) => ({ value: d, label: d }))); + const res = await promptModal('Neue Fußzeile', [{ key: 'dept', label: 'Für welche Abteilung?', type: 'select', options, required: true }]); + if (!res) return; const file = (res.dept === '_default' ? '_default' : res.dept) + '.html'; const path = SIG_FOOTERS + '/' + file; + if (existsInTree(path)) { toast('Diese Fußzeile existiert bereits.', 'error'); return; } + openNewFile(path, 'Fußzeile: ' + footerLabel(file), 'footer'); + } + async function newHeader() { + const res = await promptModal('Neue Signatur', [ + { key: 'email', label: 'E-Mail-Adresse', placeholder: 'name@hotel-park-soltau.de', required: true, live: true }, + { key: 'name', label: 'Name', placeholder: 'Max Mustermann', required: true, live: true }, + ], (values, inputs, root) => { const email = (values.email || '').trim(); const slug = slugifyHeaderName(values.name || ''); const h = root.querySelector('[data-live="name"]'); const file = (email && slug) ? (email + '.' + slug + '.html') : ''; if (h) h.innerHTML = file ? 'Datei: ' + esc(file) + '' : 'E-Mail und Name eingeben.'; }); + if (!res) return; const email = res.email.trim(); const slug = slugifyHeaderName(res.name); + if (!email || !slug) { toast('E-Mail und Name erforderlich.', 'error'); return; } + const file = email + '.' + slug + '.html'; const path = SIG_HEADERS + '/' + file; + if (existsInTree(path)) { toast('Diese Signatur existiert bereits.', 'error'); return; } + openNewFile(path, 'Signatur: ' + headerLabel(file), 'header'); + } + async function newDepartment() { + const res = await promptModal('Neue Abteilung', [{ key: 'name', label: 'Abteilungsname', placeholder: 'z. B. Rezeption', required: true, hint: 'Wird als Ordner im Repository angelegt.' }]); + if (!res) return; const name = res.name.trim(); + if (/[\/\\:*?"<>|]/.test(name)) { toast('Ungültiger Abteilungsname.', 'error'); return; } + try { const r = await api('/api/departments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + (r.name || name) + '“ angelegt.', 'success'); await loadTree(); if (state.category === 'admin') setAdminView('departments'); } + catch (e) { toast('Abteilung anlegen fehlgeschlagen: ' + e.message, 'error'); } + } + function listAddAction() { if (state.category === 'templates') newDepartment(); else if (state.category === 'footers') newFooter(); else if (state.category === 'headers') newHeader(); } - // ──────────────────────────────────────────────────────────── - // Event-Bindungen - // ──────────────────────────────────────────────────────────── + // ════════════════════════════════════════════════════════════ + // Verwaltung (Admin) + // ════════════════════════════════════════════════════════════ + function setAdminView(view) { + state.adminView = view; + document.querySelectorAll('.nav-item').forEach((n) => {}); + renderList(); // aktualisiert aktive Markierung in der Nav + showMain('admin'); + if (view === 'overview') renderOverview(); + else if (view === 'departments') renderDepartments(); + else if (view === 'mapping') renderMapping(); + else if (view === 'tags') renderTags(); + } + function adminHeader(title, subtitle) { + return '

' + esc(title) + '

' + (subtitle ? '

' + esc(subtitle) + '

' : '') + '
'; + } - function bindEvents() { - // Sektionen ein-/ausklappen - document.querySelectorAll('.section-toggle').forEach((btn) => { - btn.addEventListener('click', () => { - const key = btn.dataset.toggle; - state.collapsed[key] = !state.collapsed[key]; - applySectionCollapse(); - }); + function renderOverview() { + const t = state.tree || {}; const c = state.config || {}; + const tmpl = Object.values(t.templates || {}).reduce((n, a) => n + a.length, 0) + Object.values(t.users || {}).reduce((n, a) => n + a.length, 0); + const cards = [ + { icon: 'file-text', n: tmpl, label: 'Vorlagen' }, + { icon: 'building', n: (t.departments || []).length, label: 'Abteilungen' }, + { icon: 'panel-bottom', n: (t.footers || []).length, label: 'Fußzeilen' }, + { icon: 'pen-line', n: (t.headers || []).length, label: 'Signaturen' }, + ]; + const mode = c.demo ? 'Demo-Modus' : c.local ? 'Lokaler Ordner' : 'Gitea/Forgejo'; + el.adminPanel.innerHTML = + adminHeader('Übersicht', 'Auf einen Blick: was im Repository liegt und wie der Editor verbunden ist.') + + '
' + cards.map((k) => + '
' + icon(k.icon, 22) + '
' + k.n + '
' + k.label + '
').join('') + + '
' + + '
' + icon('plug', 18) + '
Verbindung
' + + '
' + esc(mode) + ' · ' + esc((c.owner || '?') + '/' + (c.repo || '?') + '@' + (c.branch || 'main')) + '
'; + } + + function renderDepartments() { + const t = state.tree || {}; const depts = t.departments || []; + let html = adminHeader('Abteilungen', 'Ordner im Repository. Jede Abteilung kann eigene Vorlagen und eine Fußzeile haben.'); + html += '
'; + html += '
'; + if (!depts.length) html += '
Noch keine Abteilungen.
'; + depts.forEach((d) => { + const count = (t.templates[d] || []).length; + html += '
' + icon('building', 18) + '' + esc(d) + '' + + '' + count + ' Vorlage' + (count === 1 ? '' : 'n') + '' + + '
'; }); + html += '
'; + el.adminPanel.innerHTML = html; + $('adm-dept-add').addEventListener('click', newDepartment); + $('adm-dept-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addDeptInline(); } }); + el.adminPanel.querySelectorAll('[data-del]').forEach((b) => b.addEventListener('click', () => deleteDepartment(b.dataset.del))); + } + async function addDeptInline() { + const name = ($('adm-dept-name').value || '').trim(); if (!name) return; + if (/[\/\\:*?"<>|]/.test(name)) { toast('Ungültiger Abteilungsname.', 'error'); return; } + try { await api('/api/departments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + name + '“ angelegt.', 'success'); await loadTree(); renderDepartments(); } + catch (e) { toast('Anlegen fehlgeschlagen: ' + e.message, 'error'); } + } + async function deleteDepartment(name) { + const count = ((state.tree && state.tree.templates[name]) || []).length; + const msg = count ? 'Abteilung „' + name + '“ und alle ' + count + ' enthaltenen Vorlagen löschen?' : 'Leere Abteilung „' + name + '“ löschen?'; + if (!(await confirmModal(msg, { title: 'Abteilung löschen', okLabel: 'Löschen', danger: true }))) return; + try { const r = await api('/api/departments', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + name + '“ gelöscht (' + (r.deleted || 0) + ' Dateien).', 'success'); await loadTree(); renderDepartments(); } + catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); } + } - // Sidebar-Aktionen - el.btnRefresh.addEventListener('click', loadTree); - if (el.treeSearch) el.treeSearch.addEventListener('input', applyTreeFilter); - el.btnNewDept.addEventListener('click', (e) => { e.stopPropagation(); newDepartment(); }); - el.btnNewFooter.addEventListener('click', (e) => { e.stopPropagation(); newFooter(); }); - el.btnNewHeader.addEventListener('click', (e) => { e.stopPropagation(); newHeader(); }); + async function renderMapping() { + el.adminPanel.innerHTML = adminHeader('E-Mail-Zuordnung', 'Welche Absender-Adresse gehört zu welcher Abteilung? (Datei _config/abteilungen.json)') + '
Lädt…
'; + let mapping = {}; + try { const r = await api('/api/abteilungen'); mapping = r.mapping || {}; } catch (e) { toast('Zuordnung nicht ladbar: ' + e.message, 'error'); } + const rows = Object.keys(mapping).map((email) => ({ email, dept: mapping[email] })); + const depts = (state.tree && state.tree.departments) || []; - // Editor-Aktionen + function deptSelect(val) { + return ''; + } + function rowHtml(r) { + return '
' + + deptSelect(r.dept) + '
'; + } + el.adminPanel.innerHTML = adminHeader('E-Mail-Zuordnung', 'Welche Absender-Adresse gehört zu welcher Abteilung? (Datei _config/abteilungen.json)') + + '
E-Mail-AdresseAbteilung
' + + '
' + (rows.length ? rows.map(rowHtml).join('') : '') + '
' + + '
' + + '
'; + + const rowsEl = $('map-rows'); + function bindRow(row) { row.querySelector('.map-del').addEventListener('click', () => row.remove()); } + rowsEl.querySelectorAll('.map-row').forEach(bindRow); + $('map-addrow').addEventListener('click', () => { const div = document.createElement('div'); div.innerHTML = rowHtml({ email: '', dept: '' }); const row = div.firstChild; rowsEl.appendChild(row); bindRow(row); row.querySelector('.map-email').focus(); }); + $('map-save').addEventListener('click', async () => { + const out = {}; + let bad = false; + rowsEl.querySelectorAll('.map-row').forEach((row) => { + const email = row.querySelector('.map-email').value.trim(); const dept = row.querySelector('.dept-sel').value.trim(); + if (!email && !dept) return; if (!email || !dept) { bad = true; return; } + out[email] = dept; + }); + if (bad) { toast('Bitte jede Zeile vollständig ausfüllen (E-Mail + Abteilung) oder leer lassen.', 'error'); return; } + try { await api('/api/abteilungen', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mapping: out }) }); toast('Zuordnung gespeichert.', 'success'); } + catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); } + }); + } + + async function renderTags() { + el.adminPanel.innerHTML = adminHeader('Schlagwörter', 'Farbige Tags für Thunderbird (Datei _config/schlagwoerter.json).') + '
Lädt…
'; + let tags = []; + try { const r = await api('/api/schlagwoerter'); tags = r.tags || []; } catch (e) { toast('Schlagwörter nicht ladbar: ' + e.message, 'error'); } + function rowHtml(t) { + const color = (t && t.color) || '#95a322'; const name = (t && t.name) || ''; + return '
' + + '' + + '' + + '
'; + } + el.adminPanel.innerHTML = adminHeader('Schlagwörter', 'Farbige Tags für Thunderbird (Datei _config/schlagwoerter.json).') + + '
' + (tags.length ? tags.map(rowHtml).join('') : '') + '
' + + '
' + + '
'; + const rowsEl = $('tag-rows'); + function bindRow(row) { + row.querySelector('.tag-del').addEventListener('click', () => row.remove()); + const color = row.querySelector('.tag-color'), sw = row.querySelector('.tag-swatch'); + color.addEventListener('input', () => { sw.style.background = color.value; }); + } + rowsEl.querySelectorAll('.tag-row').forEach(bindRow); + $('tag-addrow').addEventListener('click', () => { const div = document.createElement('div'); div.innerHTML = rowHtml({}); const row = div.firstChild; rowsEl.appendChild(row); bindRow(row); row.querySelector('.tag-name').focus(); }); + $('tag-save').addEventListener('click', async () => { + const out = []; let bad = false; + rowsEl.querySelectorAll('.tag-row').forEach((row) => { const name = row.querySelector('.tag-name').value.trim(); const color = row.querySelector('.tag-color').value; if (!name) { if (row.querySelector('.tag-name').value !== '') bad = true; return; } out.push({ name, color }); }); + try { await api('/api/schlagwoerter', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags: out }) }); toast('Schlagwörter gespeichert.', 'success'); } + catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); } + }); + } + + // ── Events ── + function bindEvents() { + el.catTabs.forEach((t) => t.addEventListener('click', () => setCategory(t.dataset.cat))); + el.btnRefresh.addEventListener('click', () => { loadTree(); if (state.category === 'admin') setAdminView(state.adminView); }); + el.btnListAdd.addEventListener('click', listAddAction); + el.treeSearch.addEventListener('input', applyFilter); el.btnSave.addEventListener('click', saveCurrent); el.btnReload.addEventListener('click', reloadCurrent); el.btnDelete.addEventListener('click', deleteCurrent); - - // View-Tabs el.tabVisual.addEventListener('click', () => setView('visual')); el.tabHtml.addEventListener('click', () => setView('html')); - - // Visuell editieren - el.visualEditor.addEventListener('input', afterVisualEdit); - // HTML editieren - el.htmlEditor.addEventListener('input', () => { - markDirty(); - updatePreview(); - }); - - // Toolbar - bindToolbar(); - el.fmtColorSwatch.style.background = el.fmtColor.value; - - // Tastenkürzel: Strg/Cmd+S = Speichern + 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) saveCurrent(); - } - // Escape schließt offene Modals - if (e.key === 'Escape') { - if (!el.promptBackdrop.hidden) el.promptCancel.click(); - else if (!el.confirmBackdrop.hidden) el.confirmCancel.click(); - } - }); - - // Vor Verlassen warnen, wenn ungespeichert - window.addEventListener('beforeunload', (e) => { - if (state.dirty) { - e.preventDefault(); - e.returnValue = ''; - return ''; - } + 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 - // ──────────────────────────────────────────────────────────── - + // ── Start ── async function init() { - bindEvents(); - setDirty(false); + hydrateIcons(document); + buildPlaceholderBar(); + bindEvents(); setDirty(false); setCategory('templates'); const ok = await loadConfigAndHealth(); - if (ok) { - await loadTree(); - } else if (state.config && !state.config.configured) { - // Banner ist sichtbar; kein Baum-Laden möglich. - } + if (ok) await loadTree(); } - document.addEventListener('DOMContentLoaded', init); })(); diff --git a/web-editor/public/fonts/pjs-400.ttf b/web-editor/public/fonts/pjs-400.ttf new file mode 100644 index 0000000..b655eb4 Binary files /dev/null and b/web-editor/public/fonts/pjs-400.ttf differ diff --git a/web-editor/public/fonts/pjs-500.ttf b/web-editor/public/fonts/pjs-500.ttf new file mode 100644 index 0000000..6e7af77 Binary files /dev/null and b/web-editor/public/fonts/pjs-500.ttf differ diff --git a/web-editor/public/fonts/pjs-600.ttf b/web-editor/public/fonts/pjs-600.ttf new file mode 100644 index 0000000..609def9 Binary files /dev/null and b/web-editor/public/fonts/pjs-600.ttf differ diff --git a/web-editor/public/fonts/pjs-700.ttf b/web-editor/public/fonts/pjs-700.ttf new file mode 100644 index 0000000..91ab258 Binary files /dev/null and b/web-editor/public/fonts/pjs-700.ttf differ diff --git a/web-editor/public/fonts/pjs-800.ttf b/web-editor/public/fonts/pjs-800.ttf new file mode 100644 index 0000000..313780c Binary files /dev/null and b/web-editor/public/fonts/pjs-800.ttf differ diff --git a/web-editor/public/index.html b/web-editor/public/index.html index 3b27b15..bd0ae4e 100644 --- a/web-editor/public/index.html +++ b/web-editor/public/index.html @@ -35,46 +35,43 @@
- +
- +

Wähle links einen Eintrag

Vorlage, Fußzeile oder Signatur anklicken zum Bearbeiten – oder über + Neu einen neuen Eintrag anlegen.

- + + + +
@@ -117,9 +114,7 @@
- +