12 Commits

Author SHA1 Message Date
Kendrick Bollens
eff90e9517 Auto-Update über Gitea einrichten + Web-Editor + Sync-Verbesserungen
- Thunderbird Auto-Update: update_url im Manifest, updates.json, release.sh
- .xpi neu gebaut (mit update_url, ohne defaults.local.json/Token)
- README + CLAUDE.md: Auto-Update-Doku, Repo muss public bleiben
- web-editor/ (Node/Docker WYSIWYG-Editor) hinzugefügt
- gitea-sync.js + templates_options: bestehende Anpassungen

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:12:33 +02:00
Kendrick Bollens
edb979a1b2 Toolbar-Button Fix, QuickMove-Tab, Schlagwörter-Sync, Abteilungsverwaltung
- Toolbar-Button öffnet Settings via browserAction.onClicked statt defektem Popup
- Button-Label "Vorlagen & Signaturen" statt Icon
- Tab "Erledigt" → "QuickMove" umbenannt
- QuickMove: E-Mails markieren + in Zielordner verschieben
- Schlagwörter-Sync aus Gitea (_config/schlagwoerter.json)
- Abteilungen anlegen (+Button)
- attachSignature-Fix entfernt
- message_display_action für QuickMove-Button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-03 14:02:56 +02:00
Kendrick Bollens
ee24caf8b7 Fix: Signaturen-Referenzen werden bei Speichern + Sync aktualisiert
- Beim Speichern von E-Mail 1 werden alle =email1 Identitäten mit-aktualisiert
- attachSignature: false auch im Second Pass (=other@ Referenzen) gesetzt
2026-05-08 17:09:01 +02:00
Kendrick Bollens
7a7815feca VERIFY.md, attachSignature-Fix, Sync-Hashes, Scope-Bug
- VERIFY.md: Regressions-Checkliste mit 30 Prüfpunkten
- Fix: attachSignature: false beim Signatur-Update (deaktiviert alte Datei-Signatur)
- Fix: Sig-Hashes werden beim Init + Background-Sync geschrieben (kein grau mehr)
- Fix: Scope-Badge Vergleich über normalisierte Scopes (folderToScope)
- Fix: storage.onChanged refreshed auch Signaturen-Indicator
2026-05-07 20:06:26 +02:00
Kendrick Bollens
bc82e33bf2 Smart-Sync, Scope-Fix, Auto-Refresh, defaults.local.json
- Background-Sync: SHA-Check alle 5s, voller Pull nur bei Änderung
- Sync-Hashes werden nach Pull im Storage geschrieben → grüne Dots
- UI refreshed automatisch bei Background-Sync (storage.onChanged)
- Scope-Badge-Bug gefixt (folderToScope normalisiert den Vergleich)
- defaults.local.json: optionale vorkonfigurierte Verbindungsdaten
- README: Doku für defaults.local.json und XPI-Build mit/ohne Defaults
2026-05-07 10:50:36 +02:00
Kendrick Bollens
533d5a34f2 Sync-Fix, Dateinamen mit Leerzeichen, E-Mail-Dropdown, Defaults-System
- Fix: Pull überschreibt lokale Vorlagen nicht mehr (Merge statt Replace)
- Fix: toFilename behält Leerzeichen und Groß-/Kleinschreibung
- E-Mail in Settings ist jetzt ein Dropdown mit TB-Identitäten
- Optionale defaults.local.js für vorkonfigurierte Verbindungsdaten (.gitignored)
2026-05-07 10:22:42 +02:00
Kendrick Bollens
1d00a06e30 Fix: Signaturen-Tab lädt erste Identität automatisch
- "Bitte wählen" Placeholder entfernt
- Erste Identität wird beim Laden direkt ausgewählt
- Signatur wird sofort in den Editor geladen (kein manuelles Umschalten nötig)
2026-05-07 02:22:55 +02:00
Kendrick Bollens
864be54646 v2.2.0: 3-Stufen-Sichtbarkeit, UX-Verbesserungen, Auto-Erkennung
- 3 Sichtbarkeitsstufen für Vorlagen: Persönlich / Abteilung / Alle
- Persönliche Vorlagen werden in _benutzer/{email}/ synchronisiert
- Sichtbarkeit direkt in der Liste per Dropdown änderbar
- Warnung beim Verringern der Sichtbarkeit (Server-Löschung)
- Auto-Erkennung von Abteilung + E-Mail via _config/abteilungen.json
- Toast-Benachrichtigungen statt unsichtbare Status-Badges
- Lade-Spinner bei Sync-Operationen
- Sync-Dots mit Symbolen (nicht nur Farbe) für Barrierefreiheit
- Custom Delete-Modal statt browser confirm()
- Collapsible-Sections visuell als klickbar erkennbar
- Token-Feld mit Show/Hide-Toggle
- Inline-Validierung für Template-Namen
- Checkbox-Klickflächen vergrößert + Label-Klick
- Offline-Erkennung mit Banner
- Font-Dropdown Viewport-Fix
- Popup: Prefix-Dropdown verständlicher
- Signaturen: erste Identität automatisch ausgewählt
- README komplett neu geschrieben
2026-05-06 22:04:46 +02:00
Kendrick Bollens
33eb87613e Umbenennung: HPS Vorlagen & Signaturen v2.0.0
- Name: Templates Reply → HPS Vorlagen & Signaturen
- ID: stmt@proton.meit@hotel-park-soltau.de
- Version: 1.0.3 → 2.0.0
2026-04-20 22:54:07 +02:00
Kendrick Bollens
78a6310424 UX-Überarbeitung, Signatur-Bausteine, QoL-Verbesserungen
- Neues Layout: Inline-Editor, aufklappbarer Import, ⚙-Tab
- Signatur Header/Footer Baustein-System (Footer pro Abteilung)
- Signatur-Quelle Dropdown (Eigene / = andere@)
- "Vorlage laden" mit Platzhalter-Ersetzung (Name, Email, Abteilung, Tel, Fax)
- "Signatur speichern" pusht automatisch zum Server
- Footer-Editor mit auto-load beim Aufklappen
- Abteilungswechsel synct Footer + Templates neu
- "Aktualisieren" Button = Pull + Push in einem Schritt
- Vorlagen: Checkbox "Für alle Abteilungen"
- Löschen vom Server für alle möglich
- Toolbar für Signaturen gleichwertig mit Vorlagen-Toolbar
- Base64 whitespace-Fix für Gitea API
- Offline-resilient (Cache-Fallback, graceful error handling)
2026-04-20 22:46:54 +02:00
Kendrick Bollens
f87258498d Aufräumen: Quill.js und doppelte XPI entfernt
Quill wird nicht mehr verwendet (ersetzt durch contenteditable).
2026-04-20 16:31:51 +02:00
Kendrick Bollens
cf051458bb Feature: WYSIWYG-Editor, Gitea-Sync, Signaturen-Verwaltung
- WYSIWYG-Editor mit contenteditable statt Textarea (MDI-Icons, System-Fonts, Farbwähler)
- Gitea-Sync: Templates per Abteilung aus Git-Repo laden/hochladen mit Commit-Author
- Abteilungsordner + _gemeinsam Ordner, einzelnes Pull/Push pro Vorlage
- Sync-Status pro Vorlage (grün/rot/grau Ampel), persistent über Neustarts
- Signaturen-Tab: Identitäten bearbeiten, aus Datei laden, Sync über signatures/ Ordner
- Persönliche Signaturen für geteilte E-Mail-Adressen (pro Mitarbeiter)
- Tab-Navigation: Vorlagen, Signaturen, Synchronisierung
- Auto-Pull beim Thunderbird-Start (Templates + Signaturen)
2026-04-20 16:30:40 +02:00
32 changed files with 7610 additions and 377 deletions

3
.gitignore vendored Normal file
View File

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

42
CLAUDE.md Normal file
View File

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

168
README.md
View File

