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:
Kendrick Bollens
2026-06-18 00:12:33 +02:00
parent edb979a1b2
commit eff90e9517
23 changed files with 3437 additions and 41 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
defaults.local.json
node_modules/

42
CLAUDE.md Normal file
View File

@@ -0,0 +1,42 @@
# CLAUDE.md
Thunderbird-MailExtension „HPS Vorlagen & Signaturen" mit Gitea-Sync.
## Workflow (WICHTIG)
1. **Nach jeder Code-Änderung am Plugin immer die `.xpi` neu bauen** (siehe Build unten),
damit `templates-reply-hotel.xpi` aktuell ist.
2. **Sobald der User zufrieden ist ("happy"), committen** — Code-Änderung + neu gebaute
`.xpi` zusammen. Nicht ungefragt vorher committen; auf das OK des Users warten.
## Build der .xpi
Immer **ohne** `defaults.local.json` bauen — die Datei enthält den Gitea-Token und darf
nicht in der (öffentlich released) `.xpi` landen. `zip` ist nicht installiert, `7z` schon:
```bash
rm -f templates-reply-hotel.xpi
7z a -tzip templates-reply-hotel.xpi . \
-xr'!.git' -xr'!node_modules' -xr'!web-editor' -xr'!.claude' \
-xr'!defaults.local.json' -xr'!*.xpi' -xr'!release.sh' -xr'!*.md'
```
`defaults.local.json` wird nur beim allerersten Start gelesen (`templates_options.js`,
`if (!config)`) und ist nur zum Vorkonfigurieren frischer Installationen gedacht. Updates
brauchen sie nicht — bestehende Installs behalten ihre Config in `storage.local`.
## Auto-Update (self-hosted über Gitea)
- `manifest.json``browser_specific_settings.gecko.update_url` zeigt auf `updates.json`
(raw auf `main`). Repo muss öffentlich bleiben, sonst 401 für den anonymen Updater.
- Neues Release veröffentlichen: `version` in `manifest.json` bumpen, `.xpi` neu bauen, dann
`GITEA_TOKEN=… ./release.sh` (hasht die xpi, aktualisiert `updates.json`, legt das Gitea-
Release an + lädt die xpi hoch). `release.sh` bricht ab, falls der Token in der xpi steckt.
- Danach `updates.json` + `manifest.json` committen & pushen.
## Repo
- Sync-Daten-Repo (Templates/Signaturen): `hps/email-vorlagen` auf `git.hotel-park-soltau.de`.
- Plugin-Source + Release-Host: `kendrick.bollens/hps-thunderbird-templates`.
**Muss public bleiben** — der Thunderbird-Auto-Updater greift anonym (ohne Token) auf
`updates.json` und die Release-`.xpi` zu. Privat = 401 = Auto-Updates kaputt.

View File

@@ -95,6 +95,23 @@ Wenn eine `defaults.local.json` im Plugin-Root existiert und in die XPI eingebau
Die Datei ist in `.gitignore` — Tokens landen nicht im Repository.
## Auto-Update (self-hosted über Gitea)
Installierte Add-ons aktualisieren sich automatisch über `updates.json` in diesem Repo
(`manifest.json``browser_specific_settings.gecko.update_url`).
> **⚠️ Dieses Repository muss public bleiben.**
> Der Thunderbird-Auto-Updater greift **anonym (ohne Token)** auf `updates.json` und die
> Release-`.xpi` zu. Ist das Repo privat, liefert Gitea `401` — die automatischen Updates
> funktionieren dann nicht mehr.
Neue Version veröffentlichen:
1. `version` in `manifest.json` hochzählen, `.xpi` **ohne** `defaults.local.json` neu bauen.
2. `GITEA_TOKEN=… ./release.sh` — hasht die `.xpi`, aktualisiert `updates.json`, legt das
Gitea-Release an und lädt die `.xpi` als Asset hoch.
3. `updates.json` + `manifest.json` committen & pushen.
## Einrichtung
1. **Verbindung konfigurieren**: Einstellungen-Tab (⚙) → Server-URL, Repository, Token eingeben → Verbindung speichern (entfällt bei vorkonfigurierter XPI)

View File

