Initial commit: Lagerbestand Blazor Projekt

This commit is contained in:
spaethf committed 2026-09-21 21:28:23 +02:00
commit cba9414e92
105 files changed
+36431

No files matched your search

+334
View File
@@ -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();
}
});
}
});
})();