@@ -1,38 +1,130 @@
# Templates Reply
## Mozilla Thunderbird Add-On
Templates Reply is a completely free and open-source Thunderbird extension that helps you create, manage, and reuse message templates directly from your compose window.
It is designed for speed, simplicity, and convenience — no sign-ups, no limits, and absolutely no data collection.
This add-on does not track, store, or transmit any personal information.
All templates and settings are saved locally on your device and never leave your computer.
No analytics, telemetry, or remote servers are involved — your privacy is 100% respected.
You can freely use, modify, and distribute the source code under the terms of the MIT License.
## Screenshots
![App Screenshot](https://addons.thunderbird.net/user-media/previews/full/207/207836.png?modified=1762427184)
![App Screenshot](https://addons.thunderbird.net/user-media/previews/full/207/207837.png?modified=1762427184)
![App Screenshot](https://addons.thunderbird.net/user-media/previews/full/207/207838.png?modified=1762427184)
## Install Locally (Temporary / Developer Mode)
```
Open Thunderbird
Go to Tools → Add-ons and Themes
Click the ⚙️ (gear icon) in the top-right corner
Select “Debug Add-ons”
Click “Load Temporary Add-on…”
Choose the manifest.json file from your add-on directory
⚠️ Note: This installation is temporary and will be removed when Thunderbird is restarted.
```
## OR via Thunderbird Add-on Manager
```
Open Thunderbird
Go toTools → Add-ons and Themes
Search for the Templates Reply add-on by name
Click Add to Thunderbird
Confirm installation
```
# HPS Vorlagen & Signaturen
Thunderbird-Plugin (v2.2.0) zur zentralen Verwaltung von E-Mail-Vorlagen und Signaturen für Hotel Park Soltau. Vorlagen und Signaturen werden über ein Gitea/Forgejo-Repository synchronisiert und stehen so allen Mitarbeitern zur Verfügung.
## Features
- **E-Mail-Vorlagen** erstellen, bearbeiten und per Klick in Compose-Fenster einfügen
- **3 Sichtbarkeitsstufen** pro Vorlage:
- **Persönlich** — nur für den eigenen Account, gesynct in `_benutzer/{email}/`
- **Abteilung** — für alle in der Abteilung, gesynct in den Abteilungsordner
- **Alle Abteilungen** — firmenweit, gesynct in `_gemeinsam/`
- **Signaturen-Verwaltung** mit persönlichem Kopfbereich + gemeinsamer Fußzeile pro Abteilung
- **Git-Sync** über Gitea/Forgejo API (Pull + Push, automatisch alle 15 Min.)
- **Auto-Erkennung** von Abteilung und Benutzer via `_config/abteilungen.json`
- **WYSIWYG-Editor** mit Schriftart, Farben, Listen, Bildern, Links
- **Sichtbarkeit direkt änderbar** per klickbarem Badge in der Vorlagenliste
## Repository-Struktur (Gitea)
```
repo/
├── _gemeinsam/ # Vorlagen für alle Abteilungen
│ └── beispiel-vorlage.html
├── _benutzer/ # Persönliche Vorlagen pro User
│ ├── max@hotel-park-soltau.de/
│ └── anna@hotel-park-soltau.de/
├── _config/
│ └── abteilungen.json # E-Mail → Abteilung Mapping
├── Rezeption/ # Abteilungsvorlagen
├── IT/
├── signatures/
│ ├── headers/ # Persönliche Signatur-Köpfe
│ │ └── max@hotel.de.max-mustermann.html
│ └── footers/ # Gemeinsame Fußbereiche
│ └── Rezeption.html
```
### `_config/abteilungen.json`
Mapping von Abteilungs-E-Mail-Adressen zu Ordnernamen. Wird vom Plugin gelesen, um Abteilung und persönliche E-Mail automatisch zu erkennen:
```json
{
"info@hotel-park-soltau.de": "Rezeption",
"veranstaltungs@hotel-park-soltau.de": "Veranstaltungsbuero",
"it@hotel-park-soltau.de": "IT",
"haustechnik@hotel-park-soltau.de": "Haustechnik"
}
```
## Plugin-Aufbau
| Datei | Funktion |
|---|---|
| `manifest.json` | Extension-Manifest (Thunderbird WebExtension v2) |
| `background.js` | Template-Insertion ins Compose-Fenster |
| `popup.html` / `popup.js` | Popup beim Klick auf "Vorlagen" im Compose |
| `lib/gitea-sync.js` | Gitea-API-Client + Sync-Manager |
| `lib/mdi/` | Material Design Icons (Subset) |
| `templates_options/` | Einstellungsseite (Vorlagen, Signaturen, Verbindung) |
| `defaults.local.json` | Optionale vorkonfigurierte Verbindungsdaten (gitignored) |
## Installation
### Lokal (Entwicklung)
1. Thunderbird öffnen
2. Extras → Add-ons und Themes
3. Zahnrad-Icon → Add-on aus Datei installieren
4. `templates-reply-hotel.xpi` auswählen
### XPI bauen
```bash
# Ohne vorkonfigurierte Verbindungsdaten:
7z a templates-reply-hotel.xpi manifest.json background.js popup.html popup.js lib/ templates_options/ icons/
# Mit vorkonfigurierten Verbindungsdaten (für Deployment):
7z a templates-reply-hotel.xpi manifest.json background.js popup.html popup.js lib/ templates_options/ icons/ defaults.local.json
```
### Vorkonfigurierte Verbindungsdaten (`defaults.local.json`)
Wenn eine `defaults.local.json` im Plugin-Root existiert und in die XPI eingebaut wird, werden die Verbindungsdaten beim ersten Start automatisch gesetzt. Der User muss dann nur noch "Verbindung speichern" klicken.
```json
{
"baseUrl": "https://git.example.com",
"owner": "organisation",
"repo": "email-vorlagen",
"branch": "main",
"token": "dein-api-token"
}
```
Die Datei ist in `.gitignore` — Tokens landen nicht im Repository.
## Auto-Update (self-hosted über Gitea)
Installierte Add-ons aktualisieren sich automatisch über `updates.json` in diesem Repo
(`manifest.json``browser_specific_settings.gecko.update_url`).
> **⚠️ Dieses Repository muss public bleiben.**
> Der Thunderbird-Auto-Updater greift **anonym (ohne Token)** auf `updates.json` und die
> Release-`.xpi` zu. Ist das Repo privat, liefert Gitea `401` — die automatischen Updates
> funktionieren dann nicht mehr.
Neue Version veröffentlichen:
1. `version` in `manifest.json` hochzählen, `.xpi` **ohne** `defaults.local.json` neu bauen.
2. `GITEA_TOKEN=… ./release.sh` — hasht die `.xpi`, aktualisiert `updates.json`, legt das
Gitea-Release an und lädt die `.xpi` als Asset hoch.
3. `updates.json` + `manifest.json` committen & pushen.
## Einrichtung
1. **Verbindung konfigurieren**: Einstellungen-Tab (⚙) → Server-URL, Repository, Token eingeben → Verbindung speichern (entfällt bei vorkonfigurierter XPI)
2. **Abteilung wählen** (oder automatisch erkannt via `abteilungen.json`)
3. **Vorlagen erstellen**: Vorlagen-Tab → Neue Vorlage → Sichtbarkeit wählen → Speichern
4. **Signaturen einrichten**: Signaturen-Tab → Identität wählen → Kopfbereich bearbeiten → Speichern
## Voraussetzungen
- Mozilla Thunderbird >= 109.0
- Gitea/Forgejo-Server mit API-Zugang
- API-Token mit Repository-Schreibrechten
## Lizenz
MIT License

48
VERIFY.md Normal file
View File

@@ -0,0 +1,48 @@
# Verifizierungs-Checkliste
Nach Thunderbird-Updates, Plugin-Änderungen oder Deployments diese Punkte prüfen.
## Vorlagen — Sync & Anzeige
- [ ] Pull überschreibt lokale Vorlagen nicht (Merge statt Replace) — importierte Vorlagen dürfen nach Sync nicht verschwinden
- [ ] Vorlagen mit Leerzeichen im Titel werden korrekt erstellt, gesynct und angezeigt (kein Duplikat)
- [ ] Scope-Badge (Persönlich/Abteilung/Alle) zeigt den richtigen Wert — Abteilungsvorlagen dürfen nicht als "Privat" erscheinen
- [ ] Scope-Badge umschalten funktioniert (Warnung bei Downgrade, altes File wird gelöscht, neues wird gepusht)
- [ ] Sync-Dots sind grün nach Pull (nicht grau/unknown) — Hashes müssen korrekt geschrieben werden
- [ ] Background SHA-Check alle 5s läuft, voller Pull nur bei Änderung
- [ ] UI refreshed automatisch wenn Background-Sync neue Templates pullt
## Signaturen
- [ ] Signaturen-Tab lädt beim Öffnen automatisch die erste Identität + deren Signatur
- [ ] Kein "Bitte wählen" Placeholder im Identity-Dropdown
- [ ] Signatur-Sync-Status ist grün beim Öffnen (nicht grau)
- [ ] `attachSignature: false` wird gesetzt — alte Datei-basierte Signaturen werden deaktiviert
- [ ] Footer (Banner) lädt korrekt als eingebettete data-URI, nicht als Datei-Referenz
- [ ] Signatur-Quelle "= andere E-Mail" funktioniert (Kopie von anderer Identität)
## Settings / Verbindung
- [ ] E-Mail ist ein Dropdown mit TB-Identitäten (kein Freitext)
- [ ] Token-Feld hat Show/Hide Toggle
- [ ] `defaults.local.json` wird beim ersten Start geladen (wenn in XPI vorhanden)
- [ ] Ohne `defaults.local.json` funktioniert Plugin normal (manuell konfigurieren)
- [ ] Auto-Detection via `_config/abteilungen.json` erkennt Abteilung + E-Mail
- [ ] Abteilungsname wird im Scope-Badge und Editor-Dropdown angezeigt (nicht generisch "Abteilung")
## Popup (Compose-Fenster)
- [ ] Vorlagen-Popup öffnet, zeigt alle Vorlagen
- [ ] Template-Insertion funktioniert (HTML wird korrekt eingefügt)
- [ ] Prefix-Dropdown ("Textbaustein voranstellen") funktioniert
## UX-Elemente
- [ ] Toast-Benachrichtigungen erscheinen bei Sync/Push/Pull/Fehler
- [ ] Spinner bei Sync-Operationen sichtbar
- [ ] Custom Delete-Modal statt browser confirm()
- [ ] Offline-Banner erscheint bei fehlender Verbindung
- [ ] Collapsible-Sections sind visuell klickbar (Hintergrund + Hover)
- [ ] Template-Name Inline-Validierung (leer + Duplikat)
- [ ] Checkbox-Klick auf Template-Name toggelt Checkbox
## API / Gitea
- [ ] `toFilename()` behält Leerzeichen und Groß-/Kleinschreibung
- [ ] `_benutzer/`, `_config/` werden aus Department-Liste gefiltert
- [ ] Persönliche Vorlagen syncen in `_benutzer/{email}/`
- [ ] `checkRemoteShas()` inkludiert persönlichen Ordner

View File

@@ -1,5 +1,116 @@
browser.runtime.onMessage.addListener(async msg => {
// ── Toolbar button: open settings page ──
browser.browserAction.onClicked.addListener(() => {
browser.runtime.openOptionsPage();
});
// ── "Erledigt" button in message display ──
async function executeErledigtAction(tab, actionConfig) {
const message = await messenger.messageDisplay.getDisplayedMessage(tab.id);
if (!message) {
browser.notifications.create({ type: 'basic', iconUrl: browser.runtime.getURL('icons/icon.png'), title: 'Fehler', message: 'Keine Nachricht ausgewählt.' });
return;
}
const storage = await browser.storage.local.get(['gitea_config', 'schlagwoerter_cache']);
const config = storage.gitea_config || {};
const schlagwoerter = storage.schlagwoerter_cache;
// Apply user's tag
let tagKey = null;
if (Array.isArray(schlagwoerter) && config.authorName) {
const match = schlagwoerter.find(u => u.name.toLowerCase() === config.authorName.toLowerCase());
if (match) {
tagKey = `$hps_${match.name.toLowerCase().replace(/\s+/g, '_')}`;
}
}
if (tagKey) {
const currentTags = message.tags || [];
if (!currentTags.includes(tagKey)) {
await messenger.messages.update(message.id, { tags: [...currentTags, tagKey] });
}
}
// Move to target folder
if (actionConfig.targetFolder) {
const folderInfo = JSON.parse(actionConfig.targetFolder);
await messenger.messages.move([message.id], folderInfo);
}
// Feedback
const parts = [];
if (tagKey) parts.push('markiert');
if (actionConfig.targetFolder) parts.push('verschoben');
const title = actionConfig.name || 'Erledigt';
browser.notifications.create({
type: 'basic',
iconUrl: browser.runtime.getURL('icons/icon.png'),
title,
message: parts.length ? `Nachricht ${parts.join(' & ')}.` : 'Kein Schlagwort oder Zielordner konfiguriert.'
});
}
// Single action: direct click without popup
messenger.messageDisplayAction.onClicked.addListener(async (tab) => {
try {
const result = await browser.storage.local.get('erledigt_config');
const actions = (result.erledigt_config || {}).actions || [];
await executeErledigtAction(tab, actions[0] || {});
} catch (e) {
console.error('Erledigt-Button Fehler:', e);
browser.notifications.create({ type: 'basic', iconUrl: browser.runtime.getURL('icons/icon.png'), title: 'Fehler', message: e.message });
}
});
// Toggle popup vs direct click based on action count
async function updateErledigtPopup() {
const result = await browser.storage.local.get('erledigt_config');
const actions = (result.erledigt_config || {}).actions || [];
if (actions.length > 1) {
await messenger.messageDisplayAction.setPopup({ popup: 'message_popup.html' });
await messenger.messageDisplayAction.setTitle({ title: 'Aktion wählen' });
} else {
await messenger.messageDisplayAction.setPopup({ popup: '' });
await messenger.messageDisplayAction.setTitle({ title: actions[0]?.name || 'Erledigt' });
}
}
// Update on config change
browser.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.erledigt_config) updateErledigtPopup();
});
updateErledigtPopup();
// ── Template insertion ──
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.action === 'erledigtAction') {
(async () => {
try {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
const result = await browser.storage.local.get('erledigt_config');
const actions = (result.erledigt_config || {}).actions || [];
const action = actions[msg.index] || {};
await executeErledigtAction(tab, action);
sendResponse({ success: true });
} catch (e) {
console.error('Erledigt-Action Fehler:', e);
browser.notifications.create({ type: 'basic', iconUrl: browser.runtime.getURL('icons/icon.png'), title: 'Fehler', message: e.message });
sendResponse({ success: false, error: e.message });
}
})();
return true;
}
if (msg.action !== 'insertTemplate') return;
handleInsertTemplate(msg).then(() => sendResponse())
.catch(err => sendResponse({ error: err.message }));
return true; // keep channel open for async response
});
async function handleInsertTemplate(msg) {
try {
const [tab] = await browser.tabs.query({
active: true,
@@ -48,12 +159,6 @@ browser.runtime.onMessage.addListener(async msg => {
await browser.compose.insertText(tab.id, htmlContent + '<br>', { insertAsText: false });
}
await browser.notifications.create({
type: 'basic',
iconUrl: browser.runtime.getURL('icons/icon.png'),
title: 'Vorlage eingefügt',
message: 'Erfolgreich',
});
} catch (e) {
console.error('background.js error:', e);
@@ -75,13 +180,6 @@ browser.runtime.onMessage.addListener(async msg => {
newBody = htmlTpl + '<br>' + old;
}
await browser.compose.setComposeDetails(tab.id, { body: newBody });
await browser.notifications.create({
type: 'basic',
iconUrl: browser.runtime.getURL('icons/icon.png'),
title: 'Vorlage eingefügt',
message: 'Erfolgreich (Fallback)',
});
}
} catch (e2) {
console.error('background.js fallback error:', e2);
@@ -93,4 +191,4 @@ browser.runtime.onMessage.addListener(async msg => {
});
}
}
});
}

