/* * Reifen Späth – App Interop * ============================================================= * Browser-side helpers for long-duration usage: * - Visibility refresh (tab re-activated after N minutes) * - Idle-lock overlay (confirm-to-continue after N minutes) * - beforeunload warning when a form has unsaved draft data * - sessionStorage draft helpers (per-tab) * - focus-trap + Escape + click-outside helpers for modals/menus * - Custom Blazor reconnect UI wiring (handled via CSS selectors * on #components-reconnect-modal in App.razor) * ============================================================= */ (function () { 'use strict'; // --------------------------------------------------------- // sessionStorage drafts (per-browser, per-tab) // --------------------------------------------------------- const DRAFT_PREFIX = 'rs-draft:'; window.appInterop = window.appInterop || {}; window.appInterop.saveDraft = function (key, json) { try { if (json == null) { sessionStorage.removeItem(DRAFT_PREFIX + key); return true; } sessionStorage.setItem(DRAFT_PREFIX + key, json); return true; } catch (e) { console.warn('[appInterop] saveDraft failed', e); return false; } }; window.appInterop.loadDraft = function (key) { try { return sessionStorage.getItem(DRAFT_PREFIX + key); } catch (e) { return null; } }; window.appInterop.clearDraft = function (key) { try { sessionStorage.removeItem(DRAFT_PREFIX + key); } catch (e) { /* ignore */ } }; // --------------------------------------------------------- // File download from base64 (used for PDF exports) // --------------------------------------------------------- window.appInterop.downloadFile = function (fileName, base64, mimeType) { try { const bin = atob(base64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName || 'download'; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function () { URL.revokeObjectURL(url); }, 1000); return true; } catch (e) { console.warn('[appInterop] downloadFile failed', e); return false; } }; // --------------------------------------------------------- // beforeunload: warn user when draft flag is set // --------------------------------------------------------- let hasUnsavedChanges = false; window.appInterop.setUnsaved = function (value) { hasUnsavedChanges = !!value; }; window.addEventListener('beforeunload', function (e) { if (hasUnsavedChanges) { e.preventDefault(); // Most browsers ignore the custom message and show a generic one. e.returnValue = ''; return ''; } }); // --------------------------------------------------------- // Visibility refresh: fire .NET callback when tab returns // after being hidden for more than `thresholdSeconds`. // // One .NET target per page; replacing cancels the previous. // --------------------------------------------------------- let visibilityHiddenAt = null; let visibilityTarget = null; // DotNetObjectReference let visibilityMethod = null; let visibilityThresholdSec = 120; function onVisibilityChange() { if (document.visibilityState === 'hidden') { visibilityHiddenAt = Date.now(); } else if (document.visibilityState === 'visible') { if (!visibilityTarget || !visibilityHiddenAt) return; const hiddenFor = (Date.now() - visibilityHiddenAt) / 1000; visibilityHiddenAt = null; if (hiddenFor >= visibilityThresholdSec) { try { visibilityTarget.invokeMethodAsync(visibilityMethod, Math.round(hiddenFor)); } catch (e) { console.warn('[appInterop] visibility invoke failed', e); } } } } document.addEventListener('visibilitychange', onVisibilityChange); window.appInterop.registerVisibilityRefresh = function (dotNetRef, methodName, thresholdSeconds) { visibilityTarget = dotNetRef; visibilityMethod = methodName || 'OnVisibilityReturn'; visibilityThresholdSec = thresholdSeconds > 0 ? thresholdSeconds : 120; visibilityHiddenAt = null; }; window.appInterop.unregisterVisibilityRefresh = function () { visibilityTarget = null; visibilityMethod = null; visibilityHiddenAt = null; }; // --------------------------------------------------------- // Idle lock overlay (per-tab). // Fires after N minutes of no user input; requires a click // on the "Weiter" button to resume (no password — matches // confirmed UX decision). // --------------------------------------------------------- const IDLE_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'wheel']; let idleTimerId = null; let idleTimeoutMs = 60 * 60 * 1000; // 60 min default let idleActive = false; let idleLocked = false; function resetIdleTimer() { if (!idleActive || idleLocked) return; if (idleTimerId) clearTimeout(idleTimerId); idleTimerId = setTimeout(triggerIdleLock, idleTimeoutMs); } function triggerIdleLock() { idleLocked = true; const overlay = document.getElementById('app-idle-overlay'); if (overlay) { overlay.classList.add('visible'); const btn = overlay.querySelector('.app-idle-resume'); if (btn) { setTimeout(function () { try { btn.focus(); } catch (e) { } }, 50); } } } function releaseIdleLock() { idleLocked = false; const overlay = document.getElementById('app-idle-overlay'); if (overlay) overlay.classList.remove('visible'); resetIdleTimer(); } window.appInterop.startIdleWatcher = function (idleMinutes) { const minutes = parseInt(idleMinutes, 10); if (!minutes || minutes <= 0) { window.appInterop.stopIdleWatcher(); return; } idleTimeoutMs = minutes * 60 * 1000; if (!idleActive) { idleActive = true; IDLE_EVENTS.forEach(function (ev) { window.addEventListener(ev, resetIdleTimer, { passive: true }); }); const btn = document.querySelector('#app-idle-overlay .app-idle-resume'); if (btn && !btn.dataset.bound) { btn.dataset.bound = '1'; btn.addEventListener('click', releaseIdleLock); } } resetIdleTimer(); }; window.appInterop.stopIdleWatcher = function () { if (!idleActive) return; idleActive = false; if (idleTimerId) clearTimeout(idleTimerId); idleTimerId = null; IDLE_EVENTS.forEach(function (ev) { window.removeEventListener(ev, resetIdleTimer); }); releaseIdleLock(); }; // Auto-load session settings and start watcher once DOM is ready. // Only activates if the body has [data-authenticated="true"], which // MainLayout sets when a user is logged in. function tryAutoStartIdle() { if (document.body && document.body.dataset.authenticated === 'true') { fetch('/api/session-settings', { credentials: 'same-origin' }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (s) { if (s && s.idleTimeoutMinutes > 0) { window.appInterop.startIdleWatcher(s.idleTimeoutMinutes); } }) .catch(function () { /* non-fatal */ }); } } // Observe auth marker on so the watcher (re)starts after login/logout. document.addEventListener('DOMContentLoaded', function () { tryAutoStartIdle(); if (document.body) { const mo = new MutationObserver(function () { tryAutoStartIdle(); }); mo.observe(document.body, { attributes: true, attributeFilter: ['data-authenticated'] }); } }); // --------------------------------------------------------- // Click-outside for user-menu dropdown and mobile-menu. // The Blazor component sets [data-click-outside-id="..."] on // the open panel and exposes a .NET method we call back. // --------------------------------------------------------- let clickOutsideTarget = null; let clickOutsideMethod = null; let clickOutsideContainerSelector = null; function onDocumentClick(e) { if (!clickOutsideTarget || !clickOutsideContainerSelector) return; const container = document.querySelector(clickOutsideContainerSelector); if (!container) return; if (!container.contains(e.target)) { try { clickOutsideTarget.invokeMethodAsync(clickOutsideMethod); } catch (err) { /* circuit may be reconnecting */ } } } window.appInterop.registerClickOutside = function (dotNetRef, methodName, containerSelector) { clickOutsideTarget = dotNetRef; clickOutsideMethod = methodName; clickOutsideContainerSelector = containerSelector; // Use capture phase so stopPropagation inside the panel still works for us. document.addEventListener('mousedown', onDocumentClick, true); document.addEventListener('touchstart', onDocumentClick, { capture: true, passive: true }); }; window.appInterop.unregisterClickOutside = function () { clickOutsideTarget = null; clickOutsideMethod = null; clickOutsideContainerSelector = null; document.removeEventListener('mousedown', onDocumentClick, true); document.removeEventListener('touchstart', onDocumentClick, { capture: true }); }; // --------------------------------------------------------- // Focus trap for modals (autofocus + Tab cycle + Escape). // Caller passes the container selector; we remember the // previously-focused element and restore it on release. // --------------------------------------------------------- const FOCUSABLE = [ 'a[href]', 'area[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', 'iframe', 'object', 'embed', '[contenteditable]', '[tabindex]:not([tabindex="-1"])' ].join(','); let trapState = null; function onTrapKey(e) { if (!trapState) return; if (e.key === 'Escape' && trapState.escapeMethod) { try { trapState.dotNetRef.invokeMethodAsync(trapState.escapeMethod); } catch (err) { } return; } if (e.key !== 'Tab') return; const container = document.querySelector(trapState.selector); if (!container) return; const focusables = Array.prototype.filter.call( container.querySelectorAll(FOCUSABLE), function (el) { return el.offsetParent !== null; }); if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } window.appInterop.trapFocus = function (selector, dotNetRef, escapeMethod) { if (trapState) { try { document.removeEventListener('keydown', onTrapKey, true); } catch (e) { } } trapState = { selector: selector, dotNetRef: dotNetRef || null, escapeMethod: escapeMethod || null, previouslyFocused: document.activeElement }; document.addEventListener('keydown', onTrapKey, true); // Autofocus the first tabbable element. setTimeout(function () { const container = document.querySelector(selector); if (!container) return; const focusables = Array.prototype.filter.call( container.querySelectorAll(FOCUSABLE), function (el) { return el.offsetParent !== null; }); if (focusables.length > 0) { try { focusables[0].focus(); } catch (e) { } } }, 30); }; window.appInterop.releaseFocusTrap = function () { document.removeEventListener('keydown', onTrapKey, true); if (trapState && trapState.previouslyFocused && typeof trapState.previouslyFocused.focus === 'function') { try { trapState.previouslyFocused.focus(); } catch (e) { } } trapState = null; }; // --------------------------------------------------------- // Blazor reconnect UI — replace the default overlay. // App.razor provides #components-reconnect-modal with German // copy; Blazor sets body[data-reconnect-state] automatically // via the official contract (components-reconnect-show etc.). // We just add a manual reload button handler. // --------------------------------------------------------- document.addEventListener('DOMContentLoaded', function () { const reload = document.querySelector('#components-reconnect-modal .reconnect-reload'); if (reload) { reload.addEventListener('click', function () { location.reload(); }); } const retry = document.querySelector('#components-reconnect-modal .reconnect-retry'); if (retry) { retry.addEventListener('click', function () { if (window.Blazor && typeof window.Blazor.reconnect === 'function') { window.Blazor.reconnect(); } else { location.reload(); } }); } }); })();