4 Commits

Author SHA1 Message Date
Kendrick Bollens
fd192bb8ba URLs auf hps-Org umstellen (Repo-Transfer)
- Auto-Update-URLs (manifest.json, updates.json), release.sh OWNER, CLAUDE.md
  von kendrick.bollens auf hps
- web-editor/docker-compose.yml: Git-Build-Context auf hps-Repo-URL

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

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

View File

@@ -4,10 +4,15 @@ Thunderbird-MailExtension „HPS Vorlagen & Signaturen" mit Gitea-Sync.
## Workflow (WICHTIG) ## 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. 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. `.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 ## Build der .xpi
@@ -37,6 +42,6 @@ brauchen sie nicht — bestehende Installs behalten ihre Config in `storage.loca
## Repo ## Repo
- Sync-Daten-Repo (Templates/Signaturen): `hps/email-vorlagen` auf `git.hotel-park-soltau.de`. - 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 **Muss public bleiben** — der Thunderbird-Auto-Updater greift anonym (ohne Token) auf
`updates.json` und die Release-`.xpi` zu. Privat = 401 = Auto-Updates kaputt. `updates.json` und die Release-`.xpi` zu. Privat = 401 = Auto-Updates kaputt.

View File

@@ -1,13 +1,13 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "HPS Vorlagen & Signaturen", "name": "HPS Vorlagen & Signaturen",
"version": "2.3.0", "version": "2.3.1",
"description": "Vorlagen- und Signaturverwaltung für Hotel Park Soltau mit Git-Sync", "description": "Vorlagen- und Signaturverwaltung für Hotel Park Soltau mit Git-Sync",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "it@hotel-park-soltau.de", "id": "it@hotel-park-soltau.de",
"strict_min_version": "109.0", "strict_min_version": "109.0",
"update_url": "https://git.hotel-park-soltau.de/kendrick.bollens/hps-thunderbird-templates/raw/branch/main/updates.json" "update_url": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/raw/branch/main/updates.json"
} }
}, },
"permissions": [ "permissions": [

View File

@@ -11,7 +11,7 @@
# Usage: GITEA_TOKEN=xxxx ./release.sh # Usage: GITEA_TOKEN=xxxx ./release.sh
set -euo pipefail set -euo pipefail
OWNER="kendrick.bollens" OWNER="hps"
REPO="hps-thunderbird-templates" REPO="hps-thunderbird-templates"
BASE="https://git.hotel-park-soltau.de" BASE="https://git.hotel-park-soltau.de"
XPI="templates-reply-hotel.xpi" XPI="templates-reply-hotel.xpi"
@@ -20,7 +20,9 @@ ID="it@hotel-park-soltau.de"
cd "$(dirname "$0")" cd "$(dirname "$0")"
# --- 0. Safety: never publish a build that bundles the local defaults/token --- # --- 0. Safety: never publish a build that bundles the local defaults/token ---
if grep -qa "defaults.local.json" "$XPI"; then # Check the archive's file list (not raw bytes — the source references the
# filename as a string, which would be a false positive).
if 7z l "$XPI" | grep -q "defaults.local.json"; then
echo "ABORT: $XPI contains defaults.local.json (your Gitea token!)." >&2 echo "ABORT: $XPI contains defaults.local.json (your Gitea token!)." >&2
echo " Rebuild the .xpi without it before releasing." >&2 echo " Rebuild the .xpi without it before releasing." >&2
exit 1 exit 1

Binary file not shown.

View File

@@ -3,11 +3,23 @@
"it@hotel-park-soltau.de": { "it@hotel-park-soltau.de": {
"updates": [ "updates": [
{ {
"version": "2.3.0", "version": "2.3.1",
"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.1/templates-reply-hotel.xpi",
"update_hash": "sha256:ea22d756d6156f865453b90eced7621f34995a1a1115e1e3081b54d1f50b6a75", "update_hash": "sha256:bcfb4feade849d1dabaccaa8b932ea6d57846c82f6e9796e2c39d577ffc09744",
"applications": { "applications": {
"gecko": { "strict_min_version": "109.0" } "gecko": {
"strict_min_version": "109.0"
}
}
},
{
"version": "2.3.0",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.0/templates-reply-hotel.xpi",
"update_hash": "sha256:94ca10bb1e35cc8183c4ed2cba640ad06b8cb25273a85d643c8920cfe11158ef",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
} }
} }
] ]

View File

@@ -1,6 +1,12 @@
services: services:
web-editor: 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 image: hps-vorlagen-web-editor
container_name: hps-web-editor container_name: hps-web-editor
restart: unless-stopped restart: unless-stopped
@@ -18,3 +24,8 @@ services:
BASIC_AUTH_PASS: ${BASIC_AUTH_PASS:-} BASIC_AUTH_PASS: ${BASIC_AUTH_PASS:-}
env_file: env_file:
- .env - .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 ── --> <!-- ── App ── -->
<div class="app"> <div class="app">
<!-- Kategorie-Tabs --> <!-- Kategorie-Navigation -->
<nav class="cat-tabs" role="tablist"> <nav class="cat-tabs" role="tablist">
<button class="cat-tab is-active" data-cat="templates" role="tab"> <button class="cat-tab is-active" data-cat="templates" role="tab"><span class="ic" data-icon="file-text"></span><span>Vorlagen</span></button>
<span class="cat-ico">📄</span> Vorlagen <button class="cat-tab" data-cat="footers" role="tab"><span class="ic" data-icon="panel-bottom"></span><span>Fußzeilen</span></button>
</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="footers" role="tab"> <button class="cat-tab" data-cat="admin" role="tab"><span class="ic" data-icon="settings"></span><span>Verwaltung</span></button>
<span class="cat-ico">📜</span> Fußzeilen
</button>
<button class="cat-tab" data-cat="headers" role="tab">
<span class="cat-ico">✍️</span> Signaturen
</button>
<span class="cat-spacer"></span> <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> </nav>
<div class="workspace"> <div class="workspace">
<!-- Listen-Spalte --> <!-- Listen-Spalte -->
<aside class="listpane"> <aside class="listpane">
<div class="listpane-head"> <div class="listpane-head">
<div class="search-wrap">
<span class="ic search-ic" data-icon="search"></span>
<input type="search" id="tree-search" class="tree-search" placeholder="Suchen…" autocomplete="off" /> <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>
<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>
<div id="list-body" class="list-body"></div> <div id="list-body" class="list-body"></div>
</aside> </aside>
<!-- Editor-Spalte --> <!-- Inhalts-Spalte -->
<main class="editorpane"> <main class="editorpane">
<!-- Leerzustand --> <!-- Leerzustand -->
<div class="empty-state" id="empty-state"> <div class="empty-state" id="empty-state">
<div class="empty-illu" aria-hidden="true"> <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"> <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="M4 4h11l5 5v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z"/> <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"/>
<path d="M14 4v5h5"/><path d="M8 13h8"/><path d="M8 17h5"/>
</svg> </svg>
</div> </div>
<h2>Wähle links einen Eintrag</h2> <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> <p>Vorlage, Fußzeile oder Signatur anklicken zum Bearbeiten oder über <strong> Neu</strong> einen neuen Eintrag anlegen.</p>
</div> </div>
<!-- Editor --> <!-- Datei-Editor -->
<div class="editor-panel" id="editor-panel" hidden> <div class="editor-panel" id="editor-panel" hidden>
<div class="editor-head"> <div class="editor-head">
<div class="editor-titles"> <div class="editor-titles">
@@ -83,12 +80,15 @@
</div> </div>
<div class="editor-head-actions"> <div class="editor-head-actions">
<span id="dirty-badge" class="dirty-badge" hidden>Nicht gespeichert</span> <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-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">Löschen</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">Speichern</button> <button class="btn btn-primary" id="btn-save"><span class="ic" data-icon="save"></span><span>Speichern</span></button>
</div> </div>
</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"> <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 is-active" id="tab-visual" data-view="visual" role="tab">Bearbeiten</button>
<button class="editor-tab" id="tab-html" data-view="html" role="tab">HTML</button> <button class="editor-tab" id="tab-html" data-view="html" role="tab">HTML</button>
@@ -96,19 +96,16 @@
</div> </div>
<div class="editor-body"> <div class="editor-body">
<div class="epane" id="pane-visual"> <div class="epane" id="pane-visual"><textarea id="visual-editor"></textarea></div>
<textarea id="visual-editor"></textarea> <div class="epane" id="pane-html" hidden><textarea class="html-editor" id="html-editor" spellcheck="false" wrap="soft"></textarea></div>
</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="epane" id="pane-preview" hidden>
<div class="preview-frame-wrap"> <div class="preview-frame-wrap"><iframe class="preview-frame" id="preview-frame" title="Vorschau" sandbox=""></iframe></div>
<iframe class="preview-frame" id="preview-frame" title="Vorschau" sandbox=""></iframe>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Verwaltung -->
<div class="admin-panel" id="admin-panel" hidden></div>
</main> </main>
</div> </div>
</div> </div>
@@ -117,9 +114,7 @@
<div class="toast-stack" id="toast-stack" aria-live="polite"></div> <div class="toast-stack" id="toast-stack" aria-live="polite"></div>
<!-- ── Lade-Overlay ── --> <!-- ── Lade-Overlay ── -->
<div class="loading-overlay" id="loading-overlay" hidden> <div class="loading-overlay" id="loading-overlay" hidden><div class="spinner" aria-label="Lädt"></div></div>
<div class="spinner" aria-label="Lädt"></div>
</div>
<!-- ── Confirm-Modal ── --> <!-- ── Confirm-Modal ── -->
<div class="modal-backdrop" id="confirm-backdrop" hidden> <div class="modal-backdrop" id="confirm-backdrop" hidden>

View File

@@ -1,27 +1,32 @@
/* HPS Vorlagen & Signaturen — Web-Editor /* HPS Vorlagen & Signaturen — Web-Editor
* Modernes, ruhiges Layout für nicht-technische Anwender.
* Hotel-Park-Soltau-CI: Olivgrün (#95a322) + Anthrazit (#3c3c3b). * 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 ── */ /* ── Tokens ── */
:root { :root {
--brand: #647219; /* tiefes Oliv weiße Schrift bleibt lesbar */ --brand: #647219;
--brand-600: #556114; --brand-600: #556114;
--brand-700: #45500f; --brand-700: #45500f;
--brand-50: #f1f4e1; --brand-50: #f3f6e6;
--brand-100: #e0e7bf; --brand-100: #e2e9c5;
--accent: #95a322; /* reines Logo-Grün (Akzente) */ --accent: #95a322;
--charcoal: #3c3c3b; --charcoal: #3c3c3b;
--charcoal-2: #2f2f2e; --charcoal-2: #2c2c2b;
--bg: #eceff1; --bg: #eef1ee;
--panel: #ffffff; --panel: #ffffff;
--sidebar: #f6f8f9; --text: #222a26;
--text: #232c2e; --muted: #69736d;
--muted: #687279; --muted-2: #9aa39c;
--muted-2: #97a1a7; --border: #e6eae6;
--border: #e4e8eb; --border-strong:#d6dcd6;
--border-strong:#d2d9dd;
--danger: #d6453f; --danger: #d6453f;
--danger-50: #fdecea; --danger-50: #fdecea;
@@ -30,212 +35,128 @@
--radius: 16px; --radius: 16px;
--radius-md: 11px; --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-sm: 0 1px 2px rgba(30,40,30,.05), 0 1px 3px rgba(30,40,30,.07);
--shadow-md: 0 6px 18px rgba(20,40,45,.10), 0 2px 6px rgba(20,40,45,.06); --shadow-md: 0 8px 22px rgba(30,45,30,.09), 0 2px 6px rgba(30,45,30,.06);
--shadow-lg: 0 20px 56px rgba(20,40,45,.24); --shadow-lg: 0 24px 60px rgba(25,40,25,.24);
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Noto Sans", sans-serif; --font: 'Jakarta', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
--topbar-h: 60px; --topbar-h: 60px;
--tabs-h: 56px;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
[hidden] { display: none !important; } [hidden] { display: none !important; }
html, body { 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; }
margin: 0; height: 100%;
font-family: var(--font);
color: var(--text);
background: var(--bg);
font-size: 15px;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button { font-family: inherit; } button { font-family: inherit; }
/* ── Topbar ── */ /* Icons */
.topbar { .ic { display: inline-flex; align-items: center; justify-content: center; }
height: var(--topbar-h); .ic svg { display: block; }
display: flex; align-items: center; justify-content: space-between;
padding: 0 20px;
background: linear-gradient(100deg, var(--charcoal-2), var(--charcoal) 55%, #46463f);
color: #fff;
box-shadow: var(--shadow-md);
position: sticky; top: 0; z-index: 30;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-logo {
display: flex; align-items: center;
background: #fff; border-radius: 10px; padding: 5px 11px;
box-shadow: var(--shadow-sm);
}
.brand-logo img { height: 30px; display: block; }
.brand-divider { width: 1px; height: 26px; background: rgba(255,255,255,.22); }
.brand-title { font-size: 15px; font-weight: 600; letter-spacing: .3px; color: rgba(255,255,255,.92); }
.topbar-right { display: flex; align-items: center; gap: 14px; }
.status-pill { /* ── Topbar ── */
display: inline-flex; align-items: center; gap: 8px; .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; }
padding: 6px 13px; border-radius: 999px; .brand { display: flex; align-items: center; gap: 14px; }
font-size: 12.5px; font-weight: 600; .brand-logo { display: flex; align-items: center; background: #fff; border-radius: 10px; padding: 5px 11px; box-shadow: var(--shadow-sm); }
background: rgba(255,255,255,.12); color: #fff; .brand-logo img { height: 30px; display: block; }
border: 1px solid rgba(255,255,255,.18); .brand-divider { width: 1px; height: 26px; background: rgba(255,255,255,.2); }
max-width: 46vw; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; .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-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-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); } .status-error { background: rgba(255,138,130,.16); border-color: rgba(255,138,130,.4); }
/* ── Config-Banner ── */ /* ── Config-Banner ── */
.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; }
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; } .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; } .app { height: calc(100vh - var(--topbar-h)); display: flex; flex-direction: column; }
.cat-tabs { display: flex; align-items: center; gap: 2px; padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--border); }
.cat-tabs { .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; }
height: var(--tabs-h); .cat-tab .ic { color: var(--muted-2); transition: color .15s; }
display: flex; align-items: center; gap: 6px; .cat-tab:hover { color: var(--text); }
padding: 0 18px; .cat-tab:hover .ic { color: var(--muted); }
background: var(--panel); .cat-tab.is-active { color: var(--brand-600); border-bottom-color: var(--brand); }
border-bottom: 1px solid var(--border); .cat-tab.is-active .ic { color: var(--brand); }
}
.cat-tab {
display: inline-flex; align-items: center; gap: 8px;
border: none; background: none; cursor: pointer;
padding: 9px 16px; border-radius: 999px;
font-size: 14px; font-weight: 600; color: var(--muted);
transition: all .15s;
}
.cat-tab .cat-ico { font-size: 15px; }
.cat-tab:hover { background: var(--brand-50); color: var(--brand-600); }
.cat-tab.is-active { background: var(--brand); color: #fff; box-shadow: var(--shadow-sm); }
.cat-spacer { flex: 1; } .cat-spacer { flex: 1; }
.icon-btn { .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; }
width: 36px; height: 36px; border-radius: 9px;
border: 1px solid var(--border-strong); background: #fff; color: var(--muted);
font-size: 17px; cursor: pointer; transition: all .15s;
display: grid; place-items: center;
}
.icon-btn:hover { color: var(--brand); border-color: var(--brand-100); background: var(--brand-50); } .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; } .workspace { flex: 1; display: grid; grid-template-columns: 300px 1fr; min-height: 0; }
.listpane { background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; }
.listpane { .listpane-head { display: flex; gap: 8px; align-items: center; padding: 14px 14px 12px; border-bottom: 1px solid var(--border); }
background: var(--sidebar); .search-wrap { flex: 1; position: relative; min-width: 0; }
border-right: 1px solid var(--border); .search-ic { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--muted-2); pointer-events: none; }
display: flex; flex-direction: column; .search-ic svg { width: 16px; height: 16px; }
min-height: 0; .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; }
}
.listpane-head {
display: flex; gap: 8px; align-items: center;
padding: 14px 14px 10px;
}
.tree-search {
flex: 1; min-width: 0;
border: 1px solid var(--border-strong); background: #fff; border-radius: var(--radius-sm);
padding: 9px 12px; font-size: 13.5px; color: var(--text); outline: none;
transition: border-color .15s, box-shadow .15s;
}
.tree-search::placeholder { color: var(--muted-2); } .tree-search::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) */ /* Gruppen */
.tree-group { margin-bottom: 4px; } .tree-group { margin-bottom: 7px; }
.group-title { .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; }
width: 100%; text-align: left; .group-head:hover { background: #f1f3ef; }
display: flex; align-items: center; gap: 8px; .group-head .g-caret { color: var(--muted-2); display: inline-flex; transition: transform .18s; flex: none; }
background: none; border: none; cursor: pointer; .group-head.collapsed .g-caret { transform: rotate(-90deg); }
padding: 9px 10px; border-radius: var(--radius-sm); .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; }
font-size: 12px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--muted); .group-head .g-badge svg { width: 15px; height: 15px; }
transition: background .12s; .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-title:hover { background: #eaeef0; color: var(--brand-600); } .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-title .g-caret { font-size: 9px; color: var(--muted-2); width: 10px; transition: transform .18s; } .group-head:hover .g-add { opacity: 1; }
.group-title.collapsed .g-caret { transform: rotate(-90deg); } .group-head .g-add:hover { color: var(--brand); background: var(--brand-100); }
.group-title .group-icon { font-size: 14px; } /* Dateien hängen mit einer dezenten Führungslinie an ihrer Abteilung */
.group-title .g-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: none; letter-spacing: normal; font-size: 13px; } .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-title .g-count { font-size: 11px; font-weight: 600; color: var(--muted-2); background: #fff; border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; }
.group-files { display: flex; flex-direction: column; gap: 2px; padding: 2px 0 6px 4px; }
.group-files.collapsed { display: none; } .group-files.collapsed { display: none; }
/* Datei-Items */ /* Datei-Items */
.tree-item { .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; }
display: flex; align-items: center; gap: 10px; .tree-item:hover { background: #f1f3ef; color: var(--text); }
padding: 9px 11px; border-radius: var(--radius-sm); cursor: pointer; .tree-item .ti-icon { color: var(--muted-2); display: inline-flex; flex: none; }
font-size: 13.5px; color: #3a464c;
transition: background .12s, color .12s;
}
.tree-item:hover { background: #eaeef0; color: var(--text); }
.tree-item .ti-icon { font-size: 14px; opacity: .85; flex: none; }
.tree-item .ti-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tree-item .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 { background: var(--brand-50); color: var(--brand-700); font-weight: 600; border-left-color: var(--brand); }
.tree-item.is-active .ti-icon { opacity: 1; } .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 { /* Admin-Nav (in der Listen-Spalte) */
margin: 3px 0 2px; padding: 8px 11px; width: 100%; text-align: left; .nav-list { display: flex; flex-direction: column; gap: 3px; padding-top: 4px; }
background: none; border: 1px dashed var(--border-strong); color: var(--muted); .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; }
border-radius: var(--radius-sm); font-size: 12.5px; font-weight: 600; cursor: pointer; .nav-item .ni-icon { color: var(--muted-2); display: inline-flex; }
transition: all .15s; .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); }
.add-item:hover { color: var(--brand); border-color: var(--brand-100); background: var(--brand-50); } .nav-item.is-active .ni-icon { color: var(--brand); }
/* ── Editor-Spalte ── */
.editorpane { min-height: 0; display: flex; flex-direction: column; padding: 18px; }
/* ── 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-state { margin: auto; text-align: center; max-width: 420px; color: var(--muted); }
.empty-illu { color: var(--brand-100); margin-bottom: 6px; } .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; } .empty-state p { margin: 0; line-height: 1.6; font-size: 14px; }
.editor-panel { .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; }
background: var(--panel); border: 1px solid var(--border); .editor-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 18px 22px 15px; border-bottom: 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 { min-width: 0; }
.editor-titles h2 { .editor-titles h2 { margin: 0 0 7px; font-size: 19px; font-weight: 700; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
margin: 0 0 7px; font-size: 19px; font-weight: 680; color: var(--text); .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; }
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; } .editor-head-actions { display: flex; align-items: center; gap: 9px; flex: none; }
.dirty-badge { .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; }
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); } .dirty-badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
/* Buttons */ /* Buttons */
.btn { .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; }
border: 1px solid transparent; border-radius: var(--radius-sm); padding: 9px 16px; .btn .ic svg { width: 16px; height: 16px; }
font-size: 13.5px; font-weight: 600; cursor: pointer; transition: all .15s ease;
white-space: nowrap; line-height: 1.1;
}
.btn:disabled { opacity: .5; cursor: not-allowed; } .btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--brand); color: #fff; box-shadow: var(--shadow-sm); } .btn-primary { background: var(--brand); color: #fff; box-shadow: var(--shadow-sm); }
.btn-primary:not(:disabled):hover { background: var(--brand-600); transform: translateY(-1px); box-shadow: var(--shadow-md); } .btn-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:hover { background: #bd3a35; }
.btn-danger-ghost { background: #fff; color: var(--danger); border-color: #ecc4c2; } .btn-danger-ghost { background: #fff; color: var(--danger); border-color: #ecc4c2; }
.btn-danger-ghost:hover { background: var(--danger-50); border-color: var(--danger); } .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 */
.editor-tabs { display: flex; gap: 4px; padding: 12px 22px 0; } .editor-tabs { display: flex; gap: 2px; padding: 10px 22px 0; border-bottom: 1px solid var(--border); }
.editor-tab { .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; }
border: none; background: none; cursor: pointer; .editor-tab:hover { color: var(--text); }
padding: 9px 18px; border-radius: 9px 9px 0 0; .editor-tab.is-active { color: var(--brand-600); border-bottom-color: var(--brand); }
font-size: 13.5px; font-weight: 600; color: var(--muted);
border-bottom: 2px solid transparent; transition: all .15s;
}
.editor-tab:hover { color: var(--text); background: var(--brand-50); }
.editor-tab.is-active { color: var(--brand); border-bottom-color: var(--brand); }
/* Editor-Body */ .editor-body { flex: 1; min-height: 0; display: flex; padding: 16px 22px 22px; }
.editor-body { flex: 1; min-height: 0; display: flex; padding: 14px 22px 22px; }
.epane { flex: 1; min-height: 0; display: flex; } .epane { flex: 1; min-height: 0; display: flex; }
/* TinyMCE soll die Spalte füllen und runde Ecken haben */
#pane-visual { flex-direction: column; } #pane-visual { flex-direction: column; }
.tox.tox-tinymce { flex: 1; border-radius: var(--radius-md) !important; border-color: var(--border-strong) !important; } .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; } .tox .tox-editor-header { box-shadow: none !important; }
.html-editor { flex: 1; width: 100%; resize: none; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: #fbfcfb; padding: 16px 18px; font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: #2a3a30; outline: none; tab-size: 2; transition: border-color .15s, box-shadow .15s; }
.html-editor {
flex: 1; width: 100%; resize: none;
border: 1px solid var(--border-strong); border-radius: var(--radius-md);
background: #fbfcfd; padding: 16px 18px;
font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: #2a3a42;
outline: none; tab-size: 2; transition: border-color .15s, box-shadow .15s;
}
.html-editor:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); } .html-editor:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
.preview-frame-wrap { flex: 1; border: 1px solid var(--border); border-radius: var(--radius-md); background: #e7ebe7; overflow: hidden; min-height: 0; background-image: radial-gradient(rgba(60,60,59,.07) 1px, transparent 1px); background-size: 16px 16px; }
#pane-preview { }
.preview-frame-wrap {
flex: 1; border: 1px solid var(--border); border-radius: var(--radius-md);
background: #e7ebee; overflow: hidden; min-height: 0;
background-image: radial-gradient(rgba(60,60,59,.07) 1px, transparent 1px);
background-size: 16px 16px;
}
.preview-frame { width: 100%; height: 100%; border: none; background: transparent; } .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 ── */ /* ── Toasts ── */
.toast-stack { position: fixed; right: 20px; bottom: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 60; max-width: 380px; } .toast-stack { position: fixed; right: 20px; bottom: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 60; max-width: 380px; }
.toast { .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); }
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-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-msg { padding-top: 1px; }
.toast-success { border-left-color: var(--success); } .toast-success .toast-icon { background: var(--success); } .toast-success { border-left-color: var(--success); } .toast-success .toast-icon { background: var(--success); }
@@ -303,46 +255,38 @@ button { font-family: inherit; }
@keyframes toast-out { to { opacity: 0; transform: translateX(20px); } } @keyframes toast-out { to { opacity: 0; transform: translateX(20px); } }
/* ── Lade-Overlay ── */ /* ── 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; } .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); } } @keyframes spin { to { transform: rotate(360deg); } }
/* ── Modals ── */ /* ── 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; } } @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); } .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; } } @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 h3 { margin: 0 0 10px; font-size: 17px; font-weight: 700; color: var(--text); }
.modal p { margin: 0 0 4px; color: #44525a; line-height: 1.55; font-size: 14px; } .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; } .modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.field { margin-top: 14px; } .field { margin-top: 14px; }
.field:first-child { margin-top: 6px; } .field:first-child { margin-top: 6px; }
.field label { display: block; font-size: 12.5px; font-weight: 650; color: var(--muted); margin-bottom: 6px; } .field label { display: block; font-size: 12.5px; font-weight: 650; color: var(--muted); margin-bottom: 6px; }
.field input, .field select { .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; }
width: 100%; border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
padding: 10px 12px; font-size: 14px; color: var(--text); outline: none; background: #fff;
transition: border-color .15s, box-shadow .15s;
}
.field input:focus, .field select:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); } .field 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 { 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; } .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 ── */ /* ── Scrollbars ── */
.list-body::-webkit-scrollbar, .html-editor::-webkit-scrollbar { width: 10px; } .list-body::-webkit-scrollbar, .html-editor::-webkit-scrollbar, .editorpane::-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, .html-editor::-webkit-scrollbar-thumb, .editorpane::-webkit-scrollbar-thumb { background: #cdd6c9; border-radius: 10px; border: 2px solid transparent; background-clip: content-box; }
.list-body::-webkit-scrollbar-thumb:hover { background: #b3bfc4; background-clip: content-box; }
/* ── Responsive ── */ /* ── Responsive ── */
@media (max-width: 980px) { @media (max-width: 980px) { .workspace { grid-template-columns: 250px 1fr; } .stat-grid { grid-template-columns: repeat(2, 1fr); } }
.workspace { grid-template-columns: 250px 1fr; }
}
@media (max-width: 760px) { @media (max-width: 760px) {
.app { height: auto; } .app { height: auto; }
.workspace { grid-template-columns: 1fr; } .workspace { grid-template-columns: 1fr; }
.listpane { border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; } .listpane { border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
.editorpane { height: auto; } .editorpane { height: auto; }
.editor-panel { min-height: 72vh; } .editor-panel { min-height: 72vh; }
.status-pill { max-width: 36vw; } .map-head, .map-row { grid-template-columns: 1fr 130px 36px; }
.brand-title { display: none; } .brand-title { display: none; }
} }

View File

@@ -206,6 +206,20 @@ function localWrite(filepath, content) {
return { content: { sha: 'local', path: filepath } }; 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) ── // ── Demo backend (in-memory) ──
// A flat map of repoPath → raw content, seeded with sample data. The demo // 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 // versions of the primitives mimic the Gitea API response shapes so the rest
@@ -324,6 +338,29 @@ if (DEMO) {
console.log('[DEMO] In-Memory-Demodaten geladen — keine echte Gitea-Verbindung nötig.'); 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 ── // ── Express app ──
const app = express(); const app = express();
@@ -410,39 +447,38 @@ app.get('/api/files', wrap(async (req, res) => {
res.json({ folder, files }); 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) => { app.get('/api/tree', wrap(async (_req, res) => {
const top = await listDir(''); const files = await listAllFiles();
const departments = top const special = [SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'];
.filter(e => e.type === 'dir' const dirOf = (p) => { const i = p.lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); };
&& ![SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(e.name) const baseOf = (p) => p.slice(p.lastIndexOf('/') + 1);
&& !e.name.startsWith('.'))
.map(e => e.name)
.sort((a, b) => a.localeCompare(b, 'de'));
const mapFiles = (entries) => entries // .html-Dateien direkt in einem bestimmten Ordner
.filter(f => f.type === 'file' && f.name.endsWith('.html')) const htmlIn = (folder) => files
.map(f => ({ name: f.name, path: f.path, sha: f.sha })) .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')); .sort((a, b) => a.name.localeCompare(b.name, 'de'));
// Templates: shared + each department (personal user folders listed separately). // Abteilungen = oberste Ordner außer den Spezialordnern (auch leere, via .gitkeep)
const templateFolders = [SHARED_FOLDER, ...departments]; const deptSet = new Set();
const templates = {}; for (const f of files) {
await Promise.all(templateFolders.map(async (folder) => { const seg = f.path.split('/')[0];
templates[folder] = mapFiles(await listDir(folder)); 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 = {}; const users = {};
await Promise.all(userEntries for (const f of files) {
.filter(e => e.type === 'dir') const parts = f.path.split('/');
.map(async (e) => { users[e.name] = mapFiles(await listDir(e.path)); })); 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)); res.json({ departments, templates, users, footers: htmlIn(SIG_FOOTERS), headers: htmlIn(SIG_HEADERS) });
const headers = mapFiles(await listDir(SIG_HEADERS));
res.json({ departments, templates, users, footers, headers });
})); }));
// Read a single file. Missing file → exists:false, empty content (editor can create it). // Read a single file. Missing file → exists:false, empty content (editor can create it).
@@ -491,6 +527,40 @@ app.put('/api/abteilungen', wrap(async (req, res) => {
res.json({ success: true, ...result }); res.json({ success: true, ...result });
})); }));
// Read/write the Schlagwörter (Thunderbird-Tags) config.
app.get('/api/schlagwoerter', wrap(async (_req, res) => {
const data = await getFile(`${CONFIG_FOLDER}/schlagwoerter.json`);
let tags = [];
if (data) { try { tags = JSON.parse(fromBase64(data.content)); } catch (_) {} }
if (!Array.isArray(tags)) tags = [];
res.json({ tags, exists: !!data });
}));
app.put('/api/schlagwoerter', wrap(async (req, res) => {
const tags = req.body?.tags;
if (!Array.isArray(tags)) return res.status(400).json({ error: 'Ungültige Schlagwörter' });
const json = JSON.stringify(tags, null, 2);
const result = await saveFile(`${CONFIG_FOLDER}/schlagwoerter.json`, json, 'schlagwoerter.json aktualisiert (Web-Editor)');
res.json({ success: true, ...result });
}));
// Delete a whole department folder (all its files). Destructive guarded.
app.delete('/api/departments', wrap(async (req, res) => {
const name = (req.body?.name || '').trim();
if (!name) return res.status(400).json({ error: 'Kein Name angegeben' });
if ([SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(name) || name.includes('/') || name.includes('..')) {
return res.status(400).json({ error: 'Geschützter oder ungültiger Ordner' });
}
const entries = await listDir(name);
let deleted = 0;
for (const f of entries) {
if (f.type !== 'file') continue;
const fd = await getFile(f.path);
if (fd) { await deleteFile(f.path, fd.sha, `Abteilung "${name}" gelöscht (Web-Editor)`); deleted++; }
}
res.json({ success: true, deleted });
}));
// TinyMCE (selbst gehostet, offline-tauglich) direkt aus node_modules ausliefern. // TinyMCE (selbst gehostet, offline-tauglich) direkt aus node_modules ausliefern.
app.use('/vendor/tinymce', express.static(path.join(__dirname, 'node_modules', 'tinymce'))); app.use('/vendor/tinymce', express.static(path.join(__dirname, 'node_modules', 'tinymce')));