13 Commits

Author SHA1 Message Date
Kendrick Bollens
169fc68f18 Release v2.5.0 — updates.json (Auto-Update-Manifest)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PZHd9vRsBiH8cKYdQo1Yv
2026-07-22 15:23:04 +02:00
Kendrick Bollens
adddef75ac README: auf aktuellen Stand gebracht
Überarbeitete README (QuickMove, Schlagwörter, Web-Editor, Auto-Update,
7z-Build-Befehl) plus die neuen Features 2.4.0/2.5.0: erzwungene
about:config-Einstellungen (mit Opt-out im Sync-Tab), Client-Versions-
Übersicht im Web-Editor, _clients/-Ordner und experiments/prefs/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PZHd9vRsBiH8cKYdQo1Yv
2026-07-22 14:49:06 +02:00
Kendrick Bollens
02776311c3 Release v2.5.0 — Client-Versions-Übersicht im Web-Editor
Jeder Client meldet beim Start seinen Status (Name, Abteilung, Plugin- und
Thunderbird-Version) als _clients/<email>.json ins Gitea-Repo — aber nur, wenn
sich tatsächlich etwas geändert hat (kein Commit-Spam pro Start).

Web-Editor: neuer Admin-Unterpunkt "User & Versionen" (Route /api/clients)
mit Tabelle je User und "veraltet"-Markierung für Clients, die nicht auf der
neuesten gemeldeten Version laufen. _clients/ ist in den Ordner-Filtern
ausgenommen, damit es nicht als Abteilung erscheint.

Nebenbei: Web-Editor-Button-Label "Abteilung" -> "Vorlage" (Templates-Tab).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PZHd9vRsBiH8cKYdQo1Yv
2026-07-22 14:42:23 +02:00
Kendrick Bollens
d244273e44 Release v2.4.0 — Erzwungene Einstellungen: about:config-Prefs bei jedem Start setzen
Neuer forcedPrefs-WebExtension-Experiment (Services.prefs) setzt beim Start
globale Prefs (Startseite aus, Kartenansicht, nicht gruppiert, absteigend,
3-Spalten-Layout, nicht auto-gelesen) sowie pro Identität die Signatur-/
Antwort-Einstellungen. Pro Setting im Options-Dialog (⚙-Tab) abschaltbar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PZHd9vRsBiH8cKYdQo1Yv
2026-07-22 14:24:43 +02:00
Kendrick Bollens
83d0d52a05 Release v2.3.6 — updates.json (Auto-Update-Manifest)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNGrD9ewk1gkStw9znPqQ1
2026-07-01 12:36:04 +02:00
Kendrick Bollens
c5eebd1b06 Release v2.3.6 — Signatur-Footer: neue Compose-Fenster ziehen gesyncten Footer automatisch
Thunderbird cacht die Identitäts-Signatur pro Sitzung, daher zeigte ein nach
dem Hintergrund-Sync geöffnetes Verfassen-Fenster noch den alten Footer.
background.js hängt sich jetzt an tabs.onCreated und tauscht im Signaturblock
automatisch den Footer-Teil (ab SIG_FOOTER_START) gegen den frischen aus —
ohne Benutzeraktion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNGrD9ewk1gkStw9znPqQ1
2026-07-01 12:35:08 +02:00
Kendrick Bollens
c7383f3a77 Release v2.3.5 — Signatur-Sync: Web-Editor-Edits werden korrekt übernommen
Pull-Lookup für Signatur-Header war case-sensitiv gegen eine lowercase
headerMap → Groß-/Leerzeichen-Namen wurden nie gefunden, Plugin fiel auf
die lokale TB-Signatur zurück und revertierte beim Push die Web-Editor-
Änderung. Jetzt: case-insensitive Lookup, kanonischer sigSlug (= Web-Editor-
Format, lowercase-bindestrich) zum Lesen+Schreiben, Legacy-Format nur noch
lesend, bei mehreren Treffern gewinnt der neueste Commit. Keine git-seitigen
Breaking Changes — alte Plugin-Versionen (Groß-Dateien) bleiben kompatibel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:29:56 +02:00
Kendrick Bollens
fa817050d8 Release v2.3.4 — QuickMove: "als gelesen" pro Aktion + dynamischer Button-Name
- QuickMove markiert Nachrichten als gelesen, pro Aktion ein-/ausschaltbar
  (Checkbox in den QuickMove-Einstellungen, Default an)
- Button-Label heißt wie die Aktion, wenn nur eine konfiguriert ist (setLabel)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:08:43 +02:00
Kendrick Bollens
f223f55933 Release v2.3.3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:37:24 +02:00
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
23 changed files with 1677 additions and 1201 deletions

View File

