17 Commits

Author SHA1 Message Date
Kendrick Bollens
0d05f9412c Release v2.3.2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:28:37 +02:00
Kendrick Bollens
fd192bb8ba URLs auf hps-Org umstellen (Repo-Transfer)
- Auto-Update-URLs (manifest.json, updates.json), release.sh OWNER, CLAUDE.md
  von kendrick.bollens auf hps
- web-editor/docker-compose.yml: Git-Build-Context auf hps-Repo-URL

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:24:32 +02:00
Kendrick Bollens
113bc1bc20 web-editor: TinyMCE-Editor, Verwaltung, schnelles Tree-Laden, CI-Design
- TinyMCE (selbst gehostet) mit Base64-Bildeinbettung statt contenteditable
- Kategorie-Tabs Vorlagen/Fußzeilen/Signaturen + Verwaltung
  (Übersicht, Abteilungen, E-Mail-Zuordnung, Schlagwörter)
- /api/tree über rekursive git/trees-API (1 statt ~17 Anfragen)
- Plus-Jakarta-Sans-Font, SVG-Icons, farbige Abteilungs-Badges
- Platzhalter-Hinweis (nur in Signatur-Vorlage _vorlage.html)
- LOCAL- und DEMO-Modus im Server

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:16:36 +02:00
Kendrick Bollens
8130269f8f Release v2.3.1 (Test Auto-Update)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:33:42 +02:00
Kendrick Bollens
0563146ee1 Release v2.3.0: updates.json mit korrektem xpi-Hash, release.sh Guard-Fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:28:58 +02:00
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
37 changed files with 7433 additions and 403 deletions

3
.gitignore vendored Normal file
View File

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

47
CLAUDE.md Normal file
View File

@@ -0,0 +1,47 @@
# CLAUDE.md
Thunderbird-MailExtension „HPS Vorlagen & Signaturen" mit Gitea-Sync.
## Workflow (WICHTIG)
1. **Bei jeder Änderung, die zu den Usern soll: `version` in `manifest.json` bumpen.**
Auto-Update vergleicht Versionsnummern — gleiche Version = Clients ziehen das Update NICHT.
Also vor dem Build erhöhen (z.B. 2.3.0 → 2.3.1 für Fixes, 2.4.0 für Features).
2. **Nach jeder Code-Änderung am Plugin immer die `.xpi` neu bauen** (siehe Build unten),
damit `templates-reply-hotel.xpi` aktuell ist.
3. **Sobald der User zufrieden ist ("happy"), committen** — Code-Änderung + neu gebaute
`.xpi` zusammen. Nicht ungefragt vorher committen; auf das OK des Users warten.
4. **Soll es ausgerollt werden: Release veröffentlichen** (siehe Auto-Update unten) —
`./release.sh`, dann `updates.json` + `manifest.json` committen & pushen.
## 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: `hps/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.

148
README.md
View File

