Der Fußbereich wird automatisch an alle Signaturen deiner Abteilung angefügt (Banner, Links, rechtliche Angaben). Änderungen gelten für alle Mitarbeiter der Abteilung.
Der Fußbereich wird automatisch an die Signaturen angefügt (Banner, Links, rechtliche Angaben). Wähle, ob du den gemeinsamen Fußbereich (gilt für alle Abteilungen) oder den deiner Abteilung bearbeitest. Hinweis: Eine vorhandene Abteilungs-Version überschreibt beim Anwenden den gemeinsamen Fußbereich.
diff --git a/templates_options/templates_options.js b/templates_options/templates_options.js
index 6edc994..19349d2 100644
--- a/templates_options/templates_options.js
+++ b/templates_options/templates_options.js
@@ -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 === '
')) {
- 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
diff --git a/updates.json b/updates.json
new file mode 100644
index 0000000..cc79bdc
--- /dev/null
+++ b/updates.json
@@ -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" }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/web-editor/.dockerignore b/web-editor/.dockerignore
new file mode 100644
index 0000000..3ba5037
--- /dev/null
+++ b/web-editor/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+npm-debug.log
+.env
+.git
+.gitignore
+*.md
diff --git a/web-editor/.env.example b/web-editor/.env.example
new file mode 100644
index 0000000..3b58e78
--- /dev/null
+++ b/web-editor/.env.example
@@ -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
diff --git a/web-editor/.gitignore b/web-editor/.gitignore
new file mode 100644
index 0000000..70bfc9e
--- /dev/null
+++ b/web-editor/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+.env
+npm-debug.log
diff --git a/web-editor/Dockerfile b/web-editor/Dockerfile
new file mode 100644
index 0000000..7c7e3c6
--- /dev/null
+++ b/web-editor/Dockerfile
@@ -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"]
diff --git a/web-editor/README.md b/web-editor/README.md
new file mode 100644
index 0000000..a0dbcf6
--- /dev/null
+++ b/web-editor/README.md
@@ -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/
/`).
+- **Fußzeilen** pflegen – pro Abteilung (`signatures/footers/.html`) sowie eine
+ gemeinsame Standard-Fußzeile (`signatures/footers/_default.html`).
+- **Signatur-Köpfe** bearbeiten (`signatures/headers/..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.
diff --git a/web-editor/docker-compose.yml b/web-editor/docker-compose.yml
new file mode 100644
index 0000000..d4dbccc
--- /dev/null
+++ b/web-editor/docker-compose.yml
@@ -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
diff --git a/web-editor/package-lock.json b/web-editor/package-lock.json
new file mode 100644
index 0000000..94eea1b
--- /dev/null
+++ b/web-editor/package-lock.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/web-editor/package.json b/web-editor/package.json
new file mode 100644
index 0000000..9c73b95
--- /dev/null
+++ b/web-editor/package.json
@@ -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"
+}
diff --git a/web-editor/public/app.js b/web-editor/public/app.js
new file mode 100644
index 0000000..3099e4c
--- /dev/null
+++ b/web-editor/public/app.js
@@ -0,0 +1,981 @@
+/* HPS Vorlagen & Signaturen — Web-Editor Frontend
+ * Vanilla JS, keine Abhängigkeiten. Spricht ausschließlich mit dem
+ * gleichnamigen Express-Backend (same origin) über die /api-Endpunkte.
+ */
+(function () {
+ 'use strict';
+
+ // ── Konstanten (müssen mit dem Backend übereinstimmen) ──
+ const SHARED_FOLDER = '_gemeinsam';
+ const USER_FOLDER = '_benutzer';
+ const SIG_FOOTERS = 'signatures/footers';
+ const SIG_HEADERS = 'signatures/headers';
+
+ // ── Globaler Zustand ──
+ const state = {
+ config: null, // /api/config
+ tree: null, // /api/tree
+ current: null, // { path, friendly, sha, exists, isNew, category }
+ dirty: false,
+ view: 'visual', // 'visual' | 'html'
+ collapsed: { templates: false, footers: false, headers: false },
+ groupsCollapsed: {}, // key -> bool (Vorlagen-Untergruppen)
+ pendingNetwork: 0,
+ };
+
+ // ── DOM-Referenzen ──
+ const $ = (id) => document.getElementById(id);
+ const el = {
+ statusPill: $('status-pill'),
+ statusText: $('status-text'),
+ configBanner: $('config-banner'),
+ treeTemplates:$('tree-templates'),
+ treeFooters: $('tree-footers'),
+ treeHeaders: $('tree-headers'),
+ emptyState: $('empty-state'),
+ editorPanel: $('editor-panel'),
+ fileFriendly: $('file-friendly'),
+ filePath: $('file-path'),
+ dirtyBadge: $('dirty-badge'),
+ btnSave: $('btn-save'),
+ btnReload: $('btn-reload'),
+ btnDelete: $('btn-delete'),
+ btnRefresh: $('btn-refresh-tree'),
+ treeSearch: $('tree-search'),
+ btnNewDept: $('btn-new-department'),
+ btnNewFooter: $('btn-new-footer'),
+ btnNewHeader: $('btn-new-header'),
+ tabVisual: $('tab-visual'),
+ tabHtml: $('tab-html'),
+ formatToolbar:$('format-toolbar'),
+ visualEditor: $('visual-editor'),
+ htmlEditor: $('html-editor'),
+ visualWrap: $('visual-wrap'),
+ htmlWrap: $('html-wrap'),
+ previewFrame: $('preview-frame'),
+ toastStack: $('toast-stack'),
+ loading: $('loading-overlay'),
+ fmtColor: $('fmt-color'),
+ fmtColorSwatch: $('fmt-color-swatch'),
+ fmtFontSize: $('fmt-fontsize'),
+ fmtLink: $('fmt-link'),
+ fmtImage: $('fmt-image'),
+ // Modals
+ confirmBackdrop: $('confirm-backdrop'),
+ confirmTitle: $('confirm-title'),
+ confirmMessage: $('confirm-message'),
+ confirmOk: $('confirm-ok'),
+ confirmCancel:$('confirm-cancel'),
+ promptBackdrop: $('prompt-backdrop'),
+ promptForm: $('prompt-form'),
+ promptTitle: $('prompt-title'),
+ promptFields: $('prompt-fields'),
+ promptOk: $('prompt-ok'),
+ promptCancel: $('prompt-cancel'),
+ };
+
+ // ────────────────────────────────────────────────────────────
+ // Hilfsfunktionen
+ // ────────────────────────────────────────────────────────────
+
+ // HTML-escapen für sichere Anzeige in Attributen/Text.
+ function esc(s) {
+ return String(s)
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+
+ // 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 = '' + icon + ' ';
+ 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 ──
+ 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)';
+ // ..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 = '' + icon + ' ';
+ 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 = '▾ ' + icon + ' ';
+ 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 = '▾ 👤 Persönlich ';
+ 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/
+ 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 = ' ' +
+ '' +
+ '' + 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: ' + esc(slug) + '.html ' : 'Bitte einen Namen eingeben.';
+ });
+ if (!res) return;
+ const slug = slugifyName(res.name);
+ if (!slug) { toast('Ungültiger Name.', 'error'); return; }
+ const path = folder + '/' + slug + '.html';
+ if (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: ' + esc(file) + ' ' : 'E-Mail und Name eingeben.';
+ });
+ if (!res) return;
+ const email = res.email.trim();
+ const slug = slugifyHeaderName(res.name);
+ if (!email || !slug) { toast('E-Mail und Name erforderlich.', 'error'); return; }
+ const file = email + '.' + slug + '.html';
+ const path = SIG_HEADERS + '/' + file;
+ if (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);
+})();
diff --git a/web-editor/public/index.html b/web-editor/public/index.html
new file mode 100644
index 0000000..3b27b15
--- /dev/null
+++ b/web-editor/public/index.html
@@ -0,0 +1,151 @@
+
+
+
+
+
+ HPS Vorlagen & Signaturen
+
+
+
+
+
+
+
+
+
+
Vorlagen & Signaturen
+
+
+
+
+ Verbinde…
+
+
+
+
+
+
+ Verbindung nicht konfiguriert.
+
+ Bitte die Umgebungsvariablen GITEA_URL, GITEA_OWNER,
+ GITEA_REPO und GITEA_TOKEN setzen (siehe .env.example)
+ und den Dienst neu starten.
+
+
+
+
+
+
+
+
+ 📄 Vorlagen
+
+
+ 📜 Fußzeilen
+
+
+ ✍️ Signaturen
+
+
+ ⟳
+
+
+
+
+
+
+
+
+
+
+
+
Wähle links einen Eintrag
+
Vorlage, Fußzeile oder Signatur anklicken zum Bearbeiten – oder über + Neu einen neuen Eintrag anlegen.
+
+
+
+
+
+
+
—
+ —
+
+
+ Nicht gespeichert
+ Neu laden
+ Löschen
+ Speichern
+
+
+
+
+ Bearbeiten
+ HTML
+ Vorschau
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Bestätigen
+
Bist du sicher?
+
+ Abbrechen
+ Bestätigen
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web-editor/public/logo.svg b/web-editor/public/logo.svg
new file mode 100644
index 0000000..411fc67
--- /dev/null
+++ b/web-editor/public/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web-editor/public/style.css b/web-editor/public/style.css
new file mode 100644
index 0000000..37ad6d6
--- /dev/null
+++ b/web-editor/public/style.css
@@ -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; }
+}
diff --git a/web-editor/server.js b/web-editor/server.js
new file mode 100644
index 0000000..0fc0c09
--- /dev/null
+++ b/web-editor/server.js
@@ -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) =>
+ `
+ ${name}
+ ${role}
+ Hotel Park Soltau · Winsener Straße 111 · 29614 Soltau
+
`;
+
+ 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':
+ `Sehr geehrte Damen und Herren,
+vielen Dank für Ihre Nachricht an das Hotel Park Soltau . Wir freuen uns über Ihr Interesse und melden uns schnellstmöglich bei Ihnen.
`,
+
+ '_gemeinsam/Buchungsbestaetigung.html':
+ `Guten Tag,
+wir bestätigen Ihnen hiermit Ihre Buchung im Hotel Park Soltau. Bei Fragen stehen wir Ihnen jederzeit gerne zur Verfügung.
+
+ Anreise: ab 15:00 Uhr
+ Abreise: bis 11:00 Uhr
+ Inklusive Frühstücksbuffet
+ `,
+
+ 'Rezeption/Anfrage Verfuegbarkeit.html':
+ `Guten Tag,
+gerne prüfen wir die Verfügbarkeit für Ihren gewünschten Zeitraum. Könnten Sie uns bitte folgende Angaben mitteilen?
+An- und Abreisedatum Anzahl der Personen Zimmerkategorie `,
+
+ 'Rezeption/Check-in Informationen.html':
+ `Liebe Gäste,
+Ihr Check-in ist ab 15:00 Uhr möglich. Unsere Rezeption ist rund um die Uhr für Sie besetzt. Kostenfreie Parkplätze stehen direkt am Hotel zur Verfügung.
`,
+
+ 'Veranstaltungsbuero/Angebot Tagung.html':
+ `Sehr geehrte Damen und Herren,
+anbei erhalten Sie unser unverbindliches Angebot für Ihre Tagung. Unsere Veranstaltungsräume bieten Platz für bis zu 120 Personen — inklusive moderner Tagungstechnik.
`,
+
+ 'IT/Passwort zuruecksetzen.html':
+ `Hallo,
+Ihr Passwort wurde zurückgesetzt. Bitte melden Sie sich mit dem temporären Kennwort an und vergeben Sie umgehend ein neues.
`,
+
+ '_benutzer/info@hotel-park-soltau.de/Persoenliche Notiz.html':
+ `Kurzer persönlicher Hinweis — nur für meinen eigenen Gebrauch sichtbar.
`,
+
+ 'signatures/footers/_default.html':
+ `
+ Hotel Park Soltau GmbH · Geschäftsführer: M. Mustermann · Amtsgericht Lüneburg HRB 12345
+ Tel. +49 5191 0000 · hotel-park-soltau.de
+
`,
+
+ 'signatures/footers/Rezeption.html':
+ `
+ Rezeption — rund um die Uhr für Sie da · Tel. +49 5191 0000-0
+ Hotel Park Soltau · Winsener Straße 111 · 29614 Soltau
+
`,
+
+ '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//
+ 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})`);
+});