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:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -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;
}

View 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();
}

View 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();

View 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);
});

View 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();
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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');
}
}
}

View 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;
}

View File

@@ -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', () => {

View File

@@ -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);
});
}

View File

@@ -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
}
```

View File

@@ -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')) {

View 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');
}
}

View 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()
};
}
}

View 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)
};
}
}

View 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')
}))
};
}
}

View 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
}));
}
}

View 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()
};
}
}

View 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');
}
}

View 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())
};
}
}

View 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();
}
};
}

View 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;

View 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');
}
}

View 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];
}
};

View 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);
}
}
}
};

View 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();
}
};

View 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;

View 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;

View File

@@ -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;
}

View 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;

View 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');
}
}

View 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');
}
}

View 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;
}
}

View 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 };

View File

@@ -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;
}

View File

@@ -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
}

View 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;
}

View File

@@ -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'); }

View File

@@ -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)

View 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');
}
}

View 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 };

View 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;

View File

@@ -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];
});
}
};

View File

@@ -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();
}
}
}

View File

@@ -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')
});*/
}

View File

@@ -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);
});
}
}

View File

@@ -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);
}
}
}
}