1020
lib/gitea-sync.js Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

39
lib/mdi/mdi-editor.css Normal file
View File

@@ -0,0 +1,39 @@
@font-face {
font-family: "Material Design Icons";
src: url("materialdesignicons-webfont.woff2") format("woff2");
font-weight: normal;
font-style: normal;
}
.mdi {
display: inline-block;
font: normal normal normal 18px/1 "Material Design Icons";
text-rendering: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.mdi-format-bold::before { content: "\F0264"; }
.mdi-format-italic::before { content: "\F0277"; }
.mdi-format-underline::before { content: "\F0287"; }
.mdi-format-strikethrough::before { content: "\F0280"; }
.mdi-format-color-text::before { content: "\F069E"; }
.mdi-format-color-highlight::before { content: "\F0E31"; }
.mdi-format-list-numbered::before { content: "\F027B"; }
.mdi-format-list-bulleted::before { content: "\F0279"; }
.mdi-format-align-left::before { content: "\F0262"; }
.mdi-format-align-center::before { content: "\F0260"; }
.mdi-format-align-right::before { content: "\F0263"; }
.mdi-link::before { content: "\F0337"; }
.mdi-format-clear::before { content: "\F0265"; }
.mdi-image::before { content: "\F02E9"; }
.mdi-cloud-upload::before { content: "\F0167"; }
.mdi-cloud-download::before { content: "\F0162"; }
.mdi-cloud-check::before { content: "\F0157"; }
.mdi-cloud-alert::before { content: "\F09DF"; }
.mdi-sync::before { content: "\F04E6"; }
.mdi-cog::before { content: "\F0493"; }
.mdi-eye::before { content: "\F0208"; }
.mdi-eye-off::before { content: "\F0209"; }
.mdi-account-group::before { content: "\F0849"; }
.mdi-account::before { content: "\F0004"; }

View File

@@ -1,22 +1,43 @@
{
"manifest_version": 2,
"name": "Templates Reply",
"version": "1.0.3",
"description": "Insert your reply in the body of the message",
"name": "HPS Vorlagen & Signaturen",
"version": "2.3.0",
"description": "Vorlagen- und Signaturverwaltung für Hotel Park Soltau mit Git-Sync",
"browser_specific_settings": {
"gecko": {
"id": "stmt@proton.me",
"strict_min_version": "109.0"
"id": "it@hotel-park-soltau.de",
"strict_min_version": "109.0",
"update_url": "https://git.hotel-park-soltau.de/kendrick.bollens/hps-thunderbird-templates/raw/branch/main/updates.json"
}
},
"permissions": [
"compose",
"storage",
"notifications",
"tabs"
"tabs",
"accountsRead",
"accountsIdentities",
"messagesTagsList",
"messagesTags",
"messagesRead",
"messagesUpdate",
"messagesMove",
"accountsFolders"
],
"optional_permissions": [
"*://*/*"
],
"background": {
"scripts": ["background.js"]
"scripts": ["lib/gitea-sync.js", "background.js"],
"persistent": true
},
"browser_action": {
"default_icon": {
"16": "icons/icon.png",
"32": "icons/icon.png"
},
"default_title": "Vorlagen & Signaturen verwalten",
"default_label": "Vorlagen & Signaturen"
},
"compose_action": {
"default_icon": {
@@ -26,6 +47,13 @@
"default_popup": "popup.html",
"default_label": "Vorlagen"
},
"message_display_action": {
"default_icon": {
"16": "icons/icon.png",
"32": "icons/icon.png"
},
"default_label": "QuickMove"
},
"options_ui": {
"page": "templates_options/templates_options.html",
"browser_style": true

50
message_popup.html Normal file
View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Aktion wählen</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Segoe UI", -apple-system, sans-serif;
width: 220px;
background: #fafafa;
color: #333;
}
#action-list {
padding: 6px;
}
#action-list button {
display: block;
width: 100%;
padding: 9px 12px;
margin-bottom: 2px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
text-align: left;
font-size: 12.5px;
color: #333;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
#action-list button:hover {
background: #e8f0eb;
border-color: #4a7c59;
}
#action-list button:active {
background: #d0e2d5;
}
.empty-state {
padding: 16px 14px;
text-align: center;
font-size: 12px;
color: #888;
}
</style>
</head>
<body>
<div id="action-list"></div>
<script src="message_popup.js"></script>
</body>
</html>

21
message_popup.js Normal file
View File

@@ -0,0 +1,21 @@
(async () => {
const result = await browser.storage.local.get('erledigt_config');
const actions = (result.erledigt_config || {}).actions || [];
const list = document.getElementById('action-list');
if (actions.length === 0) {
list.innerHTML = '<div class="empty-state">Keine Aktionen konfiguriert.</div>';
return;
}
for (let i = 0; i < actions.length; i++) {
const action = actions[i];
const btn = document.createElement('button');
btn.textContent = action.name || `Aktion ${i + 1}`;
btn.addEventListener('click', async () => {
await browser.runtime.sendMessage({ action: 'erledigtAction', index: i });
window.close();
});
list.appendChild(btn);
}
})();

View File