@@ -250,6 +250,9 @@ class SyncManager {
const folders = [SHARED_FOLDER];
if (this.department) folders.push(this.department);
if (this.authorEmail) folders.push(`${USER_FOLDER}/${this.authorEmail}`);
// Signatur-Bausteine mitüberwachen, damit Footer-/Header-Änderungen
// auf dem Server den Hintergrund-Sync (pullSignatures) auslösen.
folders.push('signatures/footers', 'signatures/headers');
for (const folder of folders) {
const files = await this.client.listDir(folder);
@@ -461,25 +464,50 @@ class SyncManager {
}
/**
* Load footer for editing (returns HTML)
* Resolve the footer file path for a given scope.
* - 'shared' → signatures/footers/_default.html (gilt für alle Abteilungen)
* - 'department' → signatures/footers/{department}.html
*/
async loadFooter() {
footerPathForScope(scope) {
if (scope === 'shared') return 'signatures/footers/_default.html';
if (!this.department) throw new Error('Keine Abteilung ausgewählt');
return `signatures/footers/${this.department}.html`;
}
/**
* Load footer for editing (returns HTML).
* - scope 'shared'/'department': lädt genau diese Datei (kein Fallback,
* damit der Editor den echten Inhalt der gewählten Ebene zeigt).
* - ohne scope: Fallback-Kette (Abteilung → gemeinsam) für die
* automatische Anwendung an die Signatur.
*/
async loadFooter(scope) {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
if (scope === 'shared' || scope === 'department') {
const filepath = this.footerPathForScope(scope);
const fileData = await this.client.getFile(filepath);
const html = (fileData && fileData.content) ? GiteaClient.fromBase64(fileData.content) : '';
return { success: true, html, scope };
}
const footer = await this.pullFooter();
return { success: true, html: footer };
}
/**
* Push footer for current department to signatures/footers/{department}.html
* Push footer to the chosen scope:
* - 'shared' → signatures/footers/_default.html
* - 'department' → signatures/footers/{department}.html
*/
async pushFooter(html) {
async pushFooter(html, scope) {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
if (!this.config.authorName) throw new Error('Bitte Name eintragen');
if (!this.department) throw new Error('Keine Abteilung ausgewählt');
const filepath = `signatures/footers/${this.department}.html`;
const commitMsg = `Signatur-Footer ${this.department} - von ${this.config.authorName}`;
const targetScope = (scope === 'shared') ? 'shared' : 'department';
const filepath = this.footerPathForScope(targetScope);
const label = targetScope === 'shared' ? 'gemeinsam' : this.department;
const commitMsg = `Signatur-Footer ${label} - von ${this.config.authorName}`;
const existing = await this.client.getFile(filepath);
@@ -492,8 +520,10 @@ class SyncManager {
await this.client.createFile(filepath, html, commitMsg);
}
await browser.storage.local.set({ 'sig_footer_cache': html });
return { success: true };
// Refresh cache with the footer that actually applies (Abteilung gewinnt
// vor gemeinsam) — nicht zwingend das gerade gespeicherte html.
await this.pullFooter();
return { success: true, scope: targetScope };
}
/**
@@ -544,15 +574,29 @@ class SyncManager {
targetFile = headerMap[personalName] || null;
}
if (!targetFile) continue; // No header file yet — user hasn't set up signature
let header = null;
if (targetFile) {
const fileData = await this.client.getFile(targetFile.path);
if (!fileData) continue;
if (fileData) header = GiteaClient.fromBase64(fileData.content);
}
// Fallback: kein Server-Header → lokalen Header aus der aktuellen
// Signatur verwenden, damit der Footer trotzdem angewandt wird.
if (header === null) {
const existing = identity.signature || '';
const localHeader = SyncManager.extractHeader(existing);
// Leere, nie eingerichtete Konten nicht mit reinem Footer versehen
if (!localHeader.trim() && !existing.includes(SyncManager.FOOTER_SEPARATOR)) continue;
header = localHeader;
}
const header = GiteaClient.fromBase64(fileData.content);
loadedHeaders[email] = header;
const fullSig = SyncManager.combineSignature(header, footer);
// Nur schreiben, wenn sich etwas ändert (vermeidet unnötige Writes
// bei jedem Hintergrund-Sync)
if (fullSig === identity.signature) continue;
await browser.identities.update(identity.id, {
signature: fullSig,
signatureIsPlainText: false,
@@ -854,10 +898,10 @@ browser.runtime.onMessage.addListener(async (msg, sender) => {
return await syncManager.loadSignatureTemplate();
case 'loadFooter':
return await syncManager.loadFooter();
return await syncManager.loadFooter(msg.scope);
case 'pushFooter':
return await syncManager.pushFooter(msg.html);
return await syncManager.pushFooter(msg.html, msg.scope);
case 'autoDetect':
const config = await syncManager.autoDetect();

View File

@@ -6,7 +6,8 @@
"browser_specific_settings": {
"gecko": {
"id": "it@hotel-park-soltau.de",
"strict_min_version": "109.0"
"strict_min_version": "109.0",
"update_url": "https://git.hotel-park-soltau.de/kendrick.bollens/hps-thunderbird-templates/raw/branch/main/updates.json"
}
},
"permissions": [

68
release.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# release.sh — publish a new version of the Thunderbird add-on to Gitea
# and update updates.json so installed clients auto-update.
#
# Prereqs:
# - manifest.json "version" already bumped to the new version
# - templates-reply-hotel.xpi rebuilt for that version, WITHOUT defaults.local.json
# (the token must not ship in a public release; updates don't need defaults anyway)
# - GITEA_TOKEN exported (a Gitea token with repo write access)
#
# Usage: GITEA_TOKEN=xxxx ./release.sh
set -euo pipefail
OWNER="kendrick.bollens"
REPO="hps-thunderbird-templates"
BASE="https://git.hotel-park-soltau.de"
XPI="templates-reply-hotel.xpi"
ID="it@hotel-park-soltau.de"
cd "$(dirname "$0")"
# --- 0. Safety: never publish a build that bundles the local defaults/token ---
if grep -qa "defaults.local.json" "$XPI"; then
echo "ABORT: $XPI contains defaults.local.json (your Gitea token!)." >&2
echo " Rebuild the .xpi without it before releasing." >&2
exit 1
fi
VERSION=$(jq -r '.version' manifest.json)
MINVER=$(jq -r '.browser_specific_settings.gecko.strict_min_version' manifest.json)
TAG="v${VERSION}"
HASH=$(sha256sum "$XPI" | awk '{print $1}')
LINK="${BASE}/${OWNER}/${REPO}/releases/download/${TAG}/${XPI}"
echo "Version : $VERSION"
echo "Tag : $TAG"
echo "SHA-256 : $HASH"
# --- 1. Rewrite updates.json: prepend this version (idempotent) ---
[ -f updates.json ] || echo "{\"addons\":{\"${ID}\":{\"updates\":[]}}}" > updates.json
TMP=$(mktemp)
jq --arg id "$ID" --arg v "$VERSION" --arg link "$LINK" \
--arg hash "sha256:$HASH" --arg min "$MINVER" '
.addons[$id].updates =
([{version:$v, update_link:$link, update_hash:$hash,
applications:{gecko:{strict_min_version:$min}}}]
+ [ .addons[$id].updates[]? | select(.version != $v) ])
' updates.json > "$TMP" && mv "$TMP" updates.json
echo "updates.json updated"
# --- 2. Create the Gitea release + upload the .xpi asset ---
: "${GITEA_TOKEN:?Set GITEA_TOKEN to a Gitea token with repo write access}"
API="${BASE}/api/v1/repos/${OWNER}/${REPO}"
AUTH="Authorization: token ${GITEA_TOKEN}"
echo "Creating release $TAG ..."
REL_ID=$(curl -fsS -X POST "${API}/releases" \
-H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"main\",\"name\":\"${TAG}\"}" \
| jq -r '.id')
echo "Uploading $XPI to release $REL_ID ..."
curl -fsS -X POST "${API}/releases/${REL_ID}/assets?name=${XPI}" \
-H "$AUTH" -F "attachment=@${XPI};type=application/x-xpinstall" >/dev/null
echo
echo "Done. Now commit & push the manifest:"
echo " git add updates.json manifest.json && git commit -m \"Release ${TAG}\" && git push"

Binary file not shown.

View File

@@ -933,7 +933,13 @@
</div>
<div class="collapsible-body" id="footer-body">
<div class="card">
<div class="card-desc">Der Fußbereich wird automatisch an alle Signaturen deiner Abteilung angefügt (Banner, Links, rechtliche Angaben). Änderungen gelten für alle Mitarbeiter der Abteilung.</div>
<div class="card-desc">Der Fußbereich wird automatisch an die Signaturen angefügt (Banner, Links, rechtliche Angaben). Wähle, ob du den <strong>gemeinsamen</strong> Fußbereich (gilt für alle Abteilungen) oder den deiner <strong>Abteilung</strong> bearbeitest. Hinweis: Eine vorhandene Abteilungs-Version überschreibt beim Anwenden den gemeinsamen Fußbereich.</div>
<label for="footer-scope">Geltungsbereich</label>
<select id="footer-scope" style="margin-bottom:10px;max-width:320px;">
<option value="shared">Gemeinsam (alle Abteilungen)</option>
<option value="department">Abteilung</option>
</select>
<label>Fußbereich</label>
<div class="editor-wrapper">

View File

@@ -1119,6 +1119,42 @@ function combineSignature(header, footer) {
return header + '\n' + FOOTER_SEPARATOR + '\n' + footer;
}
// Wendet den Footer lokal auf alle eingerichteten Identitäten an,
// indem der vorhandene Header (lokal) mit dem neuen Footer neu kombiniert wird.
// Funktioniert auch ohne hochgeladenen Server-Header.
async function applyFooterLocally(footer) {
const sourceMap = await getSigSourceMap();
let applied = 0;
for (const identity of allIdentities) {
const source = sourceMap[identity.email.toLowerCase()] || 'own';
let header;
if (source.startsWith('=')) {
const srcEmail = source.substring(1);
const srcIdentity = allIdentities.find(i => i.email.toLowerCase() === srcEmail);
if (!srcIdentity) continue;
header = extractHeader(srcIdentity.signature || '');
} else {
header = extractHeader(identity.signature || '');
}
// Unangetastete, leere Konten nicht mit einem reinen Footer versehen
const alreadyManaged = (identity.signature || '').includes(FOOTER_SEPARATOR);
if (!header.trim() && !alreadyManaged) continue;
const fullSig = combineSignature(header, footer);
if (fullSig === identity.signature) { applied++; continue; }
await browser.identities.update(identity.id, {
signature: fullSig,
signatureIsPlainText: false,
});
identity.signature = fullSig;
applied++;
}
return applied;
}
// ── "Vorlage laden" Button ──
document.getElementById('sig-load-template').addEventListener('click', async () => {
@@ -1328,45 +1364,94 @@ document.getElementById('sig-sync-refresh').addEventListener('click', async () =
// ── Footer Editor ──
const footerEditorArea = document.getElementById('footer-editor-area');
const footerScopeSelect = document.getElementById('footer-scope');
setupToolbarCommands('footer-toolbar', footerEditorArea);
setupImageInsert('footer-insert-image', 'footer-image-file', footerEditorArea);
// Keep the "Abteilung" option label in sync with the selected department
function updateFooterScopeLabel() {
const dept = document.getElementById('sync-department')?.value || '';
const deptOption = footerScopeSelect?.querySelector('option[value="department"]');
if (deptOption) {
deptOption.textContent = dept ? `Abteilung: ${dept}` : 'Abteilung';
deptOption.disabled = !dept;
}
}
function currentFooterScope() {
return footerScopeSelect?.value === 'department' ? 'department' : 'shared';
}
// Default-Auswahl: Abteilungs-Footer bevorzugen, wenn einer existiert.
// Sonst gemeinsam. Wird nur für die Vorbelegung genutzt — manuelles
// Umschalten bleibt erhalten.
async function pickDefaultFooterScope() {
const dept = document.getElementById('sync-department')?.value || '';
if (!dept) return 'shared';
try {
const res = await browser.runtime.sendMessage({ action: 'loadFooter', scope: 'department' });
if (res && res.success && res.html) return 'department';
} catch (_) {}
return 'shared';
}
// Race-Schutz: nur das Ergebnis des zuletzt gestarteten Loads anwenden
let footerLoadSeq = 0;
// Load footer for the currently selected scope into the editor
async function loadFooterForScope(showStatus) {
const scope = currentFooterScope();
// Gleiche Offline-Prüfung wie bei den anderen Netzwerk-Aktionen
if (showStatus && !checkOnline()) return;
const seq = ++footerLoadSeq;
const loadBtn = document.getElementById('footer-load-button');
if (showStatus && loadBtn) loadBtn.disabled = true;
if (showStatus) showFooterStatus('Lade...', '#777');
try {
const result = await browser.runtime.sendMessage({ action: 'loadFooter', scope });
// Veralteter Lauf: ein neuerer Load wurde gestartet → Ergebnis verwerfen
if (seq !== footerLoadSeq) return;
if (result && result.success) {
footerEditorArea.innerHTML = result.html || '';
if (showStatus) {
const where = scope === 'shared' ? 'gemeinsamer' : 'Abteilungs-';
showFooterStatus(result.html ? `${where}Fußbereich geladen.` : `Noch kein ${where}Fußbereich vorhanden — du kannst einen anlegen.`, result.html ? 'green' : '#e65100');
}
} else if (showStatus) {
showFooterStatus(result?.error || 'Fehler', 'red');
}
} catch (err) {
if (seq === footerLoadSeq && showStatus) showFooterStatus('Fehler: ' + err.message, 'red');
} finally {
if (seq === footerLoadSeq && showStatus && loadBtn) loadBtn.disabled = false;
}
}
document.getElementById('footer-toggle').addEventListener('click', async function() {
this.classList.toggle('open');
document.getElementById('footer-body').classList.toggle('open');
// Auto-load footer when opening and editor is empty
if (this.classList.contains('open') && (!footerEditorArea.innerHTML || footerEditorArea.innerHTML === '<br>')) {
try {
const result = await browser.runtime.sendMessage({ action: 'loadFooter' });
if (result && result.success && result.html) {
footerEditorArea.innerHTML = result.html;
}
} catch (_) {}
updateFooterScopeLabel();
// Standardmäßig den Abteilungs-Footer vorbelegen, falls vorhanden
if (footerScopeSelect) footerScopeSelect.value = await pickDefaultFooterScope();
await loadFooterForScope(false);
}
});
// Reload editor content when switching scope
footerScopeSelect?.addEventListener('change', () => loadFooterForScope(true));
function showFooterStatus(message, color) {
const type = color === 'green' ? 'success' : color === 'red' ? 'error' : 'info';
showToast(message, type, type === 'error' ? 6000 : 4000);
}
// Load footer from server
document.getElementById('footer-load-button').addEventListener('click', async () => {
showFooterStatus('Lade...', '#777');
try {
const result = await browser.runtime.sendMessage({ action: 'loadFooter' });
if (result && result.success) {
footerEditorArea.innerHTML = result.html || '';
showFooterStatus(result.html ? 'Fußbereich geladen.' : 'Kein Fußbereich für diese Abteilung gefunden.', result.html ? 'green' : '#e65100');
} else {
showFooterStatus(result?.error || 'Fehler', 'red');
}
} catch (err) {
showFooterStatus('Fehler: ' + err.message, 'red');
}
});
document.getElementById('footer-load-button').addEventListener('click', () => loadFooterForScope(true));
// Save & push footer
document.getElementById('footer-save-button').addEventListener('click', async () => {
@@ -1376,16 +1461,57 @@ document.getElementById('footer-save-button').addEventListener('click', async ()
return;
}
if (!checkOnline()) return;
const scope = currentFooterScope();
const saveBtn = document.getElementById('footer-save-button');
saveBtn.disabled = true;
showFooterStatus('Speichere...', '#777');
try {
const result = await browser.runtime.sendMessage({ action: 'pushFooter', html });
if (result && result.success) {
showFooterStatus('Fußbereich gespeichert & hochgeladen!', 'green');
} else {
const result = await browser.runtime.sendMessage({ action: 'pushFooter', html, scope });
if (!result || !result.success) {
showFooterStatus(result?.error || 'Fehler', 'red');
return;
}
// Neuen Footer direkt (lokal) auf die Thunderbird-Identitäten anwenden —
// funktioniert auch ohne hochgeladenen Server-Header.
const footer = await getFooter();
const applied = await applyFooterLocally(footer);
// Geänderte Header zusätzlich hochladen (best effort), damit andere
// Geräte beim nächsten Sync den kombinierten Stand erhalten.
try { await browser.runtime.sendMessage({ action: 'pushSignatures' }); } catch (_) {}
for (const id of allIdentities) {
sigSyncedHashes[id.email.toLowerCase()] = simpleHash(id.signature || '');
}
saveSyncHashes();
updateSigSyncIndicator();
// Aktuell geöffneten Signatur-Editor neu rendern
sigIdentitySelect.dispatchEvent(new Event('change'));
// Wenn "gemeinsam" gespeichert wurde, aber ein Abteilungs-Footer
// existiert, verdeckt dieser den gemeinsamen beim Anwenden.
let shadowed = false;
if (scope === 'shared') {
try {
const dept = await browser.runtime.sendMessage({ action: 'loadFooter', scope: 'department' });
shadowed = !!(dept && dept.success && dept.html);
} catch (_) {}
}
const where = scope === 'shared' ? 'Gemeinsamer' : 'Abteilungs-';
if (shadowed) {
showFooterStatus('Gemeinsamer Fußbereich gespeichert — aber deine Abteilung hat einen eigenen Footer, der ihn überschreibt. Zum Anwenden den Abteilungs-Footer bearbeiten/löschen.', '#e65100');
} else if (applied > 0) {
showFooterStatus(`${where} Fußbereich gespeichert & in ${applied} Signatur(en) übernommen!`, 'green');
} else {
showFooterStatus(`${where} Fußbereich gespeichert. Hinweis: keine Signatur aktualisiert — erst eine persönliche Signatur speichern/hochladen.`, '#e65100');
}
} catch (err) {
showFooterStatus('Fehler: ' + err.message, 'red');
} finally {
saveBtn.disabled = false;
}
});
@@ -1616,6 +1742,7 @@ async function loadDepartments() {
if (dept === savedDept) opt.selected = true;
select.appendChild(opt);
}
updateFooterScopeLabel();
}
} catch (_) {}
}
@@ -1652,6 +1779,8 @@ document.getElementById('sync-department').addEventListener('change', async () =
config.department = document.getElementById('sync-department').value;
await browser.storage.local.set({ [SYNC_CONFIG_KEY]: config });
updateFooterScopeLabel();
if (config.department) {
try {
// Pull templates for new department

16
updates.json Normal file
View File

@@ -0,0 +1,16 @@
{
"addons": {
"it@hotel-park-soltau.de": {
"updates": [
{
"version": "2.3.0",
"update_link": "https://git.hotel-park-soltau.de/kendrick.bollens/hps-thunderbird-templates/releases/download/v2.3.0/templates-reply-hotel.xpi",
"update_hash": "sha256:ea22d756d6156f865453b90eced7621f34995a1a1115e1e3081b54d1f50b6a75",
"applications": {
"gecko": { "strict_min_version": "109.0" }
}
}
]
}
}
}

6
web-editor/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
npm-debug.log
.env
.git
.gitignore
*.md

23
web-editor/.env.example Normal file
View File

@@ -0,0 +1,23 @@
# ── Gitea/Forgejo-Verbindung (Pflicht) ──
# Basis-URL des Servers, ohne abschließenden Slash.
GITEA_URL=https://git.example.com
# Besitzer (Organisation oder Benutzer) und Repository-Name.
GITEA_OWNER=organisation
GITEA_REPO=email-vorlagen
GITEA_BRANCH=main
# API-Token mit Schreibrechten auf das Repository.
GITEA_TOKEN=dein-api-token
# ── Commit-Autor (optional) ──
# Name/E-Mail, mit dem Änderungen aus dem Web-Editor committet werden.
COMMIT_AUTHOR_NAME=Web-Editor
COMMIT_AUTHOR_EMAIL=
# ── Zugriffsschutz (optional, aber empfohlen) ──
# Wenn beide gesetzt sind, ist der Editor per HTTP-Basic-Auth geschützt.
BASIC_AUTH_USER=
BASIC_AUTH_PASS=
# ── Host-Port (nur docker-compose) ──
# Auf welchem Port der Editor am Host erreichbar ist (Standard 8080).
HOST_PORT=8080

3
web-editor/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.env
npm-debug.log

20
web-editor/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies first for better layer caching.
COPY package.json ./
RUN npm install --omit=dev
# App source.
COPY server.js ./
COPY public ./public
ENV PORT=3000
EXPOSE 3000
# Lightweight container healthcheck against the API.
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/api/config > /dev/null || exit 1
CMD ["node", "server.js"]

152
web-editor/README.md Normal file
View File

@@ -0,0 +1,152 @@
# HPS Vorlagen & Signaturen Web-Editor
Ein kleiner, in Docker laufender Web-Editor zum Pflegen der **E-Mail-Vorlagen** und
**Signaturen** für das Hotel Park Soltau. Er ist das Web-Gegenstück zum Thunderbird-Plugin
**„HPS Vorlagen & Signaturen“** und teilt sich mit ihm dieselbe **Gitea/Forgejo-Repository**
als gemeinsame Quelle der Wahrheit.
---
## Was kann der Editor?
- **Vorlagen für alle Abteilungen** verwalten gemeinsame Vorlagen (`_gemeinsam/`),
Abteilungsvorlagen (z. B. `Rezeption/`, `IT/`) und persönliche Vorlagen pro Benutzer
(`_benutzer/<email>/`).
- **Fußzeilen** pflegen pro Abteilung (`signatures/footers/<Abteilung>.html`) sowie eine
gemeinsame Standard-Fußzeile (`signatures/footers/_default.html`).
- **Signatur-Köpfe** bearbeiten (`signatures/headers/<email>.<name-slug>.html`).
- **WYSIWYG-Bearbeitung**, Umschalten auf den **HTML-Quelltext** und eine **Live-Vorschau**.
- Jede Änderung wird **direkt als Commit ins Gitea-Repo** geschrieben.
Der Editor ist eine kleine **Node.js/Express-App** (`server.js`), die ein statisches Frontend
aus `public/` ausliefert und die **Gitea Contents API** weiterreicht. Der **Gitea-Token bleibt
serverseitig** und ist für den Browser nicht sichtbar. Konfiguriert wird ausschließlich über
**Umgebungsvariablen**.
---
## Voraussetzungen
- **Docker** mit **docker compose** *oder* **Node.js ≥ 18**, falls ohne Docker betrieben.
- Ein **Gitea/Forgejo-API-Token mit Schreibrechten** auf das Vorlagen-Repository
(siehe [Gitea-API-Token erstellen](#gitea-api-token-erstellen)).
---
## Schnellstart mit docker compose
```bash
# 1. Vorlage kopieren und ausfüllen
cp .env.example .env
nano .env # GITEA_URL, GITEA_OWNER, GITEA_REPO, GITEA_TOKEN eintragen
# 2. Bauen und starten
docker compose up -d --build
```
Anschließend den Editor im Browser öffnen:
```
http://localhost:8080
```
Den Host-Port kannst du über `HOST_PORT` in der `.env` ändern (Standard `8080`).
docker compose mappt `${HOST_PORT:-8080}:3000`.
Logs ansehen bzw. Editor stoppen:
```bash
docker compose logs -f
docker compose down
```
---
## Alternative: plain `docker build` / `docker run`
```bash
# Image bauen
docker build -t hps-vorlagen-web-editor .
# Container starten (Umgebung aus .env, Port 8080 → 3000)
docker run -d \
--name hps-web-editor \
--restart unless-stopped \
-p 8080:3000 \
--env-file .env \
hps-vorlagen-web-editor
```
---
## Alternative: lokal ohne Docker
```bash
# Abhängigkeiten installieren
npm install
# Umgebungsvariablen setzen (Beispiel)
export GITEA_URL="https://git.example.com"
export GITEA_OWNER="organisation"
export GITEA_REPO="email-vorlagen"
export GITEA_BRANCH="main"
export GITEA_TOKEN="dein-api-token"
# Starten
npm start
```
Der Editor läuft dann auf dem in `PORT` eingestellten Port (Standard `3000`),
also unter `http://localhost:3000`.
---
## Umgebungsvariablen
| Name | Pflicht | Standard | Beschreibung |
| --------------------- | :-----: | -------------- | --------------------------------------------------------------------------- |
| `GITEA_URL` | **ja** | | Basis-URL des Gitea/Forgejo-Servers, ohne abschließenden Slash. |
| `GITEA_OWNER` | **ja** | | Besitzer des Repos (Organisation oder Benutzer). |
| `GITEA_REPO` | **ja** | | Name des Vorlagen-Repositories. |
| `GITEA_BRANCH` | nein | `main` | Branch, in den committet wird. |
| `GITEA_TOKEN` | **ja** | | API-Token mit Schreibrechten auf das Repository. Bleibt serverseitig. |
| `COMMIT_AUTHOR_NAME` | nein | `Web-Editor` | Name, mit dem Änderungen committet werden. |
| `COMMIT_AUTHOR_EMAIL` | nein | | E-Mail-Adresse des Commit-Autors. |
| `BASIC_AUTH_USER` | nein | | Benutzername für HTTP-Basic-Auth (siehe [Sicherheit](#sicherheit)). |
| `BASIC_AUTH_PASS` | nein | | Passwort für HTTP-Basic-Auth. Schutz aktiv, wenn beide Werte gesetzt sind. |
| `PORT` | nein | `3000` | Port, auf dem die App im Container/Prozess lauscht. |
| `HOST_PORT` | nein | `8080` | Nur für docker compose: Port, unter dem der Editor am Host erreichbar ist. |
Eine fertige Vorlage findest du in [`.env.example`](.env.example) einfach kopieren
und ausfüllen.
---
## Gitea-API-Token erstellen
1. In Gitea/Forgejo oben rechts auf das Profilbild → **Einstellungen**.
2. Reiter **Anwendungen** öffnen.
3. Unter **Token verwalten** einen neuen Token **generieren**.
4. Als **Scope** mindestens **Schreibrechte auf Repositories** (`repo` bzw. `write:repository`)
vergeben.
5. Den angezeigten Token **sofort kopieren** (er wird nur einmal angezeigt) und als
`GITEA_TOKEN` in die `.env` eintragen.
---
## Sicherheit
- **Wenn der Editor öffentlich erreichbar ist, unbedingt `BASIC_AUTH_USER` und
`BASIC_AUTH_PASS` setzen.** Sind beide gesetzt, ist der gesamte Editor per
HTTP-Basic-Auth geschützt.
- Die **`.env` niemals committen** sie enthält den Gitea-Token. Sie steht bereits in der
`.gitignore` und bleibt damit außerhalb der Versionskontrolle.
---
## Hinweis zum Zusammenspiel mit dem Thunderbird-Plugin
Jede im Editor gespeicherte Änderung landet **sofort als Commit im Gitea-Repo**. Das
Thunderbird-Plugin **„HPS Vorlagen & Signaturen“** nutzt dasselbe Repo als Quelle der Wahrheit
und übernimmt die Änderungen automatisch beim nächsten **Sync** (alle paar Sekunden bis
spätestens alle 15 Minuten). Ein manueller Export ist nicht nötig.

View File

@@ -0,0 +1,20 @@
services:
web-editor:
build: .
image: hps-vorlagen-web-editor
container_name: hps-web-editor
restart: unless-stopped
ports:
- "${HOST_PORT:-8080}:3000"
environment:
GITEA_URL: ${GITEA_URL}
GITEA_OWNER: ${GITEA_OWNER}
GITEA_REPO: ${GITEA_REPO}
GITEA_BRANCH: ${GITEA_BRANCH:-main}
GITEA_TOKEN: ${GITEA_TOKEN}
COMMIT_AUTHOR_NAME: ${COMMIT_AUTHOR_NAME:-Web-Editor}
COMMIT_AUTHOR_EMAIL: ${COMMIT_AUTHOR_EMAIL:-}
BASIC_AUTH_USER: ${BASIC_AUTH_USER:-}
BASIC_AUTH_PASS: ${BASIC_AUTH_PASS:-}
env_file:
- .env

838
web-editor/package-lock.json generated Normal file
View File

@@ -0,0 +1,838 @@
{
"name": "hps-vorlagen-web-editor",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hps-vorlagen-web-editor",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"express": "^4.19.2",
"tinymce": "^7.9.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/tinymce": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.9.3.tgz",
"integrity": "sha512-Mtm54U5YJ6Pyo/GaAx+JSHXTGEuxrg2AowVWCD9zy1eBolp5Ub7S1rTtsyQdxhPegfhLuR3VLiTKGw1tacv09g==",
"license": "GPL-2.0-or-later"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}

18
web-editor/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "hps-vorlagen-web-editor",
"version": "1.0.0",
"description": "Web-Editor für HPS E-Mail-Vorlagen, Signaturen, Kopf- und Fußzeilen (Gitea/Forgejo)",
"main": "server.js",
"scripts": {
"start": "node server.js",
"demo": "DEMO=1 node server.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"express": "^4.19.2",
"tinymce": "^7.9.3"
},
"license": "MIT"
}

981
web-editor/public/app.js Normal file
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// 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);
})();

View 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 &amp; 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 &amp; 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">&nbsp;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>

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
View 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; }
}

510
web-editor/server.js Normal file
View File

@@ -0,0 +1,510 @@
// server.js — HPS Vorlagen & Signaturen Web-Editor
// Express backend that proxies the Gitea/Forgejo Contents API.
// The Gitea token stays server-side (never reaches the browser) and CORS
// is avoided because the browser only ever talks to this server.
const express = require('express');
const path = require('path');
const fs = require('fs');
const {
GITEA_URL,
GITEA_OWNER,
GITEA_REPO,
GITEA_BRANCH = 'main',
GITEA_TOKEN,
PORT = 3000,
COMMIT_AUTHOR_NAME = 'Web-Editor',
COMMIT_AUTHOR_EMAIL = '',
BASIC_AUTH_USER,
BASIC_AUTH_PASS,
} = process.env;
const SHARED_FOLDER = '_gemeinsam';
const USER_FOLDER = '_benutzer';
const CONFIG_FOLDER = '_config';
const SIG_FOOTERS = 'signatures/footers';
const SIG_HEADERS = 'signatures/headers';
// Demo mode: serve an in-memory sample repo so the editor can be tried out
// without a real Gitea server. Enable with DEMO=1.
const DEMO = process.env.DEMO === '1' || process.env.DEMO === 'true';
// Local mode: read/write a local checkout of the repo on disk instead of
// talking to Gitea. Useful for inspecting/editing real content offline.
const LOCAL_REPO = process.env.LOCAL_REPO || '';
const LOCAL = !!LOCAL_REPO;
const localRoot = LOCAL ? path.resolve(LOCAL_REPO) : null;
const configured = DEMO || LOCAL || !!(GITEA_URL && GITEA_OWNER && GITEA_REPO && GITEA_TOKEN);
if (!configured) {
console.warn('[WARN] Gitea-Verbindung unvollständig konfiguriert. ' +
'Bitte GITEA_URL, GITEA_OWNER, GITEA_REPO und GITEA_TOKEN setzen (siehe .env.example).');
}
// ── Gitea API client ──
const apiBase = () => `${GITEA_URL.replace(/\/$/, '')}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}`;
const giteaHeaders = () => ({
'Authorization': `token ${GITEA_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
});
const authorInfo = () => {
if (!COMMIT_AUTHOR_NAME) return {};
return {
author: {
name: COMMIT_AUTHOR_NAME,
email: COMMIT_AUTHOR_EMAIL ||
`${COMMIT_AUTHOR_NAME.toLowerCase().replace(/\s+/g, '.')}@local`,
},
};
};
const encodePath = (p) => p.split('/').map(encodeURIComponent).join('/');
const toBase64 = (str) => Buffer.from(str, 'utf-8').toString('base64');
const fromBase64 = (b64) => Buffer.from((b64 || '').replace(/\s/g, ''), 'base64').toString('utf-8');
async function giteaError(method, filepath, res) {
let detail = '';
try { const body = await res.json(); detail = body.message || JSON.stringify(body); } catch (_) {}
const err = new Error(`${method} ${filepath}: ${res.status} ${res.statusText}${detail ? ' — ' + detail : ''}`);
err.status = res.status;
throw err;
}
async function getFile(filepath) {
if (LOCAL) return localGetFile(filepath);
if (DEMO) return demoGetFile(filepath);
const url = `${apiBase()}/contents/${encodePath(filepath)}?ref=${encodeURIComponent(GITEA_BRANCH)}`;
const res = await fetch(url, { headers: giteaHeaders() });
if (res.status === 404) return null;
if (!res.ok) await giteaError('GET', filepath, res);
return res.json();
}
async function listDir(dirpath) {
if (LOCAL) return localListDir(dirpath);
if (DEMO) return demoListDir(dirpath);
const pathPart = dirpath ? `/${encodePath(dirpath)}` : '';
const url = `${apiBase()}/contents${pathPart}?ref=${encodeURIComponent(GITEA_BRANCH)}`;
const res = await fetch(url, { headers: giteaHeaders() });
if (res.status === 404) return [];
if (!res.ok) await giteaError('LIST', dirpath, res);
const result = await res.json();
return Array.isArray(result) ? result : [];
}
async function createFile(filepath, content, message) {
if (LOCAL) return localWrite(filepath, content);
if (DEMO) return demoWrite(filepath, content);
const url = `${apiBase()}/contents/${encodePath(filepath)}`;
const res = await fetch(url, {
method: 'POST',
headers: giteaHeaders(),
body: JSON.stringify({
content: toBase64(content),
message: message || `Add ${filepath}`,
branch: GITEA_BRANCH,
...authorInfo(),
}),
});
if (!res.ok) await giteaError('POST', filepath, res);
return res.json();
}
async function updateFile(filepath, content, sha, message) {
if (LOCAL) return localWrite(filepath, content);
if (DEMO) return demoWrite(filepath, content);
const url = `${apiBase()}/contents/${encodePath(filepath)}`;
const res = await fetch(url, {
method: 'PUT',
headers: giteaHeaders(),
body: JSON.stringify({
content: toBase64(content),
sha,
message: message || `Update ${filepath}`,
branch: GITEA_BRANCH,
...authorInfo(),
}),
});
if (!res.ok) await giteaError('PUT', filepath, res);
return res.json();
}
async function deleteFile(filepath, sha, message) {
if (LOCAL) { fs.rmSync(localResolve(filepath), { force: true }); return { success: true }; }
if (DEMO) { delete demoStore[filepath]; return { success: true }; }
const url = `${apiBase()}/contents/${encodePath(filepath)}`;
const res = await fetch(url, {
method: 'DELETE',
headers: giteaHeaders(),
body: JSON.stringify({
sha,
message: message || `Delete ${filepath}`,
branch: GITEA_BRANCH,
...authorInfo(),
}),
});
if (!res.ok) await giteaError('DELETE', filepath, res);
return res.json();
}
// Save = create-or-update, server resolves the latest sha to avoid races.
async function saveFile(filepath, content, message) {
const existing = await getFile(filepath);
if (existing && typeof existing.content === 'string') {
const existingContent = fromBase64(existing.content);
if (existingContent === content) {
return { unchanged: true, sha: existing.sha };
}
const r = await updateFile(filepath, content, existing.sha, message);
return { sha: r.content?.sha };
}
const r = await createFile(filepath, content, message);
return { sha: r.content?.sha };
}
// ── Local filesystem backend ──
// Reads/writes a local checkout of the repo. Mimics the Gitea response shapes
// so the rest of the server is unchanged. Paths are confined to localRoot.
function localResolve(filepath) {
const abs = path.resolve(localRoot, filepath);
if (abs !== localRoot && !abs.startsWith(localRoot + path.sep)) {
throw Object.assign(new Error('Pfad außerhalb des Repos'), { status: 400 });
}
return abs;
}
function localGetFile(filepath) {
const abs = localResolve(filepath);
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
const content = fs.readFileSync(abs, 'utf-8');
return { path: filepath, name: path.basename(filepath), type: 'file', sha: 'local', content: toBase64(content) };
}
function localListDir(dirpath) {
const abs = localResolve(dirpath || '.');
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return [];
return fs.readdirSync(abs, { withFileTypes: true })
.filter(e => e.name !== '.git')
.map(e => ({
name: e.name,
path: dirpath ? `${dirpath}/${e.name}` : e.name,
type: e.isDirectory() ? 'dir' : 'file',
sha: 'local',
}));
}
function localWrite(filepath, content) {
const abs = localResolve(filepath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf-8');
return { content: { sha: 'local', path: filepath } };
}
// ── 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
// of the server works unchanged.
const demoStore = {};
function demoGetFile(filepath) {
if (!(filepath in demoStore)) return null;
return { path: filepath, name: filepath.split('/').pop(), type: 'file', sha: 'demo', content: toBase64(demoStore[filepath]) };
}
function demoListDir(dirpath) {
const prefix = dirpath ? `${dirpath}/` : '';
const out = [];
const seenDirs = new Set();
for (const p of Object.keys(demoStore)) {
if (prefix && !p.startsWith(prefix)) continue;
const rest = p.slice(prefix.length);
const slash = rest.indexOf('/');
if (slash === -1) {
out.push({ name: rest, path: p, type: 'file', sha: 'demo' });
} else {
const dir = rest.slice(0, slash);
if (!seenDirs.has(dir)) {
seenDirs.add(dir);
out.push({ name: dir, path: prefix + dir, type: 'dir', sha: 'demo' });
}
}
}
return out;
}
function demoWrite(filepath, content) {
demoStore[filepath] = content;
return { content: { sha: 'demo', path: filepath } };
}
function seedDemo() {
const sig = (name, role) =>
`<div style="font-family:Arial,sans-serif;font-size:13px;color:#333;line-height:1.5">
<strong style="color:#0d3b66">${name}</strong><br>
<span style="color:#666">${role}</span><br>
Hotel Park Soltau · Winsener Straße 111 · 29614 Soltau
</div>`;
Object.assign(demoStore, {
// Empty department (only a .gitkeep) — shows up with no templates yet.
'Haustechnik/.gitkeep': '',
'_config/abteilungen.json': JSON.stringify({
'info@hotel-park-soltau.de': 'Rezeption',
'veranstaltung@hotel-park-soltau.de': 'Veranstaltungsbuero',
'it@hotel-park-soltau.de': 'IT',
'haustechnik@hotel-park-soltau.de': 'Haustechnik',
}, null, 2),
'_gemeinsam/Begruessung.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Sehr geehrte Damen und Herren,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">vielen Dank f&uuml;r Ihre Nachricht an das <strong>Hotel Park Soltau</strong>. Wir freuen uns &uuml;ber Ihr Interesse und melden uns schnellstm&ouml;glich bei Ihnen.</p>`,
'_gemeinsam/Buchungsbestaetigung.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Guten Tag,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">wir best&auml;tigen Ihnen hiermit Ihre Buchung im Hotel Park Soltau. Bei Fragen stehen wir Ihnen jederzeit gerne zur Verf&uuml;gung.</p>
<ul style="font-family:Arial,sans-serif;font-size:14px;color:#222">
<li>Anreise: ab 15:00 Uhr</li>
<li>Abreise: bis 11:00 Uhr</li>
<li>Inklusive Fr&uuml;hst&uuml;cksbuffet</li>
</ul>`,
'Rezeption/Anfrage Verfuegbarkeit.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Guten Tag,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">gerne pr&uuml;fen wir die Verf&uuml;gbarkeit f&uuml;r Ihren gew&uuml;nschten Zeitraum. K&ouml;nnten Sie uns bitte folgende Angaben mitteilen?</p>
<ul style="font-family:Arial,sans-serif;font-size:14px;color:#222"><li>An- und Abreisedatum</li><li>Anzahl der Personen</li><li>Zimmerkategorie</li></ul>`,
'Rezeption/Check-in Informationen.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Liebe G&auml;ste,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Ihr Check-in ist ab 15:00 Uhr m&ouml;glich. Unsere Rezeption ist rund um die Uhr f&uuml;r Sie besetzt. Kostenfreie Parkpl&auml;tze stehen direkt am Hotel zur Verf&uuml;gung.</p>`,
'Veranstaltungsbuero/Angebot Tagung.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Sehr geehrte Damen und Herren,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">anbei erhalten Sie unser unverbindliches Angebot f&uuml;r Ihre Tagung. Unsere Veranstaltungsr&auml;ume bieten Platz f&uuml;r bis zu 120 Personen &mdash; inklusive moderner Tagungstechnik.</p>`,
'IT/Passwort zuruecksetzen.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Hallo,</p>
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Ihr Passwort wurde zur&uuml;ckgesetzt. Bitte melden Sie sich mit dem tempor&auml;ren Kennwort an und vergeben Sie umgehend ein neues.</p>`,
'_benutzer/info@hotel-park-soltau.de/Persoenliche Notiz.html':
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Kurzer pers&ouml;nlicher Hinweis &mdash; nur f&uuml;r meinen eigenen Gebrauch sichtbar.</p>`,
'signatures/footers/_default.html':
`<table style="font-family:Arial,sans-serif;font-size:12px;color:#888;border-top:2px solid #0d3b66;padding-top:8px;margin-top:12px">
<tr><td>Hotel Park Soltau GmbH · Gesch&auml;ftsf&uuml;hrer: M. Mustermann · Amtsgericht L&uuml;neburg HRB 12345<br>
Tel. +49 5191 0000 · <a href="https://hotel-park-soltau.de" style="color:#0d3b66">hotel-park-soltau.de</a></td></tr>
</table>`,
'signatures/footers/Rezeption.html':
`<table style="font-family:Arial,sans-serif;font-size:12px;color:#888;border-top:2px solid #0d3b66;padding-top:8px;margin-top:12px">
<tr><td><strong>Rezeption</strong> &mdash; rund um die Uhr f&uuml;r Sie da · Tel. +49 5191 0000-0<br>
Hotel Park Soltau · Winsener Stra&szlig;e 111 · 29614 Soltau</td></tr>
</table>`,
'signatures/headers/_vorlage.html':
sig('[Vorname Nachname]', '[Position]'),
'signatures/headers/info@hotel-park-soltau.de.max-mustermann.html':
sig('Max Mustermann', 'Rezeptionsleitung'),
'signatures/headers/veranstaltung@hotel-park-soltau.de.anna-beispiel.html':
sig('Anna Beispiel', 'Veranstaltungsmanagement'),
});
}
if (DEMO) {
seedDemo();
console.log('[DEMO] In-Memory-Demodaten geladen — keine echte Gitea-Verbindung nötig.');
}
// ── Express app ──
const app = express();
app.use(express.json({ limit: '5mb' }));
// Optional basic auth — protects the whole editor when credentials are set.
if (BASIC_AUTH_USER && BASIC_AUTH_PASS) {
app.use((req, res, next) => {
const hdr = req.headers.authorization || '';
const [scheme, encoded] = hdr.split(' ');
if (scheme === 'Basic' && encoded) {
const [user, pass] = Buffer.from(encoded, 'base64').toString('utf-8').split(':');
if (user === BASIC_AUTH_USER && pass === BASIC_AUTH_PASS) return next();
}
res.set('WWW-Authenticate', 'Basic realm="HPS Web-Editor"');
res.status(401).send('Authentifizierung erforderlich');
});
}
// Async route wrapper → forwards errors to the JSON error handler.
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
function requireConfigured(_req, res, next) {
if (!configured) {
return res.status(503).json({ error: 'Gitea-Verbindung nicht konfiguriert. Bitte Umgebungsvariablen setzen.' });
}
next();
}
// Public, non-sensitive config (no token).
app.get('/api/config', (_req, res) => {
res.json({
configured,
demo: DEMO,
local: LOCAL,
url: GITEA_URL || (LOCAL ? `lokal: ${localRoot}` : (DEMO ? '(Demo-Modus — keine echten Daten)' : null)),
owner: GITEA_OWNER || (LOCAL ? 'lokal' : (DEMO ? 'demo' : null)),
repo: GITEA_REPO || (LOCAL ? path.basename(localRoot) : (DEMO ? 'email-vorlagen' : null)),
branch: GITEA_BRANCH,
author: COMMIT_AUTHOR_NAME,
});
});
app.get('/api/health', wrap(async (_req, res) => {
if (LOCAL) return res.json({ ok: true, repo: `lokal: ${localRoot}` });
if (DEMO) return res.json({ ok: true, repo: 'demo/email-vorlagen (Demo-Modus)' });
if (!configured) return res.status(503).json({ ok: false, error: 'nicht konfiguriert' });
const r = await fetch(`${apiBase()}`, { headers: giteaHeaders() });
if (!r.ok) return res.status(502).json({ ok: false, error: `${r.status} ${r.statusText}` });
const info = await r.json();
res.json({ ok: true, repo: info.full_name });
}));
app.use('/api', requireConfigured);
// Departments = top-level dirs minus the special folders.
app.get('/api/departments', wrap(async (_req, res) => {
const entries = await listDir('');
const departments = entries
.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'));
res.json({ departments });
}));
app.post('/api/departments', wrap(async (req, res) => {
const name = (req.body?.name || '').trim();
if (!name) return res.status(400).json({ error: 'Kein Name angegeben' });
if (/[\/\\:*?"<>|]/.test(name)) return res.status(400).json({ error: 'Ungültiger Abteilungsname' });
await createFile(`${name}/.gitkeep`, '', `Abteilung "${name}" angelegt (Web-Editor)`);
res.json({ success: true, name });
}));
// List .html files in any folder. Folder is validated against known prefixes.
const ALLOWED_LIST_PREFIXES = [SHARED_FOLDER, USER_FOLDER, SIG_FOOTERS, SIG_HEADERS];
app.get('/api/files', wrap(async (req, res) => {
const folder = String(req.query.folder || '');
const files = (await listDir(folder))
.filter(f => f.type === 'file' && f.name.endsWith('.html'))
.map(f => ({ name: f.name, path: f.path, sha: f.sha }))
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
res.json({ folder, files });
}));
// Full inventory in one shot: departments + all template/footer/header files.
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 mapFiles = (entries) => entries
.filter(f => f.type === 'file' && f.name.endsWith('.html'))
.map(f => ({ name: f.name, 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));
}));
// 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)); }));
const footers = mapFiles(await listDir(SIG_FOOTERS));
const headers = mapFiles(await listDir(SIG_HEADERS));
res.json({ departments, templates, users, footers, headers });
}));
// Read a single file. Missing file → exists:false, empty content (editor can create it).
app.get('/api/file', wrap(async (req, res) => {
const filepath = String(req.query.path || '');
if (!filepath || filepath.includes('..')) return res.status(400).json({ error: 'Ungültiger Pfad' });
const data = await getFile(filepath);
if (!data) return res.json({ path: filepath, content: '', sha: null, exists: false });
res.json({ path: filepath, content: fromBase64(data.content), sha: data.sha, exists: true });
}));
// Create or update a file.
app.put('/api/file', wrap(async (req, res) => {
const { path: filepath, content = '', message } = req.body || {};
if (!filepath || filepath.includes('..')) return res.status(400).json({ error: 'Ungültiger Pfad' });
if (!filepath.endsWith('.html') && !filepath.endsWith('.json')) {
return res.status(400).json({ error: 'Nur .html- oder .json-Dateien erlaubt' });
}
const result = await saveFile(filepath, content, message);
res.json({ success: true, ...result });
}));
// Delete a file.
app.delete('/api/file', wrap(async (req, res) => {
const filepath = String(req.body?.path || '');
if (!filepath || filepath.includes('..')) return res.status(400).json({ error: 'Ungültiger Pfad' });
const data = await getFile(filepath);
if (!data) return res.status(404).json({ error: 'Datei nicht gefunden' });
await deleteFile(filepath, data.sha, req.body?.message || `${filepath.split('/').pop()} gelöscht (Web-Editor)`);
res.json({ success: true });
}));
// Read/write the email→department mapping.
app.get('/api/abteilungen', wrap(async (_req, res) => {
const data = await getFile(`${CONFIG_FOLDER}/abteilungen.json`);
let mapping = {};
if (data) { try { mapping = JSON.parse(fromBase64(data.content)); } catch (_) {} }
res.json({ mapping, exists: !!data });
}));
app.put('/api/abteilungen', wrap(async (req, res) => {
const mapping = req.body?.mapping;
if (!mapping || typeof mapping !== 'object') return res.status(400).json({ error: 'Ungültiges Mapping' });
const json = JSON.stringify(mapping, null, 2);
const result = await saveFile(`${CONFIG_FOLDER}/abteilungen.json`, json, 'abteilungen.json aktualisiert (Web-Editor)');
res.json({ success: true, ...result });
}));
// TinyMCE (selbst gehostet, offline-tauglich) direkt aus node_modules ausliefern.
app.use('/vendor/tinymce', express.static(path.join(__dirname, 'node_modules', 'tinymce')));
// Static frontend.
app.use(express.static(path.join(__dirname, 'public')));
// JSON error handler.
app.use((err, _req, res, _next) => {
console.error('[ERROR]', err.message);
res.status(err.status && err.status >= 400 && err.status < 600 ? err.status : 500)
.json({ error: err.message || 'Interner Fehler' });
});
app.listen(PORT, () => {
console.log(`HPS Web-Editor läuft auf http://localhost:${PORT}`);
if (configured) console.log(`→ Repo: ${GITEA_OWNER}/${GITEA_REPO}@${GITEA_BRANCH} (${GITEA_URL})`);
});