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:
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 };
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 = '
' + 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: ' + 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(); }
// ════════════════════════════════════════════════════════════
// 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 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.') +
'
';
}
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 += '