@@ -59,6 +59,26 @@
color: #4a7c59;
text-decoration: underline;
}
.prefix-section {
padding: 8px 10px;
border-bottom: 1px solid #e0e0e0;
background: #f3f3f3;
}
.prefix-section label {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 4px;
}
.prefix-section select {
width: 100%;
padding: 5px 8px;
font-size: 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
color: #333;
}
.empty-state {
padding: 20px 14px;
text-align: center;
@@ -75,6 +95,13 @@
</head>
<body>
<div class="header">Vorlage auswählen</div>
<div id="prefix-section" class="prefix-section" style="display:none;">
<label for="prefix-select">Textbaustein voranstellen (optional)</label>
<select id="prefix-select">
<option value="">— Nichts voranstellen —</option>
</select>
<div style="font-size:10px;color:#999;margin-top:2px;">Wird VOR der gewählten Vorlage eingefügt</div>
</div>
<div id="template-list"></div>
<script src="popup.js"></script>
</body>

View File

@@ -13,9 +13,17 @@ async function getTemplates() {
}
function insertTemplateAndClose(templateText) {
// Check if a prefix template is selected
const prefixSelect = document.getElementById('prefix-select');
const prefixContent = prefixSelect ? prefixSelect.value : '';
const combinedText = prefixContent
? prefixContent + '<br>' + templateText
: templateText;
browser.runtime.sendMessage({
action: 'insertTemplate',
text: templateText
text: combinedText
}).catch(e => console.error("Error sending message to background:", e));
window.close();
}
@@ -39,6 +47,34 @@ async function renderPopupButtons() {
return;
}
// Populate prefix dropdown
const prefixSection = document.getElementById('prefix-section');
const prefixSelect = document.getElementById('prefix-select');
if (templates.length > 1) {
prefixSection.style.display = '';
templates.forEach(template => {
const option = document.createElement('option');
option.value = template.content;
option.textContent = template.name;
option.dataset.name = template.name;
prefixSelect.appendChild(option);
});
// Restore last selection
const saved = await browser.storage.local.get('last_prefix');
if (saved.last_prefix) {
const match = [...prefixSelect.options].find(o => o.dataset.name === saved.last_prefix);
if (match) prefixSelect.value = match.value;
}
// Save selection on change
prefixSelect.addEventListener('change', () => {
const selected = prefixSelect.selectedOptions[0];
const name = selected?.dataset?.name || '';
browser.storage.local.set({ last_prefix: name });
});
}
templates.forEach(template => {
const button = document.createElement('button');
button.textContent = template.name;

68
release.sh Executable file
View File

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

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

9
toolbar_popup.html Normal file
View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head>
<body>
<script>
browser.runtime.openOptionsPage();
window.close();
</script>
</body>
</html>

16
updates.json Normal file
View File

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

6
web-editor/.dockerignore Normal file
View File

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

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

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

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

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

20
web-editor/Dockerfile Normal file
View File

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

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

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

View File

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

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

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

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

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

981
web-editor/public/app.js Normal file
View File

@@ -0,0 +1,981 @@
/* HPS Vorlagen & Signaturen — Web-Editor Frontend
* Vanilla JS, keine Abhängigkeiten. Spricht ausschließlich mit dem
* gleichnamigen Express-Backend (same origin) über die /api-Endpunkte.
*/
(function () {
'use strict';
// ── Konstanten (müssen mit dem Backend übereinstimmen) ──
const SHARED_FOLDER = '_gemeinsam';
const USER_FOLDER = '_benutzer';
const SIG_FOOTERS = 'signatures/footers';
const SIG_HEADERS = 'signatures/headers';
// ── Globaler Zustand ──
const state = {
config: null, // /api/config
tree: null, // /api/tree
current: null, // { path, friendly, sha, exists, isNew, category }
dirty: false,
view: 'visual', // 'visual' | 'html'
collapsed: { templates: false, footers: false, headers: false },
groupsCollapsed: {}, // key -> bool (Vorlagen-Untergruppen)
pendingNetwork: 0,
};
// ── DOM-Referenzen ──
const $ = (id) => document.getElementById(id);
const el = {
statusPill: $('status-pill'),
statusText: $('status-text'),
configBanner: $('config-banner'),
treeTemplates:$('tree-templates'),
treeFooters: $('tree-footers'),
treeHeaders: $('tree-headers'),
emptyState: $('empty-state'),
editorPanel: $('editor-panel'),
fileFriendly: $('file-friendly'),
filePath: $('file-path'),
dirtyBadge: $('dirty-badge'),
btnSave: $('btn-save'),
btnReload: $('btn-reload'),
btnDelete: $('btn-delete'),
btnRefresh: $('btn-refresh-tree'),
treeSearch: $('tree-search'),
btnNewDept: $('btn-new-department'),
btnNewFooter: $('btn-new-footer'),
btnNewHeader: $('btn-new-header'),
tabVisual: $('tab-visual'),
tabHtml: $('tab-html'),
formatToolbar:$('format-toolbar'),
visualEditor: $('visual-editor'),
htmlEditor: $('html-editor'),
visualWrap: $('visual-wrap'),
htmlWrap: $('html-wrap'),
previewFrame: $('preview-frame'),
toastStack: $('toast-stack'),
loading: $('loading-overlay'),
fmtColor: $('fmt-color'),
fmtColorSwatch: $('fmt-color-swatch'),
fmtFontSize: $('fmt-fontsize'),
fmtLink: $('fmt-link'),
fmtImage: $('fmt-image'),
// Modals
confirmBackdrop: $('confirm-backdrop'),
confirmTitle: $('confirm-title'),
confirmMessage: $('confirm-message'),
confirmOk: $('confirm-ok'),
confirmCancel:$('confirm-cancel'),
promptBackdrop: $('prompt-backdrop'),
promptForm: $('prompt-form'),
promptTitle: $('prompt-title'),
promptFields: $('prompt-fields'),
promptOk: $('prompt-ok'),
promptCancel: $('prompt-cancel'),
};
// ────────────────────────────────────────────────────────────
// Hilfsfunktionen
// ────────────────────────────────────────────────────────────
// HTML-escapen für sichere Anzeige in Attributen/Text.
function esc(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// Dateiname aus Vorlagennamen ableiten (exakt laut Vorgabe), .html ergänzt der Aufrufer.
function slugifyName(name) {
return name
.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss')
.replace(/[/\\:*?"<>|]/g, '-')
.replace(/^[\s.-]+|[\s.-]+$/g, '')
.trim();
}
// Für Signatur-Köpfe: zusätzlich klein + Leerzeichen → '-'.
function slugifyHeaderName(name) {
return slugifyName(name).toLowerCase().replace(/\s+/g, '-');
}
// Debounce-Helfer.
function debounce(fn, ms) {
let t;
return function (...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), ms); };
}
// ── Toasts ──
function toast(message, type) {
const t = document.createElement('div');
t.className = 'toast' + (type ? ' toast-' + type : '');
const icon = type === 'success' ? '✓' : type === 'error' ? '⚠' : '';
t.innerHTML = '<span class="toast-icon">' + icon + '</span><span class="toast-msg"></span>';
t.querySelector('.toast-msg').textContent = message;
el.toastStack.appendChild(t);
setTimeout(() => {
t.classList.add('fade-out');
t.addEventListener('animationend', () => t.remove(), { once: true });
}, 4000);
}
// ── Lade-Overlay (zählt verschachtelte Netzwerkaufrufe) ──
function startLoading() { state.pendingNetwork++; el.loading.hidden = false; }
function stopLoading() { state.pendingNetwork = Math.max(0, state.pendingNetwork - 1); if (state.pendingNetwork === 0) el.loading.hidden = true; }
// ── Zentraler fetch-Wrapper: JSON, Fehler→toast, Overlay ──
async function api(path, options) {
startLoading();
try {
const res = await fetch(path, options);
let data = null;
const text = await res.text();
if (text) { try { data = JSON.parse(text); } catch (_) { data = { error: text }; } }
if (!res.ok) {
const msg = (data && data.error) ? data.error : ('HTTP ' + res.status + ' ' + res.statusText);
throw new Error(msg);
}
return data || {};
} finally {
stopLoading();
}
}
// ── Custom confirm-Modal → Promise<boolean> ──
function confirmModal(message, opts) {
opts = opts || {};
el.confirmTitle.textContent = opts.title || 'Bestätigen';
el.confirmMessage.textContent = message;
el.confirmOk.textContent = opts.okLabel || 'Bestätigen';
el.confirmOk.className = 'btn ' + (opts.danger ? 'btn-danger' : 'btn-primary');
el.confirmBackdrop.hidden = false;
return new Promise((resolve) => {
function cleanup(result) {
el.confirmBackdrop.hidden = true;
el.confirmOk.removeEventListener('click', onOk);
el.confirmCancel.removeEventListener('click', onCancel);
el.confirmBackdrop.removeEventListener('click', onBackdrop);
resolve(result);
}
const onOk = () => cleanup(true);
const onCancel = () => cleanup(false);
const onBackdrop = (e) => { if (e.target === el.confirmBackdrop) cleanup(false); };
el.confirmOk.addEventListener('click', onOk);
el.confirmCancel.addEventListener('click', onCancel);
el.confirmBackdrop.addEventListener('click', onBackdrop);
});
}
// ── Custom prompt-Modal mit beliebigen Feldern → Promise<{}|null> ──
// fields: [{ key, label, type='text', placeholder, value, options:[{value,label}], required, hint }]
// onChange(values, fieldEls) optional für Live-Vorschau.
function promptModal(title, fields, onChange) {
el.promptTitle.textContent = title;
el.promptFields.innerHTML = '';
const inputs = {};
fields.forEach((f) => {
const wrap = document.createElement('div');
wrap.className = 'field';
const label = document.createElement('label');
label.textContent = f.label;
wrap.appendChild(label);
let input;
if (f.type === 'select') {
input = document.createElement('select');
(f.options || []).forEach((o) => {
const opt = document.createElement('option');
opt.value = o.value;
opt.textContent = o.label;
input.appendChild(opt);
});
if (f.value != null) input.value = f.value;
} else {
input = document.createElement('input');
input.type = f.type || 'text';
if (f.placeholder) input.placeholder = f.placeholder;
if (f.value != null) input.value = f.value;
}
input.dataset.key = f.key;
wrap.appendChild(input);
if (f.hint || f.live) {
const hint = document.createElement('div');
hint.className = 'hint';
if (f.live) hint.dataset.live = f.key;
if (f.hint) hint.textContent = f.hint;
wrap.appendChild(hint);
}
el.promptFields.appendChild(wrap);
inputs[f.key] = input;
});
const readValues = () => {
const v = {};
Object.keys(inputs).forEach((k) => { v[k] = inputs[k].value; });
return v;
};
el.promptBackdrop.hidden = false;
const first = el.promptFields.querySelector('input, select');
if (first) setTimeout(() => first.focus(), 30);
return new Promise((resolve) => {
function cleanup(result) {
el.promptBackdrop.hidden = true;
el.promptForm.removeEventListener('submit', onSubmit);
el.promptCancel.removeEventListener('click', onCancel);
el.promptBackdrop.removeEventListener('click', onBackdrop);
el.promptForm.removeEventListener('input', onInput);
resolve(result);
}
function onSubmit(e) {
e.preventDefault();
const values = readValues();
// Pflichtfelder prüfen.
for (const f of fields) {
if (f.required && !String(values[f.key] || '').trim()) {
toast('Bitte „' + f.label + '“ ausfüllen.', 'error');
inputs[f.key].focus();
return;
}
}
cleanup(values);
}
const onCancel = () => cleanup(null);
const onBackdrop = (e) => { if (e.target === el.promptBackdrop) cleanup(null); };
const onInput = () => { if (onChange) onChange(readValues(), inputs, el.promptFields); };
el.promptForm.addEventListener('submit', onSubmit);
el.promptCancel.addEventListener('click', onCancel);
el.promptBackdrop.addEventListener('click', onBackdrop);
el.promptForm.addEventListener('input', onInput);
if (onChange) onChange(readValues(), inputs, el.promptFields); // initial
});
}
// ────────────────────────────────────────────────────────────
// Anzeige-Namen (friendly labels)
// ────────────────────────────────────────────────────────────
function footerLabel(name) {
if (name === '_default.html') return 'Gemeinsam (alle Abteilungen)';
return name.replace(/\.html$/i, '');
}
function headerLabel(name) {
if (name === '_vorlage.html') return 'Vorlage (Standard-Kopf)';
// <email>.<slug>.html → "email — slug"
// E-Mail enthält genau ein '@'; alles bis zum ersten '.' NACH dem '@' ist die E-Mail.
const base = name.replace(/\.html$/i, '');
const at = base.indexOf('@');
if (at >= 0) {
const firstDotAfterAt = base.indexOf('.', at);
if (firstDotAfterAt > -1 && firstDotAfterAt < base.length - 1) {
const email = base.slice(0, firstDotAfterAt);
const slug = base.slice(firstDotAfterAt + 1);
return email + ' — ' + slug;
}
}
return base; // Fallback
}
function templateLabel(name) {
return name.replace(/\.html$/i, '');
}
// Liefert eine friendly-Bezeichnung anhand Kategorie + Dateiname.
function friendlyFor(category, name) {
if (category === 'footer') return 'Fußzeile: ' + footerLabel(name);
if (category === 'header') return 'Signatur: ' + headerLabel(name);
return templateLabel(name);
}
// ────────────────────────────────────────────────────────────
// Verbindungsstatus
// ────────────────────────────────────────────────────────────
async function loadConfigAndHealth() {
// Config (best effort, ohne Overlay-Spam → eigener leichter Aufruf)
try {
const cfg = await api('/api/config');
state.config = cfg;
if (!cfg.configured) {
el.configBanner.hidden = false;
setStatus('error', 'Nicht konfiguriert');
return false;
}
el.configBanner.hidden = true;
} catch (e) {
setStatus('error', 'Nicht verbunden');
toast('Konfiguration konnte nicht geladen werden: ' + e.message, 'error');
return false;
}
// Health
try {
const health = await api('/api/health');
if (health.ok) {
const c = state.config;
setStatus('ok', 'Verbunden: ' + c.owner + '/' + c.repo + '@' + c.branch);
} else {
setStatus('error', 'Nicht verbunden: ' + (health.error || 'unbekannt'));
}
} catch (e) {
setStatus('error', 'Nicht verbunden: ' + e.message);
toast('Verbindung zum Repository fehlgeschlagen: ' + e.message, 'error');
return false;
}
return true;
}
function setStatus(kind, text) {
el.statusPill.className = 'status-pill status-' + (kind === 'ok' ? 'ok' : kind === 'error' ? 'error' : 'unknown');
el.statusText.textContent = text;
el.statusPill.title = text;
}
// ────────────────────────────────────────────────────────────
// Baum (Sidebar) laden & rendern
// ────────────────────────────────────────────────────────────
async function loadTree() {
try {
state.tree = await api('/api/tree');
renderTree();
} catch (e) {
toast('Liste konnte nicht geladen werden: ' + e.message, 'error');
}
}
function renderTree() {
renderTemplates();
renderFooters();
renderHeaders();
applySectionCollapse();
highlightActive();
// Aktiven Filter nach Neuaufbau erneut anwenden.
if (el.treeSearch && el.treeSearch.value.trim()) applyTreeFilter();
}
// Live-Filter über alle Dateien (Label + Pfad). Leere Suche = voller Baum.
function applyTreeFilter() {
const q = (el.treeSearch.value || '').trim().toLowerCase();
if (!q) { renderTree(); return; } // pristinen Baum (inkl. Klappzustand) wiederherstellen
document.querySelectorAll('.tree-section').forEach((s) => s.classList.remove('collapsed'));
document.querySelectorAll('.sidebar .group-files').forEach((b) => b.classList.remove('collapsed'));
document.querySelectorAll('.sidebar .group-title').forEach((t) => t.classList.remove('collapsed'));
document.querySelectorAll('.sidebar .add-item, .sidebar .tree-empty').forEach((n) => { n.style.display = 'none'; });
document.querySelectorAll('.sidebar .tree-item').forEach((it) => {
const label = (it.querySelector('.ti-label')?.textContent || '').toLowerCase();
const path = (it.dataset.path || '').toLowerCase();
it.style.display = (label.includes(q) || path.includes(q)) ? '' : 'none';
});
// Gruppen ohne sichtbaren Treffer ausblenden.
document.querySelectorAll('.sidebar .tree-group').forEach((g) => {
const items = g.querySelectorAll('.tree-item');
const anyVisible = Array.from(items).some((i) => i.style.display !== 'none');
g.style.display = (items.length && !anyVisible) ? 'none' : '';
});
}
// Datei-Item-Element bauen.
function fileItem(file, category) {
const div = document.createElement('div');
div.className = 'tree-item';
div.dataset.path = file.path;
div.dataset.sha = file.sha || '';
div.dataset.category = category;
div.dataset.name = file.name;
const label = category === 'footer' ? footerLabel(file.name)
: category === 'header' ? headerLabel(file.name)
: templateLabel(file.name);
const icon = category === 'footer' ? '📜' : category === 'header' ? '✍️' : '📄';
div.innerHTML = '<span class="ti-icon">' + icon + '</span><span class="ti-label"></span>';
div.querySelector('.ti-label').textContent = label;
div.title = file.path;
div.addEventListener('click', () => openFile(file.path, { friendly: friendlyFor(category, file.name), sha: file.sha, category, exists: true }));
return div;
}
// „+ Neue …“-Button.
function addButton(text, onClick) {
const b = document.createElement('button');
b.className = 'add-item';
b.textContent = text;
b.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
return b;
}
// Eine ein-/ausklappbare Gruppe (für Vorlagen).
function makeGroup(key, title, icon, files, category, onAdd, isSub) {
const group = document.createElement('div');
group.className = 'tree-group' + (isSub ? ' subgroup' : '');
const head = document.createElement('div');
head.className = 'group-head';
const collapsed = !!state.groupsCollapsed[key];
const toggle = document.createElement('button');
toggle.className = 'group-title' + (collapsed ? ' collapsed' : '');
toggle.innerHTML = '<span class="g-caret">▾</span><span class="group-icon">' + icon + '</span><span class="g-label"></span>';
toggle.querySelector('.g-label').textContent = title;
head.appendChild(toggle);
group.appendChild(head);
const body = document.createElement('div');
body.className = 'group-files' + (collapsed ? ' collapsed' : '');
if (files.length === 0) {
const empty = document.createElement('div');
empty.className = 'tree-empty';
empty.textContent = 'Keine Dateien';
body.appendChild(empty);
} else {
files.forEach((f) => body.appendChild(fileItem(f, category)));
}
if (onAdd) body.appendChild(addButton('+ Neue Vorlage', onAdd));
group.appendChild(body);
toggle.addEventListener('click', () => {
state.groupsCollapsed[key] = !state.groupsCollapsed[key];
toggle.classList.toggle('collapsed');
body.classList.toggle('collapsed');
});
return group;
}
function renderTemplates() {
const c = el.treeTemplates;
c.innerHTML = '';
const t = state.tree;
if (!t) return;
// _gemeinsam
c.appendChild(makeGroup(
'tmpl:' + SHARED_FOLDER, 'Alle Abteilungen (_gemeinsam)', '🌐',
t.templates[SHARED_FOLDER] || [], 'template',
() => newTemplate(SHARED_FOLDER)
));
// Abteilungen
(t.departments || []).forEach((dept) => {
c.appendChild(makeGroup(
'tmpl:' + dept, dept, '🏢',
t.templates[dept] || [], 'template',
() => newTemplate(dept)
));
});
// Persönlich → übergeordnete Gruppe mit Untergruppen je Benutzer
const users = t.users || {};
const userKeys = Object.keys(users).sort((a, b) => a.localeCompare(b, 'de'));
const persGroup = document.createElement('div');
persGroup.className = 'tree-group';
const persKey = 'tmpl:__pers';
const persCollapsed = !!state.groupsCollapsed[persKey];
const persToggle = document.createElement('button');
persToggle.className = 'group-title' + (persCollapsed ? ' collapsed' : '');
persToggle.innerHTML = '<span class="g-caret">▾</span><span class="group-icon">👤</span><span class="g-label">Persönlich</span>';
const persHead = document.createElement('div');
persHead.className = 'group-head';
persHead.appendChild(persToggle);
persGroup.appendChild(persHead);
const persBody = document.createElement('div');
persBody.className = 'group-files' + (persCollapsed ? ' collapsed' : '');
if (userKeys.length === 0) {
const empty = document.createElement('div');
empty.className = 'tree-empty';
empty.textContent = 'Keine persönlichen Ordner';
persBody.appendChild(empty);
} else {
userKeys.forEach((email) => {
// Persönlicher Ordnerpfad: _benutzer/<email>
persBody.appendChild(makeGroup(
'tmpl:user:' + email, email, '✉️',
users[email] || [], 'template',
() => newTemplate(USER_FOLDER + '/' + email),
true
));
});
}
persGroup.appendChild(persBody);
persToggle.addEventListener('click', () => {
state.groupsCollapsed[persKey] = !state.groupsCollapsed[persKey];
persToggle.classList.toggle('collapsed');
persBody.classList.toggle('collapsed');
});
c.appendChild(persGroup);
}
function renderFooters() {
const c = el.treeFooters;
c.innerHTML = '';
const footers = (state.tree && state.tree.footers) || [];
if (footers.length === 0) {
const empty = document.createElement('div');
empty.className = 'tree-empty';
empty.textContent = 'Keine Fußzeilen';
c.appendChild(empty);
} else {
footers.forEach((f) => c.appendChild(fileItem(f, 'footer')));
}
c.appendChild(addButton('+ Neue Fußzeile', newFooter));
}
function renderHeaders() {
const c = el.treeHeaders;
c.innerHTML = '';
const headers = (state.tree && state.tree.headers) || [];
if (headers.length === 0) {
const empty = document.createElement('div');
empty.className = 'tree-empty';
empty.textContent = 'Keine Signatur-Köpfe';
c.appendChild(empty);
} else {
headers.forEach((f) => c.appendChild(fileItem(f, 'header')));
}
c.appendChild(addButton('+ Neue Signatur', newHeader));
}
function applySectionCollapse() {
document.querySelectorAll('.tree-section').forEach((sec) => {
const key = sec.dataset.section;
const toggle = sec.querySelector('.section-toggle');
const collapsed = !!state.collapsed[key];
sec.classList.toggle('collapsed', collapsed);
toggle.setAttribute('aria-expanded', String(!collapsed));
});
}
function highlightActive() {
document.querySelectorAll('.tree-item').forEach((item) => {
item.classList.toggle('is-active', state.current && item.dataset.path === state.current.path);
});
}
// ────────────────────────────────────────────────────────────
// Datei öffnen / laden
// ────────────────────────────────────────────────────────────
async function openFile(path, meta) {
// Ungespeicherte Änderungen?
if (state.dirty) {
const ok = await confirmModal('Es gibt ungespeicherte Änderungen. Trotzdem eine andere Datei öffnen? Die Änderungen gehen verloren.', { title: 'Ungespeicherte Änderungen', okLabel: 'Verwerfen', danger: true });
if (!ok) return;
}
try {
const data = await api('/api/file?path=' + encodeURIComponent(path));
state.current = {
path: data.path,
friendly: meta.friendly,
sha: data.sha,
exists: data.exists,
isNew: false,
category: meta.category,
};
setEditorContent(data.content || '');
setDirty(false);
showEditor();
highlightActive();
} catch (e) {
toast('Datei konnte nicht geladen werden: ' + e.message, 'error');
}
}
// Neue (noch nicht gespeicherte) Datei direkt im Editor öffnen.
function openNewFile(path, friendly, category) {
state.current = { path, friendly, sha: null, exists: false, isNew: true, category };
setEditorContent('');
setDirty(true); // neu = ungespeichert
showEditor();
highlightActive();
el.visualEditor.focus();
toast('Neue Datei „' + friendly + '“ jetzt bearbeiten und speichern.', 'success');
}
function showEditor() {
el.emptyState.hidden = true;
el.editorPanel.hidden = false;
el.fileFriendly.textContent = state.current.friendly;
el.filePath.textContent = state.current.path;
setView('visual');
}
function hideEditor() {
state.current = null;
el.editorPanel.hidden = true;
el.emptyState.hidden = false;
highlightActive();
}
// Inhalt in beide Editoren + Vorschau setzen.
function setEditorContent(html) {
el.visualEditor.innerHTML = html;
el.htmlEditor.value = html;
updatePreview();
}
// Aktuellen HTML-Inhalt aus dem gerade aktiven View lesen.
function currentHtml() {
return state.view === 'html' ? el.htmlEditor.value : el.visualEditor.innerHTML;
}
// ────────────────────────────────────────────────────────────
// Dirty-State
// ────────────────────────────────────────────────────────────
function setDirty(d) {
state.dirty = d;
el.dirtyBadge.hidden = !d;
// Speichern aktiv, wenn: keine Datei → aus; neue (ungespeicherte) Datei → immer an;
// bestehende Datei → nur bei ungespeicherten Änderungen.
if (!state.current) el.btnSave.disabled = true;
else if (!state.current.exists) el.btnSave.disabled = false;
else el.btnSave.disabled = !d;
}
function markDirty() {
if (!state.dirty) setDirty(true);
}
// ────────────────────────────────────────────────────────────
// View-Umschaltung (Visuell ↔ HTML) synchron halten
// ────────────────────────────────────────────────────────────
function setView(view) {
if (view === state.view && el.editorPanel.hidden === false) {
// trotzdem Tabs/Anzeige korrekt setzen
}
if (view === 'html') {
// Visuell → HTML: innerHTML in Textarea schreiben
el.htmlEditor.value = el.visualEditor.innerHTML;
el.visualWrap.hidden = true;
el.htmlWrap.hidden = false;
el.formatToolbar.classList.add('disabled');
} else {
// HTML → Visuell: Textarea-Wert in contenteditable schreiben
el.visualEditor.innerHTML = el.htmlEditor.value;
el.htmlWrap.hidden = true;
el.visualWrap.hidden = false;
el.formatToolbar.classList.remove('disabled');
}
state.view = view;
el.tabVisual.classList.toggle('is-active', view === 'visual');
el.tabHtml.classList.toggle('is-active', view === 'html');
updatePreview();
}
// ────────────────────────────────────────────────────────────
// Vorschau (sandboxed iframe via srcdoc), debounced
// ────────────────────────────────────────────────────────────
const updatePreview = debounce(function () {
const html = currentHtml();
const doc = '<!DOCTYPE html><html lang="de"><head><meta charset="utf-8">' +
'<style>html,body{margin:0;padding:0;}' +
'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;' +
'color:#1f2a30;line-height:1.5;background:#eef1f4;padding:18px;}' +
'.email-card{max-width:640px;margin:0 auto;background:#fff;padding:24px 28px;' +
'border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.12);}' +
'img{max-width:100%;height:auto;}</style></head>' +
'<body><div class="email-card">' + html + '</div></body></html>';
el.previewFrame.srcdoc = doc;
}, 250);
// ────────────────────────────────────────────────────────────
// Formatierungs-Toolbar (document.execCommand)
// ────────────────────────────────────────────────────────────
function exec(cmd, value) {
el.visualEditor.focus();
try { document.execCommand(cmd, false, value); } catch (e) { /* alte Browser */ }
afterVisualEdit();
}
function afterVisualEdit() {
el.htmlEditor.value = el.visualEditor.innerHTML;
markDirty();
updatePreview();
}
function bindToolbar() {
el.formatToolbar.querySelectorAll('.fmt-btn[data-cmd]').forEach((btn) => {
btn.addEventListener('mousedown', (e) => e.preventDefault()); // Auswahl im Editor behalten
btn.addEventListener('click', () => exec(btn.dataset.cmd));
});
el.fmtFontSize.addEventListener('change', () => {
if (el.fmtFontSize.value) exec('fontSize', el.fmtFontSize.value);
el.fmtFontSize.value = '';
});
el.fmtColor.addEventListener('input', () => {
el.fmtColorSwatch.style.background = el.fmtColor.value;
});
el.fmtColor.addEventListener('change', () => {
exec('foreColor', el.fmtColor.value);
});
el.fmtLink.addEventListener('mousedown', (e) => e.preventDefault());
el.fmtLink.addEventListener('click', async () => {
const res = await promptModal('Link einfügen', [
{ key: 'url', label: 'Adresse (URL)', placeholder: 'https://…', required: true, value: 'https://' },
]);
if (res) exec('createLink', res.url.trim());
});
el.fmtImage.addEventListener('mousedown', (e) => e.preventDefault());
el.fmtImage.addEventListener('click', async () => {
const res = await promptModal('Bild einfügen', [
{ key: 'url', label: 'Bild-URL', placeholder: 'https://…/bild.png', required: true, value: 'https://' },
]);
if (res) exec('insertImage', res.url.trim());
});
}
// ────────────────────────────────────────────────────────────
// Speichern / Neu laden / Löschen
// ────────────────────────────────────────────────────────────
async function saveCurrent() {
if (!state.current) return;
// Sicherstellen, dass beide Editoren synchron sind (aus aktivem View lesen).
const content = currentHtml();
const friendly = state.current.friendly;
const message = friendly + ' bearbeitet (Web-Editor)';
try {
const res = await api('/api/file', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: state.current.path, content: content, message: message }),
});
state.current.exists = true;
state.current.isNew = false;
if (res.sha) state.current.sha = res.sha;
setDirty(false);
if (res.unchanged) toast('Keine Änderungen nichts zu speichern.', 'success');
else toast('„' + friendly + '“ gespeichert.', 'success');
await loadTree(); // neue Dateien auftauchen lassen / SHAs aktualisieren
highlightActive();
} catch (e) {
toast('Speichern fehlgeschlagen: ' + e.message, 'error');
}
}
async function reloadCurrent() {
if (!state.current) return;
if (state.current.isNew) {
toast('Diese Datei wurde noch nicht gespeichert.', 'error');
return;
}
if (state.dirty) {
const ok = await confirmModal('Ungespeicherte Änderungen verwerfen und Datei neu laden?', { title: 'Neu laden', okLabel: 'Verwerfen', danger: true });
if (!ok) return;
}
try {
const data = await api('/api/file?path=' + encodeURIComponent(state.current.path));
state.current.sha = data.sha;
state.current.exists = data.exists;
setEditorContent(data.content || '');
setDirty(false);
setView('visual');
toast('Datei neu geladen.', 'success');
} catch (e) {
toast('Neu laden fehlgeschlagen: ' + e.message, 'error');
}
}
async function deleteCurrent() {
if (!state.current) return;
const friendly = state.current.friendly;
// Noch nicht gespeicherte Datei → nur lokal verwerfen.
if (state.current.isNew) {
const ok = await confirmModal('Diese neue, noch nicht gespeicherte Datei verwerfen?', { title: 'Verwerfen', okLabel: 'Verwerfen', danger: true });
if (!ok) return;
setDirty(false);
hideEditor();
return;
}
const ok = await confirmModal('„' + friendly + '“ wirklich löschen? Dies kann nicht rückgängig gemacht werden.', { title: 'Löschen', okLabel: 'Löschen', danger: true });
if (!ok) return;
try {
await api('/api/file', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: state.current.path, message: friendly + ' gelöscht (Web-Editor)' }),
});
toast('„' + friendly + '“ gelöscht.', 'success');
setDirty(false);
hideEditor();
await loadTree();
} catch (e) {
toast('Löschen fehlgeschlagen: ' + e.message, 'error');
}
}
// ────────────────────────────────────────────────────────────
// Neue Dateien anlegen
// ────────────────────────────────────────────────────────────
async function newTemplate(folder) {
const res = await promptModal('Neue Vorlage in „' + folder + '“', [
{ key: 'name', label: 'Vorlagenname', placeholder: 'z. B. Angebot Doppelzimmer', required: true, live: true, hint: '' },
], (values, inputs, root) => {
const slug = slugifyName(values.name || '');
const liveHint = root.querySelector('[data-live="name"]');
if (liveHint) liveHint.innerHTML = slug ? 'Datei: <span class="preview-name">' + esc(slug) + '.html</span>' : 'Bitte einen Namen eingeben.';
});
if (!res) return;
const slug = slugifyName(res.name);
if (!slug) { toast('Ungültiger Name.', 'error'); return; }
const path = folder + '/' + slug + '.html';
if (await existsInTree(path)) { toast('Eine Vorlage mit diesem Namen existiert bereits.', 'error'); return; }
openNewFile(path, slug, 'template');
}
async function newFooter() {
const t = state.tree || {};
const options = [{ value: '_default', label: 'Gemeinsam (alle Abteilungen)' }]
.concat((t.departments || []).map((d) => ({ value: d, label: d })));
const res = await promptModal('Neue Fußzeile', [
{ key: 'dept', label: 'Für welche Abteilung?', type: 'select', options: options, required: true },
]);
if (!res) return;
const file = (res.dept === '_default' ? '_default' : res.dept) + '.html';
const path = SIG_FOOTERS + '/' + file;
if (await existsInTree(path)) { toast('Diese Fußzeile existiert bereits.', 'error'); return; }
openNewFile(path, 'Fußzeile: ' + footerLabel(file), 'footer');
}
async function newHeader() {
const res = await promptModal('Neue Signatur', [
{ key: 'email', label: 'E-Mail-Adresse', placeholder: 'name@hotel-park-soltau.de', required: true, live: true },
{ key: 'name', label: 'Name', placeholder: 'Max Mustermann', required: true, live: true },
], (values, inputs, root) => {
const email = (values.email || '').trim();
const slug = slugifyHeaderName(values.name || '');
const liveHint = root.querySelector('[data-live="name"]');
const file = (email && slug) ? (email + '.' + slug + '.html') : '';
if (liveHint) liveHint.innerHTML = file ? 'Datei: <span class="preview-name">' + esc(file) + '</span>' : 'E-Mail und Name eingeben.';
});
if (!res) return;
const email = res.email.trim();
const slug = slugifyHeaderName(res.name);
if (!email || !slug) { toast('E-Mail und Name erforderlich.', 'error'); return; }
const file = email + '.' + slug + '.html';
const path = SIG_HEADERS + '/' + file;
if (await existsInTree(path)) { toast('Diese Signatur existiert bereits.', 'error'); return; }
openNewFile(path, 'Signatur: ' + headerLabel(file), 'header');
}
async function newDepartment() {
const res = await promptModal('Neue Abteilung', [
{ key: 'name', label: 'Abteilungsname', placeholder: 'z. B. Rezeption', required: true, hint: 'Wird als Ordner im Repository angelegt.' },
]);
if (!res) return;
const name = res.name.trim();
if (/[\/\\:*?"<>|]/.test(name)) { toast('Ungültiger Abteilungsname.', 'error'); return; }
try {
const r = await api('/api/departments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name }),
});
toast('Abteilung „' + (r.name || name) + '“ angelegt.', 'success');
await loadTree();
} catch (e) {
toast('Abteilung anlegen fehlgeschlagen: ' + e.message, 'error');
}
}
// Prüfen, ob ein Pfad schon im aktuellen Baum vorkommt.
function existsInTree(path) {
const t = state.tree;
if (!t) return false;
const lists = [];
Object.keys(t.templates || {}).forEach((k) => lists.push(t.templates[k]));
Object.keys(t.users || {}).forEach((k) => lists.push(t.users[k]));
lists.push(t.footers || [], t.headers || []);
return lists.some((arr) => (arr || []).some((f) => f.path === path));
}
// ────────────────────────────────────────────────────────────
// Event-Bindungen
// ────────────────────────────────────────────────────────────
function bindEvents() {
// Sektionen ein-/ausklappen
document.querySelectorAll('.section-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
const key = btn.dataset.toggle;
state.collapsed[key] = !state.collapsed[key];
applySectionCollapse();
});
});
// Sidebar-Aktionen
el.btnRefresh.addEventListener('click', loadTree);
if (el.treeSearch) el.treeSearch.addEventListener('input', applyTreeFilter);
el.btnNewDept.addEventListener('click', (e) => { e.stopPropagation(); newDepartment(); });
el.btnNewFooter.addEventListener('click', (e) => { e.stopPropagation(); newFooter(); });
el.btnNewHeader.addEventListener('click', (e) => { e.stopPropagation(); newHeader(); });
// Editor-Aktionen
el.btnSave.addEventListener('click', saveCurrent);
el.btnReload.addEventListener('click', reloadCurrent);
el.btnDelete.addEventListener('click', deleteCurrent);
// View-Tabs
el.tabVisual.addEventListener('click', () => setView('visual'));
el.tabHtml.addEventListener('click', () => setView('html'));
// Visuell editieren
el.visualEditor.addEventListener('input', afterVisualEdit);
// HTML editieren
el.htmlEditor.addEventListener('input', () => {
markDirty();
updatePreview();
});
// Toolbar
bindToolbar();
el.fmtColorSwatch.style.background = el.fmtColor.value;
// Tastenkürzel: Strg/Cmd+S = Speichern
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
if (state.current && !el.btnSave.disabled) saveCurrent();
}
// Escape schließt offene Modals
if (e.key === 'Escape') {
if (!el.promptBackdrop.hidden) el.promptCancel.click();
else if (!el.confirmBackdrop.hidden) el.confirmCancel.click();
}
});
// Vor Verlassen warnen, wenn ungespeichert
window.addEventListener('beforeunload', (e) => {
if (state.dirty) {
e.preventDefault();
e.returnValue = '';
return '';
}
});
}
// ────────────────────────────────────────────────────────────
// Start
// ────────────────────────────────────────────────────────────
async function init() {
bindEvents();
setDirty(false);
const ok = await loadConfigAndHealth();
if (ok) {
await loadTree();
} else if (state.config && !state.config.configured) {
// Banner ist sichtbar; kein Baum-Laden möglich.
}
}
document.addEventListener('DOMContentLoaded', init);
})();