@@ -4,10 +4,15 @@ Thunderbird-MailExtension „HPS Vorlagen & Signaturen" mit Gitea-Sync.
## Workflow (WICHTIG)
1. **Nach jeder Code-Änderung am Plugin immer die `.xpi` neu bauen** (siehe Build unten),
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.
2. **Sobald der User zufrieden ist ("happy"), committen** — Code-Änderung + neu gebaute
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
@@ -37,6 +42,6 @@ brauchen sie nicht — bestehende Installs behalten ihre Config in `storage.loca
## Repo
- Sync-Daten-Repo (Templates/Signaturen): `hps/email-vorlagen` auf `git.hotel-park-soltau.de`.
- Plugin-Source + Release-Host: `kendrick.bollens/hps-thunderbird-templates`.
- 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.

108
README.md
View File

@@ -1,19 +1,46 @@
# HPS Vorlagen & Signaturen
Thunderbird-Plugin (v2.2.0) zur zentralen Verwaltung von E-Mail-Vorlagen und Signaturen für Hotel Park Soltau. Vorlagen und Signaturen werden über ein Gitea/Forgejo-Repository synchronisiert und stehen so allen Mitarbeitern zur Verfügung.
Thunderbird-MailExtension zur zentralen Verwaltung von E-Mail-Vorlagen und Signaturen für
Hotel Park Soltau. Vorlagen, Signaturen und Schlagwörter werden über ein Gitea/Forgejo-Repository
synchronisiert und stehen so allen Mitarbeitern zur Verfügung. (Aktuelle Version: siehe
`manifest.json`.)
## Features
- **E-Mail-Vorlagen** erstellen, bearbeiten und per Klick in Compose-Fenster einfügen
- **E-Mail-Vorlagen** erstellen, bearbeiten und per Klick ins 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
- **QuickMove** — Button in der Nachrichtenansicht: markiert die E-Mail mit deinem Schlagwort
und verschiebt sie in einen festgelegten Ordner. Mehrere Aktionen → Auswahlmenü.
- **Schlagwörter-Sync** — Benutzer-Tags werden zentral über `_config/schlagwoerter.json`
verwaltet, in Thunderbird angelegt und nie gelöscht (dauerhaft nachvollziehbar)
- **Git-Sync** über Gitea/Forgejo API (Pull + Push, automatisch alle 15 Min.)
- **Auto-Erkennung** von Abteilung und Benutzer via `_config/abteilungen.json`
- **Abteilungsverwaltung** — neue Abteilungen direkt aus den Einstellungen anlegen
- **WYSIWYG-Editor** mit Schriftart, Farben, Listen, Bildern, Links
- **Sichtbarkeit direkt änderbar** per klickbarem Badge in der Vorlagenliste
- **Auto-Update** über Gitea (siehe unten) — installierte Clients aktualisieren sich selbst
- **Toolbar-Button** öffnet direkt die Einstellungen
- **Erzwungene Thunderbird-Einstellungen** — setzt bei jedem Start bestimmte `about:config`-Prefs
(z.B. Startseite aus, Kartenansicht, „nicht automatisch als gelesen", Signatur-/Antwort-Verhalten
pro Konto); pro Einstellung im Sync-Tab abschaltbar
- **Client-Versions-Übersicht** — jeder Client meldet Plugin- und Thunderbird-Version; im
Web-Editor unter *Verwaltung → User & Versionen* einsehbar (veraltete Clients markiert)
- **[Web-Editor](web-editor/)** als optionales Web-Gegenstück (gleiche Gitea-Quelle)
## Bedienoberfläche (Einstellungsseite)
Vier Tabs:
| Tab | Funktion |
|---|---|
| **Vorlagen** | Vorlagen anlegen/bearbeiten, Sichtbarkeit, manuelles Push/Pull |
| **Signaturen** | Persönlicher Kopfbereich + Fußzeile (gemeinsam / Abteilung) |
| **QuickMove** | Aktionen konfigurieren (Schlagwort + Zielordner) |
| **Sync** | Server-Verbindung, Benutzer & Abteilung, Sync auslösen, erzwungene Einstellungen (Opt-out) |
## Repository-Struktur (Gitea)
@@ -25,14 +52,18 @@ repo/
│ ├── max@hotel-park-soltau.de/
│ └── anna@hotel-park-soltau.de/
├── _config/
── abteilungen.json # E-Mail → Abteilung Mapping
── abteilungen.json # E-Mail → Abteilung Mapping
│ └── schlagwoerter.json # Benutzer-Schlagwörter (Name + Farbe)
├── _clients/ # Status-Meldungen: Plugin-/TB-Version pro User
│ └── max@hotel-park-soltau.de.json
├── Rezeption/ # Abteilungsvorlagen
├── IT/
├── signatures/
│ ├── headers/ # Persönliche Signatur-Köpfe
│ │ └── max@hotel.de.max-mustermann.html
│ └── footers/ # Gemeinsame Fußbereiche
── Rezeption.html
│ └── footers/ # Fußbereiche
── _default.html # gemeinsame Standard-Fußzeile
│ └── Rezeption.html # Abteilungs-Fußzeile
```
### `_config/abteilungen.json`
@@ -48,17 +79,36 @@ Mapping von Abteilungs-E-Mail-Adressen zu Ordnernamen. Wird vom Plugin gelesen,
}
```
### `_config/schlagwoerter.json`
Liste der Benutzer-Schlagwörter (für QuickMove/Markierung). Wird beim Sync automatisch um neue
Benutzer ergänzt und nie geleert, damit Markierungen dauerhaft nachvollziehbar bleiben:
```json
[
{ "name": "Max Mustermann", "color": "#4a7c59" },
{ "name": "Anna Beispiel", "color": "#2874a6" }
]
```
## 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) |
| `background.js` | Template-Insertion + QuickMove-Aktionen (Tag setzen, Nachricht verschieben) |
| `popup.html` / `popup.js` | Compose-Popup ("Vorlagen" beim Schreiben) |
| `message_popup.html` / `message_popup.js` | QuickMove-Popup in der Nachrichtenansicht |
| `toolbar_popup.html` | Toolbar-Button → öffnet die Einstellungen |
| `templates_options/` | Einstellungsseite (Tabs: Vorlagen, Signaturen, QuickMove, Sync) |
| `lib/gitea-sync.js` | Gitea-API-Client + Sync-Manager (inkl. Schlagwörter-Sync + Client-Status-Meldung) |
| `lib/forced-prefs-list.js` | Liste der erzwungenen `about:config`-Prefs (geteilt: Background + Optionsseite) |
| `experiments/prefs/` | Privilegierte Experiment-API zum Setzen von `about:config`-Prefs |
| `lib/mdi/` | Material Design Icons (Subset) für den Editor |
| `defaults.local.json` | Optionale vorkonfigurierte Verbindungsdaten (gitignored) |
| `updates.json` | Update-Manifest für Thunderbird-Auto-Update |
| `release.sh` | Release-Skript (Build hashen, Gitea-Release anlegen, updates.json pflegen) |
| `web-editor/` | Optionaler Node/Docker-Web-Editor (eigenständig, gleiche Gitea-Quelle) |
## Installation
@@ -71,17 +121,25 @@ Mapping von Abteilungs-E-Mail-Adressen zu Ordnernamen. Wird vom Plugin gelesen,
### XPI bauen
```bash
# Ohne vorkonfigurierte Verbindungsdaten:
7z a templates-reply-hotel.xpi manifest.json background.js popup.html popup.js lib/ templates_options/ icons/
`zip` ist nicht zwingend vorhanden, `7z` reicht. Immer **ohne** `defaults.local.json` bauen —
die Datei enthält den Gitea-Token und darf nicht in eine veröffentlichte `.xpi`:
# 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
```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'
```
> Eine **vorkonfigurierte** `.xpi` (mit `defaults.local.json`) ist nur für die interne
> Erstinstallation auf einer neuen Maschine gedacht und darf **niemals** als öffentliches
> Release hochgeladen werden — sonst wäre der Token auslesbar.
### 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.
Wenn eine `defaults.local.json` im Plugin-Root existiert und in die XPI eingebaut wird, werden
die Verbindungsdaten **beim allerersten Start** automatisch gesetzt. Updates brauchen die Datei
nicht — bestehende Installationen behalten ihre Konfiguration in `storage.local`.
```json
{
@@ -107,17 +165,25 @@ Installierte Add-ons aktualisieren sich automatisch über `updates.json` in dies
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
1. **`version` in `manifest.json` hochzählen** (gleiche Version = Clients updaten nicht!).
2. `.xpi` **ohne** `defaults.local.json` neu bauen (siehe oben).
3. `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.
4. `updates.json` + `manifest.json` committen & pushen.
Bestehende Installationen ziehen das Update dann automatisch (Prüfung ~1×/Tag, oder manuell
über Add-ons → Zahnrad → „Auf Updates prüfen").
## 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`)
1. **Verbindung konfigurieren**: Sync-Tab → Server-URL, Repository, Token eingeben →
Verbindung speichern (entfällt bei vorkonfigurierter XPI)
2. **Abteilung wählen** (oder automatisch erkannt via `abteilungen.json`; neue Abteilungen
lassen sich per **+** direkt anlegen)
3. **Vorlagen erstellen**: Vorlagen-Tab → Neue Vorlage → Sichtbarkeit wählen → Speichern
4. **Signaturen einrichten**: Signaturen-Tab → Identität wählen → Kopfbereich bearbeiten → Speichern
5. **QuickMove einrichten** (optional): QuickMove-Tab → Aktion hinzufügen (Name + Zielordner) →
Speichern. Der Button erscheint dann in der Nachrichtenansicht.
## Voraussetzungen

View File

@@ -16,6 +16,8 @@ async function executeErledigtAction(tab, actionConfig) {
const storage = await browser.storage.local.get(['gitea_config', 'schlagwoerter_cache']);
const config = storage.gitea_config || {};
const schlagwoerter = storage.schlagwoerter_cache;
// "Als gelesen markieren" — pro Aktion konfigurierbar, standardmäßig an.
const markRead = (actionConfig || {}).markRead !== false;
// Apply user's tag
let tagKey = null;
@@ -33,6 +35,11 @@ async function executeErledigtAction(tab, actionConfig) {
}
}
// Mark as read (before the move, while the id is still valid in this folder)
if (markRead && !message.read) {
await messenger.messages.update(message.id, { read: true });
}
// Move to target folder
if (actionConfig.targetFolder) {
const folderInfo = JSON.parse(actionConfig.targetFolder);
@@ -42,6 +49,7 @@ async function executeErledigtAction(tab, actionConfig) {
// Feedback
const parts = [];
if (tagKey) parts.push('markiert');
if (markRead && !message.read) parts.push('gelesen');
if (actionConfig.targetFolder) parts.push('verschoben');
const title = actionConfig.name || 'Erledigt';
browser.notifications.create({
@@ -68,12 +76,19 @@ messenger.messageDisplayAction.onClicked.addListener(async (tab) => {
async function updateErledigtPopup() {
const result = await browser.storage.local.get('erledigt_config');
const actions = (result.erledigt_config || {}).actions || [];
const setLabel = (label) => {
// setLabel ist Thunderbird-spezifisch — defensiv prüfen, damit der Start nie bricht.
if (messenger.messageDisplayAction.setLabel) return messenger.messageDisplayAction.setLabel({ label });
};
if (actions.length > 1) {
await messenger.messageDisplayAction.setPopup({ popup: 'message_popup.html' });
await messenger.messageDisplayAction.setTitle({ title: 'Aktion wählen' });
await setLabel('QuickMove');
} else {
const name = actions[0]?.name || 'QuickMove';
await messenger.messageDisplayAction.setPopup({ popup: '' });
await messenger.messageDisplayAction.setTitle({ title: actions[0]?.name || 'Erledigt' });
await messenger.messageDisplayAction.setTitle({ title: name });
await setLabel(name); // Button heißt wie die (einzige) Aktion
}
}
@@ -83,6 +98,134 @@ browser.storage.onChanged.addListener((changes, area) => {
});
updateErledigtPopup();
// ── about:config-Prefs bei jedem Start erzwingen ──
//
// Manche Thunderbird-Einstellungen (Startseite, Ansicht, Layout, pro Konto das
// Signatur-/Antwort-Verhalten) „springen zurück" und müssen sonst von Hand
// nachgezogen werden. Wir setzen sie bei JEDEM Start neu über den privilegierten
// forcedPrefs-Experiment (Services.prefs). Die Liste liegt in
// lib/forced-prefs-list.js (globale FORCED_PREFS / FORCED_IDENTITY_PREFS).
// Pro Eintrag kann der Client im Options-Dialog opt-outen (forced_prefs_optout).
const FORCED_PREFS_OPTOUT_KEY = 'forced_prefs_optout';
async function enforceForcedPrefs() {
const stored = await browser.storage.local.get(FORCED_PREFS_OPTOUT_KEY);
const disabled = new Set(stored[FORCED_PREFS_OPTOUT_KEY] || []);
// Globale Prefs
for (const p of FORCED_PREFS) {
if (disabled.has(p.id)) continue;
try {
await browser.forcedPrefs.setPref(p.name, p.type, p.value);
} catch (e) {
console.error('[forcedPrefs] konnte', p.name, 'nicht setzen:', e);
}
}
// Pro-Identität-Prefs — über alle Konten/Identitäten iterieren
const accounts = await browser.accounts.list();
for (const acc of accounts) {
for (const ident of (acc.identities || [])) {
for (const p of FORCED_IDENTITY_PREFS) {
if (disabled.has(p.id)) continue;
const name = `mail.identity.${ident.id}.${p.suffix}`;
try {
await browser.forcedPrefs.setPref(name, p.type, p.value);
} catch (e) {
console.error('[forcedPrefs] konnte', name, 'nicht setzen:', e);
}
}
}
}
}
enforceForcedPrefs(); // Top-Level = läuft einmal pro TB-Start
// Wird eine Pref im Options-Dialog wieder AKTIVIERT, sofort anwenden
// (statt bis zum nächsten Neustart zu warten).
browser.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes[FORCED_PREFS_OPTOUT_KEY]) enforceForcedPrefs();
});
// ── Signatur in neuen Compose-Fenstern automatisch aktualisieren ──
//
// Thunderbird merkt sich die Signatur einer Identität pro Sitzung. Öffnet der
// Benutzer nach einem Hintergrund-Sync ein neues Verfassen-Fenster, steckt darin
// noch die ALTE Signatur (alter Footer) — `identities.update({signature})`
// schreibt zwar den Pref, aber das Compose-Fenster fügt die gecachte Fassung ein.
// Ein Event „neues Compose-Fenster" gibt es in der WebExtension-API nicht, daher
// hängen wir uns an tabs.onCreated und tauschen NUR den Footer-Teil (ab dem
// SIG_FOOTER_START-Marker) im eingefügten Signaturblock gegen den frisch
// gesyncten aus. Header, Trenner ("-- ") und Struktur bleiben unangetastet.
// Vollautomatisch, ohne jede Benutzeraktion.
const SIG_FOOTER_MARK = '<!-- SIG_FOOTER_START -->';
const sigSleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getFreshFooter() {
const c = await browser.storage.local.get('sig_footer_cache');
return c.sig_footer_cache || '';
}
async function refreshComposeSignature(tabId, tabType) {
// Nicht-Compose-Tabs sofort ignorieren, wenn der Typ bekannt ist.
if (tabType && tabType !== 'messageCompose') return;
const footer = await getFreshFooter();
if (!footer) return;
// Läuft IM Compose-Dokument: findet den Signaturblock, ersetzt nur den Footer.
const code = `(function(){
const editor = document.getElementById('messageEditor');
const doc = editor ? editor.contentDocument : document;
const sig = doc.querySelector('.moz-signature');
if (!sig) return 'notready';
const MARK = ${JSON.stringify(SIG_FOOTER_MARK)};
const cur = sig.innerHTML;
const i = cur.indexOf(MARK);
if (i === -1) return 'unmanaged'; // keine vom Plugin verwaltete Signatur
const before = cur.slice(0, i + MARK.length); // "-- <br>" + Header + Marker
const curFooter = cur.slice(i + MARK.length).replace(/^\\s+/, '');
const NEW = ${JSON.stringify(footer)};
if (curFooter === NEW) return 'current'; // schon aktuell, nichts tun
sig.innerHTML = before + '\\n' + NEW;
return 'updated';
})();`;
// Thunderbird fügt die Signatur evtl. erst kurz nach dem Öffnen ein → kurz pollen.
let throwCount = 0;
for (let attempt = 0; attempt < 20; attempt++) {
let details;
try {
details = await browser.compose.getComposeDetails(tabId);
} catch (_) {
// Kein Compose-Tab oder noch nicht bereit. Bei bekanntem Typ weiter pollen,
// sonst nach ein paar Fehlversuchen aufgeben.
if (tabType !== 'messageCompose' && ++throwCount > 3) return;
await sigSleep(150);
continue;
}
if (!details) { await sigSleep(150); continue; }
if (details.isPlainText) return; // Footer ist HTML — Plaintext nicht anfassen
if (details.type === 'draft') return; // gespeicherte Entwürfe nicht überschreiben
let res;
try {
const out = await browser.tabs.executeScript(tabId, { code });
res = Array.isArray(out) ? out[0] : out;
} catch (_) {
return; // Fenster bereits geschlossen o. Ä.
}
if (res !== 'notready') return; // fertig: updated / current / unmanaged
await sigSleep(150);
}
}
browser.tabs.onCreated.addListener((tab) => {
refreshComposeSignature(tab.id, tab && tab.type).catch((err) =>
console.error('[Sig] Compose-Refresh fehlgeschlagen:', err));
});
// ── Template insertion ──
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {

61
experiments/prefs/api.js Normal file
View File

@@ -0,0 +1,61 @@
"use strict";
// Privilegierter Parent-Scope: schreibt/liest about:config-Prefs über Services.prefs.
// Der Modulpfad von ExtensionCommon wechselte mitten im TB-Lebenszyklus:
// TB 115+ -> ExtensionCommon.sys.mjs (ESM)
// TB 109-114 -> ExtensionCommon.jsm (JSM)
// Deshalb defensiv laden, damit dieselbe api.js über die Flotte funktioniert.
var { ExtensionCommon } = (() => {
try {
return ChromeUtils.importESModule(
"resource://gre/modules/ExtensionCommon.sys.mjs"
);
} catch (e) {
return ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
}
})();
// `Services` ist im privilegierten ExtensionAPI-Sandbox in TB 109+ global
// (stellt Services.prefs = nsIPrefBranch bereit); kein Import nötig.
this.forcedPrefs = class extends ExtensionCommon.ExtensionAPI {
getAPI(context) {
return {
forcedPrefs: {
async setPref(name, type, value) {
switch (type) {
case "bool":
Services.prefs.setBoolPref(name, !!value);
break;
case "int":
Services.prefs.setIntPref(name, value | 0);
break;
case "string":
// setStringPref ist unicode-sicher (im Gegensatz zu setCharPref).
Services.prefs.setStringPref(name, String(value));
break;
default:
throw new Error("forcedPrefs.setPref: unknown type " + type);
}
return true;
},
async getPref(name, type) {
if (Services.prefs.getPrefType(name) === Services.prefs.PREF_INVALID) {
return null;
}
switch (type) {
case "bool":
return Services.prefs.getBoolPref(name, false);
case "int":
return Services.prefs.getIntPref(name, 0);
case "string":
return Services.prefs.getStringPref(name, "");
default:
throw new Error("forcedPrefs.getPref: unknown type " + type);
}
},
},
};
}
};

View File

@@ -0,0 +1,29 @@
[
{
"namespace": "forcedPrefs",
"description": "Read/write about:config preferences from a privileged parent context.",
"functions": [
{
"name": "setPref",
"type": "function",
"async": true,
"description": "Force an about:config pref to a fixed value. type is one of 'bool','int','string'.",
"parameters": [
{ "name": "name", "type": "string" },
{ "name": "type", "type": "string", "enum": ["bool", "int", "string"] },
{ "name": "value", "type": "any" }
]
},
{
"name": "getPref",
"type": "function",
"async": true,
"description": "Read an about:config pref; returns null if unset.",
"parameters": [
{ "name": "name", "type": "string" },
{ "name": "type", "type": "string", "enum": ["bool", "int", "string"] }
]
}
]
}
]

52
lib/forced-prefs-list.js Normal file
View File

@@ -0,0 +1,52 @@
"use strict";
// Single Source of Truth für die about:config-Prefs, die bei jedem TB-Start
// erzwungen werden. Wird als klassisches Script in BEIDEN Kontexten geladen
// (Background + Options-Seite), definiert also nur globale Variablen — keine
// import/export-Mechanik.
//
// Jeder Eintrag:
// id - stabiler Opt-out-Key (NIE umbenennen/wiederverwenden; landet in storage)
// label - Text im Options-Dialog
// type - "bool" | "int" | "string"
// value - der bei jedem Start erzwungene Wert
// FORCED_PREFS zusätzlich: name - voller about:config-Pref-Name
// FORCED_IDENTITY_PREFS: suffix - voller Name = mail.identity.<id>.<suffix>
//
// WICHTIG: Werte an einem korrekt eingerichteten Referenz-Thunderbird
// (Doku-Seite „Thunderbird einrichten", Page 99) per about:config gegenprüfen —
// dieser Code läuft privilegiert auf allen Clients, ein Tippfehler trifft alle.
var FORCED_PREFS = [
{ id: "start_page_off", label: "Startseite beim Öffnen ausblenden",
name: "mailnews.start_page.enabled", type: "bool", value: false },
{ id: "listview_cards", label: "Kartenansicht (statt Tabelle)",
name: "mail.threadpane.listview", type: "int", value: 0 },
{ id: "no_grouping", label: "Nachrichten nicht gruppieren",
name: "mailnews.default_view_flags", type: "int", value: 0 },
{ id: "sort_descending", label: "Neue Nachrichten oben (absteigend)",
name: "mailnews.default_sort_order", type: "int", value: 2 },
{ id: "layout_3col", label: "3-Spalten-Ansicht (vertikales Layout)",
name: "mail.pane_config.dynamic", type: "int", value: 2 },
{ id: "no_auto_read", label: "Nachrichten nicht automatisch als gelesen markieren",
name: "mailnews.mark_message_read.auto", type: "bool", value: false },
];
// Pro Identität angewandt: voller Name = mail.identity.<id>.<suffix>
var FORCED_IDENTITY_PREFS = [
{ id: "reply_on_top", label: "Antwort über dem Zitat",
suffix: "reply_on_top", type: "int", value: 1 },
// sig_bottom=false -> Signatur direkt unter der Antwort, VOR dem Zitat.
// (true wäre ganz unten nach dem Zitat.)
{ id: "sig_bottom", label: "Signatur unter Antwort / vor dem Zitat",
suffix: "sig_bottom", type: "bool", value: false },
{ id: "sig_on_reply", label: "Signatur bei Antworten beifügen",
suffix: "sig_on_reply", type: "bool", value: true },
{ id: "sig_on_fwd", label: "Signatur beim Weiterleiten beifügen",
suffix: "sig_on_fwd", type: "bool", value: true },
];
if (typeof globalThis !== "undefined") {
globalThis.FORCED_PREFS = FORCED_PREFS;
globalThis.FORCED_IDENTITY_PREFS = FORCED_IDENTITY_PREFS;
}

View File

@@ -8,6 +8,7 @@ const FULL_SYNC_COOLDOWN_MS = 10 * 1000; // min 10s between full pulls
const SHARED_FOLDER = '_gemeinsam';
const USER_FOLDER = '_benutzer';
const CONFIG_FOLDER = '_config';
const CLIENTS_FOLDER = '_clients';
const SCHLAGWOERTER_CACHE_KEY = 'schlagwoerter_cache';
// ── Gitea API Client ──
@@ -190,6 +191,59 @@ class SyncManager {
return this.config?.authorEmail || '';
}
get authorName() {
return this.config?.authorName || '';
}
/**
* Meldet den eigenen Client-Status (Name, Abteilung, Plugin- + TB-Version)
* als _clients/<email>.json ins Repo — Basis für die Admin-Übersicht im
* Web-Editor. Schreibt NUR, wenn sich tatsächlich etwas geändert hat
* (Plugin-Version, TB-Version, Name oder Abteilung) — sonst kein Commit.
*/
async reportClientStatus() {
if (!this.isConfigured) return { success: false, skipped: 'not-configured' };
const email = this.authorEmail.trim().toLowerCase();
if (!email) return { success: false, skipped: 'no-email' };
let tbVersion = '';
try {
const info = await browser.runtime.getBrowserInfo();
tbVersion = [info.name, info.version].filter(Boolean).join(' ');
} catch (_) {}
const status = {
name: this.authorName,
email,
department: this.department,
pluginVersion: browser.runtime.getManifest().version,
tbVersion,
lastSeen: new Date().toISOString()
};
const filepath = `${CLIENTS_FOLDER}/${email}.json`;
const body = JSON.stringify(status, null, 2);
const commitMsg = `Client-Status: ${email} (v${status.pluginVersion})`;
const existing = await this.client.getFile(filepath);
if (existing && existing.content) {
try {
const prev = JSON.parse(GiteaClient.fromBase64(existing.content));
const unchanged = prev.name === status.name
&& prev.department === status.department
&& prev.pluginVersion === status.pluginVersion
&& prev.tbVersion === status.tbVersion;
if (unchanged) {
return { success: true, skipped: 'unchanged' };
}
} catch (_) { /* kaputter Vorgänger → einfach überschreiben */ }
await this.client.updateFile(filepath, body, existing.sha, commitMsg);
} else {
await this.client.createFile(filepath, body, commitMsg);
}
return { success: true, status };
}
async autoDetect() {
if (!this.isConfigured) return null;
return await this.client.getConfig();
@@ -233,7 +287,7 @@ class SyncManager {
const entries = await this.client.listDir('');
const departments = [];
for (const entry of entries) {
if (entry.type === 'dir' && entry.name !== SHARED_FOLDER && entry.name !== USER_FOLDER && entry.name !== CONFIG_FOLDER && entry.name !== 'signatures' && !entry.name.startsWith('.')) {
if (entry.type === 'dir' && entry.name !== SHARED_FOLDER && entry.name !== USER_FOLDER && entry.name !== CONFIG_FOLDER && entry.name !== CLIENTS_FOLDER && entry.name !== 'signatures' && !entry.name.startsWith('.')) {
departments.push(entry.name);
}
}
@@ -400,6 +454,19 @@ class SyncManager {
return { success: true, pushed };
}
// Kanonischer Signatur-Slug, identisch zum Web-Editor (slugifyHeaderName):
// toFilename(...) + lowercase + Leerzeichen→Bindestrich. So lesen/schreiben
// Web-Editor und Plugin DIESELBE Datei (z.B. ".kendrick-bollens.html").
get sigSlug() {
return SyncManager.toFilename(this.config.authorName || '')
.toLowerCase()
.replace(/\s+/g, '-');
}
// Altes Plugin-Format (Groß-/Kleinschreibung + Leerzeichen, z.B.
// ".Kendrick Bollens.html"). Nur noch für Lese-Fallback, damit Dateien von
// Clients mit alter Plugin-Version weiterhin angewandt werden. Nicht mehr
// schreiben — sonst entstehen wieder Doppel-Dateien.
get authorSlug() {
return SyncManager.toFilename(this.config.authorName || '');
}
@@ -567,11 +634,30 @@ class SyncManager {
if (source.startsWith('=')) continue;
// Try personal header: email.authorslug.html
let targetFile = null;
// Personal-Header suchen. headerMap-Keys sind lowercase, daher
// Kandidaten ebenfalls lowercase nachschlagen (behebt den Bug,
// dass Groß-Namen NIE gefunden wurden → Plugin fiel auf die lokale
// Signatur zurück und revertierte beim Push die Web-Editor-Änderung).
// Kandidaten:
// • kanonisches Web-Editor-Format (lowercase-bindestrich)
// • altes Plugin-Format (Groß/Leerzeichen) von Clients mit alter Version
// Bei mehreren Treffern gewinnt der NEUESTE Commit — so verdeckt eine
// veraltete Datei keine frische Web-Editor-Änderung, egal welche der
// beiden Dateien zuletzt im Web-Editor bearbeitet wurde.
const candidates = [];
if (this.sigSlug) {
const f = headerMap[`${email}.${this.sigSlug}.html`.toLowerCase()];
if (f) candidates.push(f);
}
if (this.authorSlug) {
const personalName = `${email}.${this.authorSlug}.html`;
targetFile = headerMap[personalName] || null;
const f = headerMap[`${email}.${this.authorSlug}.html`.toLowerCase()];
if (f && !candidates.includes(f)) candidates.push(f);
}
let targetFile = null;
let targetTime = -1;
for (const f of candidates) {
const t = Date.parse(f.last_author_date || f.last_committer_date || '') || 0;
if (t >= targetTime) { targetFile = f; targetTime = t; }
}
let header = null;
@@ -634,7 +720,7 @@ class SyncManager {
async pushSignatures() {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
if (!this.config.authorName) throw new Error('Bitte Name eintragen (für Commit-Zuordnung)');
if (!this.authorSlug) throw new Error('Name fehlt für Dateiname');
if (!this.sigSlug) throw new Error('Name fehlt für Dateiname');
const sourceResult = await browser.storage.local.get('sig_source_map');
const sourceMap = sourceResult.sig_source_map || {};
@@ -655,7 +741,12 @@ class SyncManager {
const header = SyncManager.extractHeader(identity.signature);
if (!header.trim()) continue;
const filename = `${email}.${this.authorSlug}.html`;
// Ins kanonische Web-Editor-Format schreiben (lowercase-bindestrich),
// damit Plugin und Web-Editor dieselbe Datei pflegen. Alte
// Groß-Dateien bleiben unangetastet (keine Breaking Changes für
// Clients mit alter Plugin-Version), werden aber nicht mehr neu
// geschrieben.
const filename = `${email}.${this.sigSlug}.html`;
const filepath = `signatures/headers/${filename}`;
const commitMsg = `Signatur-Header ${identity.email} - von ${this.config.authorName}`;
@@ -931,6 +1022,7 @@ let lastKnownShas = null;
let lastFullSync = 0;
let lastTagSync = 0;
let syncInProgress = false;
let clientStatusReported = false;
const TAG_SYNC_INTERVAL_MS = 60 * 1000;
const HASH_STORAGE_KEY_BG = 'sync_hashes';
@@ -972,6 +1064,15 @@ async function smartSync() {
const initialized = await syncManager.init();
if (!initialized) return;
// Einmal pro Session den eigenen Client-Status melden (Admin-Übersicht).
// Fire-and-forget, blockiert den Sync nicht.
if (!clientStatusReported) {
clientStatusReported = true;
syncManager.reportClientStatus()
.then((r) => { if (r?.status) console.log('[Sync] Client-Status gemeldet:', r.status.email, 'v' + r.status.pluginVersion); })
.catch((e) => console.error('[Sync] Client-Status fehlgeschlagen:', e));
}
syncInProgress = true;
// Tag sync every 60s (schlagwoerter.json is not in SHA-checked folders)

View File

@@ -1,61 +1,75 @@
{
"manifest_version": 2,
"name": "HPS Vorlagen & Signaturen",
"version": "2.3.0",
"description": "Vorlagen- und Signaturverwaltung für Hotel Park Soltau mit Git-Sync",
"browser_specific_settings": {
"gecko": {
"id": "it@hotel-park-soltau.de",
"strict_min_version": "109.0",
"update_url": "https://git.hotel-park-soltau.de/kendrick.bollens/hps-thunderbird-templates/raw/branch/main/updates.json"
}
},
"permissions": [
"compose",
"storage",
"notifications",
"tabs",
"accountsRead",
"accountsIdentities",
"messagesTagsList",
"messagesTags",
"messagesRead",
"messagesUpdate",
"messagesMove",
"accountsFolders"
],
"optional_permissions": [
"*://*/*"
],
"background": {
"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": {
"16": "icons/icon.png",
"32": "icons/icon.png"
},
"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
}
}
{
"manifest_version": 2,
"name": "HPS Vorlagen & Signaturen",
"version": "2.5.0",
"description": "Vorlagen- und Signaturverwaltung für Hotel Park Soltau mit Git-Sync",
"browser_specific_settings": {
"gecko": {
"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",
"accountsRead",
"accountsIdentities",
"messagesTagsList",
"messagesTags",
"messagesRead",
"messagesUpdate",
"messagesMove",
"accountsFolders"
],
"optional_permissions": [
"*://*/*"
],
"background": {
"scripts": [
"lib/forced-prefs-list.js",
"lib/gitea-sync.js",
"background.js"
],
"persistent": true
},
"experiment_apis": {
"forcedPrefs": {
"schema": "experiments/prefs/schema.json",
"parent": {
"scopes": ["addon_parent"],
"paths": [["forcedPrefs"]],
"script": "experiments/prefs/api.js"
}
}
},
"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": {
"16": "icons/icon.png",
"32": "icons/icon.png"
},
"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
}
}

View File

@@ -11,7 +11,7 @@
# Usage: GITEA_TOKEN=xxxx ./release.sh
set -euo pipefail
OWNER="kendrick.bollens"
OWNER="hps"
REPO="hps-thunderbird-templates"
BASE="https://git.hotel-park-soltau.de"
XPI="templates-reply-hotel.xpi"

Binary file not shown.

View File

@@ -1049,6 +1049,13 @@
</div>
</div>
<!-- Erzwungene Einstellungen -->
<div class="card">
<div class="card-title">Erzwungene Einstellungen</div>
<div class="card-desc">Diese Thunderbird-Einstellungen werden bei jedem Start automatisch gesetzt (sie „springen" sonst zurück). Zum Abschalten den jeweiligen Haken entfernen — der bisherige Wert wird dabei <strong>nicht</strong> wiederhergestellt, es wird nur nicht mehr erzwungen.</div>
<div id="forced-prefs-list"></div>
</div>
<div id="sync-log"></div>
</div>
@@ -1088,6 +1095,8 @@
</div>
</div>
<!-- geteilte Pref-Liste muss VOR templates_options.js geladen werden -->
<script src="../lib/forced-prefs-list.js"></script>
<script src="templates_options.js"></script>
</body>
</html>

View File

@@ -1879,6 +1879,12 @@ function renderErledigtAction(action, index) {
${erledigtFolderOptions}
</select>
</div>
<div class="form-group" style="flex:0 0 auto;">
${index === 0 ? '<label>Gelesen</label>' : ''}
<label style="display:flex;align-items:center;gap:6px;height:34px;white-space:nowrap;font-size:13px;cursor:pointer;">
<input type="checkbox" class="erledigt-markread" ${action.markRead !== false ? 'checked' : ''}> als gelesen
</label>
</div>
<button type="button" class="btn btn-danger btn-sm erledigt-remove" title="Entfernen" style="margin-bottom:2px;">✕</button>
`;
if (action.targetFolder) {
@@ -1900,10 +1906,11 @@ function getErledigtActionsFromForm() {
const actions = [];
const names = document.querySelectorAll('.erledigt-name');
const folders = document.querySelectorAll('.erledigt-folder');
const reads = document.querySelectorAll('.erledigt-markread');
for (let i = 0; i < names.length; i++) {
const name = names[i].value.trim();
if (!name) continue;
actions.push({ name, targetFolder: folders[i].value || '' });
actions.push({ name, targetFolder: folders[i].value || '', markRead: reads[i] ? reads[i].checked : true });
}
return actions;
}
@@ -1971,3 +1978,51 @@ window.addEventListener('load', async () => {
}
});
});
// ── Opt-out-Schalter für erzwungene Prefs ──
// FORCED_PREFS / FORCED_IDENTITY_PREFS stammen aus ../lib/forced-prefs-list.js
// (im HTML vor diesem Script geladen). Gespeichert werden die ids der
// DEAKTIVIERTEN Prefs unter 'forced_prefs_optout'; Haken = wird erzwungen.
const FORCED_PREFS_OPTOUT_KEY = 'forced_prefs_optout';
async function renderForcedPrefsToggles() {
const container = document.getElementById('forced-prefs-list');
if (!container || typeof FORCED_PREFS === 'undefined') return;
const stored = await browser.storage.local.get(FORCED_PREFS_OPTOUT_KEY);
const disabled = new Set(stored[FORCED_PREFS_OPTOUT_KEY] || []);
container.textContent = '';
const entries = [...FORCED_PREFS, ...(typeof FORCED_IDENTITY_PREFS !== 'undefined' ? FORCED_IDENTITY_PREFS : [])];
for (const p of entries) {
const row = document.createElement('label');
row.className = 'forced-pref-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '8px';
row.style.padding = '4px 0';
row.style.cursor = 'pointer';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !disabled.has(p.id); // Haken = wird erzwungen
cb.addEventListener('change', () => setForcedPrefEnabled(p.id, cb.checked));
const span = document.createElement('span');
span.textContent = p.label;
row.append(cb, span);
container.appendChild(row);
}
}
async function setForcedPrefEnabled(id, enabled) {
const stored = await browser.storage.local.get(FORCED_PREFS_OPTOUT_KEY);
const disabled = new Set(stored[FORCED_PREFS_OPTOUT_KEY] || []);
if (enabled) disabled.delete(id);
else disabled.add(id);
await browser.storage.local.set({ [FORCED_PREFS_OPTOUT_KEY]: [...disabled] });
// background.js lauscht auf storage.onChanged und wendet Aktivierungen sofort an.
}
document.addEventListener('DOMContentLoaded', renderForcedPrefsToggles);

View File

@@ -2,9 +2,79 @@
"addons": {
"it@hotel-park-soltau.de": {
"updates": [
{
"version": "2.5.0",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.5.0/templates-reply-hotel.xpi",
"update_hash": "sha256:97f9006a88d565e6ec524841687b851c4f2f8f02b313b2effd5b05b68e141599",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{
"version": "2.3.6",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.6/templates-reply-hotel.xpi",
"update_hash": "sha256:1fe5f707ae5766a58337b4270a7739f5227ac10cf4973258f4130e33fc46d1e5",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{
"version": "2.3.5",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.5/templates-reply-hotel.xpi",
"update_hash": "sha256:41335a1b572ee38fe659d716ab7b89754fa997ec532e626930f8e470fac31281",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{
"version": "2.3.4",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.4/templates-reply-hotel.xpi",
"update_hash": "sha256:1825f81c309c7d2be4bba048b1e41d0758bb145823b765c0dbb8add75cd4ad2e",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{
"version": "2.3.3",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.3/templates-reply-hotel.xpi",
"update_hash": "sha256:7845dddcf7335f8e1411162a2aae821e9293a9de1c4bc4016cc357d2a159165a",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{
"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/kendrick.bollens/hps-thunderbird-templates/releases/download/v2.3.0/templates-reply-hotel.xpi",
"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": {

View File

@@ -1,6 +1,12 @@
services:
web-editor:
build: .
# 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
@@ -18,3 +24,8 @@ services:
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

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -35,46 +35,43 @@
<!-- ── App ── -->
<div class="app">
<!-- Kategorie-Tabs -->
<!-- Kategorie-Navigation -->
<nav class="cat-tabs" role="tablist">
<button class="cat-tab is-active" data-cat="templates" role="tab">
<span class="cat-ico">📄</span> Vorlagen
</button>
<button class="cat-tab" data-cat="footers" role="tab">
<span class="cat-ico">📜</span> Fußzeilen
</button>
<button class="cat-tab" data-cat="headers" role="tab">
<span class="cat-ico">✍️</span> Signaturen
</button>
<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"></button>
<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">
<input type="search" id="tree-search" class="tree-search" placeholder="Suchen…" autocomplete="off" />
<button id="btn-list-add" class="btn btn-primary btn-sm">&nbsp;Neu</button>
<div 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>
<!-- Editor-Spalte -->
<!-- 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="64" height="64" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h11l5 5v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z"/>
<path d="M14 4v5h5"/><path d="M8 13h8"/><path d="M8 17h5"/>
<svg 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>
<!-- Editor -->
<!-- Datei-Editor -->
<div class="editor-panel" id="editor-panel" hidden>
<div class="editor-head">
<div class="editor-titles">
@@ -83,12 +80,15 @@
</div>
<div class="editor-head-actions">
<span id="dirty-badge" class="dirty-badge" hidden>Nicht gespeichert</span>
<button class="btn btn-ghost" id="btn-reload" title="Vom Server neu laden">Neu laden</button>
<button class="btn btn-danger-ghost" id="btn-delete">Löschen</button>
<button class="btn btn-primary" id="btn-save">Speichern</button>
<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>
@@ -96,19 +96,16 @@
</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-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 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>
@@ -117,9 +114,7 @@
<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>
<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>

View File

@@ -1,27 +1,32 @@
/* HPS Vorlagen & Signaturen — Web-Editor
* Modernes, ruhiges Layout für nicht-technische Anwender.
* 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; /* tiefes Oliv weiße Schrift bleibt lesbar */
--brand: #647219;
--brand-600: #556114;
--brand-700: #45500f;
--brand-50: #f1f4e1;
--brand-100: #e0e7bf;
--accent: #95a322; /* reines Logo-Grün (Akzente) */
--brand-50: #f3f6e6;
--brand-100: #e2e9c5;
--accent: #95a322;
--charcoal: #3c3c3b;
--charcoal-2: #2f2f2e;
--charcoal-2: #2c2c2b;
--bg: #eceff1;
--bg: #eef1ee;
--panel: #ffffff;
--sidebar: #f6f8f9;
--text: #232c2e;
--muted: #687279;
--muted-2: #97a1a7;
--border: #e4e8eb;
--border-strong:#d2d9dd;
--text: #222a26;
--muted: #69736d;
--muted-2: #9aa39c;
--border: #e6eae6;
--border-strong:#d6dcd6;
--danger: #d6453f;
--danger-50: #fdecea;
@@ -30,212 +35,128 @@
--radius: 16px;
--radius-md: 11px;
--radius-sm: 8px;
--radius-sm: 9px;
--shadow-sm: 0 1px 2px rgba(20,40,45,.06), 0 1px 3px rgba(20,40,45,.07);
--shadow-md: 0 6px 18px rgba(20,40,45,.10), 0 2px 6px rgba(20,40,45,.06);
--shadow-lg: 0 20px 56px rgba(20,40,45,.24);
--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: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Noto Sans", sans-serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--font: 'Jakarta', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
--topbar-h: 60px;
--tabs-h: 56px;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body {
margin: 0; height: 100%;
font-family: var(--font);
color: var(--text);
background: var(--bg);
font-size: 15px;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
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; }
/* ── Topbar ── */
.topbar {
height: var(--topbar-h);
display: flex; align-items: center; justify-content: space-between;
padding: 0 20px;
background: linear-gradient(100deg, var(--charcoal-2), var(--charcoal) 55%, #46463f);
color: #fff;
box-shadow: var(--shadow-md);
position: sticky; top: 0; z-index: 30;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-logo {
display: flex; align-items: center;
background: #fff; border-radius: 10px; padding: 5px 11px;
box-shadow: var(--shadow-sm);
}
.brand-logo img { height: 30px; display: block; }
.brand-divider { width: 1px; height: 26px; background: rgba(255,255,255,.22); }
.brand-title { font-size: 15px; font-weight: 600; letter-spacing: .3px; color: rgba(255,255,255,.92); }
.topbar-right { display: flex; align-items: center; gap: 14px; }
/* Icons */
.ic { display: inline-flex; align-items: center; justify-content: center; }
.ic svg { display: block; }
.status-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 13px; border-radius: 999px;
font-size: 12.5px; font-weight: 600;
background: rgba(255,255,255,.12); color: #fff;
border: 1px solid rgba(255,255,255,.18);
max-width: 46vw; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
/* ── 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: #7bd14f; box-shadow: 0 0 0 3px rgba(123,209,79,.25); }
.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-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 { margin: 16px 22px 0; padding: 14px 18px; background: #fff7e6; border: 1px solid #f0d8a0; border-left: 4px solid var(--accent); border-radius: var(--radius-md); color: #6a5320; font-size: 13.5px; line-height: 1.55; }
.config-banner code { font-family: var(--mono); background: #faedce; padding: 1px 6px; border-radius: 5px; font-size: 12.5px; }
/* ── App / Tabs ── */
/* ── App / Kategorie-Tabs ── */
.app { height: calc(100vh - var(--topbar-h)); display: flex; flex-direction: column; }
.cat-tabs {
height: var(--tabs-h);
display: flex; align-items: center; gap: 6px;
padding: 0 18px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.cat-tab {
display: inline-flex; align-items: center; gap: 8px;
border: none; background: none; cursor: pointer;
padding: 9px 16px; border-radius: 999px;
font-size: 14px; font-weight: 600; color: var(--muted);
transition: all .15s;
}
.cat-tab .cat-ico { font-size: 15px; }
.cat-tab:hover { background: var(--brand-50); color: var(--brand-600); }
.cat-tab.is-active { background: var(--brand); color: #fff; box-shadow: var(--shadow-sm); }
.cat-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: 36px; height: 36px; border-radius: 9px;
border: 1px solid var(--border-strong); background: #fff; color: var(--muted);
font-size: 17px; cursor: pointer; transition: all .15s;
display: grid; place-items: center;
}
.icon-btn { 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: Liste + Editor ── */
/* ── Workspace ── */
.workspace { flex: 1; display: grid; grid-template-columns: 300px 1fr; min-height: 0; }
.listpane {
background: var(--sidebar);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
min-height: 0;
}
.listpane-head {
display: flex; gap: 8px; align-items: center;
padding: 14px 14px 10px;
}
.tree-search {
flex: 1; min-width: 0;
border: 1px solid var(--border-strong); background: #fff; border-radius: var(--radius-sm);
padding: 9px 12px; font-size: 13.5px; color: var(--text); outline: none;
transition: border-color .15s, box-shadow .15s;
}
.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); box-shadow: 0 0 0 3px var(--brand-50); }
.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: 4px 10px 30px; }
.list-body { flex: 1; overflow-y: auto; padding: 8px 10px 30px; }
/* Gruppen (Vorlagen je Abteilung) */
.tree-group { margin-bottom: 4px; }
.group-title {
width: 100%; text-align: left;
display: flex; align-items: center; gap: 8px;
background: none; border: none; cursor: pointer;
padding: 9px 10px; border-radius: var(--radius-sm);
font-size: 12px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--muted);
transition: background .12s;
}
.group-title:hover { background: #eaeef0; color: var(--brand-600); }
.group-title .g-caret { font-size: 9px; color: var(--muted-2); width: 10px; transition: transform .18s; }
.group-title.collapsed .g-caret { transform: rotate(-90deg); }
.group-title .group-icon { font-size: 14px; }
.group-title .g-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: none; letter-spacing: normal; font-size: 13px; }
.group-title .g-count { font-size: 11px; font-weight: 600; color: var(--muted-2); background: #fff; border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; }
.group-files { display: flex; flex-direction: column; gap: 2px; padding: 2px 0 6px 4px; }
/* 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: 10px;
padding: 9px 11px; border-radius: var(--radius-sm); cursor: pointer;
font-size: 13.5px; color: #3a464c;
transition: background .12s, color .12s;
}
.tree-item:hover { background: #eaeef0; color: var(--text); }
.tree-item .ti-icon { font-size: 14px; opacity: .85; flex: none; }
.tree-item { 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); color: #fff; box-shadow: var(--shadow-sm); }
.tree-item.is-active .ti-icon { opacity: 1; }
.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: 10px 12px; font-size: 12.5px; color: var(--muted-2); font-style: italic; }
.tree-empty { padding: 9px 12px; font-size: 12.5px; color: var(--muted-2); font-style: italic; }
.add-item {
margin: 3px 0 2px; padding: 8px 11px; width: 100%; text-align: left;
background: none; border: 1px dashed var(--border-strong); color: var(--muted);
border-radius: var(--radius-sm); font-size: 12.5px; font-weight: 600; cursor: pointer;
transition: all .15s;
}
.add-item:hover { color: var(--brand); border-color: var(--brand-100); background: var(--brand-50); }
/* ── Editor-Spalte ── */
.editorpane { min-height: 0; display: flex; flex-direction: column; padding: 18px; }
/* 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: 680; }
.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-panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; }
.editor-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 18px 22px 15px; border-bottom: 1px solid var(--border); }
.editor-titles { min-width: 0; }
.editor-titles h2 {
margin: 0 0 7px; font-size: 19px; font-weight: 680; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.file-path {
display: inline-block; font-family: var(--mono); font-size: 11.5px; color: var(--muted);
background: var(--bg); border: 1px solid var(--border); padding: 3px 9px; border-radius: 999px;
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.editor-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 { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: #8a6312; background: #fdf3df; border: 1px solid #f0dca6; padding: 5px 11px; border-radius: 999px; }
.dirty-badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
/* Buttons */
.btn {
border: 1px solid transparent; border-radius: var(--radius-sm); padding: 9px 16px;
font-size: 13.5px; font-weight: 600; cursor: pointer; transition: all .15s ease;
white-space: nowrap; line-height: 1.1;
}
.btn { 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); }
@@ -245,55 +166,86 @@ button { font-family: inherit; }
.btn-danger:hover { background: #bd3a35; }
.btn-danger-ghost { background: #fff; color: var(--danger); border-color: #ecc4c2; }
.btn-danger-ghost:hover { background: var(--danger-50); border-color: var(--danger); }
.btn-sm { padding: 8px 13px; font-size: 12.5px; }
.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: 4px; padding: 12px 22px 0; }
.editor-tab {
border: none; background: none; cursor: pointer;
padding: 9px 18px; border-radius: 9px 9px 0 0;
font-size: 13.5px; font-weight: 600; color: var(--muted);
border-bottom: 2px solid transparent; transition: all .15s;
}
.editor-tab:hover { color: var(--text); background: var(--brand-50); }
.editor-tab.is-active { color: var(--brand); border-bottom-color: var(--brand); }
.editor-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 */
.editor-body { flex: 1; min-height: 0; display: flex; padding: 14px 22px 22px; }
.editor-body { flex: 1; min-height: 0; display: flex; padding: 16px 22px 22px; }
.epane { flex: 1; min-height: 0; display: flex; }
/* TinyMCE soll die Spalte füllen und runde Ecken haben */
#pane-visual { flex-direction: column; }
.tox.tox-tinymce { flex: 1; border-radius: var(--radius-md) !important; border-color: var(--border-strong) !important; }
.tox .tox-editor-header { box-shadow: none !important; }
.html-editor {
flex: 1; width: 100%; resize: none;
border: 1px solid var(--border-strong); border-radius: var(--radius-md);
background: #fbfcfd; padding: 16px 18px;
font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: #2a3a42;
outline: none; tab-size: 2; transition: border-color .15s, box-shadow .15s;
}
.html-editor { 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); }
#pane-preview { }
.preview-frame-wrap {
flex: 1; border: 1px solid var(--border); border-radius: var(--radius-md);
background: #e7ebee; overflow: hidden; min-height: 0;
background-image: radial-gradient(rgba(60,60,59,.07) 1px, transparent 1px);
background-size: 16px 16px;
}
.preview-frame-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 { 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); }
@@ -303,46 +255,51 @@ button { font-family: inherit; }
@keyframes toast-out { to { opacity: 0; transform: translateX(20px); } }
/* ── Lade-Overlay ── */
.loading-overlay { position: fixed; inset: 0; background: rgba(20,40,45,.28); backdrop-filter: blur(2px); display: grid; place-items: center; z-index: 70; }
.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(18,30,33,.45); backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 80; padding: 20px; animation: fade-in .15s ease; }
.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: 680; color: var(--text); }
.modal p { margin: 0 0 4px; color: #44525a; line-height: 1.55; font-size: 14px; }
.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; color: var(--text); outline: none; background: #fff;
transition: border-color .15s, box-shadow .15s;
}
.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 { width: 10px; }
.list-body::-webkit-scrollbar-thumb, .html-editor::-webkit-scrollbar-thumb { background: #cdd6da; border-radius: 10px; border: 2px solid transparent; background-clip: content-box; }
.list-body::-webkit-scrollbar-thumb:hover { background: #b3bfc4; background-clip: content-box; }
.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; }
/* ── User & Versionen (Clients) ── */
.cl-tablewrap { margin-top: 6px; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--panel); }
.cl-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.cl-table th, .cl-table td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.cl-table thead th { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; background: var(--bg); }
.cl-table tbody tr:last-child td { border-bottom: none; }
.cl-table tbody tr:hover { background: var(--bg); }
.cl-table .cl-mail { color: var(--muted); font-family: var(--mono); font-size: 12.5px; }
.cl-row-old td { background: var(--danger-50); }
.cl-row-old:hover td { background: #fbe3e1; }
.cl-badge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; vertical-align: middle; }
.cl-badge.cl-old { background: var(--danger-50); color: var(--danger); border: 1px solid #ecc4c2; }
/* ── Responsive ── */
@media (max-width: 980px) {
.workspace { grid-template-columns: 250px 1fr; }
}
@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; }
.status-pill { max-width: 36vw; }
.map-head, .map-row { grid-template-columns: 1fr 130px 36px; }
.brand-title { display: none; }
}

View File

@@ -23,6 +23,7 @@ const {
const SHARED_FOLDER = '_gemeinsam';
const USER_FOLDER = '_benutzer';
const CONFIG_FOLDER = '_config';
const CLIENTS_FOLDER = '_clients';
const SIG_FOOTERS = 'signatures/footers';
const SIG_HEADERS = 'signatures/headers';
@@ -206,6 +207,20 @@ function localWrite(filepath, content) {
return { content: { sha: 'local', path: filepath } };
}
function localAllFiles() {
const out = [];
(function walk(dir) {
const abs = dir ? localResolve(dir) : localRoot;
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
if (e.name === '.git') continue;
const rel = dir ? `${dir}/${e.name}` : e.name;
if (e.isDirectory()) walk(rel);
else out.push({ path: rel, sha: 'local' });
}
})('');
return out;
}
// ── Demo backend (in-memory) ──
// A flat map of repoPath → raw content, seeded with sample data. The demo
// versions of the primitives mimic the Gitea API response shapes so the rest
@@ -324,6 +339,29 @@ if (DEMO) {
console.log('[DEMO] In-Memory-Demodaten geladen — keine echte Gitea-Verbindung nötig.');
}
// Liefert ALLE Dateien des Repos als flache Liste [{path, sha}].
// Gitea: ein einziger rekursiver git/trees-Aufruf (statt vieler listDir).
async function listAllFiles() {
if (LOCAL) return localAllFiles();
if (DEMO) return Object.keys(demoStore).map((p) => ({ path: p, sha: 'demo' }));
const out = [];
let page = 1, total = Infinity;
while (out.length < total) {
const url = `${apiBase()}/git/trees/${encodeURIComponent(GITEA_BRANCH)}?recursive=true&per_page=1000&page=${page}`;
const res = await fetch(url, { headers: giteaHeaders() });
if (res.status === 404) return [];
if (!res.ok) await giteaError('TREE', '', res);
const data = await res.json();
total = data.total_count || 0;
const blobs = (data.tree || []).filter((e) => e.type === 'blob').map((e) => ({ path: e.path, sha: e.sha }));
out.push(...blobs);
if (!data.tree || data.tree.length === 0) break;
page++;
}
return out;
}
// ── Express app ──
const app = express();
@@ -384,7 +422,7 @@ 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)
&& ![SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_FOLDER, 'signatures'].includes(e.name)
&& !e.name.startsWith('.'))
.map(e => e.name)
.sort((a, b) => a.localeCompare(b, 'de'));
@@ -410,39 +448,38 @@ app.get('/api/files', wrap(async (req, res) => {
res.json({ folder, files });
}));
// Full inventory in one shot: departments + all template/footer/header files.
// Full inventory in ONE request (recursive tree), strukturiert aus den Pfaden.
app.get('/api/tree', wrap(async (_req, res) => {
const top = await listDir('');
const departments = top
.filter(e => e.type === 'dir'
&& ![SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(e.name)
&& !e.name.startsWith('.'))
.map(e => e.name)
.sort((a, b) => a.localeCompare(b, 'de'));
const files = await listAllFiles();
const special = [SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_FOLDER, 'signatures'];
const dirOf = (p) => { const i = p.lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); };
const baseOf = (p) => p.slice(p.lastIndexOf('/') + 1);
const mapFiles = (entries) => entries
.filter(f => f.type === 'file' && f.name.endsWith('.html'))
.map(f => ({ name: f.name, path: f.path, sha: f.sha }))
// .html-Dateien direkt in einem bestimmten Ordner
const htmlIn = (folder) => files
.filter((f) => dirOf(f.path) === folder && baseOf(f.path).endsWith('.html'))
.map((f) => ({ name: baseOf(f.path), path: f.path, sha: f.sha }))
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
// Templates: shared + each department (personal user folders listed separately).
const templateFolders = [SHARED_FOLDER, ...departments];
const templates = {};
await Promise.all(templateFolders.map(async (folder) => {
templates[folder] = mapFiles(await listDir(folder));
}));
// Abteilungen = oberste Ordner außer den Spezialordnern (auch leere, via .gitkeep)
const deptSet = new Set();
for (const f of files) {
const seg = f.path.split('/')[0];
if (f.path.includes('/') && !special.includes(seg) && !seg.startsWith('.')) deptSet.add(seg);
}
const departments = [...deptSet].sort((a, b) => a.localeCompare(b, 'de'));
const templates = { [SHARED_FOLDER]: htmlIn(SHARED_FOLDER) };
departments.forEach((d) => { templates[d] = htmlIn(d); });
// Personal user template folders under _benutzer/<email>/
const userEntries = await listDir(USER_FOLDER);
const users = {};
await Promise.all(userEntries
.filter(e => e.type === 'dir')
.map(async (e) => { users[e.name] = mapFiles(await listDir(e.path)); }));
for (const f of files) {
const parts = f.path.split('/');
if (parts[0] === USER_FOLDER && parts.length >= 3) users[parts[1]] = users[parts[1]] || [];
}
Object.keys(users).forEach((email) => { users[email] = htmlIn(`${USER_FOLDER}/${email}`); });
const footers = mapFiles(await listDir(SIG_FOOTERS));
const headers = mapFiles(await listDir(SIG_HEADERS));
res.json({ departments, templates, users, footers, headers });
res.json({ departments, templates, users, footers: htmlIn(SIG_FOOTERS), headers: htmlIn(SIG_HEADERS) });
}));
// Read a single file. Missing file → exists:false, empty content (editor can create it).
@@ -475,6 +512,31 @@ app.delete('/api/file', wrap(async (req, res) => {
res.json({ success: true });
}));
// Client-Status-Übersicht: liest alle _clients/<email>.json und liefert die
// gemeldeten Plugin-/TB-Versionen je User (Basis für den Admin-Tab).
app.get('/api/clients', wrap(async (_req, res) => {
const entries = await listDir(CLIENTS_FOLDER);
const clients = [];
for (const e of entries) {
if (e.type !== 'file' || !e.name.endsWith('.json')) continue;
const data = await getFile(e.path);
if (!data) continue;
try {
const c = JSON.parse(fromBase64(data.content));
clients.push({
name: c.name || '',
email: c.email || e.name.replace(/\.json$/, ''),
department: c.department || '',
pluginVersion: c.pluginVersion || '',
tbVersion: c.tbVersion || '',
lastSeen: c.lastSeen || '',
});
} catch (_) { /* kaputte Statusdatei überspringen */ }
}
clients.sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email, 'de'));
res.json({ clients });
}));
// Read/write the email→department mapping.
app.get('/api/abteilungen', wrap(async (_req, res) => {
const data = await getFile(`${CONFIG_FOLDER}/abteilungen.json`);
@@ -491,6 +553,40 @@ app.put('/api/abteilungen', wrap(async (req, res) => {
res.json({ success: true, ...result });
}));
// Read/write the Schlagwörter (Thunderbird-Tags) config.
app.get('/api/schlagwoerter', wrap(async (_req, res) => {
const data = await getFile(`${CONFIG_FOLDER}/schlagwoerter.json`);
let tags = [];
if (data) { try { tags = JSON.parse(fromBase64(data.content)); } catch (_) {} }
if (!Array.isArray(tags)) tags = [];
res.json({ tags, exists: !!data });
}));
app.put('/api/schlagwoerter', wrap(async (req, res) => {
const tags = req.body?.tags;
if (!Array.isArray(tags)) return res.status(400).json({ error: 'Ungültige Schlagwörter' });
const json = JSON.stringify(tags, null, 2);
const result = await saveFile(`${CONFIG_FOLDER}/schlagwoerter.json`, json, 'schlagwoerter.json aktualisiert (Web-Editor)');
res.json({ success: true, ...result });
}));
// Delete a whole department folder (all its files). Destructive guarded.
app.delete('/api/departments', wrap(async (req, res) => {
const name = (req.body?.name || '').trim();
if (!name) return res.status(400).json({ error: 'Kein Name angegeben' });
if ([SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_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')));