All new
alle new
This commit is contained in:
1 parent
da64472251
commit
5b20adee48
1605 files changed
+396517
-1674
No files matched your search
+250
-45
@@ -1,65 +1,118 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — UI-Zustand (LocalStorage, Theme, Header)
|
||||
// -----------------------------
|
||||
// Diese Datei verwaltet den reinen UI-Zustand: gespeicherte Abteilung in
|
||||
// LocalStorage, den kollabierbaren Header sowie das Light/Dark-Theme. Sie wird
|
||||
// nach js/globals.js von beiden HTML-Einstiegspunkten geladen (Test.html und
|
||||
// project-plan.html). Die bereitgestellten Funktionen werden von js/init.js
|
||||
// beim Start sowie von js/tabs-auth.js (Tab-Wechsel, showTab) und js/store.js
|
||||
// (Abteilungswechsel, switchDepartment) aufgerufen; im Theme-Umschalter der
|
||||
// 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 Abteilung und aktiven Tab in den LocalStorage
|
||||
// saveUiState: speichert den aktiven Tab in den LocalStorage
|
||||
// Parameter: keine. Rückgabe: keine.
|
||||
function saveUiState(){
|
||||
try {
|
||||
const state = { department: currentDepartment, tab: currentTab };
|
||||
const state = { tab: currentTab };
|
||||
window.localStorage.setItem(STORAGE, JSON.stringify(state));
|
||||
} catch (err) {
|
||||
console.warn('UI-State kann nicht gespeichert werden:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// loadUiState: stellt die zuletzt gewählte Abteilung aus dem LocalStorage wieder her
|
||||
// Parameter: keine. Rückgabe: keine (setzt currentDepartment aus js/globals.js).
|
||||
// loadUiState: liest den persistierten UI-State aus dem LocalStorage.
|
||||
// Parameter: keine. Rückgabe: keine.
|
||||
function loadUiState(){
|
||||
try {
|
||||
const state = JSON.parse(window.localStorage.getItem(STORAGE) || '{}');
|
||||
if(state.department && DEPARTMENTS[state.department]) currentDepartment = state.department;
|
||||
JSON.parse(window.localStorage.getItem(STORAGE) || '{}');
|
||||
// Tab wird nicht aus dem localStorage wiederhergestellt, damit nach dem
|
||||
// Neuladen immer das Projektboard angezeigt wird.
|
||||
// Neuladen immer DEFAULT_TAB (Dashboard "Meine Aufgaben") angezeigt wird.
|
||||
} catch (err) {
|
||||
console.warn('UI-State kann nicht geladen werden:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Deep-Link-Routen (Abteilung + Tab als URL) ----------
|
||||
// ---------- Deep-Link-Routen (Tab als URL) ----------
|
||||
|
||||
// applyUrlRoute: wertet einen Deep-Link "/<Abteilung>/<Tab>" aus der URL aus,
|
||||
// z. B. "/HLS/Auslastung" oder "/ELT/Projektübersicht". Gültige Routen liefern
|
||||
// { department, tab }, alles andere (auch file://-Pfade) liefert null.
|
||||
// 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 m = window.location.pathname.match(/^\/([^/]+)\/([^/]+)\/?$/);
|
||||
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 dept = m[1];
|
||||
const tab = TAB_BY_SLUG[decodeURIComponent(m[2])];
|
||||
if(!DEPARTMENTS[dept] || !tab) return null;
|
||||
return { department: dept, tab };
|
||||
const tab = TAB_BY_SLUG[decodeURIComponent(m[1])];
|
||||
if(!tab) return null;
|
||||
return { tab };
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// updateRouteUrl: schreibt Abteilung + aktiven Tab als sprechende URL in die
|
||||
// Adresszeile (z. B. "/HLS/Auslastung"), ohne die Seite neu zu laden. Nur über
|
||||
// HTTP (nicht per file://) und nur bei bekannter Abteilung/Tab. Keine Rückgabe.
|
||||
function updateRouteUrl(dept, tab){
|
||||
// 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 || !DEPARTMENTS[dept]) return;
|
||||
window.history.replaceState(null, '', '/' + dept + '/' + slug);
|
||||
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) {}
|
||||
}
|
||||
|
||||
@@ -89,24 +142,44 @@ function saveHeaderState(collapsed){
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
// setHeaderCollapsed: klappt das Header-Menü auf/zu und aktualisiert Icon und aria-Attribute
|
||||
// Parameter: collapsed (Boolean), persist (Boolean, ob in LocalStorage gespeichert wird). Rückgabe: keine.
|
||||
// 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('.header-bar .header-toggle');
|
||||
const icon = document.querySelector('.header-bar .header-toggle-icon');
|
||||
const btn = document.querySelector('.mobile-nav-toggle');
|
||||
if(!menu) return;
|
||||
if(collapsed){
|
||||
menu.classList.remove('open');
|
||||
menu.setAttribute('aria-hidden', 'true');
|
||||
if(icon) icon.textContent = '☰';
|
||||
if(btn) btn.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
menu.classList.add('open');
|
||||
menu.setAttribute('aria-hidden', 'false');
|
||||
if(icon) icon.textContent = '✕';
|
||||
if(btn) btn.setAttribute('aria-expanded', 'true');
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -118,14 +191,146 @@ function toggleHeader(){
|
||||
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){
|
||||
const icon = document.getElementById('themeIcon');
|
||||
if(!icon) return;
|
||||
icon.textContent = theme === 'dark' ? '☀️' : '🌙';
|
||||
// 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
|
||||
|
||||
Reference in new issue
Block a user