Files
ReifenSpaeth/Lagerbestand/wwwroot/js/app-interop.js
T

335 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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();
}
});
}
});
})();