Files
projektuebersicht/public/js/ui.js
T
erik 5b20adee48 All new
alle new
2026-10-01 06:46:51 +02:00

400 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// Projektübersicht — UI-Zustand (LocalStorage, Theme, Header)
// -----------------------------
// Diese Datei verwaltet den reinen UI-Zustand: den kollabierbaren Header sowie
// das Light/Dark-Theme. Sie wird nach js/globals.js von beiden HTML-
// Einstiegspunkt Test.html geladen. Die
// bereitgestellten Funktionen werden von js/init.js beim Start sowie von
// js/tabs-auth.js (Tab-Wechsel, showTab) aufgerufen; im Theme-Umschalter der
// Seite werden toggleTheme/applyTheme direkt eingebunden.
// Seit der Projektbereich eines Projekts kein eigenes Dokument mehr ist,
// sondern der Bereich #plan von Test.html, kennen applyUrlRoute() und
// updateRouteUrl() auch dessen Adresse "/Projektplan/<Projektname>".
// saveUiState: speichert den aktiven Tab in den LocalStorage
// Parameter: keine. Rückgabe: keine.
function saveUiState(){
try {
const state = { tab: currentTab };
window.localStorage.setItem(STORAGE, JSON.stringify(state));
} catch (err) {
console.warn('UI-State kann nicht gespeichert werden:', err);
}
}
// loadUiState: liest den persistierten UI-State aus dem LocalStorage.
// Parameter: keine. Rückgabe: keine.
function loadUiState(){
try {
JSON.parse(window.localStorage.getItem(STORAGE) || '{}');
// Tab wird nicht aus dem localStorage wiederhergestellt, damit nach dem
// Neuladen immer DEFAULT_TAB (Dashboard "Meine Aufgaben") angezeigt wird.
} catch (err) {
console.warn('UI-State kann nicht geladen werden:', err);
}
}
// ---------- Deep-Link-Routen (Tab als URL) ----------
// applyUrlRoute: wertet einen Deep-Link aus der Adresszeile aus. Zwei
// Formen sind gültig:
// "/<Tab>" — z. B. "/Auslastung", "/Projektübersicht"
// "/Projektplan/<Projektname>" — der Projektbereich EINES Projekts,
// mit ?weekLabel=…&projNr=… als Parameter
// Die zweite Form führte früher auf ein eigenes Dokument
// (public/project-plan.html); seit der Projektbereich ein Bereich der
// Hauptseite ist, liefert sie schlicht den Bereich 'plan' mitsamt seinem
// Ziel. Rückgabe: { tab } bzw. { tab:'plan', weekLabel, projNr, projName },
// sonst null (auch bei file://-Pfaden).
function applyUrlRoute(){
try {
const planRe = new RegExp('^/' + PLAN_SLUG + '(?:/(.*))?/?$');
const planMatch = window.location.pathname.match(planRe);
if(planMatch){
const params = new URLSearchParams(window.location.search);
let projName = '';
try { projName = decodeURIComponent(planMatch[1] || ''); }
catch(e) { projName = params.get('projName') || ''; }
return {
tab: 'plan',
weekLabel: params.get('weekLabel') || '',
projNr: params.get('projNr') || '',
projName: projName || (params.get('projName') || ''),
planId: params.get('planId') || ''
};
}
const m = window.location.pathname.match(/^\/([^/]+)\/?$/);
if(!m) return null;
const tab = TAB_BY_SLUG[decodeURIComponent(m[1])];
if(!tab) return null;
return { tab };
} catch (err) {
return null;
}
}
// updateRouteUrl: schreibt den aktiven Bereich als sprechende URL in die
// Adresszeile (z. B. "/Auslastung"), ohne die Seite neu zu laden. Nur über
// HTTP (nicht per file://) und nur bei bekanntem Bereich. Der Projektbereich
// ist der Sonderfall: seine Adresse trägt zusätzlich das geöffnete Projekt
// ("/Projektplan/<Name>?weekLabel=…&projNr=…"), damit sie sich wie bisher
// verschicken lässt; die Werte kommen aus window.PlanApp (setPlanTarget() in
// js/plan/plan-state.js, das showTab() vorher gefüllt hat). Keine Rückgabe.
function updateRouteUrl(tab){
try {
if(!window.location.protocol || window.location.protocol.indexOf('http') !== 0) return;
if(tab === 'plan'){
const app = window.PlanApp;
if(!app || !app.weekLabel) return;
const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(app.projName || ''), window.location.origin);
url.searchParams.set('weekLabel', app.weekLabel);
// Ein Sandbox-Plan der Planung hat keine Projektnummer — er wird über
// planId adressiert (die stabile id, siehe openSandboxPlan(),
// js/plan/plan-state.js), nicht über projNr wie ein echtes Projekt.
if(app.weekLabel === SANDBOX_WEEK_LABEL){
url.searchParams.set('planId', app.sandboxPlanId || '');
} else {
url.searchParams.set('projNr', app.projNr || '');
}
// Das Fragment bleibt stehen: Es trägt den gewählten Unterbereich
// (…#aufgaben, showPlanSection() in js/plan/plan-sections.js). Würde es
// hier abgeschnitten, landete ein verschickter Link auf einen
// bestimmten Unterbereich immer wieder im Dashboard.
window.history.replaceState(null, '', url.pathname + url.search + window.location.hash);
return;
}
const slug = TAB_SLUGS[tab];
if(!slug) return;
// Der Bereich „Admin" hält seinen Abschnitt im Fragment
// (…/Admin#auslastung, showAdminSection() in js/admin/admin.js). Es muss
// erhalten bleiben, weil showTab() diese Funktion VOR dem Rendern
// aufruft — abgeschnitten wäre der Abschnitt eines verschickten Links
// schon weg, bevor ihn jemand liest. Bei allen anderen Bereichen fällt
// das Fragment bewusst weg.
const fragment = (tab === 'admin') ? window.location.hash : '';
window.history.replaceState(null, '', '/' + slug + fragment);
} catch (err) {}
}
// ---------- Kollabierbarer Header ----------
// HEADER_COLLAPSED_KEY: LocalStorage-Key für den Einklapp-Zustand des Header-Menüs
const HEADER_COLLAPSED_KEY = 'projuebersicht-header-collapsed';
// loadHeaderState: liest den gespeicherten Header-Zustand aus dem LocalStorage
// Parameter: keine. Rückgabe: keine (wendet den Zustand per setHeaderCollapsed an).
function loadHeaderState(){
let collapsed = true; // Default: Menü ist beim ersten Start geschlossen
try {
const stored = window.localStorage.getItem(HEADER_COLLAPSED_KEY);
if(stored !== null){
collapsed = stored === 'true';
}
} catch (err) {}
setHeaderCollapsed(collapsed, false);
}
// saveHeaderState: speichert den Einklapp-Zustand des Headers im LocalStorage
// Parameter: collapsed (Boolean). Rückgabe: keine.
function saveHeaderState(collapsed){
try {
window.localStorage.setItem(HEADER_COLLAPSED_KEY, collapsed ? 'true' : 'false');
} catch (err) {}
}
// isDesktopSidebarLayout: true ab der Breakpoint-Breite (769px, siehe
// styles.css), ab der die Sidebar per CSS dauerhaft sichtbar erzwungen wird
// (transform:none), unabhängig vom Off-Canvas-Zustand.
// Parameter: keine. Rückgabe: Boolean.
function isDesktopSidebarLayout(){
return window.matchMedia && window.matchMedia('(min-width: 769px)').matches;
}
// setHeaderCollapsed: klappt das Off-Canvas-Seiten-Menü auf/zu und
// aktualisiert das aria-expanded-Attribut des öffnenden Hamburger-Knopfs im
// Menüband (.mobile-nav-toggle). Öffnen- und Schließen-Knopf sind zwei
// getrennte Elemente mit je eigenem, festem Icon (Hamburger bzw. ×) — anders
// als früher gibt es keinen einzelnen Knopf mehr, dessen Icon umgeschaltet
// werden müsste. Wichtig: aria-hidden darf NUR gesetzt werden, wenn die
// Sidebar tatsächlich unsichtbar ist (Off-Canvas, < 769px) — ab 769px zeigt
// styles.css sie per CSS immer an (transform:none), ein dort weiterhin
// gesetztes aria-hidden="true" widerspräche dem sichtbaren, fokussierbaren
// Inhalt (führte zu "Blocked aria-hidden on an element because its
// descendant retained focus" in Chrome und verhinderte dort zuverlässige
// Klicks auf Elemente der Sidebar, u.a. den Einklapp-Knopf).
// Parameter: collapsed (Boolean), persist (Boolean, ob in LocalStorage
// gespeichert wird). Rückgabe: keine.
function setHeaderCollapsed(collapsed, persist){
const menu = document.getElementById('headerMenu');
const btn = document.querySelector('.mobile-nav-toggle');
if(!menu) return;
const hiddenNow = collapsed && !isDesktopSidebarLayout();
// Fokus vor dem Verstecken wegnehmen, falls er gerade auf einem Element
// innerhalb der Sidebar liegt (z. B. der zuletzt angeklickte Nav-Punkt,
// dessen Klick das Off-Canvas-Menü zugleich schließt) — sonst entsteht
// genau der "aria-hidden on focused descendant"-Konflikt aus der
// Browser-Warnung.
if(hiddenNow && document.activeElement && menu.contains(document.activeElement)){
document.activeElement.blur();
}
menu.classList.toggle('open', !collapsed);
menu.setAttribute('aria-hidden', hiddenNow ? 'true' : 'false');
if(btn) btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
if(persist) saveHeaderState(collapsed);
}
// toggleHeader: klappt das Header-Menü zwischen offen und geschlossen um
// Parameter: keine. Rückgabe: keine (persistiert den neuen Zustand).
function toggleHeader(){
const menu = document.getElementById('headerMenu');
const isOpen = menu && menu.classList.contains('open');
setHeaderCollapsed(isOpen, true);
}
// Beim Überschreiten der Sidebar-Breakpoint-Breite (769px) per Fenster-
// Resize wird aria-hidden neu bewertet (siehe setHeaderCollapsed()) — sonst
// bliebe z. B. nach einem Verkleinern-und-wieder-Vergrößern des Fensters ein
// aria-hidden="true" auf der jetzt wieder sichtbaren Desktop-Sidebar stehen.
if(window.matchMedia){
window.matchMedia('(min-width: 769px)').addEventListener('change', function(){
const menu = document.getElementById('headerMenu');
if(!menu) return;
setHeaderCollapsed(!menu.classList.contains('open'), false);
});
}
// ---------- Einklappbare Sidebar (Icon-Rail, nur Desktop ≥769px) ----------
// SIDEBAR_COLLAPSED_KEY: LocalStorage-Key für den Einklapp-Zustand der Sidebar
const SIDEBAR_COLLAPSED_KEY = 'projuebersicht-sidebar-collapsed';
// loadSidebarCollapseState: liest den gespeicherten Sidebar-Zustand aus dem
// LocalStorage. Parameter: keine. Rückgabe: keine (wendet den Zustand per
// setSidebarCollapsed an).
function loadSidebarCollapseState(){
let collapsed = false; // Default: Sidebar ist beim ersten Start aufgeklappt
try {
const stored = window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
if(stored !== null){
collapsed = stored === 'true';
}
} catch (err) {}
setSidebarCollapsed(collapsed, false);
}
// saveSidebarCollapseState: speichert den Einklapp-Zustand der Sidebar im LocalStorage
// Parameter: collapsed (Boolean). Rückgabe: keine.
function saveSidebarCollapseState(collapsed){
try {
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? 'true' : 'false');
} catch (err) {}
}
// setSidebarCollapsed: klappt die Sidebar zwischen voller Breite und
// Icon-Rail um (nur auf Desktop-Breite sichtbar, siehe styles.css) und
// aktualisiert das aria-pressed-Attribut des Umschalt-Knopfs.
// Parameter: collapsed (Boolean), persist (Boolean, ob in LocalStorage
// gespeichert wird). Rückgabe: keine.
function setSidebarCollapsed(collapsed, persist){
const menu = document.getElementById('headerMenu');
if(!menu) return;
menu.classList.toggle('collapsed', collapsed);
const btn = document.getElementById('sidebarCollapseToggle');
if(btn) btn.setAttribute('aria-pressed', collapsed ? 'true' : 'false');
if(persist) saveSidebarCollapseState(collapsed);
}
// toggleSidebarCollapse: wechselt die Sidebar zwischen aufgeklappt und
// Icon-Rail um und persistiert den neuen Zustand.
// Parameter: keine. Rückgabe: keine.
function toggleSidebarCollapse(){
const menu = document.getElementById('headerMenu');
const isCollapsed = menu && menu.classList.contains('collapsed');
setSidebarCollapsed(!isCollapsed, true);
}
// ---------- Aufklappbare Sidebar-Bereiche (Einstellungen/Admin) ----------
// toggleSidebarAccordion: klappt einen der beiden Aktions-Bereiche in der
// Seitenleiste (Einstellungen/Admin) auf oder zu. Erwartet die zugehörigen
// Elemente unter den IDs "<key>AccordionToggle" (Kopf-Button) und
// "<key>Subnav" (Liste der Aktionen), siehe Test.html. Kein Persistieren des
// Zustands — jeder Bereich startet beim Neuladen zugeklappt.
// Parameter: key ('settings'|'admin'). Rückgabe: keine.
function toggleSidebarAccordion(key){
const toggleBtn = document.getElementById(key + 'AccordionToggle');
const subnav = document.getElementById(key + 'Subnav');
if(!toggleBtn || !subnav) return;
const nowOpen = !subnav.classList.contains('open');
subnav.classList.toggle('open', nowOpen);
toggleBtn.classList.toggle('expanded', nowOpen);
toggleBtn.setAttribute('aria-expanded', nowOpen ? 'true' : 'false');
}
// ---------- Konto-Menü (Avatar-Dropdown im Menüband) ----------
// toggleUserMenu: öffnet/schließt das Konto-Dropdown (#userMenuDropdown) im
// Menüband. Parameter: keine. Rückgabe: keine.
function toggleUserMenu(){
const dropdown = document.getElementById('userMenuDropdown');
if(!dropdown) return;
if(dropdown.classList.contains('open')) closeUserMenu();
else openUserMenu();
}
// openUserMenu: öffnet das Konto-Dropdown und bindet den Klick-außerhalb-
// sowie Escape-Handler (gleiches Muster wie openRangePicker() in
// js/date-picker.js: setTimeout, damit der öffnende Klick selbst nicht
// sofort wieder schließt). Parameter: keine. Rückgabe: keine.
function openUserMenu(){
const dropdown = document.getElementById('userMenuDropdown');
const btn = document.getElementById('userMenuToggle');
if(!dropdown) return;
dropdown.classList.add('open');
if(btn) btn.setAttribute('aria-expanded', 'true');
setTimeout(() => {
document.addEventListener('mousedown', userMenuOutsideClick);
document.addEventListener('keydown', userMenuEscape);
}, 0);
}
// closeUserMenu: schließt das Konto-Dropdown und löst die Handler wieder.
// Parameter: keine. Rückgabe: keine.
function closeUserMenu(){
const dropdown = document.getElementById('userMenuDropdown');
const btn = document.getElementById('userMenuToggle');
if(dropdown) dropdown.classList.remove('open');
if(btn) btn.setAttribute('aria-expanded', 'false');
document.removeEventListener('mousedown', userMenuOutsideClick);
document.removeEventListener('keydown', userMenuEscape);
}
// userMenuOutsideClick: schließt das Dropdown bei Klick außerhalb von
// .topbar-user. Parameter: e (Mousedown-Event). Rückgabe: keine.
function userMenuOutsideClick(e){
const wrapper = document.querySelector('.topbar-user');
if(wrapper && !wrapper.contains(e.target)) closeUserMenu();
}
// userMenuEscape: schließt das Dropdown bei Escape. Parameter: e
// (Keydown-Event). Rückgabe: keine.
function userMenuEscape(e){
if(e.key === 'Escape') closeUserMenu();
}
// ---------- Theme (Light/Dark) ----------
// applyThemeIcon: setzt das Sonne-/Mond-Icon passend zum effektiven Theme
// Parameter: theme (String 'dark'/'light'). Rückgabe: keine.
function applyThemeIcon(theme){
// Bewusst `iconEl` statt `icon` benannt, siehe Kommentar in setHeaderCollapsed().
const iconEl = document.getElementById('themeIcon');
if(!iconEl) return;
iconEl.innerHTML = theme === 'dark' ? icon('sun') : icon('moon');
}
// loadTheme: liest das gespeicherte Theme aus dem LocalStorage und wendet es an
// Parameter: keine. Rückgabe: keine.
function loadTheme(){
let theme = 'auto';
try {
theme = window.localStorage.getItem('projuebersicht-theme') || 'auto';
} catch (err) {
console.warn('Theme kann nicht geladen werden:', err);
}
applyTheme(theme, false);
}
// saveTheme: speichert das gewählte Theme im LocalStorage
// Parameter: theme (String 'auto'/'light'/'dark'). Rückgabe: keine.
function saveTheme(theme){
try {
window.localStorage.setItem('projuebersicht-theme', theme);
} catch (err) {
console.warn('Theme kann nicht gespeichert werden:', err);
}
}
// effectiveTheme: ermittelt das tatsächlich verwendete Theme ('light'/'dark')
// Parameter: theme (String 'auto'/'light'/'dark'). Rückgabe: 'dark' oder 'light'
// (bei 'auto' anhand der Systemeinstellung).
function effectiveTheme(theme){
if(theme === 'dark') return 'dark';
if(theme === 'light') return 'light';
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// applyTheme: setzt das data-theme-Attribut am html-Element und aktualisiert das Icon
// Parameter: theme (String), persist (Boolean, ob in LocalStorage gespeichert wird). Rückgabe: keine.
function applyTheme(theme, persist){
const html = document.documentElement;
const eff = effectiveTheme(theme);
if(theme === 'dark') html.setAttribute('data-theme', 'dark');
else if(theme === 'light') html.setAttribute('data-theme', 'light');
else html.removeAttribute('data-theme');
applyThemeIcon(eff);
if(persist) saveTheme(theme);
}
// toggleTheme: wechselt zwischen hellem und dunklem Theme und speichert die Auswahl
// Parameter: keine. Rückgabe: keine.
function toggleTheme(){
const html = document.documentElement;
const currentAttr = html.getAttribute('data-theme');
const currentTheme = currentAttr || 'auto';
const currentEffective = effectiveTheme(currentTheme);
const next = currentEffective === 'dark' ? 'light' : 'dark';
applyTheme(next, true);
}
// Beim Wechsel der System-Farbschema-Einstellung wird das Theme im
// "Auto"-Modus sofort neu angewendet (kein Neuladen nötig).
if(window.matchMedia){
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(){
let theme = 'auto';
try {
theme = window.localStorage.getItem('projuebersicht-theme') || 'auto';
} catch (err) {}
applyTheme(theme, false);
});
}