Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
@@ -1,15 +1,12 @@
|
||||
// modules/core/click-manager.js
|
||||
import { Logger } from './logger.js';
|
||||
import { useEvent } from './useEvent.js';
|
||||
import {navigateTo} from "./navigateTo";
|
||||
import {SimpleCache} from "../utils/cache";
|
||||
import { navigateTo } from "./navigateTo";
|
||||
import { LinkPrefetcher } from './LinkPrefetcher.js';
|
||||
|
||||
let callback = null;
|
||||
let unsubscribes = [];
|
||||
let cleanupInterval = null;
|
||||
const prefetchCache = new SimpleCache(20, 60000); //new Map();
|
||||
const maxCacheSize = 20; // max. Anzahl gecachter Seiten
|
||||
const cacheTTL = 60000; // Lebensdauer in ms (60s)
|
||||
let prefetcher = null;
|
||||
|
||||
function isInternal(link) {
|
||||
return link.origin === location.origin;
|
||||
@@ -36,15 +33,14 @@ function handleClick(e) {
|
||||
if (isInternal(link)) {
|
||||
e.preventDefault();
|
||||
|
||||
const cached = prefetchCache.get(href);
|
||||
const valid = cached && Date.now() - cached.timestamp < cacheTTL;
|
||||
const cached = prefetcher ? prefetcher.getCached(href) : null;
|
||||
|
||||
const options = {
|
||||
viewTransition: link.hasAttribute('data-view-transition'),
|
||||
replace: link.hasAttribute('data-replace'),
|
||||
modal: link.hasAttribute('data-modal'),
|
||||
prefetched: valid,
|
||||
data: valid ? cached.data : null,
|
||||
prefetched: cached !== null,
|
||||
data: cached,
|
||||
};
|
||||
|
||||
Logger.info(`[click-manager] internal: ${href}`, options);
|
||||
@@ -57,37 +53,18 @@ function handleClick(e) {
|
||||
}
|
||||
}
|
||||
|
||||
let prefetchTimeout;
|
||||
function handleMouseOver(e) {
|
||||
clearTimeout(prefetchTimeout);
|
||||
const link = e.target.closest('a[href]');
|
||||
if (!link || !isInternal(link)) return;
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || prefetchCache.has(href)) return;
|
||||
|
||||
// optional: Wait 150ms to reduce noise
|
||||
prefetchTimeout = setTimeout(() => prefetch(href), 150);
|
||||
if (!link || !prefetcher) return;
|
||||
|
||||
prefetcher.handleHover(link);
|
||||
}
|
||||
|
||||
function prefetch(href) {
|
||||
Logger.info(`[click-manager] prefetching: ${href}`);
|
||||
|
||||
fetch(href)
|
||||
.then(res => res.text())
|
||||
.then(html => {
|
||||
if (prefetchCache.cache.size >= maxCacheSize) {
|
||||
const oldestKey = [...prefetchCache.cache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0][0];
|
||||
prefetchCache.cache.delete(oldestKey);
|
||||
}
|
||||
prefetchCache.set(href, {
|
||||
data: html,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
Logger.warn(`[click-manager] prefetch failed for ${href}`, err);
|
||||
});
|
||||
function handleMouseOut(e) {
|
||||
const link = e.target.closest('a[href]');
|
||||
if (!link || !prefetcher) return;
|
||||
|
||||
prefetcher.handleMouseLeave();
|
||||
}
|
||||
|
||||
function handlePopState() {
|
||||
@@ -97,40 +74,77 @@ function handlePopState() {
|
||||
}
|
||||
|
||||
export function getPrefetched(href) {
|
||||
const cached = prefetchCache.get(href);
|
||||
const valid = cached && Date.now() - cached.timestamp < cacheTTL;
|
||||
return valid ? cached.data : null;
|
||||
return prefetcher ? prefetcher.getCached(href) : null;
|
||||
}
|
||||
|
||||
export function prefetchHref(href) {
|
||||
if (!href || prefetchCache.has(href)) return;
|
||||
prefetch(href);
|
||||
if (!href || !prefetcher) return;
|
||||
prefetcher.prefetch(href, { priority: 'high' });
|
||||
}
|
||||
|
||||
export function init(onNavigate) {
|
||||
export function prefetchUrls(urls) {
|
||||
if (!prefetcher) return;
|
||||
prefetcher.prefetchEager(urls);
|
||||
}
|
||||
|
||||
export function init(onNavigate, prefetchOptions = {}) {
|
||||
callback = onNavigate;
|
||||
|
||||
// Initialize prefetcher with options
|
||||
prefetcher = new LinkPrefetcher({
|
||||
strategies: ['hover', 'visible'],
|
||||
hoverDelay: 150,
|
||||
observerMargin: '50px',
|
||||
maxCacheSize: 20,
|
||||
cacheTTL: 60000,
|
||||
...prefetchOptions
|
||||
});
|
||||
|
||||
unsubscribes = [
|
||||
useEvent(document, 'click', handleClick),
|
||||
useEvent(document, 'mouseover', handleMouseOver),
|
||||
useEvent(document, 'mouseout', handleMouseOut),
|
||||
useEvent(window, 'popstate', handlePopState),
|
||||
]
|
||||
|
||||
cleanupInterval = setInterval(() => {
|
||||
for (const [key, val] of prefetchCache.cache.entries()) {
|
||||
if (Date.now() - val.timestamp > prefetchCache.ttl) {
|
||||
prefetchCache.cache.delete(key);
|
||||
}
|
||||
];
|
||||
|
||||
// Observe all initial links if visible strategy is enabled
|
||||
if (prefetchOptions.strategies?.includes('visible') !== false) {
|
||||
// Wait for DOM to be ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
prefetcher.observeLinks();
|
||||
});
|
||||
} else {
|
||||
prefetcher.observeLinks();
|
||||
}
|
||||
}, 120000);
|
||||
}
|
||||
|
||||
// Support for eager prefetching via data attributes
|
||||
document.querySelectorAll('a[data-prefetch="eager"]').forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if (href) {
|
||||
prefetcher.prefetch(href, { priority: 'high' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Logger.info('[click-manager] ready');
|
||||
Logger.info('[click-manager] ready with prefetching');
|
||||
}
|
||||
|
||||
export function destroy() {
|
||||
callback = null;
|
||||
prefetchCache.clear();
|
||||
|
||||
if (prefetcher) {
|
||||
prefetcher.destroy();
|
||||
prefetcher = null;
|
||||
}
|
||||
|
||||
unsubscribes.forEach(unsub => unsub());
|
||||
unsubscribes = [];
|
||||
|
||||
Logger.info('[click-manager] destroyed');
|
||||
}
|
||||
|
||||
// Export prefetcher for advanced usage
|
||||
export function getPrefetcher() {
|
||||
return prefetcher;
|
||||
}
|
||||
|
||||
353
resources/js/core/DependencyManager.js
Normal file
353
resources/js/core/DependencyManager.js
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Dependency Management System für Module
|
||||
* Verwaltet Dependencies und Initialisierungsreihenfolge
|
||||
*/
|
||||
import { Logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ModuleDependency
|
||||
* @property {string} name - Module name
|
||||
* @property {string} version - Required version (semver)
|
||||
* @property {boolean} optional - Whether dependency is optional
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ModuleDefinition
|
||||
* @property {string} name - Module name
|
||||
* @property {string} version - Module version
|
||||
* @property {ModuleDependency[]} dependencies - Required dependencies
|
||||
* @property {string[]} provides - Services this module provides
|
||||
* @property {number} priority - Initialization priority (higher = earlier)
|
||||
*/
|
||||
|
||||
export class DependencyManager {
|
||||
constructor() {
|
||||
/** @type {Map<string, ModuleDefinition>} */
|
||||
this.modules = new Map();
|
||||
|
||||
/** @type {Map<string, string[]>} */
|
||||
this.dependents = new Map(); // who depends on this module
|
||||
|
||||
/** @type {Set<string>} */
|
||||
this.initialized = new Set();
|
||||
|
||||
/** @type {Set<string>} */
|
||||
this.initializing = new Set();
|
||||
|
||||
/** @type {string[]} */
|
||||
this.initializationOrder = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a module with its dependencies
|
||||
* @param {ModuleDefinition} definition - Module definition
|
||||
*/
|
||||
register(definition) {
|
||||
if (this.modules.has(definition.name)) {
|
||||
Logger.warn(`[DependencyManager] Module '${definition.name}' already registered`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate definition
|
||||
if (!this.validateDefinition(definition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modules.set(definition.name, definition);
|
||||
|
||||
// Build dependents map
|
||||
definition.dependencies.forEach(dep => {
|
||||
if (!this.dependents.has(dep.name)) {
|
||||
this.dependents.set(dep.name, []);
|
||||
}
|
||||
this.dependents.get(dep.name).push(definition.name);
|
||||
});
|
||||
|
||||
Logger.info(`[DependencyManager] Registered '${definition.name}' v${definition.version}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate initialization order based on dependencies
|
||||
* @returns {string[]} Ordered list of module names
|
||||
*/
|
||||
calculateInitializationOrder() {
|
||||
const visited = new Set();
|
||||
const visiting = new Set();
|
||||
const order = [];
|
||||
|
||||
/**
|
||||
* Depth-first search with cycle detection
|
||||
* @param {string} moduleName
|
||||
*/
|
||||
const visit = (moduleName) => {
|
||||
if (visiting.has(moduleName)) {
|
||||
throw new Error(`Circular dependency detected involving '${moduleName}'`);
|
||||
}
|
||||
|
||||
if (visited.has(moduleName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const module = this.modules.get(moduleName);
|
||||
if (!module) {
|
||||
return; // Module not registered, might be optional
|
||||
}
|
||||
|
||||
visiting.add(moduleName);
|
||||
|
||||
// Visit dependencies first
|
||||
module.dependencies.forEach(dep => {
|
||||
if (!dep.optional || this.modules.has(dep.name)) {
|
||||
visit(dep.name);
|
||||
}
|
||||
});
|
||||
|
||||
visiting.delete(moduleName);
|
||||
visited.add(moduleName);
|
||||
order.push(moduleName);
|
||||
};
|
||||
|
||||
// Sort by priority first, then resolve dependencies
|
||||
const modulesByPriority = Array.from(this.modules.entries())
|
||||
.sort(([, a], [, b]) => (b.priority || 0) - (a.priority || 0))
|
||||
.map(([name]) => name);
|
||||
|
||||
modulesByPriority.forEach(moduleName => {
|
||||
if (!visited.has(moduleName)) {
|
||||
visit(moduleName);
|
||||
}
|
||||
});
|
||||
|
||||
this.initializationOrder = order;
|
||||
Logger.info(`[DependencyManager] Initialization order: ${order.join(' → ')}`);
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all dependencies for a module are satisfied
|
||||
* @param {string} moduleName - Module to check
|
||||
* @returns {Object} Dependency check result
|
||||
*/
|
||||
checkDependencies(moduleName) {
|
||||
const module = this.modules.get(moduleName);
|
||||
if (!module) {
|
||||
return { satisfied: false, missing: [], reason: `Module '${moduleName}' not registered` };
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
const optional = [];
|
||||
|
||||
module.dependencies.forEach(dep => {
|
||||
const depModule = this.modules.get(dep.name);
|
||||
|
||||
if (!depModule) {
|
||||
if (dep.optional) {
|
||||
optional.push(dep.name);
|
||||
} else {
|
||||
missing.push(dep.name);
|
||||
}
|
||||
} else if (!this.initialized.has(dep.name)) {
|
||||
if (!dep.optional) {
|
||||
missing.push(`${dep.name} (not initialized)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
satisfied: missing.length === 0,
|
||||
missing,
|
||||
optional,
|
||||
reason: missing.length > 0 ? `Missing: ${missing.join(', ')}` : 'OK'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark module as initialized
|
||||
* @param {string} moduleName - Module name
|
||||
*/
|
||||
markInitialized(moduleName) {
|
||||
this.initialized.add(moduleName);
|
||||
this.initializing.delete(moduleName);
|
||||
Logger.info(`[DependencyManager] '${moduleName}' initialized`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark module as initializing
|
||||
* @param {string} moduleName - Module name
|
||||
*/
|
||||
markInitializing(moduleName) {
|
||||
this.initializing.add(moduleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get modules that depend on the given module
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {string[]} Dependent module names
|
||||
*/
|
||||
getDependents(moduleName) {
|
||||
return this.dependents.get(moduleName) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module definition
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {ModuleDefinition|undefined} Module definition
|
||||
*/
|
||||
getModule(moduleName) {
|
||||
return this.modules.get(moduleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if module is ready to initialize
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {boolean} Ready status
|
||||
*/
|
||||
isReadyToInitialize(moduleName) {
|
||||
if (this.initialized.has(moduleName) || this.initializing.has(moduleName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const check = this.checkDependencies(moduleName);
|
||||
return check.satisfied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initialization status
|
||||
* @returns {Object} Status report
|
||||
*/
|
||||
getStatus() {
|
||||
const total = this.modules.size;
|
||||
const initialized = this.initialized.size;
|
||||
const initializing = this.initializing.size;
|
||||
const pending = total - initialized - initializing;
|
||||
|
||||
return {
|
||||
total,
|
||||
initialized,
|
||||
initializing,
|
||||
pending,
|
||||
modules: {
|
||||
initialized: Array.from(this.initialized),
|
||||
initializing: Array.from(this.initializing),
|
||||
pending: Array.from(this.modules.keys()).filter(name =>
|
||||
!this.initialized.has(name) && !this.initializing.has(name)
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate module definition
|
||||
* @private
|
||||
* @param {ModuleDefinition} definition - Module definition to validate
|
||||
* @returns {boolean} Validation result
|
||||
*/
|
||||
validateDefinition(definition) {
|
||||
if (!definition.name || typeof definition.name !== 'string') {
|
||||
Logger.error('[DependencyManager] Module name is required and must be string');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!definition.version || typeof definition.version !== 'string') {
|
||||
Logger.error(`[DependencyManager] Version is required for module '${definition.name}'`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Array.isArray(definition.dependencies)) {
|
||||
Logger.error(`[DependencyManager] Dependencies must be array for module '${definition.name}'`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate dependencies
|
||||
for (const dep of definition.dependencies) {
|
||||
if (!dep.name || typeof dep.name !== 'string') {
|
||||
Logger.error(`[DependencyManager] Invalid dependency in module '${definition.name}': missing name`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset dependency manager
|
||||
*/
|
||||
reset() {
|
||||
this.modules.clear();
|
||||
this.dependents.clear();
|
||||
this.initialized.clear();
|
||||
this.initializing.clear();
|
||||
this.initializationOrder = [];
|
||||
Logger.info('[DependencyManager] Reset complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create module definition helper
|
||||
* @param {string} name - Module name
|
||||
* @param {string} version - Module version
|
||||
* @returns {Object} Module definition builder
|
||||
*/
|
||||
static createDefinition(name, version) {
|
||||
const definition = {
|
||||
name,
|
||||
version,
|
||||
dependencies: [],
|
||||
provides: [],
|
||||
priority: 0
|
||||
};
|
||||
|
||||
// Create a chainable API
|
||||
const builder = {
|
||||
depends(moduleName, moduleVersion = '*', optional = false) {
|
||||
definition.dependencies.push({
|
||||
name: moduleName,
|
||||
version: moduleVersion,
|
||||
optional
|
||||
});
|
||||
return this;
|
||||
},
|
||||
|
||||
provides(...services) {
|
||||
definition.provides.push(...services);
|
||||
return this;
|
||||
},
|
||||
|
||||
priority(level) {
|
||||
definition.priority = level;
|
||||
return this;
|
||||
},
|
||||
|
||||
// Return the final definition
|
||||
build() {
|
||||
return definition;
|
||||
}
|
||||
};
|
||||
|
||||
// Make the builder return the definition when accessed as an object
|
||||
Object.setPrototypeOf(builder, definition);
|
||||
|
||||
// Allow direct access to the definition properties
|
||||
Object.keys(definition).forEach(key => {
|
||||
if (!(key in builder)) {
|
||||
Object.defineProperty(builder, key, {
|
||||
get() { return definition[key]; },
|
||||
set(value) { definition[key] = value; },
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const dependencyManager = new DependencyManager();
|
||||
|
||||
// Debug access
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dependencyManager = dependencyManager;
|
||||
window.depStatus = () => dependencyManager.getStatus();
|
||||
}
|
||||
297
resources/js/core/LinkPrefetcher.js
Normal file
297
resources/js/core/LinkPrefetcher.js
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Advanced Link Prefetching System
|
||||
* Supports multiple strategies: hover, visible, eager
|
||||
*/
|
||||
import { Logger } from './logger.js';
|
||||
import { SimpleCache } from '../utils/cache.js';
|
||||
|
||||
export class LinkPrefetcher {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
maxCacheSize: options.maxCacheSize || 20,
|
||||
cacheTTL: options.cacheTTL || 60000, // 60 seconds
|
||||
hoverDelay: options.hoverDelay || 150, // ms before prefetch on hover
|
||||
strategies: options.strategies || ['hover', 'visible'], // Available: hover, visible, eager
|
||||
observerMargin: options.observerMargin || '50px',
|
||||
priority: options.priority || 'low', // low, high
|
||||
...options
|
||||
};
|
||||
|
||||
this.cache = new SimpleCache(this.options.maxCacheSize, this.options.cacheTTL);
|
||||
this.prefetching = new Set(); // Currently prefetching URLs
|
||||
this.prefetched = new Set(); // Already prefetched URLs
|
||||
this.observer = null;
|
||||
this.hoverTimeout = null;
|
||||
this.cleanupInterval = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Setup strategies
|
||||
if (this.options.strategies.includes('visible')) {
|
||||
this.setupIntersectionObserver();
|
||||
}
|
||||
|
||||
// Setup cleanup interval
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cache.cleanup();
|
||||
}, 120000); // Clean every 2 minutes
|
||||
|
||||
Logger.info('[LinkPrefetcher] initialized with strategies:', this.options.strategies);
|
||||
}
|
||||
|
||||
setupIntersectionObserver() {
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
Logger.warn('[LinkPrefetcher] IntersectionObserver not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const link = entry.target;
|
||||
const href = link.getAttribute('href');
|
||||
|
||||
// Lower priority for visible links
|
||||
this.prefetch(href, { priority: 'low' });
|
||||
|
||||
// Stop observing once prefetched
|
||||
this.observer.unobserve(link);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: this.options.observerMargin
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a link should be prefetched
|
||||
*/
|
||||
shouldPrefetch(link) {
|
||||
if (!link || !(link instanceof HTMLAnchorElement)) return false;
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip external links
|
||||
try {
|
||||
const url = new URL(href, window.location.origin);
|
||||
if (url.origin !== window.location.origin) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if has data-no-prefetch attribute
|
||||
if (link.hasAttribute('data-no-prefetch')) return false;
|
||||
|
||||
// Skip download links
|
||||
if (link.hasAttribute('download')) return false;
|
||||
|
||||
// Skip target="_blank" links
|
||||
if (link.target === '_blank') return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch a URL
|
||||
*/
|
||||
async prefetch(href, options = {}) {
|
||||
if (!href || this.prefetching.has(href) || this.cache.has(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as prefetching
|
||||
this.prefetching.add(href);
|
||||
|
||||
try {
|
||||
Logger.info(`[LinkPrefetcher] prefetching: ${href}`);
|
||||
|
||||
// Use different methods based on priority
|
||||
if (options.priority === 'high' || this.options.priority === 'high') {
|
||||
await this.fetchWithPriority(href, 'high');
|
||||
} else {
|
||||
// Use link prefetch hint for low priority
|
||||
this.createPrefetchLink(href);
|
||||
// Also fetch for cache
|
||||
await this.fetchWithPriority(href, 'low');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
Logger.warn(`[LinkPrefetcher] failed to prefetch ${href}:`, error);
|
||||
} finally {
|
||||
this.prefetching.delete(href);
|
||||
this.prefetched.add(href);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with priority
|
||||
*/
|
||||
async fetchWithPriority(href, priority = 'low') {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
|
||||
try {
|
||||
const response = await fetch(href, {
|
||||
signal: controller.signal,
|
||||
priority: priority, // Fetch priority hint (Chrome 121+)
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Prefetch': 'true'
|
||||
}
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const html = await response.text();
|
||||
this.cache.set(href, {
|
||||
data: html,
|
||||
timestamp: Date.now(),
|
||||
headers: {
|
||||
contentType: response.headers.get('content-type'),
|
||||
lastModified: response.headers.get('last-modified')
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name !== 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a link element for browser prefetch hint
|
||||
*/
|
||||
createPrefetchLink(href) {
|
||||
// Check if already exists
|
||||
if (document.querySelector(`link[rel="prefetch"][href="${href}"]`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.href = href;
|
||||
link.as = 'document';
|
||||
document.head.appendChild(link);
|
||||
|
||||
// Remove after some time to avoid cluttering
|
||||
setTimeout(() => link.remove(), 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hover events
|
||||
*/
|
||||
handleHover(link) {
|
||||
if (!this.shouldPrefetch(link)) return;
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
|
||||
clearTimeout(this.hoverTimeout);
|
||||
this.hoverTimeout = setTimeout(() => {
|
||||
this.prefetch(href, { priority: 'high' });
|
||||
}, this.options.hoverDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse leave
|
||||
*/
|
||||
handleMouseLeave() {
|
||||
clearTimeout(this.hoverTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe a link for intersection
|
||||
*/
|
||||
observeLink(link) {
|
||||
if (!this.observer || !this.shouldPrefetch(link)) return;
|
||||
|
||||
this.observer.observe(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe all links in a container
|
||||
*/
|
||||
observeLinks(container = document) {
|
||||
if (!this.options.strategies.includes('visible')) return;
|
||||
|
||||
const links = container.querySelectorAll('a[href]');
|
||||
links.forEach(link => this.observeLink(link));
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagerly prefetch specific URLs
|
||||
*/
|
||||
prefetchEager(urls) {
|
||||
if (!Array.isArray(urls)) urls = [urls];
|
||||
|
||||
urls.forEach(url => {
|
||||
this.prefetch(url, { priority: 'high' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached content
|
||||
*/
|
||||
getCached(href) {
|
||||
const cached = this.cache.get(href);
|
||||
return cached ? cached.data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL is cached
|
||||
*/
|
||||
isCached(href) {
|
||||
return this.cache.has(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
*/
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
this.prefetched.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
cacheSize: this.cache.size(),
|
||||
prefetching: this.prefetching.size,
|
||||
prefetched: this.prefetched.size,
|
||||
strategies: this.options.strategies
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the prefetcher
|
||||
*/
|
||||
destroy() {
|
||||
clearTimeout(this.hoverTimeout);
|
||||
clearInterval(this.cleanupInterval);
|
||||
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
|
||||
this.cache.clear();
|
||||
this.prefetching.clear();
|
||||
this.prefetched.clear();
|
||||
|
||||
// Remove all prefetch links
|
||||
document.querySelectorAll('link[rel="prefetch"]').forEach(link => link.remove());
|
||||
|
||||
Logger.info('[LinkPrefetcher] destroyed');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const linkPrefetcher = new LinkPrefetcher();
|
||||
223
resources/js/core/ModuleErrorBoundary.js
Normal file
223
resources/js/core/ModuleErrorBoundary.js
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Error Boundary System für Module
|
||||
* Bietet Schutz vor Module-Crashes und Recovery-Mechanismen
|
||||
*/
|
||||
import { Logger } from './logger.js';
|
||||
|
||||
export class ModuleErrorBoundary {
|
||||
constructor() {
|
||||
this.crashedModules = new Set();
|
||||
this.recoveryAttempts = new Map();
|
||||
this.maxRecoveryAttempts = 3;
|
||||
this.recoveryDelay = 1000; // 1 second
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a module with error handling
|
||||
* @param {Object} module - The module to wrap
|
||||
* @param {string} moduleName - Name of the module for logging
|
||||
* @returns {Proxy} - Protected module instance
|
||||
*/
|
||||
wrapModule(module, moduleName) {
|
||||
if (!module || typeof module !== 'object') {
|
||||
Logger.warn(`[ErrorBoundary] Cannot wrap non-object module: ${moduleName}`);
|
||||
return module;
|
||||
}
|
||||
|
||||
return new Proxy(module, {
|
||||
get: (target, prop, receiver) => {
|
||||
const originalValue = target[prop];
|
||||
|
||||
// If property is not a function, return as-is
|
||||
if (typeof originalValue !== 'function') {
|
||||
return originalValue;
|
||||
}
|
||||
|
||||
// Check if property is non-configurable - return original if so
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target, prop);
|
||||
if (descriptor && !descriptor.configurable) {
|
||||
return originalValue;
|
||||
}
|
||||
|
||||
// Return wrapped function with error handling
|
||||
return (...args) => {
|
||||
try {
|
||||
const result = originalValue.apply(target, args);
|
||||
|
||||
// Handle async functions
|
||||
if (result && typeof result.catch === 'function') {
|
||||
return result.catch(error => {
|
||||
this.handleModuleError(error, moduleName, prop, args);
|
||||
return this.getRecoveryValue(moduleName, prop);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.handleModuleError(error, moduleName, prop, args);
|
||||
return this.getRecoveryValue(moduleName, prop);
|
||||
}
|
||||
};
|
||||
},
|
||||
getOwnPropertyDescriptor: (target, prop) => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target, prop);
|
||||
|
||||
// For non-configurable properties, return original descriptor
|
||||
if (descriptor && !descriptor.configurable) {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
},
|
||||
has: (target, prop) => {
|
||||
return prop in target;
|
||||
},
|
||||
ownKeys: (target) => {
|
||||
return Object.getOwnPropertyNames(target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles module errors with recovery logic
|
||||
* @param {Error} error - The error that occurred
|
||||
* @param {string} moduleName - Name of the failing module
|
||||
* @param {string} method - Method that failed
|
||||
* @param {Array} args - Arguments passed to the method
|
||||
*/
|
||||
handleModuleError(error, moduleName, method, args) {
|
||||
const errorKey = `${moduleName}.${method}`;
|
||||
|
||||
Logger.error(`[ErrorBoundary] Module ${moduleName} crashed in ${method}():`, error);
|
||||
|
||||
// Track crashed modules
|
||||
this.crashedModules.add(moduleName);
|
||||
|
||||
// Track recovery attempts
|
||||
const attempts = this.recoveryAttempts.get(errorKey) || 0;
|
||||
this.recoveryAttempts.set(errorKey, attempts + 1);
|
||||
|
||||
// Emit custom event for external handling
|
||||
window.dispatchEvent(new CustomEvent('module-error', {
|
||||
detail: {
|
||||
moduleName,
|
||||
method,
|
||||
error: error.message,
|
||||
args,
|
||||
attempts: attempts + 1
|
||||
}
|
||||
}));
|
||||
|
||||
// Attempt recovery if under limit
|
||||
if (attempts < this.maxRecoveryAttempts) {
|
||||
this.scheduleRecovery(moduleName, method, args);
|
||||
} else {
|
||||
Logger.error(`[ErrorBoundary] Module ${moduleName} exceeded recovery attempts. Marking as permanently failed.`);
|
||||
this.markModuleAsPermanentlyFailed(moduleName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a recovery attempt for a failed module method
|
||||
* @param {string} moduleName - Name of the module
|
||||
* @param {string} method - Method to retry
|
||||
* @param {Array} args - Original arguments
|
||||
*/
|
||||
scheduleRecovery(moduleName, method, args) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
Logger.info(`[ErrorBoundary] Attempting recovery for ${moduleName}.${method}()`);
|
||||
// This would need module registry integration
|
||||
// For now, just log the attempt
|
||||
} catch (recoveryError) {
|
||||
Logger.error(`[ErrorBoundary] Recovery failed for ${moduleName}.${method}():`, recoveryError);
|
||||
}
|
||||
}, this.recoveryDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a safe fallback value for failed module methods
|
||||
* @param {string} moduleName - Name of the module
|
||||
* @param {string} method - Method that failed
|
||||
* @returns {*} - Safe fallback value
|
||||
*/
|
||||
getRecoveryValue(moduleName, method) {
|
||||
// Return safe defaults based on common method names
|
||||
switch (method) {
|
||||
case 'init':
|
||||
case 'destroy':
|
||||
case 'update':
|
||||
case 'render':
|
||||
return Promise.resolve();
|
||||
case 'getData':
|
||||
case 'getConfig':
|
||||
return {};
|
||||
case 'isEnabled':
|
||||
case 'isActive':
|
||||
return false;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a module as permanently failed
|
||||
* @param {string} moduleName - Name of the module
|
||||
*/
|
||||
markModuleAsPermanentlyFailed(moduleName) {
|
||||
window.dispatchEvent(new CustomEvent('module-permanent-failure', {
|
||||
detail: { moduleName }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets health status of all modules
|
||||
* @returns {Object} - Health status report
|
||||
*/
|
||||
getHealthStatus() {
|
||||
return {
|
||||
totalCrashedModules: this.crashedModules.size,
|
||||
crashedModules: Array.from(this.crashedModules),
|
||||
recoveryAttempts: Object.fromEntries(this.recoveryAttempts),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets error tracking for a module (useful for hot reload)
|
||||
* @param {string} moduleName - Name of the module to reset
|
||||
*/
|
||||
resetModule(moduleName) {
|
||||
this.crashedModules.delete(moduleName);
|
||||
|
||||
// Remove all recovery attempts for this module
|
||||
for (const [key] of this.recoveryAttempts) {
|
||||
if (key.startsWith(`${moduleName}.`)) {
|
||||
this.recoveryAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(`[ErrorBoundary] Reset error tracking for module: ${moduleName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all error tracking
|
||||
*/
|
||||
reset() {
|
||||
this.crashedModules.clear();
|
||||
this.recoveryAttempts.clear();
|
||||
Logger.info('[ErrorBoundary] Reset all error tracking');
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const moduleErrorBoundary = new ModuleErrorBoundary();
|
||||
|
||||
// Global error handlers
|
||||
window.addEventListener('error', (event) => {
|
||||
Logger.error('[Global] Unhandled error:', event.error || event.message);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
Logger.error('[Global] Unhandled promise rejection:', event.reason);
|
||||
});
|
||||
286
resources/js/core/StateManager.js
Normal file
286
resources/js/core/StateManager.js
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Reactive State Management System für Module-Kommunikation
|
||||
* Ermöglicht type-safe state sharing zwischen Modulen
|
||||
*/
|
||||
import { Logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} StateChangeEvent
|
||||
* @property {string} key - State key that changed
|
||||
* @property {*} value - New value
|
||||
* @property {*} oldValue - Previous value
|
||||
* @property {string} source - Module that triggered the change
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} StateSubscription
|
||||
* @property {string} id - Unique subscription ID
|
||||
* @property {Function} callback - Callback function
|
||||
* @property {string} subscriber - Module name that subscribed
|
||||
*/
|
||||
|
||||
export class StateManager {
|
||||
constructor() {
|
||||
/** @type {Map<string, any>} */
|
||||
this.state = new Map();
|
||||
|
||||
/** @type {Map<string, StateSubscription[]>} */
|
||||
this.subscribers = new Map();
|
||||
|
||||
/** @type {Map<string, string>} */
|
||||
this.stateOwners = new Map();
|
||||
|
||||
/** @type {Map<string, any>} */
|
||||
this.defaultValues = new Map();
|
||||
|
||||
/** @type {string} */
|
||||
this.currentModule = 'unknown';
|
||||
|
||||
this.subscriptionCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current module context for state operations
|
||||
* @param {string} moduleName - Name of the module
|
||||
*/
|
||||
setContext(moduleName) {
|
||||
this.currentModule = moduleName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a state key with default value and owner
|
||||
* @param {string} key - State key
|
||||
* @param {*} defaultValue - Default value
|
||||
* @param {string} [owner] - Module that owns this state
|
||||
*/
|
||||
register(key, defaultValue, owner = this.currentModule) {
|
||||
if (this.state.has(key)) {
|
||||
Logger.warn(`[StateManager] State key '${key}' already registered by ${this.stateOwners.get(key)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.set(key, defaultValue);
|
||||
this.defaultValues.set(key, defaultValue);
|
||||
this.stateOwners.set(key, owner);
|
||||
this.subscribers.set(key, []);
|
||||
|
||||
Logger.info(`[StateManager] Registered '${key}' (owner: ${owner})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current value of state key
|
||||
* @param {string} key - State key
|
||||
* @returns {*} Current value or undefined
|
||||
*/
|
||||
get(key) {
|
||||
if (!this.state.has(key)) {
|
||||
Logger.warn(`[StateManager] Unknown state key: '${key}'`);
|
||||
return undefined;
|
||||
}
|
||||
return this.state.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set value for state key (with ownership check)
|
||||
* @param {string} key - State key
|
||||
* @param {*} value - New value
|
||||
* @param {boolean} [force=false] - Force set even if not owner
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
set(key, value, force = false) {
|
||||
if (!this.state.has(key)) {
|
||||
Logger.warn(`[StateManager] Cannot set unknown state key: '${key}'`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const owner = this.stateOwners.get(key);
|
||||
if (!force && owner !== this.currentModule) {
|
||||
Logger.warn(`[StateManager] Module '${this.currentModule}' cannot modify '${key}' (owned by ${owner})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldValue = this.state.get(key);
|
||||
if (oldValue === value) {
|
||||
return true; // No change
|
||||
}
|
||||
|
||||
this.state.set(key, value);
|
||||
this.notifySubscribers(key, value, oldValue);
|
||||
|
||||
Logger.info(`[StateManager] Updated '${key}' by ${this.currentModule}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to state changes
|
||||
* @param {string} key - State key to watch
|
||||
* @param {Function} callback - Callback function (value, oldValue, key) => void
|
||||
* @param {string} [subscriber] - Subscriber module name
|
||||
* @returns {string} Subscription ID for unsubscribing
|
||||
*/
|
||||
subscribe(key, callback, subscriber = this.currentModule) {
|
||||
if (!this.state.has(key)) {
|
||||
Logger.warn(`[StateManager] Cannot subscribe to unknown state key: '${key}'`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionId = `${subscriber}_${++this.subscriptionCounter}`;
|
||||
const subscription = {
|
||||
id: subscriptionId,
|
||||
callback,
|
||||
subscriber
|
||||
};
|
||||
|
||||
if (!this.subscribers.has(key)) {
|
||||
this.subscribers.set(key, []);
|
||||
}
|
||||
|
||||
this.subscribers.get(key).push(subscription);
|
||||
Logger.info(`[StateManager] Subscribed '${subscriber}' to '${key}'`);
|
||||
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from state changes
|
||||
* @param {string} subscriptionId - Subscription ID from subscribe()
|
||||
*/
|
||||
unsubscribe(subscriptionId) {
|
||||
for (const [key, subscriptions] of this.subscribers.entries()) {
|
||||
const index = subscriptions.findIndex(sub => sub.id === subscriptionId);
|
||||
if (index !== -1) {
|
||||
const subscription = subscriptions[index];
|
||||
subscriptions.splice(index, 1);
|
||||
Logger.info(`[StateManager] Unsubscribed '${subscription.subscriber}' from '${key}'`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Logger.warn(`[StateManager] Subscription ID not found: ${subscriptionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all subscribers of a state change
|
||||
* @private
|
||||
* @param {string} key - State key
|
||||
* @param {*} value - New value
|
||||
* @param {*} oldValue - Previous value
|
||||
*/
|
||||
notifySubscribers(key, value, oldValue) {
|
||||
const subscriptions = this.subscribers.get(key) || [];
|
||||
|
||||
subscriptions.forEach(subscription => {
|
||||
try {
|
||||
subscription.callback(value, oldValue, key);
|
||||
} catch (error) {
|
||||
Logger.error(`[StateManager] Error in subscriber '${subscription.subscriber}' for '${key}':`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state key to default value
|
||||
* @param {string} key - State key to reset
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
reset(key) {
|
||||
if (!this.state.has(key)) {
|
||||
Logger.warn(`[StateManager] Cannot reset unknown state key: '${key}'`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const defaultValue = this.defaultValues.get(key);
|
||||
return this.set(key, defaultValue, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all subscriptions for a module (useful for cleanup)
|
||||
* @param {string} moduleName - Module name
|
||||
*/
|
||||
clearModuleSubscriptions(moduleName) {
|
||||
let cleared = 0;
|
||||
|
||||
for (const [key, subscriptions] of this.subscribers.entries()) {
|
||||
const filtered = subscriptions.filter(sub => sub.subscriber !== moduleName);
|
||||
cleared += subscriptions.length - filtered.length;
|
||||
this.subscribers.set(key, filtered);
|
||||
}
|
||||
|
||||
if (cleared > 0) {
|
||||
Logger.info(`[StateManager] Cleared ${cleared} subscriptions for module '${moduleName}'`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state snapshot (for debugging)
|
||||
* @returns {Object} Current state and metadata
|
||||
*/
|
||||
getSnapshot() {
|
||||
const snapshot = {
|
||||
state: Object.fromEntries(this.state),
|
||||
owners: Object.fromEntries(this.stateOwners),
|
||||
subscriptions: {}
|
||||
};
|
||||
|
||||
for (const [key, subs] of this.subscribers.entries()) {
|
||||
snapshot.subscriptions[key] = subs.map(sub => ({
|
||||
id: sub.id,
|
||||
subscriber: sub.subscriber
|
||||
}));
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset entire state manager (useful for testing)
|
||||
*/
|
||||
resetAll() {
|
||||
this.state.clear();
|
||||
this.subscribers.clear();
|
||||
this.stateOwners.clear();
|
||||
this.defaultValues.clear();
|
||||
this.subscriptionCounter = 0;
|
||||
Logger.info('[StateManager] Reset complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scoped state manager for a specific module
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {Object} Scoped state interface
|
||||
*/
|
||||
createScope(moduleName) {
|
||||
return {
|
||||
register: (key, defaultValue) => {
|
||||
this.setContext(moduleName);
|
||||
return this.register(key, defaultValue, moduleName);
|
||||
},
|
||||
|
||||
get: (key) => this.get(key),
|
||||
|
||||
set: (key, value) => {
|
||||
this.setContext(moduleName);
|
||||
return this.set(key, value);
|
||||
},
|
||||
|
||||
subscribe: (key, callback) => {
|
||||
this.setContext(moduleName);
|
||||
return this.subscribe(key, callback, moduleName);
|
||||
},
|
||||
|
||||
unsubscribe: (subscriptionId) => this.unsubscribe(subscriptionId),
|
||||
|
||||
reset: (key) => this.reset(key),
|
||||
|
||||
cleanup: () => this.clearModuleSubscriptions(moduleName)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const stateManager = new StateManager();
|
||||
|
||||
// Debug access
|
||||
if (typeof window !== 'undefined') {
|
||||
window.stateManager = stateManager;
|
||||
window.stateSnapshot = () => stateManager.getSnapshot();
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
// modules/core/frameloop.js
|
||||
import {Logger} from "./logger";
|
||||
|
||||
/**
|
||||
* @typedef {Object} FrameTaskOptions
|
||||
* @property {boolean} [autoStart=false] - Automatically start the frame loop
|
||||
*/
|
||||
|
||||
/** @type {Map<string, Function>} */
|
||||
const tasks = new Map();
|
||||
/** @type {boolean} */
|
||||
let running = false;
|
||||
/** @type {boolean} */
|
||||
let showDebug = false;
|
||||
|
||||
let lastTime = performance.now();
|
||||
let frameCount = 0;
|
||||
let fps = 0;
|
||||
|
||||
|
||||
// Create debug overlay
|
||||
const debugOverlay = document.createElement('div');
|
||||
debugOverlay.style.position = 'fixed';
|
||||
debugOverlay.style.bottom = '0';
|
||||
@@ -28,8 +36,10 @@ debugOverlay.style.lineHeight = '1.4';
|
||||
document.body.appendChild(debugOverlay);
|
||||
|
||||
import { PerformanceMonitor } from './PerformanceMonitor.js';
|
||||
/** @type {PerformanceMonitor} */
|
||||
export const monitor = new PerformanceMonitor();
|
||||
|
||||
// Debug toggle with § key
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === '§') {
|
||||
showDebug = !showDebug;
|
||||
@@ -37,19 +47,35 @@ window.addEventListener('keydown', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Register a task to run on every frame
|
||||
* @param {string} id - Unique identifier for the task
|
||||
* @param {Function} callback - Function to execute each frame
|
||||
* @param {FrameTaskOptions} [options={}] - Task options
|
||||
*/
|
||||
export function registerFrameTask(id, callback, options = {}) {
|
||||
tasks.set(id, callback);
|
||||
if (options.autoStart && !running) startFrameLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a frame task
|
||||
* @param {string} id - Task identifier to remove
|
||||
*/
|
||||
export function unregisterFrameTask(id) {
|
||||
tasks.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered frame tasks
|
||||
*/
|
||||
export function clearFrameTasks() {
|
||||
tasks.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the main frame loop
|
||||
*/
|
||||
export function startFrameLoop() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
@@ -77,6 +103,10 @@ export function startFrameLoop() {
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the frame loop
|
||||
* Note: Loop continues until next frame, this just sets the flag
|
||||
*/
|
||||
export function stopFrameLoop() {
|
||||
running = false;
|
||||
// Achtung: Loop läuft weiter, solange nicht aktiv gestoppt
|
||||
|
||||
@@ -8,8 +8,8 @@ closeBtn.addEventListener("click", () => {
|
||||
*/
|
||||
|
||||
|
||||
import {registerModules} from "../modules";
|
||||
|
||||
import { registerModules, activeModules } from "../modules/index.js";
|
||||
import { Logger } from './logger.js';
|
||||
import {useEvent} from "./useEvent";
|
||||
|
||||
|
||||
@@ -100,13 +100,184 @@ document.startViewTransition(() => {
|
||||
container.innerHTML = newContent;
|
||||
});*/
|
||||
|
||||
import { fadeScrollTrigger, zoomScrollTrigger, fixedZoomScrollTrigger } from '../modules/scrollfx/Tween.js';
|
||||
import {autoLoadResponsiveVideos} from "../utils/autoLoadResponsiveVideo";
|
||||
|
||||
/**
|
||||
* Initialize DOM elements with data-module attributes
|
||||
*/
|
||||
function initDataModuleElements() {
|
||||
const elements = document.querySelectorAll('[data-module]');
|
||||
Logger.info(`[DOMInit] Found ${elements.length} elements with data-module attributes`);
|
||||
|
||||
elements.forEach(element => {
|
||||
const moduleName = element.dataset.module;
|
||||
const moduleData = activeModules.get(moduleName);
|
||||
|
||||
if (!moduleData || !moduleData.mod) {
|
||||
Logger.warn(`[DOMInit] Module "${moduleName}" not found or failed to initialize`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse options from data-options attribute
|
||||
let options = {};
|
||||
try {
|
||||
if (element.dataset.options) {
|
||||
options = JSON.parse(element.dataset.options);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn(`[DOMInit] Invalid JSON in data-options for ${moduleName}:`, error);
|
||||
}
|
||||
|
||||
// Initialize the module on this element
|
||||
try {
|
||||
const moduleInstance = moduleData.mod;
|
||||
|
||||
// Check for element-specific init method first, then fallback to general init
|
||||
if (typeof moduleInstance.initElement === 'function') {
|
||||
const result = moduleInstance.initElement(element, options);
|
||||
Logger.info(`[DOMInit] Initialized ${moduleName} on element:`, element);
|
||||
|
||||
// Store reference for cleanup
|
||||
element._moduleInstance = result;
|
||||
element._moduleName = moduleName;
|
||||
} else if (typeof moduleInstance.init === 'function') {
|
||||
const result = moduleInstance.init(element, options);
|
||||
Logger.info(`[DOMInit] Initialized ${moduleName} on element:`, element);
|
||||
|
||||
// Store reference for cleanup
|
||||
element._moduleInstance = result;
|
||||
element._moduleName = moduleName;
|
||||
} else {
|
||||
Logger.warn(`[DOMInit] Module ${moduleName} has no init method for DOM elements`);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[DOMInit] Failed to initialize ${moduleName} on element:`, error, element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize SPA Router for seamless navigation
|
||||
*/
|
||||
function initSPARouter() {
|
||||
const spaRouterModule = activeModules.get('spa-router');
|
||||
|
||||
if (!spaRouterModule || !spaRouterModule.mod) {
|
||||
Logger.info('[Init] SPA Router module not available, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize SPA Router with default configuration
|
||||
const router = spaRouterModule.mod.init({
|
||||
containerSelector: 'main',
|
||||
enableTransitions: true,
|
||||
transitionDuration: 300
|
||||
});
|
||||
|
||||
// Make functions globally available for re-initialization
|
||||
window.initAutoFormHandling = initAutoFormHandling;
|
||||
window.initDataModuleElements = initDataModuleElements;
|
||||
|
||||
Logger.info('[Init] SPA Router initialized successfully');
|
||||
} catch (error) {
|
||||
Logger.error('[Init] Failed to initialize SPA Router:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-initialize all forms with form-handling (opt-out approach)
|
||||
*/
|
||||
function initAutoFormHandling() {
|
||||
// Find all forms except those explicitly opting out and those already enhanced
|
||||
const forms = document.querySelectorAll('form:not([data-form-handling="false"]):not([data-auto-enhanced])');
|
||||
Logger.info(`[AutoForms] Found ${forms.length} forms for auto-enhancement`);
|
||||
|
||||
if (forms.length === 0) return;
|
||||
|
||||
// Check if form-handling module is available
|
||||
const formHandlingModule = activeModules.get('form-handling');
|
||||
if (!formHandlingModule || !formHandlingModule.mod) {
|
||||
Logger.warn('[AutoForms] form-handling module not available, skipping auto-init');
|
||||
return;
|
||||
}
|
||||
|
||||
forms.forEach(form => {
|
||||
// Skip forms that already have explicit data-module
|
||||
if (form.hasAttribute('data-module')) {
|
||||
Logger.info(`[AutoForms] Skipping form with explicit data-module:`, form);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip forms that are already enhanced (double-check)
|
||||
if (form.hasAttribute('data-auto-enhanced')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse options from data-form-options attribute
|
||||
let options = {};
|
||||
try {
|
||||
if (form.dataset.formOptions) {
|
||||
options = JSON.parse(form.dataset.formOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn('[AutoForms] Invalid JSON in data-form-options:', error);
|
||||
}
|
||||
|
||||
// Set default options for auto-enhanced forms
|
||||
const autoOptions = {
|
||||
validateOnSubmit: true,
|
||||
validateOnBlur: false,
|
||||
validateOnInput: false,
|
||||
showInlineErrors: true,
|
||||
ajaxSubmit: true,
|
||||
enableStateTracking: false, // Conservative default
|
||||
...options
|
||||
};
|
||||
|
||||
// Initialize form handling
|
||||
try {
|
||||
// Use initElement for DOM-specific initialization
|
||||
const result = formHandlingModule.mod.initElement ?
|
||||
formHandlingModule.mod.initElement(form, autoOptions) :
|
||||
formHandlingModule.mod.init(form, autoOptions);
|
||||
|
||||
Logger.info('[AutoForms] Auto-enhanced form:', {
|
||||
id: form.id || 'unnamed',
|
||||
action: form.action || 'none',
|
||||
method: form.method || 'get',
|
||||
elements: form.elements.length
|
||||
});
|
||||
|
||||
// Mark as auto-enhanced
|
||||
form.setAttribute('data-auto-enhanced', 'true');
|
||||
form._moduleInstance = result;
|
||||
form._moduleName = 'form-handling';
|
||||
} catch (error) {
|
||||
Logger.error('[AutoForms] Failed to auto-enhance form:', error, form);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the application
|
||||
* Sets up all modules and core functionality
|
||||
* @async
|
||||
* @function initApp
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function initApp() {
|
||||
|
||||
await registerModules();
|
||||
|
||||
// Initialize DOM elements after modules are registered
|
||||
initDataModuleElements();
|
||||
|
||||
// Auto-enhance all forms with form-handling
|
||||
initAutoFormHandling();
|
||||
|
||||
// Initialize SPA Router for seamless navigation
|
||||
initSPARouter();
|
||||
|
||||
autoLoadResponsiveVideos();
|
||||
|
||||
|
||||
@@ -1,37 +1,135 @@
|
||||
import {monitor} from "./frameloop";
|
||||
|
||||
/**
|
||||
* Centralized logging system with performance monitoring integration
|
||||
* @class Logger
|
||||
*/
|
||||
export class Logger {
|
||||
static enabled = true //import.meta.env.MODE !== 'production';
|
||||
/** @type {boolean} Enable/disable logging */
|
||||
static enabled = import.meta.env.MODE !== 'production';
|
||||
|
||||
/** @type {string} Log level: 'debug', 'info', 'warn', 'error', 'none' */
|
||||
static level = import.meta.env.VITE_LOG_LEVEL || (import.meta.env.MODE === 'production' ? 'error' : 'debug');
|
||||
|
||||
/** @type {Object} Log level priorities */
|
||||
static levels = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
none: 99
|
||||
};
|
||||
|
||||
/**
|
||||
* Log debug messages (development only)
|
||||
* @param {...any} args - Arguments to log
|
||||
*/
|
||||
static debug(...args) {
|
||||
this._write('log', '[DEBUG]', args, 'debug');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log general information
|
||||
* @param {...any} args - Arguments to log
|
||||
*/
|
||||
static log(...args) {
|
||||
this._write('log', '[LOG]', args);
|
||||
}
|
||||
|
||||
static warn(...args) {
|
||||
this._write('warn', '[WARN]', args)
|
||||
this._write('log', '[LOG]', args, 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log informational messages
|
||||
* @param {...any} args - Arguments to log
|
||||
*/
|
||||
static info(...args) {
|
||||
this._write('info', '[INFO]', args);
|
||||
this._write('info', '[INFO]', args, 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning messages
|
||||
* @param {...any} args - Arguments to log
|
||||
*/
|
||||
static warn(...args) {
|
||||
this._write('warn', '[WARN]', args, 'warn');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error messages
|
||||
* @param {...any} args - Arguments to log
|
||||
*/
|
||||
static error(...args) {
|
||||
this._write('error', '[ERROR]', args);
|
||||
this._write('error', '[ERROR]', args, 'error');
|
||||
}
|
||||
|
||||
static _write(consoleMethod, prefix, args) {
|
||||
if(!this.enabled) return;
|
||||
/**
|
||||
* Internal method to write log messages
|
||||
* @private
|
||||
* @param {string} consoleMethod - Console method to use
|
||||
* @param {string} prefix - Log level prefix
|
||||
* @param {Array} args - Arguments to log
|
||||
* @param {string} logLevel - Log level (debug, info, warn, error)
|
||||
*/
|
||||
static _write(consoleMethod, prefix, args, logLevel = 'info') {
|
||||
if (!this.enabled) return;
|
||||
|
||||
// Check if this log level should be output
|
||||
const currentLevelPriority = this.levels[this.level] || this.levels.info;
|
||||
const messageLevelPriority = this.levels[logLevel] || this.levels.info;
|
||||
|
||||
if (messageLevelPriority < currentLevelPriority) {
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date();
|
||||
const timestamp = date.toLocaleTimeString('de-DE');
|
||||
|
||||
const msg = `${prefix} [${timestamp}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ')}`;
|
||||
const msg = `${prefix} [${timestamp}] ${args.map(a => {
|
||||
if (a instanceof Error) {
|
||||
return `${a.name}: ${a.message}\n${a.stack || ''}`;
|
||||
} else if (typeof a === 'object' && a !== null) {
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch (e) {
|
||||
return '[Circular Object]';
|
||||
}
|
||||
}
|
||||
return a;
|
||||
}).join(' ')}`;
|
||||
|
||||
if(typeof console[consoleMethod] === 'function') {
|
||||
console[consoleMethod](msg);
|
||||
}
|
||||
|
||||
monitor?.log(msg)
|
||||
// Safe monitor logging
|
||||
if (monitor && typeof monitor.log === 'function') {
|
||||
try {
|
||||
monitor.log(msg);
|
||||
} catch (e) {
|
||||
// Silent fail if monitor fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set log level dynamically
|
||||
* @param {string} level - New log level (debug, info, warn, error, none)
|
||||
*/
|
||||
static setLevel(level) {
|
||||
if (this.levels.hasOwnProperty(level)) {
|
||||
this.level = level;
|
||||
this.info(`[Logger] Log level set to: ${level}`);
|
||||
} else {
|
||||
this.warn(`[Logger] Invalid log level: ${level}. Valid levels:`, Object.keys(this.levels));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable logging
|
||||
* @param {boolean} enabled - Enable or disable logging
|
||||
*/
|
||||
static setEnabled(enabled) {
|
||||
this.enabled = !!enabled;
|
||||
if (enabled) {
|
||||
console.log('[Logger] Logging enabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
78
resources/js/core/prefetch-config.js
Normal file
78
resources/js/core/prefetch-config.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Prefetching Configuration Examples
|
||||
*
|
||||
* Use these configurations with startRouter() or directly with LinkPrefetcher
|
||||
*/
|
||||
|
||||
// Default configuration - balanced performance
|
||||
export const defaultConfig = {
|
||||
strategies: ['hover', 'visible'], // Prefetch on hover and when visible
|
||||
hoverDelay: 150, // Wait 150ms before prefetching on hover
|
||||
observerMargin: '50px', // Start prefetching when link is 50px from viewport
|
||||
maxCacheSize: 20, // Cache up to 20 pages
|
||||
cacheTTL: 60000, // Cache for 60 seconds
|
||||
priority: 'low' // Use low priority for network requests
|
||||
};
|
||||
|
||||
// Aggressive prefetching - for fast networks
|
||||
export const aggressiveConfig = {
|
||||
strategies: ['hover', 'visible', 'eager'],
|
||||
hoverDelay: 50, // Faster hover response
|
||||
observerMargin: '200px', // Prefetch earlier
|
||||
maxCacheSize: 50, // Larger cache
|
||||
cacheTTL: 300000, // Cache for 5 minutes
|
||||
priority: 'high' // High priority requests
|
||||
};
|
||||
|
||||
// Conservative prefetching - for slow networks or mobile
|
||||
export const conservativeConfig = {
|
||||
strategies: ['hover'], // Only prefetch on hover
|
||||
hoverDelay: 300, // Wait longer before prefetching
|
||||
observerMargin: '0px', // Don't use intersection observer
|
||||
maxCacheSize: 10, // Smaller cache
|
||||
cacheTTL: 30000, // Cache for 30 seconds
|
||||
priority: 'low' // Low priority
|
||||
};
|
||||
|
||||
// Minimal prefetching - disabled by default
|
||||
export const minimalConfig = {
|
||||
strategies: [], // No automatic prefetching
|
||||
maxCacheSize: 5, // Minimal cache
|
||||
cacheTTL: 15000, // Short cache duration
|
||||
};
|
||||
|
||||
// Adaptive configuration based on connection
|
||||
export function getAdaptiveConfig() {
|
||||
// Use Network Information API if available
|
||||
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
|
||||
if (!connection) {
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
// Check effective connection type
|
||||
if (connection.effectiveType === '4g' && !connection.saveData) {
|
||||
return aggressiveConfig;
|
||||
} else if (connection.effectiveType === '3g' || connection.saveData) {
|
||||
return conservativeConfig;
|
||||
} else if (connection.effectiveType === '2g' || connection.effectiveType === 'slow-2g') {
|
||||
return minimalConfig;
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
// Custom configuration for specific pages
|
||||
export function getPageSpecificConfig(pathname) {
|
||||
// Disable prefetching on certain pages
|
||||
if (pathname.includes('/admin') || pathname.includes('/checkout')) {
|
||||
return minimalConfig;
|
||||
}
|
||||
|
||||
// Aggressive prefetching on landing pages
|
||||
if (pathname === '/' || pathname === '/products') {
|
||||
return aggressiveConfig;
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
@@ -90,9 +90,10 @@ function animateLayoutSwitch(type) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Router
|
||||
* Startet den Router mit optionaler Prefetch-Konfiguration
|
||||
* @param {Object} prefetchOptions - Optionen für das Prefetching-System
|
||||
*/
|
||||
export function startRouter() {
|
||||
export function startRouter(prefetchOptions = {}) {
|
||||
initClickManager((href, link, options) => {
|
||||
if (!runGuard(href)) return;
|
||||
|
||||
@@ -122,7 +123,7 @@ export function startRouter() {
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, prefetchOptions);
|
||||
|
||||
// Bei Seitenstart erste Route prüfen
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@@ -65,6 +65,7 @@ export class SecureLogger {
|
||||
return response.json();
|
||||
})
|
||||
.catch(err => {
|
||||
// Fallback auf console.error da wir hier im Logger sind
|
||||
console.error('Fehler beim Senden des Logs:', err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,29 +1,239 @@
|
||||
|
||||
---
|
||||
# Link Prefetching System
|
||||
|
||||
## 🔧 Neue Funktionen:
|
||||
## 🚀 Overview
|
||||
|
||||
### `getPrefetched(href: string): string | null`
|
||||
Das erweiterte Prefetching-System für interne Links bietet mehrere Strategien zur Verbesserung der Navigation-Performance:
|
||||
|
||||
* Gibt den HTML-Text zurück, **falls gültig gecached**
|
||||
* Sonst `null`
|
||||
- **Hover Prefetching**: Lädt Seiten vor, wenn der Nutzer mit der Maus über Links fährt
|
||||
- **Visible Prefetching**: Lädt Links, die im Viewport sichtbar werden (Intersection Observer)
|
||||
- **Eager Prefetching**: Sofortiges Laden wichtiger Seiten
|
||||
- **Browser Hints**: Nutzt native `<link rel="prefetch">` für optimale Browser-Integration
|
||||
|
||||
### `prefetchHref(href: string): void`
|
||||
## 📋 Features
|
||||
|
||||
* Manuelles Prefetching von URLs
|
||||
* Wird nur ausgeführt, wenn nicht bereits gecached
|
||||
- ✅ Multiple Prefetch-Strategien (hover, visible, eager)
|
||||
- ✅ Intersection Observer für sichtbare Links
|
||||
- ✅ Intelligentes Cache-Management mit TTL
|
||||
- ✅ Priority-based Fetching (high/low)
|
||||
- ✅ Network-aware Configuration (4G, 3G, 2G)
|
||||
- ✅ Browser Prefetch Hints (`<link rel="prefetch">`)
|
||||
- ✅ Abort Controller für Timeout-Management
|
||||
- ✅ Performance Monitoring und Stats
|
||||
|
||||
---
|
||||
## 🔧 Basic Usage
|
||||
|
||||
## 🧪 Verwendung:
|
||||
### Mit dem Router
|
||||
|
||||
```js
|
||||
import { getPrefetched, prefetchHref } from './core/click-manager.js';
|
||||
import { startRouter } from './core/router.js';
|
||||
|
||||
prefetchHref('/about.html'); // manuelles Prefetching
|
||||
// Standard-Konfiguration
|
||||
startRouter({
|
||||
strategies: ['hover', 'visible'],
|
||||
hoverDelay: 150,
|
||||
observerMargin: '50px',
|
||||
maxCacheSize: 20,
|
||||
cacheTTL: 60000
|
||||
});
|
||||
```
|
||||
|
||||
const html = getPrefetched('/about.html');
|
||||
### Adaptive Konfiguration
|
||||
|
||||
```js
|
||||
import { startRouter } from './core/router.js';
|
||||
import { getAdaptiveConfig } from './core/prefetch-config.js';
|
||||
|
||||
// Passt sich automatisch an die Netzwerkverbindung an
|
||||
startRouter(getAdaptiveConfig());
|
||||
```
|
||||
|
||||
### Direkte API-Nutzung
|
||||
|
||||
```js
|
||||
import { getPrefetched, prefetchHref, prefetchUrls } from './core/ClickManager.js';
|
||||
|
||||
// Einzelne URL prefetchen
|
||||
prefetchHref('/about');
|
||||
|
||||
// Mehrere URLs prefetchen
|
||||
prefetchUrls(['/products', '/contact', '/team']);
|
||||
|
||||
// Gecachten Content abrufen
|
||||
const html = getPrefetched('/about');
|
||||
if (html) {
|
||||
render(html);
|
||||
render(html);
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Prefetch Strategies
|
||||
|
||||
### 1. Hover Strategy
|
||||
Links werden vorgeladen, wenn der Nutzer mit der Maus darüber fährt:
|
||||
|
||||
```js
|
||||
{
|
||||
strategies: ['hover'],
|
||||
hoverDelay: 150 // Wartezeit in ms
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Visible Strategy
|
||||
Links werden vorgeladen, wenn sie in den Viewport kommen:
|
||||
|
||||
```js
|
||||
{
|
||||
strategies: ['visible'],
|
||||
observerMargin: '50px' // Lädt 50px bevor Link sichtbar wird
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Eager Strategy
|
||||
Wichtige Links sofort laden:
|
||||
|
||||
```html
|
||||
<!-- HTML Attribute -->
|
||||
<a href="/important-page" data-prefetch="eager">Important Link</a>
|
||||
```
|
||||
|
||||
```js
|
||||
// Oder programmatisch
|
||||
prefetchUrls(['/critical-path-1', '/critical-path-2']);
|
||||
```
|
||||
|
||||
### 4. Combined Strategies
|
||||
Mehrere Strategien kombinieren:
|
||||
|
||||
```js
|
||||
{
|
||||
strategies: ['hover', 'visible', 'eager'],
|
||||
// Weitere Optionen...
|
||||
}
|
||||
```
|
||||
|
||||
## ⚙️ Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `strategies` | Array | `['hover', 'visible']` | Aktive Prefetch-Strategien |
|
||||
| `hoverDelay` | Number | `150` | Verzögerung vor Hover-Prefetch (ms) |
|
||||
| `observerMargin` | String | `'50px'` | Intersection Observer Margin |
|
||||
| `maxCacheSize` | Number | `20` | Maximale Anzahl gecachter Seiten |
|
||||
| `cacheTTL` | Number | `60000` | Cache-Lebensdauer in ms |
|
||||
| `priority` | String | `'low'` | Fetch Priority (`'low'` oder `'high'`) |
|
||||
|
||||
## 📊 Performance Monitoring
|
||||
|
||||
```js
|
||||
import { getPrefetcher } from './core/ClickManager.js';
|
||||
|
||||
// Stats abrufen
|
||||
const prefetcher = getPrefetcher();
|
||||
const stats = prefetcher.getStats();
|
||||
|
||||
console.log(stats);
|
||||
// {
|
||||
// cacheSize: 5,
|
||||
// prefetching: 2,
|
||||
// prefetched: 10,
|
||||
// strategies: ['hover', 'visible']
|
||||
// }
|
||||
```
|
||||
|
||||
## 🎨 HTML Attributes
|
||||
|
||||
Links können mit speziellen Attributen gesteuert werden:
|
||||
|
||||
```html
|
||||
<!-- Eager Prefetch -->
|
||||
<a href="/page" data-prefetch="eager">Sofort laden</a>
|
||||
|
||||
<!-- Kein Prefetch -->
|
||||
<a href="/page" data-no-prefetch>Nicht vorladen</a>
|
||||
|
||||
<!-- View Transition -->
|
||||
<a href="/page" data-view-transition>Mit Animation</a>
|
||||
|
||||
<!-- Modal -->
|
||||
<a href="/page" data-modal>Als Modal öffnen</a>
|
||||
```
|
||||
|
||||
## 🌐 Network-Aware Configuration
|
||||
|
||||
```js
|
||||
import { getAdaptiveConfig } from './core/prefetch-config.js';
|
||||
|
||||
// Automatische Anpassung basierend auf:
|
||||
// - Connection.effectiveType (4g, 3g, 2g)
|
||||
// - Connection.saveData
|
||||
// - Device capabilities
|
||||
|
||||
const config = getAdaptiveConfig();
|
||||
startRouter(config);
|
||||
```
|
||||
|
||||
## 🚫 Ausnahmen
|
||||
|
||||
Folgende Links werden nicht geprefetcht:
|
||||
|
||||
- Externe Links (andere Domain)
|
||||
- Links mit `target="_blank"`
|
||||
- Download Links (`download` Attribute)
|
||||
- Hash Links (`#section`)
|
||||
- Links mit `data-no-prefetch`
|
||||
- `mailto:` und `tel:` Links
|
||||
|
||||
## 💡 Best Practices
|
||||
|
||||
1. **Mobile First**: Konservative Einstellungen für Mobile
|
||||
2. **Critical Path**: Wichtige Navigation-Links mit `data-prefetch="eager"`
|
||||
3. **Monitor Performance**: Cache-Stats überwachen
|
||||
4. **Network Detection**: Adaptive Config für verschiedene Verbindungen
|
||||
5. **User Preference**: `prefers-reduced-data` Media Query beachten
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
```js
|
||||
// Debug Logging aktivieren
|
||||
localStorage.setItem('DEBUG', 'true');
|
||||
|
||||
// Prefetch Stats
|
||||
const stats = getPrefetcher().getStats();
|
||||
console.table(stats);
|
||||
|
||||
// Cache leeren
|
||||
getPrefetcher().clearCache();
|
||||
```
|
||||
|
||||
## 🎯 Beispiel-Konfigurationen
|
||||
|
||||
### Aggressive (Schnelle Verbindung)
|
||||
```js
|
||||
{
|
||||
strategies: ['hover', 'visible', 'eager'],
|
||||
hoverDelay: 50,
|
||||
observerMargin: '200px',
|
||||
maxCacheSize: 50,
|
||||
cacheTTL: 300000,
|
||||
priority: 'high'
|
||||
}
|
||||
```
|
||||
|
||||
### Conservative (Langsame Verbindung)
|
||||
```js
|
||||
{
|
||||
strategies: ['hover'],
|
||||
hoverDelay: 300,
|
||||
maxCacheSize: 10,
|
||||
cacheTTL: 30000,
|
||||
priority: 'low'
|
||||
}
|
||||
```
|
||||
|
||||
### Disabled
|
||||
```js
|
||||
{
|
||||
strategies: [],
|
||||
maxCacheSize: 0
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,43 +1,52 @@
|
||||
import '../css/styles.css';
|
||||
|
||||
import { initApp } from './core/init.js';
|
||||
import { Logger } from './core/logger.js';
|
||||
import { CsrfAutoRefresh } from './modules/csrf-auto-refresh.js';
|
||||
import { FormAutoSave } from './modules/form-autosave.js';
|
||||
|
||||
// resources/js/app.js (dein Einstiegspunkt)
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
// PWA Service Worker temporarily disabled
|
||||
// TODO: Re-enable after fixing build issues
|
||||
|
||||
const updateSW = registerSW({
|
||||
onNeedRefresh() {
|
||||
const reload = confirm('🔄 Neue Version verfügbar. Seite neu laden?');
|
||||
if (reload) updateSW(true);
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('📦 Offline-Inhalte sind bereit.');
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
try {
|
||||
console.log('🚀 Starting app initialization...');
|
||||
await initApp();
|
||||
console.log('✅ App initialized successfully!');
|
||||
|
||||
// Initialize CSRF Auto-Refresh for all forms
|
||||
const csrfInstances = CsrfAutoRefresh.initializeAll();
|
||||
console.log(`🔒 CSRF Auto-Refresh initialized for ${csrfInstances.length} forms`);
|
||||
|
||||
// Initialize Form Auto-Save for all forms
|
||||
const autosaveInstances = FormAutoSave.initializeAll();
|
||||
console.log(`💾 Form Auto-Save initialized for ${autosaveInstances.length} forms`);
|
||||
|
||||
// Debug info
|
||||
setTimeout(() => {
|
||||
console.log('📊 Debug Info:');
|
||||
console.log('- Forms found:', document.querySelectorAll('form').length);
|
||||
console.log('- Links found:', document.querySelectorAll('a[href^="/"]').length);
|
||||
console.log('- SPA Router:', window.spaRouter ? 'Active' : 'Missing');
|
||||
console.log('- Enhanced forms:', document.querySelectorAll('form[data-auto-enhanced]').length);
|
||||
console.log('- CSRF protected forms:', document.querySelectorAll('input[name="_token"]').length);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('❌ App initialization failed:', error);
|
||||
console.error('Stack trace:', error.stack);
|
||||
}
|
||||
});
|
||||
|
||||
registerSW({
|
||||
onRegistered(reg) {
|
||||
console.log('Service Worker registriert:', reg);
|
||||
},
|
||||
onRegisterError(error) {
|
||||
console.error('Service Worker Fehler:', error);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initApp();
|
||||
});
|
||||
|
||||
function isHtmlAttributeSupported(elementName, attribute) {
|
||||
const element = document.createElement(elementName);
|
||||
return attribute in element;
|
||||
}
|
||||
|
||||
|
||||
let closedAttr = document.getElementById('my-dialog');
|
||||
if(! 'closedby' in closedAttr) {
|
||||
/*let closedAttr = document.getElementById('my-dialog');
|
||||
if(closedAttr && !('closedby' in closedAttr)) {
|
||||
alert('oh no');
|
||||
}
|
||||
}*/
|
||||
|
||||
/*
|
||||
if (isHtmlAttributeSupported('dialog', 'closedby')) {
|
||||
|
||||
555
resources/js/modules/api-manager/AnimationManager.js
Normal file
555
resources/js/modules/api-manager/AnimationManager.js
Normal file
@@ -0,0 +1,555 @@
|
||||
// modules/api-manager/AnimationManager.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Web Animations API Manager - High-performance animations with timeline control
|
||||
*/
|
||||
export class AnimationManager {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.activeAnimations = new Map();
|
||||
this.animationGroups = new Map();
|
||||
this.defaultEasing = config.easing || 'ease-out';
|
||||
this.defaultDuration = config.duration || 300;
|
||||
|
||||
// Check for Web Animations API support
|
||||
this.supported = 'animate' in Element.prototype;
|
||||
|
||||
if (!this.supported) {
|
||||
Logger.warn('[AnimationManager] Web Animations API not supported, using fallbacks');
|
||||
} else {
|
||||
Logger.info('[AnimationManager] Initialized with Web Animations API support');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate element using Web Animations API
|
||||
*/
|
||||
animate(element, keyframes, options = {}) {
|
||||
if (!element || !keyframes) {
|
||||
Logger.warn('[AnimationManager] Missing element or keyframes');
|
||||
return null;
|
||||
}
|
||||
|
||||
const animationOptions = {
|
||||
duration: this.defaultDuration,
|
||||
easing: this.defaultEasing,
|
||||
fill: 'forwards',
|
||||
...options
|
||||
};
|
||||
|
||||
const animationId = this.generateId();
|
||||
|
||||
if (this.supported) {
|
||||
const animation = element.animate(keyframes, animationOptions);
|
||||
|
||||
// Enhanced animation object
|
||||
const enhancedAnimation = this.enhanceAnimation(animation, animationId, {
|
||||
element,
|
||||
keyframes,
|
||||
options: animationOptions
|
||||
});
|
||||
|
||||
this.activeAnimations.set(animationId, enhancedAnimation);
|
||||
|
||||
// Auto-cleanup on finish
|
||||
animation.addEventListener('finish', () => {
|
||||
this.activeAnimations.delete(animationId);
|
||||
});
|
||||
|
||||
Logger.info(`[AnimationManager] Animation started: ${animationId}`);
|
||||
return enhancedAnimation;
|
||||
} else {
|
||||
return this.fallbackAnimate(element, keyframes, animationOptions, animationId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create keyframe animations
|
||||
*/
|
||||
keyframes(keyframeSet) {
|
||||
// Convert CSS-like keyframes to Web Animations format
|
||||
if (Array.isArray(keyframeSet)) {
|
||||
return keyframeSet;
|
||||
}
|
||||
|
||||
// Handle object format: { '0%': {...}, '50%': {...}, '100%': {...} }
|
||||
if (typeof keyframeSet === 'object') {
|
||||
const frames = [];
|
||||
const sortedKeys = Object.keys(keyframeSet).sort((a, b) => {
|
||||
const aPercent = parseFloat(a.replace('%', ''));
|
||||
const bPercent = parseFloat(b.replace('%', ''));
|
||||
return aPercent - bPercent;
|
||||
});
|
||||
|
||||
sortedKeys.forEach(key => {
|
||||
const offset = parseFloat(key.replace('%', '')) / 100;
|
||||
frames.push({
|
||||
...keyframeSet[key],
|
||||
offset
|
||||
});
|
||||
});
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
return keyframeSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-defined animation effects
|
||||
*/
|
||||
effects = {
|
||||
// Entrance animations
|
||||
fadeIn: (duration = 300) => ([
|
||||
{ opacity: 0 },
|
||||
{ opacity: 1 }
|
||||
]),
|
||||
|
||||
slideInLeft: (distance = '100px', duration = 300) => ([
|
||||
{ transform: `translateX(-${distance})`, opacity: 0 },
|
||||
{ transform: 'translateX(0)', opacity: 1 }
|
||||
]),
|
||||
|
||||
slideInRight: (distance = '100px', duration = 300) => ([
|
||||
{ transform: `translateX(${distance})`, opacity: 0 },
|
||||
{ transform: 'translateX(0)', opacity: 1 }
|
||||
]),
|
||||
|
||||
slideInUp: (distance = '100px', duration = 300) => ([
|
||||
{ transform: `translateY(${distance})`, opacity: 0 },
|
||||
{ transform: 'translateY(0)', opacity: 1 }
|
||||
]),
|
||||
|
||||
slideInDown: (distance = '100px', duration = 300) => ([
|
||||
{ transform: `translateY(-${distance})`, opacity: 0 },
|
||||
{ transform: 'translateY(0)', opacity: 1 }
|
||||
]),
|
||||
|
||||
scaleIn: (scale = 0.8, duration = 300) => ([
|
||||
{ transform: `scale(${scale})`, opacity: 0 },
|
||||
{ transform: 'scale(1)', opacity: 1 }
|
||||
]),
|
||||
|
||||
rotateIn: (rotation = '-180deg', duration = 600) => ([
|
||||
{ transform: `rotate(${rotation})`, opacity: 0 },
|
||||
{ transform: 'rotate(0deg)', opacity: 1 }
|
||||
]),
|
||||
|
||||
// Exit animations
|
||||
fadeOut: (duration = 300) => ([
|
||||
{ opacity: 1 },
|
||||
{ opacity: 0 }
|
||||
]),
|
||||
|
||||
slideOutLeft: (distance = '100px', duration = 300) => ([
|
||||
{ transform: 'translateX(0)', opacity: 1 },
|
||||
{ transform: `translateX(-${distance})`, opacity: 0 }
|
||||
]),
|
||||
|
||||
slideOutRight: (distance = '100px', duration = 300) => ([
|
||||
{ transform: 'translateX(0)', opacity: 1 },
|
||||
{ transform: `translateX(${distance})`, opacity: 0 }
|
||||
]),
|
||||
|
||||
scaleOut: (scale = 0.8, duration = 300) => ([
|
||||
{ transform: 'scale(1)', opacity: 1 },
|
||||
{ transform: `scale(${scale})`, opacity: 0 }
|
||||
]),
|
||||
|
||||
// Attention seekers
|
||||
bounce: (intensity = '20px', duration = 600) => ([
|
||||
{ transform: 'translateY(0)' },
|
||||
{ transform: `translateY(-${intensity})`, offset: 0.25 },
|
||||
{ transform: 'translateY(0)', offset: 0.5 },
|
||||
{ transform: `translateY(-${intensity})`, offset: 0.75 },
|
||||
{ transform: 'translateY(0)' }
|
||||
]),
|
||||
|
||||
pulse: (scale = 1.1, duration = 600) => ([
|
||||
{ transform: 'scale(1)' },
|
||||
{ transform: `scale(${scale})`, offset: 0.5 },
|
||||
{ transform: 'scale(1)' }
|
||||
]),
|
||||
|
||||
shake: (distance = '10px', duration = 600) => ([
|
||||
{ transform: 'translateX(0)' },
|
||||
{ transform: `translateX(-${distance})`, offset: 0.1 },
|
||||
{ transform: `translateX(${distance})`, offset: 0.2 },
|
||||
{ transform: `translateX(-${distance})`, offset: 0.3 },
|
||||
{ transform: `translateX(${distance})`, offset: 0.4 },
|
||||
{ transform: `translateX(-${distance})`, offset: 0.5 },
|
||||
{ transform: `translateX(${distance})`, offset: 0.6 },
|
||||
{ transform: `translateX(-${distance})`, offset: 0.7 },
|
||||
{ transform: `translateX(${distance})`, offset: 0.8 },
|
||||
{ transform: `translateX(-${distance})`, offset: 0.9 },
|
||||
{ transform: 'translateX(0)' }
|
||||
]),
|
||||
|
||||
rubberBand: (duration = 1000) => ([
|
||||
{ transform: 'scale(1)' },
|
||||
{ transform: 'scale(1.25, 0.75)', offset: 0.3 },
|
||||
{ transform: 'scale(0.75, 1.25)', offset: 0.4 },
|
||||
{ transform: 'scale(1.15, 0.85)', offset: 0.5 },
|
||||
{ transform: 'scale(0.95, 1.05)', offset: 0.65 },
|
||||
{ transform: 'scale(1.05, 0.95)', offset: 0.75 },
|
||||
{ transform: 'scale(1)' }
|
||||
]),
|
||||
|
||||
// Loading animations
|
||||
spin: (duration = 1000) => ([
|
||||
{ transform: 'rotate(0deg)' },
|
||||
{ transform: 'rotate(360deg)' }
|
||||
]),
|
||||
|
||||
heartbeat: (scale = 1.3, duration = 1000) => ([
|
||||
{ transform: 'scale(1)' },
|
||||
{ transform: `scale(${scale})`, offset: 0.14 },
|
||||
{ transform: 'scale(1)', offset: 0.28 },
|
||||
{ transform: `scale(${scale})`, offset: 0.42 },
|
||||
{ transform: 'scale(1)', offset: 0.70 }
|
||||
])
|
||||
};
|
||||
|
||||
/**
|
||||
* Predefined easing functions
|
||||
*/
|
||||
easings = {
|
||||
linear: 'linear',
|
||||
ease: 'ease',
|
||||
easeIn: 'ease-in',
|
||||
easeOut: 'ease-out',
|
||||
easeInOut: 'ease-in-out',
|
||||
|
||||
// Custom cubic-bezier easings
|
||||
easeInQuad: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
easeOutQuad: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
easeInOutQuad: 'cubic-bezier(0.455, 0.03, 0.515, 0.955)',
|
||||
|
||||
easeInCubic: 'cubic-bezier(0.32, 0, 0.67, 0)',
|
||||
easeOutCubic: 'cubic-bezier(0.33, 1, 0.68, 1)',
|
||||
easeInOutCubic: 'cubic-bezier(0.65, 0, 0.35, 1)',
|
||||
|
||||
easeInQuart: 'cubic-bezier(0.5, 0, 0.75, 0)',
|
||||
easeOutQuart: 'cubic-bezier(0.25, 1, 0.5, 1)',
|
||||
easeInOutQuart: 'cubic-bezier(0.76, 0, 0.24, 1)',
|
||||
|
||||
// Bouncy easings
|
||||
easeOutBack: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
easeInBack: 'cubic-bezier(0.36, 0, 0.66, -0.56)',
|
||||
easeInOutBack: 'cubic-bezier(0.68, -0.6, 0.32, 1.6)'
|
||||
};
|
||||
|
||||
/**
|
||||
* Quick effect methods
|
||||
*/
|
||||
fadeIn(element, options = {}) {
|
||||
return this.animate(element, this.effects.fadeIn(), {
|
||||
duration: 300,
|
||||
easing: this.easings.easeOut,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
fadeOut(element, options = {}) {
|
||||
return this.animate(element, this.effects.fadeOut(), {
|
||||
duration: 300,
|
||||
easing: this.easings.easeIn,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
slideIn(element, direction = 'up', options = {}) {
|
||||
const effectMap = {
|
||||
up: 'slideInUp',
|
||||
down: 'slideInDown',
|
||||
left: 'slideInLeft',
|
||||
right: 'slideInRight'
|
||||
};
|
||||
|
||||
return this.animate(element, this.effects[effectMap[direction]](), {
|
||||
duration: 400,
|
||||
easing: this.easings.easeOutBack,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
bounce(element, options = {}) {
|
||||
return this.animate(element, this.effects.bounce(), {
|
||||
duration: 600,
|
||||
easing: this.easings.easeInOut,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
pulse(element, options = {}) {
|
||||
return this.animate(element, this.effects.pulse(), {
|
||||
duration: 600,
|
||||
easing: this.easings.easeInOut,
|
||||
iterations: Infinity,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation groups - animate multiple elements together
|
||||
*/
|
||||
group(animations, options = {}) {
|
||||
const groupId = this.generateId('group');
|
||||
const animationPromises = [];
|
||||
|
||||
animations.forEach(({ element, keyframes, animationOptions = {} }) => {
|
||||
const animation = this.animate(element, keyframes, {
|
||||
...animationOptions,
|
||||
...options
|
||||
});
|
||||
|
||||
if (animation && animation.finished) {
|
||||
animationPromises.push(animation.finished);
|
||||
}
|
||||
});
|
||||
|
||||
const groupController = {
|
||||
id: groupId,
|
||||
finished: Promise.all(animationPromises),
|
||||
play: () => {
|
||||
animations.forEach(anim => {
|
||||
if (anim.animation) anim.animation.play();
|
||||
});
|
||||
},
|
||||
pause: () => {
|
||||
animations.forEach(anim => {
|
||||
if (anim.animation) anim.animation.pause();
|
||||
});
|
||||
},
|
||||
reverse: () => {
|
||||
animations.forEach(anim => {
|
||||
if (anim.animation) anim.animation.reverse();
|
||||
});
|
||||
},
|
||||
cancel: () => {
|
||||
animations.forEach(anim => {
|
||||
if (anim.animation) anim.animation.cancel();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.animationGroups.set(groupId, groupController);
|
||||
return groupController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staggered animations - animate elements with delay
|
||||
*/
|
||||
stagger(elements, keyframes, options = {}) {
|
||||
const staggerDelay = options.staggerDelay || 100;
|
||||
const animations = [];
|
||||
|
||||
elements.forEach((element, index) => {
|
||||
const delay = index * staggerDelay;
|
||||
const animation = this.animate(element, keyframes, {
|
||||
...options,
|
||||
delay
|
||||
});
|
||||
animations.push({ element, animation });
|
||||
});
|
||||
|
||||
return this.group(animations.map(({ element, animation }) => ({
|
||||
element,
|
||||
keyframes,
|
||||
animation
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline animations - sequence animations
|
||||
*/
|
||||
timeline(sequence) {
|
||||
const timelineId = this.generateId('timeline');
|
||||
let currentTime = 0;
|
||||
const animations = [];
|
||||
|
||||
sequence.forEach(step => {
|
||||
const delay = step.at !== undefined ? step.at : currentTime;
|
||||
const animation = this.animate(step.element, step.keyframes, {
|
||||
...step.options,
|
||||
delay
|
||||
});
|
||||
|
||||
animations.push(animation);
|
||||
|
||||
if (step.at === undefined) {
|
||||
currentTime += step.options?.duration || this.defaultDuration;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: timelineId,
|
||||
animations,
|
||||
finished: Promise.all(animations.map(a => a.finished)),
|
||||
play: () => animations.forEach(a => a.play()),
|
||||
pause: () => animations.forEach(a => a.pause()),
|
||||
reverse: () => animations.forEach(a => a.reverse()),
|
||||
cancel: () => animations.forEach(a => a.cancel())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll-triggered animations
|
||||
*/
|
||||
onScroll(element, keyframes, options = {}) {
|
||||
const scrollTrigger = options.trigger || element;
|
||||
const startOffset = options.start || 0;
|
||||
const endOffset = options.end || 1;
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const progress = Math.min(Math.max(
|
||||
(entry.intersectionRatio - startOffset) / (endOffset - startOffset),
|
||||
0
|
||||
), 1);
|
||||
|
||||
if (progress >= 0) {
|
||||
this.animate(element, keyframes, {
|
||||
...options,
|
||||
duration: options.duration || this.defaultDuration
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: this.createThresholdArray(20)
|
||||
});
|
||||
|
||||
observer.observe(scrollTrigger);
|
||||
|
||||
return {
|
||||
observer,
|
||||
disconnect: () => observer.disconnect()
|
||||
};
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
enhanceAnimation(animation, id, metadata) {
|
||||
return {
|
||||
id,
|
||||
animation,
|
||||
metadata,
|
||||
|
||||
// Enhanced controls
|
||||
play: () => animation.play(),
|
||||
pause: () => animation.pause(),
|
||||
reverse: () => animation.reverse(),
|
||||
cancel: () => animation.cancel(),
|
||||
finish: () => animation.finish(),
|
||||
|
||||
// Properties
|
||||
get currentTime() { return animation.currentTime; },
|
||||
set currentTime(time) { animation.currentTime = time; },
|
||||
|
||||
get playbackRate() { return animation.playbackRate; },
|
||||
set playbackRate(rate) { animation.playbackRate = rate; },
|
||||
|
||||
get playState() { return animation.playState; },
|
||||
get finished() { return animation.finished; },
|
||||
|
||||
// Enhanced methods
|
||||
seek(progress) {
|
||||
animation.currentTime = animation.effect.getTiming().duration * progress;
|
||||
},
|
||||
|
||||
setProgress(progress) {
|
||||
this.seek(Math.max(0, Math.min(1, progress)));
|
||||
},
|
||||
|
||||
onFinish(callback) {
|
||||
animation.addEventListener('finish', callback);
|
||||
},
|
||||
|
||||
onCancel(callback) {
|
||||
animation.addEventListener('cancel', callback);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fallbackAnimate(element, keyframes, options, id) {
|
||||
Logger.info('[AnimationManager] Using CSS fallback animation');
|
||||
|
||||
// Simple CSS transition fallback
|
||||
const finalFrame = keyframes[keyframes.length - 1];
|
||||
element.style.transition = `all ${options.duration}ms ${options.easing}`;
|
||||
|
||||
// Apply final styles
|
||||
Object.assign(element.style, finalFrame);
|
||||
|
||||
// Return promise-like object
|
||||
return {
|
||||
id,
|
||||
finished: new Promise(resolve => setTimeout(resolve, options.duration)),
|
||||
play: () => {},
|
||||
pause: () => {},
|
||||
cancel: () => {
|
||||
element.style.transition = '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
generateId(prefix = 'anim') {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
createThresholdArray(steps) {
|
||||
const thresholds = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
thresholds.push(i / steps);
|
||||
}
|
||||
return thresholds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active animations
|
||||
*/
|
||||
getActiveAnimations() {
|
||||
return Array.from(this.activeAnimations.entries()).map(([id, animation]) => ({
|
||||
id,
|
||||
playState: animation.playState,
|
||||
currentTime: animation.currentTime,
|
||||
metadata: animation.metadata
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all active animations
|
||||
*/
|
||||
cancelAll() {
|
||||
this.activeAnimations.forEach(animation => {
|
||||
animation.cancel();
|
||||
});
|
||||
this.activeAnimations.clear();
|
||||
Logger.info('[AnimationManager] All animations cancelled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause all active animations
|
||||
*/
|
||||
pauseAll() {
|
||||
this.activeAnimations.forEach(animation => {
|
||||
animation.pause();
|
||||
});
|
||||
Logger.info('[AnimationManager] All animations paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume all paused animations
|
||||
*/
|
||||
resumeAll() {
|
||||
this.activeAnimations.forEach(animation => {
|
||||
if (animation.playState === 'paused') {
|
||||
animation.play();
|
||||
}
|
||||
});
|
||||
Logger.info('[AnimationManager] All animations resumed');
|
||||
}
|
||||
}
|
||||
678
resources/js/modules/api-manager/BiometricAuthManager.js
Normal file
678
resources/js/modules/api-manager/BiometricAuthManager.js
Normal file
@@ -0,0 +1,678 @@
|
||||
// modules/api-manager/BiometricAuthManager.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Biometric Authentication Manager - WebAuthn API
|
||||
*/
|
||||
export class BiometricAuthManager {
|
||||
constructor(config = {}) {
|
||||
this.config = {
|
||||
rpName: config.rpName || 'Custom PHP Framework',
|
||||
rpId: config.rpId || window.location.hostname,
|
||||
timeout: config.timeout || 60000,
|
||||
userVerification: config.userVerification || 'preferred',
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: 'platform', // Prefer built-in authenticators
|
||||
userVerification: 'preferred',
|
||||
requireResidentKey: false,
|
||||
...config.authenticatorSelection
|
||||
},
|
||||
...config
|
||||
};
|
||||
|
||||
this.credentials = new Map();
|
||||
this.authSessions = new Map();
|
||||
|
||||
// Check WebAuthn support
|
||||
this.support = {
|
||||
webAuthn: 'credentials' in navigator && 'create' in navigator.credentials,
|
||||
conditionalUI: 'conditional' in window.PublicKeyCredential || false,
|
||||
userVerifyingPlatformAuthenticator: false,
|
||||
residentKey: false
|
||||
};
|
||||
|
||||
// Enhanced feature detection
|
||||
this.detectFeatures();
|
||||
|
||||
Logger.info('[BiometricAuthManager] Initialized with support:', this.support);
|
||||
}
|
||||
|
||||
async detectFeatures() {
|
||||
if (!this.support.webAuthn) return;
|
||||
|
||||
try {
|
||||
// Check for user-verifying platform authenticator
|
||||
const available = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||
this.support.userVerifyingPlatformAuthenticator = available;
|
||||
|
||||
// Check for resident key support
|
||||
if (window.PublicKeyCredential.isConditionalMediationAvailable) {
|
||||
this.support.conditionalUI = await window.PublicKeyCredential.isConditionalMediationAvailable();
|
||||
}
|
||||
|
||||
Logger.info('[BiometricAuthManager] Enhanced features detected:', {
|
||||
platform: this.support.userVerifyingPlatformAuthenticator,
|
||||
conditional: this.support.conditionalUI
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
Logger.warn('[BiometricAuthManager] Feature detection failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register new biometric credential
|
||||
*/
|
||||
async register(userInfo, options = {}) {
|
||||
if (!this.support.webAuthn) {
|
||||
throw new Error('WebAuthn not supported');
|
||||
}
|
||||
|
||||
const {
|
||||
challenge = null,
|
||||
excludeCredentials = [],
|
||||
extensions = {}
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// Generate challenge if not provided
|
||||
const challengeBuffer = challenge ?
|
||||
this.base64ToArrayBuffer(challenge) :
|
||||
crypto.getRandomValues(new Uint8Array(32));
|
||||
|
||||
// Create credential creation options
|
||||
const createOptions = {
|
||||
rp: {
|
||||
name: this.config.rpName,
|
||||
id: this.config.rpId
|
||||
},
|
||||
user: {
|
||||
id: this.stringToArrayBuffer(userInfo.id || userInfo.username),
|
||||
name: userInfo.username || userInfo.email,
|
||||
displayName: userInfo.displayName || userInfo.name || userInfo.username
|
||||
},
|
||||
challenge: challengeBuffer,
|
||||
pubKeyCredParams: [
|
||||
{ type: 'public-key', alg: -7 }, // ES256
|
||||
{ type: 'public-key', alg: -35 }, // ES384
|
||||
{ type: 'public-key', alg: -36 }, // ES512
|
||||
{ type: 'public-key', alg: -257 }, // RS256
|
||||
{ type: 'public-key', alg: -258 }, // RS384
|
||||
{ type: 'public-key', alg: -259 } // RS512
|
||||
],
|
||||
authenticatorSelection: this.config.authenticatorSelection,
|
||||
timeout: this.config.timeout,
|
||||
attestation: 'direct',
|
||||
extensions: {
|
||||
credProps: true,
|
||||
...extensions
|
||||
}
|
||||
};
|
||||
|
||||
// Add exclude list if provided
|
||||
if (excludeCredentials.length > 0) {
|
||||
createOptions.excludeCredentials = excludeCredentials.map(cred => ({
|
||||
type: 'public-key',
|
||||
id: typeof cred === 'string' ? this.base64ToArrayBuffer(cred) : cred,
|
||||
transports: ['internal', 'hybrid']
|
||||
}));
|
||||
}
|
||||
|
||||
Logger.info('[BiometricAuthManager] Starting registration...');
|
||||
|
||||
// Create credential
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: createOptions
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Credential creation failed');
|
||||
}
|
||||
|
||||
// Process the credential
|
||||
const processedCredential = this.processCredential(credential, 'registration');
|
||||
|
||||
// Store credential info locally
|
||||
const credentialInfo = {
|
||||
id: processedCredential.id,
|
||||
rawId: processedCredential.rawId,
|
||||
userId: userInfo.id || userInfo.username,
|
||||
userDisplayName: userInfo.displayName || userInfo.name,
|
||||
createdAt: Date.now(),
|
||||
lastUsed: null,
|
||||
counter: processedCredential.response.counter || 0,
|
||||
transports: credential.response.getTransports?.() || ['internal']
|
||||
};
|
||||
|
||||
this.credentials.set(processedCredential.id, credentialInfo);
|
||||
|
||||
Logger.info('[BiometricAuthManager] Registration successful:', {
|
||||
id: processedCredential.id,
|
||||
user: userInfo.username,
|
||||
authenticator: credentialInfo.transports
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
credential: processedCredential,
|
||||
info: credentialInfo,
|
||||
attestation: this.parseAttestation(credential.response)
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
Logger.error('[BiometricAuthManager] Registration failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
name: error.name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with biometric credential
|
||||
*/
|
||||
async authenticate(options = {}) {
|
||||
if (!this.support.webAuthn) {
|
||||
throw new Error('WebAuthn not supported');
|
||||
}
|
||||
|
||||
const {
|
||||
challenge = null,
|
||||
allowCredentials = [],
|
||||
userVerification = this.config.userVerification,
|
||||
conditional = false,
|
||||
extensions = {}
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// Generate challenge if not provided
|
||||
const challengeBuffer = challenge ?
|
||||
this.base64ToArrayBuffer(challenge) :
|
||||
crypto.getRandomValues(new Uint8Array(32));
|
||||
|
||||
// Create authentication options
|
||||
const getOptions = {
|
||||
challenge: challengeBuffer,
|
||||
timeout: this.config.timeout,
|
||||
userVerification,
|
||||
extensions: {
|
||||
credProps: true,
|
||||
...extensions
|
||||
}
|
||||
};
|
||||
|
||||
// Add allow list if provided
|
||||
if (allowCredentials.length > 0) {
|
||||
getOptions.allowCredentials = allowCredentials.map(cred => ({
|
||||
type: 'public-key',
|
||||
id: typeof cred === 'string' ? this.base64ToArrayBuffer(cred) : cred,
|
||||
transports: ['internal', 'hybrid', 'usb', 'nfc', 'ble']
|
||||
}));
|
||||
}
|
||||
|
||||
Logger.info('[BiometricAuthManager] Starting authentication...', { conditional });
|
||||
|
||||
// Authenticate
|
||||
const credential = conditional && this.support.conditionalUI ?
|
||||
await navigator.credentials.get({
|
||||
publicKey: getOptions,
|
||||
mediation: 'conditional'
|
||||
}) :
|
||||
await navigator.credentials.get({
|
||||
publicKey: getOptions
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
// Process the credential
|
||||
const processedCredential = this.processCredential(credential, 'authentication');
|
||||
|
||||
// Update credential usage
|
||||
const credentialInfo = this.credentials.get(processedCredential.id);
|
||||
if (credentialInfo) {
|
||||
credentialInfo.lastUsed = Date.now();
|
||||
credentialInfo.counter = processedCredential.response.counter || 0;
|
||||
}
|
||||
|
||||
// Create authentication session
|
||||
const sessionId = this.generateSessionId();
|
||||
const authSession = {
|
||||
id: sessionId,
|
||||
credentialId: processedCredential.id,
|
||||
userId: credentialInfo?.userId,
|
||||
authenticatedAt: Date.now(),
|
||||
userAgent: navigator.userAgent,
|
||||
ipAddress: await this.getClientIP().catch(() => 'unknown')
|
||||
};
|
||||
|
||||
this.authSessions.set(sessionId, authSession);
|
||||
|
||||
Logger.info('[BiometricAuthManager] Authentication successful:', {
|
||||
sessionId,
|
||||
credentialId: processedCredential.id,
|
||||
userId: credentialInfo?.userId
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
credential: processedCredential,
|
||||
session: authSession,
|
||||
info: credentialInfo
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
Logger.error('[BiometricAuthManager] Authentication failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
name: error.name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if biometric authentication is available
|
||||
*/
|
||||
async isAvailable() {
|
||||
const availability = {
|
||||
webAuthn: this.support.webAuthn,
|
||||
platform: this.support.userVerifyingPlatformAuthenticator,
|
||||
conditional: this.support.conditionalUI,
|
||||
hasCredentials: this.credentials.size > 0,
|
||||
recommended: false
|
||||
};
|
||||
|
||||
// Overall recommendation
|
||||
availability.recommended = availability.webAuthn &&
|
||||
(availability.platform || availability.hasCredentials);
|
||||
|
||||
return availability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up conditional UI for seamless authentication
|
||||
*/
|
||||
async setupConditionalUI(inputSelector = 'input[type="email"], input[type="text"]', options = {}) {
|
||||
if (!this.support.conditionalUI) {
|
||||
Logger.warn('[BiometricAuthManager] Conditional UI not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputs = document.querySelectorAll(inputSelector);
|
||||
if (inputs.length === 0) {
|
||||
Logger.warn('[BiometricAuthManager] No suitable inputs found for conditional UI');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Set up conditional UI
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('focus', async () => {
|
||||
Logger.info('[BiometricAuthManager] Setting up conditional authentication');
|
||||
|
||||
try {
|
||||
const result = await this.authenticate({
|
||||
...options,
|
||||
conditional: true
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Trigger custom event for successful authentication
|
||||
const event = new CustomEvent('biometric-auth-success', {
|
||||
detail: result
|
||||
});
|
||||
input.dispatchEvent(event);
|
||||
|
||||
// Auto-fill username if available
|
||||
if (result.info?.userDisplayName) {
|
||||
input.value = result.info.userDisplayName;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn('[BiometricAuthManager] Conditional auth failed:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Logger.info('[BiometricAuthManager] Conditional UI setup complete');
|
||||
return { inputs: inputs.length, selector: inputSelector };
|
||||
|
||||
} catch (error) {
|
||||
Logger.error('[BiometricAuthManager] Conditional UI setup failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete biometric login flow
|
||||
*/
|
||||
createLoginFlow(options = {}) {
|
||||
const {
|
||||
registerSelector = '#register-biometric',
|
||||
loginSelector = '#login-biometric',
|
||||
statusSelector = '#biometric-status',
|
||||
onRegister = null,
|
||||
onLogin = null,
|
||||
onError = null
|
||||
} = options;
|
||||
|
||||
return {
|
||||
async init() {
|
||||
const availability = await this.isAvailable();
|
||||
|
||||
// Update status display
|
||||
const statusEl = document.querySelector(statusSelector);
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = this.createStatusHTML(availability);
|
||||
}
|
||||
|
||||
// Set up register button
|
||||
const registerBtn = document.querySelector(registerSelector);
|
||||
if (registerBtn) {
|
||||
registerBtn.style.display = availability.webAuthn ? 'block' : 'none';
|
||||
registerBtn.onclick = () => this.handleRegister();
|
||||
}
|
||||
|
||||
// Set up login button
|
||||
const loginBtn = document.querySelector(loginSelector);
|
||||
if (loginBtn) {
|
||||
loginBtn.style.display = availability.hasCredentials ? 'block' : 'none';
|
||||
loginBtn.onclick = () => this.handleLogin();
|
||||
}
|
||||
|
||||
// Set up conditional UI
|
||||
if (availability.conditional) {
|
||||
await this.setupConditionalUI();
|
||||
}
|
||||
|
||||
return availability;
|
||||
},
|
||||
|
||||
async handleRegister() {
|
||||
try {
|
||||
// Get user information (could be from form or prompt)
|
||||
const userInfo = await this.getUserInfo();
|
||||
if (!userInfo) return;
|
||||
|
||||
const result = await this.register(userInfo);
|
||||
|
||||
if (result.success) {
|
||||
if (onRegister) onRegister(result);
|
||||
this.showSuccess('Biometric authentication registered successfully!');
|
||||
} else {
|
||||
if (onError) onError(result);
|
||||
this.showError(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) onError({ error: error.message });
|
||||
this.showError(error.message);
|
||||
}
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
try {
|
||||
const result = await this.authenticate();
|
||||
|
||||
if (result.success) {
|
||||
if (onLogin) onLogin(result);
|
||||
this.showSuccess('Biometric authentication successful!');
|
||||
} else {
|
||||
if (onError) onError(result);
|
||||
this.showError(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) onError({ error: error.message });
|
||||
this.showError(error.message);
|
||||
}
|
||||
},
|
||||
|
||||
async getUserInfo() {
|
||||
// Try to get from form first
|
||||
const usernameInput = document.querySelector('input[name="username"], input[name="email"]');
|
||||
const nameInput = document.querySelector('input[name="name"], input[name="display_name"]');
|
||||
|
||||
if (usernameInput?.value) {
|
||||
return {
|
||||
id: usernameInput.value,
|
||||
username: usernameInput.value,
|
||||
displayName: nameInput?.value || usernameInput.value
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to prompt
|
||||
const username = prompt('Please enter your username or email:');
|
||||
if (!username) return null;
|
||||
|
||||
const displayName = prompt('Please enter your display name:') || username;
|
||||
|
||||
return {
|
||||
id: username,
|
||||
username,
|
||||
displayName
|
||||
};
|
||||
},
|
||||
|
||||
createStatusHTML(availability) {
|
||||
if (!availability.webAuthn) {
|
||||
return '<div class="alert alert-warning">⚠️ Biometric authentication not supported in this browser</div>';
|
||||
}
|
||||
|
||||
if (!availability.platform && !availability.hasCredentials) {
|
||||
return '<div class="alert alert-info">ℹ️ No biometric authenticators available</div>';
|
||||
}
|
||||
|
||||
if (availability.hasCredentials) {
|
||||
return '<div class="alert alert-success">✅ Biometric authentication available</div>';
|
||||
}
|
||||
|
||||
return '<div class="alert alert-info">🔐 Biometric authentication can be set up</div>';
|
||||
},
|
||||
|
||||
showSuccess(message) {
|
||||
this.showMessage(message, 'success');
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
this.showMessage(message, 'error');
|
||||
},
|
||||
|
||||
showMessage(message, type = 'info') {
|
||||
// Create toast notification
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `biometric-toast toast-${type}`;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: ${type === 'error' ? '#f44336' : type === 'success' ? '#4caf50' : '#2196f3'};
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
z-index: 10001;
|
||||
max-width: 300px;
|
||||
`;
|
||||
toast.textContent = message;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
document.body.removeChild(toast);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
processCredential(credential, type) {
|
||||
const processed = {
|
||||
id: this.arrayBufferToBase64(credential.rawId),
|
||||
rawId: this.arrayBufferToBase64(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {}
|
||||
};
|
||||
|
||||
if (type === 'registration') {
|
||||
processed.response = {
|
||||
attestationObject: this.arrayBufferToBase64(credential.response.attestationObject),
|
||||
clientDataJSON: this.arrayBufferToBase64(credential.response.clientDataJSON),
|
||||
transports: credential.response.getTransports?.() || []
|
||||
};
|
||||
} else {
|
||||
processed.response = {
|
||||
authenticatorData: this.arrayBufferToBase64(credential.response.authenticatorData),
|
||||
clientDataJSON: this.arrayBufferToBase64(credential.response.clientDataJSON),
|
||||
signature: this.arrayBufferToBase64(credential.response.signature),
|
||||
userHandle: credential.response.userHandle ?
|
||||
this.arrayBufferToBase64(credential.response.userHandle) : null
|
||||
};
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
parseAttestation(response) {
|
||||
try {
|
||||
// Basic attestation parsing
|
||||
const clientDataJSON = JSON.parse(this.arrayBufferToString(response.clientDataJSON));
|
||||
|
||||
return {
|
||||
format: 'packed', // Simplified
|
||||
clientData: clientDataJSON,
|
||||
origin: clientDataJSON.origin,
|
||||
challenge: clientDataJSON.challenge
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn('[BiometricAuthManager] Attestation parsing failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getClientIP() {
|
||||
try {
|
||||
// Use a public IP service (consider privacy implications)
|
||||
const response = await fetch('https://api.ipify.org?format=json');
|
||||
const data = await response.json();
|
||||
return data.ip;
|
||||
} catch (error) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
generateSessionId() {
|
||||
return `biometric_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Encoding/decoding utilities
|
||||
|
||||
arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
base64ToArrayBuffer(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
stringToArrayBuffer(str) {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(str);
|
||||
}
|
||||
|
||||
arrayBufferToString(buffer) {
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered credentials
|
||||
*/
|
||||
getCredentials() {
|
||||
return Array.from(this.credentials.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active authentication sessions
|
||||
*/
|
||||
getActiveSessions() {
|
||||
return Array.from(this.authSessions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a credential
|
||||
*/
|
||||
revokeCredential(credentialId) {
|
||||
const removed = this.credentials.delete(credentialId);
|
||||
if (removed) {
|
||||
Logger.info(`[BiometricAuthManager] Credential revoked: ${credentialId}`);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* End authentication session
|
||||
*/
|
||||
endSession(sessionId) {
|
||||
const removed = this.authSessions.delete(sessionId);
|
||||
if (removed) {
|
||||
Logger.info(`[BiometricAuthManager] Session ended: ${sessionId}`);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired sessions
|
||||
*/
|
||||
cleanupSessions(maxAge = 24 * 60 * 60 * 1000) { // 24 hours
|
||||
const now = Date.now();
|
||||
let cleaned = 0;
|
||||
|
||||
for (const [sessionId, session] of this.authSessions.entries()) {
|
||||
if (now - session.authenticatedAt > maxAge) {
|
||||
this.authSessions.delete(sessionId);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0) {
|
||||
Logger.info(`[BiometricAuthManager] Cleaned up ${cleaned} expired sessions`);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive status report
|
||||
*/
|
||||
async getStatusReport() {
|
||||
const availability = await this.isAvailable();
|
||||
|
||||
return {
|
||||
availability,
|
||||
credentials: this.credentials.size,
|
||||
sessions: this.authSessions.size,
|
||||
support: this.support,
|
||||
config: {
|
||||
rpName: this.config.rpName,
|
||||
rpId: this.config.rpId,
|
||||
timeout: this.config.timeout
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
}
|
||||
704
resources/js/modules/api-manager/DeviceManager.js
Normal file
704
resources/js/modules/api-manager/DeviceManager.js
Normal file
@@ -0,0 +1,704 @@
|
||||
// modules/api-manager/DeviceManager.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Device APIs Manager - Geolocation, Sensors, Battery, Network, Vibration
|
||||
*/
|
||||
export class DeviceManager {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.activeWatchers = new Map();
|
||||
this.sensorData = new Map();
|
||||
|
||||
// Check API support
|
||||
this.support = {
|
||||
geolocation: 'geolocation' in navigator,
|
||||
deviceMotion: 'DeviceMotionEvent' in window,
|
||||
deviceOrientation: 'DeviceOrientationEvent' in window,
|
||||
vibration: 'vibrate' in navigator,
|
||||
battery: 'getBattery' in navigator,
|
||||
networkInfo: 'connection' in navigator || 'mozConnection' in navigator || 'webkitConnection' in navigator,
|
||||
wakeLock: 'wakeLock' in navigator,
|
||||
bluetooth: 'bluetooth' in navigator,
|
||||
usb: 'usb' in navigator,
|
||||
serial: 'serial' in navigator
|
||||
};
|
||||
|
||||
Logger.info('[DeviceManager] Initialized with support:', this.support);
|
||||
|
||||
// Initialize sensors if available
|
||||
this.initializeSensors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Geolocation API
|
||||
*/
|
||||
geolocation = {
|
||||
// Get current position
|
||||
getCurrent: (options = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.support.geolocation) {
|
||||
reject(new Error('Geolocation not supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 60000,
|
||||
...options
|
||||
};
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const location = this.enhanceLocationData(position);
|
||||
Logger.info('[DeviceManager] Location acquired:', {
|
||||
lat: location.latitude,
|
||||
lng: location.longitude,
|
||||
accuracy: location.accuracy
|
||||
});
|
||||
resolve(location);
|
||||
},
|
||||
(error) => {
|
||||
Logger.error('[DeviceManager] Geolocation failed:', error.message);
|
||||
reject(error);
|
||||
},
|
||||
defaultOptions
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
// Watch position changes
|
||||
watch: (callback, options = {}) => {
|
||||
if (!this.support.geolocation) {
|
||||
throw new Error('Geolocation not supported');
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 30000,
|
||||
maximumAge: 10000,
|
||||
...options
|
||||
};
|
||||
|
||||
const watchId = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
const location = this.enhanceLocationData(position);
|
||||
callback(location);
|
||||
},
|
||||
(error) => {
|
||||
Logger.error('[DeviceManager] Location watch failed:', error.message);
|
||||
callback({ error });
|
||||
},
|
||||
defaultOptions
|
||||
);
|
||||
|
||||
this.activeWatchers.set(`geo_${watchId}`, {
|
||||
type: 'geolocation',
|
||||
id: watchId,
|
||||
stop: () => {
|
||||
navigator.geolocation.clearWatch(watchId);
|
||||
this.activeWatchers.delete(`geo_${watchId}`);
|
||||
}
|
||||
});
|
||||
|
||||
Logger.info('[DeviceManager] Location watch started:', watchId);
|
||||
|
||||
return {
|
||||
id: watchId,
|
||||
stop: () => {
|
||||
navigator.geolocation.clearWatch(watchId);
|
||||
this.activeWatchers.delete(`geo_${watchId}`);
|
||||
Logger.info('[DeviceManager] Location watch stopped:', watchId);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Calculate distance between two points
|
||||
distance: (pos1, pos2) => {
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = this.toRadians(pos2.latitude - pos1.latitude);
|
||||
const dLon = this.toRadians(pos2.longitude - pos1.longitude);
|
||||
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(this.toRadians(pos1.latitude)) * Math.cos(this.toRadians(pos2.latitude)) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const distance = R * c;
|
||||
|
||||
return {
|
||||
kilometers: distance,
|
||||
miles: distance * 0.621371,
|
||||
meters: distance * 1000
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Device Motion and Orientation
|
||||
*/
|
||||
motion = {
|
||||
// Start motion detection
|
||||
start: (callback, options = {}) => {
|
||||
if (!this.support.deviceMotion) {
|
||||
throw new Error('Device Motion not supported');
|
||||
}
|
||||
|
||||
const handler = (event) => {
|
||||
const motionData = {
|
||||
acceleration: event.acceleration,
|
||||
accelerationIncludingGravity: event.accelerationIncludingGravity,
|
||||
rotationRate: event.rotationRate,
|
||||
interval: event.interval,
|
||||
timestamp: event.timeStamp,
|
||||
// Enhanced data
|
||||
totalAcceleration: this.calculateTotalAcceleration(event.acceleration),
|
||||
shake: this.detectShake(event.accelerationIncludingGravity),
|
||||
orientation: this.getDeviceOrientation(event)
|
||||
};
|
||||
|
||||
callback(motionData);
|
||||
};
|
||||
|
||||
// Request permission for iOS 13+
|
||||
if (typeof DeviceMotionEvent.requestPermission === 'function') {
|
||||
DeviceMotionEvent.requestPermission().then(response => {
|
||||
if (response === 'granted') {
|
||||
window.addEventListener('devicemotion', handler);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
window.addEventListener('devicemotion', handler);
|
||||
}
|
||||
|
||||
const watcherId = this.generateId('motion');
|
||||
this.activeWatchers.set(watcherId, {
|
||||
type: 'motion',
|
||||
handler,
|
||||
stop: () => {
|
||||
window.removeEventListener('devicemotion', handler);
|
||||
this.activeWatchers.delete(watcherId);
|
||||
}
|
||||
});
|
||||
|
||||
Logger.info('[DeviceManager] Motion detection started');
|
||||
|
||||
return {
|
||||
id: watcherId,
|
||||
stop: () => {
|
||||
window.removeEventListener('devicemotion', handler);
|
||||
this.activeWatchers.delete(watcherId);
|
||||
Logger.info('[DeviceManager] Motion detection stopped');
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Start orientation detection
|
||||
startOrientation: (callback, options = {}) => {
|
||||
if (!this.support.deviceOrientation) {
|
||||
throw new Error('Device Orientation not supported');
|
||||
}
|
||||
|
||||
const handler = (event) => {
|
||||
const orientationData = {
|
||||
alpha: event.alpha, // Z axis (0-360)
|
||||
beta: event.beta, // X axis (-180 to 180)
|
||||
gamma: event.gamma, // Y axis (-90 to 90)
|
||||
absolute: event.absolute,
|
||||
timestamp: event.timeStamp,
|
||||
// Enhanced data
|
||||
compass: this.calculateCompass(event.alpha),
|
||||
tilt: this.calculateTilt(event.beta, event.gamma),
|
||||
rotation: this.getRotationState(event)
|
||||
};
|
||||
|
||||
callback(orientationData);
|
||||
};
|
||||
|
||||
// Request permission for iOS 13+
|
||||
if (typeof DeviceOrientationEvent.requestPermission === 'function') {
|
||||
DeviceOrientationEvent.requestPermission().then(response => {
|
||||
if (response === 'granted') {
|
||||
window.addEventListener('deviceorientation', handler);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
window.addEventListener('deviceorientation', handler);
|
||||
}
|
||||
|
||||
const watcherId = this.generateId('orientation');
|
||||
this.activeWatchers.set(watcherId, {
|
||||
type: 'orientation',
|
||||
handler,
|
||||
stop: () => {
|
||||
window.removeEventListener('deviceorientation', handler);
|
||||
this.activeWatchers.delete(watcherId);
|
||||
}
|
||||
});
|
||||
|
||||
Logger.info('[DeviceManager] Orientation detection started');
|
||||
|
||||
return {
|
||||
id: watcherId,
|
||||
stop: () => {
|
||||
window.removeEventListener('deviceorientation', handler);
|
||||
this.activeWatchers.delete(watcherId);
|
||||
Logger.info('[DeviceManager] Orientation detection stopped');
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Vibration API
|
||||
*/
|
||||
vibration = {
|
||||
// Simple vibration
|
||||
vibrate: (pattern) => {
|
||||
if (!this.support.vibration) {
|
||||
Logger.warn('[DeviceManager] Vibration not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
navigator.vibrate(pattern);
|
||||
Logger.info('[DeviceManager] Vibration triggered:', pattern);
|
||||
return true;
|
||||
} catch (error) {
|
||||
Logger.error('[DeviceManager] Vibration failed:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Predefined patterns
|
||||
patterns: {
|
||||
short: 200,
|
||||
long: 600,
|
||||
double: [200, 100, 200],
|
||||
triple: [200, 100, 200, 100, 200],
|
||||
sos: [100, 30, 100, 30, 100, 200, 200, 30, 200, 30, 200, 200, 100, 30, 100, 30, 100],
|
||||
heartbeat: [100, 30, 100, 130, 40, 30, 40, 30, 100],
|
||||
notification: [200, 100, 200],
|
||||
success: [100],
|
||||
error: [300, 100, 300],
|
||||
warning: [200, 100, 200, 100, 200]
|
||||
},
|
||||
|
||||
// Stop vibration
|
||||
stop: () => {
|
||||
if (this.support.vibration) {
|
||||
navigator.vibrate(0);
|
||||
Logger.info('[DeviceManager] Vibration stopped');
|
||||
}
|
||||
},
|
||||
|
||||
// Haptic feedback helpers
|
||||
success: () => this.vibration.vibrate(this.vibration.patterns.success),
|
||||
error: () => this.vibration.vibrate(this.vibration.patterns.error),
|
||||
warning: () => this.vibration.vibrate(this.vibration.patterns.warning),
|
||||
notification: () => this.vibration.vibrate(this.vibration.patterns.notification)
|
||||
};
|
||||
|
||||
/**
|
||||
* Battery API
|
||||
*/
|
||||
battery = {
|
||||
// Get battery status
|
||||
get: async () => {
|
||||
if (!this.support.battery) {
|
||||
Logger.warn('[DeviceManager] Battery API not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const battery = await navigator.getBattery();
|
||||
|
||||
const batteryInfo = {
|
||||
level: Math.round(battery.level * 100),
|
||||
charging: battery.charging,
|
||||
chargingTime: battery.chargingTime,
|
||||
dischargingTime: battery.dischargingTime,
|
||||
// Enhanced data
|
||||
status: this.getBatteryStatus(battery),
|
||||
timeRemaining: this.formatBatteryTime(battery)
|
||||
};
|
||||
|
||||
Logger.info('[DeviceManager] Battery status:', batteryInfo);
|
||||
return batteryInfo;
|
||||
} catch (error) {
|
||||
Logger.error('[DeviceManager] Battery status failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Watch battery changes
|
||||
watch: async (callback) => {
|
||||
if (!this.support.battery) {
|
||||
throw new Error('Battery API not supported');
|
||||
}
|
||||
|
||||
try {
|
||||
const battery = await navigator.getBattery();
|
||||
|
||||
const events = ['chargingchange', 'levelchange', 'chargingtimechange', 'dischargingtimechange'];
|
||||
const handlers = [];
|
||||
|
||||
events.forEach(eventType => {
|
||||
const handler = () => {
|
||||
const batteryInfo = {
|
||||
level: Math.round(battery.level * 100),
|
||||
charging: battery.charging,
|
||||
chargingTime: battery.chargingTime,
|
||||
dischargingTime: battery.dischargingTime,
|
||||
status: this.getBatteryStatus(battery),
|
||||
timeRemaining: this.formatBatteryTime(battery),
|
||||
event: eventType
|
||||
};
|
||||
callback(batteryInfo);
|
||||
};
|
||||
|
||||
battery.addEventListener(eventType, handler);
|
||||
handlers.push({ event: eventType, handler });
|
||||
});
|
||||
|
||||
const watcherId = this.generateId('battery');
|
||||
this.activeWatchers.set(watcherId, {
|
||||
type: 'battery',
|
||||
battery,
|
||||
handlers,
|
||||
stop: () => {
|
||||
handlers.forEach(({ event, handler }) => {
|
||||
battery.removeEventListener(event, handler);
|
||||
});
|
||||
this.activeWatchers.delete(watcherId);
|
||||
}
|
||||
});
|
||||
|
||||
Logger.info('[DeviceManager] Battery watch started');
|
||||
|
||||
return {
|
||||
id: watcherId,
|
||||
stop: () => {
|
||||
handlers.forEach(({ event, handler }) => {
|
||||
battery.removeEventListener(event, handler);
|
||||
});
|
||||
this.activeWatchers.delete(watcherId);
|
||||
Logger.info('[DeviceManager] Battery watch stopped');
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error('[DeviceManager] Battery watch failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Network Information API
|
||||
*/
|
||||
network = {
|
||||
// Get connection info
|
||||
get: () => {
|
||||
if (!this.support.networkInfo) {
|
||||
Logger.warn('[DeviceManager] Network Information not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
|
||||
return {
|
||||
effectiveType: connection.effectiveType,
|
||||
downlink: connection.downlink,
|
||||
rtt: connection.rtt,
|
||||
saveData: connection.saveData,
|
||||
// Enhanced data
|
||||
speed: this.getConnectionSpeed(connection),
|
||||
quality: this.getConnectionQuality(connection),
|
||||
recommendation: this.getNetworkRecommendation(connection)
|
||||
};
|
||||
},
|
||||
|
||||
// Watch network changes
|
||||
watch: (callback) => {
|
||||
if (!this.support.networkInfo) {
|
||||
throw new Error('Network Information not supported');
|
||||
}
|
||||
|
||||
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
|
||||
const handler = () => {
|
||||
const networkInfo = {
|
||||
effectiveType: connection.effectiveType,
|
||||
downlink: connection.downlink,
|
||||
rtt: connection.rtt,
|
||||
saveData: connection.saveData,
|
||||
speed: this.getConnectionSpeed(connection),
|
||||
quality: this.getConnectionQuality(connection),
|
||||
recommendation: this.getNetworkRecommendation(connection)
|
||||
};
|
||||
|
||||
callback(networkInfo);
|
||||
};
|
||||
|
||||
connection.addEventListener('change', handler);
|
||||
|
||||
const watcherId = this.generateId('network');
|
||||
this.activeWatchers.set(watcherId, {
|
||||
type: 'network',
|
||||
connection,
|
||||
handler,
|
||||
stop: () => {
|
||||
connection.removeEventListener('change', handler);
|
||||
this.activeWatchers.delete(watcherId);
|
||||
}
|
||||
});
|
||||
|
||||
Logger.info('[DeviceManager] Network watch started');
|
||||
|
||||
return {
|
||||
id: watcherId,
|
||||
stop: () => {
|
||||
connection.removeEventListener('change', handler);
|
||||
this.activeWatchers.delete(watcherId);
|
||||
Logger.info('[DeviceManager] Network watch stopped');
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Wake Lock API
|
||||
*/
|
||||
wakeLock = {
|
||||
// Request wake lock
|
||||
request: async (type = 'screen') => {
|
||||
if (!this.support.wakeLock) {
|
||||
Logger.warn('[DeviceManager] Wake Lock not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const wakeLock = await navigator.wakeLock.request(type);
|
||||
Logger.info(`[DeviceManager] Wake lock acquired: ${type}`);
|
||||
|
||||
return {
|
||||
type: wakeLock.type,
|
||||
release: () => {
|
||||
wakeLock.release();
|
||||
Logger.info(`[DeviceManager] Wake lock released: ${type}`);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error('[DeviceManager] Wake lock failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper methods
|
||||
|
||||
initializeSensors() {
|
||||
// Initialize any sensors that need setup
|
||||
if (this.support.deviceMotion || this.support.deviceOrientation) {
|
||||
// Store baseline sensor data for comparison
|
||||
this.sensorData.set('motionBaseline', { x: 0, y: 0, z: 0 });
|
||||
this.sensorData.set('shakeThreshold', this.config.shakeThreshold || 15);
|
||||
}
|
||||
}
|
||||
|
||||
enhanceLocationData(position) {
|
||||
return {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
altitude: position.coords.altitude,
|
||||
altitudeAccuracy: position.coords.altitudeAccuracy,
|
||||
heading: position.coords.heading,
|
||||
speed: position.coords.speed,
|
||||
timestamp: position.timestamp,
|
||||
// Enhanced data
|
||||
coordinates: `${position.coords.latitude},${position.coords.longitude}`,
|
||||
accuracyLevel: this.getAccuracyLevel(position.coords.accuracy),
|
||||
mapUrl: `https://maps.google.com/?q=${position.coords.latitude},${position.coords.longitude}`
|
||||
};
|
||||
}
|
||||
|
||||
getAccuracyLevel(accuracy) {
|
||||
if (accuracy <= 5) return 'excellent';
|
||||
if (accuracy <= 10) return 'good';
|
||||
if (accuracy <= 50) return 'fair';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
calculateTotalAcceleration(acceleration) {
|
||||
if (!acceleration) return 0;
|
||||
const x = acceleration.x || 0;
|
||||
const y = acceleration.y || 0;
|
||||
const z = acceleration.z || 0;
|
||||
return Math.sqrt(x * x + y * y + z * z);
|
||||
}
|
||||
|
||||
detectShake(acceleration) {
|
||||
if (!acceleration) return false;
|
||||
|
||||
const threshold = this.sensorData.get('shakeThreshold');
|
||||
const x = Math.abs(acceleration.x || 0);
|
||||
const y = Math.abs(acceleration.y || 0);
|
||||
const z = Math.abs(acceleration.z || 0);
|
||||
|
||||
return (x > threshold || y > threshold || z > threshold);
|
||||
}
|
||||
|
||||
getDeviceOrientation(event) {
|
||||
const acceleration = event.accelerationIncludingGravity;
|
||||
if (!acceleration) return 'unknown';
|
||||
|
||||
const x = acceleration.x || 0;
|
||||
const y = acceleration.y || 0;
|
||||
const z = acceleration.z || 0;
|
||||
|
||||
if (Math.abs(x) > Math.abs(y) && Math.abs(x) > Math.abs(z)) {
|
||||
return x > 0 ? 'landscape-right' : 'landscape-left';
|
||||
} else if (Math.abs(y) > Math.abs(z)) {
|
||||
return y > 0 ? 'portrait-upside-down' : 'portrait';
|
||||
} else {
|
||||
return z > 0 ? 'face-down' : 'face-up';
|
||||
}
|
||||
}
|
||||
|
||||
calculateCompass(alpha) {
|
||||
if (alpha === null) return null;
|
||||
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
const index = Math.round(alpha / 45) % 8;
|
||||
|
||||
return {
|
||||
degrees: Math.round(alpha),
|
||||
direction: directions[index],
|
||||
cardinal: this.getCardinalDirection(alpha)
|
||||
};
|
||||
}
|
||||
|
||||
getCardinalDirection(alpha) {
|
||||
if (alpha >= 337.5 || alpha < 22.5) return 'North';
|
||||
if (alpha >= 22.5 && alpha < 67.5) return 'Northeast';
|
||||
if (alpha >= 67.5 && alpha < 112.5) return 'East';
|
||||
if (alpha >= 112.5 && alpha < 157.5) return 'Southeast';
|
||||
if (alpha >= 157.5 && alpha < 202.5) return 'South';
|
||||
if (alpha >= 202.5 && alpha < 247.5) return 'Southwest';
|
||||
if (alpha >= 247.5 && alpha < 292.5) return 'West';
|
||||
if (alpha >= 292.5 && alpha < 337.5) return 'Northwest';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
calculateTilt(beta, gamma) {
|
||||
return {
|
||||
x: Math.round(beta || 0),
|
||||
y: Math.round(gamma || 0),
|
||||
magnitude: Math.round(Math.sqrt((beta || 0) ** 2 + (gamma || 0) ** 2))
|
||||
};
|
||||
}
|
||||
|
||||
getRotationState(event) {
|
||||
const { alpha, beta, gamma } = event;
|
||||
|
||||
// Determine if device is being rotated significantly
|
||||
const rotationThreshold = 10;
|
||||
const isRotating = Math.abs(beta) > rotationThreshold || Math.abs(gamma) > rotationThreshold;
|
||||
|
||||
return {
|
||||
isRotating,
|
||||
intensity: isRotating ? Math.max(Math.abs(beta), Math.abs(gamma)) : 0
|
||||
};
|
||||
}
|
||||
|
||||
getBatteryStatus(battery) {
|
||||
const level = battery.level * 100;
|
||||
|
||||
if (battery.charging) return 'charging';
|
||||
if (level <= 10) return 'critical';
|
||||
if (level <= 20) return 'low';
|
||||
if (level <= 50) return 'medium';
|
||||
return 'high';
|
||||
}
|
||||
|
||||
formatBatteryTime(battery) {
|
||||
const time = battery.charging ? battery.chargingTime : battery.dischargingTime;
|
||||
|
||||
if (time === Infinity || isNaN(time)) return 'Unknown';
|
||||
|
||||
const hours = Math.floor(time / 3600);
|
||||
const minutes = Math.floor((time % 3600) / 60);
|
||||
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
getConnectionSpeed(connection) {
|
||||
const downlink = connection.downlink;
|
||||
|
||||
if (downlink >= 10) return 'fast';
|
||||
if (downlink >= 1.5) return 'good';
|
||||
if (downlink >= 0.5) return 'slow';
|
||||
return 'very-slow';
|
||||
}
|
||||
|
||||
getConnectionQuality(connection) {
|
||||
const effectiveType = connection.effectiveType;
|
||||
|
||||
switch (effectiveType) {
|
||||
case '4g': return 'excellent';
|
||||
case '3g': return 'good';
|
||||
case '2g': return 'poor';
|
||||
case 'slow-2g': return 'very-poor';
|
||||
default: return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
getNetworkRecommendation(connection) {
|
||||
const quality = this.getConnectionQuality(connection);
|
||||
|
||||
switch (quality) {
|
||||
case 'excellent':
|
||||
return 'Full quality content recommended';
|
||||
case 'good':
|
||||
return 'Moderate quality content recommended';
|
||||
case 'poor':
|
||||
return 'Light content only, avoid large files';
|
||||
case 'very-poor':
|
||||
return 'Text-only content recommended';
|
||||
default:
|
||||
return 'Monitor connection quality';
|
||||
}
|
||||
}
|
||||
|
||||
toRadians(degrees) {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
generateId(prefix = 'device') {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active watchers
|
||||
*/
|
||||
stopAllWatchers() {
|
||||
this.activeWatchers.forEach(watcher => {
|
||||
watcher.stop();
|
||||
});
|
||||
this.activeWatchers.clear();
|
||||
Logger.info('[DeviceManager] All watchers stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device capabilities summary
|
||||
*/
|
||||
getCapabilities() {
|
||||
return {
|
||||
support: this.support,
|
||||
activeWatchers: this.activeWatchers.size,
|
||||
watcherTypes: Array.from(this.activeWatchers.values()).map(w => w.type)
|
||||
};
|
||||
}
|
||||
}
|
||||
554
resources/js/modules/api-manager/MediaManager.js
Normal file
554
resources/js/modules/api-manager/MediaManager.js
Normal file
@@ -0,0 +1,554 @@
|
||||
// modules/api-manager/MediaManager.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Media APIs Manager - Camera, Microphone, WebRTC, Audio, Recording
|
||||
*/
|
||||
export class MediaManager {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.activeStreams = new Map();
|
||||
this.activeConnections = new Map();
|
||||
this.audioContext = null;
|
||||
|
||||
// Check API support
|
||||
this.support = {
|
||||
mediaDevices: navigator.mediaDevices !== undefined,
|
||||
webRTC: 'RTCPeerConnection' in window,
|
||||
webAudio: 'AudioContext' in window || 'webkitAudioContext' in window,
|
||||
mediaRecorder: 'MediaRecorder' in window,
|
||||
screenShare: navigator.mediaDevices?.getDisplayMedia !== undefined
|
||||
};
|
||||
|
||||
Logger.info('[MediaManager] Initialized with support:', this.support);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user camera stream
|
||||
*/
|
||||
async getUserCamera(constraints = {}) {
|
||||
if (!this.support.mediaDevices) {
|
||||
throw new Error('MediaDevices API not supported');
|
||||
}
|
||||
|
||||
const defaultConstraints = {
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: 'user'
|
||||
},
|
||||
audio: false,
|
||||
...constraints
|
||||
};
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(defaultConstraints);
|
||||
const streamId = this.generateId('camera');
|
||||
|
||||
this.activeStreams.set(streamId, {
|
||||
stream,
|
||||
type: 'camera',
|
||||
constraints: defaultConstraints,
|
||||
tracks: stream.getTracks()
|
||||
});
|
||||
|
||||
Logger.info(`[MediaManager] Camera stream acquired: ${streamId}`);
|
||||
|
||||
return {
|
||||
id: streamId,
|
||||
stream,
|
||||
video: stream.getVideoTracks()[0],
|
||||
audio: stream.getAudioTracks()[0],
|
||||
stop: () => this.stopStream(streamId),
|
||||
switchCamera: () => this.switchCamera(streamId),
|
||||
takePhoto: (canvas) => this.takePhoto(stream, canvas),
|
||||
applyFilter: (filter) => this.applyVideoFilter(streamId, filter)
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn('[MediaManager] Camera access failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user microphone stream
|
||||
*/
|
||||
async getUserMicrophone(constraints = {}) {
|
||||
if (!this.support.mediaDevices) {
|
||||
throw new Error('MediaDevices API not supported');
|
||||
}
|
||||
|
||||
const defaultConstraints = {
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
...constraints.audio
|
||||
},
|
||||
video: false,
|
||||
...constraints
|
||||
};
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(defaultConstraints);
|
||||
const streamId = this.generateId('microphone');
|
||||
|
||||
this.activeStreams.set(streamId, {
|
||||
stream,
|
||||
type: 'microphone',
|
||||
constraints: defaultConstraints,
|
||||
tracks: stream.getTracks()
|
||||
});
|
||||
|
||||
Logger.info(`[MediaManager] Microphone stream acquired: ${streamId}`);
|
||||
|
||||
return {
|
||||
id: streamId,
|
||||
stream,
|
||||
audio: stream.getAudioTracks()[0],
|
||||
stop: () => this.stopStream(streamId),
|
||||
getVolume: () => this.getAudioLevel(stream),
|
||||
startRecording: (options) => this.startRecording(stream, options)
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn('[MediaManager] Microphone access failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get screen share stream
|
||||
*/
|
||||
async getScreenShare(constraints = {}) {
|
||||
if (!this.support.screenShare) {
|
||||
throw new Error('Screen sharing not supported');
|
||||
}
|
||||
|
||||
const defaultConstraints = {
|
||||
video: {
|
||||
cursor: 'always'
|
||||
},
|
||||
audio: false,
|
||||
...constraints
|
||||
};
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia(defaultConstraints);
|
||||
const streamId = this.generateId('screen');
|
||||
|
||||
this.activeStreams.set(streamId, {
|
||||
stream,
|
||||
type: 'screen',
|
||||
constraints: defaultConstraints,
|
||||
tracks: stream.getTracks()
|
||||
});
|
||||
|
||||
// Auto-cleanup when user stops sharing
|
||||
stream.getTracks().forEach(track => {
|
||||
track.addEventListener('ended', () => {
|
||||
this.stopStream(streamId);
|
||||
});
|
||||
});
|
||||
|
||||
Logger.info(`[MediaManager] Screen share acquired: ${streamId}`);
|
||||
|
||||
return {
|
||||
id: streamId,
|
||||
stream,
|
||||
video: stream.getVideoTracks()[0],
|
||||
audio: stream.getAudioTracks()[0],
|
||||
stop: () => this.stopStream(streamId)
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn('[MediaManager] Screen share failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording media stream
|
||||
*/
|
||||
async startRecording(stream, options = {}) {
|
||||
if (!this.support.mediaRecorder) {
|
||||
throw new Error('MediaRecorder API not supported');
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
mimeType: 'video/webm;codecs=vp9',
|
||||
videoBitsPerSecond: 2000000,
|
||||
audioBitsPerSecond: 128000,
|
||||
...options
|
||||
};
|
||||
|
||||
// Find supported MIME type
|
||||
const mimeType = this.getSupportedMimeType([
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
'video/mp4'
|
||||
]) || defaultOptions.mimeType;
|
||||
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
...defaultOptions,
|
||||
mimeType
|
||||
});
|
||||
|
||||
const recordingId = this.generateId('recording');
|
||||
const chunks = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: mimeType });
|
||||
this.onRecordingComplete(recordingId, blob);
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
|
||||
Logger.info(`[MediaManager] Recording started: ${recordingId}`);
|
||||
|
||||
return {
|
||||
id: recordingId,
|
||||
recorder,
|
||||
stop: () => {
|
||||
recorder.stop();
|
||||
return new Promise(resolve => {
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: mimeType });
|
||||
resolve({
|
||||
blob,
|
||||
url: URL.createObjectURL(blob),
|
||||
size: blob.size,
|
||||
type: blob.type,
|
||||
download: (filename = `recording-${Date.now()}.webm`) => {
|
||||
this.downloadBlob(blob, filename);
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
},
|
||||
pause: () => recorder.pause(),
|
||||
resume: () => recorder.resume(),
|
||||
get state() { return recorder.state; }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take photo from video stream
|
||||
*/
|
||||
takePhoto(stream, canvas) {
|
||||
const video = document.createElement('video');
|
||||
video.srcObject = stream;
|
||||
video.autoplay = true;
|
||||
video.muted = true;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
video.onloadedmetadata = () => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
}
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
resolve({
|
||||
canvas,
|
||||
blob,
|
||||
url: URL.createObjectURL(blob),
|
||||
dataURL: canvas.toDataURL('image/jpeg', 0.9),
|
||||
download: (filename = `photo-${Date.now()}.jpg`) => {
|
||||
this.downloadBlob(blob, filename);
|
||||
}
|
||||
});
|
||||
}, 'image/jpeg', 0.9);
|
||||
|
||||
video.remove();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Audio Context setup
|
||||
*/
|
||||
getAudioContext() {
|
||||
if (!this.audioContext) {
|
||||
if (this.support.webAudio) {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
Logger.info('[MediaManager] Audio context created');
|
||||
} else {
|
||||
Logger.warn('[MediaManager] Web Audio API not supported');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return this.audioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create audio analyzer for visualizations
|
||||
*/
|
||||
createAudioAnalyzer(stream, options = {}) {
|
||||
const audioContext = this.getAudioContext();
|
||||
if (!audioContext) return null;
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const analyzer = audioContext.createAnalyser();
|
||||
|
||||
analyzer.fftSize = options.fftSize || 256;
|
||||
analyzer.smoothingTimeConstant = options.smoothing || 0.8;
|
||||
|
||||
source.connect(analyzer);
|
||||
|
||||
const bufferLength = analyzer.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
return {
|
||||
analyzer,
|
||||
bufferLength,
|
||||
dataArray,
|
||||
getFrequencyData: () => {
|
||||
analyzer.getByteFrequencyData(dataArray);
|
||||
return Array.from(dataArray);
|
||||
},
|
||||
getTimeDomainData: () => {
|
||||
analyzer.getByteTimeDomainData(dataArray);
|
||||
return Array.from(dataArray);
|
||||
},
|
||||
getAverageVolume: () => {
|
||||
analyzer.getByteFrequencyData(dataArray);
|
||||
return dataArray.reduce((sum, value) => sum + value, 0) / bufferLength;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple WebRTC peer connection setup
|
||||
*/
|
||||
async createPeerConnection(config = {}) {
|
||||
if (!this.support.webRTC) {
|
||||
throw new Error('WebRTC not supported');
|
||||
}
|
||||
|
||||
const defaultConfig = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
],
|
||||
...config
|
||||
};
|
||||
|
||||
const pc = new RTCPeerConnection(defaultConfig);
|
||||
const connectionId = this.generateId('rtc');
|
||||
|
||||
this.activeConnections.set(connectionId, pc);
|
||||
|
||||
// Enhanced peer connection with event handling
|
||||
const enhancedPC = {
|
||||
id: connectionId,
|
||||
connection: pc,
|
||||
|
||||
// Event handlers
|
||||
onTrack: (callback) => pc.addEventListener('track', callback),
|
||||
onIceCandidate: (callback) => pc.addEventListener('icecandidate', callback),
|
||||
onConnectionStateChange: (callback) => pc.addEventListener('connectionstatechange', callback),
|
||||
|
||||
// Methods
|
||||
addStream: (stream) => {
|
||||
stream.getTracks().forEach(track => {
|
||||
pc.addTrack(track, stream);
|
||||
});
|
||||
},
|
||||
|
||||
createOffer: () => pc.createOffer(),
|
||||
createAnswer: () => pc.createAnswer(),
|
||||
setLocalDescription: (desc) => pc.setLocalDescription(desc),
|
||||
setRemoteDescription: (desc) => pc.setRemoteDescription(desc),
|
||||
addIceCandidate: (candidate) => pc.addIceCandidate(candidate),
|
||||
|
||||
close: () => {
|
||||
pc.close();
|
||||
this.activeConnections.delete(connectionId);
|
||||
},
|
||||
|
||||
get connectionState() { return pc.connectionState; },
|
||||
get iceConnectionState() { return pc.iceConnectionState; }
|
||||
};
|
||||
|
||||
Logger.info(`[MediaManager] Peer connection created: ${connectionId}`);
|
||||
return enhancedPC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available media devices
|
||||
*/
|
||||
async getDevices() {
|
||||
if (!this.support.mediaDevices) {
|
||||
return { cameras: [], microphones: [], speakers: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
return {
|
||||
cameras: devices.filter(d => d.kind === 'videoinput'),
|
||||
microphones: devices.filter(d => d.kind === 'audioinput'),
|
||||
speakers: devices.filter(d => d.kind === 'audiooutput'),
|
||||
all: devices
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn('[MediaManager] Device enumeration failed:', error);
|
||||
return { cameras: [], microphones: [], speakers: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check device permissions
|
||||
*/
|
||||
async checkPermissions() {
|
||||
const permissions = {};
|
||||
|
||||
try {
|
||||
if (navigator.permissions) {
|
||||
const camera = await navigator.permissions.query({ name: 'camera' });
|
||||
const microphone = await navigator.permissions.query({ name: 'microphone' });
|
||||
|
||||
permissions.camera = camera.state;
|
||||
permissions.microphone = microphone.state;
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn('[MediaManager] Permission check failed:', error);
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
stopStream(streamId) {
|
||||
const streamData = this.activeStreams.get(streamId);
|
||||
if (streamData) {
|
||||
streamData.tracks.forEach(track => track.stop());
|
||||
this.activeStreams.delete(streamId);
|
||||
Logger.info(`[MediaManager] Stream stopped: ${streamId}`);
|
||||
}
|
||||
}
|
||||
|
||||
stopAllStreams() {
|
||||
this.activeStreams.forEach((streamData, id) => {
|
||||
streamData.tracks.forEach(track => track.stop());
|
||||
});
|
||||
this.activeStreams.clear();
|
||||
Logger.info('[MediaManager] All streams stopped');
|
||||
}
|
||||
|
||||
async switchCamera(streamId) {
|
||||
const streamData = this.activeStreams.get(streamId);
|
||||
if (!streamData || streamData.type !== 'camera') return null;
|
||||
|
||||
const currentFacing = streamData.constraints.video.facingMode;
|
||||
const newFacing = currentFacing === 'user' ? 'environment' : 'user';
|
||||
|
||||
// Stop current stream
|
||||
this.stopStream(streamId);
|
||||
|
||||
// Get new stream with switched camera
|
||||
return this.getUserCamera({
|
||||
video: {
|
||||
...streamData.constraints.video,
|
||||
facingMode: newFacing
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getAudioLevel(stream) {
|
||||
const audioContext = this.getAudioContext();
|
||||
if (!audioContext) return 0;
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const analyzer = audioContext.createAnalyser();
|
||||
source.connect(analyzer);
|
||||
|
||||
const dataArray = new Uint8Array(analyzer.frequencyBinCount);
|
||||
analyzer.getByteFrequencyData(dataArray);
|
||||
|
||||
return dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length;
|
||||
}
|
||||
|
||||
getSupportedMimeType(types) {
|
||||
return types.find(type => MediaRecorder.isTypeSupported(type));
|
||||
}
|
||||
|
||||
downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
generateId(prefix = 'media') {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
onRecordingComplete(id, blob) {
|
||||
Logger.info(`[MediaManager] Recording completed: ${id}, size: ${blob.size} bytes`);
|
||||
// Custom completion handling can be added here
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply video filters using CSS filters
|
||||
*/
|
||||
applyVideoFilter(streamId, filterName) {
|
||||
const streamData = this.activeStreams.get(streamId);
|
||||
if (!streamData || streamData.type !== 'camera') return;
|
||||
|
||||
const filters = {
|
||||
none: 'none',
|
||||
blur: 'blur(2px)',
|
||||
brightness: 'brightness(1.2)',
|
||||
contrast: 'contrast(1.3)',
|
||||
grayscale: 'grayscale(1)',
|
||||
sepia: 'sepia(1)',
|
||||
invert: 'invert(1)',
|
||||
vintage: 'sepia(0.8) contrast(1.4) brightness(1.1)',
|
||||
cool: 'hue-rotate(180deg) saturate(1.5)',
|
||||
warm: 'hue-rotate(25deg) saturate(1.2)'
|
||||
};
|
||||
|
||||
return {
|
||||
filter: filters[filterName] || filterName,
|
||||
apply: (videoElement) => {
|
||||
videoElement.style.filter = filters[filterName] || filterName;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status of all media operations
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
activeStreams: this.activeStreams.size,
|
||||
activeConnections: this.activeConnections.size,
|
||||
audioContextState: this.audioContext?.state || 'none',
|
||||
support: this.support,
|
||||
streams: Array.from(this.activeStreams.entries()).map(([id, data]) => ({
|
||||
id,
|
||||
type: data.type,
|
||||
tracks: data.tracks.length,
|
||||
active: data.tracks.some(track => track.readyState === 'live')
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
491
resources/js/modules/api-manager/ObserverManager.js
Normal file
491
resources/js/modules/api-manager/ObserverManager.js
Normal file
@@ -0,0 +1,491 @@
|
||||
// modules/api-manager/ObserverManager.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Observer APIs Manager - Intersection, Resize, Mutation, Performance Observers
|
||||
*/
|
||||
export class ObserverManager {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.activeObservers = new Map();
|
||||
this.observerInstances = new Map();
|
||||
|
||||
Logger.info('[ObserverManager] Initialized with support:', {
|
||||
intersection: 'IntersectionObserver' in window,
|
||||
resize: 'ResizeObserver' in window,
|
||||
mutation: 'MutationObserver' in window,
|
||||
performance: 'PerformanceObserver' in window
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersection Observer - Viewport intersection detection
|
||||
*/
|
||||
intersection(elements, callback, options = {}) {
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
Logger.warn('[ObserverManager] IntersectionObserver not supported');
|
||||
return this.createFallbackObserver('intersection', elements, callback);
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
root: null,
|
||||
rootMargin: '50px',
|
||||
threshold: [0, 0.1, 0.5, 1.0],
|
||||
...options
|
||||
};
|
||||
|
||||
const observerId = this.generateId('intersection');
|
||||
const observer = new IntersectionObserver((entries, obs) => {
|
||||
const processedEntries = entries.map(entry => ({
|
||||
element: entry.target,
|
||||
isIntersecting: entry.isIntersecting,
|
||||
intersectionRatio: entry.intersectionRatio,
|
||||
boundingClientRect: entry.boundingClientRect,
|
||||
rootBounds: entry.rootBounds,
|
||||
intersectionRect: entry.intersectionRect,
|
||||
time: entry.time,
|
||||
// Enhanced data
|
||||
visibility: this.calculateVisibility(entry),
|
||||
direction: this.getScrollDirection(entry),
|
||||
position: this.getElementPosition(entry)
|
||||
}));
|
||||
|
||||
callback(processedEntries, obs);
|
||||
}, defaultOptions);
|
||||
|
||||
// Observe elements
|
||||
const elementList = Array.isArray(elements) ? elements : [elements];
|
||||
elementList.forEach(el => {
|
||||
if (el instanceof Element) observer.observe(el);
|
||||
});
|
||||
|
||||
this.observerInstances.set(observerId, observer);
|
||||
this.activeObservers.set(observerId, {
|
||||
type: 'intersection',
|
||||
elements: elementList,
|
||||
callback,
|
||||
options: defaultOptions
|
||||
});
|
||||
|
||||
Logger.info(`[ObserverManager] IntersectionObserver created: ${observerId}`);
|
||||
|
||||
return {
|
||||
id: observerId,
|
||||
observer,
|
||||
unobserve: (element) => observer.unobserve(element),
|
||||
disconnect: () => this.disconnect(observerId),
|
||||
updateThreshold: (threshold) => this.updateIntersectionThreshold(observerId, threshold)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize Observer - Element resize detection
|
||||
*/
|
||||
resize(elements, callback, options = {}) {
|
||||
if (!('ResizeObserver' in window)) {
|
||||
Logger.warn('[ObserverManager] ResizeObserver not supported');
|
||||
return this.createFallbackObserver('resize', elements, callback);
|
||||
}
|
||||
|
||||
const observerId = this.generateId('resize');
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const processedEntries = entries.map(entry => ({
|
||||
element: entry.target,
|
||||
contentRect: entry.contentRect,
|
||||
borderBoxSize: entry.borderBoxSize,
|
||||
contentBoxSize: entry.contentBoxSize,
|
||||
devicePixelContentBoxSize: entry.devicePixelContentBoxSize,
|
||||
// Enhanced data
|
||||
dimensions: {
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
aspectRatio: entry.contentRect.width / entry.contentRect.height
|
||||
},
|
||||
deltaSize: this.calculateDeltaSize(entry),
|
||||
breakpoint: this.detectBreakpoint(entry.contentRect.width)
|
||||
}));
|
||||
|
||||
callback(processedEntries);
|
||||
});
|
||||
|
||||
const elementList = Array.isArray(elements) ? elements : [elements];
|
||||
elementList.forEach(el => {
|
||||
if (el instanceof Element) observer.observe(el);
|
||||
});
|
||||
|
||||
this.observerInstances.set(observerId, observer);
|
||||
this.activeObservers.set(observerId, {
|
||||
type: 'resize',
|
||||
elements: elementList,
|
||||
callback,
|
||||
options
|
||||
});
|
||||
|
||||
Logger.info(`[ObserverManager] ResizeObserver created: ${observerId}`);
|
||||
|
||||
return {
|
||||
id: observerId,
|
||||
observer,
|
||||
unobserve: (element) => observer.unobserve(element),
|
||||
disconnect: () => this.disconnect(observerId)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation Observer - DOM change detection
|
||||
*/
|
||||
mutation(target, callback, options = {}) {
|
||||
if (!('MutationObserver' in window)) {
|
||||
Logger.warn('[ObserverManager] MutationObserver not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
attributeOldValue: true,
|
||||
characterDataOldValue: true,
|
||||
...options
|
||||
};
|
||||
|
||||
const observerId = this.generateId('mutation');
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
const processedMutations = mutations.map(mutation => ({
|
||||
type: mutation.type,
|
||||
target: mutation.target,
|
||||
addedNodes: Array.from(mutation.addedNodes),
|
||||
removedNodes: Array.from(mutation.removedNodes),
|
||||
attributeName: mutation.attributeName,
|
||||
attributeNamespace: mutation.attributeNamespace,
|
||||
oldValue: mutation.oldValue,
|
||||
// Enhanced data
|
||||
summary: this.summarizeMutation(mutation),
|
||||
impact: this.assessMutationImpact(mutation)
|
||||
}));
|
||||
|
||||
callback(processedMutations);
|
||||
});
|
||||
|
||||
observer.observe(target, defaultOptions);
|
||||
|
||||
this.observerInstances.set(observerId, observer);
|
||||
this.activeObservers.set(observerId, {
|
||||
type: 'mutation',
|
||||
target,
|
||||
callback,
|
||||
options: defaultOptions
|
||||
});
|
||||
|
||||
Logger.info(`[ObserverManager] MutationObserver created: ${observerId}`);
|
||||
|
||||
return {
|
||||
id: observerId,
|
||||
observer,
|
||||
disconnect: () => this.disconnect(observerId),
|
||||
takeRecords: () => observer.takeRecords()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance Observer - Performance metrics monitoring
|
||||
*/
|
||||
performance(callback, options = {}) {
|
||||
if (!('PerformanceObserver' in window)) {
|
||||
Logger.warn('[ObserverManager] PerformanceObserver not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
entryTypes: ['measure', 'navigation', 'paint', 'largest-contentful-paint'],
|
||||
buffered: true,
|
||||
...options
|
||||
};
|
||||
|
||||
const observerId = this.generateId('performance');
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries().map(entry => ({
|
||||
name: entry.name,
|
||||
entryType: entry.entryType,
|
||||
startTime: entry.startTime,
|
||||
duration: entry.duration,
|
||||
// Enhanced data based on entry type
|
||||
details: this.enhancePerformanceEntry(entry),
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
|
||||
callback(entries);
|
||||
});
|
||||
|
||||
observer.observe(defaultOptions);
|
||||
|
||||
this.observerInstances.set(observerId, observer);
|
||||
this.activeObservers.set(observerId, {
|
||||
type: 'performance',
|
||||
callback,
|
||||
options: defaultOptions
|
||||
});
|
||||
|
||||
Logger.info(`[ObserverManager] PerformanceObserver created: ${observerId}`);
|
||||
|
||||
return {
|
||||
id: observerId,
|
||||
observer,
|
||||
disconnect: () => this.disconnect(observerId),
|
||||
takeRecords: () => observer.takeRecords()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy Loading Helper - Uses IntersectionObserver
|
||||
*/
|
||||
lazyLoad(selector = 'img[data-src], iframe[data-src]', options = {}) {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
|
||||
return this.intersection(elements, (entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const element = entry.element;
|
||||
|
||||
// Load image or iframe
|
||||
if (element.dataset.src) {
|
||||
element.src = element.dataset.src;
|
||||
delete element.dataset.src;
|
||||
}
|
||||
|
||||
// Load srcset if available
|
||||
if (element.dataset.srcset) {
|
||||
element.srcset = element.dataset.srcset;
|
||||
delete element.dataset.srcset;
|
||||
}
|
||||
|
||||
// Add loaded class
|
||||
element.classList.add('loaded');
|
||||
|
||||
// Stop observing this element
|
||||
entry.observer.unobserve(element);
|
||||
|
||||
Logger.info('[ObserverManager] Lazy loaded:', element.src);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '100px',
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll Trigger Helper - Uses IntersectionObserver
|
||||
*/
|
||||
scrollTrigger(elements, callback, options = {}) {
|
||||
return this.intersection(elements, (entries) => {
|
||||
entries.forEach(entry => {
|
||||
const triggerData = {
|
||||
element: entry.element,
|
||||
progress: entry.intersectionRatio,
|
||||
isVisible: entry.isIntersecting,
|
||||
direction: entry.direction,
|
||||
position: entry.position
|
||||
};
|
||||
|
||||
callback(triggerData);
|
||||
});
|
||||
}, {
|
||||
threshold: this.createThresholdArray(options.steps || 10),
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport Detection Helper
|
||||
*/
|
||||
viewport(callback, options = {}) {
|
||||
const viewportElement = document.createElement('div');
|
||||
viewportElement.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
`;
|
||||
document.body.appendChild(viewportElement);
|
||||
|
||||
return this.resize([viewportElement], (entries) => {
|
||||
const viewport = entries[0];
|
||||
callback({
|
||||
width: viewport.dimensions.width,
|
||||
height: viewport.dimensions.height,
|
||||
aspectRatio: viewport.dimensions.aspectRatio,
|
||||
orientation: viewport.dimensions.width > viewport.dimensions.height ? 'landscape' : 'portrait',
|
||||
breakpoint: viewport.breakpoint
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
generateId(type) {
|
||||
return `${type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
disconnect(observerId) {
|
||||
const observer = this.observerInstances.get(observerId);
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
this.observerInstances.delete(observerId);
|
||||
this.activeObservers.delete(observerId);
|
||||
Logger.info(`[ObserverManager] Observer disconnected: ${observerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectAll() {
|
||||
this.observerInstances.forEach((observer, id) => {
|
||||
observer.disconnect();
|
||||
});
|
||||
this.observerInstances.clear();
|
||||
this.activeObservers.clear();
|
||||
Logger.info('[ObserverManager] All observers disconnected');
|
||||
}
|
||||
|
||||
calculateVisibility(entry) {
|
||||
if (!entry.isIntersecting) return 0;
|
||||
|
||||
const visibleArea = entry.intersectionRect.width * entry.intersectionRect.height;
|
||||
const totalArea = entry.boundingClientRect.width * entry.boundingClientRect.height;
|
||||
|
||||
return totalArea > 0 ? Math.round((visibleArea / totalArea) * 100) : 0;
|
||||
}
|
||||
|
||||
getScrollDirection(entry) {
|
||||
// This would need to store previous positions to determine direction
|
||||
// For now, return based on intersection ratio change
|
||||
return entry.intersectionRatio > 0.5 ? 'down' : 'up';
|
||||
}
|
||||
|
||||
getElementPosition(entry) {
|
||||
const rect = entry.boundingClientRect;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
if (rect.top < 0 && rect.bottom > 0) return 'entering-top';
|
||||
if (rect.top < viewportHeight && rect.bottom > viewportHeight) return 'entering-bottom';
|
||||
if (rect.top >= 0 && rect.bottom <= viewportHeight) return 'visible';
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
calculateDeltaSize(entry) {
|
||||
// Would need to store previous sizes to calculate delta
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
detectBreakpoint(width) {
|
||||
if (width < 576) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 992) return 'md';
|
||||
if (width < 1200) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
summarizeMutation(mutation) {
|
||||
return `${mutation.type} on ${mutation.target.tagName}`;
|
||||
}
|
||||
|
||||
assessMutationImpact(mutation) {
|
||||
// Simple impact assessment
|
||||
if (mutation.type === 'childList') {
|
||||
return mutation.addedNodes.length + mutation.removedNodes.length > 5 ? 'high' : 'low';
|
||||
}
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
enhancePerformanceEntry(entry) {
|
||||
const details = { raw: entry };
|
||||
|
||||
switch (entry.entryType) {
|
||||
case 'navigation':
|
||||
details.loadTime = entry.loadEventEnd - entry.navigationStart;
|
||||
details.domContentLoaded = entry.domContentLoadedEventEnd - entry.navigationStart;
|
||||
break;
|
||||
case 'paint':
|
||||
details.paintType = entry.name;
|
||||
break;
|
||||
case 'largest-contentful-paint':
|
||||
details.element = entry.element;
|
||||
details.url = entry.url;
|
||||
break;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
createThresholdArray(steps) {
|
||||
const thresholds = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
thresholds.push(i / steps);
|
||||
}
|
||||
return thresholds;
|
||||
}
|
||||
|
||||
createFallbackObserver(type, elements, callback) {
|
||||
Logger.warn(`[ObserverManager] Creating fallback for ${type}Observer`);
|
||||
|
||||
// Simple polling fallback
|
||||
const fallbackId = this.generateId(`fallback_${type}`);
|
||||
let intervalId;
|
||||
|
||||
switch (type) {
|
||||
case 'intersection':
|
||||
intervalId = setInterval(() => {
|
||||
const elementList = Array.isArray(elements) ? elements : [elements];
|
||||
const entries = elementList.map(el => ({
|
||||
element: el,
|
||||
isIntersecting: this.isElementInViewport(el),
|
||||
intersectionRatio: this.calculateIntersectionRatio(el)
|
||||
}));
|
||||
callback(entries);
|
||||
}, 100);
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
id: fallbackId,
|
||||
disconnect: () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
isElementInViewport(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
);
|
||||
}
|
||||
|
||||
calculateIntersectionRatio(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
const visibleHeight = Math.min(rect.bottom, viewportHeight) - Math.max(rect.top, 0);
|
||||
const visibleWidth = Math.min(rect.right, viewportWidth) - Math.max(rect.left, 0);
|
||||
|
||||
if (visibleHeight <= 0 || visibleWidth <= 0) return 0;
|
||||
|
||||
const visibleArea = visibleHeight * visibleWidth;
|
||||
const totalArea = rect.height * rect.width;
|
||||
|
||||
return totalArea > 0 ? visibleArea / totalArea : 0;
|
||||
}
|
||||
|
||||
getActiveObservers() {
|
||||
return Array.from(this.activeObservers.entries()).map(([id, data]) => ({
|
||||
id,
|
||||
...data
|
||||
}));
|
||||
}
|
||||
}
|
||||
756
resources/js/modules/api-manager/PerformanceManager.js
Normal file
756
resources/js/modules/api-manager/PerformanceManager.js
Normal file
@@ -0,0 +1,756 @@
|
||||
// modules/api-manager/PerformanceManager.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Performance APIs Manager - Timing, Metrics, Observers, Optimization
|
||||
*/
|
||||
export class PerformanceManager {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.marks = new Map();
|
||||
this.measures = new Map();
|
||||
this.observers = new Map();
|
||||
this.metrics = new Map();
|
||||
this.thresholds = {
|
||||
fcp: 2000, // First Contentful Paint
|
||||
lcp: 2500, // Largest Contentful Paint
|
||||
fid: 100, // First Input Delay
|
||||
cls: 0.1, // Cumulative Layout Shift
|
||||
...config.thresholds
|
||||
};
|
||||
|
||||
// Check API support
|
||||
this.support = {
|
||||
performance: 'performance' in window,
|
||||
timing: 'timing' in (window.performance || {}),
|
||||
navigation: 'navigation' in (window.performance || {}),
|
||||
observer: 'PerformanceObserver' in window,
|
||||
memory: 'memory' in (window.performance || {}),
|
||||
userTiming: 'mark' in (window.performance || {}),
|
||||
resourceTiming: 'getEntriesByType' in (window.performance || {}),
|
||||
paintTiming: 'PerformanceObserver' in window && PerformanceObserver.supportedEntryTypes?.includes('paint'),
|
||||
layoutInstability: 'PerformanceObserver' in window && PerformanceObserver.supportedEntryTypes?.includes('layout-shift'),
|
||||
longTask: 'PerformanceObserver' in window && PerformanceObserver.supportedEntryTypes?.includes('longtask')
|
||||
};
|
||||
|
||||
Logger.info('[PerformanceManager] Initialized with support:', this.support);
|
||||
|
||||
// Auto-start core metrics collection
|
||||
this.startCoreMetrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* User Timing API - Marks and Measures
|
||||
*/
|
||||
timing = {
|
||||
// Create performance mark
|
||||
mark: (name, options = {}) => {
|
||||
if (!this.support.userTiming) {
|
||||
Logger.warn('[PerformanceManager] User Timing not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
performance.mark(name, options);
|
||||
this.marks.set(name, {
|
||||
name,
|
||||
timestamp: performance.now(),
|
||||
options
|
||||
});
|
||||
|
||||
Logger.info(`[PerformanceManager] Mark created: ${name}`);
|
||||
return this.marks.get(name);
|
||||
} catch (error) {
|
||||
Logger.error('[PerformanceManager] Mark creation failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Create performance measure
|
||||
measure: (name, startMark, endMark, options = {}) => {
|
||||
if (!this.support.userTiming) {
|
||||
Logger.warn('[PerformanceManager] User Timing not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
performance.measure(name, startMark, endMark, options);
|
||||
const entry = performance.getEntriesByName(name, 'measure')[0];
|
||||
|
||||
const measure = {
|
||||
name,
|
||||
startTime: entry.startTime,
|
||||
duration: entry.duration,
|
||||
startMark,
|
||||
endMark,
|
||||
options
|
||||
};
|
||||
|
||||
this.measures.set(name, measure);
|
||||
Logger.info(`[PerformanceManager] Measure created: ${name} (${measure.duration.toFixed(2)}ms)`);
|
||||
|
||||
return measure;
|
||||
} catch (error) {
|
||||
Logger.error('[PerformanceManager] Measure creation failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear marks and measures
|
||||
clear: (name = null) => {
|
||||
if (!this.support.userTiming) return;
|
||||
|
||||
try {
|
||||
if (name) {
|
||||
performance.clearMarks(name);
|
||||
performance.clearMeasures(name);
|
||||
this.marks.delete(name);
|
||||
this.measures.delete(name);
|
||||
} else {
|
||||
performance.clearMarks();
|
||||
performance.clearMeasures();
|
||||
this.marks.clear();
|
||||
this.measures.clear();
|
||||
}
|
||||
|
||||
Logger.info(`[PerformanceManager] Cleared: ${name || 'all'}`);
|
||||
} catch (error) {
|
||||
Logger.error('[PerformanceManager] Clear failed:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Get all marks
|
||||
getMarks: () => {
|
||||
if (!this.support.userTiming) return [];
|
||||
return Array.from(this.marks.values());
|
||||
},
|
||||
|
||||
// Get all measures
|
||||
getMeasures: () => {
|
||||
if (!this.support.userTiming) return [];
|
||||
return Array.from(this.measures.values());
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigation Timing API
|
||||
*/
|
||||
navigation = {
|
||||
// Get navigation timing data
|
||||
get: () => {
|
||||
if (!this.support.navigation) {
|
||||
return this.getLegacyNavigationTiming();
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = performance.getEntriesByType('navigation')[0];
|
||||
if (!entry) return null;
|
||||
|
||||
return {
|
||||
// Navigation phases
|
||||
redirect: entry.redirectEnd - entry.redirectStart,
|
||||
dns: entry.domainLookupEnd - entry.domainLookupStart,
|
||||
connect: entry.connectEnd - entry.connectStart,
|
||||
ssl: entry.connectEnd - entry.secureConnectionStart,
|
||||
ttfb: entry.responseStart - entry.requestStart, // Time to First Byte
|
||||
download: entry.responseEnd - entry.responseStart,
|
||||
domProcessing: entry.domContentLoadedEventStart - entry.responseEnd,
|
||||
domComplete: entry.domComplete - entry.domContentLoadedEventStart,
|
||||
loadComplete: entry.loadEventEnd - entry.loadEventStart,
|
||||
|
||||
// Total times
|
||||
totalTime: entry.loadEventEnd - entry.startTime,
|
||||
|
||||
// Enhanced metrics
|
||||
navigationStart: entry.startTime,
|
||||
unloadTime: entry.unloadEventEnd - entry.unloadEventStart,
|
||||
redirectCount: entry.redirectCount,
|
||||
transferSize: entry.transferSize,
|
||||
encodedBodySize: entry.encodedBodySize,
|
||||
decodedBodySize: entry.decodedBodySize,
|
||||
|
||||
// Connection info
|
||||
connectionInfo: {
|
||||
nextHopProtocol: entry.nextHopProtocol,
|
||||
renderBlockingStatus: entry.renderBlockingStatus
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error('[PerformanceManager] Navigation timing failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Get performance insights
|
||||
getInsights: () => {
|
||||
const timing = this.navigation.get();
|
||||
if (!timing) return null;
|
||||
|
||||
return {
|
||||
insights: {
|
||||
serverResponseTime: this.getInsight('ttfb', timing.ttfb, 200, 500),
|
||||
domProcessing: this.getInsight('domProcessing', timing.domProcessing, 500, 1000),
|
||||
totalLoadTime: this.getInsight('totalTime', timing.totalTime, 2000, 4000),
|
||||
transferEfficiency: this.getTransferEfficiency(timing)
|
||||
},
|
||||
recommendations: this.getNavigationRecommendations(timing)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resource Timing API
|
||||
*/
|
||||
resources = {
|
||||
// Get resource timing data
|
||||
get: (type = null) => {
|
||||
if (!this.support.resourceTiming) {
|
||||
Logger.warn('[PerformanceManager] Resource Timing not supported');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = performance.getEntriesByType('resource');
|
||||
const resources = entries.map(entry => ({
|
||||
name: entry.name,
|
||||
type: this.getResourceType(entry),
|
||||
startTime: entry.startTime,
|
||||
duration: entry.duration,
|
||||
size: {
|
||||
transfer: entry.transferSize,
|
||||
encoded: entry.encodedBodySize,
|
||||
decoded: entry.decodedBodySize
|
||||
},
|
||||
timing: {
|
||||
redirect: entry.redirectEnd - entry.redirectStart,
|
||||
dns: entry.domainLookupEnd - entry.domainLookupStart,
|
||||
connect: entry.connectEnd - entry.connectStart,
|
||||
ssl: entry.connectEnd - entry.secureConnectionStart,
|
||||
ttfb: entry.responseStart - entry.requestStart,
|
||||
download: entry.responseEnd - entry.responseStart
|
||||
},
|
||||
protocol: entry.nextHopProtocol,
|
||||
cached: entry.transferSize === 0 && entry.decodedBodySize > 0
|
||||
}));
|
||||
|
||||
return type ? resources.filter(r => r.type === type) : resources;
|
||||
} catch (error) {
|
||||
Logger.error('[PerformanceManager] Resource timing failed:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// Get resource performance summary
|
||||
getSummary: () => {
|
||||
const resources = this.resources.get();
|
||||
|
||||
const summary = {
|
||||
total: resources.length,
|
||||
types: {},
|
||||
totalSize: 0,
|
||||
totalDuration: 0,
|
||||
cached: 0,
|
||||
slowResources: []
|
||||
};
|
||||
|
||||
resources.forEach(resource => {
|
||||
const type = resource.type;
|
||||
if (!summary.types[type]) {
|
||||
summary.types[type] = { count: 0, size: 0, duration: 0 };
|
||||
}
|
||||
|
||||
summary.types[type].count++;
|
||||
summary.types[type].size += resource.size.transfer;
|
||||
summary.types[type].duration += resource.duration;
|
||||
|
||||
summary.totalSize += resource.size.transfer;
|
||||
summary.totalDuration += resource.duration;
|
||||
|
||||
if (resource.cached) summary.cached++;
|
||||
if (resource.duration > 1000) summary.slowResources.push(resource);
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Core Web Vitals monitoring
|
||||
*/
|
||||
vitals = {
|
||||
// Start monitoring Core Web Vitals
|
||||
start: (callback = null) => {
|
||||
const vitalsData = {};
|
||||
|
||||
// First Contentful Paint
|
||||
this.observePaint((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.name === 'first-contentful-paint') {
|
||||
vitalsData.fcp = entry.startTime;
|
||||
this.checkThreshold('fcp', entry.startTime);
|
||||
if (callback) callback('fcp', entry.startTime);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Largest Contentful Paint
|
||||
this.observeLCP((entries) => {
|
||||
entries.forEach(entry => {
|
||||
vitalsData.lcp = entry.startTime;
|
||||
this.checkThreshold('lcp', entry.startTime);
|
||||
if (callback) callback('lcp', entry.startTime);
|
||||
});
|
||||
});
|
||||
|
||||
// First Input Delay
|
||||
this.observeFID((entries) => {
|
||||
entries.forEach(entry => {
|
||||
vitalsData.fid = entry.processingStart - entry.startTime;
|
||||
this.checkThreshold('fid', vitalsData.fid);
|
||||
if (callback) callback('fid', vitalsData.fid);
|
||||
});
|
||||
});
|
||||
|
||||
// Cumulative Layout Shift
|
||||
this.observeCLS((entries) => {
|
||||
let cls = 0;
|
||||
entries.forEach(entry => {
|
||||
if (!entry.hadRecentInput) {
|
||||
cls += entry.value;
|
||||
}
|
||||
});
|
||||
vitalsData.cls = cls;
|
||||
this.checkThreshold('cls', cls);
|
||||
if (callback) callback('cls', cls);
|
||||
});
|
||||
|
||||
return vitalsData;
|
||||
},
|
||||
|
||||
// Get current vitals
|
||||
get: () => {
|
||||
return {
|
||||
fcp: this.getMetric('fcp'),
|
||||
lcp: this.getMetric('lcp'),
|
||||
fid: this.getMetric('fid'),
|
||||
cls: this.getMetric('cls'),
|
||||
ratings: {
|
||||
fcp: this.getRating('fcp', this.getMetric('fcp')),
|
||||
lcp: this.getRating('lcp', this.getMetric('lcp')),
|
||||
fid: this.getRating('fid', this.getMetric('fid')),
|
||||
cls: this.getRating('cls', this.getMetric('cls'))
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Memory monitoring
|
||||
*/
|
||||
memory = {
|
||||
// Get memory usage
|
||||
get: () => {
|
||||
if (!this.support.memory) {
|
||||
Logger.warn('[PerformanceManager] Memory API not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const memory = performance.memory;
|
||||
return {
|
||||
used: memory.usedJSHeapSize,
|
||||
total: memory.totalJSHeapSize,
|
||||
limit: memory.jsHeapSizeLimit,
|
||||
percentage: (memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100,
|
||||
formatted: {
|
||||
used: this.formatBytes(memory.usedJSHeapSize),
|
||||
total: this.formatBytes(memory.totalJSHeapSize),
|
||||
limit: this.formatBytes(memory.jsHeapSizeLimit)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error('[PerformanceManager] Memory get failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Monitor memory usage
|
||||
monitor: (callback, interval = 5000) => {
|
||||
if (!this.support.memory) return null;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
const memoryData = this.memory.get();
|
||||
if (memoryData) {
|
||||
callback(memoryData);
|
||||
|
||||
// Warn if memory usage is high
|
||||
if (memoryData.percentage > 80) {
|
||||
Logger.warn('[PerformanceManager] High memory usage detected:', memoryData.percentage.toFixed(1) + '%');
|
||||
}
|
||||
}
|
||||
}, interval);
|
||||
|
||||
return {
|
||||
stop: () => clearInterval(intervalId)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Long Task monitoring
|
||||
*/
|
||||
longTasks = {
|
||||
// Start monitoring long tasks
|
||||
start: (callback = null) => {
|
||||
if (!this.support.longTask) {
|
||||
Logger.warn('[PerformanceManager] Long Task API not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
list.getEntries().forEach(entry => {
|
||||
const taskInfo = {
|
||||
duration: entry.duration,
|
||||
startTime: entry.startTime,
|
||||
name: entry.name,
|
||||
attribution: entry.attribution || []
|
||||
};
|
||||
|
||||
Logger.warn('[PerformanceManager] Long task detected:', taskInfo);
|
||||
if (callback) callback(taskInfo);
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['longtask'] });
|
||||
|
||||
const observerId = this.generateId('longtask');
|
||||
this.observers.set(observerId, observer);
|
||||
|
||||
return {
|
||||
id: observerId,
|
||||
stop: () => {
|
||||
observer.disconnect();
|
||||
this.observers.delete(observerId);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error('[PerformanceManager] Long task monitoring failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Performance optimization utilities
|
||||
*/
|
||||
optimize = {
|
||||
// Defer non-critical JavaScript
|
||||
deferScript: (src, callback = null) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.defer = true;
|
||||
if (callback) script.onload = callback;
|
||||
document.head.appendChild(script);
|
||||
return script;
|
||||
},
|
||||
|
||||
// Preload critical resources
|
||||
preload: (href, as, crossorigin = false) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'preload';
|
||||
link.href = href;
|
||||
link.as = as;
|
||||
if (crossorigin) link.crossOrigin = 'anonymous';
|
||||
document.head.appendChild(link);
|
||||
return link;
|
||||
},
|
||||
|
||||
// Prefetch future resources
|
||||
prefetch: (href) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
return link;
|
||||
},
|
||||
|
||||
// Optimize images with lazy loading
|
||||
lazyImages: (selector = 'img[data-src]') => {
|
||||
if ('IntersectionObserver' in window) {
|
||||
const images = document.querySelectorAll(selector);
|
||||
const imageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
return imageObserver;
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const images = document.querySelectorAll(selector);
|
||||
images.forEach(img => {
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Bundle size analyzer
|
||||
analyzeBundles: () => {
|
||||
const scripts = Array.from(document.querySelectorAll('script[src]'));
|
||||
const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
|
||||
|
||||
const analysis = {
|
||||
scripts: scripts.map(script => ({
|
||||
src: script.src,
|
||||
async: script.async,
|
||||
defer: script.defer
|
||||
})),
|
||||
styles: styles.map(style => ({
|
||||
href: style.href,
|
||||
media: style.media
|
||||
})),
|
||||
recommendations: []
|
||||
};
|
||||
|
||||
// Add recommendations
|
||||
if (scripts.length > 10) {
|
||||
analysis.recommendations.push('Consider bundling JavaScript files');
|
||||
}
|
||||
if (styles.length > 5) {
|
||||
analysis.recommendations.push('Consider bundling CSS files');
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper methods
|
||||
|
||||
startCoreMetrics() {
|
||||
// Auto-start vitals monitoring
|
||||
this.vitals.start((metric, value) => {
|
||||
this.setMetric(metric, value);
|
||||
});
|
||||
|
||||
// Start memory monitoring if supported
|
||||
if (this.support.memory) {
|
||||
this.memory.monitor((memoryData) => {
|
||||
this.setMetric('memory', memoryData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
observePaint(callback) {
|
||||
if (!this.support.paintTiming) return;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
callback(list.getEntries());
|
||||
});
|
||||
observer.observe({ entryTypes: ['paint'] });
|
||||
return observer;
|
||||
} catch (error) {
|
||||
Logger.error('[PerformanceManager] Paint observer failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
observeLCP(callback) {
|
||||
if (!this.support.observer) return;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
callback(list.getEntries());
|
||||
});
|
||||
observer.observe({ entryTypes: ['largest-contentful-paint'] });
|
||||
return observer;
|
||||
} catch (error) {
|
||||
Logger.error('[PerformanceManager] LCP observer failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
observeFID(callback) {
|
||||
if (!this.support.observer) return;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
callback(list.getEntries());
|
||||
});
|
||||
observer.observe({ entryTypes: ['first-input'] });
|
||||
return observer;
|
||||
} catch (error) {
|
||||
Logger.error('[PerformanceManager] FID observer failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
observeCLS(callback) {
|
||||
if (!this.support.layoutInstability) return;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
callback(list.getEntries());
|
||||
});
|
||||
observer.observe({ entryTypes: ['layout-shift'] });
|
||||
return observer;
|
||||
} catch (error) {
|
||||
Logger.error('[PerformanceManager] CLS observer failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
getLegacyNavigationTiming() {
|
||||
if (!this.support.timing) return null;
|
||||
|
||||
const timing = performance.timing;
|
||||
const navigationStart = timing.navigationStart;
|
||||
|
||||
return {
|
||||
redirect: timing.redirectEnd - timing.redirectStart,
|
||||
dns: timing.domainLookupEnd - timing.domainLookupStart,
|
||||
connect: timing.connectEnd - timing.connectStart,
|
||||
ssl: timing.connectEnd - timing.secureConnectionStart,
|
||||
ttfb: timing.responseStart - timing.requestStart,
|
||||
download: timing.responseEnd - timing.responseStart,
|
||||
domProcessing: timing.domContentLoadedEventStart - timing.responseEnd,
|
||||
domComplete: timing.domComplete - timing.domContentLoadedEventStart,
|
||||
loadComplete: timing.loadEventEnd - timing.loadEventStart,
|
||||
totalTime: timing.loadEventEnd - navigationStart
|
||||
};
|
||||
}
|
||||
|
||||
getResourceType(entry) {
|
||||
const url = new URL(entry.name);
|
||||
const extension = url.pathname.split('.').pop().toLowerCase();
|
||||
|
||||
const typeMap = {
|
||||
js: 'script',
|
||||
css: 'stylesheet',
|
||||
png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', svg: 'image', webp: 'image',
|
||||
woff: 'font', woff2: 'font', ttf: 'font', eot: 'font',
|
||||
json: 'fetch', xml: 'fetch'
|
||||
};
|
||||
|
||||
return typeMap[extension] || entry.initiatorType || 'other';
|
||||
}
|
||||
|
||||
getInsight(metric, value, good, needs) {
|
||||
if (value < good) return { rating: 'good', message: `Excellent ${metric}` };
|
||||
if (value < needs) return { rating: 'needs-improvement', message: `${metric} needs improvement` };
|
||||
return { rating: 'poor', message: `Poor ${metric} performance` };
|
||||
}
|
||||
|
||||
getTransferEfficiency(timing) {
|
||||
const compressionRatio = timing.decodedBodySize > 0 ?
|
||||
timing.encodedBodySize / timing.decodedBodySize : 1;
|
||||
|
||||
return {
|
||||
ratio: compressionRatio,
|
||||
rating: compressionRatio < 0.7 ? 'good' : compressionRatio < 0.9 ? 'fair' : 'poor'
|
||||
};
|
||||
}
|
||||
|
||||
getNavigationRecommendations(timing) {
|
||||
const recommendations = [];
|
||||
|
||||
if (timing.ttfb > 500) {
|
||||
recommendations.push('Server response time is slow. Consider optimizing backend performance.');
|
||||
}
|
||||
if (timing.dns > 100) {
|
||||
recommendations.push('DNS lookup time is high. Consider using a faster DNS provider.');
|
||||
}
|
||||
if (timing.connect > 1000) {
|
||||
recommendations.push('Connection time is slow. Check network latency.');
|
||||
}
|
||||
if (timing.domProcessing > 1000) {
|
||||
recommendations.push('DOM processing is slow. Consider optimizing JavaScript execution.');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
checkThreshold(metric, value) {
|
||||
const threshold = this.thresholds[metric];
|
||||
if (threshold && value > threshold) {
|
||||
Logger.warn(`[PerformanceManager] ${metric.toUpperCase()} threshold exceeded: ${value}ms (threshold: ${threshold}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
getRating(metric, value) {
|
||||
const thresholds = {
|
||||
fcp: { good: 1800, poor: 3000 },
|
||||
lcp: { good: 2500, poor: 4000 },
|
||||
fid: { good: 100, poor: 300 },
|
||||
cls: { good: 0.1, poor: 0.25 }
|
||||
};
|
||||
|
||||
const threshold = thresholds[metric];
|
||||
if (!threshold || value === null || value === undefined) return 'unknown';
|
||||
|
||||
if (value <= threshold.good) return 'good';
|
||||
if (value <= threshold.poor) return 'needs-improvement';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
setMetric(key, value) {
|
||||
this.metrics.set(key, {
|
||||
value,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
getMetric(key) {
|
||||
const metric = this.metrics.get(key);
|
||||
return metric ? metric.value : null;
|
||||
}
|
||||
|
||||
formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
generateId(prefix = 'perf') {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all observers and clear data
|
||||
*/
|
||||
cleanup() {
|
||||
this.observers.forEach(observer => {
|
||||
observer.disconnect();
|
||||
});
|
||||
this.observers.clear();
|
||||
this.metrics.clear();
|
||||
this.marks.clear();
|
||||
this.measures.clear();
|
||||
Logger.info('[PerformanceManager] Cleanup completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive performance report
|
||||
*/
|
||||
getReport() {
|
||||
return {
|
||||
support: this.support,
|
||||
navigation: this.navigation.get(),
|
||||
resources: this.resources.getSummary(),
|
||||
vitals: this.vitals.get(),
|
||||
memory: this.memory.get(),
|
||||
marks: this.timing.getMarks(),
|
||||
measures: this.timing.getMeasures(),
|
||||
activeObservers: this.observers.size,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
}
|
||||
687
resources/js/modules/api-manager/PermissionManager.js
Normal file
687
resources/js/modules/api-manager/PermissionManager.js
Normal file
@@ -0,0 +1,687 @@
|
||||
// modules/api-manager/PermissionManager.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Permission Management System for Web APIs
|
||||
*/
|
||||
export class PermissionManager {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.permissionCache = new Map();
|
||||
this.permissionWatchers = new Map();
|
||||
this.requestQueue = new Map();
|
||||
|
||||
// Check Permissions API support
|
||||
this.support = {
|
||||
permissions: 'permissions' in navigator,
|
||||
query: navigator.permissions?.query !== undefined,
|
||||
request: 'requestPermission' in Notification || 'getUserMedia' in (navigator.mediaDevices || {}),
|
||||
geolocation: 'geolocation' in navigator,
|
||||
notifications: 'Notification' in window,
|
||||
camera: navigator.mediaDevices !== undefined,
|
||||
microphone: navigator.mediaDevices !== undefined,
|
||||
clipboard: 'clipboard' in navigator,
|
||||
vibration: 'vibrate' in navigator
|
||||
};
|
||||
|
||||
// Permission mappings for different APIs
|
||||
this.permissionMap = {
|
||||
camera: { name: 'camera', api: 'mediaDevices' },
|
||||
microphone: { name: 'microphone', api: 'mediaDevices' },
|
||||
geolocation: { name: 'geolocation', api: 'geolocation' },
|
||||
notifications: { name: 'notifications', api: 'notification' },
|
||||
'clipboard-read': { name: 'clipboard-read', api: 'clipboard' },
|
||||
'clipboard-write': { name: 'clipboard-write', api: 'clipboard' },
|
||||
'background-sync': { name: 'background-sync', api: 'serviceWorker' },
|
||||
'persistent-storage': { name: 'persistent-storage', api: 'storage' },
|
||||
'push': { name: 'push', api: 'serviceWorker' },
|
||||
'midi': { name: 'midi', api: 'midi' },
|
||||
'payment-handler': { name: 'payment-handler', api: 'payment' }
|
||||
};
|
||||
|
||||
Logger.info('[PermissionManager] Initialized with support:', this.support);
|
||||
|
||||
// Auto-cache current permissions
|
||||
this.cacheCurrentPermissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check permission status for a specific permission
|
||||
*/
|
||||
async check(permission) {
|
||||
if (!this.support.permissions || !this.support.query) {
|
||||
Logger.warn('[PermissionManager] Permissions API not supported, using fallback');
|
||||
return this.checkFallback(permission);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
const cached = this.permissionCache.get(permission);
|
||||
if (cached && Date.now() - cached.timestamp < 30000) { // 30s cache
|
||||
return cached.status;
|
||||
}
|
||||
|
||||
const permissionDescriptor = this.getPermissionDescriptor(permission);
|
||||
const status = await navigator.permissions.query(permissionDescriptor);
|
||||
|
||||
const result = {
|
||||
name: permission,
|
||||
state: status.state,
|
||||
timestamp: Date.now(),
|
||||
supported: true
|
||||
};
|
||||
|
||||
this.permissionCache.set(permission, result);
|
||||
|
||||
// Watch for changes
|
||||
status.addEventListener('change', () => {
|
||||
this.onPermissionChange(permission, status.state);
|
||||
});
|
||||
|
||||
Logger.info(`[PermissionManager] Permission checked: ${permission} = ${status.state}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
Logger.warn(`[PermissionManager] Permission check failed for ${permission}:`, error.message);
|
||||
return this.checkFallback(permission);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permission for a specific API
|
||||
*/
|
||||
async request(permission, options = {}) {
|
||||
const {
|
||||
showRationale = true,
|
||||
fallbackMessage = null,
|
||||
timeout = 30000
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// Check current status first
|
||||
const currentStatus = await this.check(permission);
|
||||
if (currentStatus.state === 'granted') {
|
||||
return { granted: true, state: 'granted', fromCache: true };
|
||||
}
|
||||
|
||||
if (currentStatus.state === 'denied') {
|
||||
if (showRationale) {
|
||||
await this.showPermissionRationale(permission, fallbackMessage);
|
||||
}
|
||||
return { granted: false, state: 'denied', reason: 'previously-denied' };
|
||||
}
|
||||
|
||||
// Add to request queue to prevent multiple simultaneous requests
|
||||
const queueKey = permission;
|
||||
if (this.requestQueue.has(queueKey)) {
|
||||
Logger.info(`[PermissionManager] Permission request already in progress: ${permission}`);
|
||||
return this.requestQueue.get(queueKey);
|
||||
}
|
||||
|
||||
const requestPromise = this.executePermissionRequest(permission, timeout);
|
||||
this.requestQueue.set(queueKey, requestPromise);
|
||||
|
||||
const result = await requestPromise;
|
||||
this.requestQueue.delete(queueKey);
|
||||
|
||||
// Update cache
|
||||
this.permissionCache.set(permission, {
|
||||
name: permission,
|
||||
state: result.state,
|
||||
timestamp: Date.now(),
|
||||
supported: true
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
Logger.error(`[PermissionManager] Permission request failed: ${permission}`, error);
|
||||
this.requestQueue.delete(permission);
|
||||
return { granted: false, state: 'error', error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request multiple permissions at once
|
||||
*/
|
||||
async requestMultiple(permissions, options = {}) {
|
||||
const results = {};
|
||||
|
||||
if (options.sequential) {
|
||||
// Request permissions one by one
|
||||
for (const permission of permissions) {
|
||||
results[permission] = await this.request(permission, options);
|
||||
|
||||
// Stop if any critical permission is denied
|
||||
if (options.requireAll && !results[permission].granted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Request permissions in parallel
|
||||
const requests = permissions.map(permission =>
|
||||
this.request(permission, options).then(result => [permission, result])
|
||||
);
|
||||
|
||||
const responses = await Promise.allSettled(requests);
|
||||
responses.forEach(response => {
|
||||
if (response.status === 'fulfilled') {
|
||||
const [permission, result] = response.value;
|
||||
results[permission] = result;
|
||||
} else {
|
||||
Logger.error('[PermissionManager] Batch permission request failed:', response.reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const summary = {
|
||||
results,
|
||||
granted: Object.values(results).filter(r => r.granted).length,
|
||||
denied: Object.values(results).filter(r => !r.granted).length,
|
||||
total: Object.keys(results).length
|
||||
};
|
||||
|
||||
Logger.info(`[PermissionManager] Batch permissions: ${summary.granted}/${summary.total} granted`);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all required permissions are granted
|
||||
*/
|
||||
async checkRequired(requiredPermissions) {
|
||||
const statuses = {};
|
||||
const missing = [];
|
||||
|
||||
for (const permission of requiredPermissions) {
|
||||
const status = await this.check(permission);
|
||||
statuses[permission] = status;
|
||||
|
||||
if (status.state !== 'granted') {
|
||||
missing.push(permission);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allGranted: missing.length === 0,
|
||||
granted: Object.keys(statuses).filter(p => statuses[p].state === 'granted'),
|
||||
missing,
|
||||
statuses
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch permission changes
|
||||
*/
|
||||
watch(permission, callback) {
|
||||
const watcherId = this.generateId('watcher');
|
||||
|
||||
this.permissionWatchers.set(watcherId, {
|
||||
permission,
|
||||
callback,
|
||||
active: true
|
||||
});
|
||||
|
||||
// Set up the watcher
|
||||
this.setupPermissionWatcher(permission, callback);
|
||||
|
||||
Logger.info(`[PermissionManager] Permission watcher created: ${permission}`);
|
||||
|
||||
return {
|
||||
id: watcherId,
|
||||
stop: () => {
|
||||
const watcher = this.permissionWatchers.get(watcherId);
|
||||
if (watcher) {
|
||||
watcher.active = false;
|
||||
this.permissionWatchers.delete(watcherId);
|
||||
Logger.info(`[PermissionManager] Permission watcher stopped: ${permission}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permission recommendations based on app features
|
||||
*/
|
||||
getRecommendations(features = []) {
|
||||
const recommendations = {
|
||||
essential: [],
|
||||
recommended: [],
|
||||
optional: []
|
||||
};
|
||||
|
||||
const featurePermissionMap = {
|
||||
camera: { permissions: ['camera'], priority: 'essential' },
|
||||
microphone: { permissions: ['microphone'], priority: 'essential' },
|
||||
location: { permissions: ['geolocation'], priority: 'recommended' },
|
||||
notifications: { permissions: ['notifications'], priority: 'recommended' },
|
||||
clipboard: { permissions: ['clipboard-read', 'clipboard-write'], priority: 'optional' },
|
||||
offline: { permissions: ['persistent-storage'], priority: 'recommended' },
|
||||
background: { permissions: ['background-sync', 'push'], priority: 'optional' }
|
||||
};
|
||||
|
||||
features.forEach(feature => {
|
||||
const mapping = featurePermissionMap[feature];
|
||||
if (mapping) {
|
||||
recommendations[mapping.priority].push(...mapping.permissions);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove duplicates
|
||||
Object.keys(recommendations).forEach(key => {
|
||||
recommendations[key] = [...new Set(recommendations[key])];
|
||||
});
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create permission onboarding flow
|
||||
*/
|
||||
createOnboardingFlow(permissions, options = {}) {
|
||||
const {
|
||||
title = 'Permissions Required',
|
||||
descriptions = {},
|
||||
onComplete = null,
|
||||
onSkip = null
|
||||
} = options;
|
||||
|
||||
return {
|
||||
permissions,
|
||||
title,
|
||||
descriptions,
|
||||
|
||||
async start() {
|
||||
Logger.info('[PermissionManager] Starting onboarding flow');
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const permission of permissions) {
|
||||
const description = descriptions[permission] || this.getDefaultDescription(permission);
|
||||
|
||||
// Show permission explanation
|
||||
const userChoice = await this.showPermissionDialog(permission, description);
|
||||
|
||||
if (userChoice === 'grant') {
|
||||
const result = await this.request(permission);
|
||||
results.push({ permission, ...result });
|
||||
} else if (userChoice === 'skip') {
|
||||
results.push({ permission, granted: false, skipped: true });
|
||||
} else {
|
||||
// User cancelled the entire flow
|
||||
if (onSkip) onSkip(results);
|
||||
return { cancelled: true, results };
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
completed: true,
|
||||
granted: results.filter(r => r.granted).length,
|
||||
total: results.length,
|
||||
results
|
||||
};
|
||||
|
||||
if (onComplete) onComplete(summary);
|
||||
return summary;
|
||||
},
|
||||
|
||||
getDefaultDescription(permission) {
|
||||
const descriptions = {
|
||||
camera: 'Take photos and record videos for enhanced functionality',
|
||||
microphone: 'Record audio for voice features and communication',
|
||||
geolocation: 'Provide location-based services and content',
|
||||
notifications: 'Send you important updates and reminders',
|
||||
'clipboard-read': 'Access clipboard content for convenience features',
|
||||
'clipboard-write': 'Copy content to clipboard for easy sharing'
|
||||
};
|
||||
|
||||
return descriptions[permission] || `Allow access to ${permission} functionality`;
|
||||
},
|
||||
|
||||
async showPermissionDialog(permission, description) {
|
||||
return new Promise(resolve => {
|
||||
// Create modal dialog
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'permission-dialog-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="permission-dialog-backdrop" style="
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
">
|
||||
<div class="permission-dialog" style="
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
max-width: 400px;
|
||||
margin: 1rem;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
">
|
||||
<h3 style="margin: 0 0 1rem 0; color: #333;">
|
||||
${this.getPermissionIcon(permission)} ${this.getPermissionTitle(permission)}
|
||||
</h3>
|
||||
<p style="margin: 0 0 2rem 0; color: #666; line-height: 1.5;">
|
||||
${description}
|
||||
</p>
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
||||
<button class="btn-skip" style="
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
">Skip</button>
|
||||
<button class="btn-grant" style="
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
">Allow</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const skipBtn = modal.querySelector('.btn-skip');
|
||||
const grantBtn = modal.querySelector('.btn-grant');
|
||||
|
||||
const cleanup = () => document.body.removeChild(modal);
|
||||
|
||||
skipBtn.onclick = () => {
|
||||
cleanup();
|
||||
resolve('skip');
|
||||
};
|
||||
|
||||
grantBtn.onclick = () => {
|
||||
cleanup();
|
||||
resolve('grant');
|
||||
};
|
||||
|
||||
// Close on backdrop click
|
||||
modal.querySelector('.permission-dialog-backdrop').onclick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
cleanup();
|
||||
resolve('cancel');
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
getPermissionIcon(permission) {
|
||||
const icons = {
|
||||
camera: '📷',
|
||||
microphone: '🎤',
|
||||
geolocation: '📍',
|
||||
notifications: '🔔',
|
||||
'clipboard-read': '📋',
|
||||
'clipboard-write': '📋'
|
||||
};
|
||||
return icons[permission] || '🔐';
|
||||
},
|
||||
|
||||
getPermissionTitle(permission) {
|
||||
const titles = {
|
||||
camera: 'Camera Access',
|
||||
microphone: 'Microphone Access',
|
||||
geolocation: 'Location Access',
|
||||
notifications: 'Notifications',
|
||||
'clipboard-read': 'Clipboard Access',
|
||||
'clipboard-write': 'Clipboard Access'
|
||||
};
|
||||
return titles[permission] || `${permission} Permission`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
async cacheCurrentPermissions() {
|
||||
if (!this.support.permissions) return;
|
||||
|
||||
const commonPermissions = ['camera', 'microphone', 'geolocation', 'notifications'];
|
||||
|
||||
for (const permission of commonPermissions) {
|
||||
try {
|
||||
await this.check(permission);
|
||||
} catch (error) {
|
||||
// Ignore errors during initial caching
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getPermissionDescriptor(permission) {
|
||||
// Handle different permission descriptor formats
|
||||
switch (permission) {
|
||||
case 'camera':
|
||||
return { name: 'camera' };
|
||||
case 'microphone':
|
||||
return { name: 'microphone' };
|
||||
case 'geolocation':
|
||||
return { name: 'geolocation' };
|
||||
case 'notifications':
|
||||
return { name: 'notifications' };
|
||||
case 'clipboard-read':
|
||||
return { name: 'clipboard-read' };
|
||||
case 'clipboard-write':
|
||||
return { name: 'clipboard-write' };
|
||||
case 'persistent-storage':
|
||||
return { name: 'persistent-storage' };
|
||||
case 'background-sync':
|
||||
return { name: 'background-sync' };
|
||||
case 'push':
|
||||
return { name: 'push', userVisibleOnly: true };
|
||||
default:
|
||||
return { name: permission };
|
||||
}
|
||||
}
|
||||
|
||||
async checkFallback(permission) {
|
||||
// Fallback checks for browsers without Permissions API
|
||||
const fallbacks = {
|
||||
camera: () => navigator.mediaDevices !== undefined,
|
||||
microphone: () => navigator.mediaDevices !== undefined,
|
||||
geolocation: () => 'geolocation' in navigator,
|
||||
notifications: () => 'Notification' in window,
|
||||
'clipboard-read': () => 'clipboard' in navigator,
|
||||
'clipboard-write': () => 'clipboard' in navigator
|
||||
};
|
||||
|
||||
const check = fallbacks[permission];
|
||||
const supported = check ? check() : false;
|
||||
|
||||
return {
|
||||
name: permission,
|
||||
state: supported ? 'prompt' : 'unsupported',
|
||||
timestamp: Date.now(),
|
||||
supported,
|
||||
fallback: true
|
||||
};
|
||||
}
|
||||
|
||||
async executePermissionRequest(permission, timeout) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error('Permission request timeout'));
|
||||
}, timeout);
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (permission) {
|
||||
case 'notifications':
|
||||
if ('Notification' in window) {
|
||||
const notificationPermission = await Notification.requestPermission();
|
||||
result = { granted: notificationPermission === 'granted', state: notificationPermission };
|
||||
} else {
|
||||
result = { granted: false, state: 'unsupported' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'camera':
|
||||
case 'microphone':
|
||||
try {
|
||||
const constraints = {};
|
||||
constraints[permission === 'camera' ? 'video' : 'audio'] = true;
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
stream.getTracks().forEach(track => track.stop()); // Stop immediately
|
||||
|
||||
result = { granted: true, state: 'granted' };
|
||||
} catch (error) {
|
||||
result = {
|
||||
granted: false,
|
||||
state: error.name === 'NotAllowedError' ? 'denied' : 'error',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'geolocation':
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
timeout: 10000,
|
||||
maximumAge: 0
|
||||
});
|
||||
});
|
||||
result = { granted: true, state: 'granted' };
|
||||
} catch (error) {
|
||||
result = {
|
||||
granted: false,
|
||||
state: error.code === 1 ? 'denied' : 'error',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Try generic permission request
|
||||
if (this.support.permissions) {
|
||||
const status = await navigator.permissions.query(this.getPermissionDescriptor(permission));
|
||||
result = { granted: status.state === 'granted', state: status.state };
|
||||
} else {
|
||||
result = { granted: false, state: 'unsupported' };
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
resolve(result);
|
||||
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setupPermissionWatcher(permission, callback) {
|
||||
if (!this.support.permissions) return;
|
||||
|
||||
try {
|
||||
const status = await navigator.permissions.query(this.getPermissionDescriptor(permission));
|
||||
status.addEventListener('change', () => {
|
||||
callback({
|
||||
permission,
|
||||
state: status.state,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.warn(`[PermissionManager] Could not set up watcher for ${permission}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
onPermissionChange(permission, newState) {
|
||||
Logger.info(`[PermissionManager] Permission changed: ${permission} → ${newState}`);
|
||||
|
||||
// Update cache
|
||||
this.permissionCache.set(permission, {
|
||||
name: permission,
|
||||
state: newState,
|
||||
timestamp: Date.now(),
|
||||
supported: true
|
||||
});
|
||||
|
||||
// Notify watchers
|
||||
this.permissionWatchers.forEach(watcher => {
|
||||
if (watcher.permission === permission && watcher.active) {
|
||||
watcher.callback({
|
||||
permission,
|
||||
state: newState,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async showPermissionRationale(permission, customMessage = null) {
|
||||
const rationales = {
|
||||
camera: 'Camera access is needed to take photos and record videos. You can enable this in your browser settings.',
|
||||
microphone: 'Microphone access is needed for audio recording and voice features. Please check your browser settings.',
|
||||
geolocation: 'Location access helps provide relevant local content. You can manage this in your browser settings.',
|
||||
notifications: 'Notifications keep you updated with important information. You can change this anytime in settings.'
|
||||
};
|
||||
|
||||
const message = customMessage || rationales[permission] || `${permission} permission was denied. Please enable it in your browser settings to use this feature.`;
|
||||
|
||||
// Simple alert for now - could be enhanced with custom UI
|
||||
if (typeof window !== 'undefined' && window.confirm) {
|
||||
return window.confirm(message + '\n\nWould you like to try again?');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
generateId(prefix = 'perm') {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive permission status report
|
||||
*/
|
||||
async getPermissionReport() {
|
||||
const report = {
|
||||
support: this.support,
|
||||
cached: Array.from(this.permissionCache.entries()).map(([name, data]) => ({
|
||||
name,
|
||||
...data
|
||||
})),
|
||||
watchers: Array.from(this.permissionWatchers.keys()),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached permissions
|
||||
*/
|
||||
clearCache() {
|
||||
this.permissionCache.clear();
|
||||
Logger.info('[PermissionManager] Permission cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all watchers and cleanup
|
||||
*/
|
||||
cleanup() {
|
||||
this.permissionWatchers.forEach(watcher => {
|
||||
watcher.active = false;
|
||||
});
|
||||
this.permissionWatchers.clear();
|
||||
this.requestQueue.clear();
|
||||
this.permissionCache.clear();
|
||||
|
||||
Logger.info('[PermissionManager] Cleanup completed');
|
||||
}
|
||||
}
|
||||
761
resources/js/modules/api-manager/StorageManager.js
Normal file
761
resources/js/modules/api-manager/StorageManager.js
Normal file
@@ -0,0 +1,761 @@
|
||||
// modules/api-manager/StorageManager.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Storage APIs Manager - IndexedDB, Cache API, Web Locks, Broadcast Channel
|
||||
*/
|
||||
export class StorageManager {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.dbName = config.dbName || 'AppDatabase';
|
||||
this.dbVersion = config.dbVersion || 1;
|
||||
this.db = null;
|
||||
this.cache = null;
|
||||
this.channels = new Map();
|
||||
|
||||
// Check API support
|
||||
this.support = {
|
||||
indexedDB: 'indexedDB' in window,
|
||||
cacheAPI: 'caches' in window,
|
||||
webLocks: 'locks' in navigator,
|
||||
broadcastChannel: 'BroadcastChannel' in window,
|
||||
localStorage: 'localStorage' in window,
|
||||
sessionStorage: 'sessionStorage' in window
|
||||
};
|
||||
|
||||
Logger.info('[StorageManager] Initialized with support:', this.support);
|
||||
|
||||
// Initialize databases
|
||||
this.initializeDB();
|
||||
this.initializeCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize IndexedDB
|
||||
*/
|
||||
async initializeDB() {
|
||||
if (!this.support.indexedDB) {
|
||||
Logger.warn('[StorageManager] IndexedDB not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
|
||||
request.onerror = () => {
|
||||
Logger.error('[StorageManager] IndexedDB failed to open');
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
|
||||
// Create default object stores
|
||||
if (!db.objectStoreNames.contains('keyValue')) {
|
||||
const store = db.createObjectStore('keyValue', { keyPath: 'key' });
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('cache')) {
|
||||
db.createObjectStore('cache', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('files')) {
|
||||
const fileStore = db.createObjectStore('files', { keyPath: 'id', autoIncrement: true });
|
||||
fileStore.createIndex('name', 'name', { unique: false });
|
||||
fileStore.createIndex('type', 'type', { unique: false });
|
||||
}
|
||||
|
||||
Logger.info('[StorageManager] IndexedDB schema updated');
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
this.db = event.target.result;
|
||||
Logger.info('[StorageManager] IndexedDB connected');
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] IndexedDB initialization failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Cache API
|
||||
*/
|
||||
async initializeCache() {
|
||||
if (!this.support.cacheAPI) {
|
||||
Logger.warn('[StorageManager] Cache API not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.cache = await caches.open(this.config.cacheName || 'app-cache-v1');
|
||||
Logger.info('[StorageManager] Cache API initialized');
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] Cache API initialization failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IndexedDB Operations
|
||||
*/
|
||||
db = {
|
||||
// Set key-value data
|
||||
set: async (key, value, expiration = null) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = this.db.transaction(['keyValue'], 'readwrite');
|
||||
const store = transaction.objectStore('keyValue');
|
||||
|
||||
const data = {
|
||||
key,
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
expiration: expiration ? Date.now() + expiration : null
|
||||
};
|
||||
|
||||
const request = store.put(data);
|
||||
|
||||
request.onsuccess = () => {
|
||||
Logger.info(`[StorageManager] DB set: ${key}`);
|
||||
resolve(data);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
Logger.error(`[StorageManager] DB set failed: ${key}`);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Get key-value data
|
||||
get: async (key) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = this.db.transaction(['keyValue'], 'readonly');
|
||||
const store = transaction.objectStore('keyValue');
|
||||
const request = store.get(key);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result;
|
||||
|
||||
if (!result) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (result.expiration && Date.now() > result.expiration) {
|
||||
this.db.delete(key); // Auto-cleanup expired data
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(result.value);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
Logger.error(`[StorageManager] DB get failed: ${key}`);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Delete key
|
||||
delete: async (key) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = this.db.transaction(['keyValue'], 'readwrite');
|
||||
const store = transaction.objectStore('keyValue');
|
||||
const request = store.delete(key);
|
||||
|
||||
request.onsuccess = () => {
|
||||
Logger.info(`[StorageManager] DB deleted: ${key}`);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Get all keys
|
||||
keys: async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = this.db.transaction(['keyValue'], 'readonly');
|
||||
const store = transaction.objectStore('keyValue');
|
||||
const request = store.getAllKeys();
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Clear all data
|
||||
clear: async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = this.db.transaction(['keyValue'], 'readwrite');
|
||||
const store = transaction.objectStore('keyValue');
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => {
|
||||
Logger.info('[StorageManager] DB cleared');
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Store files/blobs
|
||||
storeFile: async (name, file, metadata = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = this.db.transaction(['files'], 'readwrite');
|
||||
const store = transaction.objectStore('files');
|
||||
|
||||
const fileData = {
|
||||
name,
|
||||
file,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
timestamp: Date.now(),
|
||||
metadata
|
||||
};
|
||||
|
||||
const request = store.add(fileData);
|
||||
|
||||
request.onsuccess = () => {
|
||||
Logger.info(`[StorageManager] File stored: ${name}`);
|
||||
resolve({ id: request.result, ...fileData });
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Get file
|
||||
getFile: async (id) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = this.db.transaction(['files'], 'readonly');
|
||||
const store = transaction.objectStore('files');
|
||||
const request = store.get(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cache API Operations
|
||||
*/
|
||||
cache = {
|
||||
// Add request/response to cache
|
||||
add: async (request, response = null) => {
|
||||
if (!this.cache) {
|
||||
throw new Error('Cache API not available');
|
||||
}
|
||||
|
||||
try {
|
||||
if (response) {
|
||||
await this.cache.put(request, response);
|
||||
} else {
|
||||
await this.cache.add(request);
|
||||
}
|
||||
Logger.info(`[StorageManager] Cache add: ${request}`);
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] Cache add failed:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get from cache
|
||||
get: async (request) => {
|
||||
if (!this.cache) {
|
||||
throw new Error('Cache API not available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.cache.match(request);
|
||||
if (response) {
|
||||
Logger.info(`[StorageManager] Cache hit: ${request}`);
|
||||
} else {
|
||||
Logger.info(`[StorageManager] Cache miss: ${request}`);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] Cache get failed:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete from cache
|
||||
delete: async (request) => {
|
||||
if (!this.cache) {
|
||||
throw new Error('Cache API not available');
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await this.cache.delete(request);
|
||||
if (success) {
|
||||
Logger.info(`[StorageManager] Cache deleted: ${request}`);
|
||||
}
|
||||
return success;
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] Cache delete failed:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get all cached requests
|
||||
keys: async () => {
|
||||
if (!this.cache) {
|
||||
throw new Error('Cache API not available');
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.cache.keys();
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] Cache keys failed:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear cache
|
||||
clear: async () => {
|
||||
if (!this.cache) {
|
||||
throw new Error('Cache API not available');
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await this.cache.keys();
|
||||
await Promise.all(keys.map(key => this.cache.delete(key)));
|
||||
Logger.info('[StorageManager] Cache cleared');
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] Cache clear failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Broadcast Channel for cross-tab communication
|
||||
*/
|
||||
channel = {
|
||||
// Create or get channel
|
||||
create: (channelName, onMessage = null) => {
|
||||
if (!this.support.broadcastChannel) {
|
||||
Logger.warn('[StorageManager] BroadcastChannel not supported');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.channels.has(channelName)) {
|
||||
return this.channels.get(channelName);
|
||||
}
|
||||
|
||||
const channel = new BroadcastChannel(channelName);
|
||||
|
||||
if (onMessage) {
|
||||
channel.addEventListener('message', onMessage);
|
||||
}
|
||||
|
||||
const channelWrapper = {
|
||||
name: channelName,
|
||||
channel,
|
||||
|
||||
send: (data) => {
|
||||
channel.postMessage({
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
sender: 'current-tab'
|
||||
});
|
||||
Logger.info(`[StorageManager] Broadcast sent to ${channelName}`);
|
||||
},
|
||||
|
||||
onMessage: (callback) => {
|
||||
channel.addEventListener('message', (event) => {
|
||||
callback(event.data);
|
||||
});
|
||||
},
|
||||
|
||||
close: () => {
|
||||
channel.close();
|
||||
this.channels.delete(channelName);
|
||||
Logger.info(`[StorageManager] Channel closed: ${channelName}`);
|
||||
}
|
||||
};
|
||||
|
||||
this.channels.set(channelName, channelWrapper);
|
||||
Logger.info(`[StorageManager] Channel created: ${channelName}`);
|
||||
|
||||
return channelWrapper;
|
||||
},
|
||||
|
||||
// Get existing channel
|
||||
get: (channelName) => {
|
||||
return this.channels.get(channelName) || null;
|
||||
},
|
||||
|
||||
// Close channel
|
||||
close: (channelName) => {
|
||||
const channel = this.channels.get(channelName);
|
||||
if (channel) {
|
||||
channel.close();
|
||||
}
|
||||
},
|
||||
|
||||
// Close all channels
|
||||
closeAll: () => {
|
||||
this.channels.forEach(channel => channel.close());
|
||||
this.channels.clear();
|
||||
Logger.info('[StorageManager] All channels closed');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Web Locks API for resource locking
|
||||
*/
|
||||
locks = {
|
||||
// Acquire lock
|
||||
acquire: async (lockName, callback, options = {}) => {
|
||||
if (!this.support.webLocks) {
|
||||
Logger.warn('[StorageManager] Web Locks not supported, executing without lock');
|
||||
return await callback();
|
||||
}
|
||||
|
||||
try {
|
||||
return await navigator.locks.request(lockName, options, async (lock) => {
|
||||
Logger.info(`[StorageManager] Lock acquired: ${lockName}`);
|
||||
const result = await callback(lock);
|
||||
Logger.info(`[StorageManager] Lock released: ${lockName}`);
|
||||
return result;
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(`[StorageManager] Lock failed: ${lockName}`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Query locks
|
||||
query: async () => {
|
||||
if (!this.support.webLocks) {
|
||||
return { held: [], pending: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
return await navigator.locks.query();
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] Lock query failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Local/Session Storage helpers (with JSON support)
|
||||
*/
|
||||
local = {
|
||||
set: (key, value, expiration = null) => {
|
||||
if (!this.support.localStorage) return false;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
expiration: expiration ? Date.now() + expiration : null
|
||||
};
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
return true;
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] localStorage set failed:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
get: (key) => {
|
||||
if (!this.support.localStorage) return null;
|
||||
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
if (!item) return null;
|
||||
|
||||
const data = JSON.parse(item);
|
||||
|
||||
// Check expiration
|
||||
if (data.expiration && Date.now() > data.expiration) {
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.value;
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] localStorage get failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
delete: (key) => {
|
||||
if (!this.support.localStorage) return false;
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
if (!this.support.localStorage) return false;
|
||||
localStorage.clear();
|
||||
return true;
|
||||
},
|
||||
|
||||
keys: () => {
|
||||
if (!this.support.localStorage) return [];
|
||||
return Object.keys(localStorage);
|
||||
}
|
||||
};
|
||||
|
||||
session = {
|
||||
set: (key, value) => {
|
||||
if (!this.support.sessionStorage) return false;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] sessionStorage set failed:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
get: (key) => {
|
||||
if (!this.support.sessionStorage) return null;
|
||||
|
||||
try {
|
||||
const item = sessionStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : null;
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] sessionStorage get failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
delete: (key) => {
|
||||
if (!this.support.sessionStorage) return false;
|
||||
sessionStorage.removeItem(key);
|
||||
return true;
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
if (!this.support.sessionStorage) return false;
|
||||
sessionStorage.clear();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Smart caching with automatic expiration
|
||||
*/
|
||||
smartCache = {
|
||||
set: async (key, value, options = {}) => {
|
||||
const {
|
||||
storage = 'indexedDB',
|
||||
expiration = null,
|
||||
fallback = true
|
||||
} = options;
|
||||
|
||||
try {
|
||||
if (storage === 'indexedDB' && this.db) {
|
||||
return await this.db.set(key, value, expiration);
|
||||
} else if (fallback) {
|
||||
return this.local.set(key, value, expiration);
|
||||
}
|
||||
} catch (error) {
|
||||
if (fallback) {
|
||||
return this.local.set(key, value, expiration);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
get: async (key, options = {}) => {
|
||||
const { storage = 'indexedDB', fallback = true } = options;
|
||||
|
||||
try {
|
||||
if (storage === 'indexedDB' && this.db) {
|
||||
return await this.db.get(key);
|
||||
} else if (fallback) {
|
||||
return this.local.get(key);
|
||||
}
|
||||
} catch (error) {
|
||||
if (fallback) {
|
||||
return this.local.get(key);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
delete: async (key, options = {}) => {
|
||||
const { storage = 'indexedDB', fallback = true } = options;
|
||||
|
||||
try {
|
||||
if (storage === 'indexedDB' && this.db) {
|
||||
await this.db.delete(key);
|
||||
}
|
||||
if (fallback) {
|
||||
this.local.delete(key);
|
||||
}
|
||||
} catch (error) {
|
||||
if (fallback) {
|
||||
this.local.delete(key);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get storage usage statistics
|
||||
*/
|
||||
async getStorageUsage() {
|
||||
const stats = {
|
||||
quota: 0,
|
||||
usage: 0,
|
||||
available: 0,
|
||||
percentage: 0
|
||||
};
|
||||
|
||||
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||
try {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
stats.quota = estimate.quota || 0;
|
||||
stats.usage = estimate.usage || 0;
|
||||
stats.available = stats.quota - stats.usage;
|
||||
stats.percentage = stats.quota > 0 ? Math.round((stats.usage / stats.quota) * 100) : 0;
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] Storage estimate failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired data
|
||||
*/
|
||||
async cleanup() {
|
||||
Logger.info('[StorageManager] Starting cleanup...');
|
||||
|
||||
try {
|
||||
// Cleanup IndexedDB expired entries
|
||||
if (this.db) {
|
||||
const transaction = this.db.transaction(['keyValue'], 'readwrite');
|
||||
const store = transaction.objectStore('keyValue');
|
||||
const index = store.index('timestamp');
|
||||
|
||||
const request = index.openCursor();
|
||||
let cleaned = 0;
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
const data = cursor.value;
|
||||
if (data.expiration && Date.now() > data.expiration) {
|
||||
cursor.delete();
|
||||
cleaned++;
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
Logger.info(`[StorageManager] Cleanup completed: ${cleaned} expired entries removed`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup localStorage expired entries
|
||||
if (this.support.localStorage) {
|
||||
const keys = Object.keys(localStorage);
|
||||
let localCleaned = 0;
|
||||
|
||||
keys.forEach(key => {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
const data = JSON.parse(item);
|
||||
|
||||
if (data.expiration && Date.now() > data.expiration) {
|
||||
localStorage.removeItem(key);
|
||||
localCleaned++;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore non-JSON items
|
||||
}
|
||||
});
|
||||
|
||||
if (localCleaned > 0) {
|
||||
Logger.info(`[StorageManager] LocalStorage cleanup: ${localCleaned} expired entries removed`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
Logger.error('[StorageManager] Cleanup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive storage status
|
||||
*/
|
||||
async getStatus() {
|
||||
const usage = await this.getStorageUsage();
|
||||
|
||||
return {
|
||||
support: this.support,
|
||||
usage,
|
||||
activeChannels: this.channels.size,
|
||||
dbConnected: !!this.db,
|
||||
cacheConnected: !!this.cache,
|
||||
channelNames: Array.from(this.channels.keys())
|
||||
};
|
||||
}
|
||||
}
|
||||
648
resources/js/modules/api-manager/WorkerManager.js
Normal file
648
resources/js/modules/api-manager/WorkerManager.js
Normal file
@@ -0,0 +1,648 @@
|
||||
// modules/api-manager/WorkerManager.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Worker APIs Manager - Web Workers, Service Workers, Shared Workers
|
||||
*/
|
||||
export class WorkerManager {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.activeWorkers = new Map();
|
||||
this.messageHandlers = new Map();
|
||||
this.workerScripts = new Map();
|
||||
|
||||
// Check API support
|
||||
this.support = {
|
||||
webWorkers: 'Worker' in window,
|
||||
serviceWorker: 'serviceWorker' in navigator,
|
||||
sharedWorker: 'SharedWorker' in window,
|
||||
offscreenCanvas: 'OffscreenCanvas' in window
|
||||
};
|
||||
|
||||
Logger.info('[WorkerManager] Initialized with support:', this.support);
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Workers for background processing
|
||||
*/
|
||||
web = {
|
||||
// Create a new Web Worker
|
||||
create: (script, options = {}) => {
|
||||
if (!this.support.webWorkers) {
|
||||
throw new Error('Web Workers not supported');
|
||||
}
|
||||
|
||||
let worker;
|
||||
const workerId = this.generateId('worker');
|
||||
|
||||
try {
|
||||
// Handle different script types
|
||||
if (typeof script === 'string') {
|
||||
// URL string
|
||||
worker = new Worker(script, options);
|
||||
} else if (typeof script === 'function') {
|
||||
// Function to blob
|
||||
const blob = this.createWorkerBlob(script);
|
||||
worker = new Worker(URL.createObjectURL(blob), options);
|
||||
} else if (script instanceof Blob) {
|
||||
// Blob directly
|
||||
worker = new Worker(URL.createObjectURL(script), options);
|
||||
} else {
|
||||
throw new Error('Invalid script type');
|
||||
}
|
||||
|
||||
// Enhanced worker wrapper
|
||||
const workerWrapper = {
|
||||
id: workerId,
|
||||
worker,
|
||||
|
||||
// Send message to worker
|
||||
send: (message, transfer = null) => {
|
||||
if (transfer) {
|
||||
worker.postMessage(message, transfer);
|
||||
} else {
|
||||
worker.postMessage(message);
|
||||
}
|
||||
Logger.info(`[WorkerManager] Message sent to worker: ${workerId}`);
|
||||
},
|
||||
|
||||
// Listen for messages
|
||||
onMessage: (callback) => {
|
||||
worker.addEventListener('message', (event) => {
|
||||
callback(event.data, event);
|
||||
});
|
||||
},
|
||||
|
||||
// Handle errors
|
||||
onError: (callback) => {
|
||||
worker.addEventListener('error', callback);
|
||||
worker.addEventListener('messageerror', callback);
|
||||
},
|
||||
|
||||
// Terminate worker
|
||||
terminate: () => {
|
||||
worker.terminate();
|
||||
this.activeWorkers.delete(workerId);
|
||||
Logger.info(`[WorkerManager] Worker terminated: ${workerId}`);
|
||||
},
|
||||
|
||||
// Execute function in worker
|
||||
execute: (fn, data = null) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const messageId = this.generateId('msg');
|
||||
|
||||
const handler = (event) => {
|
||||
if (event.data.id === messageId) {
|
||||
worker.removeEventListener('message', handler);
|
||||
|
||||
if (event.data.error) {
|
||||
reject(new Error(event.data.error));
|
||||
} else {
|
||||
resolve(event.data.result);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
worker.addEventListener('message', handler);
|
||||
|
||||
worker.postMessage({
|
||||
id: messageId,
|
||||
type: 'execute',
|
||||
function: fn.toString(),
|
||||
data
|
||||
});
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
worker.removeEventListener('message', handler);
|
||||
reject(new Error('Worker execution timeout'));
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.activeWorkers.set(workerId, workerWrapper);
|
||||
Logger.info(`[WorkerManager] Web Worker created: ${workerId}`);
|
||||
|
||||
return workerWrapper;
|
||||
|
||||
} catch (error) {
|
||||
Logger.error('[WorkerManager] Worker creation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Create worker pool for parallel processing
|
||||
createPool: (script, poolSize = navigator.hardwareConcurrency || 4, options = {}) => {
|
||||
const workers = [];
|
||||
|
||||
for (let i = 0; i < poolSize; i++) {
|
||||
workers.push(this.web.create(script, options));
|
||||
}
|
||||
|
||||
let currentWorker = 0;
|
||||
|
||||
const pool = {
|
||||
workers,
|
||||
|
||||
// Execute task on next available worker
|
||||
execute: async (fn, data = null) => {
|
||||
const worker = workers[currentWorker];
|
||||
currentWorker = (currentWorker + 1) % workers.length;
|
||||
|
||||
return worker.execute(fn, data);
|
||||
},
|
||||
|
||||
// Broadcast message to all workers
|
||||
broadcast: (message) => {
|
||||
workers.forEach(worker => {
|
||||
worker.send(message);
|
||||
});
|
||||
},
|
||||
|
||||
// Terminate all workers
|
||||
terminate: () => {
|
||||
workers.forEach(worker => {
|
||||
worker.terminate();
|
||||
});
|
||||
workers.length = 0;
|
||||
Logger.info('[WorkerManager] Worker pool terminated');
|
||||
}
|
||||
};
|
||||
|
||||
Logger.info(`[WorkerManager] Worker pool created with ${poolSize} workers`);
|
||||
return pool;
|
||||
},
|
||||
|
||||
// Common worker tasks
|
||||
tasks: {
|
||||
// Heavy computation
|
||||
compute: (fn, data) => {
|
||||
const workerCode = `
|
||||
self.addEventListener('message', function(e) {
|
||||
const { id, function: fnString, data } = e.data;
|
||||
|
||||
try {
|
||||
const fn = new Function('return ' + fnString)();
|
||||
const result = fn(data);
|
||||
self.postMessage({ id, result });
|
||||
} catch (error) {
|
||||
self.postMessage({ id, error: error.message });
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
const worker = this.web.create(workerCode);
|
||||
return worker.execute(fn, data);
|
||||
},
|
||||
|
||||
// Image processing
|
||||
processImage: (imageData, filters) => {
|
||||
const workerCode = `
|
||||
self.addEventListener('message', function(e) {
|
||||
const { id, data: { imageData, filters } } = e.data;
|
||||
|
||||
try {
|
||||
const pixels = imageData.data;
|
||||
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
// Apply filters
|
||||
if (filters.brightness) {
|
||||
pixels[i] *= filters.brightness; // R
|
||||
pixels[i + 1] *= filters.brightness; // G
|
||||
pixels[i + 2] *= filters.brightness; // B
|
||||
}
|
||||
|
||||
if (filters.contrast) {
|
||||
const factor = (259 * (filters.contrast * 255 + 255)) / (255 * (259 - filters.contrast * 255));
|
||||
pixels[i] = factor * (pixels[i] - 128) + 128;
|
||||
pixels[i + 1] = factor * (pixels[i + 1] - 128) + 128;
|
||||
pixels[i + 2] = factor * (pixels[i + 2] - 128) + 128;
|
||||
}
|
||||
|
||||
if (filters.grayscale) {
|
||||
const gray = 0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2];
|
||||
pixels[i] = gray;
|
||||
pixels[i + 1] = gray;
|
||||
pixels[i + 2] = gray;
|
||||
}
|
||||
}
|
||||
|
||||
self.postMessage({ id, result: imageData }, [imageData.data.buffer]);
|
||||
} catch (error) {
|
||||
self.postMessage({ id, error: error.message });
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
const worker = this.web.create(workerCode);
|
||||
return worker.execute(null, { imageData, filters });
|
||||
},
|
||||
|
||||
// Data processing
|
||||
processData: (data, processor) => {
|
||||
const workerCode = `
|
||||
self.addEventListener('message', function(e) {
|
||||
const { id, data, function: processorString } = e.data;
|
||||
|
||||
try {
|
||||
const processor = new Function('return ' + processorString)();
|
||||
const result = data.map(processor);
|
||||
self.postMessage({ id, result });
|
||||
} catch (error) {
|
||||
self.postMessage({ id, error: error.message });
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
const worker = this.web.create(workerCode);
|
||||
return worker.execute(processor, data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Service Workers for caching and background sync
|
||||
*/
|
||||
service = {
|
||||
// Register service worker
|
||||
register: async (scriptURL, options = {}) => {
|
||||
if (!this.support.serviceWorker) {
|
||||
throw new Error('Service Worker not supported');
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(scriptURL, options);
|
||||
|
||||
Logger.info('[WorkerManager] Service Worker registered:', scriptURL);
|
||||
|
||||
// Enhanced registration wrapper
|
||||
return {
|
||||
registration,
|
||||
scope: registration.scope,
|
||||
|
||||
// Update service worker
|
||||
update: () => registration.update(),
|
||||
|
||||
// Unregister service worker
|
||||
unregister: () => registration.unregister(),
|
||||
|
||||
// Send message to service worker
|
||||
postMessage: (message) => {
|
||||
if (registration.active) {
|
||||
registration.active.postMessage(message);
|
||||
}
|
||||
},
|
||||
|
||||
// Listen for updates
|
||||
onUpdate: (callback) => {
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
callback(newWorker);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Check for updates
|
||||
checkForUpdates: () => {
|
||||
return registration.update();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error('[WorkerManager] Service Worker registration failed:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get registration
|
||||
getRegistration: async (scope = '/') => {
|
||||
if (!this.support.serviceWorker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await navigator.serviceWorker.getRegistration(scope);
|
||||
} catch (error) {
|
||||
Logger.error('[WorkerManager] Get registration failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Get all registrations
|
||||
getRegistrations: async () => {
|
||||
if (!this.support.serviceWorker) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await navigator.serviceWorker.getRegistrations();
|
||||
} catch (error) {
|
||||
Logger.error('[WorkerManager] Get registrations failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared Workers for cross-tab communication
|
||||
*/
|
||||
shared = {
|
||||
// Create shared worker
|
||||
create: (script, options = {}) => {
|
||||
if (!this.support.sharedWorker) {
|
||||
throw new Error('Shared Worker not supported');
|
||||
}
|
||||
|
||||
try {
|
||||
const sharedWorker = new SharedWorker(script, options);
|
||||
const port = sharedWorker.port;
|
||||
const workerId = this.generateId('shared');
|
||||
|
||||
// Start the port
|
||||
port.start();
|
||||
|
||||
const workerWrapper = {
|
||||
id: workerId,
|
||||
worker: sharedWorker,
|
||||
port,
|
||||
|
||||
// Send message
|
||||
send: (message, transfer = null) => {
|
||||
if (transfer) {
|
||||
port.postMessage(message, transfer);
|
||||
} else {
|
||||
port.postMessage(message);
|
||||
}
|
||||
},
|
||||
|
||||
// Listen for messages
|
||||
onMessage: (callback) => {
|
||||
port.addEventListener('message', (event) => {
|
||||
callback(event.data, event);
|
||||
});
|
||||
},
|
||||
|
||||
// Handle errors
|
||||
onError: (callback) => {
|
||||
sharedWorker.addEventListener('error', callback);
|
||||
port.addEventListener('messageerror', callback);
|
||||
},
|
||||
|
||||
// Close connection
|
||||
close: () => {
|
||||
port.close();
|
||||
this.activeWorkers.delete(workerId);
|
||||
Logger.info(`[WorkerManager] Shared Worker closed: ${workerId}`);
|
||||
}
|
||||
};
|
||||
|
||||
this.activeWorkers.set(workerId, workerWrapper);
|
||||
Logger.info(`[WorkerManager] Shared Worker created: ${workerId}`);
|
||||
|
||||
return workerWrapper;
|
||||
} catch (error) {
|
||||
Logger.error('[WorkerManager] Shared Worker creation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Offscreen Canvas for worker-based rendering
|
||||
*/
|
||||
offscreen = {
|
||||
// Create offscreen canvas worker
|
||||
create: (canvas, workerScript = null) => {
|
||||
if (!this.support.offscreenCanvas) {
|
||||
throw new Error('Offscreen Canvas not supported');
|
||||
}
|
||||
|
||||
try {
|
||||
const offscreenCanvas = canvas.transferControlToOffscreen();
|
||||
|
||||
const defaultWorkerScript = `
|
||||
self.addEventListener('message', function(e) {
|
||||
const { canvas, type, data } = e.data;
|
||||
|
||||
if (type === 'init') {
|
||||
self.canvas = canvas;
|
||||
self.ctx = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
if (type === 'draw' && self.ctx) {
|
||||
// Basic drawing operations
|
||||
const { operations } = data;
|
||||
|
||||
operations.forEach(op => {
|
||||
switch (op.type) {
|
||||
case 'fillRect':
|
||||
self.ctx.fillRect(...op.args);
|
||||
break;
|
||||
case 'strokeRect':
|
||||
self.ctx.strokeRect(...op.args);
|
||||
break;
|
||||
case 'fillText':
|
||||
self.ctx.fillText(...op.args);
|
||||
break;
|
||||
case 'setFillStyle':
|
||||
self.ctx.fillStyle = op.value;
|
||||
break;
|
||||
case 'setStrokeStyle':
|
||||
self.ctx.strokeStyle = op.value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
const script = workerScript || defaultWorkerScript;
|
||||
const worker = this.web.create(script);
|
||||
|
||||
// Initialize worker with canvas
|
||||
worker.send({
|
||||
type: 'init',
|
||||
canvas: offscreenCanvas
|
||||
}, [offscreenCanvas]);
|
||||
|
||||
return {
|
||||
worker,
|
||||
|
||||
// Draw operations
|
||||
draw: (operations) => {
|
||||
worker.send({
|
||||
type: 'draw',
|
||||
data: { operations }
|
||||
});
|
||||
},
|
||||
|
||||
// Send custom message
|
||||
send: (message) => worker.send(message),
|
||||
|
||||
// Listen for messages
|
||||
onMessage: (callback) => worker.onMessage(callback),
|
||||
|
||||
// Terminate worker
|
||||
terminate: () => worker.terminate()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
Logger.error('[WorkerManager] Offscreen Canvas creation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper methods
|
||||
|
||||
createWorkerBlob(fn) {
|
||||
const code = `
|
||||
(function() {
|
||||
const workerFunction = ${fn.toString()};
|
||||
|
||||
if (typeof workerFunction === 'function') {
|
||||
// If function expects to be called immediately
|
||||
if (workerFunction.length === 0) {
|
||||
workerFunction();
|
||||
}
|
||||
}
|
||||
|
||||
// Standard worker message handling
|
||||
self.addEventListener('message', function(e) {
|
||||
if (e.data.type === 'execute' && e.data.function) {
|
||||
try {
|
||||
const fn = new Function('return ' + e.data.function)();
|
||||
const result = fn(e.data.data);
|
||||
self.postMessage({
|
||||
id: e.data.id,
|
||||
result
|
||||
});
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
id: e.data.id,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
return new Blob([code], { type: 'application/javascript' });
|
||||
}
|
||||
|
||||
generateId(prefix = 'worker') {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate all active workers
|
||||
*/
|
||||
terminateAll() {
|
||||
this.activeWorkers.forEach(worker => {
|
||||
if (worker.terminate) {
|
||||
worker.terminate();
|
||||
} else if (worker.close) {
|
||||
worker.close();
|
||||
}
|
||||
});
|
||||
this.activeWorkers.clear();
|
||||
Logger.info('[WorkerManager] All workers terminated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worker statistics
|
||||
*/
|
||||
getStats() {
|
||||
const workers = Array.from(this.activeWorkers.values());
|
||||
|
||||
return {
|
||||
total: workers.length,
|
||||
byType: workers.reduce((acc, worker) => {
|
||||
const type = worker.id.split('_')[0];
|
||||
acc[type] = (acc[type] || 0) + 1;
|
||||
return acc;
|
||||
}, {}),
|
||||
support: this.support,
|
||||
hardwareConcurrency: navigator.hardwareConcurrency || 'unknown'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create common worker utilities
|
||||
*/
|
||||
utils = {
|
||||
// Benchmark function performance
|
||||
benchmark: async (fn, data, iterations = 1000) => {
|
||||
const worker = this.web.create(() => {
|
||||
self.addEventListener('message', (e) => {
|
||||
const { id, function: fnString, data, iterations } = e.data;
|
||||
|
||||
try {
|
||||
const fn = new Function('return ' + fnString)();
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
fn(data);
|
||||
}
|
||||
|
||||
const end = performance.now();
|
||||
const totalTime = end - start;
|
||||
const avgTime = totalTime / iterations;
|
||||
|
||||
self.postMessage({
|
||||
id,
|
||||
result: {
|
||||
totalTime,
|
||||
avgTime,
|
||||
iterations,
|
||||
opsPerSecond: 1000 / avgTime
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
self.postMessage({ id, error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const result = await worker.execute(fn, data, iterations);
|
||||
worker.terminate();
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
// Parallel array processing
|
||||
parallelMap: async (array, fn, chunkSize = null) => {
|
||||
const chunks = chunkSize || Math.ceil(array.length / (navigator.hardwareConcurrency || 4));
|
||||
const pool = this.web.createPool(() => {
|
||||
self.addEventListener('message', (e) => {
|
||||
const { id, data: { chunk, function: fnString } } = e.data;
|
||||
|
||||
try {
|
||||
const fn = new Function('return ' + fnString)();
|
||||
const result = chunk.map(fn);
|
||||
self.postMessage({ id, result });
|
||||
} catch (error) {
|
||||
self.postMessage({ id, error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < array.length; i += chunks) {
|
||||
const chunk = array.slice(i, i + chunks);
|
||||
promises.push(pool.execute(fn, chunk));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
pool.terminate();
|
||||
|
||||
return results.flat();
|
||||
}
|
||||
};
|
||||
}
|
||||
265
resources/js/modules/api-manager/index.js
Normal file
265
resources/js/modules/api-manager/index.js
Normal file
@@ -0,0 +1,265 @@
|
||||
// modules/api-manager/index.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
import { ObserverManager } from './ObserverManager.js';
|
||||
import { MediaManager } from './MediaManager.js';
|
||||
import { StorageManager } from './StorageManager.js';
|
||||
import { DeviceManager } from './DeviceManager.js';
|
||||
import { AnimationManager } from './AnimationManager.js';
|
||||
import { WorkerManager } from './WorkerManager.js';
|
||||
import { PerformanceManager } from './PerformanceManager.js';
|
||||
import { PermissionManager } from './PermissionManager.js';
|
||||
import { BiometricAuthManager } from './BiometricAuthManager.js';
|
||||
|
||||
/**
|
||||
* Centralized API Manager for all Web APIs
|
||||
* Provides unified, simplified access to modern browser APIs
|
||||
*/
|
||||
const APIManagerModule = {
|
||||
name: 'api-manager',
|
||||
|
||||
// Module-level init (called by module system)
|
||||
init(config = {}, state = null) {
|
||||
Logger.info('[APIManager] Module initialized - All Web APIs available');
|
||||
this.initializeAPIManagers(config);
|
||||
this.exposeGlobalAPI();
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize all API managers
|
||||
*/
|
||||
initializeAPIManagers(config) {
|
||||
this.observers = new ObserverManager(config.observers || {});
|
||||
this.media = new MediaManager(config.media || {});
|
||||
this.storage = new StorageManager(config.storage || {});
|
||||
this.device = new DeviceManager(config.device || {});
|
||||
this.animation = new AnimationManager(config.animation || {});
|
||||
this.worker = new WorkerManager(config.worker || {});
|
||||
this.performance = new PerformanceManager(config.performance || {});
|
||||
this.permissions = new PermissionManager(config.permissions || {});
|
||||
this.biometric = new BiometricAuthManager(config.biometric || {});
|
||||
|
||||
Logger.info('[APIManager] All API managers initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Expose global API for easy access
|
||||
*/
|
||||
exposeGlobalAPI() {
|
||||
// Make API managers globally available
|
||||
if (typeof window !== 'undefined') {
|
||||
window.API = {
|
||||
observers: this.observers,
|
||||
media: this.media,
|
||||
storage: this.storage,
|
||||
device: this.device,
|
||||
animation: this.animation,
|
||||
worker: this.worker,
|
||||
performance: this.performance,
|
||||
permissions: this.permissions,
|
||||
biometric: this.biometric
|
||||
};
|
||||
|
||||
Logger.info('[APIManager] Global API exposed at window.API');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get specific API manager
|
||||
*/
|
||||
getAPI(name) {
|
||||
return this[name] || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if specific Web API is supported
|
||||
*/
|
||||
isSupported(apiName) {
|
||||
const supportMap = {
|
||||
// Observer APIs
|
||||
'IntersectionObserver': 'IntersectionObserver' in window,
|
||||
'ResizeObserver': 'ResizeObserver' in window,
|
||||
'MutationObserver': 'MutationObserver' in window,
|
||||
'PerformanceObserver': 'PerformanceObserver' in window,
|
||||
|
||||
// Media APIs
|
||||
'MediaDevices': navigator.mediaDevices !== undefined,
|
||||
'WebRTC': 'RTCPeerConnection' in window,
|
||||
'WebAudio': 'AudioContext' in window || 'webkitAudioContext' in window,
|
||||
'MediaRecorder': 'MediaRecorder' in window,
|
||||
|
||||
// Storage APIs
|
||||
'IndexedDB': 'indexedDB' in window,
|
||||
'CacheAPI': 'caches' in window,
|
||||
'WebLocks': 'locks' in navigator,
|
||||
'BroadcastChannel': 'BroadcastChannel' in window,
|
||||
|
||||
// Device APIs
|
||||
'Geolocation': 'geolocation' in navigator,
|
||||
'DeviceMotion': 'DeviceMotionEvent' in window,
|
||||
'DeviceOrientation': 'DeviceOrientationEvent' in window,
|
||||
'Vibration': 'vibrate' in navigator,
|
||||
'Battery': 'getBattery' in navigator,
|
||||
'NetworkInfo': 'connection' in navigator,
|
||||
|
||||
// Animation APIs
|
||||
'WebAnimations': 'animate' in Element.prototype,
|
||||
'VisualViewport': 'visualViewport' in window,
|
||||
|
||||
// Worker APIs
|
||||
'WebWorkers': 'Worker' in window,
|
||||
'ServiceWorker': 'serviceWorker' in navigator,
|
||||
'SharedWorker': 'SharedWorker' in window,
|
||||
|
||||
// Performance APIs
|
||||
'PerformanceAPI': 'performance' in window,
|
||||
'NavigationTiming': 'getEntriesByType' in performance,
|
||||
|
||||
// Permission APIs
|
||||
'Permissions': 'permissions' in navigator,
|
||||
'WebAuthn': 'credentials' in navigator && 'create' in navigator.credentials
|
||||
};
|
||||
|
||||
return supportMap[apiName] || false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get browser capabilities report
|
||||
*/
|
||||
getCapabilities() {
|
||||
const capabilities = {};
|
||||
|
||||
// Check all API support
|
||||
Object.keys(this.getSupportMap()).forEach(api => {
|
||||
capabilities[api] = this.isSupported(api);
|
||||
});
|
||||
|
||||
return {
|
||||
capabilities,
|
||||
modernBrowser: this.isModernBrowser(),
|
||||
recommendedAPIs: this.getRecommendedAPIs(),
|
||||
summary: this.getCapabilitySummary(capabilities)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if browser is considered modern
|
||||
*/
|
||||
isModernBrowser() {
|
||||
const requiredAPIs = [
|
||||
'IntersectionObserver',
|
||||
'ResizeObserver',
|
||||
'WebAnimations',
|
||||
'IndexedDB'
|
||||
];
|
||||
|
||||
return requiredAPIs.every(api => this.isSupported(api));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get recommended APIs for current browser
|
||||
*/
|
||||
getRecommendedAPIs() {
|
||||
const recommendations = [];
|
||||
|
||||
if (this.isSupported('IntersectionObserver')) {
|
||||
recommendations.push({
|
||||
api: 'IntersectionObserver',
|
||||
use: 'Lazy loading, scroll triggers, viewport detection',
|
||||
example: 'API.observers.intersection(elements, callback)'
|
||||
});
|
||||
}
|
||||
|
||||
if (this.isSupported('WebAnimations')) {
|
||||
recommendations.push({
|
||||
api: 'Web Animations',
|
||||
use: 'High-performance animations with timeline control',
|
||||
example: 'API.animation.animate(element, keyframes, options)'
|
||||
});
|
||||
}
|
||||
|
||||
if (this.isSupported('IndexedDB')) {
|
||||
recommendations.push({
|
||||
api: 'IndexedDB',
|
||||
use: 'Client-side database for complex data',
|
||||
example: 'API.storage.db.set(key, value)'
|
||||
});
|
||||
}
|
||||
|
||||
if (this.isSupported('MediaDevices')) {
|
||||
recommendations.push({
|
||||
api: 'Media Devices',
|
||||
use: 'Camera, microphone, screen sharing',
|
||||
example: 'API.media.getUserCamera()'
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get capability summary
|
||||
*/
|
||||
getCapabilitySummary(capabilities) {
|
||||
const total = Object.keys(capabilities).length;
|
||||
const supported = Object.values(capabilities).filter(Boolean).length;
|
||||
const percentage = Math.round((supported / total) * 100);
|
||||
|
||||
return {
|
||||
total,
|
||||
supported,
|
||||
percentage,
|
||||
grade: this.getGrade(percentage)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get grade based on API support percentage
|
||||
*/
|
||||
getGrade(percentage) {
|
||||
if (percentage >= 90) return 'A+';
|
||||
if (percentage >= 80) return 'A';
|
||||
if (percentage >= 70) return 'B';
|
||||
if (percentage >= 60) return 'C';
|
||||
return 'D';
|
||||
},
|
||||
|
||||
/**
|
||||
* Get support map for reference
|
||||
*/
|
||||
getSupportMap() {
|
||||
return {
|
||||
'IntersectionObserver': 'Viewport intersection detection',
|
||||
'ResizeObserver': 'Element resize detection',
|
||||
'MutationObserver': 'DOM change detection',
|
||||
'PerformanceObserver': 'Performance metrics monitoring',
|
||||
'MediaDevices': 'Camera and microphone access',
|
||||
'WebRTC': 'Real-time communication',
|
||||
'WebAudio': 'Audio processing and synthesis',
|
||||
'MediaRecorder': 'Audio/video recording',
|
||||
'IndexedDB': 'Client-side database',
|
||||
'CacheAPI': 'HTTP cache management',
|
||||
'WebLocks': 'Resource locking',
|
||||
'BroadcastChannel': 'Cross-tab communication',
|
||||
'Geolocation': 'GPS and location services',
|
||||
'DeviceMotion': 'Accelerometer and gyroscope',
|
||||
'DeviceOrientation': 'Device orientation',
|
||||
'Vibration': 'Haptic feedback',
|
||||
'Battery': 'Battery status',
|
||||
'NetworkInfo': 'Network connection info',
|
||||
'WebAnimations': 'High-performance animations',
|
||||
'VisualViewport': 'Viewport information',
|
||||
'WebWorkers': 'Background processing',
|
||||
'ServiceWorker': 'Background sync and caching',
|
||||
'SharedWorker': 'Shared background processing',
|
||||
'PerformanceAPI': 'Performance measurement',
|
||||
'NavigationTiming': 'Navigation timing metrics',
|
||||
'Permissions': 'Browser permission management',
|
||||
'WebAuthn': 'Biometric authentication'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Export für module system
|
||||
export const init = APIManagerModule.init.bind(APIManagerModule);
|
||||
export default APIManagerModule;
|
||||
184
resources/js/modules/canvas-animations/CanvasManager.js
Normal file
184
resources/js/modules/canvas-animations/CanvasManager.js
Normal file
@@ -0,0 +1,184 @@
|
||||
// modules/canvas-animations/CanvasManager.js
|
||||
import { useEvent } from '../../core/useEvent.js';
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Canvas Manager - Handles canvas setup, resizing, and basic operations
|
||||
*/
|
||||
export class CanvasManager {
|
||||
constructor(canvas, options = {}) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.options = {
|
||||
responsive: true,
|
||||
pixelRatio: window.devicePixelRatio || 1,
|
||||
...options
|
||||
};
|
||||
|
||||
this.animationId = null;
|
||||
this.isAnimating = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize canvas manager
|
||||
*/
|
||||
init() {
|
||||
this.setupCanvas();
|
||||
|
||||
if (this.options.responsive) {
|
||||
this.setupResponsive();
|
||||
}
|
||||
|
||||
Logger.info('[CanvasManager] Initialized', {
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
pixelRatio: this.options.pixelRatio
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup initial canvas properties
|
||||
*/
|
||||
setupCanvas() {
|
||||
this.resize();
|
||||
|
||||
// Set CSS to prevent blurriness on high-DPI displays
|
||||
this.canvas.style.width = this.canvas.width + 'px';
|
||||
this.canvas.style.height = this.canvas.height + 'px';
|
||||
|
||||
// Scale context for high-DPI displays
|
||||
if (this.options.pixelRatio > 1) {
|
||||
this.ctx.scale(this.options.pixelRatio, this.options.pixelRatio);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup responsive behavior
|
||||
*/
|
||||
setupResponsive() {
|
||||
// Resize on window resize
|
||||
useEvent(window, 'resize', () => {
|
||||
this.resize();
|
||||
}, 'canvas-manager');
|
||||
|
||||
// Optional: Resize on orientation change for mobile
|
||||
useEvent(window, 'orientationchange', () => {
|
||||
setTimeout(() => this.resize(), 100);
|
||||
}, 'canvas-manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize canvas to match container or window
|
||||
*/
|
||||
resize() {
|
||||
const container = this.canvas.parentElement;
|
||||
const pixelRatio = this.options.pixelRatio;
|
||||
|
||||
// Get display size
|
||||
let displayWidth, displayHeight;
|
||||
|
||||
if (container && getComputedStyle(container).position !== 'static') {
|
||||
// Use container size if it has positioning
|
||||
displayWidth = container.clientWidth;
|
||||
displayHeight = container.clientHeight;
|
||||
} else {
|
||||
// Fallback to canvas CSS size or window size
|
||||
displayWidth = this.canvas.clientWidth || window.innerWidth;
|
||||
displayHeight = this.canvas.clientHeight || window.innerHeight;
|
||||
}
|
||||
|
||||
// Set actual canvas size
|
||||
this.canvas.width = Math.floor(displayWidth * pixelRatio);
|
||||
this.canvas.height = Math.floor(displayHeight * pixelRatio);
|
||||
|
||||
// Set CSS size
|
||||
this.canvas.style.width = displayWidth + 'px';
|
||||
this.canvas.style.height = displayHeight + 'px';
|
||||
|
||||
// Re-scale context if needed
|
||||
if (pixelRatio > 1) {
|
||||
this.ctx.scale(pixelRatio, pixelRatio);
|
||||
}
|
||||
|
||||
Logger.info('[CanvasManager] Resized', {
|
||||
displayWidth,
|
||||
displayHeight,
|
||||
canvasWidth: this.canvas.width,
|
||||
canvasHeight: this.canvas.height
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire canvas
|
||||
*/
|
||||
clear() {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get canvas dimensions (display size, not pixel size)
|
||||
*/
|
||||
getSize() {
|
||||
return {
|
||||
width: this.canvas.clientWidth,
|
||||
height: this.canvas.clientHeight,
|
||||
pixelWidth: this.canvas.width,
|
||||
pixelHeight: this.canvas.height
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mouse position relative to canvas
|
||||
*/
|
||||
getMousePosition(event) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (event.clientX - rect.left) * this.options.pixelRatio,
|
||||
y: (event.clientY - rect.top) * this.options.pixelRatio
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start animation loop
|
||||
*/
|
||||
startAnimation(animationFunction) {
|
||||
if (this.isAnimating) {
|
||||
this.stopAnimation();
|
||||
}
|
||||
|
||||
this.isAnimating = true;
|
||||
|
||||
const animate = (timestamp) => {
|
||||
if (!this.isAnimating) return;
|
||||
|
||||
animationFunction(timestamp);
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
Logger.info('[CanvasManager] Animation started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop animation loop
|
||||
*/
|
||||
stopAnimation() {
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
this.isAnimating = false;
|
||||
Logger.info('[CanvasManager] Animation stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup canvas manager
|
||||
*/
|
||||
destroy() {
|
||||
this.stopAnimation();
|
||||
// Event cleanup is handled by useEvent system
|
||||
Logger.info('[CanvasManager] Destroyed');
|
||||
}
|
||||
}
|
||||
464
resources/js/modules/canvas-animations/DataVisualization.js
Normal file
464
resources/js/modules/canvas-animations/DataVisualization.js
Normal file
@@ -0,0 +1,464 @@
|
||||
// modules/canvas-animations/DataVisualization.js
|
||||
import { CanvasManager } from './CanvasManager.js';
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Data Visualization Canvas Components - Animated charts and graphs
|
||||
*/
|
||||
export const DataVisualization = {
|
||||
|
||||
/**
|
||||
* Initialize data visualization canvas
|
||||
*/
|
||||
init(canvas, config) {
|
||||
const manager = new CanvasManager(canvas);
|
||||
const vizType = config.type || 'bar';
|
||||
|
||||
// Get data from config or data attributes
|
||||
const data = this.parseData(canvas, config);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
Logger.warn('[DataVisualization] No data provided for canvas');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (vizType) {
|
||||
case 'bar':
|
||||
this.createBarChart(manager, data, config);
|
||||
break;
|
||||
case 'line':
|
||||
this.createLineChart(manager, data, config);
|
||||
break;
|
||||
case 'pie':
|
||||
this.createPieChart(manager, data, config);
|
||||
break;
|
||||
case 'progress':
|
||||
this.createProgressChart(manager, data, config);
|
||||
break;
|
||||
default:
|
||||
this.createBarChart(manager, data, config);
|
||||
}
|
||||
|
||||
Logger.info('[DataVisualization] Initialized', vizType, 'chart with', data.length, 'data points');
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse data from canvas element or config
|
||||
*/
|
||||
parseData(canvas, config) {
|
||||
let data = config.data;
|
||||
|
||||
// Try to get data from data-canvas-data attribute
|
||||
if (!data && canvas.dataset.canvasData) {
|
||||
try {
|
||||
data = JSON.parse(canvas.dataset.canvasData);
|
||||
} catch (e) {
|
||||
Logger.warn('[DataVisualization] Failed to parse canvas data', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get data from script tag
|
||||
if (!data) {
|
||||
const scriptTag = canvas.nextElementSibling;
|
||||
if (scriptTag && scriptTag.tagName === 'SCRIPT' && scriptTag.type === 'application/json') {
|
||||
try {
|
||||
data = JSON.parse(scriptTag.textContent);
|
||||
} catch (e) {
|
||||
Logger.warn('[DataVisualization] Failed to parse script data', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure data is always an array
|
||||
if (!data) {
|
||||
Logger.warn('[DataVisualization] No data found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// If data is not an array, wrap it or convert it
|
||||
if (!Array.isArray(data)) {
|
||||
// If it's a single number (for progress charts)
|
||||
if (typeof data === 'number') {
|
||||
data = [{ value: data, label: 'Progress' }];
|
||||
}
|
||||
// If it's an object with values, convert to array
|
||||
else if (typeof data === 'object') {
|
||||
data = Object.entries(data).map(([key, value]) => ({
|
||||
label: key,
|
||||
value: typeof value === 'object' ? value.value || value : value
|
||||
}));
|
||||
}
|
||||
// Fallback: empty array
|
||||
else {
|
||||
Logger.warn('[DataVisualization] Data format not recognized:', data);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create animated bar chart
|
||||
*/
|
||||
createBarChart(manager, data, config) {
|
||||
const { ctx } = manager;
|
||||
const { width, height } = manager.getSize();
|
||||
|
||||
const padding = config.padding || 40;
|
||||
const chartWidth = width - padding * 2;
|
||||
const chartHeight = height - padding * 2;
|
||||
|
||||
const barWidth = chartWidth / data.length * 0.8;
|
||||
const barSpacing = chartWidth / data.length * 0.2;
|
||||
const maxValue = Math.max(...data.map(d => d.value));
|
||||
|
||||
let animationProgress = 0;
|
||||
const animationDuration = config.animationDuration || 2000;
|
||||
let startTime = null;
|
||||
|
||||
const animate = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = timestamp - startTime;
|
||||
|
||||
animationProgress = Math.min(elapsed / animationDuration, 1);
|
||||
|
||||
// Easing function (easeOutCubic)
|
||||
const progress = 1 - Math.pow(1 - animationProgress, 3);
|
||||
|
||||
manager.clear();
|
||||
this.renderBarChart(manager, data, config, progress, {
|
||||
padding,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
barWidth,
|
||||
barSpacing,
|
||||
maxValue
|
||||
});
|
||||
|
||||
if (animationProgress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
},
|
||||
|
||||
/**
|
||||
* Render bar chart frame
|
||||
*/
|
||||
renderBarChart(manager, data, config, progress, layout) {
|
||||
const { ctx } = manager;
|
||||
const { padding, chartHeight, barWidth, barSpacing, maxValue } = layout;
|
||||
|
||||
// Draw bars
|
||||
data.forEach((item, index) => {
|
||||
const x = padding + index * (barWidth + barSpacing);
|
||||
const barHeight = (item.value / maxValue) * chartHeight * progress;
|
||||
const y = padding + chartHeight - barHeight;
|
||||
|
||||
// Bar
|
||||
ctx.fillStyle = item.color || config.barColor || 'rgba(100, 150, 255, 0.8)';
|
||||
ctx.fillRect(x, y, barWidth, barHeight);
|
||||
|
||||
// Label
|
||||
if (config.showLabels !== false) {
|
||||
ctx.fillStyle = config.textColor || '#333';
|
||||
ctx.font = config.font || '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(
|
||||
item.label,
|
||||
x + barWidth / 2,
|
||||
padding + chartHeight + 20
|
||||
);
|
||||
|
||||
// Value
|
||||
if (config.showValues !== false) {
|
||||
ctx.fillText(
|
||||
Math.round(item.value * progress),
|
||||
x + barWidth / 2,
|
||||
y - 5
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Draw axes if enabled
|
||||
if (config.showAxes !== false) {
|
||||
ctx.strokeStyle = config.axisColor || '#ccc';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Y-axis
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, padding);
|
||||
ctx.lineTo(padding, padding + chartHeight);
|
||||
ctx.stroke();
|
||||
|
||||
// X-axis
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, padding + chartHeight);
|
||||
ctx.lineTo(padding + layout.chartWidth, padding + chartHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create animated line chart
|
||||
*/
|
||||
createLineChart(manager, data, config) {
|
||||
const { ctx } = manager;
|
||||
const { width, height } = manager.getSize();
|
||||
|
||||
const padding = config.padding || 40;
|
||||
const chartWidth = width - padding * 2;
|
||||
const chartHeight = height - padding * 2;
|
||||
|
||||
const maxValue = Math.max(...data.map(d => d.value));
|
||||
const minValue = Math.min(...data.map(d => d.value));
|
||||
const valueRange = maxValue - minValue;
|
||||
|
||||
let animationProgress = 0;
|
||||
const animationDuration = config.animationDuration || 2000;
|
||||
let startTime = null;
|
||||
|
||||
const animate = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = timestamp - startTime;
|
||||
|
||||
animationProgress = Math.min(elapsed / animationDuration, 1);
|
||||
|
||||
manager.clear();
|
||||
this.renderLineChart(manager, data, config, animationProgress, {
|
||||
padding,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
maxValue,
|
||||
minValue,
|
||||
valueRange
|
||||
});
|
||||
|
||||
if (animationProgress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
},
|
||||
|
||||
/**
|
||||
* Render line chart frame
|
||||
*/
|
||||
renderLineChart(manager, data, config, progress, layout) {
|
||||
const { ctx } = manager;
|
||||
const { padding, chartWidth, chartHeight, minValue, valueRange } = layout;
|
||||
|
||||
const pointsToShow = Math.floor(data.length * progress);
|
||||
|
||||
ctx.strokeStyle = config.lineColor || 'rgba(100, 150, 255, 0.8)';
|
||||
ctx.lineWidth = config.lineWidth || 3;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Draw line
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= pointsToShow && i < data.length; i++) {
|
||||
const x = padding + (i / (data.length - 1)) * chartWidth;
|
||||
const y = padding + chartHeight - ((data[i].value - minValue) / valueRange) * chartHeight;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Draw points
|
||||
if (config.showPoints !== false) {
|
||||
ctx.fillStyle = config.pointColor || config.lineColor || 'rgba(100, 150, 255, 0.8)';
|
||||
|
||||
for (let i = 0; i <= pointsToShow && i < data.length; i++) {
|
||||
const x = padding + (i / (data.length - 1)) * chartWidth;
|
||||
const y = padding + chartHeight - ((data[i].value - minValue) / valueRange) * chartHeight;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, config.pointSize || 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Labels
|
||||
if (config.showLabels !== false && data[i].label) {
|
||||
ctx.fillStyle = config.textColor || '#333';
|
||||
ctx.font = config.font || '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(data[i].label, x, padding + chartHeight + 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create animated pie chart
|
||||
*/
|
||||
createPieChart(manager, data, config) {
|
||||
const { ctx } = manager;
|
||||
const { width, height } = manager.getSize();
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const radius = Math.min(width, height) / 2 * 0.8;
|
||||
|
||||
const total = data.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
let animationProgress = 0;
|
||||
const animationDuration = config.animationDuration || 2000;
|
||||
let startTime = null;
|
||||
|
||||
const animate = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = timestamp - startTime;
|
||||
|
||||
animationProgress = Math.min(elapsed / animationDuration, 1);
|
||||
|
||||
manager.clear();
|
||||
this.renderPieChart(manager, data, config, animationProgress, {
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
total
|
||||
});
|
||||
|
||||
if (animationProgress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
},
|
||||
|
||||
/**
|
||||
* Render pie chart frame
|
||||
*/
|
||||
renderPieChart(manager, data, config, progress, layout) {
|
||||
const { ctx } = manager;
|
||||
const { centerX, centerY, radius, total } = layout;
|
||||
|
||||
let currentAngle = -Math.PI / 2; // Start at top
|
||||
const maxAngle = currentAngle + (Math.PI * 2 * progress);
|
||||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
if (currentAngle >= maxAngle) break;
|
||||
|
||||
const item = data[index];
|
||||
const sliceAngle = (item.value / total) * Math.PI * 2;
|
||||
const endAngle = Math.min(currentAngle + sliceAngle, maxAngle);
|
||||
|
||||
if (endAngle > currentAngle) {
|
||||
// Draw slice
|
||||
ctx.fillStyle = item.color || this.getDefaultColor(index);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.arc(centerX, centerY, radius, currentAngle, endAngle);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Draw labels if enabled
|
||||
if (config.showLabels !== false && endAngle === currentAngle + sliceAngle) {
|
||||
const labelAngle = currentAngle + sliceAngle / 2;
|
||||
const labelX = centerX + Math.cos(labelAngle) * radius * 0.7;
|
||||
const labelY = centerY + Math.sin(labelAngle) * radius * 0.7;
|
||||
|
||||
ctx.fillStyle = config.textColor || '#333';
|
||||
ctx.font = config.font || '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(item.label, labelX, labelY);
|
||||
}
|
||||
}
|
||||
|
||||
currentAngle += sliceAngle;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create progress visualization
|
||||
*/
|
||||
createProgressChart(manager, data, config) {
|
||||
const { ctx } = manager;
|
||||
const { width, height } = manager.getSize();
|
||||
|
||||
let animationProgress = 0;
|
||||
const animationDuration = config.animationDuration || 1500;
|
||||
let startTime = null;
|
||||
|
||||
const animate = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = timestamp - startTime;
|
||||
|
||||
animationProgress = Math.min(elapsed / animationDuration, 1);
|
||||
|
||||
manager.clear();
|
||||
this.renderProgressChart(manager, data, config, animationProgress);
|
||||
|
||||
if (animationProgress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
},
|
||||
|
||||
/**
|
||||
* Render progress chart
|
||||
*/
|
||||
renderProgressChart(manager, data, config, progress) {
|
||||
const { ctx } = manager;
|
||||
const { width, height } = manager.getSize();
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const radius = Math.min(width, height) / 2 * 0.8;
|
||||
const lineWidth = config.lineWidth || 20;
|
||||
|
||||
// Get progress value from data array
|
||||
const value = data[0]?.value || 0;
|
||||
const maxValue = config.maxValue || 100;
|
||||
const progressValue = (value / maxValue) * progress;
|
||||
|
||||
// Background circle
|
||||
ctx.strokeStyle = config.backgroundColor || 'rgba(200, 200, 200, 0.3)';
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Progress arc
|
||||
ctx.strokeStyle = config.color || 'rgba(100, 150, 255, 0.8)';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + (progressValue / maxValue) * Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Center text
|
||||
if (config.showText !== false) {
|
||||
ctx.fillStyle = config.textColor || '#333';
|
||||
ctx.font = config.font || 'bold 24px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(Math.round(progressValue) + '%', centerX, centerY);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get default color for data point
|
||||
*/
|
||||
getDefaultColor(index) {
|
||||
const colors = [
|
||||
'rgba(100, 150, 255, 0.8)',
|
||||
'rgba(255, 100, 150, 0.8)',
|
||||
'rgba(150, 255, 100, 0.8)',
|
||||
'rgba(255, 200, 100, 0.8)',
|
||||
'rgba(200, 100, 255, 0.8)',
|
||||
'rgba(100, 255, 200, 0.8)'
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
};
|
||||
335
resources/js/modules/canvas-animations/InteractiveEffects.js
Normal file
335
resources/js/modules/canvas-animations/InteractiveEffects.js
Normal file
@@ -0,0 +1,335 @@
|
||||
// modules/canvas-animations/InteractiveEffects.js
|
||||
import { CanvasManager } from './CanvasManager.js';
|
||||
import { useEvent } from '../../core/useEvent.js';
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Interactive Canvas Effects - Mouse/touch interactions, hover effects
|
||||
*/
|
||||
export const InteractiveEffects = {
|
||||
|
||||
/**
|
||||
* Initialize interactive canvas
|
||||
*/
|
||||
init(canvas, config) {
|
||||
const manager = new CanvasManager(canvas);
|
||||
const effectType = config.effect || 'ripple';
|
||||
|
||||
const state = {
|
||||
mouse: { x: 0, y: 0, isOver: false },
|
||||
effects: [],
|
||||
lastTime: 0
|
||||
};
|
||||
|
||||
this.setupInteractionEvents(canvas, manager, state, config);
|
||||
this.startAnimationLoop(manager, state, effectType, config);
|
||||
|
||||
Logger.info('[InteractiveEffects] Initialized with effect:', effectType);
|
||||
},
|
||||
|
||||
/**
|
||||
* Setup mouse/touch interaction events
|
||||
*/
|
||||
setupInteractionEvents(canvas, manager, state, config) {
|
||||
// Mouse events
|
||||
useEvent(canvas, 'mousemove', (e) => {
|
||||
const pos = manager.getMousePosition(e);
|
||||
state.mouse.x = pos.x;
|
||||
state.mouse.y = pos.y;
|
||||
|
||||
if (config.effect === 'trail') {
|
||||
this.addTrailPoint(state, pos.x, pos.y);
|
||||
}
|
||||
}, 'interactive-effects');
|
||||
|
||||
useEvent(canvas, 'mouseenter', () => {
|
||||
state.mouse.isOver = true;
|
||||
}, 'interactive-effects');
|
||||
|
||||
useEvent(canvas, 'mouseleave', () => {
|
||||
state.mouse.isOver = false;
|
||||
}, 'interactive-effects');
|
||||
|
||||
useEvent(canvas, 'click', (e) => {
|
||||
const pos = manager.getMousePosition(e);
|
||||
this.addClickEffect(state, pos.x, pos.y, config);
|
||||
}, 'interactive-effects');
|
||||
|
||||
// Touch events for mobile
|
||||
useEvent(canvas, 'touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const pos = manager.getMousePosition(touch);
|
||||
this.addClickEffect(state, pos.x, pos.y, config);
|
||||
}, 'interactive-effects');
|
||||
|
||||
useEvent(canvas, 'touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const pos = manager.getMousePosition(touch);
|
||||
state.mouse.x = pos.x;
|
||||
state.mouse.y = pos.y;
|
||||
state.mouse.isOver = true;
|
||||
|
||||
if (config.effect === 'trail') {
|
||||
this.addTrailPoint(state, pos.x, pos.y);
|
||||
}
|
||||
}, 'interactive-effects');
|
||||
|
||||
useEvent(canvas, 'touchend', () => {
|
||||
state.mouse.isOver = false;
|
||||
}, 'interactive-effects');
|
||||
},
|
||||
|
||||
/**
|
||||
* Start animation loop
|
||||
*/
|
||||
startAnimationLoop(manager, state, effectType, config) {
|
||||
const animate = (timestamp) => {
|
||||
const deltaTime = timestamp - state.lastTime;
|
||||
state.lastTime = timestamp;
|
||||
|
||||
manager.clear();
|
||||
|
||||
switch (effectType) {
|
||||
case 'ripple':
|
||||
this.renderRippleEffect(manager, state, config);
|
||||
break;
|
||||
case 'trail':
|
||||
this.renderTrailEffect(manager, state, config);
|
||||
break;
|
||||
case 'particles':
|
||||
this.renderParticleEffect(manager, state, config, deltaTime);
|
||||
break;
|
||||
case 'magnetic':
|
||||
this.renderMagneticEffect(manager, state, config);
|
||||
break;
|
||||
default:
|
||||
this.renderRippleEffect(manager, state, config);
|
||||
}
|
||||
|
||||
// Update effects
|
||||
this.updateEffects(state.effects, deltaTime);
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add click effect (ripple, explosion, etc.)
|
||||
*/
|
||||
addClickEffect(state, x, y, config) {
|
||||
const effect = {
|
||||
x,
|
||||
y,
|
||||
age: 0,
|
||||
maxAge: config.duration || 1000,
|
||||
type: 'click',
|
||||
intensity: config.intensity || 1
|
||||
};
|
||||
|
||||
state.effects.push(effect);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add trail point for mouse trail effect
|
||||
*/
|
||||
addTrailPoint(state, x, y) {
|
||||
const point = {
|
||||
x,
|
||||
y,
|
||||
age: 0,
|
||||
maxAge: 500,
|
||||
type: 'trail'
|
||||
};
|
||||
|
||||
state.effects.push(point);
|
||||
|
||||
// Limit trail length
|
||||
const trailLength = 20;
|
||||
const trailPoints = state.effects.filter(e => e.type === 'trail');
|
||||
if (trailPoints.length > trailLength) {
|
||||
const oldestIndex = state.effects.indexOf(trailPoints[0]);
|
||||
state.effects.splice(oldestIndex, 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render ripple effect
|
||||
*/
|
||||
renderRippleEffect(manager, state, config) {
|
||||
const { ctx } = manager;
|
||||
|
||||
// Draw active ripples
|
||||
state.effects.forEach(effect => {
|
||||
if (effect.type === 'click') {
|
||||
const progress = effect.age / effect.maxAge;
|
||||
const radius = progress * (config.maxRadius || 100);
|
||||
const opacity = (1 - progress) * 0.8;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.strokeStyle = config.color || 'rgba(100, 150, 255, 1)';
|
||||
ctx.lineWidth = config.lineWidth || 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(effect.x / manager.options.pixelRatio, effect.y / manager.options.pixelRatio, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// Draw hover effect
|
||||
if (state.mouse.isOver) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = config.hoverColor || 'rgba(100, 150, 255, 0.3)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
state.mouse.x / manager.options.pixelRatio,
|
||||
state.mouse.y / manager.options.pixelRatio,
|
||||
config.hoverRadius || 30,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render trail effect
|
||||
*/
|
||||
renderTrailEffect(manager, state, config) {
|
||||
const { ctx } = manager;
|
||||
const trailPoints = state.effects.filter(e => e.type === 'trail');
|
||||
|
||||
if (trailPoints.length < 2) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = config.color || 'rgba(100, 150, 255, 0.8)';
|
||||
ctx.lineWidth = config.lineWidth || 5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Draw trail path
|
||||
ctx.beginPath();
|
||||
trailPoints.forEach((point, index) => {
|
||||
const progress = 1 - (point.age / point.maxAge);
|
||||
const x = point.x / manager.options.pixelRatio;
|
||||
const y = point.y / manager.options.pixelRatio;
|
||||
|
||||
ctx.globalAlpha = progress * 0.8;
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
/**
|
||||
* Render particle effect
|
||||
*/
|
||||
renderParticleEffect(manager, state, config, deltaTime) {
|
||||
const { ctx } = manager;
|
||||
|
||||
// Spawn particles on mouse move
|
||||
if (state.mouse.isOver && Math.random() < 0.1) {
|
||||
const particle = {
|
||||
x: state.mouse.x,
|
||||
y: state.mouse.y,
|
||||
vx: (Math.random() - 0.5) * 4,
|
||||
vy: (Math.random() - 0.5) * 4,
|
||||
age: 0,
|
||||
maxAge: 1000,
|
||||
size: Math.random() * 5 + 2,
|
||||
type: 'particle'
|
||||
};
|
||||
state.effects.push(particle);
|
||||
}
|
||||
|
||||
// Update and draw particles
|
||||
state.effects.forEach(effect => {
|
||||
if (effect.type === 'particle') {
|
||||
// Update position
|
||||
effect.x += effect.vx;
|
||||
effect.y += effect.vy;
|
||||
effect.vy += 0.1; // Gravity
|
||||
|
||||
const progress = effect.age / effect.maxAge;
|
||||
const opacity = (1 - progress) * 0.8;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillStyle = config.color || 'rgba(100, 150, 255, 1)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
effect.x / manager.options.pixelRatio,
|
||||
effect.y / manager.options.pixelRatio,
|
||||
effect.size * (1 - progress * 0.5),
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Render magnetic effect
|
||||
*/
|
||||
renderMagneticEffect(manager, state, config) {
|
||||
const { ctx } = manager;
|
||||
const { width, height } = manager.getSize();
|
||||
|
||||
if (!state.mouse.isOver) return;
|
||||
|
||||
// Draw magnetic field lines
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const mouseX = state.mouse.x / manager.options.pixelRatio;
|
||||
const mouseY = state.mouse.y / manager.options.pixelRatio;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = config.color || 'rgba(100, 150, 255, 0.6)';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
const lines = 8;
|
||||
for (let i = 0; i < lines; i++) {
|
||||
const angle = (i / lines) * Math.PI * 2;
|
||||
const startX = centerX + Math.cos(angle) * 50;
|
||||
const startY = centerY + Math.sin(angle) * 50;
|
||||
|
||||
// Curve towards mouse
|
||||
const controlX = (startX + mouseX) / 2 + Math.sin(angle) * 30;
|
||||
const controlY = (startY + mouseY) / 2 + Math.cos(angle) * 30;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, startY);
|
||||
ctx.quadraticCurveTo(controlX, controlY, mouseX, mouseY);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update all effects (age and cleanup)
|
||||
*/
|
||||
updateEffects(effects, deltaTime) {
|
||||
for (let i = effects.length - 1; i >= 0; i--) {
|
||||
effects[i].age += deltaTime;
|
||||
|
||||
if (effects[i].age >= effects[i].maxAge) {
|
||||
effects.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
244
resources/js/modules/canvas-animations/ScrollEffects.js
Normal file
244
resources/js/modules/canvas-animations/ScrollEffects.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// modules/canvas-animations/ScrollEffects.js
|
||||
import { CanvasManager } from './CanvasManager.js';
|
||||
import { useEvent } from '../../core/useEvent.js';
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* Scroll-based Canvas Effects - Parallax and scroll animations
|
||||
*/
|
||||
export const ScrollEffects = {
|
||||
|
||||
/**
|
||||
* Initialize parallax canvas effect
|
||||
*/
|
||||
initParallax(canvas, config) {
|
||||
const manager = new CanvasManager(canvas);
|
||||
const elements = this.createParallaxElements(canvas, config);
|
||||
|
||||
let ticking = false;
|
||||
|
||||
const updateParallax = () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
this.renderParallax(manager, elements, config);
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Listen to scroll events
|
||||
useEvent(window, 'scroll', updateParallax, 'scroll-parallax');
|
||||
useEvent(window, 'resize', updateParallax, 'scroll-parallax');
|
||||
|
||||
// Initial render
|
||||
updateParallax();
|
||||
|
||||
Logger.info('[ScrollEffects] Parallax initialized with', elements.length, 'elements');
|
||||
},
|
||||
|
||||
/**
|
||||
* Create parallax elements based on config
|
||||
*/
|
||||
createParallaxElements(canvas, config) {
|
||||
const elements = [];
|
||||
const layerCount = config.layers || 3;
|
||||
const elementCount = config.elements || 20;
|
||||
|
||||
for (let i = 0; i < elementCount; i++) {
|
||||
elements.push({
|
||||
x: Math.random() * canvas.clientWidth,
|
||||
y: Math.random() * canvas.clientHeight * 2, // Allow elements outside viewport
|
||||
size: Math.random() * 20 + 5,
|
||||
layer: Math.floor(Math.random() * layerCount),
|
||||
speed: 0.1 + (Math.random() * 0.5), // Different parallax speeds
|
||||
opacity: Math.random() * 0.7 + 0.3,
|
||||
color: this.getLayerColor(Math.floor(Math.random() * layerCount), config)
|
||||
});
|
||||
}
|
||||
|
||||
return elements;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get color for parallax layer
|
||||
*/
|
||||
getLayerColor(layer, config) {
|
||||
const colors = config.colors || [
|
||||
'rgba(100, 150, 255, 0.6)', // Front layer - more opaque
|
||||
'rgba(150, 100, 255, 0.4)', // Middle layer
|
||||
'rgba(200, 100, 150, 0.2)' // Back layer - more transparent
|
||||
];
|
||||
return colors[layer] || colors[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* Render parallax effect
|
||||
*/
|
||||
renderParallax(manager, elements, config) {
|
||||
manager.clear();
|
||||
|
||||
const scrollY = window.pageYOffset;
|
||||
const canvasRect = manager.canvas.getBoundingClientRect();
|
||||
const canvasTop = canvasRect.top + scrollY;
|
||||
|
||||
// Calculate relative scroll position
|
||||
const relativeScroll = scrollY - canvasTop;
|
||||
const scrollProgress = relativeScroll / window.innerHeight;
|
||||
|
||||
elements.forEach(element => {
|
||||
// Apply parallax offset based on layer and scroll
|
||||
const parallaxOffset = scrollProgress * element.speed * 100;
|
||||
const y = element.y - parallaxOffset;
|
||||
|
||||
// Only render elements that are potentially visible
|
||||
if (y > -element.size && y < manager.canvas.clientHeight + element.size) {
|
||||
manager.ctx.save();
|
||||
manager.ctx.globalAlpha = element.opacity;
|
||||
manager.ctx.fillStyle = element.color;
|
||||
manager.ctx.beginPath();
|
||||
manager.ctx.arc(element.x, y, element.size, 0, Math.PI * 2);
|
||||
manager.ctx.fill();
|
||||
manager.ctx.restore();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize scroll-based animations
|
||||
*/
|
||||
initScrollAnimation(canvas, config) {
|
||||
const manager = new CanvasManager(canvas);
|
||||
const animationType = config.animation || 'wave';
|
||||
|
||||
let ticking = false;
|
||||
|
||||
const updateAnimation = () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
this.renderScrollAnimation(manager, animationType, config);
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
useEvent(window, 'scroll', updateAnimation, 'scroll-animation');
|
||||
useEvent(window, 'resize', updateAnimation, 'scroll-animation');
|
||||
|
||||
// Initial render
|
||||
updateAnimation();
|
||||
|
||||
Logger.info('[ScrollEffects] Scroll animation initialized:', animationType);
|
||||
},
|
||||
|
||||
/**
|
||||
* Render scroll-based animations
|
||||
*/
|
||||
renderScrollAnimation(manager, animationType, config) {
|
||||
manager.clear();
|
||||
|
||||
const scrollY = window.pageYOffset;
|
||||
const canvasRect = manager.canvas.getBoundingClientRect();
|
||||
const canvasTop = canvasRect.top + scrollY;
|
||||
const relativeScroll = scrollY - canvasTop;
|
||||
const scrollProgress = Math.max(0, Math.min(1, relativeScroll / window.innerHeight));
|
||||
|
||||
switch (animationType) {
|
||||
case 'wave':
|
||||
this.renderWaveAnimation(manager, scrollProgress, config);
|
||||
break;
|
||||
case 'progress':
|
||||
this.renderProgressAnimation(manager, scrollProgress, config);
|
||||
break;
|
||||
case 'morph':
|
||||
this.renderMorphAnimation(manager, scrollProgress, config);
|
||||
break;
|
||||
default:
|
||||
this.renderWaveAnimation(manager, scrollProgress, config);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render wave animation based on scroll
|
||||
*/
|
||||
renderWaveAnimation(manager, progress, config) {
|
||||
const { ctx } = manager;
|
||||
const { width, height } = manager.getSize();
|
||||
|
||||
ctx.strokeStyle = config.color || 'rgba(100, 150, 255, 0.8)';
|
||||
ctx.lineWidth = config.lineWidth || 3;
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
const amplitude = (config.amplitude || 50) * progress;
|
||||
const frequency = config.frequency || 0.02;
|
||||
const phase = progress * Math.PI * 2;
|
||||
|
||||
for (let x = 0; x <= width; x += 2) {
|
||||
const y = height / 2 + Math.sin(x * frequency + phase) * amplitude;
|
||||
|
||||
if (x === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
/**
|
||||
* Render progress bar animation
|
||||
*/
|
||||
renderProgressAnimation(manager, progress, config) {
|
||||
const { ctx } = manager;
|
||||
const { width, height } = manager.getSize();
|
||||
|
||||
const barHeight = config.barHeight || 10;
|
||||
const y = height / 2 - barHeight / 2;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = config.backgroundColor || 'rgba(255, 255, 255, 0.2)';
|
||||
ctx.fillRect(0, y, width, barHeight);
|
||||
|
||||
// Progress
|
||||
ctx.fillStyle = config.color || 'rgba(100, 150, 255, 0.8)';
|
||||
ctx.fillRect(0, y, width * progress, barHeight);
|
||||
},
|
||||
|
||||
/**
|
||||
* Render morphing shapes
|
||||
*/
|
||||
renderMorphAnimation(manager, progress, config) {
|
||||
const { ctx } = manager;
|
||||
const { width, height } = manager.getSize();
|
||||
|
||||
ctx.fillStyle = config.color || 'rgba(100, 150, 255, 0.6)';
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const maxRadius = Math.min(width, height) / 3;
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
const points = config.points || 6;
|
||||
for (let i = 0; i <= points; i++) {
|
||||
const angle = (i / points) * Math.PI * 2;
|
||||
const radiusVariation = Math.sin(progress * Math.PI * 4 + angle * 3) * 0.3 + 1;
|
||||
const radius = maxRadius * progress * radiusVariation;
|
||||
|
||||
const x = centerX + Math.cos(angle) * radius;
|
||||
const y = centerY + Math.sin(angle) * radius;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
153
resources/js/modules/canvas-animations/index.js
Normal file
153
resources/js/modules/canvas-animations/index.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// modules/canvas-animations/index.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
import { CanvasManager } from './CanvasManager.js';
|
||||
import { ScrollEffects } from './ScrollEffects.js';
|
||||
import { InteractiveEffects } from './InteractiveEffects.js';
|
||||
import { DataVisualization } from './DataVisualization.js';
|
||||
|
||||
const CanvasAnimationsModule = {
|
||||
name: 'canvas-animations',
|
||||
|
||||
// Module-level init (called by module system)
|
||||
init(config = {}, state = null) {
|
||||
Logger.info('[CanvasAnimations] Module initialized');
|
||||
this.initializeCanvasElements();
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize all canvas elements with data-canvas attributes
|
||||
*/
|
||||
initializeCanvasElements() {
|
||||
// Auto-discover canvas elements
|
||||
const canvasElements = document.querySelectorAll('canvas[data-canvas-type]');
|
||||
|
||||
canvasElements.forEach(canvas => {
|
||||
this.initElement(canvas);
|
||||
});
|
||||
|
||||
Logger.info(`[CanvasAnimations] Initialized ${canvasElements.length} canvas elements`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize individual canvas element
|
||||
*/
|
||||
initElement(canvas, options = {}) {
|
||||
const canvasType = canvas.dataset.canvasType;
|
||||
const canvasConfig = this.parseCanvasConfig(canvas);
|
||||
|
||||
Logger.info(`[CanvasAnimations] Initializing canvas type: ${canvasType}`);
|
||||
|
||||
switch (canvasType) {
|
||||
case 'interactive':
|
||||
InteractiveEffects.init(canvas, canvasConfig);
|
||||
break;
|
||||
case 'scroll-parallax':
|
||||
ScrollEffects.initParallax(canvas, canvasConfig);
|
||||
break;
|
||||
case 'scroll-animation':
|
||||
ScrollEffects.initScrollAnimation(canvas, canvasConfig);
|
||||
break;
|
||||
case 'data-viz':
|
||||
DataVisualization.init(canvas, canvasConfig);
|
||||
break;
|
||||
case 'background':
|
||||
this.initBackgroundAnimation(canvas, canvasConfig);
|
||||
break;
|
||||
default:
|
||||
Logger.warn(`[CanvasAnimations] Unknown canvas type: ${canvasType}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse configuration from data attributes
|
||||
*/
|
||||
parseCanvasConfig(canvas) {
|
||||
const config = {};
|
||||
|
||||
// Parse all data-canvas-* attributes
|
||||
Object.keys(canvas.dataset).forEach(key => {
|
||||
if (key.startsWith('canvas')) {
|
||||
const configKey = key.replace('canvas', '').toLowerCase();
|
||||
let value = canvas.dataset[key];
|
||||
|
||||
// Try to parse as JSON if it looks like an object/array
|
||||
if ((value.startsWith('{') && value.endsWith('}')) ||
|
||||
(value.startsWith('[') && value.endsWith(']'))) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
Logger.warn(`[CanvasAnimations] Failed to parse JSON config: ${key}`, e);
|
||||
}
|
||||
}
|
||||
// Parse boolean strings
|
||||
else if (value === 'true') value = true;
|
||||
else if (value === 'false') value = false;
|
||||
// Parse numbers
|
||||
else if (!isNaN(value) && value !== '') {
|
||||
value = parseFloat(value);
|
||||
}
|
||||
|
||||
config[configKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize background animation
|
||||
*/
|
||||
initBackgroundAnimation(canvas, config) {
|
||||
const manager = new CanvasManager(canvas);
|
||||
|
||||
// Default background animation: floating particles
|
||||
const particleCount = config.particles || 50;
|
||||
const particles = [];
|
||||
|
||||
// Create particles
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
vx: (Math.random() - 0.5) * 0.5,
|
||||
vy: (Math.random() - 0.5) * 0.5,
|
||||
size: Math.random() * 3 + 1,
|
||||
opacity: Math.random() * 0.5 + 0.2
|
||||
});
|
||||
}
|
||||
|
||||
// Animation loop
|
||||
function animate() {
|
||||
manager.clear();
|
||||
manager.ctx.fillStyle = config.color || 'rgba(100, 150, 255, 0.6)';
|
||||
|
||||
particles.forEach(particle => {
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
|
||||
// Wrap around edges
|
||||
if (particle.x < 0) particle.x = canvas.width;
|
||||
if (particle.x > canvas.width) particle.x = 0;
|
||||
if (particle.y < 0) particle.y = canvas.height;
|
||||
if (particle.y > canvas.height) particle.y = 0;
|
||||
|
||||
// Draw particle
|
||||
manager.ctx.save();
|
||||
manager.ctx.globalAlpha = particle.opacity;
|
||||
manager.ctx.beginPath();
|
||||
manager.ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
||||
manager.ctx.fill();
|
||||
manager.ctx.restore();
|
||||
});
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
animate();
|
||||
}
|
||||
};
|
||||
|
||||
// Export für module system
|
||||
export const init = CanvasAnimationsModule.init.bind(CanvasAnimationsModule);
|
||||
export default CanvasAnimationsModule;
|
||||
377
resources/js/modules/csrf-auto-refresh.js
Normal file
377
resources/js/modules/csrf-auto-refresh.js
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* CSRF Token Auto-Refresh System
|
||||
*
|
||||
* Automatically refreshes CSRF tokens before they expire to prevent
|
||||
* form submission errors when users keep forms open for extended periods.
|
||||
*
|
||||
* Features:
|
||||
* - Automatic token refresh every 105 minutes (15 minutes before expiry)
|
||||
* - Visual feedback when tokens are refreshed
|
||||
* - Graceful error handling with fallback strategies
|
||||
* - Page visibility optimization (pause when tab is inactive)
|
||||
* - Multiple form support
|
||||
*
|
||||
* Usage:
|
||||
* import { CsrfAutoRefresh } from './modules/csrf-auto-refresh.js';
|
||||
*
|
||||
* // Initialize for contact form
|
||||
* const csrfRefresh = new CsrfAutoRefresh({
|
||||
* formId: 'contact_form',
|
||||
* refreshInterval: 105 * 60 * 1000 // 105 minutes
|
||||
* });
|
||||
*
|
||||
* // Or auto-detect all forms with CSRF tokens
|
||||
* CsrfAutoRefresh.initializeAll();
|
||||
*/
|
||||
|
||||
export class CsrfAutoRefresh {
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
static DEFAULT_CONFIG = {
|
||||
formId: 'contact_form',
|
||||
refreshInterval: 105 * 60 * 1000, // 105 minutes (15 min before 2h expiry)
|
||||
apiEndpoint: '/api/csrf/refresh',
|
||||
tokenSelector: 'input[name="_token"]',
|
||||
formIdSelector: 'input[name="_form_id"]',
|
||||
enableVisualFeedback: true,
|
||||
enableConsoleLogging: true,
|
||||
pauseWhenHidden: true, // Pause refresh when tab is not visible
|
||||
maxRetries: 3,
|
||||
retryDelay: 5000 // 5 seconds
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} config Configuration options
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
this.config = { ...CsrfAutoRefresh.DEFAULT_CONFIG, ...config };
|
||||
this.intervalId = null;
|
||||
this.isActive = false;
|
||||
this.retryCount = 0;
|
||||
this.lastRefreshTime = null;
|
||||
|
||||
// Track page visibility for optimization
|
||||
this.isPageVisible = !document.hidden;
|
||||
|
||||
this.log('CSRF Auto-Refresh initialized', this.config);
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Start auto-refresh if page is visible
|
||||
if (this.isPageVisible) {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for page visibility and unload
|
||||
*/
|
||||
setupEventListeners() {
|
||||
if (this.config.pauseWhenHidden) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
this.isPageVisible = !document.hidden;
|
||||
|
||||
if (this.isPageVisible) {
|
||||
this.log('Page became visible, resuming CSRF refresh');
|
||||
this.start();
|
||||
} else {
|
||||
this.log('Page became hidden, pausing CSRF refresh');
|
||||
this.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.stop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the auto-refresh timer
|
||||
*/
|
||||
start() {
|
||||
if (this.isActive) {
|
||||
this.log('Auto-refresh already active');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isActive = true;
|
||||
|
||||
// Set up interval for token refresh
|
||||
this.intervalId = setInterval(() => {
|
||||
this.refreshToken();
|
||||
}, this.config.refreshInterval);
|
||||
|
||||
this.log(`Auto-refresh started. Next refresh in ${this.config.refreshInterval / 1000 / 60} minutes`);
|
||||
|
||||
// Show visual feedback if enabled
|
||||
if (this.config.enableVisualFeedback) {
|
||||
this.showStatusMessage('CSRF protection enabled - tokens will refresh automatically', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the auto-refresh timer
|
||||
*/
|
||||
stop() {
|
||||
if (!this.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
this.isActive = false;
|
||||
this.log('Auto-refresh stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the CSRF token via API call
|
||||
*/
|
||||
async refreshToken() {
|
||||
if (!this.isPageVisible && this.config.pauseWhenHidden) {
|
||||
this.log('Page not visible, skipping refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.log('Refreshing CSRF token...');
|
||||
|
||||
const response = await fetch(`${this.config.apiEndpoint}?form_id=${encodeURIComponent(this.config.formId)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success || !data.token) {
|
||||
throw new Error(data.error || 'Invalid response from server');
|
||||
}
|
||||
|
||||
// Update token in all matching forms
|
||||
this.updateTokenInForms(data.token);
|
||||
|
||||
this.lastRefreshTime = new Date();
|
||||
this.retryCount = 0; // Reset retry count on success
|
||||
|
||||
this.log('CSRF token refreshed successfully', {
|
||||
token: data.token.substring(0, 8) + '...',
|
||||
formId: data.form_id,
|
||||
expiresIn: data.expires_in
|
||||
});
|
||||
|
||||
// Show visual feedback
|
||||
if (this.config.enableVisualFeedback) {
|
||||
this.showStatusMessage('Security token refreshed', 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.handleRefreshError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update CSRF token in all forms on the page
|
||||
*/
|
||||
updateTokenInForms(newToken) {
|
||||
const tokenInputs = document.querySelectorAll(this.config.tokenSelector);
|
||||
let updatedCount = 0;
|
||||
|
||||
tokenInputs.forEach(input => {
|
||||
// Check if this token belongs to our form
|
||||
const form = input.closest('form');
|
||||
if (form) {
|
||||
const formIdInput = form.querySelector(this.config.formIdSelector);
|
||||
if (formIdInput && formIdInput.value === this.config.formId) {
|
||||
input.value = newToken;
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.log(`Updated ${updatedCount} token input(s)`);
|
||||
|
||||
if (updatedCount === 0) {
|
||||
console.warn('CsrfAutoRefresh: No token inputs found to update. Check your selectors.');
|
||||
}
|
||||
|
||||
return updatedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle refresh errors with retry logic
|
||||
*/
|
||||
handleRefreshError(error) {
|
||||
this.retryCount++;
|
||||
|
||||
console.error('CSRF token refresh failed:', error);
|
||||
|
||||
if (this.retryCount <= this.config.maxRetries) {
|
||||
this.log(`Retrying in ${this.config.retryDelay / 1000}s (attempt ${this.retryCount}/${this.config.maxRetries})`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.refreshToken();
|
||||
}, this.config.retryDelay);
|
||||
|
||||
// Show visual feedback for retry
|
||||
if (this.config.enableVisualFeedback) {
|
||||
this.showStatusMessage(`Token refresh failed, retrying... (${this.retryCount}/${this.config.maxRetries})`, 'warning');
|
||||
}
|
||||
} else {
|
||||
// Max retries reached
|
||||
this.log('Max retries reached, stopping auto-refresh');
|
||||
this.stop();
|
||||
|
||||
if (this.config.enableVisualFeedback) {
|
||||
this.showStatusMessage('Token refresh failed. Please refresh the page if you encounter errors.', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show visual status message to user
|
||||
*/
|
||||
showStatusMessage(message, type = 'info') {
|
||||
// Create or update status element
|
||||
let statusEl = document.getElementById('csrf-status-message');
|
||||
|
||||
if (!statusEl) {
|
||||
statusEl = document.createElement('div');
|
||||
statusEl.id = 'csrf-status-message';
|
||||
statusEl.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
z-index: 10000;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
transition: opacity 0.3s ease;
|
||||
`;
|
||||
document.body.appendChild(statusEl);
|
||||
}
|
||||
|
||||
// Set message and styling based on type
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = `csrf-status-${type}`;
|
||||
|
||||
// Style based on type
|
||||
const styles = {
|
||||
info: 'background: #e3f2fd; color: #1976d2; border-left: 4px solid #2196f3;',
|
||||
success: 'background: #e8f5e8; color: #2e7d32; border-left: 4px solid #4caf50;',
|
||||
warning: 'background: #fff3e0; color: #f57c00; border-left: 4px solid #ff9800;',
|
||||
error: 'background: #ffebee; color: #d32f2f; border-left: 4px solid #f44336;'
|
||||
};
|
||||
|
||||
statusEl.style.cssText += styles[type] || styles.info;
|
||||
statusEl.style.opacity = '1';
|
||||
|
||||
// Auto-hide after delay (except for errors)
|
||||
if (type !== 'error') {
|
||||
setTimeout(() => {
|
||||
if (statusEl) {
|
||||
statusEl.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (statusEl && statusEl.parentNode) {
|
||||
statusEl.parentNode.removeChild(statusEl);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}, type === 'success' ? 3000 : 5000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log messages if console logging is enabled
|
||||
*/
|
||||
log(message, data = null) {
|
||||
if (this.config.enableConsoleLogging) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
if (data) {
|
||||
console.log(`[${timestamp}] CsrfAutoRefresh: ${message}`, data);
|
||||
} else {
|
||||
console.log(`[${timestamp}] CsrfAutoRefresh: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status information
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
isActive: this.isActive,
|
||||
formId: this.config.formId,
|
||||
lastRefreshTime: this.lastRefreshTime,
|
||||
retryCount: this.retryCount,
|
||||
isPageVisible: this.isPageVisible,
|
||||
nextRefreshIn: this.isActive ?
|
||||
Math.max(0, this.config.refreshInterval - (Date.now() - (this.lastRefreshTime?.getTime() || Date.now()))) :
|
||||
null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual token refresh (useful for debugging)
|
||||
*/
|
||||
async manualRefresh() {
|
||||
this.log('Manual refresh triggered');
|
||||
await this.refreshToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method to initialize auto-refresh for all forms with CSRF tokens
|
||||
*/
|
||||
static initializeAll() {
|
||||
const tokenInputs = document.querySelectorAll('input[name="_token"]');
|
||||
const formIds = new Set();
|
||||
|
||||
// Collect unique form IDs
|
||||
tokenInputs.forEach(input => {
|
||||
const form = input.closest('form');
|
||||
if (form) {
|
||||
const formIdInput = form.querySelector('input[name="_form_id"]');
|
||||
if (formIdInput && formIdInput.value) {
|
||||
formIds.add(formIdInput.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize auto-refresh for each unique form ID
|
||||
const instances = [];
|
||||
formIds.forEach(formId => {
|
||||
const instance = new CsrfAutoRefresh({ formId });
|
||||
instances.push(instance);
|
||||
});
|
||||
|
||||
console.log(`CsrfAutoRefresh: Initialized for ${instances.length} forms:`, Array.from(formIds));
|
||||
return instances;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
CsrfAutoRefresh.initializeAll();
|
||||
});
|
||||
} else {
|
||||
CsrfAutoRefresh.initializeAll();
|
||||
}
|
||||
|
||||
// Export for manual usage
|
||||
export default CsrfAutoRefresh;
|
||||
@@ -1,28 +1,59 @@
|
||||
// modules/example-module/index.js
|
||||
import { registerFrameTask, unregisterFrameTask } from '../../core/frameloop.js';
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
let frameId = 'example-module';
|
||||
let resizeHandler = null;
|
||||
let state = null;
|
||||
|
||||
export function init(config = {}) {
|
||||
console.log('[example-module] init');
|
||||
/**
|
||||
* Initialize example module with state management
|
||||
* @param {Object} config - Module configuration
|
||||
* @param {Object} stateManager - Scoped state manager
|
||||
*/
|
||||
export function init(config = {}, stateManager = null) {
|
||||
Logger.info('[example-module] init');
|
||||
state = stateManager;
|
||||
|
||||
// z. B. Event-Listener hinzufügen
|
||||
// Register module state
|
||||
if (state) {
|
||||
state.register('windowSize', { width: window.innerWidth, height: window.innerHeight });
|
||||
state.register('scrollPosition', { x: 0, y: 0 });
|
||||
state.register('isVisible', true);
|
||||
|
||||
// Example: Subscribe to global app state (if exists)
|
||||
// state.subscribe('app.theme', (theme) => {
|
||||
// Logger.info(`[example-module] Theme changed to: ${theme}`);
|
||||
// });
|
||||
}
|
||||
|
||||
// Event-Listener mit State-Updates
|
||||
resizeHandler = () => {
|
||||
console.log('Fenstergröße geändert');
|
||||
const newSize = { width: window.innerWidth, height: window.innerHeight };
|
||||
Logger.info('Fenstergröße geändert:', newSize);
|
||||
|
||||
if (state) {
|
||||
state.set('windowSize', newSize);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
|
||||
// Scroll- oder Frame-Logik
|
||||
// Frame-Logik mit State-Updates
|
||||
registerFrameTask(frameId, () => {
|
||||
// wiederkehrende Aufgabe
|
||||
const scrollY = window.scrollY;
|
||||
// ggf. transformieren oder Werte speichern
|
||||
const scrollX = window.scrollX;
|
||||
|
||||
if (state) {
|
||||
const currentScroll = state.get('scrollPosition');
|
||||
if (currentScroll.x !== scrollX || currentScroll.y !== scrollY) {
|
||||
state.set('scrollPosition', { x: scrollX, y: scrollY });
|
||||
}
|
||||
}
|
||||
}, { autoStart: true });
|
||||
}
|
||||
|
||||
export function destroy() {
|
||||
console.log('[example-module] destroy');
|
||||
Logger.info('[example-module] destroy');
|
||||
|
||||
// EventListener entfernen
|
||||
if (resizeHandler) {
|
||||
@@ -33,5 +64,9 @@ export function destroy() {
|
||||
// FrameTask entfernen
|
||||
unregisterFrameTask(frameId);
|
||||
|
||||
// weitere Aufräumarbeiten, z.B. Observer disconnect
|
||||
}
|
||||
// State cleanup
|
||||
if (state && typeof state.cleanup === 'function') {
|
||||
state.cleanup();
|
||||
}
|
||||
state = null;
|
||||
}
|
||||
674
resources/js/modules/form-autosave.js
Normal file
674
resources/js/modules/form-autosave.js
Normal file
@@ -0,0 +1,674 @@
|
||||
/**
|
||||
* Form Auto-Save System
|
||||
*
|
||||
* Automatically saves form data to localStorage and restores it when the user
|
||||
* returns to the page. Helps prevent data loss from browser crashes, accidental
|
||||
* navigation, network issues, or other interruptions.
|
||||
*
|
||||
* Features:
|
||||
* - Automatic saving every 30 seconds
|
||||
* - Smart field change detection
|
||||
* - Secure storage (excludes passwords and sensitive data)
|
||||
* - Configurable retention period (default 24 hours)
|
||||
* - Visual indicators when data is saved/restored
|
||||
* - Privacy-conscious (excludes honeypot fields)
|
||||
* - Multiple form support
|
||||
* - Graceful cleanup of expired drafts
|
||||
*
|
||||
* Usage:
|
||||
* import { FormAutoSave } from './modules/form-autosave.js';
|
||||
*
|
||||
* // Initialize for specific form
|
||||
* const autosave = new FormAutoSave({
|
||||
* formSelector: '#contact-form',
|
||||
* storageKey: 'contact_form_draft'
|
||||
* });
|
||||
*
|
||||
* // Or auto-initialize all forms
|
||||
* FormAutoSave.initializeAll();
|
||||
*/
|
||||
|
||||
export class FormAutoSave {
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
static DEFAULT_CONFIG = {
|
||||
formSelector: 'form[data-autosave]',
|
||||
saveInterval: 30000, // 30 seconds
|
||||
retentionPeriod: 24 * 60 * 60 * 1000, // 24 hours
|
||||
storagePrefix: 'form_draft_',
|
||||
enableVisualFeedback: true,
|
||||
enableConsoleLogging: true,
|
||||
|
||||
// Security settings
|
||||
excludeFields: [
|
||||
'input[type="password"]',
|
||||
'input[type="hidden"][name="_token"]',
|
||||
'input[type="hidden"][name="_form_id"]',
|
||||
'input[name*="password"]',
|
||||
'input[name*="confirm"]',
|
||||
'input[name*="honeypot"]',
|
||||
'input[name="email_confirm"]',
|
||||
'input[name="website_url"]',
|
||||
'input[name="user_name"]',
|
||||
'input[name="company_name"]'
|
||||
],
|
||||
|
||||
// Fields that trigger immediate save (important fields)
|
||||
immediateFields: [
|
||||
'textarea',
|
||||
'input[type="email"]',
|
||||
'input[type="text"]',
|
||||
'select'
|
||||
],
|
||||
|
||||
// Cleanup settings
|
||||
maxDraftAge: 7 * 24 * 60 * 60 * 1000, // 7 days max storage
|
||||
cleanupOnInit: true
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} config Configuration options
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
this.config = { ...FormAutoSave.DEFAULT_CONFIG, ...config };
|
||||
this.form = null;
|
||||
this.storageKey = null;
|
||||
this.saveTimer = null;
|
||||
this.lastSaveTime = null;
|
||||
this.hasChanges = false;
|
||||
this.isInitialized = false;
|
||||
this.fieldValues = new Map();
|
||||
|
||||
this.log('FormAutoSave initializing', this.config);
|
||||
|
||||
// Initialize the form
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the autosave system for the form
|
||||
*/
|
||||
initialize() {
|
||||
// Find the form
|
||||
this.form = document.querySelector(this.config.formSelector);
|
||||
|
||||
if (!this.form) {
|
||||
this.log('Form not found with selector:', this.config.formSelector);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate storage key based on form or use provided key
|
||||
this.storageKey = this.config.storageKey ||
|
||||
this.config.storagePrefix + this.generateFormId();
|
||||
|
||||
this.log('Form found, storage key:', this.storageKey);
|
||||
|
||||
// Cleanup old drafts if enabled
|
||||
if (this.config.cleanupOnInit) {
|
||||
this.cleanupExpiredDrafts();
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Store initial field values
|
||||
this.storeInitialValues();
|
||||
|
||||
// Restore saved data
|
||||
this.restoreDraft();
|
||||
|
||||
// Start periodic saving
|
||||
this.startAutoSave();
|
||||
|
||||
this.isInitialized = true;
|
||||
this.log('FormAutoSave initialized successfully');
|
||||
|
||||
// Show status if enabled
|
||||
if (this.config.enableVisualFeedback) {
|
||||
this.showStatus('Auto-save enabled', 'info', 3000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique form identifier
|
||||
*/
|
||||
generateFormId() {
|
||||
// Try to get form ID from various sources
|
||||
const formId = this.form.id ||
|
||||
this.form.getAttribute('data-form-id') ||
|
||||
this.form.querySelector('input[name="_form_id"]')?.value ||
|
||||
'form_' + Date.now();
|
||||
|
||||
return formId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup form event listeners
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Listen for all input changes
|
||||
this.form.addEventListener('input', (e) => {
|
||||
this.onFieldChange(e);
|
||||
});
|
||||
|
||||
this.form.addEventListener('change', (e) => {
|
||||
this.onFieldChange(e);
|
||||
});
|
||||
|
||||
// Listen for form submission to clear draft
|
||||
this.form.addEventListener('submit', () => {
|
||||
this.onFormSubmit();
|
||||
});
|
||||
|
||||
// Save on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (this.hasChanges) {
|
||||
this.saveDraft();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle page visibility changes
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && this.hasChanges) {
|
||||
this.saveDraft();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Store initial field values to detect changes
|
||||
*/
|
||||
storeInitialValues() {
|
||||
const fields = this.getFormFields();
|
||||
|
||||
fields.forEach(field => {
|
||||
const key = this.getFieldKey(field);
|
||||
const value = this.getFieldValue(field);
|
||||
this.fieldValues.set(key, value);
|
||||
});
|
||||
|
||||
this.log(`Stored initial values for ${fields.length} fields`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle field value changes
|
||||
*/
|
||||
onFieldChange(event) {
|
||||
const field = event.target;
|
||||
|
||||
// Skip excluded fields
|
||||
if (this.isFieldExcluded(field)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = this.getFieldKey(field);
|
||||
const newValue = this.getFieldValue(field);
|
||||
const oldValue = this.fieldValues.get(key);
|
||||
|
||||
// Check if value actually changed
|
||||
if (newValue !== oldValue) {
|
||||
this.fieldValues.set(key, newValue);
|
||||
this.hasChanges = true;
|
||||
|
||||
this.log(`Field changed: ${key} = "${newValue}"`);
|
||||
|
||||
// Save immediately for important fields
|
||||
if (this.isImmediateField(field)) {
|
||||
this.saveDraft();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle form submission
|
||||
*/
|
||||
onFormSubmit() {
|
||||
this.log('Form submitted, clearing draft');
|
||||
this.clearDraft();
|
||||
this.stopAutoSave();
|
||||
|
||||
if (this.config.enableVisualFeedback) {
|
||||
this.showStatus('Form submitted, draft cleared', 'success', 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic auto-save
|
||||
*/
|
||||
startAutoSave() {
|
||||
if (this.saveTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saveTimer = setInterval(() => {
|
||||
if (this.hasChanges) {
|
||||
this.saveDraft();
|
||||
}
|
||||
}, this.config.saveInterval);
|
||||
|
||||
this.log(`Auto-save started, interval: ${this.config.saveInterval}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic auto-save
|
||||
*/
|
||||
stopAutoSave() {
|
||||
if (this.saveTimer) {
|
||||
clearInterval(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
this.log('Auto-save stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current form data as draft
|
||||
*/
|
||||
saveDraft() {
|
||||
if (!this.form || !this.hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = this.extractFormData();
|
||||
|
||||
const draft = {
|
||||
data: formData,
|
||||
timestamp: Date.now(),
|
||||
formId: this.generateFormId(),
|
||||
url: window.location.href,
|
||||
version: '1.0'
|
||||
};
|
||||
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(draft));
|
||||
|
||||
this.lastSaveTime = new Date();
|
||||
this.hasChanges = false;
|
||||
|
||||
this.log('Draft saved', {
|
||||
fields: Object.keys(formData).length,
|
||||
size: JSON.stringify(draft).length + ' chars'
|
||||
});
|
||||
|
||||
if (this.config.enableVisualFeedback) {
|
||||
this.showStatus('Draft saved', 'success', 1500);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to save form draft:', error);
|
||||
|
||||
if (this.config.enableVisualFeedback) {
|
||||
this.showStatus('Failed to save draft', 'error', 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore draft data to form
|
||||
*/
|
||||
restoreDraft() {
|
||||
try {
|
||||
const draftJson = localStorage.getItem(this.storageKey);
|
||||
|
||||
if (!draftJson) {
|
||||
this.log('No draft found');
|
||||
return;
|
||||
}
|
||||
|
||||
const draft = JSON.parse(draftJson);
|
||||
|
||||
// Check if draft is expired
|
||||
if (this.isDraftExpired(draft)) {
|
||||
this.log('Draft expired, removing');
|
||||
localStorage.removeItem(this.storageKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if draft is from same form/URL (optional)
|
||||
if (draft.url && draft.url !== window.location.href) {
|
||||
this.log('Draft from different URL, skipping restore');
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore form data
|
||||
this.restoreFormData(draft.data);
|
||||
|
||||
const age = this.formatDuration(Date.now() - draft.timestamp);
|
||||
this.log(`Draft restored (age: ${age})`, draft.data);
|
||||
|
||||
if (this.config.enableVisualFeedback) {
|
||||
this.showStatus(`Draft restored from ${age} ago`, 'info', 4000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to restore draft:', error);
|
||||
// Remove corrupted draft
|
||||
localStorage.removeItem(this.storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form data (excluding sensitive fields)
|
||||
*/
|
||||
extractFormData() {
|
||||
const fields = this.getFormFields();
|
||||
const data = {};
|
||||
|
||||
fields.forEach(field => {
|
||||
if (!this.isFieldExcluded(field)) {
|
||||
const key = this.getFieldKey(field);
|
||||
const value = this.getFieldValue(field);
|
||||
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore data to form fields
|
||||
*/
|
||||
restoreFormData(data) {
|
||||
let restoredCount = 0;
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
const field = this.findFieldByKey(key);
|
||||
|
||||
if (field && !this.isFieldExcluded(field)) {
|
||||
this.setFieldValue(field, value);
|
||||
this.fieldValues.set(key, value);
|
||||
restoredCount++;
|
||||
}
|
||||
});
|
||||
|
||||
this.log(`Restored ${restoredCount} field values`);
|
||||
|
||||
// Trigger change events for any listeners
|
||||
const fields = this.getFormFields();
|
||||
fields.forEach(field => {
|
||||
if (!this.isFieldExcluded(field)) {
|
||||
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
return restoredCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all form fields
|
||||
*/
|
||||
getFormFields() {
|
||||
return Array.from(this.form.querySelectorAll(
|
||||
'input:not([type="submit"]):not([type="button"]):not([type="reset"]), ' +
|
||||
'textarea, select'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if field should be excluded from saving
|
||||
*/
|
||||
isFieldExcluded(field) {
|
||||
return this.config.excludeFields.some(selector =>
|
||||
field.matches(selector)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if field should trigger immediate save
|
||||
*/
|
||||
isImmediateField(field) {
|
||||
return this.config.immediateFields.some(selector =>
|
||||
field.matches(selector)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique key for field
|
||||
*/
|
||||
getFieldKey(field) {
|
||||
return field.name || field.id || `field_${field.type}_${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field value based on field type
|
||||
*/
|
||||
getFieldValue(field) {
|
||||
switch (field.type) {
|
||||
case 'checkbox':
|
||||
return field.checked;
|
||||
case 'radio':
|
||||
return field.checked ? field.value : null;
|
||||
case 'file':
|
||||
return null; // Don't save file inputs
|
||||
default:
|
||||
return field.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set field value based on field type
|
||||
*/
|
||||
setFieldValue(field, value) {
|
||||
switch (field.type) {
|
||||
case 'checkbox':
|
||||
field.checked = Boolean(value);
|
||||
break;
|
||||
case 'radio':
|
||||
field.checked = (field.value === value);
|
||||
break;
|
||||
case 'file':
|
||||
// Can't restore file inputs
|
||||
break;
|
||||
default:
|
||||
field.value = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find field by key
|
||||
*/
|
||||
findFieldByKey(key) {
|
||||
return this.form.querySelector(`[name="${key}"], #${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if draft has expired
|
||||
*/
|
||||
isDraftExpired(draft) {
|
||||
const age = Date.now() - draft.timestamp;
|
||||
return age > this.config.retentionPeriod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current draft
|
||||
*/
|
||||
clearDraft() {
|
||||
localStorage.removeItem(this.storageKey);
|
||||
this.hasChanges = false;
|
||||
this.log('Draft cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all expired drafts
|
||||
*/
|
||||
cleanupExpiredDrafts() {
|
||||
let cleaned = 0;
|
||||
const prefix = this.config.storagePrefix;
|
||||
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const key = localStorage.key(i);
|
||||
|
||||
if (key && key.startsWith(prefix)) {
|
||||
try {
|
||||
const draft = JSON.parse(localStorage.getItem(key));
|
||||
const age = Date.now() - draft.timestamp;
|
||||
|
||||
if (age > this.config.maxDraftAge) {
|
||||
localStorage.removeItem(key);
|
||||
cleaned++;
|
||||
}
|
||||
} catch (error) {
|
||||
// Remove corrupted entries
|
||||
localStorage.removeItem(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0) {
|
||||
this.log(`Cleaned up ${cleaned} expired drafts`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration for human reading
|
||||
*/
|
||||
formatDuration(ms) {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show visual status message
|
||||
*/
|
||||
showStatus(message, type = 'info', duration = 3000) {
|
||||
// Create or update status element
|
||||
let statusEl = document.getElementById('form-autosave-status');
|
||||
|
||||
if (!statusEl) {
|
||||
statusEl = document.createElement('div');
|
||||
statusEl.id = 'form-autosave-status';
|
||||
statusEl.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 9999;
|
||||
max-width: 250px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
transition: opacity 0.3s ease;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
`;
|
||||
document.body.appendChild(statusEl);
|
||||
}
|
||||
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = `autosave-status-${type}`;
|
||||
|
||||
const styles = {
|
||||
info: 'background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb;',
|
||||
success: 'background: #e8f5e8; color: #2e7d32; border: 1px solid #c8e6c9;',
|
||||
error: 'background: #ffebee; color: #c62828; border: 1px solid #ffcdd2;'
|
||||
};
|
||||
|
||||
statusEl.style.cssText += styles[type] || styles.info;
|
||||
statusEl.style.opacity = '1';
|
||||
|
||||
setTimeout(() => {
|
||||
if (statusEl) {
|
||||
statusEl.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (statusEl && statusEl.parentNode) {
|
||||
statusEl.parentNode.removeChild(statusEl);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log messages if console logging is enabled
|
||||
*/
|
||||
log(message, data = null) {
|
||||
if (this.config.enableConsoleLogging) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const prefix = `[${timestamp}] FormAutoSave`;
|
||||
|
||||
if (data) {
|
||||
console.log(`${prefix}: ${message}`, data);
|
||||
} else {
|
||||
console.log(`${prefix}: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
isInitialized: this.isInitialized,
|
||||
formId: this.generateFormId(),
|
||||
storageKey: this.storageKey,
|
||||
hasChanges: this.hasChanges,
|
||||
lastSaveTime: this.lastSaveTime,
|
||||
fieldCount: this.fieldValues.size,
|
||||
draftExists: !!localStorage.getItem(this.storageKey)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual save (for debugging/testing)
|
||||
*/
|
||||
forceSave() {
|
||||
this.hasChanges = true;
|
||||
this.saveDraft();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the autosave instance
|
||||
*/
|
||||
destroy() {
|
||||
this.stopAutoSave();
|
||||
this.isInitialized = false;
|
||||
this.log('FormAutoSave destroyed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method to initialize all forms with autosave
|
||||
*/
|
||||
static initializeAll() {
|
||||
const forms = document.querySelectorAll('form[data-autosave], form:has(input[name="_token"])');
|
||||
const instances = [];
|
||||
|
||||
forms.forEach((form, index) => {
|
||||
const formId = form.id ||
|
||||
form.getAttribute('data-form-id') ||
|
||||
form.querySelector('input[name="_form_id"]')?.value ||
|
||||
`form_${index}`;
|
||||
|
||||
const instance = new FormAutoSave({
|
||||
formSelector: `#${form.id || 'form_' + index}`,
|
||||
storageKey: `form_draft_${formId}`
|
||||
});
|
||||
|
||||
if (!form.id) {
|
||||
form.id = `form_${index}`;
|
||||
}
|
||||
|
||||
instances.push(instance);
|
||||
});
|
||||
|
||||
console.log(`FormAutoSave: Initialized for ${instances.length} forms`);
|
||||
return instances;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for manual usage
|
||||
export default FormAutoSave;
|
||||
353
resources/js/modules/form-handling/FormHandler.js
Normal file
353
resources/js/modules/form-handling/FormHandler.js
Normal file
@@ -0,0 +1,353 @@
|
||||
import { Logger } from '../../core/logger.js';
|
||||
import { FormValidator } from './FormValidator.js';
|
||||
import { FormState } from './FormState.js';
|
||||
|
||||
export class FormHandler {
|
||||
constructor(form, options = {}) {
|
||||
this.form = form;
|
||||
this.options = {
|
||||
validateOnSubmit: true,
|
||||
validateOnBlur: false,
|
||||
validateOnInput: false,
|
||||
showInlineErrors: true,
|
||||
preventSubmitOnError: true,
|
||||
submitMethod: 'POST',
|
||||
ajaxSubmit: true,
|
||||
...options
|
||||
};
|
||||
|
||||
this.validator = FormValidator.create(form);
|
||||
this.state = FormState.create(form);
|
||||
this.isSubmitting = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
static create(form, options = {}) {
|
||||
return new FormHandler(form, options);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.setupErrorDisplay();
|
||||
|
||||
// Mark form as enhanced
|
||||
this.form.setAttribute('data-enhanced', 'true');
|
||||
|
||||
Logger.info(`[FormHandler] Initialized for form: ${this.form.id || 'unnamed'}`);
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Submit event
|
||||
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||
|
||||
// Field validation events
|
||||
if (this.options.validateOnBlur || this.options.validateOnInput) {
|
||||
const fields = this.form.querySelectorAll('input, textarea, select');
|
||||
|
||||
fields.forEach(field => {
|
||||
if (this.options.validateOnBlur) {
|
||||
field.addEventListener('blur', () => this.validateSingleField(field));
|
||||
}
|
||||
|
||||
if (this.options.validateOnInput) {
|
||||
field.addEventListener('input', () => this.validateSingleField(field));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleSubmit(event) {
|
||||
if (this.isSubmitting) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate if enabled
|
||||
if (this.options.validateOnSubmit) {
|
||||
const isValid = this.validator.validate();
|
||||
|
||||
if (!isValid) {
|
||||
event.preventDefault();
|
||||
this.displayErrors();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// AJAX submission
|
||||
if (this.options.ajaxSubmit) {
|
||||
event.preventDefault();
|
||||
await this.submitViaAjax();
|
||||
}
|
||||
|
||||
// If not AJAX, let the form submit naturally
|
||||
}
|
||||
|
||||
async submitViaAjax() {
|
||||
try {
|
||||
this.setSubmitState(true);
|
||||
this.clearErrors();
|
||||
|
||||
const formData = new FormData(this.form);
|
||||
const url = this.form.action || window.location.href;
|
||||
const method = this.form.method || this.options.submitMethod;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await this.parseResponse(response);
|
||||
|
||||
if (response.ok) {
|
||||
this.handleSuccess(data);
|
||||
} else {
|
||||
this.handleError(data);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
Logger.error('[FormHandler] Submit error:', error);
|
||||
this.handleError({
|
||||
message: 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
errors: {}
|
||||
});
|
||||
} finally {
|
||||
this.setSubmitState(false);
|
||||
}
|
||||
}
|
||||
|
||||
async parseResponse(response) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
// Try to parse as JSON, fallback to text
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { message: text };
|
||||
}
|
||||
}
|
||||
|
||||
handleSuccess(data) {
|
||||
Logger.info('[FormHandler] Form submitted successfully');
|
||||
|
||||
// Clear form if configured
|
||||
if (data.clearForm !== false) {
|
||||
this.form.reset();
|
||||
this.state.reset();
|
||||
}
|
||||
|
||||
this.showMessage(data.message || 'Formular erfolgreich gesendet!', 'success');
|
||||
|
||||
// Call success callback
|
||||
this.triggerEvent('form:success', { data });
|
||||
}
|
||||
|
||||
handleError(data) {
|
||||
Logger.warn('[FormHandler] Form submission error:', data);
|
||||
|
||||
// Handle field-specific errors
|
||||
if (data.errors && typeof data.errors === 'object') {
|
||||
for (const [fieldName, errorMessage] of Object.entries(data.errors)) {
|
||||
this.validator.errors.set(fieldName, errorMessage);
|
||||
}
|
||||
this.displayErrors();
|
||||
}
|
||||
|
||||
// Show general error message
|
||||
this.showMessage(data.message || 'Ein Fehler ist aufgetreten.', 'error');
|
||||
|
||||
// Call error callback
|
||||
this.triggerEvent('form:error', { data });
|
||||
}
|
||||
|
||||
validateSingleField(field) {
|
||||
this.validator.errors.delete(field.name);
|
||||
this.validator.validateField(field);
|
||||
|
||||
this.displayFieldError(field);
|
||||
}
|
||||
|
||||
displayErrors() {
|
||||
if (!this.options.showInlineErrors) return;
|
||||
|
||||
const errors = this.validator.getErrors();
|
||||
|
||||
for (const [fieldName, message] of Object.entries(errors)) {
|
||||
const field = this.form.querySelector(`[name="${fieldName}"]`);
|
||||
if (field) {
|
||||
this.displayFieldError(field, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
displayFieldError(field, message = null) {
|
||||
const errorMessage = message || this.validator.getFieldError(field.name);
|
||||
const errorElement = this.getOrCreateErrorElement(field);
|
||||
|
||||
if (errorMessage) {
|
||||
errorElement.textContent = errorMessage;
|
||||
errorElement.style.display = 'block';
|
||||
field.classList.add('error');
|
||||
field.setAttribute('aria-invalid', 'true');
|
||||
field.setAttribute('aria-describedby', errorElement.id);
|
||||
} else {
|
||||
errorElement.textContent = '';
|
||||
errorElement.style.display = 'none';
|
||||
field.classList.remove('error');
|
||||
field.removeAttribute('aria-invalid');
|
||||
field.removeAttribute('aria-describedby');
|
||||
}
|
||||
}
|
||||
|
||||
getOrCreateErrorElement(field) {
|
||||
const errorId = `error-${field.name}`;
|
||||
let errorElement = document.getElementById(errorId);
|
||||
|
||||
if (!errorElement) {
|
||||
errorElement = document.createElement('div');
|
||||
errorElement.id = errorId;
|
||||
errorElement.className = 'form-error';
|
||||
errorElement.setAttribute('role', 'alert');
|
||||
errorElement.style.display = 'none';
|
||||
|
||||
// Insert after field or field container
|
||||
const container = field.closest('.form-group') || field.parentElement;
|
||||
container.appendChild(errorElement);
|
||||
}
|
||||
|
||||
return errorElement;
|
||||
}
|
||||
|
||||
setupErrorDisplay() {
|
||||
// Add CSS for error states if not present
|
||||
if (!document.getElementById('form-handler-styles')) {
|
||||
const styles = document.createElement('style');
|
||||
styles.id = 'form-handler-styles';
|
||||
styles.textContent = `
|
||||
.form-error {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
input.error, textarea.error, select.error {
|
||||
border-color: #dc2626;
|
||||
box-shadow: 0 0 0 1px #dc2626;
|
||||
}
|
||||
.form-message {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.form-message.success {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
.form-message.error {
|
||||
background-color: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styles);
|
||||
}
|
||||
}
|
||||
|
||||
showMessage(message, type = 'info') {
|
||||
let messageContainer = this.form.querySelector('.form-messages');
|
||||
|
||||
if (!messageContainer) {
|
||||
messageContainer = document.createElement('div');
|
||||
messageContainer.className = 'form-messages';
|
||||
this.form.prepend(messageContainer);
|
||||
}
|
||||
|
||||
const messageElement = document.createElement('div');
|
||||
messageElement.className = `form-message ${type}`;
|
||||
messageElement.textContent = message;
|
||||
messageElement.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||
|
||||
messageContainer.innerHTML = '';
|
||||
messageContainer.appendChild(messageElement);
|
||||
|
||||
// Auto-hide success messages after 5 seconds
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
if (messageElement.parentElement) {
|
||||
messageElement.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
clearErrors() {
|
||||
this.validator.clearErrors();
|
||||
|
||||
// Clear visual error states
|
||||
const errorFields = this.form.querySelectorAll('.error');
|
||||
errorFields.forEach(field => {
|
||||
field.classList.remove('error');
|
||||
field.removeAttribute('aria-invalid');
|
||||
field.removeAttribute('aria-describedby');
|
||||
});
|
||||
|
||||
// Hide error messages
|
||||
const errorElements = this.form.querySelectorAll('.form-error');
|
||||
errorElements.forEach(element => {
|
||||
element.style.display = 'none';
|
||||
element.textContent = '';
|
||||
});
|
||||
|
||||
// Clear general messages
|
||||
const messageContainer = this.form.querySelector('.form-messages');
|
||||
if (messageContainer) {
|
||||
messageContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitState(isSubmitting) {
|
||||
this.isSubmitting = isSubmitting;
|
||||
|
||||
const submitButtons = this.form.querySelectorAll('button[type="submit"], input[type="submit"]');
|
||||
|
||||
submitButtons.forEach(button => {
|
||||
button.disabled = isSubmitting;
|
||||
|
||||
if (isSubmitting) {
|
||||
button.setAttribute('data-original-text', button.textContent);
|
||||
button.textContent = 'Wird gesendet...';
|
||||
} else {
|
||||
const originalText = button.getAttribute('data-original-text');
|
||||
if (originalText) {
|
||||
button.textContent = originalText;
|
||||
button.removeAttribute('data-original-text');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
triggerEvent(eventName, detail = {}) {
|
||||
const event = new CustomEvent(eventName, {
|
||||
detail,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
this.form.dispatchEvent(event);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Remove event listeners and clean up
|
||||
this.form.removeAttribute('data-enhanced');
|
||||
Logger.info('[FormHandler] Destroyed');
|
||||
}
|
||||
}
|
||||
256
resources/js/modules/form-handling/FormState.js
Normal file
256
resources/js/modules/form-handling/FormState.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
export class FormState {
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
this.pristineValues = new Map();
|
||||
this.touchedFields = new Set();
|
||||
this.dirtyFields = new Set();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
static create(form) {
|
||||
return new FormState(form);
|
||||
}
|
||||
|
||||
init() {
|
||||
// Store initial values
|
||||
this.captureInitialValues();
|
||||
|
||||
// Track field interactions
|
||||
this.bindEvents();
|
||||
|
||||
Logger.info(`[FormState] Initialized for form: ${this.form.id || 'unnamed'}`);
|
||||
}
|
||||
|
||||
captureInitialValues() {
|
||||
const fields = this.form.querySelectorAll('input, textarea, select');
|
||||
|
||||
fields.forEach(field => {
|
||||
let value;
|
||||
|
||||
switch (field.type) {
|
||||
case 'checkbox':
|
||||
case 'radio':
|
||||
value = field.checked;
|
||||
break;
|
||||
default:
|
||||
value = field.value;
|
||||
}
|
||||
|
||||
this.pristineValues.set(field.name, value);
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const fields = this.form.querySelectorAll('input, textarea, select');
|
||||
|
||||
fields.forEach(field => {
|
||||
// Skip hidden fields for interaction tracking (they can't be touched/interacted with)
|
||||
if (field.type === 'hidden') return;
|
||||
|
||||
// Mark field as touched on first interaction
|
||||
field.addEventListener('focus', () => {
|
||||
this.markAsTouched(field.name);
|
||||
});
|
||||
|
||||
// Track changes for dirty state
|
||||
field.addEventListener('input', () => {
|
||||
this.checkDirtyState(field);
|
||||
});
|
||||
|
||||
field.addEventListener('change', () => {
|
||||
this.checkDirtyState(field);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
markAsTouched(fieldName) {
|
||||
this.touchedFields.add(fieldName);
|
||||
this.updateFormClasses();
|
||||
}
|
||||
|
||||
checkDirtyState(field) {
|
||||
const fieldName = field.name;
|
||||
const currentValue = this.getFieldValue(field);
|
||||
const pristineValue = this.pristineValues.get(fieldName);
|
||||
|
||||
if (currentValue !== pristineValue) {
|
||||
this.dirtyFields.add(fieldName);
|
||||
} else {
|
||||
this.dirtyFields.delete(fieldName);
|
||||
}
|
||||
|
||||
this.updateFormClasses();
|
||||
}
|
||||
|
||||
getFieldValue(field) {
|
||||
switch (field.type) {
|
||||
case 'checkbox':
|
||||
case 'radio':
|
||||
return field.checked;
|
||||
default:
|
||||
return field.value;
|
||||
}
|
||||
}
|
||||
|
||||
updateFormClasses() {
|
||||
// Update form-level classes based on state
|
||||
if (this.isDirty()) {
|
||||
this.form.classList.add('form-dirty');
|
||||
this.form.classList.remove('form-pristine');
|
||||
} else {
|
||||
this.form.classList.add('form-pristine');
|
||||
this.form.classList.remove('form-dirty');
|
||||
}
|
||||
|
||||
if (this.hasTouchedFields()) {
|
||||
this.form.classList.add('form-touched');
|
||||
} else {
|
||||
this.form.classList.remove('form-touched');
|
||||
}
|
||||
}
|
||||
|
||||
// State check methods
|
||||
isPristine() {
|
||||
return this.dirtyFields.size === 0;
|
||||
}
|
||||
|
||||
isDirty() {
|
||||
return this.dirtyFields.size > 0;
|
||||
}
|
||||
|
||||
hasTouchedFields() {
|
||||
return this.touchedFields.size > 0;
|
||||
}
|
||||
|
||||
isFieldTouched(fieldName) {
|
||||
return this.touchedFields.has(fieldName);
|
||||
}
|
||||
|
||||
isFieldDirty(fieldName) {
|
||||
return this.dirtyFields.has(fieldName);
|
||||
}
|
||||
|
||||
isFieldPristine(fieldName) {
|
||||
return !this.dirtyFields.has(fieldName);
|
||||
}
|
||||
|
||||
// Get field states
|
||||
getFieldState(fieldName) {
|
||||
return {
|
||||
pristine: this.isFieldPristine(fieldName),
|
||||
dirty: this.isFieldDirty(fieldName),
|
||||
touched: this.isFieldTouched(fieldName),
|
||||
pristineValue: this.pristineValues.get(fieldName),
|
||||
currentValue: this.getCurrentFieldValue(fieldName)
|
||||
};
|
||||
}
|
||||
|
||||
getCurrentFieldValue(fieldName) {
|
||||
const field = this.form.querySelector(`[name="${fieldName}"]`);
|
||||
return field ? this.getFieldValue(field) : undefined;
|
||||
}
|
||||
|
||||
// Form-level state
|
||||
getFormState() {
|
||||
return {
|
||||
pristine: this.isPristine(),
|
||||
dirty: this.isDirty(),
|
||||
touched: this.hasTouchedFields(),
|
||||
dirtyFields: Array.from(this.dirtyFields),
|
||||
touchedFields: Array.from(this.touchedFields),
|
||||
totalFields: this.pristineValues.size
|
||||
};
|
||||
}
|
||||
|
||||
// Reset methods
|
||||
reset() {
|
||||
this.touchedFields.clear();
|
||||
this.dirtyFields.clear();
|
||||
|
||||
// Re-capture values after form reset
|
||||
setTimeout(() => {
|
||||
this.captureInitialValues();
|
||||
this.updateFormClasses();
|
||||
}, 0);
|
||||
|
||||
Logger.info('[FormState] State reset');
|
||||
}
|
||||
|
||||
resetField(fieldName) {
|
||||
this.touchedFields.delete(fieldName);
|
||||
this.dirtyFields.delete(fieldName);
|
||||
|
||||
// Reset field to pristine value
|
||||
const field = this.form.querySelector(`[name="${fieldName}"]`);
|
||||
const pristineValue = this.pristineValues.get(fieldName);
|
||||
|
||||
if (field && pristineValue !== undefined) {
|
||||
switch (field.type) {
|
||||
case 'checkbox':
|
||||
case 'radio':
|
||||
field.checked = pristineValue;
|
||||
break;
|
||||
default:
|
||||
field.value = pristineValue;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateFormClasses();
|
||||
|
||||
Logger.info(`[FormState] Field "${fieldName}" reset to pristine state`);
|
||||
}
|
||||
|
||||
// Event handling for external state changes
|
||||
triggerStateEvent(eventName, detail = {}) {
|
||||
const event = new CustomEvent(eventName, {
|
||||
detail: {
|
||||
...detail,
|
||||
formState: this.getFormState()
|
||||
},
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
this.form.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
hasChanges() {
|
||||
return this.isDirty();
|
||||
}
|
||||
|
||||
getChangedFields() {
|
||||
const changes = {};
|
||||
|
||||
this.dirtyFields.forEach(fieldName => {
|
||||
changes[fieldName] = {
|
||||
pristineValue: this.pristineValues.get(fieldName),
|
||||
currentValue: this.getCurrentFieldValue(fieldName)
|
||||
};
|
||||
});
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
// Warning for unsaved changes
|
||||
enableUnsavedChangesWarning() {
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (this.isDirty()) {
|
||||
e.preventDefault();
|
||||
e.returnValue = 'Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?';
|
||||
return e.returnValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.pristineValues.clear();
|
||||
this.touchedFields.clear();
|
||||
this.dirtyFields.clear();
|
||||
|
||||
Logger.info('[FormState] Destroyed');
|
||||
}
|
||||
}
|
||||
190
resources/js/modules/form-handling/FormValidator.js
Normal file
190
resources/js/modules/form-handling/FormValidator.js
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
export class FormValidator {
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
this.errors = new Map();
|
||||
}
|
||||
|
||||
static create(form) {
|
||||
return new FormValidator(form);
|
||||
}
|
||||
|
||||
validate() {
|
||||
this.errors.clear();
|
||||
const fields = this.form.querySelectorAll('input, textarea, select');
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.type === 'hidden' || field.disabled) continue;
|
||||
|
||||
this.validateField(field);
|
||||
}
|
||||
|
||||
return this.errors.size === 0;
|
||||
}
|
||||
|
||||
validateField(field) {
|
||||
const value = field.value;
|
||||
const fieldName = field.name;
|
||||
|
||||
// HTML5 required attribute
|
||||
if (field.hasAttribute('required') && (!value || value.trim() === '')) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'valueMissing') || `${this.getFieldLabel(field)} ist erforderlich`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip further validation if field is empty and not required
|
||||
if (!value || value.trim() === '') return;
|
||||
|
||||
// HTML5 type validation
|
||||
if (field.type === 'email' && !this.isValidEmail(value)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'typeMismatch') || 'Bitte geben Sie eine gültige E-Mail-Adresse ein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.type === 'url' && !this.isValidUrl(value)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'typeMismatch') || 'Bitte geben Sie eine gültige URL ein');
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML5 minlength attribute
|
||||
const minLength = field.getAttribute('minlength');
|
||||
if (minLength && value.length < parseInt(minLength)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'tooShort') || `Mindestens ${minLength} Zeichen erforderlich`);
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML5 maxlength attribute (usually enforced by browser, but for safety)
|
||||
const maxLength = field.getAttribute('maxlength');
|
||||
if (maxLength && value.length > parseInt(maxLength)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'tooLong') || `Maximal ${maxLength} Zeichen erlaubt`);
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML5 min/max for number inputs
|
||||
if (field.type === 'number') {
|
||||
const min = field.getAttribute('min');
|
||||
const max = field.getAttribute('max');
|
||||
const numValue = parseFloat(value);
|
||||
|
||||
if (min && numValue < parseFloat(min)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'rangeUnderflow') || `Wert muss mindestens ${min} sein`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (max && numValue > parseFloat(max)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'rangeOverflow') || `Wert darf maximal ${max} sein`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// HTML5 pattern attribute
|
||||
const pattern = field.getAttribute('pattern');
|
||||
if (pattern) {
|
||||
const regex = new RegExp(pattern);
|
||||
if (!regex.test(value)) {
|
||||
this.errors.set(fieldName, this.getErrorMessage(field, 'patternMismatch') || 'Ungültiges Format');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validation via data-validate attribute
|
||||
const customValidation = field.getAttribute('data-validate');
|
||||
if (customValidation) {
|
||||
const result = this.runCustomValidation(customValidation, value, field);
|
||||
if (!result.valid) {
|
||||
this.errors.set(fieldName, result.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runCustomValidation(validationType, value, field) {
|
||||
switch (validationType) {
|
||||
case 'phone':
|
||||
const phoneRegex = /^[\+]?[0-9\s\-\(\)]{10,}$/;
|
||||
return {
|
||||
valid: phoneRegex.test(value),
|
||||
message: 'Bitte geben Sie eine gültige Telefonnummer ein'
|
||||
};
|
||||
|
||||
case 'postal-code-de':
|
||||
const postalRegex = /^[0-9]{5}$/;
|
||||
return {
|
||||
valid: postalRegex.test(value),
|
||||
message: 'Bitte geben Sie eine gültige Postleitzahl ein'
|
||||
};
|
||||
|
||||
case 'no-html':
|
||||
const hasHtml = /<[^>]*>/g.test(value);
|
||||
return {
|
||||
valid: !hasHtml,
|
||||
message: 'HTML-Code ist nicht erlaubt'
|
||||
};
|
||||
|
||||
default:
|
||||
Logger.warn(`Unknown custom validation: ${validationType}`);
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
}
|
||||
|
||||
isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
isValidUrl(url) {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getFieldLabel(field) {
|
||||
const label = this.form.querySelector(`label[for="${field.id}"]`) ||
|
||||
this.form.querySelector(`label:has([name="${field.name}"])`);
|
||||
|
||||
return label ? label.textContent.trim().replace(':', '') : field.name;
|
||||
}
|
||||
|
||||
getErrorMessage(field, validityType) {
|
||||
// Check for custom error messages via data-error-* attributes
|
||||
const customMessage = field.getAttribute(`data-error-${validityType}`) ||
|
||||
field.getAttribute('data-error');
|
||||
|
||||
return customMessage;
|
||||
}
|
||||
|
||||
getErrors() {
|
||||
return Object.fromEntries(this.errors);
|
||||
}
|
||||
|
||||
getFieldError(fieldName) {
|
||||
return this.errors.get(fieldName);
|
||||
}
|
||||
|
||||
hasErrors() {
|
||||
return this.errors.size > 0;
|
||||
}
|
||||
|
||||
clearErrors() {
|
||||
this.errors.clear();
|
||||
}
|
||||
|
||||
// Check HTML5 validity API as fallback
|
||||
validateWithHTML5() {
|
||||
const isValid = this.form.checkValidity();
|
||||
|
||||
if (!isValid) {
|
||||
const fields = this.form.querySelectorAll('input, textarea, select');
|
||||
for (const field of fields) {
|
||||
if (!field.validity.valid) {
|
||||
this.errors.set(field.name, field.validationMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
125
resources/js/modules/form-handling/index.js
Normal file
125
resources/js/modules/form-handling/index.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Logger } from '../../core/logger.js';
|
||||
import { FormHandler } from './FormHandler.js';
|
||||
import { FormValidator } from './FormValidator.js';
|
||||
import { FormState } from './FormState.js';
|
||||
|
||||
/**
|
||||
* Form Handling Module
|
||||
*
|
||||
* Provides comprehensive form handling with:
|
||||
* - HTML5-based validation (reads validation rules from HTML attributes)
|
||||
* - AJAX form submission with error handling
|
||||
* - Form state management (pristine, dirty, touched)
|
||||
* - Progressive enhancement
|
||||
*
|
||||
* Usage:
|
||||
* - Add data-module="form-handling" to any form element
|
||||
* - Configure via data-options attribute:
|
||||
* data-options='{"validateOnBlur": true, "ajaxSubmit": true}'
|
||||
*
|
||||
* HTML Validation Attributes Supported:
|
||||
* - required: Field is required
|
||||
* - pattern: Custom regex pattern
|
||||
* - minlength/maxlength: String length limits
|
||||
* - min/max: Number range limits
|
||||
* - type="email": Email validation
|
||||
* - type="url": URL validation
|
||||
* - data-validate: Custom validation (phone, postal-code-de, no-html)
|
||||
* - data-error-*: Custom error messages
|
||||
*/
|
||||
|
||||
const FormHandlingModule = {
|
||||
name: 'form-handling',
|
||||
|
||||
// Module-level init (called by module system)
|
||||
init(config = {}, state = null) {
|
||||
Logger.info('[FormHandling] Module initialized (ready for DOM elements)');
|
||||
// Module is now ready - no DOM operations here
|
||||
return this;
|
||||
},
|
||||
|
||||
// Element-specific init (called for individual form elements)
|
||||
initElement(element, options = {}) {
|
||||
Logger.info(`[FormHandling] Initializing on form: ${element.id || 'unnamed'}`);
|
||||
|
||||
// Default configuration
|
||||
const config = {
|
||||
validateOnSubmit: true,
|
||||
validateOnBlur: false,
|
||||
validateOnInput: false,
|
||||
showInlineErrors: true,
|
||||
preventSubmitOnError: true,
|
||||
ajaxSubmit: true,
|
||||
submitMethod: 'POST',
|
||||
enableStateTracking: true,
|
||||
enableUnsavedWarning: false,
|
||||
...options
|
||||
};
|
||||
|
||||
// Initialize form handler
|
||||
const formHandler = FormHandler.create(element, config);
|
||||
|
||||
// Store reference for later access
|
||||
element._formHandler = formHandler;
|
||||
element._formValidator = formHandler.validator;
|
||||
element._formState = formHandler.state;
|
||||
|
||||
// Enable unsaved changes warning if configured
|
||||
if (config.enableUnsavedWarning) {
|
||||
formHandler.state.enableUnsavedChangesWarning();
|
||||
}
|
||||
|
||||
// Add module-specific CSS classes
|
||||
element.classList.add('form-enhanced');
|
||||
|
||||
// Trigger initialization event
|
||||
const event = new CustomEvent('form:initialized', {
|
||||
detail: {
|
||||
handler: formHandler,
|
||||
validator: formHandler.validator,
|
||||
state: formHandler.state,
|
||||
config: config
|
||||
},
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
element.dispatchEvent(event);
|
||||
|
||||
Logger.info(`[FormHandling] Successfully initialized for form: ${element.id || 'unnamed'}`);
|
||||
|
||||
return formHandler;
|
||||
},
|
||||
|
||||
// Element-specific destroy (for form elements)
|
||||
destroyElement(element) {
|
||||
if (element._formHandler) {
|
||||
element._formHandler.destroy();
|
||||
delete element._formHandler;
|
||||
delete element._formValidator;
|
||||
delete element._formState;
|
||||
}
|
||||
|
||||
element.classList.remove('form-enhanced');
|
||||
element.removeAttribute('data-enhanced');
|
||||
|
||||
Logger.info(`[FormHandling] Destroyed for form: ${element.id || 'unnamed'}`);
|
||||
},
|
||||
|
||||
// Module-level destroy
|
||||
destroy() {
|
||||
Logger.info('[FormHandling] Module destroyed');
|
||||
}
|
||||
};
|
||||
|
||||
// Export individual classes for direct usage
|
||||
export { FormHandler, FormValidator, FormState };
|
||||
|
||||
// Export as default for module system
|
||||
export default FormHandlingModule;
|
||||
|
||||
// Export init function directly for compatibility with module system
|
||||
export const init = FormHandlingModule.init.bind(FormHandlingModule);
|
||||
export const initElement = FormHandlingModule.initElement.bind(FormHandlingModule);
|
||||
|
||||
// Also export named for direct usage
|
||||
export { FormHandlingModule };
|
||||
@@ -1,35 +1,121 @@
|
||||
import { moduleConfig } from './config.js';
|
||||
import { Logger } from '../core/logger.js';
|
||||
import { moduleErrorBoundary } from '../core/ModuleErrorBoundary.js';
|
||||
import { stateManager } from '../core/StateManager.js';
|
||||
import { dependencyManager } from '../core/DependencyManager.js';
|
||||
|
||||
export const activeModules = new Map(); // key: modulename → { mod, config }
|
||||
export const activeModules = new Map(); // key: modulename → { mod, config, state }
|
||||
|
||||
export async function registerModules() {
|
||||
const modules = import.meta.glob('./*/index.js', { eager: true });
|
||||
// Use EAGER loading for single bundle - all modules in one chunk
|
||||
let modules;
|
||||
if (typeof global !== 'undefined' && global.importMeta?.glob) {
|
||||
modules = global.importMeta.glob('./*/index.js', { eager: true });
|
||||
} else {
|
||||
modules = import.meta.glob('./*/index.js', { eager: true });
|
||||
}
|
||||
|
||||
Logger.info('[Modules] Found modules:', Object.keys(modules));
|
||||
|
||||
const domModules = new Set(
|
||||
Array.from(document.querySelectorAll('[data-module]')).map(el => el.dataset.module).filter(Boolean)
|
||||
);
|
||||
|
||||
const usedModules = new Set(domModules);
|
||||
const fallbackMode = usedModules.size === 0;
|
||||
// Always load these core modules
|
||||
const coreModules = new Set(['spa-router', 'form-handling', 'api-manager']);
|
||||
|
||||
const usedModules = new Set([...domModules, ...coreModules]);
|
||||
const fallbackMode = usedModules.size === coreModules.size && domModules.size === 0;
|
||||
|
||||
Logger.info('[Modules] DOM modules found:', [...domModules]);
|
||||
Logger.info('[Modules] Core modules:', [...coreModules]);
|
||||
Logger.info('[Modules] Used modules:', [...usedModules]);
|
||||
Logger.info('[Modules] Fallback mode:', fallbackMode);
|
||||
|
||||
// Phase 1: Register only USED modules with dependency manager
|
||||
Object.entries(modules).forEach(([path, mod]) => {
|
||||
const name = path.split('/').slice(-2, -1)[0]; // z.B. "noise-toggle.js"
|
||||
const config = moduleConfig[name] || {};
|
||||
|
||||
const name = path.split('/').slice(-2, -1)[0];
|
||||
|
||||
if(!fallbackMode && !usedModules.has(name)) {
|
||||
Logger.info(`⏭️ [Module] Skipped (not used in DOM): ${name}`);
|
||||
Logger.info(`⏭️ [Module] Skipping unused module: ${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Register module definition if provided
|
||||
if (typeof mod.definition === 'object') {
|
||||
dependencyManager.register(mod.definition);
|
||||
} else {
|
||||
// Create default definition for modules without explicit dependencies
|
||||
const defaultDef = {
|
||||
name,
|
||||
version: '1.0.0',
|
||||
dependencies: [],
|
||||
provides: [],
|
||||
priority: 0
|
||||
};
|
||||
dependencyManager.register(defaultDef);
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 2: Calculate initialization order
|
||||
const initOrder = dependencyManager.calculateInitializationOrder();
|
||||
|
||||
// Phase 3: Initialize modules in dependency order
|
||||
for (const name of initOrder) {
|
||||
if(!fallbackMode && !usedModules.has(name)) {
|
||||
Logger.info(`⏭️ [Module] Skipped (not used in DOM): ${name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const modulePath = Object.keys(modules).find(path =>
|
||||
path.split('/').slice(-2, -1)[0] === name
|
||||
);
|
||||
|
||||
if (!modulePath) {
|
||||
Logger.warn(`⛔ [Module] No implementation found for: ${name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const mod = modules[modulePath];
|
||||
const config = moduleConfig[name] || {};
|
||||
|
||||
// Check dependencies before initialization
|
||||
const depCheck = dependencyManager.checkDependencies(name);
|
||||
if (!depCheck.satisfied) {
|
||||
Logger.error(`❌ [Module] Cannot initialize ${name}: ${depCheck.reason}`);
|
||||
activeModules.set(name, { mod: null, config, error: new Error(depCheck.reason), original: mod });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof mod.init === 'function') {
|
||||
mod.init(config);
|
||||
activeModules.set(name, { mod, config });
|
||||
Logger.info(`✅ [Module] Initialized: ${name}`);
|
||||
try {
|
||||
dependencyManager.markInitializing(name);
|
||||
|
||||
// Create scoped state manager for module
|
||||
const scopedState = stateManager.createScope(name);
|
||||
|
||||
// Wrap module with error boundary
|
||||
const protectedMod = moduleErrorBoundary.wrapModule(mod, name);
|
||||
|
||||
// Initialize module with config and state
|
||||
await protectedMod.init(config, scopedState);
|
||||
|
||||
dependencyManager.markInitialized(name);
|
||||
activeModules.set(name, {
|
||||
mod: protectedMod,
|
||||
config,
|
||||
state: scopedState,
|
||||
original: mod
|
||||
});
|
||||
Logger.info(`✅ [Module] Initialized: ${name}`);
|
||||
} catch (error) {
|
||||
Logger.error(`❌ [Module] Failed to initialize ${name}:`, error);
|
||||
activeModules.set(name, { mod: null, config, error, original: mod });
|
||||
}
|
||||
} else {
|
||||
Logger.warn(`⛔ [Module] No init() in ${name}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (fallbackMode) {
|
||||
Logger.info('⚠️ [Module] No data-module usage detected, fallback to full init mode');
|
||||
@@ -37,11 +123,56 @@ export async function registerModules() {
|
||||
}
|
||||
|
||||
export function destroyModules() {
|
||||
for (const [name, { mod }] of activeModules.entries()) {
|
||||
if (typeof mod.destroy === 'function') {
|
||||
mod.destroy();
|
||||
Logger.info(`🧹 [Module] Destroyed: ${name}`);
|
||||
for (const [name, { mod, state }] of activeModules.entries()) {
|
||||
if (mod && typeof mod.destroy === 'function') {
|
||||
try {
|
||||
mod.destroy();
|
||||
Logger.info(`🧹 [Module] Destroyed: ${name}`);
|
||||
} catch (error) {
|
||||
Logger.error(`❌ [Module] Failed to destroy ${name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up state subscriptions
|
||||
if (state && typeof state.cleanup === 'function') {
|
||||
state.cleanup();
|
||||
}
|
||||
}
|
||||
activeModules.clear();
|
||||
moduleErrorBoundary.reset();
|
||||
stateManager.reset();
|
||||
dependencyManager.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets health status of all active modules
|
||||
* @returns {Object} Module health report
|
||||
*/
|
||||
export function getModuleHealth() {
|
||||
const health = {
|
||||
total: activeModules.size,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
modules: {},
|
||||
errorBoundary: moduleErrorBoundary.getHealthStatus()
|
||||
};
|
||||
|
||||
for (const [name, { mod, error }] of activeModules.entries()) {
|
||||
if (error) {
|
||||
health.failed++;
|
||||
health.modules[name] = { status: 'failed', error: error.message };
|
||||
} else if (mod) {
|
||||
health.active++;
|
||||
health.modules[name] = { status: 'active' };
|
||||
} else {
|
||||
health.modules[name] = { status: 'unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
// Debug function - access via console
|
||||
if (typeof window !== 'undefined') {
|
||||
window.moduleHealth = getModuleHealth;
|
||||
}
|
||||
|
||||
@@ -4,23 +4,140 @@ import { Logger } from '../../core/logger.js';
|
||||
import { useEvent } from '../../core/useEvent.js';
|
||||
|
||||
function onClick(e) {
|
||||
const img = e.target.closest('[data-lightbox]');
|
||||
if (!img || img.tagName !== 'IMG') return;
|
||||
|
||||
// Check if the clicked element is an image
|
||||
let img = null;
|
||||
|
||||
if (e.target.tagName === 'IMG') {
|
||||
img = e.target;
|
||||
} else {
|
||||
// Check if clicked element contains an image
|
||||
img = e.target.querySelector('img');
|
||||
}
|
||||
|
||||
if (!img) return;
|
||||
|
||||
// Opt-out mechanism: skip if image has data-lightbox="false"
|
||||
if (img.dataset.lightbox === 'false') return;
|
||||
|
||||
// Skip very small images (likely icons, thumbnails, decorative)
|
||||
if (img.naturalWidth < 200 || img.naturalHeight < 200) return;
|
||||
|
||||
// Skip images in specific containers that shouldn't open lightbox
|
||||
if (img.closest('nav, header, footer, .no-lightbox')) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
UIManager.open('lightbox', {
|
||||
content: `<img src="${img.src}" alt="${img.alt || ''}" />`
|
||||
|
||||
// Get the best quality source for lightbox
|
||||
const lightboxSrc = getBestImageSource(img);
|
||||
const caption = img.alt || img.dataset.caption || img.title || '';
|
||||
|
||||
// Create responsive picture element for lightbox
|
||||
const pictureElement = img.closest('picture');
|
||||
let lightboxContent;
|
||||
|
||||
if (pictureElement) {
|
||||
// Clone the entire picture element for responsive display
|
||||
const clonedPicture = pictureElement.cloneNode(true);
|
||||
const clonedImg = clonedPicture.querySelector('img');
|
||||
clonedImg.className = 'lightbox-image';
|
||||
|
||||
lightboxContent = `
|
||||
<div class="lightbox-content">
|
||||
${clonedPicture.outerHTML}
|
||||
${caption ? `<div class="lightbox-caption">${caption}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
Logger.info('[Lightbox] Opening responsive image (picture element):', lightboxSrc);
|
||||
} else {
|
||||
// Fallback for standalone img elements
|
||||
lightboxContent = `
|
||||
<div class="lightbox-content">
|
||||
<img src="${lightboxSrc}" alt="${caption}" class="lightbox-image" />
|
||||
${caption ? `<div class="lightbox-caption">${caption}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
Logger.info('[Lightbox] Opening standalone image:', lightboxSrc);
|
||||
}
|
||||
|
||||
// Use singleton lightbox instance
|
||||
const lightbox = UIManager.open('lightbox', {
|
||||
content: lightboxContent
|
||||
});
|
||||
|
||||
// Log reuse status
|
||||
if (UIManager.isOpen('lightbox') && lightbox) {
|
||||
Logger.info('[Lightbox] Reused existing lightbox instance');
|
||||
}
|
||||
}
|
||||
|
||||
function getBestImageSource(img) {
|
||||
// Priority order for getting the best image source:
|
||||
// 1. data-lightbox-src (explicitly defined high-res version)
|
||||
// 2. currentSrc (browser-selected responsive source)
|
||||
// 3. src (fallback)
|
||||
|
||||
if (img.dataset.lightboxSrc) {
|
||||
return img.dataset.lightboxSrc;
|
||||
}
|
||||
|
||||
if (img.currentSrc) {
|
||||
return img.currentSrc;
|
||||
}
|
||||
|
||||
return img.src;
|
||||
}
|
||||
|
||||
export function init() {
|
||||
Logger.info('[lightbox-trigger] init');
|
||||
Logger.info('[lightbox-trigger] Auto-enabling lightbox for all images (including picture/srcset)');
|
||||
Logger.info('[lightbox-trigger] Use data-lightbox="false" to opt-out');
|
||||
Logger.info('[lightbox-trigger] Use data-lightbox-src for custom high-res versions');
|
||||
|
||||
useEvent(document, 'click', onClick);
|
||||
|
||||
// Add visual indicator to lightbox-enabled images
|
||||
enhanceImages();
|
||||
}
|
||||
|
||||
function enhanceImages() {
|
||||
const images = document.querySelectorAll('img:not([data-lightbox="false"])');
|
||||
let enhancedCount = 0;
|
||||
|
||||
images.forEach(img => {
|
||||
// Skip small images and images in excluded containers
|
||||
if (img.naturalWidth < 200 || img.naturalHeight < 200) return;
|
||||
if (img.closest('nav, header, footer, .no-lightbox')) return;
|
||||
|
||||
// Add CSS class for styling
|
||||
img.classList.add('lightbox-enabled');
|
||||
|
||||
// Add cursor pointer
|
||||
img.style.cursor = 'zoom-in';
|
||||
|
||||
// Add title hint if none exists
|
||||
if (!img.title && !img.alt) {
|
||||
img.title = 'Klicken zum Vergrößern';
|
||||
}
|
||||
|
||||
enhancedCount++;
|
||||
});
|
||||
|
||||
Logger.info(`[Lightbox] Enhanced ${enhancedCount} images with lightbox functionality`);
|
||||
}
|
||||
|
||||
export function destroy() {
|
||||
Logger.info('[lightbox-trigger] destroy');
|
||||
// Automatische Entfernung über EventManager erfolgt über Modulkennung
|
||||
// Kein direkter Aufruf nötig, solange removeModules() global verwendet wird
|
||||
|
||||
// Remove enhancements
|
||||
const enhancedImages = document.querySelectorAll('img.lightbox-enabled');
|
||||
enhancedImages.forEach(img => {
|
||||
img.classList.remove('lightbox-enabled');
|
||||
img.style.cursor = '';
|
||||
if (img.title === 'Klicken zum Vergrößern') {
|
||||
img.title = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Event removal happens automatically via EventManager
|
||||
}
|
||||
|
||||
61
resources/js/modules/scroll-dependent/index.js
Normal file
61
resources/js/modules/scroll-dependent/index.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// modules/scroll-dependent/index.js
|
||||
import { Logger } from '../../core/logger.js';
|
||||
import { DependencyManager } from '../../core/DependencyManager.js';
|
||||
|
||||
// Module definition with explicit dependencies
|
||||
export const definition = DependencyManager.createDefinition('scroll-dependent', '1.0.0')
|
||||
.depends('example-module', '1.0.0') // Required dependency
|
||||
.depends('scrollfx', '1.0.0', true) // Optional dependency
|
||||
.provides('scroll-coordination')
|
||||
.priority(10); // Lower priority = later initialization
|
||||
|
||||
let scrollSubscription = null;
|
||||
let state = null;
|
||||
|
||||
/**
|
||||
* Initialize scroll-dependent module
|
||||
* @param {Object} config - Module configuration
|
||||
* @param {Object} stateManager - Scoped state manager
|
||||
*/
|
||||
export function init(config = {}, stateManager = null) {
|
||||
Logger.info('[scroll-dependent] init');
|
||||
state = stateManager;
|
||||
|
||||
// Register own state
|
||||
if (state) {
|
||||
state.register('isScrolling', false);
|
||||
state.register('scrollDirection', 'none');
|
||||
|
||||
// Subscribe to dependency state
|
||||
scrollSubscription = state.subscribe('example-module.scrollPosition', (newPos, oldPos) => {
|
||||
if (oldPos.y !== newPos.y) {
|
||||
const direction = newPos.y > oldPos.y ? 'down' : 'up';
|
||||
state.set('scrollDirection', direction);
|
||||
state.set('isScrolling', true);
|
||||
|
||||
// Reset scrolling state after delay
|
||||
setTimeout(() => {
|
||||
if (state) {
|
||||
state.set('isScrolling', false);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
Logger.info(`[scroll-dependent] Scroll ${direction}: ${newPos.y}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function destroy() {
|
||||
Logger.info('[scroll-dependent] destroy');
|
||||
|
||||
if (scrollSubscription && state) {
|
||||
state.unsubscribe(scrollSubscription);
|
||||
scrollSubscription = null;
|
||||
}
|
||||
|
||||
if (state && typeof state.cleanup === 'function') {
|
||||
state.cleanup();
|
||||
}
|
||||
state = null;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
export const scrollSteps = {
|
||||
onEnter(index, el) {
|
||||
el.classList.add('active');
|
||||
document.body.dataset.activeScrollStep = index;
|
||||
console.log(`[ScrollStep] Enter: ${index}`);
|
||||
Logger.info(`[ScrollStep] Enter: ${index}`);
|
||||
// Beispielaktionen
|
||||
if (index === 1) showIntro();
|
||||
if (index === 2) activateChart();
|
||||
@@ -12,7 +14,7 @@ export const scrollSteps = {
|
||||
onLeave(index, el) {
|
||||
el.classList.remove('active');
|
||||
el.style.transitionDelay = '';
|
||||
console.log(`[ScrollStep] Leave: ${index}`);
|
||||
Logger.info(`[ScrollStep] Leave: ${index}`);
|
||||
|
||||
if (document.body.dataset.activeScrollStep === String(index)) {
|
||||
delete document.body.dataset.activeScrollStep;
|
||||
@@ -25,9 +27,9 @@ export const scrollSteps = {
|
||||
};
|
||||
|
||||
|
||||
function showIntro() { console.log('Intro sichtbar'); }
|
||||
function hideIntro() { console.log('Intro ausgeblendet'); }
|
||||
function activateChart() { console.log('Chart aktiviert'); }
|
||||
function deactivateChart() { console.log('Chart deaktiviert'); }
|
||||
function revealQuote() { console.log('Zitat eingeblendet'); }
|
||||
function hideQuote() { console.log('Zitat ausgeblendet'); }
|
||||
function showIntro() { Logger.info('Intro sichtbar'); }
|
||||
function hideIntro() { Logger.info('Intro ausgeblendet'); }
|
||||
function activateChart() { Logger.info('Chart aktiviert'); }
|
||||
function deactivateChart() { Logger.info('Chart deaktiviert'); }
|
||||
function revealQuote() { Logger.info('Zitat eingeblendet'); }
|
||||
function hideQuote() { Logger.info('Zitat ausgeblendet'); }
|
||||
|
||||
@@ -13,6 +13,11 @@ export function init() {
|
||||
const footer = document.querySelector('footer');
|
||||
const headerLink = document.querySelector('header a');
|
||||
|
||||
// Check if required elements exist
|
||||
if (!button || !aside || !backdrop) {
|
||||
console.info('[Sidebar] Required elements not found, skipping sidebar initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
useEvent(button, 'click', (e) => {
|
||||
aside.classList.toggle('show');
|
||||
@@ -23,13 +28,13 @@ export function init() {
|
||||
if (isVisible) {
|
||||
backdrop.classList.add('visible');
|
||||
|
||||
footer.setAttribute('inert', 'true');
|
||||
headerLink.setAttribute('inert', 'true');
|
||||
if (footer) footer.setAttribute('inert', 'true');
|
||||
if (headerLink) headerLink.setAttribute('inert', 'true');
|
||||
} else {
|
||||
backdrop.classList.remove('visible');
|
||||
|
||||
footer.removeAttribute('inert');
|
||||
headerLink.removeAttribute('inert');
|
||||
if (footer) footer.removeAttribute('inert');
|
||||
if (headerLink) headerLink.removeAttribute('inert');
|
||||
}
|
||||
})
|
||||
|
||||
@@ -48,8 +53,8 @@ export function init() {
|
||||
aside.classList.remove('show');
|
||||
backdrop.classList.remove('visible');
|
||||
|
||||
footer.removeAttribute('inert');
|
||||
headerLink.removeAttribute('inert');
|
||||
if (footer) footer.removeAttribute('inert');
|
||||
if (headerLink) headerLink.removeAttribute('inert');
|
||||
}
|
||||
|
||||
useEvent(backdrop, 'click' , clickHandler)
|
||||
|
||||
489
resources/js/modules/spa-router/SPARouter.js
Normal file
489
resources/js/modules/spa-router/SPARouter.js
Normal file
@@ -0,0 +1,489 @@
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
export class SPARouter {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
containerSelector: 'main',
|
||||
linkSelector: 'a[href^="/"]', // All internal links
|
||||
loadingClass: 'spa-loading',
|
||||
excludeSelector: '[data-spa="false"], [download], [target="_blank"], [href^="mailto:"], [href^="tel:"], [href^="#"]',
|
||||
enableTransitions: true,
|
||||
transitionDuration: 100, // Beschleunigt von 300ms auf 100ms
|
||||
skeletonTemplate: this.createSkeletonTemplate(),
|
||||
...options
|
||||
};
|
||||
|
||||
this.container = null;
|
||||
this.isLoading = false;
|
||||
this.currentUrl = window.location.href;
|
||||
this.abortController = null;
|
||||
|
||||
// Bind event handlers to preserve context for removal
|
||||
this.handleLinkClick = this.handleLinkClick.bind(this);
|
||||
this.handlePopState = this.handlePopState.bind(this);
|
||||
this.handleFormSubmit = this.handleFormSubmit.bind(this);
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
static create(options = {}) {
|
||||
return new SPARouter(options);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container = document.querySelector(this.options.containerSelector);
|
||||
|
||||
if (!this.container) {
|
||||
Logger.error(`[SPARouter] Container "${this.options.containerSelector}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindEvents();
|
||||
this.setupStyles();
|
||||
|
||||
// Handle initial page load for history
|
||||
this.updateHistoryState(window.location.href, document.title);
|
||||
|
||||
Logger.info('[SPARouter] Initialized');
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Intercept link clicks
|
||||
document.addEventListener('click', this.handleLinkClick);
|
||||
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('popstate', this.handlePopState);
|
||||
|
||||
// Handle form submissions that might redirect
|
||||
document.addEventListener('submit', this.handleFormSubmit);
|
||||
}
|
||||
|
||||
handleLinkClick(event) {
|
||||
const link = event.target.closest(this.options.linkSelector);
|
||||
|
||||
if (!link) return;
|
||||
|
||||
// Check for exclusions
|
||||
if (link.matches(this.options.excludeSelector)) return;
|
||||
|
||||
// Check for modifier keys (Ctrl, Cmd, etc.)
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const href = link.href;
|
||||
const title = link.title || link.textContent.trim();
|
||||
|
||||
this.navigate(href, title);
|
||||
}
|
||||
|
||||
handlePopState(event) {
|
||||
const url = window.location.href;
|
||||
|
||||
if (url !== this.currentUrl) {
|
||||
this.loadContent(url, false); // Don't update history on popstate
|
||||
}
|
||||
}
|
||||
|
||||
handleFormSubmit(event) {
|
||||
const form = event.target;
|
||||
|
||||
// Only handle forms that might redirect (non-AJAX forms)
|
||||
if (form.hasAttribute('data-spa') && form.getAttribute('data-spa') === 'false') return;
|
||||
if (form._moduleInstance) return; // Skip forms with form-handling module
|
||||
|
||||
// For now, let forms submit normally
|
||||
// Could be enhanced later to handle form submissions via SPA
|
||||
}
|
||||
|
||||
async navigate(url, title = '') {
|
||||
if (this.isLoading) {
|
||||
Logger.warn(`[SPARouter] Already loading, aborting previous request`);
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
if (url === this.currentUrl) {
|
||||
Logger.info(`[SPARouter] Already at ${url}, skipping navigation`);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.info(`[SPARouter] Navigating to: ${url}`);
|
||||
|
||||
try {
|
||||
await this.loadContent(url, true, title);
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
Logger.error('[SPARouter] Navigation failed:', error);
|
||||
// Fallback to normal navigation
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadContent(url, updateHistory = true, title = '') {
|
||||
// Prevent loading the same URL that's already current
|
||||
if (url === this.currentUrl && !updateHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isLoading) {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
this.showLoadingState();
|
||||
|
||||
// Request only the main content
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-SPA-Request': 'true', // Signal to backend this is an SPA request
|
||||
'Accept': 'application/json, text/html'
|
||||
},
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Check content type to determine how to parse response
|
||||
const contentType = response.headers.get('content-type');
|
||||
let newContent, newTitle;
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
// Backend sent JSON SPA response
|
||||
const jsonData = await response.json();
|
||||
newContent = jsonData.html;
|
||||
newTitle = jsonData.title || title;
|
||||
|
||||
// Update meta tags if provided
|
||||
if (jsonData.meta) {
|
||||
this.updateMetaTags(jsonData.meta);
|
||||
}
|
||||
} else {
|
||||
// Backend sent full HTML response - extract content
|
||||
const html = await response.text();
|
||||
newContent = this.extractMainContent(html);
|
||||
newTitle = this.extractTitle(html) || title;
|
||||
}
|
||||
|
||||
// Update content
|
||||
await this.updateContent(newContent, newTitle);
|
||||
|
||||
// Update browser history
|
||||
if (updateHistory) {
|
||||
this.updateHistoryState(url, newTitle);
|
||||
}
|
||||
|
||||
this.currentUrl = url;
|
||||
|
||||
Logger.info(`[SPARouter] Successfully loaded: ${url}`);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
this.hideLoadingState();
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
extractMainContent(html) {
|
||||
// Create a temporary DOM to parse the response
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const mainElement = doc.querySelector('main');
|
||||
|
||||
if (mainElement) {
|
||||
return mainElement.innerHTML;
|
||||
}
|
||||
|
||||
// Fallback: try to find content in common containers
|
||||
const fallbackSelectors = ['[role="main"]', '.main-content', '#main', '.content'];
|
||||
|
||||
for (const selector of fallbackSelectors) {
|
||||
const element = doc.querySelector(selector);
|
||||
if (element) {
|
||||
Logger.warn(`[SPARouter] Using fallback selector: ${selector}`);
|
||||
return element.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: use the entire body
|
||||
Logger.warn('[SPARouter] No main element found, using entire body');
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
extractTitle(html) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const titleElement = doc.querySelector('title');
|
||||
return titleElement ? titleElement.textContent.trim() : '';
|
||||
}
|
||||
|
||||
async updateContent(newContent, newTitle) {
|
||||
// Update page title
|
||||
if (newTitle) {
|
||||
document.title = newTitle;
|
||||
}
|
||||
|
||||
// Smooth transition
|
||||
if (this.options.enableTransitions) {
|
||||
await this.transitionOut();
|
||||
}
|
||||
|
||||
// Update content
|
||||
this.container.innerHTML = newContent;
|
||||
|
||||
// Re-initialize modules for new content
|
||||
this.reinitializeModules();
|
||||
|
||||
// Smooth transition in
|
||||
if (this.options.enableTransitions) {
|
||||
await this.transitionIn();
|
||||
}
|
||||
|
||||
this.hideLoadingState();
|
||||
|
||||
// Scroll to top
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
// Trigger custom event
|
||||
this.triggerNavigationEvent();
|
||||
}
|
||||
|
||||
showLoadingState() {
|
||||
document.body.classList.add(this.options.loadingClass);
|
||||
|
||||
// Show skeleton loader
|
||||
if (this.options.enableTransitions) {
|
||||
this.container.classList.add('spa-transitioning-out');
|
||||
}
|
||||
}
|
||||
|
||||
hideLoadingState() {
|
||||
document.body.classList.remove(this.options.loadingClass);
|
||||
}
|
||||
|
||||
async transitionOut() {
|
||||
return new Promise(resolve => {
|
||||
// Verwende schnellere cubic-bezier für snappigere Animation
|
||||
this.container.style.transition = `opacity ${this.options.transitionDuration}ms cubic-bezier(0.4, 0, 1, 1)`;
|
||||
this.container.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, this.options.transitionDuration);
|
||||
});
|
||||
}
|
||||
|
||||
async transitionIn() {
|
||||
return new Promise(resolve => {
|
||||
this.container.style.opacity = '0';
|
||||
|
||||
// Reduziere Verzögerung von 50ms auf 10ms
|
||||
setTimeout(() => {
|
||||
// Verwende schnellere cubic-bezier für snappigere Animation
|
||||
this.container.style.transition = `opacity ${this.options.transitionDuration}ms cubic-bezier(0, 0, 0.2, 1)`;
|
||||
this.container.style.opacity = '1';
|
||||
|
||||
setTimeout(() => {
|
||||
this.container.style.transition = '';
|
||||
this.container.classList.remove('spa-transitioning-out');
|
||||
resolve();
|
||||
}, this.options.transitionDuration);
|
||||
}, 10); // Von 50ms auf 10ms reduziert
|
||||
});
|
||||
}
|
||||
|
||||
updateHistoryState(url, title) {
|
||||
const state = { url, title, timestamp: Date.now() };
|
||||
|
||||
if (url !== window.location.href) {
|
||||
history.pushState(state, title, url);
|
||||
} else {
|
||||
history.replaceState(state, title, url);
|
||||
}
|
||||
}
|
||||
|
||||
reinitializeModules() {
|
||||
// Re-run form auto-enhancement for new content
|
||||
if (window.initAutoFormHandling) {
|
||||
window.initAutoFormHandling();
|
||||
}
|
||||
|
||||
// Re-initialize any data-module elements in new content
|
||||
const moduleElements = this.container.querySelectorAll('[data-module]');
|
||||
|
||||
moduleElements.forEach(element => {
|
||||
const moduleName = element.dataset.module;
|
||||
Logger.info(`[SPARouter] Re-initializing module "${moduleName}" on new content`);
|
||||
|
||||
// Trigger module initialization (would need access to module system)
|
||||
const event = new CustomEvent('spa:reinit-module', {
|
||||
detail: { element, moduleName },
|
||||
bubbles: true
|
||||
});
|
||||
element.dispatchEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
createSkeletonTemplate() {
|
||||
return `
|
||||
<div class="spa-skeleton">
|
||||
<div class="spa-skeleton-header"></div>
|
||||
<div class="spa-skeleton-content">
|
||||
<div class="spa-skeleton-line"></div>
|
||||
<div class="spa-skeleton-line"></div>
|
||||
<div class="spa-skeleton-line short"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupStyles() {
|
||||
if (document.getElementById('spa-router-styles')) return;
|
||||
|
||||
const styles = document.createElement('style');
|
||||
styles.id = 'spa-router-styles';
|
||||
styles.textContent = `
|
||||
/* SPA Router Transitions */
|
||||
.spa-loading {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.spa-transitioning-out {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Skeleton Loading Styles */
|
||||
.spa-skeleton {
|
||||
animation: spa-pulse 1.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.spa-skeleton-header {
|
||||
height: 2rem;
|
||||
background: #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.spa-skeleton-content {
|
||||
space-y: 0.75rem;
|
||||
}
|
||||
|
||||
.spa-skeleton-line {
|
||||
height: 1rem;
|
||||
background: #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.spa-skeleton-line.short {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
@keyframes spa-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.spa-skeleton-header,
|
||||
.spa-skeleton-line {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(styles);
|
||||
}
|
||||
|
||||
updateMetaTags(metaData) {
|
||||
// Update meta description
|
||||
if (metaData.description) {
|
||||
let metaDesc = document.querySelector('meta[name="description"]');
|
||||
if (metaDesc) {
|
||||
metaDesc.content = metaData.description;
|
||||
} else {
|
||||
metaDesc = document.createElement('meta');
|
||||
metaDesc.name = 'description';
|
||||
metaDesc.content = metaData.description;
|
||||
document.head.appendChild(metaDesc);
|
||||
}
|
||||
}
|
||||
|
||||
// Add other meta tag updates as needed
|
||||
Object.entries(metaData).forEach(([key, value]) => {
|
||||
if (key !== 'description' && value) {
|
||||
let metaTag = document.querySelector(`meta[name="${key}"]`);
|
||||
if (metaTag) {
|
||||
metaTag.content = value;
|
||||
} else {
|
||||
metaTag = document.createElement('meta');
|
||||
metaTag.name = key;
|
||||
metaTag.content = value;
|
||||
document.head.appendChild(metaTag);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
triggerNavigationEvent() {
|
||||
const event = new CustomEvent('spa:navigated', {
|
||||
detail: {
|
||||
url: this.currentUrl,
|
||||
container: this.container,
|
||||
timestamp: Date.now()
|
||||
},
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Public API
|
||||
navigateTo(url, title) {
|
||||
return this.navigate(url, title);
|
||||
}
|
||||
|
||||
getCurrentUrl() {
|
||||
return this.currentUrl;
|
||||
}
|
||||
|
||||
isNavigating() {
|
||||
return this.isLoading;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.abortController?.abort();
|
||||
|
||||
// Remove event listeners
|
||||
document.removeEventListener('click', this.handleLinkClick);
|
||||
window.removeEventListener('popstate', this.handlePopState);
|
||||
document.removeEventListener('submit', this.handleFormSubmit);
|
||||
|
||||
// Remove styles
|
||||
const styles = document.getElementById('spa-router-styles');
|
||||
if (styles) {
|
||||
styles.remove();
|
||||
}
|
||||
|
||||
Logger.info('[SPARouter] Destroyed');
|
||||
}
|
||||
}
|
||||
159
resources/js/modules/spa-router/index.js
Normal file
159
resources/js/modules/spa-router/index.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Logger } from '../../core/logger.js';
|
||||
import { SPARouter } from './SPARouter.js';
|
||||
import { getAdaptiveTransition, fastTransition } from './transition-config.js';
|
||||
|
||||
/**
|
||||
* SPA Router Module
|
||||
*
|
||||
* Provides Single Page Application navigation:
|
||||
* - Intercepts internal links and loads content via AJAX
|
||||
* - Updates only the <main> element, keeping header/footer unchanged
|
||||
* - Manages browser history (back/forward buttons work)
|
||||
* - Shows skeleton loading states during navigation
|
||||
* - Progressive enhancement (falls back to normal navigation on errors)
|
||||
*
|
||||
* Features:
|
||||
* - Automatic link interception for all internal links (href="/...")
|
||||
* - Opt-out via data-spa="false"
|
||||
* - Browser history management with pushState
|
||||
* - Loading states and smooth transitions
|
||||
* - Module re-initialization after content changes
|
||||
* - Skeleton loading animation
|
||||
*
|
||||
* Backend Integration:
|
||||
* - Sends X-SPA-Request header to signal SPA navigation
|
||||
* - Backend can return only <main> content for SPA requests
|
||||
* - Falls back to full page load if SPA request fails
|
||||
*/
|
||||
|
||||
const SPARouterModule = {
|
||||
name: 'spa-router',
|
||||
router: null,
|
||||
initialized: false,
|
||||
|
||||
init(config = {}) {
|
||||
// Prevent multiple initialization
|
||||
if (this.initialized && this.router) {
|
||||
Logger.warn('[SPARouterModule] SPA Router already initialized, returning existing instance');
|
||||
return this.router;
|
||||
}
|
||||
|
||||
Logger.info('[SPARouterModule] Initializing SPA Router');
|
||||
|
||||
// Default configuration mit schnelleren Transitions
|
||||
const defaultConfig = {
|
||||
containerSelector: 'main',
|
||||
linkSelector: 'a[href^="/"]',
|
||||
excludeSelector: '[data-spa="false"], [download], [target="_blank"], [href^="mailto:"], [href^="tel:"], [href^="#"]',
|
||||
enableSkeletonLoading: true,
|
||||
...fastTransition, // Verwende schnelle Transitions als Standard
|
||||
...getAdaptiveTransition() // Überschreibe mit adaptiven Einstellungen
|
||||
};
|
||||
|
||||
const options = { ...defaultConfig, ...config };
|
||||
|
||||
// Initialize router
|
||||
this.router = SPARouter.create(options);
|
||||
this.initialized = true;
|
||||
|
||||
// Set up global access
|
||||
if (typeof window !== 'undefined') {
|
||||
window.spaRouter = this.router;
|
||||
}
|
||||
|
||||
// Listen for module re-initialization events
|
||||
document.addEventListener('spa:reinit-module', this.handleModuleReinit.bind(this));
|
||||
|
||||
// Listen for navigation events for debugging
|
||||
document.addEventListener('spa:navigated', this.handleNavigation.bind(this));
|
||||
|
||||
Logger.info('[SPARouterModule] SPA Router initialized successfully');
|
||||
|
||||
return this.router;
|
||||
},
|
||||
|
||||
handleModuleReinit(event) {
|
||||
const { element, moduleName } = event.detail;
|
||||
|
||||
Logger.info(`[SPARouterModule] Re-initializing module: ${moduleName}`, element);
|
||||
|
||||
// This would need access to the main module system
|
||||
// For now, we'll just log and let the main init system handle it
|
||||
|
||||
// Trigger a custom event that the main module system can listen to
|
||||
const reinitEvent = new CustomEvent('module:reinit-needed', {
|
||||
detail: { element, moduleName },
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
document.dispatchEvent(reinitEvent);
|
||||
},
|
||||
|
||||
handleNavigation(event) {
|
||||
const { url, timestamp } = event.detail;
|
||||
|
||||
Logger.info(`[SPARouterModule] Navigation completed to: ${url}`);
|
||||
|
||||
// Re-run auto form enhancement for new content
|
||||
if (typeof window.initAutoFormHandling === 'function') {
|
||||
// Only re-initialize forms that are not already enhanced
|
||||
setTimeout(() => {
|
||||
window.initAutoFormHandling();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Trigger analytics or other tracking if needed
|
||||
if (typeof window.gtag === 'function') {
|
||||
window.gtag('config', 'GA_TRACKING_ID', {
|
||||
page_path: new URL(url).pathname
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Public API methods
|
||||
navigateTo(url, title) {
|
||||
if (this.router) {
|
||||
return this.router.navigateTo(url, title);
|
||||
}
|
||||
Logger.warn('[SPARouterModule] Router not initialized');
|
||||
},
|
||||
|
||||
getCurrentUrl() {
|
||||
return this.router?.getCurrentUrl() || window.location.href;
|
||||
},
|
||||
|
||||
isNavigating() {
|
||||
return this.router?.isNavigating() || false;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.router) {
|
||||
this.router.destroy();
|
||||
this.router = null;
|
||||
}
|
||||
|
||||
this.initialized = false;
|
||||
|
||||
// Remove global reference
|
||||
if (typeof window !== 'undefined' && window.spaRouter) {
|
||||
delete window.spaRouter;
|
||||
}
|
||||
|
||||
document.removeEventListener('spa:reinit-module', this.handleModuleReinit);
|
||||
document.removeEventListener('spa:navigated', this.handleNavigation);
|
||||
|
||||
Logger.info('[SPARouterModule] SPA Router destroyed');
|
||||
}
|
||||
};
|
||||
|
||||
// Export the router class for direct usage
|
||||
export { SPARouter };
|
||||
|
||||
// Export as default for module system
|
||||
export default SPARouterModule;
|
||||
|
||||
// Export init function directly for compatibility with module system
|
||||
export const init = SPARouterModule.init.bind(SPARouterModule);
|
||||
|
||||
// Also export named for direct usage
|
||||
export { SPARouterModule };
|
||||
67
resources/js/modules/spa-router/transition-config.js
Normal file
67
resources/js/modules/spa-router/transition-config.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* SPA Router Transition Configuration
|
||||
*
|
||||
* Verschiedene Transition-Presets für unterschiedliche Use-Cases
|
||||
*/
|
||||
|
||||
// Ultra-schnelle Transitions (fast keine sichtbare Animation)
|
||||
export const instantTransition = {
|
||||
enableTransitions: true,
|
||||
transitionDuration: 50 // 50ms - kaum wahrnehmbar
|
||||
};
|
||||
|
||||
// Schnelle Transitions (snappy, aber noch sichtbar)
|
||||
export const fastTransition = {
|
||||
enableTransitions: true,
|
||||
transitionDuration: 100 // 100ms - schnell und responsiv
|
||||
};
|
||||
|
||||
// Standard Transitions (balanced)
|
||||
export const standardTransition = {
|
||||
enableTransitions: true,
|
||||
transitionDuration: 200 // 200ms - ausgewogen
|
||||
};
|
||||
|
||||
// Langsame Transitions (smooth, aber träge)
|
||||
export const slowTransition = {
|
||||
enableTransitions: true,
|
||||
transitionDuration: 300 // 300ms - original Wert
|
||||
};
|
||||
|
||||
// Keine Transitions (instant switch)
|
||||
export const noTransition = {
|
||||
enableTransitions: false,
|
||||
transitionDuration: 0
|
||||
};
|
||||
|
||||
// Performance-basierte Konfiguration
|
||||
export function getAdaptiveTransition() {
|
||||
// Check for reduced motion preference
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
return noTransition;
|
||||
}
|
||||
|
||||
// Check device performance
|
||||
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
|
||||
if (connection) {
|
||||
// Schnelle Verbindung = schnellere Transitions
|
||||
if (connection.effectiveType === '4g') {
|
||||
return instantTransition;
|
||||
} else if (connection.effectiveType === '3g') {
|
||||
return fastTransition;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if device is likely mobile (rough detection)
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
|
||||
if (isMobile) {
|
||||
return fastTransition; // Mobile devices: schnellere Transitions
|
||||
}
|
||||
|
||||
return fastTransition; // Default zu schnell
|
||||
}
|
||||
|
||||
// Export default configuration
|
||||
export default fastTransition;
|
||||
@@ -1,23 +1,121 @@
|
||||
// modules/ui/UIManager.js
|
||||
import { Modal } from './components/Modal.js';
|
||||
import { Lightbox } from './components/Lightbox.js';
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} UIComponentProps
|
||||
* @property {string} [content] - HTML content for the component
|
||||
* @property {Function} [onClose] - Callback when component closes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available UI components
|
||||
* @type {Object<string, Function>}
|
||||
*/
|
||||
const components = {
|
||||
modal: Modal,
|
||||
lightbox: Lightbox,
|
||||
};
|
||||
|
||||
/**
|
||||
* Active component instances (singletons)
|
||||
* @type {Object<string, Object>}
|
||||
*/
|
||||
const activeInstances = {};
|
||||
|
||||
/**
|
||||
* UI Manager for creating and managing UI components
|
||||
* @namespace UIManager
|
||||
*/
|
||||
export const UIManager = {
|
||||
/**
|
||||
* Open a UI component (reuses existing instance for singletons)
|
||||
* @param {string} type - Component type (e.g., 'modal', 'lightbox')
|
||||
* @param {UIComponentProps} [props={}] - Component properties
|
||||
* @returns {Object|null} Component instance or null if type unknown
|
||||
*/
|
||||
open(type, props = {}) {
|
||||
const Component = components[type];
|
||||
if (!Component) {
|
||||
console.warn(`[UIManager] Unknown type: ${type}`);
|
||||
Logger.warn(`[UIManager] Unknown type: ${type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// For lightbox, reuse existing instance for efficiency
|
||||
if (type === 'lightbox') {
|
||||
if (activeInstances.lightbox) {
|
||||
Logger.info('[UIManager] Reusing existing lightbox instance');
|
||||
|
||||
// Update content and reopen
|
||||
activeInstances.lightbox.updateContent(props.content || '');
|
||||
|
||||
// Always call open() to ensure it's shown, regardless of current state
|
||||
activeInstances.lightbox.open();
|
||||
|
||||
return activeInstances.lightbox;
|
||||
} else {
|
||||
Logger.info('[UIManager] Creating new lightbox instance');
|
||||
|
||||
// Create new instance and store it
|
||||
const instance = new Component({
|
||||
...props,
|
||||
onClose: () => {
|
||||
// Don't delete the instance, just close it for reuse
|
||||
Logger.info('[UIManager] Lightbox closed, instance kept for reuse');
|
||||
if (props.onClose) props.onClose();
|
||||
}
|
||||
});
|
||||
activeInstances.lightbox = instance;
|
||||
instance.open();
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
// For other components, create new instances
|
||||
const instance = new Component(props);
|
||||
instance.open();
|
||||
return instance;
|
||||
},
|
||||
|
||||
/**
|
||||
* Close a UI component instance
|
||||
* @param {Object} instance - Component instance to close
|
||||
*/
|
||||
close(instance) {
|
||||
if (instance?.close) instance.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Close a component by type
|
||||
* @param {string} type - Component type to close
|
||||
*/
|
||||
closeByType(type) {
|
||||
if (activeInstances[type]) {
|
||||
this.close(activeInstances[type]);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a component is currently open
|
||||
* @param {string} type - Component type to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOpen(type) {
|
||||
return activeInstances[type]?.isOpen() || false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Destroy all active instances (cleanup)
|
||||
*/
|
||||
destroyAll() {
|
||||
Object.values(activeInstances).forEach(instance => {
|
||||
if (instance?.destroy) {
|
||||
instance.destroy();
|
||||
}
|
||||
});
|
||||
Object.keys(activeInstances).forEach(key => {
|
||||
delete activeInstances[key];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,39 +3,125 @@ import {useEvent} from "../../../core/useEvent";
|
||||
export class Dialog {
|
||||
constructor({content = '', className = '', onClose = null} = {}) {
|
||||
this.onClose = onClose;
|
||||
this.className = className;
|
||||
this.isOpenState = false;
|
||||
this.dialog = document.createElement('dialog');
|
||||
this.dialog.className = className;
|
||||
this.dialog.innerHTML = `
|
||||
<form method="dialog" class="${className}-content">
|
||||
${content}
|
||||
<button class="${className}-close" value="close">×</button>
|
||||
</form>
|
||||
`;
|
||||
this.eventCleanup = []; // Track cleanup functions
|
||||
|
||||
this.updateContent(content);
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
useEvent(this.dialog, 'click', (e) => {
|
||||
const isOutside = !e.target.closest(className+'-content');
|
||||
/**
|
||||
* Bind event handlers to the dialog
|
||||
*/
|
||||
bindEvents() {
|
||||
// Clean up any existing event listeners first
|
||||
this.cleanupEvents();
|
||||
|
||||
// Store references to bound event handlers for cleanup
|
||||
this.clickHandler = (e) => {
|
||||
const isOutside = !e.target.closest('.' + this.className + '-content');
|
||||
if (isOutside) this.close();
|
||||
})
|
||||
};
|
||||
|
||||
useEvent(this.dialog, 'cancel', (e) => {
|
||||
this.cancelHandler = (e) => {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
})
|
||||
|
||||
};
|
||||
|
||||
// Use direct event listeners instead of useEvent to avoid module-based cleanup issues
|
||||
this.dialog.addEventListener('click', this.clickHandler);
|
||||
this.dialog.addEventListener('cancel', this.cancelHandler);
|
||||
|
||||
// Store cleanup functions
|
||||
this.eventCleanup = [
|
||||
() => this.dialog.removeEventListener('click', this.clickHandler),
|
||||
() => this.dialog.removeEventListener('cancel', this.cancelHandler)
|
||||
];
|
||||
}
|
||||
|
||||
open()
|
||||
{
|
||||
document.body.appendChild(this.dialog);
|
||||
/**
|
||||
* Clean up existing event listeners
|
||||
*/
|
||||
cleanupEvents() {
|
||||
this.eventCleanup.forEach(cleanup => cleanup());
|
||||
this.eventCleanup = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the content of the dialog without recreating it
|
||||
* @param {string} content - New HTML content
|
||||
*/
|
||||
updateContent(content) {
|
||||
this.dialog.innerHTML = `
|
||||
<form method="dialog" class="${this.className}-content">
|
||||
${content}
|
||||
<button class="${this.className}-close" value="close">×</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
// No need to rebind events - they're attached to the dialog element itself
|
||||
// which doesn't get replaced by innerHTML
|
||||
}
|
||||
|
||||
open() {
|
||||
// Always ensure dialog is in DOM
|
||||
if (!this.dialog.parentElement) {
|
||||
document.body.appendChild(this.dialog);
|
||||
}
|
||||
|
||||
// Force close if somehow already open
|
||||
if (this.dialog.hasAttribute('open') || this.dialog.open) {
|
||||
this.dialog.close?.() || this.dialog.removeAttribute('open');
|
||||
}
|
||||
|
||||
// Now open fresh
|
||||
this.dialog.showModal?.() || this.dialog.setAttribute('open', '');
|
||||
document.documentElement.dataset[`${this.dialog.className}Open`] = 'true';
|
||||
this.isOpenState = true;
|
||||
}
|
||||
|
||||
close()
|
||||
{
|
||||
this.dialog.close?.() || this.dialog.removeAttribute('open');
|
||||
this.dialog.remove();
|
||||
delete document.documentElement.dataset[`${this.dialog.className}Open`];
|
||||
this.onClose?.();
|
||||
close() {
|
||||
if (this.isOpenState) {
|
||||
this.dialog.close?.() || this.dialog.removeAttribute('open');
|
||||
delete document.documentElement.dataset[`${this.dialog.className}Open`];
|
||||
this.isOpenState = false;
|
||||
|
||||
// Keep dialog in DOM for reuse, just hide it
|
||||
// Don't remove from DOM to enable singleton pattern
|
||||
|
||||
this.onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dialog is currently open (checks both state and DOM)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOpen() {
|
||||
// Check both our internal state and the actual DOM state
|
||||
const domIsOpen = this.dialog && (this.dialog.hasAttribute('open') || this.dialog.open);
|
||||
|
||||
// Sync our internal state with DOM reality
|
||||
if (domIsOpen !== this.isOpenState) {
|
||||
this.isOpenState = domIsOpen;
|
||||
}
|
||||
|
||||
return this.isOpenState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the dialog completely (for cleanup)
|
||||
*/
|
||||
destroy() {
|
||||
if (this.isOpenState) {
|
||||
this.close();
|
||||
}
|
||||
this.cleanupEvents();
|
||||
if (this.dialog.parentElement) {
|
||||
this.dialog.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { UIManager } from './UIManager.js';
|
||||
import { Logger } from '../../core/logger.js';
|
||||
|
||||
export function init() {
|
||||
|
||||
/*UIManager.open('modal', {
|
||||
content: '<p>Hallo!</p><button class="modal-close">OK</button>',
|
||||
onClose: () => console.log('Modal wurde geschlossen')
|
||||
onClose: () => Logger.info('Modal wurde geschlossen')
|
||||
});*/
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Logger } from './core/logger.js';
|
||||
|
||||
export function registerServiceWorker() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
@@ -18,6 +20,8 @@ export function registerServiceWorker() {
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(err => console.error('❌ SW-Fehler:', err));
|
||||
.catch(err => {
|
||||
Logger.error('Service Worker Fehler:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,61 @@
|
||||
/**
|
||||
* Simple cache implementation with TTL support
|
||||
*/
|
||||
export class SimpleCache {
|
||||
constructor(maxSize = 20, ttl = 60000) {
|
||||
constructor(maxSize = 20, defaultTTL = 60000) {
|
||||
this.cache = new Map();
|
||||
this.maxSize = maxSize;
|
||||
this.ttl = ttl;
|
||||
this.ttl = defaultTTL;
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const cached = this.cache.get(key);
|
||||
if (!cached) return null;
|
||||
if (Date.now() - cached.timestamp > this.ttl) {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
// Check if entry is expired
|
||||
if (Date.now() - entry.timestamp > this.ttl) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return cached.data;
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
set(key, data) {
|
||||
set(key, value) {
|
||||
// Remove oldest entries if cache is full
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
// Entferne das älteste Element
|
||||
const oldest = [...this.cache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0][0];
|
||||
this.cache.delete(oldest);
|
||||
const oldestKey = [...this.cache.entries()]
|
||||
.sort((a, b) => a[1].timestamp - b[1].timestamp)[0][0];
|
||||
this.cache.delete(oldestKey);
|
||||
}
|
||||
this.cache.set(key, { data, timestamp: Date.now() });
|
||||
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.get(key) !== null;
|
||||
const entry = this.get(key);
|
||||
return entry !== null;
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
// Clean up expired entries
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
if (now - value.timestamp > this.ttl) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user