@@ -1,38 +1,130 @@
# Templates Reply
## Mozilla Thunderbird Add-On
# HPS Vorlagen & Signaturen
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.
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.
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.
## Features
You can freely use, modify, and distribute the source code under the terms of the MIT License.
## Screenshots
- **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
![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)
## Repository-Struktur (Gitea)
## 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.
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
```
## OR via Thunderbird Add-on Manager
### `_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"
}
```
Open Thunderbird
Go toTools → Add-ons and Themes
Search for the Templates Reply add-on by name
Click Add to Thunderbird
Confirm installation
## 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,46 @@
{
"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.2",
"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/hps/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 +50,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;

70
release.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/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="hps"
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 ---
# Check the archive's file list (not raw bytes — the source references the
# filename as a string, which would be a false positive).
if 7z l "$XPI" | grep -q "defaults.local.json"; 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>

38
updates.json Normal file
View File

@@ -0,0 +1,38 @@
{
"addons": {
"it@hotel-park-soltau.de": {
"updates": [
{
"version": "2.3.2",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.2/templates-reply-hotel.xpi",
"update_hash": "sha256:8466d174aeda6c15577a45ed1cd442bc592d35a1da602d79b1f169ae3d012bd7",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{
"version": "2.3.1",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.1/templates-reply-hotel.xpi",
"update_hash": "sha256:bcfb4feade849d1dabaccaa8b932ea6d57846c82f6e9796e2c39d577ffc09744",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{
"version": "2.3.0",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.0/templates-reply-hotel.xpi",
"update_hash": "sha256:94ca10bb1e35cc8183c4ed2cba640ad06b8cb25273a85d643c8920cfe11158ef",
"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,31 @@
services:
web-editor:
# Quellcode direkt aus dem Git-Repo bauen (kein Kopieren nötig).
# Für lokale Entwicklung stattdessen `build: .` verwenden (siehe unten).
build:
context: "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates.git#main:web-editor"
secrets:
- GIT_AUTH_TOKEN
# build: . # ← lokale Variante: baut aus diesem Ordner statt aus Git
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
# BuildKit nutzt dieses Secret, um das (private) Repo beim Git-Build zu klonen.
secrets:
GIT_AUTH_TOKEN:
environment: GITEA_TOKEN

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"
}

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

@@ -0,0 +1,726 @@
/* HPS Vorlagen & Signaturen — Web-Editor Frontend
* Vanilla JS + TinyMCE (selbst gehostet). Spricht ausschließlich mit dem
* Express-Backend (same origin) über die /api-Endpunkte.
*/
(function () {
'use strict';
// ── Backend-Konstanten ──
const SHARED_FOLDER = '_gemeinsam';
const USER_FOLDER = '_benutzer';
const SIG_FOOTERS = 'signatures/footers';
const SIG_HEADERS = 'signatures/headers';
// ── Icon-Set (Lucide-Stil, currentColor) ──
const ICONS = {
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
'panel-bottom': '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 15h18"/>',
'pen-line': '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'globe': '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
'building': '<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M12 14h.01M16 10h.01M16 14h.01M8 10h.01M8 14h.01"/>',
'at-sign': '<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/>',
'plus': '<path d="M5 12h14"/><path d="M12 5v14"/>',
'refresh': '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
'reload': '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>',
'save': '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
'trash': '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
'search': '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
'dashboard': '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
'tag': '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
'link2': '<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
'chevron': '<path d="m6 9 6 6 6-6"/>',
'plug': '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
};
function icon(name, size) {
const s = size || 18;
return '<svg viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + (ICONS[name] || '') + '</svg>';
}
function hydrateIcons(root) {
(root || document).querySelectorAll('[data-icon]').forEach((e) => { e.innerHTML = icon(e.dataset.icon); });
}
// ── Zustand ──
const state = {
config: null, tree: null,
category: 'templates', // templates | footers | headers | admin
adminView: 'overview', // overview | departments | mapping | tags
current: null, dirty: false,
view: 'visual', html: '',
groupsCollapsed: {},
};
let ed = null, edReady = false, suppressDirty = false, pendingNetwork = 0;
// ── DOM ──
const $ = (id) => document.getElementById(id);
const el = {
statusPill: $('status-pill'), statusText: $('status-text'), configBanner: $('config-banner'),
catTabs: document.querySelectorAll('.cat-tab'),
btnRefresh: $('btn-refresh'), btnListAdd: $('btn-list-add'), btnListAddLabel: $('btn-list-add-label'),
treeSearch: $('tree-search'), listBody: $('list-body'),
emptyState: $('empty-state'), editorPanel: $('editor-panel'), adminPanel: $('admin-panel'),
fileFriendly: $('file-friendly'), filePath: $('file-path'), dirtyBadge: $('dirty-badge'),
btnSave: $('btn-save'), btnReload: $('btn-reload'), btnDelete: $('btn-delete'),
tabVisual: $('tab-visual'), tabHtml: $('tab-html'), tabPreview: $('tab-preview'),
paneVisual: $('pane-visual'), paneHtml: $('pane-html'), panePreview: $('pane-preview'),
htmlEditor: $('html-editor'), previewFrame: $('preview-frame'),
toastStack: $('toast-stack'), loading: $('loading-overlay'),
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'),
};
// ── Helfer ──
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function slugifyName(name) {
return name.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss')
.replace(/[/\\:*?"<>|]/g, '-').replace(/^[\s.-]+|[\s.-]+$/g, '').trim();
}
function slugifyHeaderName(name) { return slugifyName(name).toLowerCase().replace(/\s+/g, '-'); }
// ── Toasts ──
function toast(message, type) {
const t = document.createElement('div');
t.className = 'toast' + (type ? ' toast-' + type : '');
const ic = type === 'success' ? '✓' : type === 'error' ? '⚠' : '';
t.innerHTML = '<span class="toast-icon">' + ic + '</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 }); }, 4200);
}
// ── Loading + API ──
function startLoading() { pendingNetwork++; el.loading.hidden = false; }
function stopLoading() { pendingNetwork = Math.max(0, pendingNetwork - 1); if (pendingNetwork === 0) el.loading.hidden = true; }
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) throw new Error((data && data.error) ? data.error : ('HTTP ' + res.status + ' ' + res.statusText));
return data || {};
} finally { stopLoading(); }
}
// ── Modals ──
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(r) {
el.confirmBackdrop.hidden = true;
el.confirmOk.removeEventListener('click', onOk); el.confirmCancel.removeEventListener('click', onCancel); el.confirmBackdrop.removeEventListener('click', onBackdrop);
resolve(r);
}
const onOk = () => cleanup(true), 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);
});
}
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 h = document.createElement('div'); h.className = 'hint'; if (f.live) h.dataset.live = f.key; if (f.hint) h.textContent = f.hint; wrap.appendChild(h); }
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(r) {
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(r);
}
function onSubmit(e) {
e.preventDefault(); const values = readValues();
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);
});
}
// ── Anzeige-Namen ──
function footerLabel(name) { return name === '_default.html' ? 'Gemeinsam (alle Abteilungen)' : name.replace(/\.html$/i, ''); }
function headerLabel(name) {
if (name === '_vorlage.html') return 'Vorlage (Standard-Kopf)';
const base = name.replace(/\.html$/i, ''); const at = base.indexOf('@');
if (at >= 0) { const dot = base.indexOf('.', at); if (dot > -1 && dot < base.length - 1) return base.slice(0, dot) + ' — ' + base.slice(dot + 1); }
return base;
}
function templateLabel(name) { return name.replace(/\.html$/i, ''); }
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() {
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 nicht ladbar: ' + e.message, 'error'); return false; }
try {
const health = await api('/api/health');
if (health.ok) { const c = state.config; setStatus('ok', c.owner + '/' + c.repo + '@' + c.branch); }
else setStatus('error', 'Nicht verbunden: ' + (health.error || 'unbekannt'));
} catch (e) { setStatus('error', 'Nicht verbunden: ' + e.message); toast('Verbindung 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;
}
// ── Hauptbereich umschalten ──
function showMain(kind) {
el.emptyState.hidden = kind !== 'empty';
el.editorPanel.hidden = kind !== 'editor';
el.adminPanel.hidden = kind !== 'admin';
}
// ── Kategorie ──
function setCategory(cat) {
state.category = cat;
el.catTabs.forEach((t) => t.classList.toggle('is-active', t.dataset.cat === cat));
const isAdmin = cat === 'admin';
el.btnListAdd.style.display = isAdmin ? 'none' : '';
el.treeSearch.parentElement.style.display = isAdmin ? 'none' : '';
if (cat === 'templates') el.btnListAddLabel.textContent = 'Abteilung';
else if (cat === 'footers') el.btnListAddLabel.textContent = 'Fußzeile';
else if (cat === 'headers') el.btnListAddLabel.textContent = 'Signatur';
renderList();
if (isAdmin) setAdminView(state.adminView);
else showMain(state.current ? 'editor' : 'empty');
}
// ── Tree laden ──
async function loadTree() { try { state.tree = await api('/api/tree'); renderList(); } catch (e) { toast('Liste nicht ladbar: ' + e.message, 'error'); } }
// ── Listen-Spalte ──
function fileItem(file, category) {
const div = document.createElement('div');
div.className = 'tree-item'; div.dataset.path = file.path;
const label = category === 'footer' ? footerLabel(file.name) : category === 'header' ? headerLabel(file.name) : templateLabel(file.name);
const ic = category === 'footer' ? 'panel-bottom' : category === 'header' ? 'pen-line' : 'file-text';
div.innerHTML = '<span class="ti-icon">' + icon(ic, 16) + '</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;
}
// Gedämpfte, aber unterscheidbare Farbpalette für Abteilungs-Badges.
const DEPT_PALETTE = [
{ fg: '#647219', bg: '#eef2da' }, { fg: '#2f7d83', bg: '#dff0f0' },
{ fg: '#b5683f', bg: '#f7e8df' }, { fg: '#6c5a90', bg: '#ece7f3' },
{ fg: '#4a6488', bg: '#e5ecf5' }, { fg: '#9a7d1e', bg: '#f5efd6' },
{ fg: '#a8527a', bg: '#f6e5ee' }, { fg: '#3f7d5a', bg: '#e2f0e8' },
];
// Kürzeste eindeutige Abkürzung je Abteilung (min. 2 Zeichen):
// Rezeption/Restaurant → REZ/RES, Buchhaltung → BU, IT → IT.
function computeAbbrevs(names) {
const clean = (n) => (n.replace(/[^a-z0-9äöüß]/gi, '') || n);
const cleaned = names.map(clean);
const map = {};
names.forEach((n, i) => {
const cn = cleaned[i];
const maxLen = Math.min(3, cn.length); // Badge bleibt kurz; Rest unterscheidet die Farbe
let len = Math.min(2, cn.length);
while (len < maxLen) {
const pre = cn.slice(0, len).toLowerCase();
const collide = cleaned.some((o, j) => j !== i && o.slice(0, len).toLowerCase() === pre);
if (!collide) break;
len++;
}
map[n] = cn.slice(0, len).toUpperCase();
});
return map;
}
function deptBadge(name, label) {
const i = Math.abs([...name].reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0)) % DEPT_PALETTE.length;
return { type: 'initial', value: label || (name.trim()[0] || '?').toUpperCase(), fg: DEPT_PALETTE[i].fg, bg: DEPT_PALETTE[i].bg };
}
function neutralBadge(iconName) { return { type: 'icon', value: iconName, fg: 'var(--muted)', bg: 'var(--bg)' }; }
function makeGroup(key, title, badge, files, onAdd) {
const group = document.createElement('div'); group.className = 'tree-group';
const collapsed = !!state.groupsCollapsed[key];
const head = document.createElement('div'); head.className = 'group-head' + (collapsed ? ' collapsed' : '');
const badgeInner = badge.type === 'icon' ? icon(badge.value, 15) : esc(badge.value);
const fs = badge.type === 'icon' ? '' : (badge.value.length >= 3 ? ';font-size:9.5px' : badge.value.length === 2 ? ';font-size:11px' : '');
head.innerHTML =
'<span class="g-caret">' + icon('chevron', 14) + '</span>' +
'<span class="g-badge" style="background:' + badge.bg + ';color:' + badge.fg + fs + '">' + badgeInner + '</span>' +
'<span class="g-label"></span>' +
'<span class="g-count">' + files.length + '</span>' +
(onAdd ? '<button class="g-add" title="Neue Vorlage">' + icon('plus', 15) + '</button>' : '');
head.querySelector('.g-label').textContent = title;
const body = document.createElement('div'); body.className = 'group-files' + (collapsed ? ' collapsed' : '');
if (files.length === 0) { const e = document.createElement('div'); e.className = 'tree-empty'; e.textContent = 'Noch leer'; body.appendChild(e); }
else files.forEach((f) => body.appendChild(fileItem(f, 'template')));
head.addEventListener('click', (ev) => {
if (ev.target.closest('.g-add')) return;
state.groupsCollapsed[key] = !state.groupsCollapsed[key];
head.classList.toggle('collapsed'); body.classList.toggle('collapsed');
});
if (onAdd) head.querySelector('.g-add').addEventListener('click', (ev) => { ev.stopPropagation(); onAdd(); });
group.appendChild(head); group.appendChild(body); return group;
}
function adminNavItem(view, label, iconName) {
const b = document.createElement('button');
b.className = 'nav-item' + (state.adminView === view ? ' is-active' : '');
b.innerHTML = '<span class="ni-icon">' + icon(iconName, 17) + '</span><span>' + esc(label) + '</span>';
b.addEventListener('click', () => setAdminView(view));
return b;
}
function renderList() {
const c = el.listBody; c.innerHTML = ''; const t = state.tree;
if (state.category === 'admin') {
const wrap = document.createElement('div'); wrap.className = 'nav-list';
wrap.appendChild(adminNavItem('overview', 'Übersicht', 'dashboard'));
wrap.appendChild(adminNavItem('departments', 'Abteilungen', 'building'));
wrap.appendChild(adminNavItem('mapping', 'E-Mail-Zuordnung', 'at-sign'));
wrap.appendChild(adminNavItem('tags', 'Schlagwörter', 'tag'));
c.appendChild(wrap);
return;
}
if (!t) return;
if (state.category === 'templates') {
c.appendChild(makeGroup('tmpl:' + SHARED_FOLDER, 'Alle Abteilungen', neutralBadge('globe'), t.templates[SHARED_FOLDER] || [], () => newTemplate(SHARED_FOLDER)));
const abbr = computeAbbrevs(t.departments || []);
(t.departments || []).forEach((d) => c.appendChild(makeGroup('tmpl:' + d, d, deptBadge(d, abbr[d]), t.templates[d] || [], () => newTemplate(d))));
const users = t.users || {}; Object.keys(users).sort((a, b) => a.localeCompare(b, 'de')).forEach((email) =>
c.appendChild(makeGroup('tmpl:user:' + email, email, neutralBadge('at-sign'), users[email] || [], () => newTemplate(USER_FOLDER + '/' + email))));
} else if (state.category === 'footers') {
const footers = t.footers || [];
if (!footers.length) c.appendChild(emptyHint('Keine Fußzeilen')); else footers.forEach((f) => c.appendChild(fileItem(f, 'footer')));
} else {
const headers = t.headers || [];
if (!headers.length) c.appendChild(emptyHint('Keine Signaturen')); else headers.forEach((f) => c.appendChild(fileItem(f, 'header')));
}
highlightActive();
if (el.treeSearch.value.trim()) applyFilter();
}
function emptyHint(text) { const e = document.createElement('div'); e.className = 'tree-empty'; e.textContent = text; return e; }
function applyFilter() {
const q = el.treeSearch.value.trim().toLowerCase();
document.querySelectorAll('.list-body .tree-empty').forEach((n) => { n.style.display = q ? 'none' : ''; });
document.querySelectorAll('.list-body .group-files').forEach((b) => { if (q) b.classList.remove('collapsed'); });
document.querySelectorAll('.list-body .group-head').forEach((tg) => { if (q) tg.classList.remove('collapsed'); });
document.querySelectorAll('.list-body .tree-item').forEach((it) => {
const label = (it.querySelector('.ti-label')?.textContent || '').toLowerCase();
it.style.display = (!q || label.includes(q) || (it.dataset.path || '').toLowerCase().includes(q)) ? '' : 'none';
});
document.querySelectorAll('.list-body .tree-group').forEach((g) => {
const items = g.querySelectorAll('.tree-item');
const any = Array.from(items).some((i) => i.style.display !== 'none');
g.style.display = (q && items.length && !any) ? 'none' : '';
});
}
function highlightActive() {
document.querySelectorAll('.tree-item').forEach((it) => it.classList.toggle('is-active', state.current && it.dataset.path === state.current.path));
}
// ── TinyMCE ──
function ensureEditor() {
if (edReady) return Promise.resolve();
return tinymce.init({
target: $('visual-editor'), base_url: '/vendor/tinymce', license_key: 'gpl',
menubar: false, branding: false, statusbar: false, height: '100%',
plugins: 'link image lists table code autolink searchreplace visualblocks',
toolbar: 'undo redo | blocks fontfamily fontsize | bold italic underline forecolor backcolor | alignleft aligncenter alignright | bullist numlist | link image table | removeformat | code',
toolbar_mode: 'wrap',
valid_elements: '*[*]', extended_valid_elements: '*[*]', valid_children: '+body[style]',
verify_html: false, convert_urls: false,
content_style: 'body{font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#1f2a30;line-height:1.6;padding:10px 12px;} img{max-width:100%;height:auto;} table{border-collapse:collapse;}',
paste_data_images: true, automatic_uploads: false, file_picker_types: 'image',
file_picker_callback: function (cb) {
const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*';
input.onchange = function () { const file = input.files[0]; if (!file) return; const r = new FileReader(); r.onload = function () { cb(r.result, { title: file.name }); }; r.readAsDataURL(file); };
input.click();
},
setup: function (editor) {
ed = editor;
editor.on('init', function () { edReady = true; });
editor.on('input ExecCommand Undo Redo SetContent paste', function () { if (!suppressDirty) markDirty(); });
},
}).then(() => { edReady = true; });
}
// ── Platzhalter (nur sinnvoll in der Signatur-Vorlage _vorlage.html) ──
const VORLAGE_PATH = SIG_HEADERS + '/_vorlage.html';
const PLACEHOLDERS = [
{ t: '{{NAME}}', d: 'Name des Mitarbeiters', s: 'aus dem Feld „Name" in den Plugin-Einstellungen' },
{ t: '{{EMAIL}}', d: 'E-Mail-Adresse', s: 'aus der gewählten Thunderbird-Identität' },
{ t: '{{ABTEILUNG}}', d: 'Abteilung', s: 'automatisch erkannt über die E-Mail-Zuordnung', link: true },
{ t: '{{TELEFON}}', d: '+49 (0) 5191 - 605-0', s: 'fest im Plugin-Code hinterlegt' },
{ t: '{{FAX}}', d: '+49 (0) 5191 - 605-185', s: 'fest im Plugin-Code hinterlegt' },
];
function buildPlaceholderBar() {
const bar = $('ph-bar'), details = $('ph-details');
bar.innerHTML =
'<span class="ph-lead">' + icon('tag', 15) + ' Platzhalter klick zum Einfügen:</span>' +
'<span class="ph-chips">' + PLACEHOLDERS.map((p) => '<button class="ph-chip" data-token="' + esc(p.t) + '" title="wird zu: ' + esc(p.d) + '">' + esc(p.t) + '</button>').join('') + '</span>' +
'<button class="ph-more" id="ph-more">Was ist das?</button>';
details.innerHTML =
'<p>Dies ist die zentrale <strong>Signatur-Vorlage</strong>. Klickt ein Mitarbeiter im Thunderbird-Plugin (Tab „Signaturen") auf <strong>„Vorlage laden"</strong>, ersetzt das Plugin diese Platzhalter <strong>einmalig</strong> durch seine eigenen Daten:</p>' +
'<table class="ph-table"><tbody>' + PLACEHOLDERS.map((p) =>
'<tr><td><code>' + esc(p.t) + '</code></td><td>→ ' + (p.link ? '<a href="#" id="ph-link">' + esc(p.d) + '</a>' : esc(p.d)) + '</td><td class="ph-src">' + esc(p.s) + '</td></tr>').join('') + '</tbody></table>' +
'<p class="ph-foot">Telefon &amp; Fax sind <strong>fest im Plugin-Code</strong> hinterlegt zum Ändern muss das Plugin angepasst werden, nicht diese Oberfläche. In normalen Vorlagen, Fußzeilen und bereits gespeicherten Signaturen werden Platzhalter <strong>nicht</strong> ersetzt.</p>';
bar.querySelectorAll('.ph-chip').forEach((b) => b.addEventListener('click', () => insertPlaceholder(b.dataset.token)));
$('ph-more').addEventListener('click', () => { details.hidden = !details.hidden; $('ph-more').classList.toggle('is-open', !details.hidden); });
const link = $('ph-link');
if (link) link.addEventListener('click', (e) => { e.preventDefault(); setCategory('admin'); setAdminView('mapping'); });
}
// Leiste nur in der Signatur-Vorlage zeigen sonst sind Platzhalter wirkungslos.
function updatePlaceholderBar() {
const show = !!state.current && state.current.path === VORLAGE_PATH;
$('ph-bar').hidden = !show;
if (!show) { $('ph-details').hidden = true; const m = $('ph-more'); if (m) m.classList.remove('is-open'); }
}
function insertPlaceholder(token) {
if (!state.current) return;
if (state.view === 'preview') setView('visual');
if (state.view === 'visual' && edReady && ed) { ed.insertContent(token); markDirty(); }
else if (state.view === 'html') {
const ta = el.htmlEditor, s = ta.selectionStart, e = ta.selectionEnd;
ta.value = ta.value.slice(0, s) + token + ta.value.slice(e);
ta.selectionStart = ta.selectionEnd = s + token.length; ta.focus(); markDirty();
}
}
// ── Datei öffnen ──
async function openFile(path, meta) {
if (!(await guardUnsaved())) 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 };
await showEditor(data.content || ''); setDirty(false); highlightActive();
} catch (e) { toast('Datei nicht ladbar: ' + e.message, 'error'); }
}
async function openNewFile(path, friendly, category) {
if (!(await guardUnsaved())) return;
state.current = { path, friendly, sha: null, exists: false, isNew: true, category };
await showEditor(''); setDirty(true); highlightActive();
toast('Neuer Eintrag „' + friendly + '“ jetzt bearbeiten und speichern.', 'success');
}
async function guardUnsaved() {
if (!state.dirty) return true;
return await confirmModal('Es gibt ungespeicherte Änderungen. Trotzdem fortfahren? Die Änderungen gehen verloren.', { title: 'Ungespeicherte Änderungen', okLabel: 'Verwerfen', danger: true });
}
async function showEditor(html) {
state.html = html; showMain('editor');
el.fileFriendly.textContent = state.current.friendly; el.filePath.textContent = state.current.path;
updatePlaceholderBar();
await ensureEditor(); setView('visual', true);
}
function hideEditor() { state.current = null; showMain('empty'); highlightActive(); }
function syncFromActive() {
if (state.view === 'visual' && edReady && ed) state.html = ed.getContent();
else if (state.view === 'html') state.html = el.htmlEditor.value;
return state.html;
}
function setView(view, skipSync) {
const html = skipSync ? state.html : syncFromActive();
state.html = html; state.view = view;
el.paneVisual.hidden = view !== 'visual'; el.paneHtml.hidden = view !== 'html'; el.panePreview.hidden = view !== 'preview';
el.tabVisual.classList.toggle('is-active', view === 'visual');
el.tabHtml.classList.toggle('is-active', view === 'html');
el.tabPreview.classList.toggle('is-active', view === 'preview');
if (view === 'visual' && edReady && ed) { suppressDirty = true; ed.setContent(html); setTimeout(() => { suppressDirty = false; }, 0); }
else if (view === 'html') el.htmlEditor.value = html;
else if (view === 'preview') renderPreview(html);
}
function renderPreview(html) {
const doc = '<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><style>html,body{margin:0;padding:0;}' +
'body{font-family:Arial,Helvetica,sans-serif;color:#1f2a30;line-height:1.5;background:#e7ebee;padding:22px;}' +
'.email-card{max-width:640px;margin:0 auto;background:#fff;padding:26px 30px;border-radius:10px;box-shadow:0 1px 5px rgba(0,0,0,.12);}' +
'img{max-width:100%;height:auto;}table{border-collapse:collapse;}</style></head><body><div class="email-card">' + html + '</div></body></html>';
el.previewFrame.srcdoc = doc;
}
// ── Dirty ──
function setDirty(d) {
state.dirty = d; el.dirtyBadge.hidden = !d;
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); }
// ── Speichern / Neu laden / Löschen ──
async function saveCurrent() {
if (!state.current) return;
const content = syncFromActive(); const friendly = state.current.friendly;
const wasNew = state.current.isNew;
try {
const res = await api('/api/file', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.current.path, content, message: friendly + ' bearbeitet (Web-Editor)' }) });
state.current.exists = true; state.current.isNew = false; if (res.sha) state.current.sha = res.sha;
setDirty(false);
toast(res.unchanged ? 'Keine Änderungen nichts zu speichern.' : '„' + friendly + '“ gespeichert.', 'success');
// Baum nur neu laden, wenn eine NEUE Datei dazukam (sonst ändert sich die Liste nicht).
if (wasNew) { await loadTree(); highlightActive(); }
} catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); }
}
async function reloadCurrent() {
if (!state.current) return;
if (state.current.isNew) { toast('Dieser Eintrag wurde noch nicht gespeichert.', 'error'); return; }
if (state.dirty && !(await confirmModal('Ungespeicherte Änderungen verwerfen und neu laden?', { title: 'Neu laden', okLabel: 'Verwerfen', danger: true }))) return;
try {
const data = await api('/api/file?path=' + encodeURIComponent(state.current.path));
state.current.sha = data.sha; state.current.exists = data.exists; state.html = data.content || '';
setView('visual', true); setDirty(false); toast('Neu geladen.', 'success');
} catch (e) { toast('Neu laden fehlgeschlagen: ' + e.message, 'error'); }
}
async function deleteCurrent() {
if (!state.current) return; const friendly = state.current.friendly;
if (state.current.isNew) { if (await confirmModal('Diesen neuen, noch nicht gespeicherten Eintrag verwerfen?', { title: 'Verwerfen', okLabel: 'Verwerfen', danger: true })) { setDirty(false); hideEditor(); } return; }
if (!(await confirmModal('„' + friendly + '“ wirklich löschen? Das kann nicht rückgängig gemacht werden.', { title: 'Löschen', okLabel: 'Löschen', danger: true }))) 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 Einträge ──
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));
}
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 }],
(values, inputs, root) => { const slug = slugifyName(values.name || ''); const h = root.querySelector('[data-live="name"]'); if (h) h.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 (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, required: true }]);
if (!res) return; const file = (res.dept === '_default' ? '_default' : res.dept) + '.html'; const path = SIG_FOOTERS + '/' + file;
if (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 h = root.querySelector('[data-live="name"]'); const file = (email && slug) ? (email + '.' + slug + '.html') : ''; if (h) h.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 (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 }) }); toast('Abteilung „' + (r.name || name) + '“ angelegt.', 'success'); await loadTree(); if (state.category === 'admin') setAdminView('departments'); }
catch (e) { toast('Abteilung anlegen fehlgeschlagen: ' + e.message, 'error'); }
}
function listAddAction() { if (state.category === 'templates') newDepartment(); else if (state.category === 'footers') newFooter(); else if (state.category === 'headers') newHeader(); }
// ════════════════════════════════════════════════════════════
// Verwaltung (Admin)
// ════════════════════════════════════════════════════════════
function setAdminView(view) {
state.adminView = view;
document.querySelectorAll('.nav-item').forEach((n) => {});
renderList(); // aktualisiert aktive Markierung in der Nav
showMain('admin');
if (view === 'overview') renderOverview();
else if (view === 'departments') renderDepartments();
else if (view === 'mapping') renderMapping();
else if (view === 'tags') renderTags();
}
function adminHeader(title, subtitle) {
return '<div class="admin-head"><h2>' + esc(title) + '</h2>' + (subtitle ? '<p>' + esc(subtitle) + '</p>' : '') + '</div>';
}
function renderOverview() {
const t = state.tree || {}; const c = state.config || {};
const tmpl = Object.values(t.templates || {}).reduce((n, a) => n + a.length, 0) + Object.values(t.users || {}).reduce((n, a) => n + a.length, 0);
const cards = [
{ icon: 'file-text', n: tmpl, label: 'Vorlagen' },
{ icon: 'building', n: (t.departments || []).length, label: 'Abteilungen' },
{ icon: 'panel-bottom', n: (t.footers || []).length, label: 'Fußzeilen' },
{ icon: 'pen-line', n: (t.headers || []).length, label: 'Signaturen' },
];
const mode = c.demo ? 'Demo-Modus' : c.local ? 'Lokaler Ordner' : 'Gitea/Forgejo';
el.adminPanel.innerHTML =
adminHeader('Übersicht', 'Auf einen Blick: was im Repository liegt und wie der Editor verbunden ist.') +
'<div class="stat-grid">' + cards.map((k) =>
'<div class="stat-card"><span class="stat-ic">' + icon(k.icon, 22) + '</span><div class="stat-n">' + k.n + '</div><div class="stat-l">' + k.label + '</div></div>').join('') +
'</div>' +
'<div class="info-card"><div class="info-row"><span class="info-ic">' + icon('plug', 18) + '</span><div><div class="info-k">Verbindung</div>' +
'<div class="info-v">' + esc(mode) + ' · ' + esc((c.owner || '?') + '/' + (c.repo || '?') + '@' + (c.branch || 'main')) + '</div></div></div></div>';
}
function renderDepartments() {
const t = state.tree || {}; const depts = t.departments || [];
let html = adminHeader('Abteilungen', 'Ordner im Repository. Jede Abteilung kann eigene Vorlagen und eine Fußzeile haben.');
html += '<div class="adm-add"><input id="adm-dept-name" class="inp" placeholder="Neue Abteilung z. B. Spa" /><button id="adm-dept-add" class="btn btn-primary"><span class="ic">' + icon('plus', 16) + '</span><span>Anlegen</span></button></div>';
html += '<div class="adm-list">';
if (!depts.length) html += '<div class="tree-empty">Noch keine Abteilungen.</div>';
depts.forEach((d) => {
const count = (t.templates[d] || []).length;
html += '<div class="adm-row"><span class="adm-ic">' + icon('building', 18) + '</span><span class="adm-name">' + esc(d) + '</span>' +
'<span class="adm-meta">' + count + ' Vorlage' + (count === 1 ? '' : 'n') + '</span>' +
'<button class="icon-btn danger" data-del="' + esc(d) + '" title="Abteilung löschen">' + icon('trash', 16) + '</button></div>';
});
html += '</div>';
el.adminPanel.innerHTML = html;
$('adm-dept-add').addEventListener('click', newDepartment);
$('adm-dept-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addDeptInline(); } });
el.adminPanel.querySelectorAll('[data-del]').forEach((b) => b.addEventListener('click', () => deleteDepartment(b.dataset.del)));
}
async function addDeptInline() {
const name = ($('adm-dept-name').value || '').trim(); if (!name) return;
if (/[\/\\:*?"<>|]/.test(name)) { toast('Ungültiger Abteilungsname.', 'error'); return; }
try { await api('/api/departments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + name + '“ angelegt.', 'success'); await loadTree(); renderDepartments(); }
catch (e) { toast('Anlegen fehlgeschlagen: ' + e.message, 'error'); }
}
async function deleteDepartment(name) {
const count = ((state.tree && state.tree.templates[name]) || []).length;
const msg = count ? 'Abteilung „' + name + '“ und alle ' + count + ' enthaltenen Vorlagen löschen?' : 'Leere Abteilung „' + name + '“ löschen?';
if (!(await confirmModal(msg, { title: 'Abteilung löschen', okLabel: 'Löschen', danger: true }))) return;
try { const r = await api('/api/departments', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + name + '“ gelöscht (' + (r.deleted || 0) + ' Dateien).', 'success'); await loadTree(); renderDepartments(); }
catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
}
async function renderMapping() {
el.adminPanel.innerHTML = adminHeader('E-Mail-Zuordnung', 'Welche Absender-Adresse gehört zu welcher Abteilung? (Datei _config/abteilungen.json)') + '<div class="tree-empty">Lädt…</div>';
let mapping = {};
try { const r = await api('/api/abteilungen'); mapping = r.mapping || {}; } catch (e) { toast('Zuordnung nicht ladbar: ' + e.message, 'error'); }
const rows = Object.keys(mapping).map((email) => ({ email, dept: mapping[email] }));
const depts = (state.tree && state.tree.departments) || [];
function deptSelect(val) {
return '<select class="inp dept-sel">' + ['<option value="">— wählen —</option>'].concat(depts.map((d) => '<option' + (d === val ? ' selected' : '') + '>' + esc(d) + '</option>')).join('') +
(val && !depts.includes(val) ? '<option selected>' + esc(val) + '</option>' : '') + '</select>';
}
function rowHtml(r) {
return '<div class="map-row"><input class="inp map-email" value="' + esc(r.email) + '" placeholder="name@hotel-park-soltau.de" />' +
deptSelect(r.dept) + '<button class="icon-btn danger map-del" title="Zeile entfernen">' + icon('trash', 16) + '</button></div>';
}
el.adminPanel.innerHTML = adminHeader('E-Mail-Zuordnung', 'Welche Absender-Adresse gehört zu welcher Abteilung? (Datei _config/abteilungen.json)') +
'<div class="map-head"><span>E-Mail-Adresse</span><span>Abteilung</span><span></span></div>' +
'<div id="map-rows" class="map-rows">' + (rows.length ? rows.map(rowHtml).join('') : '') + '</div>' +
'<div class="adm-actions"><button id="map-addrow" class="btn btn-ghost"><span class="ic">' + icon('plus', 16) + '</span><span>Zeile hinzufügen</span></button>' +
'<button id="map-save" class="btn btn-primary"><span class="ic">' + icon('save', 16) + '</span><span>Speichern</span></button></div>';
const rowsEl = $('map-rows');
function bindRow(row) { row.querySelector('.map-del').addEventListener('click', () => row.remove()); }
rowsEl.querySelectorAll('.map-row').forEach(bindRow);
$('map-addrow').addEventListener('click', () => { const div = document.createElement('div'); div.innerHTML = rowHtml({ email: '', dept: '' }); const row = div.firstChild; rowsEl.appendChild(row); bindRow(row); row.querySelector('.map-email').focus(); });
$('map-save').addEventListener('click', async () => {
const out = {};
let bad = false;
rowsEl.querySelectorAll('.map-row').forEach((row) => {
const email = row.querySelector('.map-email').value.trim(); const dept = row.querySelector('.dept-sel').value.trim();
if (!email && !dept) return; if (!email || !dept) { bad = true; return; }
out[email] = dept;
});
if (bad) { toast('Bitte jede Zeile vollständig ausfüllen (E-Mail + Abteilung) oder leer lassen.', 'error'); return; }
try { await api('/api/abteilungen', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mapping: out }) }); toast('Zuordnung gespeichert.', 'success'); }
catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); }
});
}
async function renderTags() {
el.adminPanel.innerHTML = adminHeader('Schlagwörter', 'Farbige Tags für Thunderbird (Datei _config/schlagwoerter.json).') + '<div class="tree-empty">Lädt…</div>';
let tags = [];
try { const r = await api('/api/schlagwoerter'); tags = r.tags || []; } catch (e) { toast('Schlagwörter nicht ladbar: ' + e.message, 'error'); }
function rowHtml(t) {
const color = (t && t.color) || '#95a322'; const name = (t && t.name) || '';
return '<div class="tag-row"><span class="tag-swatch" style="background:' + esc(color) + '"></span>' +
'<input class="inp tag-name" value="' + esc(name) + '" placeholder="Name / Schlagwort" />' +
'<input type="color" class="tag-color" value="' + esc(color) + '" />' +
'<button class="icon-btn danger tag-del" title="Entfernen">' + icon('trash', 16) + '</button></div>';
}
el.adminPanel.innerHTML = adminHeader('Schlagwörter', 'Farbige Tags für Thunderbird (Datei _config/schlagwoerter.json).') +
'<div id="tag-rows" class="tag-rows">' + (tags.length ? tags.map(rowHtml).join('') : '') + '</div>' +
'<div class="adm-actions"><button id="tag-addrow" class="btn btn-ghost"><span class="ic">' + icon('plus', 16) + '</span><span>Schlagwort hinzufügen</span></button>' +
'<button id="tag-save" class="btn btn-primary"><span class="ic">' + icon('save', 16) + '</span><span>Speichern</span></button></div>';
const rowsEl = $('tag-rows');
function bindRow(row) {
row.querySelector('.tag-del').addEventListener('click', () => row.remove());
const color = row.querySelector('.tag-color'), sw = row.querySelector('.tag-swatch');
color.addEventListener('input', () => { sw.style.background = color.value; });
}
rowsEl.querySelectorAll('.tag-row').forEach(bindRow);
$('tag-addrow').addEventListener('click', () => { const div = document.createElement('div'); div.innerHTML = rowHtml({}); const row = div.firstChild; rowsEl.appendChild(row); bindRow(row); row.querySelector('.tag-name').focus(); });
$('tag-save').addEventListener('click', async () => {
const out = []; let bad = false;
rowsEl.querySelectorAll('.tag-row').forEach((row) => { const name = row.querySelector('.tag-name').value.trim(); const color = row.querySelector('.tag-color').value; if (!name) { if (row.querySelector('.tag-name').value !== '') bad = true; return; } out.push({ name, color }); });
try { await api('/api/schlagwoerter', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags: out }) }); toast('Schlagwörter gespeichert.', 'success'); }
catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); }
});
}
// ── Events ──
function bindEvents() {
el.catTabs.forEach((t) => t.addEventListener('click', () => setCategory(t.dataset.cat)));
el.btnRefresh.addEventListener('click', () => { loadTree(); if (state.category === 'admin') setAdminView(state.adminView); });
el.btnListAdd.addEventListener('click', listAddAction);
el.treeSearch.addEventListener('input', applyFilter);
el.btnSave.addEventListener('click', saveCurrent);
el.btnReload.addEventListener('click', reloadCurrent);
el.btnDelete.addEventListener('click', deleteCurrent);
el.tabVisual.addEventListener('click', () => setView('visual'));
el.tabHtml.addEventListener('click', () => setView('html'));
el.tabPreview.addEventListener('click', () => setView('preview'));
el.htmlEditor.addEventListener('input', () => { markDirty(); });
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); if (state.current && !el.btnSave.disabled && !el.editorPanel.hidden) saveCurrent(); }
if (e.key === 'Escape') { if (!el.promptBackdrop.hidden) el.promptCancel.click(); else if (!el.confirmBackdrop.hidden) el.confirmCancel.click(); }
});
window.addEventListener('beforeunload', (e) => { if (state.dirty) { e.preventDefault(); e.returnValue = ''; return ''; } });
}
// ── Start ──
async function init() {
hydrateIcons(document);
buildPlaceholderBar();
bindEvents(); setDirty(false); setCategory('templates');
const ok = await loadConfigAndHealth();
if (ok) await loadTree();
}
document.addEventListener('DOMContentLoaded', init);
})();

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,146 @@
<!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-Navigation -->
<nav class="cat-tabs" role="tablist">
<button class="cat-tab is-active" data-cat="templates" role="tab"><span class="ic" data-icon="file-text"></span><span>Vorlagen</span></button>
<button class="cat-tab" data-cat="footers" role="tab"><span class="ic" data-icon="panel-bottom"></span><span>Fußzeilen</span></button>
<button class="cat-tab" data-cat="headers" role="tab"><span class="ic" data-icon="pen-line"></span><span>Signaturen</span></button>
<button class="cat-tab" data-cat="admin" role="tab"><span class="ic" data-icon="settings"></span><span>Verwaltung</span></button>
<span class="cat-spacer"></span>
<button id="btn-refresh" class="icon-btn" title="Liste neu laden"><span class="ic" data-icon="refresh"></span></button>
</nav>
<div class="workspace">
<!-- Listen-Spalte -->
<aside class="listpane">
<div class="listpane-head">
<div class="search-wrap">
<span class="ic search-ic" data-icon="search"></span>
<input type="search" id="tree-search" class="tree-search" placeholder="Suchen…" autocomplete="off" />
</div>
<button id="btn-list-add" class="btn btn-primary btn-sm"><span class="ic" data-icon="plus"></span><span id="btn-list-add-label">Neu</span></button>
</div>
<div id="list-body" class="list-body"></div>
</aside>
<!-- Inhalts-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="60" height="60" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 13h6"/><path d="M9 17h4"/>
</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>
<!-- Datei-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"><span class="ic" data-icon="reload"></span><span>Neu laden</span></button>
<button class="btn btn-danger-ghost" id="btn-delete"><span class="ic" data-icon="trash"></span><span>Löschen</span></button>
<button class="btn btn-primary" id="btn-save"><span class="ic" data-icon="save"></span><span>Speichern</span></button>
</div>
</div>
<div class="ph-bar" id="ph-bar" hidden></div>
<div class="ph-details" id="ph-details" hidden></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>
<!-- Verwaltung -->
<div class="admin-panel" id="admin-panel" hidden></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

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

@@ -0,0 +1,292 @@
/* HPS Vorlagen & Signaturen — Web-Editor
* Hotel-Park-Soltau-CI: Olivgrün (#95a322) + Anthrazit (#3c3c3b).
*/
/* ── Schrift ── */
@font-face { font-family: 'Jakarta'; src: url('fonts/pjs-400.ttf') format('truetype'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Jakarta'; src: url('fonts/pjs-500.ttf') format('truetype'); font-weight: 500; font-display: swap; }
@font-face { font-family: 'Jakarta'; src: url('fonts/pjs-600.ttf') format('truetype'); font-weight: 600; font-display: swap; }
@font-face { font-family: 'Jakarta'; src: url('fonts/pjs-700.ttf') format('truetype'); font-weight: 700; font-display: swap; }
@font-face { font-family: 'Jakarta'; src: url('fonts/pjs-800.ttf') format('truetype'); font-weight: 800; font-display: swap; }
/* ── Tokens ── */
:root {
--brand: #647219;
--brand-600: #556114;
--brand-700: #45500f;
--brand-50: #f3f6e6;
--brand-100: #e2e9c5;
--accent: #95a322;
--charcoal: #3c3c3b;
--charcoal-2: #2c2c2b;
--bg: #eef1ee;
--panel: #ffffff;
--text: #222a26;
--muted: #69736d;
--muted-2: #9aa39c;
--border: #e6eae6;
--border-strong:#d6dcd6;
--danger: #d6453f;
--danger-50: #fdecea;
--success: #2f9e6b;
--info: #2b6c8f;
--radius: 16px;
--radius-md: 11px;
--radius-sm: 9px;
--shadow-sm: 0 1px 2px rgba(30,40,30,.05), 0 1px 3px rgba(30,40,30,.07);
--shadow-md: 0 8px 22px rgba(30,45,30,.09), 0 2px 6px rgba(30,45,30,.06);
--shadow-lg: 0 24px 60px rgba(25,40,25,.24);
--font: 'Jakarta', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
--topbar-h: 60px;
}
* { 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: 14.5px; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
button { font-family: inherit; }
/* Icons */
.ic { display: inline-flex; align-items: center; justify-content: center; }
.ic svg { display: block; }
/* ── 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) 60%, #494941); 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,.2); }
.brand-title { font-size: 15px; font-weight: 600; letter-spacing: .2px; color: rgba(255,255,255,.9); }
.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,.1); color: #fff; border: 1px solid rgba(255,255,255,.16); 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: #b7d34a; box-shadow: 0 0 0 3px rgba(149,163,34,.3); }
.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 / Kategorie-Tabs ── */
.app { height: calc(100vh - var(--topbar-h)); display: flex; flex-direction: column; }
.cat-tabs { display: flex; align-items: center; gap: 2px; padding: 0 16px; 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: 16px 16px 14px; font-size: 14px; font-weight: 600; color: var(--muted); border-bottom: 2.5px solid transparent; margin-bottom: -1px; transition: color .15s, border-color .15s; }
.cat-tab .ic { color: var(--muted-2); transition: color .15s; }
.cat-tab:hover { color: var(--text); }
.cat-tab:hover .ic { color: var(--muted); }
.cat-tab.is-active { color: var(--brand-600); border-bottom-color: var(--brand); }
.cat-tab.is-active .ic { color: var(--brand); }
.cat-spacer { flex: 1; }
.icon-btn { width: 34px; height: 34px; border-radius: 9px; border: 1px solid var(--border-strong); background: #fff; color: var(--muted); cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; justify-content: center; }
.icon-btn:hover { color: var(--brand); border-color: var(--brand-100); background: var(--brand-50); }
.icon-btn.danger:hover { color: var(--danger); border-color: #ecc4c2; background: var(--danger-50); }
/* ── Workspace ── */
.workspace { flex: 1; display: grid; grid-template-columns: 300px 1fr; min-height: 0; }
.listpane { background: var(--panel); 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 12px; border-bottom: 1px solid var(--border); }
.search-wrap { flex: 1; position: relative; min-width: 0; }
.search-ic { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--muted-2); pointer-events: none; }
.search-ic svg { width: 16px; height: 16px; }
.tree-search { width: 100%; border: 1px solid var(--border-strong); background: #fbfcfb; border-radius: var(--radius-sm); padding: 9px 12px 9px 34px; 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); background: #fff; box-shadow: 0 0 0 3px var(--brand-50); }
.list-body { flex: 1; overflow-y: auto; padding: 8px 10px 30px; }
/* Gruppen */
.tree-group { margin-bottom: 7px; }
.group-head { display: flex; align-items: center; gap: 9px; padding: 6px 8px 6px 6px; border-radius: var(--radius-sm); cursor: pointer; color: var(--text); transition: background .12s; }
.group-head:hover { background: #f1f3ef; }
.group-head .g-caret { color: var(--muted-2); display: inline-flex; transition: transform .18s; flex: none; }
.group-head.collapsed .g-caret { transform: rotate(-90deg); }
.group-head .g-badge { width: 27px; height: 27px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; flex: none; letter-spacing: 0; }
.group-head .g-badge svg { width: 15px; height: 15px; }
.group-head .g-label { flex: 1; font-size: 13.5px; font-weight: 700; letter-spacing: .1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.group-head .g-count { font-size: 11px; font-weight: 700; color: var(--muted); background: var(--bg); border-radius: 999px; padding: 2px 8px; min-width: 22px; text-align: center; }
.group-head .g-add { border: none; background: none; color: var(--muted-2); cursor: pointer; padding: 3px; border-radius: 6px; display: inline-flex; opacity: 0; transition: opacity .12s, color .12s, background .12s; flex: none; }
.group-head:hover .g-add { opacity: 1; }
.group-head .g-add:hover { color: var(--brand); background: var(--brand-100); }
/* Dateien hängen mit einer dezenten Führungslinie an ihrer Abteilung */
.group-files { display: flex; flex-direction: column; gap: 1px; margin: 3px 0 0 19px; padding: 1px 0 2px 12px; border-left: 1.5px solid var(--border); }
.group-files.collapsed { display: none; }
/* Datei-Items */
.tree-item { display: flex; align-items: center; gap: 9px; padding: 8px 11px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13.5px; color: #46504a; border-left: 3px solid transparent; transition: background .12s, color .12s; }
.tree-item:hover { background: #f1f3ef; color: var(--text); }
.tree-item .ti-icon { color: var(--muted-2); display: inline-flex; flex: none; }
.tree-item .ti-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-item.is-active { background: var(--brand-50); color: var(--brand-700); font-weight: 600; border-left-color: var(--brand); }
.tree-item.is-active .ti-icon { color: var(--brand); }
.tree-empty { padding: 9px 12px; font-size: 12.5px; color: var(--muted-2); font-style: italic; }
/* Admin-Nav (in der Listen-Spalte) */
.nav-list { display: flex; flex-direction: column; gap: 3px; padding-top: 4px; }
.nav-item { display: flex; align-items: center; gap: 11px; padding: 11px 12px; border-radius: var(--radius-sm); border: none; background: none; cursor: pointer; font-size: 14px; font-weight: 600; color: #46504a; border-left: 3px solid transparent; text-align: left; transition: all .12s; }
.nav-item .ni-icon { color: var(--muted-2); display: inline-flex; }
.nav-item:hover { background: #f1f3ef; color: var(--text); }
.nav-item.is-active { background: var(--brand-50); color: var(--brand-700); border-left-color: var(--brand); }
.nav-item.is-active .ni-icon { color: var(--brand); }
/* ── Inhalts-Spalte ── */
.editorpane { min-height: 0; display: flex; flex-direction: column; padding: 20px; overflow-y: auto; }
.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: 700; }
.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: 700; 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 { display: inline-flex; align-items: center; gap: 7px; border: 1px solid transparent; border-radius: var(--radius-sm); padding: 9px 15px; font-size: 13.5px; font-weight: 600; cursor: pointer; transition: all .15s ease; white-space: nowrap; line-height: 1.1; }
.btn .ic svg { width: 16px; height: 16px; }
.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 12px; font-size: 12.5px; }
/* Platzhalter-Leiste */
.ph-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 10px 22px; background: #fbfcf7; border-bottom: 1px solid var(--border); }
.ph-lead { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--muted); }
.ph-lead .ic { color: var(--accent); }
.ph-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.ph-chip { font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--brand-700); background: var(--brand-50); border: 1px solid var(--brand-100); border-radius: 999px; padding: 4px 11px; cursor: pointer; transition: all .12s; }
.ph-chip:hover { background: var(--brand); color: #fff; border-color: var(--brand); transform: translateY(-1px); }
.ph-more { margin-left: auto; border: none; background: none; color: var(--brand-600); font-size: 12.5px; font-weight: 600; cursor: pointer; padding: 4px 6px; border-radius: 6px; }
.ph-more:hover { background: var(--brand-50); }
.ph-more::after { content: " ▾"; font-size: 9px; }
.ph-more.is-open::after { content: " ▴"; }
.ph-details { padding: 14px 22px 16px; background: #fbfcf7; border-bottom: 1px solid var(--border); font-size: 13px; color: #44524a; }
.ph-details p { margin: 0 0 10px; line-height: 1.55; }
.ph-details .ph-foot { margin: 12px 0 0; }
.ph-table { width: 100%; border-collapse: collapse; }
.ph-table td { padding: 5px 10px 5px 0; vertical-align: top; border-bottom: 1px solid var(--border); }
.ph-table tr:last-child td { border-bottom: none; }
.ph-table code { font-family: var(--mono); font-size: 12px; background: var(--brand-50); color: var(--brand-700); padding: 2px 7px; border-radius: 5px; white-space: nowrap; }
.ph-table .ph-src { color: var(--muted); font-size: 12px; }
.ph-details a { color: var(--brand-600); font-weight: 600; text-decoration: none; border-bottom: 1px solid var(--brand-100); }
.ph-details a:hover { border-bottom-color: var(--brand); }
/* Editor-Tabs */
.editor-tabs { display: flex; gap: 2px; padding: 10px 22px 0; border-bottom: 1px solid var(--border); }
.editor-tab { border: none; background: none; cursor: pointer; padding: 9px 16px; font-size: 13.5px; font-weight: 600; color: var(--muted); border-bottom: 2.5px solid transparent; margin-bottom: -1px; transition: all .15s; }
.editor-tab:hover { color: var(--text); }
.editor-tab.is-active { color: var(--brand-600); border-bottom-color: var(--brand); }
.editor-body { flex: 1; min-height: 0; display: flex; padding: 16px 22px 22px; }
.epane { flex: 1; min-height: 0; display: flex; }
#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: #fbfcfb; padding: 16px 18px; font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: #2a3a30; 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); }
.preview-frame-wrap { flex: 1; border: 1px solid var(--border); border-radius: var(--radius-md); background: #e7ebe7; 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; }
/* ── Verwaltung ── */
.admin-panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); padding: 26px 28px; max-width: 860px; width: 100%; margin: 0 auto; }
.admin-head { margin-bottom: 22px; }
.admin-head h2 { margin: 0 0 6px; font-size: 22px; font-weight: 800; letter-spacing: -.2px; color: var(--text); }
.admin-head p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
.stat-card { background: linear-gradient(180deg, #fbfcf8, #fff); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 18px; box-shadow: var(--shadow-sm); }
.stat-ic { display: inline-flex; width: 40px; height: 40px; border-radius: 11px; background: var(--brand-50); color: var(--brand); align-items: center; justify-content: center; margin-bottom: 12px; }
.stat-n { font-size: 30px; font-weight: 800; color: var(--text); line-height: 1; letter-spacing: -.5px; }
.stat-l { font-size: 12.5px; font-weight: 600; color: var(--muted); margin-top: 5px; }
.info-card { border: 1px solid var(--border); border-radius: var(--radius-md); padding: 16px 18px; background: #fbfcf8; }
.info-row { display: flex; align-items: center; gap: 13px; }
.info-ic { width: 38px; height: 38px; border-radius: 10px; background: var(--brand-50); color: var(--brand); display: inline-flex; align-items: center; justify-content: center; flex: none; }
.info-k { font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .6px; color: var(--muted-2); }
.info-v { font-size: 14px; color: var(--text); font-weight: 600; margin-top: 2px; }
.inp { border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 10px 12px; font-size: 14px; font-family: inherit; color: var(--text); outline: none; background: #fff; transition: border-color .15s, box-shadow .15s; }
.inp:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
.adm-add { display: flex; gap: 10px; margin-bottom: 18px; }
.adm-add .inp { flex: 1; }
.adm-list { display: flex; flex-direction: column; gap: 8px; }
.adm-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-md); background: #fff; transition: border-color .12s, box-shadow .12s; }
.adm-row:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); }
.adm-ic { color: var(--brand); display: inline-flex; }
.adm-name { font-weight: 700; flex: 1; }
.adm-meta { font-size: 12.5px; color: var(--muted); }
.map-head { display: grid; grid-template-columns: 1fr 220px 40px; gap: 10px; padding: 0 4px 8px; font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: var(--muted-2); }
.map-rows, .tag-rows { display: flex; flex-direction: column; gap: 8px; }
.map-row { display: grid; grid-template-columns: 1fr 220px 40px; gap: 10px; align-items: center; }
.tag-row { display: grid; grid-template-columns: 28px 1fr 56px 40px; gap: 10px; align-items: center; }
.tag-swatch { width: 22px; height: 22px; border-radius: 7px; border: 1px solid rgba(0,0,0,.12); }
.tag-color { width: 100%; height: 38px; padding: 2px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: #fff; cursor: pointer; }
.adm-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
/* ── 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(25,40,25,.26); 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(20,30,20,.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: 700; color: var(--text); }
.modal p { margin: 0 0 4px; color: #44524a; 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; font-family: inherit; 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, .editorpane::-webkit-scrollbar { width: 10px; }
.list-body::-webkit-scrollbar-thumb, .html-editor::-webkit-scrollbar-thumb, .editorpane::-webkit-scrollbar-thumb { background: #cdd6c9; border-radius: 10px; border: 2px solid transparent; background-clip: content-box; }
/* ── Responsive ── */
@media (max-width: 980px) { .workspace { grid-template-columns: 250px 1fr; } .stat-grid { grid-template-columns: repeat(2, 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; }
.map-head, .map-row { grid-template-columns: 1fr 130px 36px; }
.brand-title { display: none; }
}

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

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