Auto-Update über Gitea einrichten + Web-Editor + Sync-Verbesserungen
- Thunderbird Auto-Update: update_url im Manifest, updates.json, release.sh - .xpi neu gebaut (mit update_url, ohne defaults.local.json/Token) - README + CLAUDE.md: Auto-Update-Doku, Repo muss public bleiben - web-editor/ (Node/Docker WYSIWYG-Editor) hinzugefügt - gitea-sync.js + templates_options: bestehende Anpassungen Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
981
web-editor/public/app.js
Normal file
981
web-editor/public/app.js
Normal file
@@ -0,0 +1,981 @@
|
||||
/* HPS Vorlagen & Signaturen — Web-Editor Frontend
|
||||
* Vanilla JS, keine Abhängigkeiten. Spricht ausschließlich mit dem
|
||||
* gleichnamigen Express-Backend (same origin) über die /api-Endpunkte.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Konstanten (müssen mit dem Backend übereinstimmen) ──
|
||||
const SHARED_FOLDER = '_gemeinsam';
|
||||
const USER_FOLDER = '_benutzer';
|
||||
const SIG_FOOTERS = 'signatures/footers';
|
||||
const SIG_HEADERS = 'signatures/headers';
|
||||
|
||||
// ── Globaler Zustand ──
|
||||
const state = {
|
||||
config: null, // /api/config
|
||||
tree: null, // /api/tree
|
||||
current: null, // { path, friendly, sha, exists, isNew, category }
|
||||
dirty: false,
|
||||
view: 'visual', // 'visual' | 'html'
|
||||
collapsed: { templates: false, footers: false, headers: false },
|
||||
groupsCollapsed: {}, // key -> bool (Vorlagen-Untergruppen)
|
||||
pendingNetwork: 0,
|
||||
};
|
||||
|
||||
// ── DOM-Referenzen ──
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const el = {
|
||||
statusPill: $('status-pill'),
|
||||
statusText: $('status-text'),
|
||||
configBanner: $('config-banner'),
|
||||
treeTemplates:$('tree-templates'),
|
||||
treeFooters: $('tree-footers'),
|
||||
treeHeaders: $('tree-headers'),
|
||||
emptyState: $('empty-state'),
|
||||
editorPanel: $('editor-panel'),
|
||||
fileFriendly: $('file-friendly'),
|
||||
filePath: $('file-path'),
|
||||
dirtyBadge: $('dirty-badge'),
|
||||
btnSave: $('btn-save'),
|
||||
btnReload: $('btn-reload'),
|
||||
btnDelete: $('btn-delete'),
|
||||
btnRefresh: $('btn-refresh-tree'),
|
||||
treeSearch: $('tree-search'),
|
||||
btnNewDept: $('btn-new-department'),
|
||||
btnNewFooter: $('btn-new-footer'),
|
||||
btnNewHeader: $('btn-new-header'),
|
||||
tabVisual: $('tab-visual'),
|
||||
tabHtml: $('tab-html'),
|
||||
formatToolbar:$('format-toolbar'),
|
||||
visualEditor: $('visual-editor'),
|
||||
htmlEditor: $('html-editor'),
|
||||
visualWrap: $('visual-wrap'),
|
||||
htmlWrap: $('html-wrap'),
|
||||
previewFrame: $('preview-frame'),
|
||||
toastStack: $('toast-stack'),
|
||||
loading: $('loading-overlay'),
|
||||
fmtColor: $('fmt-color'),
|
||||
fmtColorSwatch: $('fmt-color-swatch'),
|
||||
fmtFontSize: $('fmt-fontsize'),
|
||||
fmtLink: $('fmt-link'),
|
||||
fmtImage: $('fmt-image'),
|
||||
// Modals
|
||||
confirmBackdrop: $('confirm-backdrop'),
|
||||
confirmTitle: $('confirm-title'),
|
||||
confirmMessage: $('confirm-message'),
|
||||
confirmOk: $('confirm-ok'),
|
||||
confirmCancel:$('confirm-cancel'),
|
||||
promptBackdrop: $('prompt-backdrop'),
|
||||
promptForm: $('prompt-form'),
|
||||
promptTitle: $('prompt-title'),
|
||||
promptFields: $('prompt-fields'),
|
||||
promptOk: $('prompt-ok'),
|
||||
promptCancel: $('prompt-cancel'),
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Hilfsfunktionen
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
// HTML-escapen für sichere Anzeige in Attributen/Text.
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Dateiname aus Vorlagennamen ableiten (exakt laut Vorgabe), .html ergänzt der Aufrufer.
|
||||
function slugifyName(name) {
|
||||
return name
|
||||
.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss')
|
||||
.replace(/[/\\:*?"<>|]/g, '-')
|
||||
.replace(/^[\s.-]+|[\s.-]+$/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Für Signatur-Köpfe: zusätzlich klein + Leerzeichen → '-'.
|
||||
function slugifyHeaderName(name) {
|
||||
return slugifyName(name).toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
// Debounce-Helfer.
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return function (...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), ms); };
|
||||
}
|
||||
|
||||
// ── Toasts ──
|
||||
function toast(message, type) {
|
||||
const t = document.createElement('div');
|
||||
t.className = 'toast' + (type ? ' toast-' + type : '');
|
||||
const icon = type === 'success' ? '✓' : type === 'error' ? '⚠' : 'ℹ';
|
||||
t.innerHTML = '<span class="toast-icon">' + icon + '</span><span class="toast-msg"></span>';
|
||||
t.querySelector('.toast-msg').textContent = message;
|
||||
el.toastStack.appendChild(t);
|
||||
setTimeout(() => {
|
||||
t.classList.add('fade-out');
|
||||
t.addEventListener('animationend', () => t.remove(), { once: true });
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// ── Lade-Overlay (zählt verschachtelte Netzwerkaufrufe) ──
|
||||
function startLoading() { state.pendingNetwork++; el.loading.hidden = false; }
|
||||
function stopLoading() { state.pendingNetwork = Math.max(0, state.pendingNetwork - 1); if (state.pendingNetwork === 0) el.loading.hidden = true; }
|
||||
|
||||
// ── Zentraler fetch-Wrapper: JSON, Fehler→toast, Overlay ──
|
||||
async function api(path, options) {
|
||||
startLoading();
|
||||
try {
|
||||
const res = await fetch(path, options);
|
||||
let data = null;
|
||||
const text = await res.text();
|
||||
if (text) { try { data = JSON.parse(text); } catch (_) { data = { error: text }; } }
|
||||
if (!res.ok) {
|
||||
const msg = (data && data.error) ? data.error : ('HTTP ' + res.status + ' ' + res.statusText);
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data || {};
|
||||
} finally {
|
||||
stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom confirm-Modal → Promise<boolean> ──
|
||||
function confirmModal(message, opts) {
|
||||
opts = opts || {};
|
||||
el.confirmTitle.textContent = opts.title || 'Bestätigen';
|
||||
el.confirmMessage.textContent = message;
|
||||
el.confirmOk.textContent = opts.okLabel || 'Bestätigen';
|
||||
el.confirmOk.className = 'btn ' + (opts.danger ? 'btn-danger' : 'btn-primary');
|
||||
el.confirmBackdrop.hidden = false;
|
||||
return new Promise((resolve) => {
|
||||
function cleanup(result) {
|
||||
el.confirmBackdrop.hidden = true;
|
||||
el.confirmOk.removeEventListener('click', onOk);
|
||||
el.confirmCancel.removeEventListener('click', onCancel);
|
||||
el.confirmBackdrop.removeEventListener('click', onBackdrop);
|
||||
resolve(result);
|
||||
}
|
||||
const onOk = () => cleanup(true);
|
||||
const onCancel = () => cleanup(false);
|
||||
const onBackdrop = (e) => { if (e.target === el.confirmBackdrop) cleanup(false); };
|
||||
el.confirmOk.addEventListener('click', onOk);
|
||||
el.confirmCancel.addEventListener('click', onCancel);
|
||||
el.confirmBackdrop.addEventListener('click', onBackdrop);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Custom prompt-Modal mit beliebigen Feldern → Promise<{}|null> ──
|
||||
// fields: [{ key, label, type='text', placeholder, value, options:[{value,label}], required, hint }]
|
||||
// onChange(values, fieldEls) optional für Live-Vorschau.
|
||||
function promptModal(title, fields, onChange) {
|
||||
el.promptTitle.textContent = title;
|
||||
el.promptFields.innerHTML = '';
|
||||
const inputs = {};
|
||||
|
||||
fields.forEach((f) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = f.label;
|
||||
wrap.appendChild(label);
|
||||
|
||||
let input;
|
||||
if (f.type === 'select') {
|
||||
input = document.createElement('select');
|
||||
(f.options || []).forEach((o) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = o.value;
|
||||
opt.textContent = o.label;
|
||||
input.appendChild(opt);
|
||||
});
|
||||
if (f.value != null) input.value = f.value;
|
||||
} else {
|
||||
input = document.createElement('input');
|
||||
input.type = f.type || 'text';
|
||||
if (f.placeholder) input.placeholder = f.placeholder;
|
||||
if (f.value != null) input.value = f.value;
|
||||
}
|
||||
input.dataset.key = f.key;
|
||||
wrap.appendChild(input);
|
||||
|
||||
if (f.hint || f.live) {
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'hint';
|
||||
if (f.live) hint.dataset.live = f.key;
|
||||
if (f.hint) hint.textContent = f.hint;
|
||||
wrap.appendChild(hint);
|
||||
}
|
||||
el.promptFields.appendChild(wrap);
|
||||
inputs[f.key] = input;
|
||||
});
|
||||
|
||||
const readValues = () => {
|
||||
const v = {};
|
||||
Object.keys(inputs).forEach((k) => { v[k] = inputs[k].value; });
|
||||
return v;
|
||||
};
|
||||
|
||||
el.promptBackdrop.hidden = false;
|
||||
const first = el.promptFields.querySelector('input, select');
|
||||
if (first) setTimeout(() => first.focus(), 30);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
function cleanup(result) {
|
||||
el.promptBackdrop.hidden = true;
|
||||
el.promptForm.removeEventListener('submit', onSubmit);
|
||||
el.promptCancel.removeEventListener('click', onCancel);
|
||||
el.promptBackdrop.removeEventListener('click', onBackdrop);
|
||||
el.promptForm.removeEventListener('input', onInput);
|
||||
resolve(result);
|
||||
}
|
||||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
const values = readValues();
|
||||
// Pflichtfelder prüfen.
|
||||
for (const f of fields) {
|
||||
if (f.required && !String(values[f.key] || '').trim()) {
|
||||
toast('Bitte „' + f.label + '“ ausfüllen.', 'error');
|
||||
inputs[f.key].focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
cleanup(values);
|
||||
}
|
||||
const onCancel = () => cleanup(null);
|
||||
const onBackdrop = (e) => { if (e.target === el.promptBackdrop) cleanup(null); };
|
||||
const onInput = () => { if (onChange) onChange(readValues(), inputs, el.promptFields); };
|
||||
el.promptForm.addEventListener('submit', onSubmit);
|
||||
el.promptCancel.addEventListener('click', onCancel);
|
||||
el.promptBackdrop.addEventListener('click', onBackdrop);
|
||||
el.promptForm.addEventListener('input', onInput);
|
||||
if (onChange) onChange(readValues(), inputs, el.promptFields); // initial
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Anzeige-Namen (friendly labels)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
function footerLabel(name) {
|
||||
if (name === '_default.html') return 'Gemeinsam (alle Abteilungen)';
|
||||
return name.replace(/\.html$/i, '');
|
||||
}
|
||||
|
||||
function headerLabel(name) {
|
||||
if (name === '_vorlage.html') return 'Vorlage (Standard-Kopf)';
|
||||
// <email>.<slug>.html → "email — slug"
|
||||
// E-Mail enthält genau ein '@'; alles bis zum ersten '.' NACH dem '@' ist die E-Mail.
|
||||
const base = name.replace(/\.html$/i, '');
|
||||
const at = base.indexOf('@');
|
||||
if (at >= 0) {
|
||||
const firstDotAfterAt = base.indexOf('.', at);
|
||||
if (firstDotAfterAt > -1 && firstDotAfterAt < base.length - 1) {
|
||||
const email = base.slice(0, firstDotAfterAt);
|
||||
const slug = base.slice(firstDotAfterAt + 1);
|
||||
return email + ' — ' + slug;
|
||||
}
|
||||
}
|
||||
return base; // Fallback
|
||||
}
|
||||
|
||||
function templateLabel(name) {
|
||||
return name.replace(/\.html$/i, '');
|
||||
}
|
||||
|
||||
// Liefert eine friendly-Bezeichnung anhand Kategorie + Dateiname.
|
||||
function friendlyFor(category, name) {
|
||||
if (category === 'footer') return 'Fußzeile: ' + footerLabel(name);
|
||||
if (category === 'header') return 'Signatur: ' + headerLabel(name);
|
||||
return templateLabel(name);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Verbindungsstatus
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadConfigAndHealth() {
|
||||
// Config (best effort, ohne Overlay-Spam → eigener leichter Aufruf)
|
||||
try {
|
||||
const cfg = await api('/api/config');
|
||||
state.config = cfg;
|
||||
if (!cfg.configured) {
|
||||
el.configBanner.hidden = false;
|
||||
setStatus('error', 'Nicht konfiguriert');
|
||||
return false;
|
||||
}
|
||||
el.configBanner.hidden = true;
|
||||
} catch (e) {
|
||||
setStatus('error', 'Nicht verbunden');
|
||||
toast('Konfiguration konnte nicht geladen werden: ' + e.message, 'error');
|
||||
return false;
|
||||
}
|
||||
// Health
|
||||
try {
|
||||
const health = await api('/api/health');
|
||||
if (health.ok) {
|
||||
const c = state.config;
|
||||
setStatus('ok', 'Verbunden: ' + c.owner + '/' + c.repo + '@' + c.branch);
|
||||
} else {
|
||||
setStatus('error', 'Nicht verbunden: ' + (health.error || 'unbekannt'));
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus('error', 'Nicht verbunden: ' + e.message);
|
||||
toast('Verbindung zum Repository fehlgeschlagen: ' + e.message, 'error');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function setStatus(kind, text) {
|
||||
el.statusPill.className = 'status-pill status-' + (kind === 'ok' ? 'ok' : kind === 'error' ? 'error' : 'unknown');
|
||||
el.statusText.textContent = text;
|
||||
el.statusPill.title = text;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Baum (Sidebar) laden & rendern
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadTree() {
|
||||
try {
|
||||
state.tree = await api('/api/tree');
|
||||
renderTree();
|
||||
} catch (e) {
|
||||
toast('Liste konnte nicht geladen werden: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
renderTemplates();
|
||||
renderFooters();
|
||||
renderHeaders();
|
||||
applySectionCollapse();
|
||||
highlightActive();
|
||||
// Aktiven Filter nach Neuaufbau erneut anwenden.
|
||||
if (el.treeSearch && el.treeSearch.value.trim()) applyTreeFilter();
|
||||
}
|
||||
|
||||
// Live-Filter über alle Dateien (Label + Pfad). Leere Suche = voller Baum.
|
||||
function applyTreeFilter() {
|
||||
const q = (el.treeSearch.value || '').trim().toLowerCase();
|
||||
if (!q) { renderTree(); return; } // pristinen Baum (inkl. Klappzustand) wiederherstellen
|
||||
|
||||
document.querySelectorAll('.tree-section').forEach((s) => s.classList.remove('collapsed'));
|
||||
document.querySelectorAll('.sidebar .group-files').forEach((b) => b.classList.remove('collapsed'));
|
||||
document.querySelectorAll('.sidebar .group-title').forEach((t) => t.classList.remove('collapsed'));
|
||||
document.querySelectorAll('.sidebar .add-item, .sidebar .tree-empty').forEach((n) => { n.style.display = 'none'; });
|
||||
|
||||
document.querySelectorAll('.sidebar .tree-item').forEach((it) => {
|
||||
const label = (it.querySelector('.ti-label')?.textContent || '').toLowerCase();
|
||||
const path = (it.dataset.path || '').toLowerCase();
|
||||
it.style.display = (label.includes(q) || path.includes(q)) ? '' : 'none';
|
||||
});
|
||||
|
||||
// Gruppen ohne sichtbaren Treffer ausblenden.
|
||||
document.querySelectorAll('.sidebar .tree-group').forEach((g) => {
|
||||
const items = g.querySelectorAll('.tree-item');
|
||||
const anyVisible = Array.from(items).some((i) => i.style.display !== 'none');
|
||||
g.style.display = (items.length && !anyVisible) ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
// Datei-Item-Element bauen.
|
||||
function fileItem(file, category) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'tree-item';
|
||||
div.dataset.path = file.path;
|
||||
div.dataset.sha = file.sha || '';
|
||||
div.dataset.category = category;
|
||||
div.dataset.name = file.name;
|
||||
const label = category === 'footer' ? footerLabel(file.name)
|
||||
: category === 'header' ? headerLabel(file.name)
|
||||
: templateLabel(file.name);
|
||||
const icon = category === 'footer' ? '📜' : category === 'header' ? '✍️' : '📄';
|
||||
div.innerHTML = '<span class="ti-icon">' + icon + '</span><span class="ti-label"></span>';
|
||||
div.querySelector('.ti-label').textContent = label;
|
||||
div.title = file.path;
|
||||
div.addEventListener('click', () => openFile(file.path, { friendly: friendlyFor(category, file.name), sha: file.sha, category, exists: true }));
|
||||
return div;
|
||||
}
|
||||
|
||||
// „+ Neue …“-Button.
|
||||
function addButton(text, onClick) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'add-item';
|
||||
b.textContent = text;
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
|
||||
return b;
|
||||
}
|
||||
|
||||
// Eine ein-/ausklappbare Gruppe (für Vorlagen).
|
||||
function makeGroup(key, title, icon, files, category, onAdd, isSub) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'tree-group' + (isSub ? ' subgroup' : '');
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'group-head';
|
||||
|
||||
const collapsed = !!state.groupsCollapsed[key];
|
||||
const toggle = document.createElement('button');
|
||||
toggle.className = 'group-title' + (collapsed ? ' collapsed' : '');
|
||||
toggle.innerHTML = '<span class="g-caret">▾</span><span class="group-icon">' + icon + '</span><span class="g-label"></span>';
|
||||
toggle.querySelector('.g-label').textContent = title;
|
||||
head.appendChild(toggle);
|
||||
group.appendChild(head);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'group-files' + (collapsed ? ' collapsed' : '');
|
||||
if (files.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'tree-empty';
|
||||
empty.textContent = 'Keine Dateien';
|
||||
body.appendChild(empty);
|
||||
} else {
|
||||
files.forEach((f) => body.appendChild(fileItem(f, category)));
|
||||
}
|
||||
if (onAdd) body.appendChild(addButton('+ Neue Vorlage', onAdd));
|
||||
group.appendChild(body);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
state.groupsCollapsed[key] = !state.groupsCollapsed[key];
|
||||
toggle.classList.toggle('collapsed');
|
||||
body.classList.toggle('collapsed');
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
function renderTemplates() {
|
||||
const c = el.treeTemplates;
|
||||
c.innerHTML = '';
|
||||
const t = state.tree;
|
||||
if (!t) return;
|
||||
|
||||
// _gemeinsam
|
||||
c.appendChild(makeGroup(
|
||||
'tmpl:' + SHARED_FOLDER, 'Alle Abteilungen (_gemeinsam)', '🌐',
|
||||
t.templates[SHARED_FOLDER] || [], 'template',
|
||||
() => newTemplate(SHARED_FOLDER)
|
||||
));
|
||||
|
||||
// Abteilungen
|
||||
(t.departments || []).forEach((dept) => {
|
||||
c.appendChild(makeGroup(
|
||||
'tmpl:' + dept, dept, '🏢',
|
||||
t.templates[dept] || [], 'template',
|
||||
() => newTemplate(dept)
|
||||
));
|
||||
});
|
||||
|
||||
// Persönlich → übergeordnete Gruppe mit Untergruppen je Benutzer
|
||||
const users = t.users || {};
|
||||
const userKeys = Object.keys(users).sort((a, b) => a.localeCompare(b, 'de'));
|
||||
const persGroup = document.createElement('div');
|
||||
persGroup.className = 'tree-group';
|
||||
const persKey = 'tmpl:__pers';
|
||||
const persCollapsed = !!state.groupsCollapsed[persKey];
|
||||
const persToggle = document.createElement('button');
|
||||
persToggle.className = 'group-title' + (persCollapsed ? ' collapsed' : '');
|
||||
persToggle.innerHTML = '<span class="g-caret">▾</span><span class="group-icon">👤</span><span class="g-label">Persönlich</span>';
|
||||
const persHead = document.createElement('div');
|
||||
persHead.className = 'group-head';
|
||||
persHead.appendChild(persToggle);
|
||||
persGroup.appendChild(persHead);
|
||||
const persBody = document.createElement('div');
|
||||
persBody.className = 'group-files' + (persCollapsed ? ' collapsed' : '');
|
||||
if (userKeys.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'tree-empty';
|
||||
empty.textContent = 'Keine persönlichen Ordner';
|
||||
persBody.appendChild(empty);
|
||||
} else {
|
||||
userKeys.forEach((email) => {
|
||||
// Persönlicher Ordnerpfad: _benutzer/<email>
|
||||
persBody.appendChild(makeGroup(
|
||||
'tmpl:user:' + email, email, '✉️',
|
||||
users[email] || [], 'template',
|
||||
() => newTemplate(USER_FOLDER + '/' + email),
|
||||
true
|
||||
));
|
||||
});
|
||||
}
|
||||
persGroup.appendChild(persBody);
|
||||
persToggle.addEventListener('click', () => {
|
||||
state.groupsCollapsed[persKey] = !state.groupsCollapsed[persKey];
|
||||
persToggle.classList.toggle('collapsed');
|
||||
persBody.classList.toggle('collapsed');
|
||||
});
|
||||
c.appendChild(persGroup);
|
||||
}
|
||||
|
||||
function renderFooters() {
|
||||
const c = el.treeFooters;
|
||||
c.innerHTML = '';
|
||||
const footers = (state.tree && state.tree.footers) || [];
|
||||
if (footers.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'tree-empty';
|
||||
empty.textContent = 'Keine Fußzeilen';
|
||||
c.appendChild(empty);
|
||||
} else {
|
||||
footers.forEach((f) => c.appendChild(fileItem(f, 'footer')));
|
||||
}
|
||||
c.appendChild(addButton('+ Neue Fußzeile', newFooter));
|
||||
}
|
||||
|
||||
function renderHeaders() {
|
||||
const c = el.treeHeaders;
|
||||
c.innerHTML = '';
|
||||
const headers = (state.tree && state.tree.headers) || [];
|
||||
if (headers.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'tree-empty';
|
||||
empty.textContent = 'Keine Signatur-Köpfe';
|
||||
c.appendChild(empty);
|
||||
} else {
|
||||
headers.forEach((f) => c.appendChild(fileItem(f, 'header')));
|
||||
}
|
||||
c.appendChild(addButton('+ Neue Signatur', newHeader));
|
||||
}
|
||||
|
||||
function applySectionCollapse() {
|
||||
document.querySelectorAll('.tree-section').forEach((sec) => {
|
||||
const key = sec.dataset.section;
|
||||
const toggle = sec.querySelector('.section-toggle');
|
||||
const collapsed = !!state.collapsed[key];
|
||||
sec.classList.toggle('collapsed', collapsed);
|
||||
toggle.setAttribute('aria-expanded', String(!collapsed));
|
||||
});
|
||||
}
|
||||
|
||||
function highlightActive() {
|
||||
document.querySelectorAll('.tree-item').forEach((item) => {
|
||||
item.classList.toggle('is-active', state.current && item.dataset.path === state.current.path);
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Datei öffnen / laden
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
async function openFile(path, meta) {
|
||||
// Ungespeicherte Änderungen?
|
||||
if (state.dirty) {
|
||||
const ok = await confirmModal('Es gibt ungespeicherte Änderungen. Trotzdem eine andere Datei öffnen? Die Änderungen gehen verloren.', { title: 'Ungespeicherte Änderungen', okLabel: 'Verwerfen', danger: true });
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
const data = await api('/api/file?path=' + encodeURIComponent(path));
|
||||
state.current = {
|
||||
path: data.path,
|
||||
friendly: meta.friendly,
|
||||
sha: data.sha,
|
||||
exists: data.exists,
|
||||
isNew: false,
|
||||
category: meta.category,
|
||||
};
|
||||
setEditorContent(data.content || '');
|
||||
setDirty(false);
|
||||
showEditor();
|
||||
highlightActive();
|
||||
} catch (e) {
|
||||
toast('Datei konnte nicht geladen werden: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Neue (noch nicht gespeicherte) Datei direkt im Editor öffnen.
|
||||
function openNewFile(path, friendly, category) {
|
||||
state.current = { path, friendly, sha: null, exists: false, isNew: true, category };
|
||||
setEditorContent('');
|
||||
setDirty(true); // neu = ungespeichert
|
||||
showEditor();
|
||||
highlightActive();
|
||||
el.visualEditor.focus();
|
||||
toast('Neue Datei „' + friendly + '“ – jetzt bearbeiten und speichern.', 'success');
|
||||
}
|
||||
|
||||
function showEditor() {
|
||||
el.emptyState.hidden = true;
|
||||
el.editorPanel.hidden = false;
|
||||
el.fileFriendly.textContent = state.current.friendly;
|
||||
el.filePath.textContent = state.current.path;
|
||||
setView('visual');
|
||||
}
|
||||
|
||||
function hideEditor() {
|
||||
state.current = null;
|
||||
el.editorPanel.hidden = true;
|
||||
el.emptyState.hidden = false;
|
||||
highlightActive();
|
||||
}
|
||||
|
||||
// Inhalt in beide Editoren + Vorschau setzen.
|
||||
function setEditorContent(html) {
|
||||
el.visualEditor.innerHTML = html;
|
||||
el.htmlEditor.value = html;
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// Aktuellen HTML-Inhalt aus dem gerade aktiven View lesen.
|
||||
function currentHtml() {
|
||||
return state.view === 'html' ? el.htmlEditor.value : el.visualEditor.innerHTML;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Dirty-State
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
function setDirty(d) {
|
||||
state.dirty = d;
|
||||
el.dirtyBadge.hidden = !d;
|
||||
// Speichern aktiv, wenn: keine Datei → aus; neue (ungespeicherte) Datei → immer an;
|
||||
// bestehende Datei → nur bei ungespeicherten Änderungen.
|
||||
if (!state.current) el.btnSave.disabled = true;
|
||||
else if (!state.current.exists) el.btnSave.disabled = false;
|
||||
else el.btnSave.disabled = !d;
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
if (!state.dirty) setDirty(true);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// View-Umschaltung (Visuell ↔ HTML) – synchron halten
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
function setView(view) {
|
||||
if (view === state.view && el.editorPanel.hidden === false) {
|
||||
// trotzdem Tabs/Anzeige korrekt setzen
|
||||
}
|
||||
if (view === 'html') {
|
||||
// Visuell → HTML: innerHTML in Textarea schreiben
|
||||
el.htmlEditor.value = el.visualEditor.innerHTML;
|
||||
el.visualWrap.hidden = true;
|
||||
el.htmlWrap.hidden = false;
|
||||
el.formatToolbar.classList.add('disabled');
|
||||
} else {
|
||||
// HTML → Visuell: Textarea-Wert in contenteditable schreiben
|
||||
el.visualEditor.innerHTML = el.htmlEditor.value;
|
||||
el.htmlWrap.hidden = true;
|
||||
el.visualWrap.hidden = false;
|
||||
el.formatToolbar.classList.remove('disabled');
|
||||
}
|
||||
state.view = view;
|
||||
el.tabVisual.classList.toggle('is-active', view === 'visual');
|
||||
el.tabHtml.classList.toggle('is-active', view === 'html');
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Vorschau (sandboxed iframe via srcdoc), debounced
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
const updatePreview = debounce(function () {
|
||||
const html = currentHtml();
|
||||
const doc = '<!DOCTYPE html><html lang="de"><head><meta charset="utf-8">' +
|
||||
'<style>html,body{margin:0;padding:0;}' +
|
||||
'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;' +
|
||||
'color:#1f2a30;line-height:1.5;background:#eef1f4;padding:18px;}' +
|
||||
'.email-card{max-width:640px;margin:0 auto;background:#fff;padding:24px 28px;' +
|
||||
'border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.12);}' +
|
||||
'img{max-width:100%;height:auto;}</style></head>' +
|
||||
'<body><div class="email-card">' + html + '</div></body></html>';
|
||||
el.previewFrame.srcdoc = doc;
|
||||
}, 250);
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Formatierungs-Toolbar (document.execCommand)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
function exec(cmd, value) {
|
||||
el.visualEditor.focus();
|
||||
try { document.execCommand(cmd, false, value); } catch (e) { /* alte Browser */ }
|
||||
afterVisualEdit();
|
||||
}
|
||||
|
||||
function afterVisualEdit() {
|
||||
el.htmlEditor.value = el.visualEditor.innerHTML;
|
||||
markDirty();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function bindToolbar() {
|
||||
el.formatToolbar.querySelectorAll('.fmt-btn[data-cmd]').forEach((btn) => {
|
||||
btn.addEventListener('mousedown', (e) => e.preventDefault()); // Auswahl im Editor behalten
|
||||
btn.addEventListener('click', () => exec(btn.dataset.cmd));
|
||||
});
|
||||
el.fmtFontSize.addEventListener('change', () => {
|
||||
if (el.fmtFontSize.value) exec('fontSize', el.fmtFontSize.value);
|
||||
el.fmtFontSize.value = '';
|
||||
});
|
||||
el.fmtColor.addEventListener('input', () => {
|
||||
el.fmtColorSwatch.style.background = el.fmtColor.value;
|
||||
});
|
||||
el.fmtColor.addEventListener('change', () => {
|
||||
exec('foreColor', el.fmtColor.value);
|
||||
});
|
||||
el.fmtLink.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
el.fmtLink.addEventListener('click', async () => {
|
||||
const res = await promptModal('Link einfügen', [
|
||||
{ key: 'url', label: 'Adresse (URL)', placeholder: 'https://…', required: true, value: 'https://' },
|
||||
]);
|
||||
if (res) exec('createLink', res.url.trim());
|
||||
});
|
||||
el.fmtImage.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
el.fmtImage.addEventListener('click', async () => {
|
||||
const res = await promptModal('Bild einfügen', [
|
||||
{ key: 'url', label: 'Bild-URL', placeholder: 'https://…/bild.png', required: true, value: 'https://' },
|
||||
]);
|
||||
if (res) exec('insertImage', res.url.trim());
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Speichern / Neu laden / Löschen
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
async function saveCurrent() {
|
||||
if (!state.current) return;
|
||||
// Sicherstellen, dass beide Editoren synchron sind (aus aktivem View lesen).
|
||||
const content = currentHtml();
|
||||
const friendly = state.current.friendly;
|
||||
const message = friendly + ' bearbeitet (Web-Editor)';
|
||||
try {
|
||||
const res = await api('/api/file', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: state.current.path, content: content, message: message }),
|
||||
});
|
||||
state.current.exists = true;
|
||||
state.current.isNew = false;
|
||||
if (res.sha) state.current.sha = res.sha;
|
||||
setDirty(false);
|
||||
if (res.unchanged) toast('Keine Änderungen – nichts zu speichern.', 'success');
|
||||
else toast('„' + friendly + '“ gespeichert.', 'success');
|
||||
await loadTree(); // neue Dateien auftauchen lassen / SHAs aktualisieren
|
||||
highlightActive();
|
||||
} catch (e) {
|
||||
toast('Speichern fehlgeschlagen: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadCurrent() {
|
||||
if (!state.current) return;
|
||||
if (state.current.isNew) {
|
||||
toast('Diese Datei wurde noch nicht gespeichert.', 'error');
|
||||
return;
|
||||
}
|
||||
if (state.dirty) {
|
||||
const ok = await confirmModal('Ungespeicherte Änderungen verwerfen und Datei neu laden?', { title: 'Neu laden', okLabel: 'Verwerfen', danger: true });
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
const data = await api('/api/file?path=' + encodeURIComponent(state.current.path));
|
||||
state.current.sha = data.sha;
|
||||
state.current.exists = data.exists;
|
||||
setEditorContent(data.content || '');
|
||||
setDirty(false);
|
||||
setView('visual');
|
||||
toast('Datei neu geladen.', 'success');
|
||||
} catch (e) {
|
||||
toast('Neu laden fehlgeschlagen: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrent() {
|
||||
if (!state.current) return;
|
||||
const friendly = state.current.friendly;
|
||||
// Noch nicht gespeicherte Datei → nur lokal verwerfen.
|
||||
if (state.current.isNew) {
|
||||
const ok = await confirmModal('Diese neue, noch nicht gespeicherte Datei verwerfen?', { title: 'Verwerfen', okLabel: 'Verwerfen', danger: true });
|
||||
if (!ok) return;
|
||||
setDirty(false);
|
||||
hideEditor();
|
||||
return;
|
||||
}
|
||||
const ok = await confirmModal('„' + friendly + '“ wirklich löschen? Dies kann nicht rückgängig gemacht werden.', { title: 'Löschen', okLabel: 'Löschen', danger: true });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api('/api/file', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: state.current.path, message: friendly + ' gelöscht (Web-Editor)' }),
|
||||
});
|
||||
toast('„' + friendly + '“ gelöscht.', 'success');
|
||||
setDirty(false);
|
||||
hideEditor();
|
||||
await loadTree();
|
||||
} catch (e) {
|
||||
toast('Löschen fehlgeschlagen: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Neue Dateien anlegen
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
async function newTemplate(folder) {
|
||||
const res = await promptModal('Neue Vorlage in „' + folder + '“', [
|
||||
{ key: 'name', label: 'Vorlagenname', placeholder: 'z. B. Angebot Doppelzimmer', required: true, live: true, hint: '' },
|
||||
], (values, inputs, root) => {
|
||||
const slug = slugifyName(values.name || '');
|
||||
const liveHint = root.querySelector('[data-live="name"]');
|
||||
if (liveHint) liveHint.innerHTML = slug ? 'Datei: <span class="preview-name">' + esc(slug) + '.html</span>' : 'Bitte einen Namen eingeben.';
|
||||
});
|
||||
if (!res) return;
|
||||
const slug = slugifyName(res.name);
|
||||
if (!slug) { toast('Ungültiger Name.', 'error'); return; }
|
||||
const path = folder + '/' + slug + '.html';
|
||||
if (await existsInTree(path)) { toast('Eine Vorlage mit diesem Namen existiert bereits.', 'error'); return; }
|
||||
openNewFile(path, slug, 'template');
|
||||
}
|
||||
|
||||
async function newFooter() {
|
||||
const t = state.tree || {};
|
||||
const options = [{ value: '_default', label: 'Gemeinsam (alle Abteilungen)' }]
|
||||
.concat((t.departments || []).map((d) => ({ value: d, label: d })));
|
||||
const res = await promptModal('Neue Fußzeile', [
|
||||
{ key: 'dept', label: 'Für welche Abteilung?', type: 'select', options: options, required: true },
|
||||
]);
|
||||
if (!res) return;
|
||||
const file = (res.dept === '_default' ? '_default' : res.dept) + '.html';
|
||||
const path = SIG_FOOTERS + '/' + file;
|
||||
if (await existsInTree(path)) { toast('Diese Fußzeile existiert bereits.', 'error'); return; }
|
||||
openNewFile(path, 'Fußzeile: ' + footerLabel(file), 'footer');
|
||||
}
|
||||
|
||||
async function newHeader() {
|
||||
const res = await promptModal('Neue Signatur', [
|
||||
{ key: 'email', label: 'E-Mail-Adresse', placeholder: 'name@hotel-park-soltau.de', required: true, live: true },
|
||||
{ key: 'name', label: 'Name', placeholder: 'Max Mustermann', required: true, live: true },
|
||||
], (values, inputs, root) => {
|
||||
const email = (values.email || '').trim();
|
||||
const slug = slugifyHeaderName(values.name || '');
|
||||
const liveHint = root.querySelector('[data-live="name"]');
|
||||
const file = (email && slug) ? (email + '.' + slug + '.html') : '';
|
||||
if (liveHint) liveHint.innerHTML = file ? 'Datei: <span class="preview-name">' + esc(file) + '</span>' : 'E-Mail und Name eingeben.';
|
||||
});
|
||||
if (!res) return;
|
||||
const email = res.email.trim();
|
||||
const slug = slugifyHeaderName(res.name);
|
||||
if (!email || !slug) { toast('E-Mail und Name erforderlich.', 'error'); return; }
|
||||
const file = email + '.' + slug + '.html';
|
||||
const path = SIG_HEADERS + '/' + file;
|
||||
if (await existsInTree(path)) { toast('Diese Signatur existiert bereits.', 'error'); return; }
|
||||
openNewFile(path, 'Signatur: ' + headerLabel(file), 'header');
|
||||
}
|
||||
|
||||
async function newDepartment() {
|
||||
const res = await promptModal('Neue Abteilung', [
|
||||
{ key: 'name', label: 'Abteilungsname', placeholder: 'z. B. Rezeption', required: true, hint: 'Wird als Ordner im Repository angelegt.' },
|
||||
]);
|
||||
if (!res) return;
|
||||
const name = res.name.trim();
|
||||
if (/[\/\\:*?"<>|]/.test(name)) { toast('Ungültiger Abteilungsname.', 'error'); return; }
|
||||
try {
|
||||
const r = await api('/api/departments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name }),
|
||||
});
|
||||
toast('Abteilung „' + (r.name || name) + '“ angelegt.', 'success');
|
||||
await loadTree();
|
||||
} catch (e) {
|
||||
toast('Abteilung anlegen fehlgeschlagen: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfen, ob ein Pfad schon im aktuellen Baum vorkommt.
|
||||
function existsInTree(path) {
|
||||
const t = state.tree;
|
||||
if (!t) return false;
|
||||
const lists = [];
|
||||
Object.keys(t.templates || {}).forEach((k) => lists.push(t.templates[k]));
|
||||
Object.keys(t.users || {}).forEach((k) => lists.push(t.users[k]));
|
||||
lists.push(t.footers || [], t.headers || []);
|
||||
return lists.some((arr) => (arr || []).some((f) => f.path === path));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Event-Bindungen
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
function bindEvents() {
|
||||
// Sektionen ein-/ausklappen
|
||||
document.querySelectorAll('.section-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const key = btn.dataset.toggle;
|
||||
state.collapsed[key] = !state.collapsed[key];
|
||||
applySectionCollapse();
|
||||
});
|
||||
});
|
||||
|
||||
// Sidebar-Aktionen
|
||||
el.btnRefresh.addEventListener('click', loadTree);
|
||||
if (el.treeSearch) el.treeSearch.addEventListener('input', applyTreeFilter);
|
||||
el.btnNewDept.addEventListener('click', (e) => { e.stopPropagation(); newDepartment(); });
|
||||
el.btnNewFooter.addEventListener('click', (e) => { e.stopPropagation(); newFooter(); });
|
||||
el.btnNewHeader.addEventListener('click', (e) => { e.stopPropagation(); newHeader(); });
|
||||
|
||||
// Editor-Aktionen
|
||||
el.btnSave.addEventListener('click', saveCurrent);
|
||||
el.btnReload.addEventListener('click', reloadCurrent);
|
||||
el.btnDelete.addEventListener('click', deleteCurrent);
|
||||
|
||||
// View-Tabs
|
||||
el.tabVisual.addEventListener('click', () => setView('visual'));
|
||||
el.tabHtml.addEventListener('click', () => setView('html'));
|
||||
|
||||
// Visuell editieren
|
||||
el.visualEditor.addEventListener('input', afterVisualEdit);
|
||||
// HTML editieren
|
||||
el.htmlEditor.addEventListener('input', () => {
|
||||
markDirty();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// Toolbar
|
||||
bindToolbar();
|
||||
el.fmtColorSwatch.style.background = el.fmtColor.value;
|
||||
|
||||
// Tastenkürzel: Strg/Cmd+S = Speichern
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||||
e.preventDefault();
|
||||
if (state.current && !el.btnSave.disabled) saveCurrent();
|
||||
}
|
||||
// Escape schließt offene Modals
|
||||
if (e.key === 'Escape') {
|
||||
if (!el.promptBackdrop.hidden) el.promptCancel.click();
|
||||
else if (!el.confirmBackdrop.hidden) el.confirmCancel.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Vor Verlassen warnen, wenn ungespeichert
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (state.dirty) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
return '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Start
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
async function init() {
|
||||
bindEvents();
|
||||
setDirty(false);
|
||||
const ok = await loadConfigAndHealth();
|
||||
if (ok) {
|
||||
await loadTree();
|
||||
} else if (state.config && !state.config.configured) {
|
||||
// Banner ist sichtbar; kein Baum-Laden möglich.
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
151
web-editor/public/index.html
Normal file
151
web-editor/public/index.html
Normal file
@@ -0,0 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HPS Vorlagen & Signaturen</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="icon" href="logo.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- ── Topbar ── -->
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-logo"><img src="logo.svg" alt="Hotel Park Soltau" /></span>
|
||||
<span class="brand-divider" aria-hidden="true"></span>
|
||||
<span class="brand-title">Vorlagen & Signaturen</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<span id="status-pill" class="status-pill status-unknown" title="Verbindungsstatus">
|
||||
<span class="status-dot"></span>
|
||||
<span id="status-text">Verbinde…</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Config-Banner ── -->
|
||||
<div id="config-banner" class="config-banner" hidden>
|
||||
<strong>Verbindung nicht konfiguriert.</strong>
|
||||
<span>
|
||||
Bitte die Umgebungsvariablen <code>GITEA_URL</code>, <code>GITEA_OWNER</code>,
|
||||
<code>GITEA_REPO</code> und <code>GITEA_TOKEN</code> setzen (siehe <code>.env.example</code>)
|
||||
und den Dienst neu starten.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ── App ── -->
|
||||
<div class="app">
|
||||
<!-- Kategorie-Tabs -->
|
||||
<nav class="cat-tabs" role="tablist">
|
||||
<button class="cat-tab is-active" data-cat="templates" role="tab">
|
||||
<span class="cat-ico">📄</span> Vorlagen
|
||||
</button>
|
||||
<button class="cat-tab" data-cat="footers" role="tab">
|
||||
<span class="cat-ico">📜</span> Fußzeilen
|
||||
</button>
|
||||
<button class="cat-tab" data-cat="headers" role="tab">
|
||||
<span class="cat-ico">✍️</span> Signaturen
|
||||
</button>
|
||||
<span class="cat-spacer"></span>
|
||||
<button id="btn-refresh" class="icon-btn" title="Liste neu laden">⟳</button>
|
||||
</nav>
|
||||
|
||||
<div class="workspace">
|
||||
<!-- Listen-Spalte -->
|
||||
<aside class="listpane">
|
||||
<div class="listpane-head">
|
||||
<input type="search" id="tree-search" class="tree-search" placeholder="Suchen…" autocomplete="off" />
|
||||
<button id="btn-list-add" class="btn btn-primary btn-sm">+ Neu</button>
|
||||
</div>
|
||||
<div id="list-body" class="list-body"></div>
|
||||
</aside>
|
||||
|
||||
<!-- Editor-Spalte -->
|
||||
<main class="editorpane">
|
||||
<!-- Leerzustand -->
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-illu" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 4h11l5 5v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z"/>
|
||||
<path d="M14 4v5h5"/><path d="M8 13h8"/><path d="M8 17h5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Wähle links einen Eintrag</h2>
|
||||
<p>Vorlage, Fußzeile oder Signatur anklicken zum Bearbeiten – oder über <strong>+ Neu</strong> einen neuen Eintrag anlegen.</p>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="editor-panel" id="editor-panel" hidden>
|
||||
<div class="editor-head">
|
||||
<div class="editor-titles">
|
||||
<h2 id="file-friendly">—</h2>
|
||||
<code id="file-path" class="file-path">—</code>
|
||||
</div>
|
||||
<div class="editor-head-actions">
|
||||
<span id="dirty-badge" class="dirty-badge" hidden>Nicht gespeichert</span>
|
||||
<button class="btn btn-ghost" id="btn-reload" title="Vom Server neu laden">Neu laden</button>
|
||||
<button class="btn btn-danger-ghost" id="btn-delete">Löschen</button>
|
||||
<button class="btn btn-primary" id="btn-save">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-tabs" role="tablist">
|
||||
<button class="editor-tab is-active" id="tab-visual" data-view="visual" role="tab">Bearbeiten</button>
|
||||
<button class="editor-tab" id="tab-html" data-view="html" role="tab">HTML</button>
|
||||
<button class="editor-tab" id="tab-preview" data-view="preview" role="tab">Vorschau</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-body">
|
||||
<div class="epane" id="pane-visual">
|
||||
<textarea id="visual-editor"></textarea>
|
||||
</div>
|
||||
<div class="epane" id="pane-html" hidden>
|
||||
<textarea class="html-editor" id="html-editor" spellcheck="false" wrap="soft"></textarea>
|
||||
</div>
|
||||
<div class="epane" id="pane-preview" hidden>
|
||||
<div class="preview-frame-wrap">
|
||||
<iframe class="preview-frame" id="preview-frame" title="Vorschau" sandbox=""></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Toasts ── -->
|
||||
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
|
||||
|
||||
<!-- ── Lade-Overlay ── -->
|
||||
<div class="loading-overlay" id="loading-overlay" hidden>
|
||||
<div class="spinner" aria-label="Lädt"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Confirm-Modal ── -->
|
||||
<div class="modal-backdrop" id="confirm-backdrop" hidden>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
||||
<h3 id="confirm-title">Bestätigen</h3>
|
||||
<p id="confirm-message">Bist du sicher?</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" id="confirm-cancel">Abbrechen</button>
|
||||
<button class="btn btn-danger" id="confirm-ok">Bestätigen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Prompt-Modal ── -->
|
||||
<div class="modal-backdrop" id="prompt-backdrop" hidden>
|
||||
<form class="modal" id="prompt-form" role="dialog" aria-modal="true" aria-labelledby="prompt-title">
|
||||
<h3 id="prompt-title">Eingabe</h3>
|
||||
<div id="prompt-fields"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-ghost" id="prompt-cancel">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary" id="prompt-ok">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="/vendor/tinymce/tinymce.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
web-editor/public/logo.svg
Normal file
1
web-editor/public/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.5 KiB |
348
web-editor/public/style.css
Normal file
348
web-editor/public/style.css
Normal file
@@ -0,0 +1,348 @@
|
||||
/* HPS Vorlagen & Signaturen — Web-Editor
|
||||
* Modernes, ruhiges Layout für nicht-technische Anwender.
|
||||
* Hotel-Park-Soltau-CI: Olivgrün (#95a322) + Anthrazit (#3c3c3b).
|
||||
*/
|
||||
|
||||
/* ── Tokens ── */
|
||||
:root {
|
||||
--brand: #647219; /* tiefes Oliv – weiße Schrift bleibt lesbar */
|
||||
--brand-600: #556114;
|
||||
--brand-700: #45500f;
|
||||
--brand-50: #f1f4e1;
|
||||
--brand-100: #e0e7bf;
|
||||
--accent: #95a322; /* reines Logo-Grün (Akzente) */
|
||||
--charcoal: #3c3c3b;
|
||||
--charcoal-2: #2f2f2e;
|
||||
|
||||
--bg: #eceff1;
|
||||
--panel: #ffffff;
|
||||
--sidebar: #f6f8f9;
|
||||
--text: #232c2e;
|
||||
--muted: #687279;
|
||||
--muted-2: #97a1a7;
|
||||
--border: #e4e8eb;
|
||||
--border-strong:#d2d9dd;
|
||||
|
||||
--danger: #d6453f;
|
||||
--danger-50: #fdecea;
|
||||
--success: #2f9e6b;
|
||||
--info: #2b6c8f;
|
||||
|
||||
--radius: 16px;
|
||||
--radius-md: 11px;
|
||||
--radius-sm: 8px;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(20,40,45,.06), 0 1px 3px rgba(20,40,45,.07);
|
||||
--shadow-md: 0 6px 18px rgba(20,40,45,.10), 0 2px 6px rgba(20,40,45,.06);
|
||||
--shadow-lg: 0 20px 56px rgba(20,40,45,.24);
|
||||
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Noto Sans", sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
|
||||
--topbar-h: 60px;
|
||||
--tabs-h: 56px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html, body {
|
||||
margin: 0; height: 100%;
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-size: 15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
button { font-family: inherit; }
|
||||
|
||||
/* ── Topbar ── */
|
||||
.topbar {
|
||||
height: var(--topbar-h);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: linear-gradient(100deg, var(--charcoal-2), var(--charcoal) 55%, #46463f);
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow-md);
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 14px; }
|
||||
.brand-logo {
|
||||
display: flex; align-items: center;
|
||||
background: #fff; border-radius: 10px; padding: 5px 11px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.brand-logo img { height: 30px; display: block; }
|
||||
.brand-divider { width: 1px; height: 26px; background: rgba(255,255,255,.22); }
|
||||
.brand-title { font-size: 15px; font-weight: 600; letter-spacing: .3px; color: rgba(255,255,255,.92); }
|
||||
.topbar-right { display: flex; align-items: center; gap: 14px; }
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 6px 13px; border-radius: 999px;
|
||||
font-size: 12.5px; font-weight: 600;
|
||||
background: rgba(255,255,255,.12); color: #fff;
|
||||
border: 1px solid rgba(255,255,255,.18);
|
||||
max-width: 46vw; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
||||
}
|
||||
.status-pill .status-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted-2); flex: none; }
|
||||
.status-ok .status-dot { background: #7bd14f; box-shadow: 0 0 0 3px rgba(123,209,79,.25); }
|
||||
.status-error .status-dot { background: #ff8a82; box-shadow: 0 0 0 3px rgba(255,138,130,.25); }
|
||||
.status-ok { background: rgba(149,163,34,.22); border-color: rgba(149,163,34,.4); }
|
||||
.status-error { background: rgba(255,138,130,.16); border-color: rgba(255,138,130,.4); }
|
||||
|
||||
/* ── Config-Banner ── */
|
||||
.config-banner {
|
||||
margin: 16px 22px 0; padding: 14px 18px;
|
||||
background: #fff7e6; border: 1px solid #f0d8a0; border-left: 4px solid var(--accent);
|
||||
border-radius: var(--radius-md); color: #6a5320; font-size: 13.5px; line-height: 1.55;
|
||||
}
|
||||
.config-banner code { font-family: var(--mono); background: #faedce; padding: 1px 6px; border-radius: 5px; font-size: 12.5px; }
|
||||
|
||||
/* ── App / Tabs ── */
|
||||
.app { height: calc(100vh - var(--topbar-h)); display: flex; flex-direction: column; }
|
||||
|
||||
.cat-tabs {
|
||||
height: var(--tabs-h);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 0 18px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cat-tab {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
border: none; background: none; cursor: pointer;
|
||||
padding: 9px 16px; border-radius: 999px;
|
||||
font-size: 14px; font-weight: 600; color: var(--muted);
|
||||
transition: all .15s;
|
||||
}
|
||||
.cat-tab .cat-ico { font-size: 15px; }
|
||||
.cat-tab:hover { background: var(--brand-50); color: var(--brand-600); }
|
||||
.cat-tab.is-active { background: var(--brand); color: #fff; box-shadow: var(--shadow-sm); }
|
||||
.cat-spacer { flex: 1; }
|
||||
.icon-btn {
|
||||
width: 36px; height: 36px; border-radius: 9px;
|
||||
border: 1px solid var(--border-strong); background: #fff; color: var(--muted);
|
||||
font-size: 17px; cursor: pointer; transition: all .15s;
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.icon-btn:hover { color: var(--brand); border-color: var(--brand-100); background: var(--brand-50); }
|
||||
|
||||
/* ── Workspace: Liste + Editor ── */
|
||||
.workspace { flex: 1; display: grid; grid-template-columns: 300px 1fr; min-height: 0; }
|
||||
|
||||
.listpane {
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.listpane-head {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
padding: 14px 14px 10px;
|
||||
}
|
||||
.tree-search {
|
||||
flex: 1; min-width: 0;
|
||||
border: 1px solid var(--border-strong); background: #fff; border-radius: var(--radius-sm);
|
||||
padding: 9px 12px; font-size: 13.5px; color: var(--text); outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.tree-search::placeholder { color: var(--muted-2); }
|
||||
.tree-search:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
|
||||
|
||||
.list-body { flex: 1; overflow-y: auto; padding: 4px 10px 30px; }
|
||||
|
||||
/* Gruppen (Vorlagen je Abteilung) */
|
||||
.tree-group { margin-bottom: 4px; }
|
||||
.group-title {
|
||||
width: 100%; text-align: left;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
padding: 9px 10px; border-radius: var(--radius-sm);
|
||||
font-size: 12px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--muted);
|
||||
transition: background .12s;
|
||||
}
|
||||
.group-title:hover { background: #eaeef0; color: var(--brand-600); }
|
||||
.group-title .g-caret { font-size: 9px; color: var(--muted-2); width: 10px; transition: transform .18s; }
|
||||
.group-title.collapsed .g-caret { transform: rotate(-90deg); }
|
||||
.group-title .group-icon { font-size: 14px; }
|
||||
.group-title .g-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: none; letter-spacing: normal; font-size: 13px; }
|
||||
.group-title .g-count { font-size: 11px; font-weight: 600; color: var(--muted-2); background: #fff; border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; }
|
||||
.group-files { display: flex; flex-direction: column; gap: 2px; padding: 2px 0 6px 4px; }
|
||||
.group-files.collapsed { display: none; }
|
||||
|
||||
/* Datei-Items */
|
||||
.tree-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 11px; border-radius: var(--radius-sm); cursor: pointer;
|
||||
font-size: 13.5px; color: #3a464c;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.tree-item:hover { background: #eaeef0; color: var(--text); }
|
||||
.tree-item .ti-icon { font-size: 14px; opacity: .85; flex: none; }
|
||||
.tree-item .ti-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tree-item.is-active { background: var(--brand); color: #fff; box-shadow: var(--shadow-sm); }
|
||||
.tree-item.is-active .ti-icon { opacity: 1; }
|
||||
|
||||
.tree-empty { padding: 10px 12px; font-size: 12.5px; color: var(--muted-2); font-style: italic; }
|
||||
|
||||
.add-item {
|
||||
margin: 3px 0 2px; padding: 8px 11px; width: 100%; text-align: left;
|
||||
background: none; border: 1px dashed var(--border-strong); color: var(--muted);
|
||||
border-radius: var(--radius-sm); font-size: 12.5px; font-weight: 600; cursor: pointer;
|
||||
transition: all .15s;
|
||||
}
|
||||
.add-item:hover { color: var(--brand); border-color: var(--brand-100); background: var(--brand-50); }
|
||||
|
||||
/* ── Editor-Spalte ── */
|
||||
.editorpane { min-height: 0; display: flex; flex-direction: column; padding: 18px; }
|
||||
|
||||
.empty-state { margin: auto; text-align: center; max-width: 420px; color: var(--muted); }
|
||||
.empty-illu { color: var(--brand-100); margin-bottom: 6px; }
|
||||
.empty-state h2 { margin: 6px 0 8px; color: var(--text); font-size: 21px; font-weight: 680; }
|
||||
.empty-state p { margin: 0; line-height: 1.6; font-size: 14px; }
|
||||
|
||||
.editor-panel {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow-md);
|
||||
display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden;
|
||||
}
|
||||
.editor-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
|
||||
padding: 18px 22px 15px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.editor-titles { min-width: 0; }
|
||||
.editor-titles h2 {
|
||||
margin: 0 0 7px; font-size: 19px; font-weight: 680; color: var(--text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.file-path {
|
||||
display: inline-block; font-family: var(--mono); font-size: 11.5px; color: var(--muted);
|
||||
background: var(--bg); border: 1px solid var(--border); padding: 3px 9px; border-radius: 999px;
|
||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.editor-head-actions { display: flex; align-items: center; gap: 9px; flex: none; }
|
||||
.dirty-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; font-weight: 600; color: #8a6312;
|
||||
background: #fdf3df; border: 1px solid #f0dca6; padding: 5px 11px; border-radius: 999px;
|
||||
}
|
||||
.dirty-badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
border: 1px solid transparent; border-radius: var(--radius-sm); padding: 9px 16px;
|
||||
font-size: 13.5px; font-weight: 600; cursor: pointer; transition: all .15s ease;
|
||||
white-space: nowrap; line-height: 1.1;
|
||||
}
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--brand); color: #fff; box-shadow: var(--shadow-sm); }
|
||||
.btn-primary:not(:disabled):hover { background: var(--brand-600); transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||
.btn-ghost { background: #fff; color: var(--text); border-color: var(--border-strong); }
|
||||
.btn-ghost:hover { background: var(--bg); border-color: var(--muted-2); }
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-danger:hover { background: #bd3a35; }
|
||||
.btn-danger-ghost { background: #fff; color: var(--danger); border-color: #ecc4c2; }
|
||||
.btn-danger-ghost:hover { background: var(--danger-50); border-color: var(--danger); }
|
||||
.btn-sm { padding: 8px 13px; font-size: 12.5px; }
|
||||
|
||||
/* Editor-Tabs */
|
||||
.editor-tabs { display: flex; gap: 4px; padding: 12px 22px 0; }
|
||||
.editor-tab {
|
||||
border: none; background: none; cursor: pointer;
|
||||
padding: 9px 18px; border-radius: 9px 9px 0 0;
|
||||
font-size: 13.5px; font-weight: 600; color: var(--muted);
|
||||
border-bottom: 2px solid transparent; transition: all .15s;
|
||||
}
|
||||
.editor-tab:hover { color: var(--text); background: var(--brand-50); }
|
||||
.editor-tab.is-active { color: var(--brand); border-bottom-color: var(--brand); }
|
||||
|
||||
/* Editor-Body */
|
||||
.editor-body { flex: 1; min-height: 0; display: flex; padding: 14px 22px 22px; }
|
||||
.epane { flex: 1; min-height: 0; display: flex; }
|
||||
|
||||
/* TinyMCE soll die Spalte füllen und runde Ecken haben */
|
||||
#pane-visual { flex-direction: column; }
|
||||
.tox.tox-tinymce { flex: 1; border-radius: var(--radius-md) !important; border-color: var(--border-strong) !important; }
|
||||
.tox .tox-editor-header { box-shadow: none !important; }
|
||||
|
||||
.html-editor {
|
||||
flex: 1; width: 100%; resize: none;
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius-md);
|
||||
background: #fbfcfd; padding: 16px 18px;
|
||||
font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: #2a3a42;
|
||||
outline: none; tab-size: 2; transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.html-editor:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
|
||||
|
||||
#pane-preview { }
|
||||
.preview-frame-wrap {
|
||||
flex: 1; border: 1px solid var(--border); border-radius: var(--radius-md);
|
||||
background: #e7ebee; overflow: hidden; min-height: 0;
|
||||
background-image: radial-gradient(rgba(60,60,59,.07) 1px, transparent 1px);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
.preview-frame { width: 100%; height: 100%; border: none; background: transparent; }
|
||||
|
||||
/* ── Toasts ── */
|
||||
.toast-stack { position: fixed; right: 20px; bottom: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 60; max-width: 380px; }
|
||||
.toast {
|
||||
display: flex; align-items: flex-start; gap: 11px;
|
||||
background: #fff; border: 1px solid var(--border); border-left: 4px solid var(--info);
|
||||
border-radius: var(--radius-md); box-shadow: var(--shadow-lg);
|
||||
padding: 13px 16px; font-size: 13.5px; line-height: 1.45; color: var(--text);
|
||||
animation: toast-in .26s cubic-bezier(.21,1.02,.73,1);
|
||||
}
|
||||
.toast-icon { width: 22px; height: 22px; flex: none; display: grid; place-items: center; border-radius: 50%; font-size: 13px; font-weight: 700; color: #fff; background: var(--info); }
|
||||
.toast-msg { padding-top: 1px; }
|
||||
.toast-success { border-left-color: var(--success); } .toast-success .toast-icon { background: var(--success); }
|
||||
.toast-error { border-left-color: var(--danger); } .toast-error .toast-icon { background: var(--danger); }
|
||||
.toast.fade-out { animation: toast-out .3s ease forwards; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(12px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
@keyframes toast-out { to { opacity: 0; transform: translateX(20px); } }
|
||||
|
||||
/* ── Lade-Overlay ── */
|
||||
.loading-overlay { position: fixed; inset: 0; background: rgba(20,40,45,.28); backdrop-filter: blur(2px); display: grid; place-items: center; z-index: 70; }
|
||||
.spinner { width: 44px; height: 44px; border: 4px solid rgba(255,255,255,.4); border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Modals ── */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(18,30,33,.45); backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 80; padding: 20px; animation: fade-in .15s ease; }
|
||||
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
.modal { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow-lg); width: 100%; max-width: 440px; padding: 24px 24px 20px; animation: modal-pop .2s cubic-bezier(.21,1.02,.73,1); }
|
||||
@keyframes modal-pop { from { opacity: 0; transform: translateY(10px) scale(.97); } to { opacity: 1; transform: none; } }
|
||||
.modal h3 { margin: 0 0 10px; font-size: 17px; font-weight: 680; color: var(--text); }
|
||||
.modal p { margin: 0 0 4px; color: #44525a; line-height: 1.55; font-size: 14px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
|
||||
|
||||
.field { margin-top: 14px; }
|
||||
.field:first-child { margin-top: 6px; }
|
||||
.field label { display: block; font-size: 12.5px; font-weight: 650; color: var(--muted); margin-bottom: 6px; }
|
||||
.field input, .field select {
|
||||
width: 100%; border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||
padding: 10px 12px; font-size: 14px; color: var(--text); outline: none; background: #fff;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.field input:focus, .field select:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
|
||||
.field .hint { margin-top: 7px; font-size: 12.5px; color: var(--muted); line-height: 1.4; }
|
||||
.field .hint .preview-name { font-family: var(--mono); font-size: 12px; background: var(--brand-50); color: var(--brand-600); padding: 1px 6px; border-radius: 5px; }
|
||||
|
||||
/* ── Scrollbars ── */
|
||||
.list-body::-webkit-scrollbar, .html-editor::-webkit-scrollbar { width: 10px; }
|
||||
.list-body::-webkit-scrollbar-thumb, .html-editor::-webkit-scrollbar-thumb { background: #cdd6da; border-radius: 10px; border: 2px solid transparent; background-clip: content-box; }
|
||||
.list-body::-webkit-scrollbar-thumb:hover { background: #b3bfc4; background-clip: content-box; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 980px) {
|
||||
.workspace { grid-template-columns: 250px 1fr; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.app { height: auto; }
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.listpane { border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
|
||||
.editorpane { height: auto; }
|
||||
.editor-panel { min-height: 72vh; }
|
||||
.status-pill { max-width: 36vw; }
|
||||
.brand-title { display: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user