View File

@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HPS Vorlagen &amp; Signaturen</title>
<link rel="stylesheet" href="style.css" />
<link rel="icon" href="logo.svg" />
</head>
<body>
<!-- ── Topbar ── -->
<header class="topbar">
<div class="brand">
<span class="brand-logo"><img src="logo.svg" alt="Hotel Park Soltau" /></span>
<span class="brand-divider" aria-hidden="true"></span>
<span class="brand-title">Vorlagen &amp; Signaturen</span>
</div>
<div class="topbar-right">
<span id="status-pill" class="status-pill status-unknown" title="Verbindungsstatus">
<span class="status-dot"></span>
<span id="status-text">Verbinde…</span>
</span>
</div>
</header>
<!-- ── Config-Banner ── -->
<div id="config-banner" class="config-banner" hidden>
<strong>Verbindung nicht konfiguriert.</strong>
<span>
Bitte die Umgebungsvariablen <code>GITEA_URL</code>, <code>GITEA_OWNER</code>,
<code>GITEA_REPO</code> und <code>GITEA_TOKEN</code> setzen (siehe <code>.env.example</code>)
und den Dienst neu starten.
</span>
</div>
<!-- ── App ── -->
<div class="app">
<!-- Kategorie-Tabs -->
<nav class="cat-tabs" role="tablist">
<button class="cat-tab is-active" data-cat="templates" role="tab">
<span class="cat-ico">📄</span> Vorlagen
</button>
<button class="cat-tab" data-cat="footers" role="tab">
<span class="cat-ico">📜</span> Fußzeilen
</button>
<button class="cat-tab" data-cat="headers" role="tab">
<span class="cat-ico">✍️</span> Signaturen
</button>
<span class="cat-spacer"></span>
<button id="btn-refresh" class="icon-btn" title="Liste neu laden"></button>
</nav>
<div class="workspace">
<!-- Listen-Spalte -->
<aside class="listpane">
<div class="listpane-head">
<input type="search" id="tree-search" class="tree-search" placeholder="Suchen…" autocomplete="off" />
<button id="btn-list-add" class="btn btn-primary btn-sm">&nbsp;Neu</button>
</div>
<div id="list-body" class="list-body"></div>
</aside>
<!-- Editor-Spalte -->
<main class="editorpane">
<!-- Leerzustand -->
<div class="empty-state" id="empty-state">
<div class="empty-illu" aria-hidden="true">
<svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h11l5 5v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z"/>
<path d="M14 4v5h5"/><path d="M8 13h8"/><path d="M8 17h5"/>
</svg>
</div>
<h2>Wähle links einen Eintrag</h2>
<p>Vorlage, Fußzeile oder Signatur anklicken zum Bearbeiten oder über <strong> Neu</strong> einen neuen Eintrag anlegen.</p>
</div>
<!-- Editor -->
<div class="editor-panel" id="editor-panel" hidden>
<div class="editor-head">
<div class="editor-titles">
<h2 id="file-friendly"></h2>
<code id="file-path" class="file-path"></code>
</div>
<div class="editor-head-actions">
<span id="dirty-badge" class="dirty-badge" hidden>Nicht gespeichert</span>
<button class="btn btn-ghost" id="btn-reload" title="Vom Server neu laden">Neu laden</button>
<button class="btn btn-danger-ghost" id="btn-delete">Löschen</button>
<button class="btn btn-primary" id="btn-save">Speichern</button>
</div>
</div>
<div class="editor-tabs" role="tablist">
<button class="editor-tab is-active" id="tab-visual" data-view="visual" role="tab">Bearbeiten</button>
<button class="editor-tab" id="tab-html" data-view="html" role="tab">HTML</button>
<button class="editor-tab" id="tab-preview" data-view="preview" role="tab">Vorschau</button>
</div>
<div class="editor-body">
<div class="epane" id="pane-visual">
<textarea id="visual-editor"></textarea>
</div>
<div class="epane" id="pane-html" hidden>
<textarea class="html-editor" id="html-editor" spellcheck="false" wrap="soft"></textarea>
</div>
<div class="epane" id="pane-preview" hidden>
<div class="preview-frame-wrap">
<iframe class="preview-frame" id="preview-frame" title="Vorschau" sandbox=""></iframe>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- ── Toasts ── -->
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
<!-- ── Lade-Overlay ── -->
<div class="loading-overlay" id="loading-overlay" hidden>
<div class="spinner" aria-label="Lädt"></div>
</div>
<!-- ── Confirm-Modal ── -->
<div class="modal-backdrop" id="confirm-backdrop" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<h3 id="confirm-title">Bestätigen</h3>
<p id="confirm-message">Bist du sicher?</p>
<div class="modal-actions">
<button class="btn btn-ghost" id="confirm-cancel">Abbrechen</button>
<button class="btn btn-danger" id="confirm-ok">Bestätigen</button>
</div>
</div>
</div>
<!-- ── Prompt-Modal ── -->
<div class="modal-backdrop" id="prompt-backdrop" hidden>
<form class="modal" id="prompt-form" role="dialog" aria-modal="true" aria-labelledby="prompt-title">
<h3 id="prompt-title">Eingabe</h3>
<div id="prompt-fields"></div>
<div class="modal-actions">
<button type="button" class="btn btn-ghost" id="prompt-cancel">Abbrechen</button>
<button type="submit" class="btn btn-primary" id="prompt-ok">OK</button>
</div>
</form>
</div>
<script src="/vendor/tinymce/tinymce.min.js"></script>
<script src="app.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

