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:
@@ -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')));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user