WebUI-Kopfleiste: Aufklappmenues statt elf Tabs
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s

Umsetzung von #541, folgt auf #532.

Elf Tabs nebeneinander waren nicht mehr zu ueberblicken. Die Leiste hat jetzt
vier Eintraege, gruppiert nach dem, was man dort tut:

- Dashboard
- Analytics: Analytics, Accounting, Supervisor
- Module: Moduleinstellungen (die allgemeine Modulansicht), danach alle Module
- Einstellungen: Einstellungen, Jobs, Konten, Terminal / Logs

Supervisor und Accounting stehen unter Analytics und zusaetzlich in der
Modulliste, weil dort alle Module stehen sollen. Hervorgehoben wird das
Oberelement nur an ihrem Hauptplatz (Analytics); der zweite Zugang traegt
data-secondary. Sonst leuchteten bei geoeffnetem Supervisor zwei Menues.

switchView() findet den aktiven Eintrag jetzt ueber data-view statt ueber eine
Teilzeichenkette im onclick-Attribut. Das alte Verfahren hing am Zufall der
Namen: Eine Ansicht, deren Name in einer anderen enthalten ist, haette beide
markiert.

Die Modulliste zeigt den Zustand aus /api/modules: Ein deaktiviertes Modul
erscheint ausgegraut mit "deaktiviert" und ist nicht anklickbar, statt in eine
leere Ansicht zu fuehren. Ein Modul, das der Server fuehrt, fuer das es im
WebUI aber keine Ansicht gibt, wird mit "keine Ansicht" gelistet.

Bedienbar mit Maus und Tastatur: Klick ausserhalb und Esc schliessen (Esc gibt
den Fokus ans Oberelement zurueck), Pfeil nach unten oeffnet und springt in das
Menue, Pfeiltasten laufen um und ueberspringen deaktivierte Eintraege, Tab
verlaesst das Menue. aria-haspopup, aria-expanded und aria-current sind gesetzt.

Unter 760 px haengen die Menues an der ganzen Leiste statt am Eintrag. Bei
400 px ragte "Module" sonst 35 px ueber den rechten Rand. Ein Nachmessen per
Skript taugt dagegen nicht: In der Mobilansicht waechst der Layout-Viewport mit
dem Ueberstand mit, und die Messung meldet genau dann "passt".

Die Modulansicht heisst jetzt wie ihr Menueeintrag "Moduleinstellungen".

