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>
This commit is contained in:
Kendrick Bollens
2026-06-18 09:16:36 +02:00
parent 8130269f8f
commit 113bc1bc20
9 changed files with 857 additions and 1103 deletions

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,38 @@ 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; }
/* ── 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

@@ -206,6 +206,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 +338,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();
@@ -410,39 +447,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, '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).
@@ -491,6 +527,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, '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')));