// ----------------------------- // 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/". // 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: // "/" — z. B. "/Auslastung", "/Projektübersicht" // "/Projektplan/" — 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/?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 "AccordionToggle" (Kopf-Button) und // "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); }); }