335 lines
13 KiB
JavaScript
335 lines
13 KiB
JavaScript
/*
|
||
* 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 */ }
|
||
};
|
||
|
||
// ---------------------------------------------------------
|
||
// 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 <body> 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();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
})();
|