web-editor: TinyMCE-Editor, Verwaltung, schnelles Tree-Laden, CI-Design

- TinyMCE (selbst gehostet) mit Base64-Bildeinbettung statt contenteditable
- Kategorie-Tabs Vorlagen/Fußzeilen/Signaturen + Verwaltung
  (Übersicht, Abteilungen, E-Mail-Zuordnung, Schlagwörter)
- /api/tree über rekursive git/trees-API (1 statt ~17 Anfragen)
- Plus-Jakarta-Sans-Font, SVG-Icons, farbige Abteilungs-Badges
- Platzhalter-Hinweis (nur in Signatur-Vorlage _vorlage.html)
- LOCAL- und DEMO-Modus im Server

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kendrick Bollens
2026-06-18 09:16:36 +02:00
parent 8130269f8f
commit 113bc1bc20
9 changed files with 857 additions and 1103 deletions

View File

@@ -206,6 +206,20 @@ function localWrite(filepath, content) {
return { content: { sha: 'local', path: filepath } };
}
function localAllFiles() {
const out = [];
(function walk(dir) {
const abs = dir ? localResolve(dir) : localRoot;
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
if (e.name === '.git') continue;
const rel = dir ? `${dir}/${e.name}` : e.name;
if (e.isDirectory()) walk(rel);
else out.push({ path: rel, sha: 'local' });
}
})('');
return out;
}
// ── Demo backend (in-memory) ──
// A flat map of repoPath → raw content, seeded with sample data. The demo
// versions of the primitives mimic the Gitea API response shapes so the rest
@@ -324,6 +338,29 @@ if (DEMO) {
console.log('[DEMO] In-Memory-Demodaten geladen — keine echte Gitea-Verbindung nötig.');
}
// Liefert ALLE Dateien des Repos als flache Liste [{path, sha}].
// Gitea: ein einziger rekursiver git/trees-Aufruf (statt vieler listDir).
async function listAllFiles() {
if (LOCAL) return localAllFiles();
if (DEMO) return Object.keys(demoStore).map((p) => ({ path: p, sha: 'demo' }));
const out = [];
let page = 1, total = Infinity;
while (out.length < total) {
const url = `${apiBase()}/git/trees/${encodeURIComponent(GITEA_BRANCH)}?recursive=true&per_page=1000&page=${page}`;
const res = await fetch(url, { headers: giteaHeaders() });
if (res.status === 404) return [];
if (!res.ok) await giteaError('TREE', '', res);
const data = await res.json();
total = data.total_count || 0;
const blobs = (data.tree || []).filter((e) => e.type === 'blob').map((e) => ({ path: e.path, sha: e.sha }));
out.push(...blobs);
if (!data.tree || data.tree.length === 0) break;
page++;
}
return out;
}
// ── Express app ──
const app = express();
@@ -410,39 +447,38 @@ app.get('/api/files', wrap(async (req, res) => {
res.json({ folder, files });
}));
// Full inventory in one shot: departments + all template/footer/header files.
// Full inventory in ONE request (recursive tree), strukturiert aus den Pfaden.
app.get('/api/tree', wrap(async (_req, res) => {
const top = await listDir('');
const departments = top
.filter(e => e.type === 'dir'
&& ![SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(e.name)
&& !e.name.startsWith('.'))
.map(e => e.name)
.sort((a, b) => a.localeCompare(b, 'de'));
const files = await listAllFiles();
const special = [SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'];
const dirOf = (p) => { const i = p.lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); };
const baseOf = (p) => p.slice(p.lastIndexOf('/') + 1);
const mapFiles = (entries) => entries
.filter(f => f.type === 'file' && f.name.endsWith('.html'))
.map(f => ({ name: f.name, path: f.path, sha: f.sha }))
// .html-Dateien direkt in einem bestimmten Ordner
const htmlIn = (folder) => files
.filter((f) => dirOf(f.path) === folder && baseOf(f.path).endsWith('.html'))
.map((f) => ({ name: baseOf(f.path), path: f.path, sha: f.sha }))
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
// Templates: shared + each department (personal user folders listed separately).
const templateFolders = [SHARED_FOLDER, ...departments];
const templates = {};
await Promise.all(templateFolders.map(async (folder) => {
templates[folder] = mapFiles(await listDir(folder));
}));
// Abteilungen = oberste Ordner außer den Spezialordnern (auch leere, via .gitkeep)
const deptSet = new Set();
for (const f of files) {
const seg = f.path.split('/')[0];
if (f.path.includes('/') && !special.includes(seg) && !seg.startsWith('.')) deptSet.add(seg);
}
const departments = [...deptSet].sort((a, b) => a.localeCompare(b, 'de'));
const templates = { [SHARED_FOLDER]: htmlIn(SHARED_FOLDER) };
departments.forEach((d) => { templates[d] = htmlIn(d); });
// Personal user template folders under _benutzer/<email>/
const userEntries = await listDir(USER_FOLDER);
const users = {};
await Promise.all(userEntries
.filter(e => e.type === 'dir')
.map(async (e) => { users[e.name] = mapFiles(await listDir(e.path)); }));
for (const f of files) {
const parts = f.path.split('/');
if (parts[0] === USER_FOLDER && parts.length >= 3) users[parts[1]] = users[parts[1]] || [];
}
Object.keys(users).forEach((email) => { users[email] = htmlIn(`${USER_FOLDER}/${email}`); });
const footers = mapFiles(await listDir(SIG_FOOTERS));
const headers = mapFiles(await listDir(SIG_HEADERS));
res.json({ departments, templates, users, footers, headers });
res.json({ departments, templates, users, footers: htmlIn(SIG_FOOTERS), headers: htmlIn(SIG_HEADERS) });
}));
// Read a single file. Missing file → exists:false, empty content (editor can create it).
@@ -491,6 +527,40 @@ app.put('/api/abteilungen', wrap(async (req, res) => {
res.json({ success: true, ...result });
}));
// Read/write the Schlagwörter (Thunderbird-Tags) config.
app.get('/api/schlagwoerter', wrap(async (_req, res) => {
const data = await getFile(`${CONFIG_FOLDER}/schlagwoerter.json`);
let tags = [];
if (data) { try { tags = JSON.parse(fromBase64(data.content)); } catch (_) {} }
if (!Array.isArray(tags)) tags = [];
res.json({ tags, exists: !!data });
}));
app.put('/api/schlagwoerter', wrap(async (req, res) => {
const tags = req.body?.tags;
if (!Array.isArray(tags)) return res.status(400).json({ error: 'Ungültige Schlagwörter' });
const json = JSON.stringify(tags, null, 2);
const result = await saveFile(`${CONFIG_FOLDER}/schlagwoerter.json`, json, 'schlagwoerter.json aktualisiert (Web-Editor)');
res.json({ success: true, ...result });
}));
// Delete a whole department folder (all its files). Destructive guarded.
app.delete('/api/departments', wrap(async (req, res) => {
const name = (req.body?.name || '').trim();
if (!name) return res.status(400).json({ error: 'Kein Name angegeben' });
if ([SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(name) || name.includes('/') || name.includes('..')) {
return res.status(400).json({ error: 'Geschützter oder ungültiger Ordner' });
}
const entries = await listDir(name);
let deleted = 0;
for (const f of entries) {
if (f.type !== 'file') continue;
const fd = await getFile(f.path);
if (fd) { await deleteFile(f.path, fd.sha, `Abteilung "${name}" gelöscht (Web-Editor)`); deleted++; }
}
res.json({ success: true, deleted });
}));
// TinyMCE (selbst gehostet, offline-tauglich) direkt aus node_modules ausliefern.
app.use('/vendor/tinymce', express.static(path.join(__dirname, 'node_modules', 'tinymce')));