Geprueft auf der Linux-Test-VM im Browser: Alle 12 Eintraege schalten die
richtige Ansicht und heben das richtige Oberelement hervor, das Menue schliesst
danach; Tastaturbedienung, deaktiviertes und unbekanntes Modul (simuliert)
verhalten sich wie beschrieben. Vermessen bei 400, 700, 768 und 1024 px: kein
Menue ragt aus dem Fenster, die Seite scrollt nie waagerecht. 556 von 556 Tests
bestanden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-11 10:33:41 +02:00
co-authored by Claude Opus 5
parent 4c556be57d
commit 2c0ab79dcd
3 changed files with 331 additions and 17 deletions
+141
View File
@@ -94,6 +94,147 @@ body {
background: var(--accent-soft);
}
/* Aufklappmenues der Kopfleiste (#541) */
.nav-dropdown {
position: relative;
}
.nav-dropdown-toggle {
display: inline-flex;
align-items: center;
gap: 7px;
}
/* Winkel statt Zeichen: "▾" sitzt je nach Schrift unterschiedlich hoch. */
.nav-dropdown-toggle::after {
content: "";
width: 6px;
height: 6px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: translateY(-2px) rotate(45deg);
transition: transform 0.15s;
}
.nav-dropdown-toggle[aria-expanded="true"]::after {
transform: translateY(1px) rotate(225deg);
}
.nav-dropdown-toggle[aria-expanded="true"]:not(.active) {
color: var(--text);
background: var(--surface-alt);
}
.nav-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 220px;
max-width: calc(100vw - 16px);
display: flex;
flex-direction: column;
gap: 1px;
padding: 6px;
background: var(--card);
border: 1px solid var(--divider);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
z-index: 200;
}
/* Ohne diese Regel gewinnt display:flex gegen das hidden-Attribut. */
.nav-menu[hidden] {
display: none;
}
.nav-menu-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.nav-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
padding: 8px 10px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text);
font: inherit;
font-size: 14px;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.nav-menu-item:hover:not(:disabled) {
background: var(--surface-alt);
}
.nav-menu-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.nav-menu-item.active {
color: var(--accent);
background: var(--accent-soft);
}
.nav-menu-item:disabled {
color: var(--muted);
cursor: not-allowed;
opacity: 0.7;
}
.nav-menu-hint {
font-size: 11px;
color: var(--muted);
}
.nav-menu-sep {
height: 1px;
margin: 5px 4px;
background: var(--border);
}
.nav-menu-label {
padding: 4px 10px 2px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
/* Schmale Fenster: Ein Menue, das am Oberelement klebt, ragt dort ueber den rechten Rand -
bei 400 px stand "Module" 35 px heraus. Nachmessen per Skript taugt als Abhilfe nicht:
Auf Mobilgeraeten waechst der Layout-Viewport mit dem Ueberstand mit, und die Messung meldet
genau dann "passt". Deshalb haengen die Menues hier an der ganzen Leiste statt am einzelnen
Eintrag. Ab 760 px passt die angeheftete Form: gemessen bei 768 px endet das aeusserste
Menue bei 701 px. */
@media (max-width: 760px) {
.header-nav {
position: relative;
}
.nav-dropdown {
position: static;
}
.nav-menu {
left: 0;
right: 0;
top: calc(100% + 2px);
min-width: 0;
max-width: none;
}
}
.header-actions {
display: flex;
align-items: center;
+48 -13
View File
@@ -14,18 +14,53 @@
<span style="font-size: 11px; padding: 2px 6px; background: var(--accent-soft); color: var(--accent); border-radius: 4px; font-weight: normal;">WebGUI</span>
</div>
<nav class="header-nav">
<button class="nav-link active" onclick="switchView('dashboard')">Dashboard</button>
<button class="nav-link" onclick="switchView('analytics')">Analytics</button>
<button class="nav-link" onclick="switchView('copytrading')">CopyTrading</button>
<button class="nav-link" onclick="switchView('resolutionfarming')">Resolution Farming</button>
<button class="nav-link" onclick="switchView('supervisor')">Supervisor</button>
<button class="nav-link" onclick="switchView('accounting')">Accounting</button>
<button class="nav-link" onclick="switchView('accounts')">Konten</button>
<button class="nav-link" onclick="switchView('jobs')">Jobs</button>
<button class="nav-link" onclick="switchView('logs')">Terminal / Logs</button>
<button class="nav-link" onclick="switchView('modules')">Module</button>
<button class="nav-link" onclick="switchView('settings')">Einstellungen</button>
<!-- Kopfleiste (#541): Elf Tabs nebeneinander waren nicht mehr zu ueberblicken. Gruppiert
wird nach dem, was man dort tut - auswerten, Module bedienen, den Betrieb einrichten.
data-view verbindet einen Eintrag mit seiner Ansicht; switchView() hebt darueber den
aktiven Eintrag und das Oberelement seines Menues hervor. data-module verbindet ihn mit
einem Modul, damit ein deaktiviertes Modul auch im Menue als deaktiviert erscheint.
data-secondary markiert einen zweiten Zugang: Supervisor und Accounting stehen unter
Analytics und zusaetzlich in der Modulliste, hervorgehoben wird nur ihr Hauptplatz. -->
<nav class="header-nav" aria-label="Hauptnavigation">
<button class="nav-link active" data-view="dashboard" aria-current="page" onclick="switchView('dashboard')">Dashboard</button>
<div class="nav-dropdown">
<button class="nav-link nav-dropdown-toggle" aria-haspopup="menu" aria-expanded="false" onclick="toggleNavDropdown(this)">Analytics</button>
<div class="nav-menu" role="menu" hidden>
<button class="nav-menu-item" role="menuitem" data-view="analytics" onclick="switchView('analytics')"><span>Analytics</span></button>
<button class="nav-menu-item" role="menuitem" data-view="accounting" data-module="Accounting" onclick="switchView('accounting')"><span>Accounting</span><span class="nav-menu-hint"></span></button>
<button class="nav-menu-item" role="menuitem" data-view="supervisor" data-module="Supervisor" onclick="switchView('supervisor')"><span>Supervisor</span><span class="nav-menu-hint"></span></button>
</div>
</div>
<div class="nav-dropdown">
<button class="nav-link nav-dropdown-toggle" aria-haspopup="menu" aria-expanded="false" onclick="toggleNavDropdown(this)">Module</button>
<div class="nav-menu" role="menu" hidden>
<button class="nav-menu-item" role="menuitem" data-view="modules" onclick="switchView('modules')"><span>Moduleinstellungen</span></button>
<div class="nav-menu-sep" role="separator"></div>
<div class="nav-menu-label" aria-hidden="true">Module</div>
<!-- Die Liste ergaenzt refreshNavModules() um Module, die der Server kennt,
fuer die es im WebUI aber noch keine Ansicht gibt. -->
<div class="nav-menu-group" id="navModuleList" role="none">
<button class="nav-menu-item" role="menuitem" data-view="copytrading" data-module="CopyTrading" onclick="switchView('copytrading')"><span>CopyTrading</span><span class="nav-menu-hint"></span></button>
<button class="nav-menu-item" role="menuitem" data-view="resolutionfarming" data-module="ResolutionFarming" onclick="switchView('resolutionfarming')"><span>Resolution Farming</span><span class="nav-menu-hint"></span></button>
<button class="nav-menu-item" role="menuitem" data-view="supervisor" data-module="Supervisor" data-secondary onclick="switchView('supervisor')"><span>Supervisor</span><span class="nav-menu-hint"></span></button>
<button class="nav-menu-item" role="menuitem" data-view="accounting" data-module="Accounting" data-secondary onclick="switchView('accounting')"><span>Accounting</span><span class="nav-menu-hint"></span></button>
</div>
</div>
</div>
<div class="nav-dropdown">
<button class="nav-link nav-dropdown-toggle" aria-haspopup="menu" aria-expanded="false" onclick="toggleNavDropdown(this)">Einstellungen</button>
<div class="nav-menu" role="menu" hidden>
<button class="nav-menu-item" role="menuitem" data-view="settings" onclick="switchView('settings')"><span>Einstellungen</span></button>
<button class="nav-menu-item" role="menuitem" data-view="jobs" onclick="switchView('jobs')"><span>Jobs</span></button>
<button class="nav-menu-item" role="menuitem" data-view="accounts" onclick="switchView('accounts')"><span>Konten</span></button>
<div class="nav-menu-sep" role="separator"></div>
<button class="nav-menu-item" role="menuitem" data-view="logs" onclick="switchView('logs')"><span>Terminal / Logs</span></button>
</div>
</div>
</nav>
<div class="header-actions">
@@ -1108,7 +1143,7 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
<!-- VIEW: MODULES -->
<section id="view-modules" class="view-panel">
<h2 style="margin-bottom: 16px; font-size: 18px;">Strategie- & Analyse-Module</h2>
<h2 style="margin-bottom: 16px; font-size: 18px;">Moduleinstellungen</h2>
<div class="grid-2" id="modulesContainer">
<div class="card"><div class="card-title">Lade Module …</div></div>
</div>
+142 -4
View File
@@ -6,6 +6,8 @@ let allLogs = [];
// Init on DOM ready
document.addEventListener('DOMContentLoaded', () => {
initNavMenus();
refreshNavModules();
initSseLogs();
refreshDashboard();
loadDashboardAccounts();
@@ -18,12 +20,10 @@ document.addEventListener('DOMContentLoaded', () => {
// Navigation
function switchView(viewName) {
currentView = viewName;
document.querySelectorAll('.nav-link').forEach(btn => btn.classList.remove('active'));
markActiveNav(viewName);
closeNavDropdowns();
document.querySelectorAll('.view-panel').forEach(p => p.classList.remove('active'));
const activeBtn = Array.from(document.querySelectorAll('.nav-link')).find(b => b.getAttribute('onclick')?.includes(viewName));
if (activeBtn) activeBtn.classList.add('active');
const panel = document.getElementById(`view-${viewName}`);
if (panel) panel.classList.add('active');
@@ -49,6 +49,143 @@ function switchView(viewName) {
}
}
// ==================== Kopfleiste: Aufklappmenues (#541) ====================
/**
* Hebt den Eintrag der aktiven Ansicht hervor und dazu das Oberelement seines Menues -
* sonst sieht man bei geschlossenem Menue nicht, wo man ist. Supervisor und Accounting
* stehen zweimal im Menue; das Oberelement leuchtet nur an ihrem Hauptplatz (Analytics),
* der zweite Zugang in der Modulliste traegt data-secondary.
*/
function markActiveNav(viewName) {
document.querySelectorAll('.header-nav [data-view]').forEach(el => {
const on = el.dataset.view === viewName;
el.classList.toggle('active', on);
if (on) el.setAttribute('aria-current', 'page');
else el.removeAttribute('aria-current');
});
document.querySelectorAll('.nav-dropdown').forEach(dd => {
const home = dd.querySelector(`[data-view="${viewName}"]:not([data-secondary])`);
dd.querySelector('.nav-dropdown-toggle').classList.toggle('active', !!home);
});
}
function toggleNavDropdown(toggle) {
const menu = toggle.closest('.nav-dropdown').querySelector('.nav-menu');
const willOpen = menu.hidden;
closeNavDropdowns();
if (willOpen) {
menu.hidden = false;
toggle.setAttribute('aria-expanded', 'true');
}
}
function closeNavDropdowns() {
document.querySelectorAll('.nav-dropdown').forEach(dd => {
dd.querySelector('.nav-menu').hidden = true;
dd.querySelector('.nav-dropdown-toggle').setAttribute('aria-expanded', 'false');
});
}
function navMenuItems(menu) {
return Array.from(menu.querySelectorAll('.nav-menu-item')).filter(b => !b.disabled);
}
function initNavMenus() {
// Klick ausserhalb schliesst. Der Klick auf ein Oberelement selbst laeuft ueber
// toggleNavDropdown und kommt hier mit closest('.nav-dropdown') an - bleibt also offen.
document.addEventListener('click', e => {
if (!e.target.closest('.nav-dropdown')) closeNavDropdowns();
});
document.querySelector('.header-nav')?.addEventListener('keydown', e => {
const dd = e.target.closest('.nav-dropdown');
if (!dd) return;
const toggle = dd.querySelector('.nav-dropdown-toggle');
const menu = dd.querySelector('.nav-menu');
if (e.key === 'Escape' && !menu.hidden) {
e.preventDefault();
closeNavDropdowns();
toggle.focus();
return;
}
// Pfeil nach unten auf dem Oberelement oeffnet und springt in das Menue.
if (e.target === toggle && e.key === 'ArrowDown') {
e.preventDefault();
if (menu.hidden) toggleNavDropdown(toggle);
navMenuItems(menu)[0]?.focus();
return;
}
if (menu.contains(e.target) && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) {
e.preventDefault();
const items = navMenuItems(menu);
const i = items.indexOf(e.target);
const next = e.key === 'Home' ? 0
: e.key === 'End' ? items.length - 1
: e.key === 'ArrowDown' ? (i + 1) % items.length
: (i - 1 + items.length) % items.length;
items[next]?.focus();
return;
}
// Tab verlaesst das Menue, statt durch alle Eintraege zu wandern.
if (e.key === 'Tab' && !menu.hidden) closeNavDropdowns();
});
}
/**
* Zeigt deaktivierte Module im Menue als deaktiviert und ergaenzt die Modulliste um
* Module, die der Server fuehrt, fuer die es im WebUI aber noch keine Ansicht gibt.
* Ohne Uebergabe wird die Liste selbst geholt; loadModules() reicht ihre mit durch.
*/
async function refreshNavModules(modules) {
if (!modules) {
try {
const res = await fetch('/api/modules');
if (!res.ok) return;
modules = await res.json();
} catch (e) {
return;
}
}
const isActive = new Map(modules.map(m => [m.name.toLowerCase(), m.isActive]));
document.querySelectorAll('.header-nav [data-module]').forEach(item => {
const off = isActive.get(item.dataset.module.toLowerCase()) === false;
item.disabled = off;
item.title = off ? 'Modul ist deaktiviert – siehe Moduleinstellungen' : '';
const hint = item.querySelector('.nav-menu-hint');
if (hint) hint.textContent = off ? 'deaktiviert' : '';
});
const list = document.getElementById('navModuleList');
if (!list) return;
list.querySelectorAll('[data-no-view]').forEach(e => e.remove());
const shown = new Set(Array.from(list.querySelectorAll('[data-module]')).map(e => e.dataset.module.toLowerCase()));
modules.filter(m => !shown.has(m.name.toLowerCase())).forEach(m => {
const item = document.createElement('button');
item.className = 'nav-menu-item';
item.setAttribute('role', 'menuitem');
item.disabled = true;
item.dataset.noView = '1';
item.title = 'Für dieses Modul gibt es im WebUI noch keine eigene Ansicht.';
const label = document.createElement('span');
label.textContent = m.name;
const hint = document.createElement('span');
hint.className = 'nav-menu-hint';
hint.textContent = m.isActive ? 'keine Ansicht' : 'deaktiviert';
item.append(label, hint);
list.appendChild(item);
});
}
// Dashboard Refresh
async function refreshDashboard() {
try {
@@ -337,6 +474,7 @@ async function loadModules() {
const res = await fetch('/api/modules');
if (!res.ok) return;
const modules = await res.json();
refreshNavModules(modules);
const container = document.getElementById('modulesContainer');
container.innerHTML = modules.map(m => {