400 lines
17 KiB
JavaScript
400 lines
17 KiB
JavaScript
// -----------------------------
|
||
// 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);
|
||
});
|
||
} |