348
web-editor/public/style.css Normal file
View File

@@ -0,0 +1,348 @@
/* HPS Vorlagen & Signaturen — Web-Editor
* Modernes, ruhiges Layout für nicht-technische Anwender.
* Hotel-Park-Soltau-CI: Olivgrün (#95a322) + Anthrazit (#3c3c3b).
*/
/* ── Tokens ── */
:root {
--brand: #647219; /* tiefes Oliv weiße Schrift bleibt lesbar */
--brand-600: #556114;
--brand-700: #45500f;
--brand-50: #f1f4e1;
--brand-100: #e0e7bf;
--accent: #95a322; /* reines Logo-Grün (Akzente) */
--charcoal: #3c3c3b;
--charcoal-2: #2f2f2e;
--bg: #eceff1;
--panel: #ffffff;
--sidebar: #f6f8f9;
--text: #232c2e;
--muted: #687279;
--muted-2: #97a1a7;
--border: #e4e8eb;
--border-strong:#d2d9dd;
--danger: #d6453f;
--danger-50: #fdecea;
--success: #2f9e6b;
--info: #2b6c8f;
--radius: 16px;
--radius-md: 11px;
--radius-sm: 8px;
--shadow-sm: 0 1px 2px rgba(20,40,45,.06), 0 1px 3px rgba(20,40,45,.07);
--shadow-md: 0 6px 18px rgba(20,40,45,.10), 0 2px 6px rgba(20,40,45,.06);
--shadow-lg: 0 20px 56px rgba(20,40,45,.24);
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Noto Sans", sans-serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--topbar-h: 60px;
--tabs-h: 56px;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body {
margin: 0; height: 100%;
font-family: var(--font);
color: var(--text);
background: var(--bg);
font-size: 15px;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button { font-family: inherit; }
/* ── Topbar ── */
.topbar {
height: var(--topbar-h);
display: flex; align-items: center; justify-content: space-between;
padding: 0 20px;
background: linear-gradient(100deg, var(--charcoal-2), var(--charcoal) 55%, #46463f);
color: #fff;
box-shadow: var(--shadow-md);
position: sticky; top: 0; z-index: 30;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-logo {
display: flex; align-items: center;
background: #fff; border-radius: 10px; padding: 5px 11px;
box-shadow: var(--shadow-sm);
}
.brand-logo img { height: 30px; display: block; }
.brand-divider { width: 1px; height: 26px; background: rgba(255,255,255,.22); }
.brand-title { font-size: 15px; font-weight: 600; letter-spacing: .3px; color: rgba(255,255,255,.92); }
.topbar-right { display: flex; align-items: center; gap: 14px; }
.status-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 13px; border-radius: 999px;
font-size: 12.5px; font-weight: 600;
background: rgba(255,255,255,.12); color: #fff;
border: 1px solid rgba(255,255,255,.18);
max-width: 46vw; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
.status-pill .status-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted-2); flex: none; }
.status-ok .status-dot { background: #7bd14f; box-shadow: 0 0 0 3px rgba(123,209,79,.25); }
.status-error .status-dot { background: #ff8a82; box-shadow: 0 0 0 3px rgba(255,138,130,.25); }
.status-ok { background: rgba(149,163,34,.22); border-color: rgba(149,163,34,.4); }
.status-error { background: rgba(255,138,130,.16); border-color: rgba(255,138,130,.4); }
/* ── Config-Banner ── */
.config-banner {
margin: 16px 22px 0; padding: 14px 18px;
background: #fff7e6; border: 1px solid #f0d8a0; border-left: 4px solid var(--accent);
border-radius: var(--radius-md); color: #6a5320; font-size: 13.5px; line-height: 1.55;
}
.config-banner code { font-family: var(--mono); background: #faedce; padding: 1px 6px; border-radius: 5px; font-size: 12.5px; }
/* ── App / Tabs ── */
.app { height: calc(100vh - var(--topbar-h)); display: flex; flex-direction: column; }
.cat-tabs {
height: var(--tabs-h);
display: flex; align-items: center; gap: 6px;
padding: 0 18px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.cat-tab {
display: inline-flex; align-items: center; gap: 8px;
border: none; background: none; cursor: pointer;
padding: 9px 16px; border-radius: 999px;
font-size: 14px; font-weight: 600; color: var(--muted);
transition: all .15s;
}
.cat-tab .cat-ico { font-size: 15px; }
.cat-tab:hover { background: var(--brand-50); color: var(--brand-600); }
.cat-tab.is-active { background: var(--brand); color: #fff; box-shadow: var(--shadow-sm); }
.cat-spacer { flex: 1; }
.icon-btn {
width: 36px; height: 36px; border-radius: 9px;
border: 1px solid var(--border-strong); background: #fff; color: var(--muted);
font-size: 17px; cursor: pointer; transition: all .15s;
display: grid; place-items: center;
}
.icon-btn:hover { color: var(--brand); border-color: var(--brand-100); background: var(--brand-50); }
/* ── Workspace: Liste + Editor ── */
.workspace { flex: 1; display: grid; grid-template-columns: 300px 1fr; min-height: 0; }
.listpane {
background: var(--sidebar);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
min-height: 0;
}
.listpane-head {
display: flex; gap: 8px; align-items: center;
padding: 14px 14px 10px;
}
.tree-search {
flex: 1; min-width: 0;
border: 1px solid var(--border-strong); background: #fff; border-radius: var(--radius-sm);
padding: 9px 12px; font-size: 13.5px; color: var(--text); outline: none;
transition: border-color .15s, box-shadow .15s;
}
.tree-search::placeholder { color: var(--muted-2); }
.tree-search:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
.list-body { flex: 1; overflow-y: auto; padding: 4px 10px 30px; }
/* Gruppen (Vorlagen je Abteilung) */
.tree-group { margin-bottom: 4px; }
.group-title {
width: 100%; text-align: left;
display: flex; align-items: center; gap: 8px;
background: none; border: none; cursor: pointer;
padding: 9px 10px; border-radius: var(--radius-sm);
font-size: 12px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--muted);
transition: background .12s;
}
.group-title:hover { background: #eaeef0; color: var(--brand-600); }
.group-title .g-caret { font-size: 9px; color: var(--muted-2); width: 10px; transition: transform .18s; }
.group-title.collapsed .g-caret { transform: rotate(-90deg); }
.group-title .group-icon { font-size: 14px; }
.group-title .g-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: none; letter-spacing: normal; font-size: 13px; }
.group-title .g-count { font-size: 11px; font-weight: 600; color: var(--muted-2); background: #fff; border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; }
.group-files { display: flex; flex-direction: column; gap: 2px; padding: 2px 0 6px 4px; }
.group-files.collapsed { display: none; }
/* Datei-Items */
.tree-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 11px; border-radius: var(--radius-sm); cursor: pointer;
font-size: 13.5px; color: #3a464c;
transition: background .12s, color .12s;
}
.tree-item:hover { background: #eaeef0; color: var(--text); }
.tree-item .ti-icon { font-size: 14px; opacity: .85; flex: none; }
.tree-item .ti-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-item.is-active { background: var(--brand); color: #fff; box-shadow: var(--shadow-sm); }
.tree-item.is-active .ti-icon { opacity: 1; }
.tree-empty { padding: 10px 12px; font-size: 12.5px; color: var(--muted-2); font-style: italic; }
.add-item {
margin: 3px 0 2px; padding: 8px 11px; width: 100%; text-align: left;
background: none; border: 1px dashed var(--border-strong); color: var(--muted);
border-radius: var(--radius-sm); font-size: 12.5px; font-weight: 600; cursor: pointer;
transition: all .15s;
}
.add-item:hover { color: var(--brand); border-color: var(--brand-100); background: var(--brand-50); }
/* ── Editor-Spalte ── */
.editorpane { min-height: 0; display: flex; flex-direction: column; padding: 18px; }
.empty-state { margin: auto; text-align: center; max-width: 420px; color: var(--muted); }
.empty-illu { color: var(--brand-100); margin-bottom: 6px; }
.empty-state h2 { margin: 6px 0 8px; color: var(--text); font-size: 21px; font-weight: 680; }
.empty-state p { margin: 0; line-height: 1.6; font-size: 14px; }
.editor-panel {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-md);
display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden;
}
.editor-head {
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
padding: 18px 22px 15px; border-bottom: 1px solid var(--border);
}
.editor-titles { min-width: 0; }
.editor-titles h2 {
margin: 0 0 7px; font-size: 19px; font-weight: 680; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.file-path {
display: inline-block; font-family: var(--mono); font-size: 11.5px; color: var(--muted);
background: var(--bg); border: 1px solid var(--border); padding: 3px 9px; border-radius: 999px;
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.editor-head-actions { display: flex; align-items: center; gap: 9px; flex: none; }
.dirty-badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 600; color: #8a6312;
background: #fdf3df; border: 1px solid #f0dca6; padding: 5px 11px; border-radius: 999px;
}
.dirty-badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
/* Buttons */
.btn {
border: 1px solid transparent; border-radius: var(--radius-sm); padding: 9px 16px;
font-size: 13.5px; font-weight: 600; cursor: pointer; transition: all .15s ease;
white-space: nowrap; line-height: 1.1;
}
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--brand); color: #fff; box-shadow: var(--shadow-sm); }
.btn-primary:not(:disabled):hover { background: var(--brand-600); transform: translateY(-1px); box-shadow: var(--shadow-md); }
.btn-ghost { background: #fff; color: var(--text); border-color: var(--border-strong); }
.btn-ghost:hover { background: var(--bg); border-color: var(--muted-2); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: #bd3a35; }
.btn-danger-ghost { background: #fff; color: var(--danger); border-color: #ecc4c2; }
.btn-danger-ghost:hover { background: var(--danger-50); border-color: var(--danger); }
.btn-sm { padding: 8px 13px; font-size: 12.5px; }
/* Editor-Tabs */
.editor-tabs { display: flex; gap: 4px; padding: 12px 22px 0; }
.editor-tab {
border: none; background: none; cursor: pointer;
padding: 9px 18px; border-radius: 9px 9px 0 0;
font-size: 13.5px; font-weight: 600; color: var(--muted);
border-bottom: 2px solid transparent; transition: all .15s;
}
.editor-tab:hover { color: var(--text); background: var(--brand-50); }
.editor-tab.is-active { color: var(--brand); border-bottom-color: var(--brand); }
/* Editor-Body */
.editor-body { flex: 1; min-height: 0; display: flex; padding: 14px 22px 22px; }
.epane { flex: 1; min-height: 0; display: flex; }
/* TinyMCE soll die Spalte füllen und runde Ecken haben */
#pane-visual { flex-direction: column; }
.tox.tox-tinymce { flex: 1; border-radius: var(--radius-md) !important; border-color: var(--border-strong) !important; }
.tox .tox-editor-header { box-shadow: none !important; }
.html-editor {
flex: 1; width: 100%; resize: none;
border: 1px solid var(--border-strong); border-radius: var(--radius-md);
background: #fbfcfd; padding: 16px 18px;
font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: #2a3a42;
outline: none; tab-size: 2; transition: border-color .15s, box-shadow .15s;
}
.html-editor:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
#pane-preview { }
.preview-frame-wrap {
flex: 1; border: 1px solid var(--border); border-radius: var(--radius-md);
background: #e7ebee; overflow: hidden; min-height: 0;
background-image: radial-gradient(rgba(60,60,59,.07) 1px, transparent 1px);
background-size: 16px 16px;
}
.preview-frame { width: 100%; height: 100%; border: none; background: transparent; }
/* ── Toasts ── */
.toast-stack { position: fixed; right: 20px; bottom: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 60; max-width: 380px; }
.toast {
display: flex; align-items: flex-start; gap: 11px;
background: #fff; border: 1px solid var(--border); border-left: 4px solid var(--info);
border-radius: var(--radius-md); box-shadow: var(--shadow-lg);
padding: 13px 16px; font-size: 13.5px; line-height: 1.45; color: var(--text);
animation: toast-in .26s cubic-bezier(.21,1.02,.73,1);
}
.toast-icon { width: 22px; height: 22px; flex: none; display: grid; place-items: center; border-radius: 50%; font-size: 13px; font-weight: 700; color: #fff; background: var(--info); }
.toast-msg { padding-top: 1px; }
.toast-success { border-left-color: var(--success); } .toast-success .toast-icon { background: var(--success); }
.toast-error { border-left-color: var(--danger); } .toast-error .toast-icon { background: var(--danger); }
.toast.fade-out { animation: toast-out .3s ease forwards; }
@keyframes toast-in { from { opacity: 0; transform: translateY(12px) scale(.98); } to { opacity: 1; transform: none; } }
@keyframes toast-out { to { opacity: 0; transform: translateX(20px); } }
/* ── Lade-Overlay ── */
.loading-overlay { position: fixed; inset: 0; background: rgba(20,40,45,.28); backdrop-filter: blur(2px); display: grid; place-items: center; z-index: 70; }
.spinner { width: 44px; height: 44px; border: 4px solid rgba(255,255,255,.4); border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Modals ── */
.modal-backdrop { position: fixed; inset: 0; background: rgba(18,30,33,.45); backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 80; padding: 20px; animation: fade-in .15s ease; }
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
.modal { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow-lg); width: 100%; max-width: 440px; padding: 24px 24px 20px; animation: modal-pop .2s cubic-bezier(.21,1.02,.73,1); }
@keyframes modal-pop { from { opacity: 0; transform: translateY(10px) scale(.97); } to { opacity: 1; transform: none; } }
.modal h3 { margin: 0 0 10px; font-size: 17px; font-weight: 680; color: var(--text); }
.modal p { margin: 0 0 4px; color: #44525a; line-height: 1.55; font-size: 14px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.field { margin-top: 14px; }
.field:first-child { margin-top: 6px; }
.field label { display: block; font-size: 12.5px; font-weight: 650; color: var(--muted); margin-bottom: 6px; }
.field input, .field select {
width: 100%; border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
padding: 10px 12px; font-size: 14px; color: var(--text); outline: none; background: #fff;
transition: border-color .15s, box-shadow .15s;
}
.field input:focus, .field select:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
.field .hint { margin-top: 7px; font-size: 12.5px; color: var(--muted); line-height: 1.4; }
.field .hint .preview-name { font-family: var(--mono); font-size: 12px; background: var(--brand-50); color: var(--brand-600); padding: 1px 6px; border-radius: 5px; }
/* ── Scrollbars ── */
.list-body::-webkit-scrollbar, .html-editor::-webkit-scrollbar { width: 10px; }
.list-body::-webkit-scrollbar-thumb, .html-editor::-webkit-scrollbar-thumb { background: #cdd6da; border-radius: 10px; border: 2px solid transparent; background-clip: content-box; }
.list-body::-webkit-scrollbar-thumb:hover { background: #b3bfc4; background-clip: content-box; }
/* ── Responsive ── */
@media (max-width: 980px) {
.workspace { grid-template-columns: 250px 1fr; }
}
@media (max-width: 760px) {
.app { height: auto; }
.workspace { grid-template-columns: 1fr; }
.listpane { border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
.editorpane { height: auto; }
.editor-panel { min-height: 72vh; }
.status-pill { max-width: 36vw; }
.brand-title { display: none; }
}

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

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