Initial commit: Lagerbestand Blazor Projekt
This commit is contained in:
commit
cba9414e92
105 files changed
+36431
No files matched your search
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in new issue
Block a user