Info-Menü mit Benutzerhandbuch/Disclaimer-Dialog ergaenzen

Neues (i)-Symbol in der Titelleiste öffnet ein Dropdown mit den
Einträgen "Benutzerhandbuch" und "Disclaimer". Klick auf einen Eintrag
zeigt den jeweiligen Text als modalen, scrollbaren Dialog (marked
wandelt die .md-Dateien zur Laufzeit im Main-Prozess in HTML um).
Beide Dateien sind jetzt auch in build.files gelistet, damit sie im
gepackten Release verfügbar sind.

Claude-Session: https://claude.ai/code/session_01VeqoaAZDVSCU4QBtiYDC5C
This commit is contained in:
Winkler, Stefan
2026-09-01 11:43:03 +02:00
parent dd560a36cb
commit bb7089f560
11 changed files with 433 additions and 0 deletions
+15
View File
@@ -8,6 +8,9 @@
"name": "claude-live-dashboard", "name": "claude-live-dashboard",
"version": "1.2.1", "version": "1.2.1",
"license": "MIT", "license": "MIT",
"dependencies": {
"marked": "^18.0.11"
},
"devDependencies": { "devDependencies": {
"electron": "^43.2.0", "electron": "^43.2.0",
"electron-builder": "^25.1.8" "electron-builder": "^25.1.8"
@@ -3048,6 +3051,18 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/marked": {
"version": "18.0.11",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+5
View File
@@ -35,6 +35,8 @@
"src/**/*", "src/**/*",
"assets/**/*", "assets/**/*",
"config.json", "config.json",
"BENUTZERHANDBUCH.md",
"DISCLAIMER.md",
"!**/*.map" "!**/*.map"
], ],
"win": { "win": {
@@ -97,5 +99,8 @@
}, },
"artifactName": "${productName}-${version}-${arch}.${ext}" "artifactName": "${productName}-${version}-${arch}.${ext}"
} }
},
"dependencies": {
"marked": "^18.0.11"
} }
} }
+14
View File
@@ -0,0 +1,14 @@
'use strict';
/** Brücke für den Benutzerhandbuch-/Disclaimer-Dialog. Bewusst getrennt vom Widget-Preload. */
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('infoBridge', {
/** Registriert den Empfänger für Titel/Inhalt, gesendet bei jedem Öffnen. */
onContent(callback) {
ipcRenderer.on('info:content', (_event, data) => callback(data));
},
/** Schließt das Fenster. */
close: () => ipcRenderer.send('info:close'),
});
+27
View File
@@ -0,0 +1,27 @@
'use strict';
/**
* Benutzerhandbuch-/Disclaimer-Dialog. Zeigt nur an, was der Main-Prozess
* schickt — Datei lesen und Markdown→HTML wandeln passiert dort (siehe
* `openInfoDoc()` in `src/main/index.js`).
*/
const $ = (id) => document.getElementById(id);
const el = {
title: $('title'),
content: $('content'),
close: $('btn-close'),
};
window.infoBridge.onContent(({ title, html }) => {
document.title = title;
el.title.textContent = title;
el.content.innerHTML = html;
el.content.scrollTop = 0;
});
el.close.addEventListener('click', () => window.infoBridge.close());
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') window.infoBridge.close();
});
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self';" />
<title>Info</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="titlebar">
<span class="title" id="title">Info</span>
<span class="spacer"></span>
<button class="icon-btn" id="btn-close" title="Schließen" aria-label="Schließen"></button>
</header>
<main class="content" id="content"></main>
<script src="app.js"></script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
/* Benutzerhandbuch-/Disclaimer-Fenster — gleiche Farbwelt wie das Widget,
plus Basis-Typografie für aus Markdown gerendertes HTML. */
:root {
color-scheme: dark;
--surface: #1e1e22;
--surface-raised: rgba(255, 255, 255, 0.05);
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--hairline: rgba(255, 255, 255, 0.1);
--accent: #3987e5;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "Cascadia Mono", Consolas, monospace;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
background: var(--surface);
color: var(--text-primary);
font-family: var(--font);
font-size: 13px;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
}
/* ── Titelleiste ─────────────────────────────────────────────────────── */
.titlebar {
-webkit-app-region: drag;
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px 9px 14px;
border-bottom: 1px solid var(--hairline);
flex: 0 0 auto;
}
.title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.spacer {
flex: 1;
}
.icon-btn {
-webkit-app-region: no-drag;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 22px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
}
.icon-btn:hover {
background: var(--surface-raised);
color: var(--text-primary);
}
/* ── Inhalt (aus Markdown gerendertes HTML) ─────────────────────────────── */
.content {
flex: 1 1 auto;
overflow-y: auto;
padding: 16px 20px 22px;
line-height: 1.5;
color: var(--text-secondary);
}
.content::-webkit-scrollbar {
width: 8px;
}
.content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 4px;
}
.content h1,
.content h2,
.content h3 {
color: var(--text-primary);
line-height: 1.3;
}
.content h1 {
margin: 0 0 12px;
font-size: 18px;
padding-bottom: 8px;
border-bottom: 1px solid var(--hairline);
}
.content h2 {
margin: 24px 0 8px;
font-size: 14px;
}
.content h3 {
margin: 18px 0 6px;
font-size: 13px;
}
.content p {
margin: 0 0 12px;
}
.content ul,
.content ol {
margin: 0 0 12px;
padding-left: 22px;
}
.content li {
margin-bottom: 4px;
}
.content a {
color: var(--accent);
}
.content code {
font-family: var(--font-mono);
font-size: 12px;
background: var(--surface-raised);
border-radius: 3px;
padding: 1px 4px;
}
.content pre {
background: var(--surface-raised);
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
}
.content pre code {
background: none;
padding: 0;
}
.content hr {
border: 0;
border-top: 1px solid var(--hairline);
margin: 20px 0;
}
.content table {
border-collapse: collapse;
width: 100%;
margin: 0 0 16px;
font-size: 12px;
}
.content th,
.content td {
border: 1px solid var(--hairline);
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
.content th {
color: var(--text-primary);
background: var(--surface-raised);
}
+85
View File
@@ -8,6 +8,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { app, BrowserWindow, Tray, Menu, ipcMain, screen, nativeImage, shell, Notification } = require('electron'); const { app, BrowserWindow, Tray, Menu, ipcMain, screen, nativeImage, shell, Notification } = require('electron');
const { marked } = require('marked');
const { Store, loadConfig } = require('./store'); const { Store, loadConfig } = require('./store');
const { Collector } = require('./collector'); const { Collector } = require('./collector');
@@ -30,6 +31,16 @@ const SETTINGS_HEIGHT = 660;
const ERRORS_WIDTH = 420; const ERRORS_WIDTH = 420;
const ERRORS_HEIGHT = 480; const ERRORS_HEIGHT = 480;
const INFO_WIDTH = 560;
const INFO_HEIGHT = 680;
/** Feste Whitelist — der Renderer kann nur diese beiden Schlüssel anfragen,
* nie einen beliebigen Dateipfad. */
const INFO_DOCS = {
manual: { title: 'Benutzerhandbuch', file: path.join(ROOT, 'BENUTZERHANDBUCH.md') },
disclaimer: { title: 'Disclaimer', file: path.join(ROOT, 'DISCLAIMER.md') },
};
/** Wartezeit vor der einmaligen Update-Prüfung — verzögert, damit sie den /** Wartezeit vor der einmaligen Update-Prüfung — verzögert, damit sie den
* Start nicht verlangsamt und nicht mit dem ersten Poll konkurriert. */ * Start nicht verlangsamt und nicht mit dem ersten Poll konkurriert. */
const UPDATE_CHECK_DELAY_MS = 60 * 1000; const UPDATE_CHECK_DELAY_MS = 60 * 1000;
@@ -37,6 +48,7 @@ const UPDATE_CHECK_DELAY_MS = 60 * 1000;
let win = null; let win = null;
let settingsWin = null; let settingsWin = null;
let errorsWin = null; let errorsWin = null;
let infoWin = null;
let tray = null; let tray = null;
let collector = null; let collector = null;
let store = null; let store = null;
@@ -364,6 +376,76 @@ function openErrorLog() {
}); });
} }
/** Escaped Text für die Einbettung in HTML — nur für die Fehlermeldung unten. */
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* Benutzerhandbuch-/Disclaimer-Dialog — ein Fenster für beide Dokumente
* (Singleton wie oben), Titel und Inhalt werden bei jedem Öffnen aktualisiert.
* Modal relativ zum Hauptfenster, ausschließlich mit Schließen-Button.
*/
function openInfoDoc(kind) {
const doc = INFO_DOCS[kind];
if (!doc) return;
let html;
try {
const raw = fs.readFileSync(doc.file, 'utf8');
html = marked.parse(raw);
} catch (err) {
html = `<p>Datei konnte nicht gelesen werden: ${escapeHtml(err.message)}</p>`;
}
const send = () => infoWin.webContents.send('info:content', { title: doc.title, html });
if (infoWin && !infoWin.isDestroyed()) {
infoWin.show();
infoWin.focus();
send();
return;
}
const target = win && !win.isDestroyed()
? screen.getDisplayMatching(win.getBounds())
: screen.getPrimaryDisplay();
const area = target.workArea;
infoWin = new BrowserWindow({
width: INFO_WIDTH,
height: INFO_HEIGHT,
x: Math.round(area.x + (area.width - INFO_WIDTH) / 2),
y: Math.round(area.y + Math.max(0, (area.height - INFO_HEIGHT) / 2)),
minWidth: 420,
minHeight: 360,
frame: false,
resizable: true,
skipTaskbar: false,
modal: true,
parent: win && !win.isDestroyed() ? win : undefined,
alwaysOnTop: true,
show: false,
backgroundColor: '#1e1e22',
icon: path.join(ROOT, 'assets', 'icon.png'),
webPreferences: {
preload: path.join(__dirname, '..', 'info-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
infoWin.loadFile(path.join(__dirname, '..', 'info', 'index.html'));
infoWin.webContents.once('did-finish-load', send);
infoWin.once('ready-to-show', () => infoWin.show());
infoWin.on('closed', () => {
infoWin = null;
});
}
/** /**
* Übernimmt geänderte Einstellungen ohne Neustart und schreibt sie in die * Übernimmt geänderte Einstellungen ohne Neustart und schreibt sie in die
* Datei. Unbekannte Felder und die `_`-Kommentare bleiben erhalten, weil die * Datei. Unbekannte Felder und die `_`-Kommentare bleiben erhalten, weil die
@@ -419,6 +501,9 @@ function registerIpc() {
const info = collector?.state?.update; const info = collector?.state?.update;
if (info?.available && info.htmlUrl) shell.openExternal(info.htmlUrl); if (info?.available && info.htmlUrl) shell.openExternal(info.htmlUrl);
}); });
ipcMain.on('open-manual', () => openInfoDoc('manual'));
ipcMain.on('open-disclaimer', () => openInfoDoc('disclaimer'));
ipcMain.on('info:close', () => infoWin && infoWin.close());
ipcMain.handle('settings:load', () => ({ ipcMain.handle('settings:load', () => ({
config, config,
+4
View File
@@ -26,4 +26,8 @@ contextBridge.exposeInMainWorld('dashboard', {
openErrorLog: () => ipcRenderer.send('open-error-log'), openErrorLog: () => ipcRenderer.send('open-error-log'),
/** Öffnet die Release-Seite eines gefundenen neuen Updates im Browser. */ /** Öffnet die Release-Seite eines gefundenen neuen Updates im Browser. */
openReleasePage: () => ipcRenderer.send('open-release-page'), openReleasePage: () => ipcRenderer.send('open-release-page'),
/** Öffnet den Benutzerhandbuch-Dialog. */
openManual: () => ipcRenderer.send('open-manual'),
/** Öffnet den Disclaimer-Dialog. */
openDisclaimer: () => ipcRenderer.send('open-disclaimer'),
}); });
+27
View File
@@ -48,6 +48,11 @@ const el = {
staleBadge: $('stale-badge'), staleBadge: $('stale-badge'),
btnEndpointStatus: $('btn-endpoint-status'), btnEndpointStatus: $('btn-endpoint-status'),
btnUpdateAvailable: $('btn-update-available'), btnUpdateAvailable: $('btn-update-available'),
infoWrap: $('info-wrap'),
btnInfo: $('btn-info'),
infoMenu: $('info-menu'),
infoMenuManual: $('info-menu-manual'),
infoMenuDisclaimer: $('info-menu-disclaimer'),
btnHide: $('btn-hide'), btnHide: $('btn-hide'),
btnSettings: $('btn-settings'), btnSettings: $('btn-settings'),
btnExpand: $('btn-expand'), btnExpand: $('btn-expand'),
@@ -475,6 +480,28 @@ el.btnSettings.addEventListener('click', () => window.dashboard.openSettings());
el.btnEndpointStatus.addEventListener('click', () => window.dashboard.openErrorLog()); el.btnEndpointStatus.addEventListener('click', () => window.dashboard.openErrorLog());
el.btnUpdateAvailable.addEventListener('click', () => window.dashboard.openReleasePage()); el.btnUpdateAvailable.addEventListener('click', () => window.dashboard.openReleasePage());
el.btnInfo.addEventListener('click', () => {
el.infoMenu.hidden = !el.infoMenu.hidden;
});
document.addEventListener('click', (ev) => {
if (!el.infoMenu.hidden && !el.infoWrap.contains(ev.target)) {
el.infoMenu.hidden = true;
}
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape' && !el.infoMenu.hidden) {
el.infoMenu.hidden = true;
}
});
el.infoMenuManual.addEventListener('click', () => {
el.infoMenu.hidden = true;
window.dashboard.openManual();
});
el.infoMenuDisclaimer.addEventListener('click', () => {
el.infoMenu.hidden = true;
window.dashboard.openDisclaimer();
});
window.dashboard.onState(render); window.dashboard.onState(render);
// Reihenfolge: erst die Ansicht auf die wiederhergestellte Fenstergröße // Reihenfolge: erst die Ansicht auf die wiederhergestellte Fenstergröße
+7
View File
@@ -30,6 +30,13 @@
/> />
</svg> </svg>
</button> </button>
<div class="info-wrap" id="info-wrap">
<button class="icon-btn" id="btn-info" title="Info" aria-label="Info"></button>
<div class="info-menu" id="info-menu" hidden>
<button class="info-menu-item" id="info-menu-manual">Benutzerhandbuch</button>
<button class="info-menu-item" id="info-menu-disclaimer">Disclaimer</button>
</div>
</div>
<button class="icon-btn" id="btn-hide" title="Ausblenden (über das Tray-Symbol zurückholen)"></button> <button class="icon-btn" id="btn-hide" title="Ausblenden (über das Tray-Symbol zurückholen)"></button>
</header> </header>
+41
View File
@@ -157,6 +157,47 @@ body {
color: var(--status-update); color: var(--status-update);
} }
.info-wrap {
position: relative;
display: flex;
align-items: center;
}
.info-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 10;
display: flex;
flex-direction: column;
min-width: 148px;
padding: 4px;
background: var(--surface-solid);
border: 1px solid var(--hairline);
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
}
.info-menu-item {
-webkit-app-region: no-drag;
display: block;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--text-secondary);
font-family: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.info-menu-item:hover {
background: var(--surface-raised);
color: var(--text-primary);
}
/* ── Meter ───────────────────────────────────────────────────────────── */ /* ── Meter ───────────────────────────────────────────────────────────── */
.meters { .meters {