fix: Gitea Traefik routing and connection pool optimization
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled

- Remove middleware reference from Gitea Traefik labels (caused routing issues)
- Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s)
- Add explicit service reference in Traefik labels
- Fix intermittent 504 timeouts by improving PostgreSQL connection handling

Fixes Gitea unreachability via git.michaelschiemer.de
This commit is contained in:
2025-11-09 14:46:15 +01:00
parent 85c369e846
commit 36ef2a1e2c
1366 changed files with 104925 additions and 28719 deletions

View File

@@ -0,0 +1,19 @@
/**
* Generated Design Tokens - DO NOT EDIT MANUALLY
*
* This file is automatically generated from AdminTokenRegistry.
* To modify tokens, edit: src/Application/Admin/ValueObjects/AdminTokenRegistry.php
*
* Generated: 2025-11-07 (Placeholder - run 'php console.php design:generate-tokens --scope=admin' to regenerate)
* Source: AdminTokenRegistry
* Scope: admin
* Architecture: ITCSS
*
* NOTE: This is a placeholder file. Run the following command to generate the actual tokens:
* php console.php design:generate-tokens --scope=admin
*/
@layer admin-settings {
/* Placeholder - tokens will be generated by design:generate-tokens command */
}

View File

@@ -14,7 +14,7 @@
*/
/* Layer 1: Settings */
@import "./01-settings/_tokens.css";
@import "./01-settings/_generated-tokens.css";
@import "./01-settings/_breakpoints.css";
/* Layer 2: Tools */

View File

@@ -2,7 +2,6 @@ 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';
// WebPushManager is now loaded via framework module system
// LiveComponent is now loaded via framework module system
@@ -29,11 +28,7 @@ document.addEventListener("DOMContentLoaded", async () => {
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`);

View File

@@ -13,6 +13,8 @@
*/
import { Core } from '../core.js';
import { PerformanceProfiler, LiveComponentsProfiler } from '../performance-profiler/profiler.js';
import { FlamegraphVisualizer, TimelineVisualizer } from '../performance-profiler/visualizer.js';
export class LiveComponentDevTools {
constructor() {
@@ -26,12 +28,21 @@ export class LiveComponentDevTools {
this.domBadges = new Map(); // Track DOM badges for cleanup
this.badgesEnabled = true; // Badge visibility toggle
// Performance profiling state
// Performance profiling state - using PerformanceProfiler
this.profiler = new PerformanceProfiler({
enabled: true,
maxSamples: 1000,
samplingInterval: 10,
autoStart: false
});
this.flamegraphVisualizer = null;
this.timelineVisualizer = null;
this.isRecording = false;
this.performanceRecording = [];
this.performanceRecording = []; // Legacy data for backward compatibility
this.componentRenderTimes = new Map();
this.actionExecutionTimes = new Map();
this.memorySnapshots = [];
this.componentProfilers = new Map(); // Track profilers for each component
this.overlay = null;
this.isEnabled = this.checkIfEnabled();
@@ -163,8 +174,14 @@ export class LiveComponentDevTools {
<button class="lc-devtools__btn lc-devtools__btn--small" data-action="clear-performance">
Clear
</button>
<button class="lc-devtools__btn lc-devtools__btn--small" data-action="export-performance">
Export
</button>
</div>
<div class="lc-devtools__metrics" data-content="performance">
<div id="lc-flamegraph-container" style="margin-bottom: 16px;"></div>
<div id="lc-timeline-container" style="margin-bottom: 16px;"></div>
</div>
<div class="lc-devtools__metrics" data-content="performance"></div>
</div>
<div class="lc-devtools__pane" data-pane="network">
@@ -500,6 +517,10 @@ export class LiveComponentDevTools {
this.clearPerformanceData();
});
this.overlay.querySelector('[data-action="export-performance"]')?.addEventListener('click', () => {
this.exportPerformanceData();
});
this.overlay.querySelector('[data-action="clear-network"]')?.addEventListener('click', () => {
this.clearNetworkLog();
});
@@ -616,6 +637,11 @@ export class LiveComponentDevTools {
pane.classList.toggle('lc-devtools__pane--active', pane.dataset.pane === tabName);
});
// Initialize visualizers when switching to performance tab
if (tabName === 'performance') {
this.initializePerformanceVisualizers();
}
// Refresh content for active tab
this.refreshActiveTab();
}
@@ -956,14 +982,80 @@ export class LiveComponentDevTools {
* Clear performance data
*/
clearPerformanceData() {
// Clear profiler data
this.profiler.clear();
// Clear legacy data
this.performanceData = [];
this.performanceRecording = [];
this.componentRenderTimes.clear();
this.actionExecutionTimes.clear();
this.memorySnapshots = [];
// Clear visualizers
if (this.flamegraphVisualizer) {
this.flamegraphVisualizer.clear();
}
if (this.timelineVisualizer) {
this.timelineVisualizer.clear();
}
this.renderPerformanceData();
}
/**
* Export performance data
*/
exportPerformanceData() {
const status = this.profiler.getStatus();
if (status.measuresCount === 0 && status.marksCount === 0 && status.samplesCount === 0) {
alert('No performance data to export. Start recording first.');
return;
}
// Export Chrome DevTools trace format
const traceData = this.profiler.exportToChromeTrace();
// Create download
const blob = new Blob([JSON.stringify(traceData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `livecomponent-performance-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('[LiveComponent DevTools] Performance data exported');
}
/**
* Initialize performance visualizers
*/
initializePerformanceVisualizers() {
const flamegraphContainer = this.overlay.querySelector('#lc-flamegraph-container');
const timelineContainer = this.overlay.querySelector('#lc-timeline-container');
if (flamegraphContainer && !this.flamegraphVisualizer) {
this.flamegraphVisualizer = new FlamegraphVisualizer(flamegraphContainer, {
width: flamegraphContainer.clientWidth || 750,
height: 300,
barHeight: 20,
colorScheme: 'category'
});
}
if (timelineContainer && !this.timelineVisualizer) {
this.timelineVisualizer = new TimelineVisualizer(timelineContainer, {
width: timelineContainer.clientWidth || 750,
height: 200,
trackHeight: 30
});
}
}
/**
* Toggle performance recording
*/
@@ -989,6 +1081,11 @@ export class LiveComponentDevTools {
*/
startPerformanceRecording() {
console.log('[LiveComponent DevTools] Performance recording started');
// Start the profiler
this.profiler.start();
// Clear legacy data
this.performanceRecording = [];
this.memorySnapshots = [];
@@ -1011,6 +1108,12 @@ export class LiveComponentDevTools {
stopPerformanceRecording() {
console.log('[LiveComponent DevTools] Performance recording stopped');
// Stop the profiler and get results
const results = this.profiler.stop();
if (results) {
console.log('[LiveComponent DevTools] Performance results:', results);
}
if (this.memorySnapshotInterval) {
clearInterval(this.memorySnapshotInterval);
}
@@ -1027,6 +1130,31 @@ export class LiveComponentDevTools {
* Record action execution for performance profiling
*/
recordActionExecution(componentId, actionName, duration, startTime, endTime) {
// Use profiler marks and measures if recording
if (this.profiler.isRecording) {
// Create synthetic marks based on the actual times
// Note: The profiler uses performance.now() internally, so we need to adjust
const markStart = `action:${componentId}:${actionName}:start`;
const markEnd = `action:${componentId}:${actionName}:end`;
const measureName = `action:${componentId}:${actionName}`;
// Record start mark (using current time as reference)
this.profiler.mark(markStart, {
componentId,
actionName,
type: 'action',
originalStartTime: startTime,
originalEndTime: endTime
});
// Record end mark immediately (profiler will calculate duration from marks)
this.profiler.mark(markEnd);
// Create measure - the profiler will calculate duration from the marks
this.profiler.measure(measureName, markStart, markEnd);
}
// Legacy recording for backward compatibility
this.performanceRecording.push({
type: 'action',
componentId,
@@ -1054,8 +1182,30 @@ export class LiveComponentDevTools {
* Record component render for performance profiling
*/
recordComponentRender(componentId, duration, startTime, endTime) {
if (!this.isRecording) return;
if (!this.isRecording && !this.profiler.isRecording) return;
// Use profiler marks and measures if recording
if (this.profiler.isRecording) {
const markStart = `render:${componentId}:start`;
const markEnd = `render:${componentId}:end`;
const measureName = `render:${componentId}`;
// Record start mark
this.profiler.mark(markStart, {
componentId,
type: 'render',
originalStartTime: startTime,
originalEndTime: endTime
});
// Record end mark immediately
this.profiler.mark(markEnd);
// Create measure
this.profiler.measure(measureName, markStart, markEnd);
}
// Legacy recording for backward compatibility
this.performanceRecording.push({
type: 'render',
componentId,
@@ -1103,40 +1253,99 @@ export class LiveComponentDevTools {
const container = this.overlay.querySelector('[data-content="performance"]');
if (!container) return;
if (this.performanceRecording.length === 0 && this.memorySnapshots.length === 0) {
container.innerHTML = `
<div style="color: #969696; text-align: center; padding: 40px;">
// Get profiler status
const status = this.profiler.getStatus();
const hasData = status.samplesCount > 0 || status.marksCount > 0 || status.measuresCount > 0;
const hasLegacyData = this.performanceRecording.length > 0 || this.memorySnapshots.length > 0;
// Initialize visualizers if not already done
this.initializePerformanceVisualizers();
// Get containers for visualizers (they should already exist in the DOM)
const flamegraphContainer = this.overlay.querySelector('#lc-flamegraph-container');
const timelineContainer = this.overlay.querySelector('#lc-timeline-container');
if (!hasData && !hasLegacyData) {
// Clear visualizers
if (this.flamegraphVisualizer) {
this.flamegraphVisualizer.clear();
}
if (this.timelineVisualizer) {
this.timelineVisualizer.clear();
}
// Show empty state but preserve visualizer containers
const existingContent = container.querySelector('.lc-perf-empty-state');
if (!existingContent) {
const emptyState = document.createElement('div');
emptyState.className = 'lc-perf-empty-state';
emptyState.style.cssText = 'color: #969696; text-align: center; padding: 40px;';
emptyState.innerHTML = `
<p>No performance data recorded</p>
<p style="font-size: 12px; margin-top: 12px;">Click "● Record" to start profiling</p>
</div>
`;
`;
container.insertBefore(emptyState, flamegraphContainer);
}
return;
}
let html = '<div class="lc-performance-view" style="padding: 0;">';
// Performance Summary
html += this.renderPerformanceSummary();
// Flamegraph
html += this.renderFlamegraph();
// Timeline
html += this.renderPerformanceTimeline();
// Memory Usage Chart
if (this.memorySnapshots.length > 0) {
html += this.renderMemoryChart();
// Remove empty state if present
const emptyState = container.querySelector('.lc-perf-empty-state');
if (emptyState) {
emptyState.remove();
}
html += '</div>';
container.innerHTML = html;
// Render summary
const summaryHtml = this.renderPerformanceSummary();
// Insert or update summary (before visualizer containers)
const existingSummary = container.querySelector('.lc-perf-summary');
if (existingSummary) {
existingSummary.outerHTML = summaryHtml;
} else {
container.insertAdjacentHTML('afterbegin', summaryHtml);
}
// Render flamegraph if we have data
if (this.flamegraphVisualizer && hasData && flamegraphContainer) {
const flamegraphData = this.profiler.generateFlamegraph();
if (flamegraphData) {
this.flamegraphVisualizer.render(flamegraphData);
} else {
this.flamegraphVisualizer.clear();
}
}
// Render timeline if we have data
if (this.timelineVisualizer && hasData && timelineContainer) {
const timelineData = this.profiler.generateTimeline();
if (timelineData && timelineData.length > 0) {
this.timelineVisualizer.render(timelineData);
} else {
this.timelineVisualizer.clear();
}
}
// Render legacy memory chart if available
if (this.memorySnapshots.length > 0) {
const existingMemoryChart = container.querySelector('.lc-memory-chart');
const memoryChartHtml = this.renderMemoryChart();
if (existingMemoryChart) {
existingMemoryChart.outerHTML = memoryChartHtml;
} else {
container.insertAdjacentHTML('beforeend', memoryChartHtml);
}
}
}
/**
* Render performance summary
*/
renderPerformanceSummary() {
const status = this.profiler.getStatus();
const summary = this.profiler.generateSummary();
// Legacy data counts
const actionCount = this.performanceRecording.filter(r => r.type === 'action').length;
const renderCount = this.performanceRecording.filter(r => r.type === 'render').length;
@@ -1144,15 +1353,9 @@ export class LiveComponentDevTools {
? this.performanceRecording
.filter(r => r.type === 'action')
.reduce((sum, r) => sum + r.duration, 0) / actionCount
: 0;
: (summary.avgDuration || 0);
const avgRenderTime = renderCount > 0
? this.performanceRecording
.filter(r => r.type === 'render')
.reduce((sum, r) => sum + r.duration, 0) / renderCount
: 0;
const totalDuration = this.performanceRecording.reduce((sum, r) => sum + r.duration, 0);
const totalDuration = summary.totalDuration || this.performanceRecording.reduce((sum, r) => sum + r.duration, 0);
return `
<div class="lc-perf-summary" style="padding: 16px; border-bottom: 1px solid #3c3c3c; background: #252526;">
@@ -1160,18 +1363,18 @@ export class LiveComponentDevTools {
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; font-size: 12px;">
<div>
<div style="color: #858585; margin-bottom: 4px;">Total Events</div>
<div style="color: #4ec9b0; font-size: 18px; font-weight: 600;">${this.performanceRecording.length}</div>
<div style="color: #4ec9b0; font-size: 18px; font-weight: 600;">${status.timelineEventsCount || this.performanceRecording.length}</div>
</div>
<div>
<div style="color: #858585; margin-bottom: 4px;">Actions</div>
<div style="color: #dcdcaa; font-size: 18px; font-weight: 600;">${actionCount}</div>
<div style="color: #858585; margin-bottom: 4px;">Measures</div>
<div style="color: #dcdcaa; font-size: 18px; font-weight: 600;">${status.measuresCount || actionCount}</div>
</div>
<div>
<div style="color: #858585; margin-bottom: 4px;">Renders</div>
<div style="color: #569cd6; font-size: 18px; font-weight: 600;">${renderCount}</div>
<div style="color: #858585; margin-bottom: 4px;">Samples</div>
<div style="color: #569cd6; font-size: 18px; font-weight: 600;">${status.samplesCount}</div>
</div>
<div>
<div style="color: #858585; margin-bottom: 4px;">Avg Action</div>
<div style="color: #858585; margin-bottom: 4px;">Avg Duration</div>
<div style="color: #dcdcaa; font-size: 18px; font-weight: 600;">${avgActionTime.toFixed(2)}ms</div>
</div>
<div>
@@ -1179,6 +1382,13 @@ export class LiveComponentDevTools {
<div style="color: #f48771; font-size: 18px; font-weight: 600;">${totalDuration.toFixed(2)}ms</div>
</div>
</div>
${summary.percentiles ? `
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #3c3c3c; font-size: 11px; color: #858585;">
<strong>Percentiles:</strong> P50: ${summary.percentiles.p50?.toFixed(2) || 'N/A'}ms |
P90: ${summary.percentiles.p90?.toFixed(2) || 'N/A'}ms |
P99: ${summary.percentiles.p99?.toFixed(2) || 'N/A'}ms
</div>
` : ''}
</div>
`;
}

View File

@@ -0,0 +1,406 @@
/**
* Analytics Module
*
* Provides unified analytics system for event tracking, page views, and user behavior.
* Features:
* - Event tracking
* - Page view tracking
* - User behavior tracking
* - Custom events
* - Integration with LiveComponents
* - GDPR compliance
*/
import { Logger } from '../../core/logger.js';
/**
* AnalyticsProvider - Base class for analytics providers
*/
export class AnalyticsProvider {
constructor(config = {}) {
this.config = config;
this.enabled = config.enabled ?? true;
}
/**
* Track event
*/
async track(eventName, properties = {}) {
if (!this.enabled) return;
// Override in subclasses
}
/**
* Track page view
*/
async pageView(path, properties = {}) {
if (!this.enabled) return;
// Override in subclasses
}
/**
* Identify user
*/
async identify(userId, traits = {}) {
if (!this.enabled) return;
// Override in subclasses
}
}
/**
* GoogleAnalyticsProvider - Google Analytics integration
*/
export class GoogleAnalyticsProvider extends AnalyticsProvider {
constructor(config = {}) {
super(config);
this.measurementId = config.measurementId || config.gaId;
}
async track(eventName, properties = {}) {
if (typeof gtag !== 'undefined') {
gtag('event', eventName, properties);
}
}
async pageView(path, properties = {}) {
if (typeof gtag !== 'undefined') {
gtag('config', this.measurementId, {
page_path: path,
...properties
});
}
}
}
/**
* CustomProvider - Custom analytics endpoint
*/
export class CustomProvider extends AnalyticsProvider {
constructor(config = {}) {
super(config);
this.endpoint = config.endpoint || '/api/analytics';
}
async track(eventName, properties = {}) {
try {
await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
type: 'event',
name: eventName,
properties,
timestamp: Date.now()
})
});
} catch (error) {
Logger.error('[Analytics] Failed to track event', error);
}
}
async pageView(path, properties = {}) {
try {
await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
type: 'page_view',
path,
properties,
timestamp: Date.now()
})
});
} catch (error) {
Logger.error('[Analytics] Failed to track page view', error);
}
}
}
/**
* Analytics - Unified analytics system
*/
export class Analytics {
constructor(config = {}) {
this.config = {
enabled: config.enabled ?? true,
providers: config.providers || [],
gdprCompliant: config.gdprCompliant ?? true,
requireConsent: config.requireConsent ?? false,
consentGiven: config.consentGiven ?? false,
anonymizeIp: config.anonymizeIp ?? true,
...config
};
this.providers = [];
this.eventQueue = [];
this.userId = null;
this.userTraits = {};
// Initialize providers
this.initProviders();
// Track initial page view
if (this.config.enabled && this.hasConsent()) {
this.trackPageView();
}
Logger.info('[Analytics] Initialized', {
enabled: this.config.enabled,
providers: this.providers.length,
gdprCompliant: this.config.gdprCompliant
});
}
/**
* Create a new Analytics instance
*/
static create(config = {}) {
return new Analytics(config);
}
/**
* Initialize providers
*/
initProviders() {
for (const providerConfig of this.config.providers) {
let provider;
if (typeof providerConfig === 'string') {
// Provider name
if (providerConfig === 'google-analytics' || providerConfig === 'ga') {
provider = new GoogleAnalyticsProvider({});
} else if (providerConfig === 'custom') {
provider = new CustomProvider({});
}
} else if (typeof providerConfig === 'object') {
// Provider config
if (providerConfig.type === 'google-analytics' || providerConfig.type === 'ga') {
provider = new GoogleAnalyticsProvider(providerConfig);
} else if (providerConfig.type === 'custom') {
provider = new CustomProvider(providerConfig);
} else if (providerConfig instanceof AnalyticsProvider) {
provider = providerConfig;
}
}
if (provider) {
this.providers.push(provider);
}
}
}
/**
* Check if consent is given (GDPR)
*/
hasConsent() {
if (!this.config.requireConsent) {
return true;
}
return this.config.consentGiven;
}
/**
* Give consent (GDPR)
*/
giveConsent() {
this.config.consentGiven = true;
// Process queued events
this.processEventQueue();
Logger.info('[Analytics] Consent given');
}
/**
* Revoke consent (GDPR)
*/
revokeConsent() {
this.config.consentGiven = false;
Logger.info('[Analytics] Consent revoked');
}
/**
* Track event
*/
async track(eventName, properties = {}) {
if (!this.config.enabled || !this.hasConsent()) {
// Queue event if consent required
if (this.config.requireConsent && !this.config.consentGiven) {
this.eventQueue.push({ type: 'event', name: eventName, properties });
}
return;
}
const eventData = {
name: eventName,
properties: this.anonymizeData(properties),
timestamp: Date.now(),
userId: this.userId
};
// Send to all providers
for (const provider of this.providers) {
try {
await provider.track(eventName, eventData.properties);
} catch (error) {
Logger.error('[Analytics] Provider error', error);
}
}
Logger.debug('[Analytics] Event tracked', eventData);
// Trigger event
this.triggerAnalyticsEvent('track', eventData);
}
/**
* Track page view
*/
async trackPageView(path = null, properties = {}) {
if (!this.config.enabled || !this.hasConsent()) {
return;
}
const pagePath = path || window.location.pathname;
const pageData = {
path: pagePath,
title: document.title,
properties: this.anonymizeData(properties),
timestamp: Date.now()
};
// Send to all providers
for (const provider of this.providers) {
try {
await provider.pageView(pagePath, pageData.properties);
} catch (error) {
Logger.error('[Analytics] Provider error', error);
}
}
Logger.debug('[Analytics] Page view tracked', pageData);
// Trigger event
this.triggerAnalyticsEvent('page_view', pageData);
}
/**
* Identify user
*/
async identify(userId, traits = {}) {
if (!this.config.enabled || !this.hasConsent()) {
return;
}
this.userId = userId;
this.userTraits = { ...this.userTraits, ...traits };
// Send to all providers
for (const provider of this.providers) {
try {
if (typeof provider.identify === 'function') {
await provider.identify(userId, this.userTraits);
}
} catch (error) {
Logger.error('[Analytics] Provider error', error);
}
}
Logger.debug('[Analytics] User identified', { userId, traits: this.userTraits });
}
/**
* Track user behavior
*/
async trackBehavior(action, target, properties = {}) {
await this.track('user_behavior', {
action,
target,
...properties
});
}
/**
* Anonymize data for GDPR compliance
*/
anonymizeData(data) {
if (!this.config.gdprCompliant) {
return data;
}
const anonymized = { ...data };
// Anonymize IP if enabled
if (this.config.anonymizeIp && anonymized.ip) {
anonymized.ip = anonymized.ip.split('.').slice(0, 3).join('.') + '.0';
}
// Remove PII if present
const piiFields = ['email', 'phone', 'address', 'ssn', 'credit_card'];
piiFields.forEach(field => {
if (anonymized[field]) {
delete anonymized[field];
}
});
return anonymized;
}
/**
* Process queued events
*/
processEventQueue() {
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift();
if (event.type === 'event') {
this.track(event.name, event.properties);
}
}
}
/**
* Trigger analytics event
*/
triggerAnalyticsEvent(type, data) {
const event = new CustomEvent(`analytics:${type}`, {
detail: data,
bubbles: true
});
window.dispatchEvent(event);
}
/**
* Enable analytics
*/
enable() {
this.config.enabled = true;
Logger.info('[Analytics] Enabled');
}
/**
* Disable analytics
*/
disable() {
this.config.enabled = false;
Logger.info('[Analytics] Disabled');
}
/**
* Destroy analytics
*/
destroy() {
this.providers = [];
this.eventQueue = [];
this.userId = null;
this.userTraits = {};
Logger.info('[Analytics] Destroyed');
}
}

View File

@@ -0,0 +1,8 @@
/**
* Analytics Provider Base Classes
*
* Base classes and implementations for analytics providers.
*/
export { AnalyticsProvider, GoogleAnalyticsProvider, CustomProvider } from './Analytics.js';

View File

@@ -0,0 +1,85 @@
/**
* Analytics Module
*
* Provides unified analytics system for event tracking, page views, and user behavior.
*
* Usage:
* - Add data-module="analytics" to enable global analytics
* - Or import and use directly: import { Analytics } from './modules/analytics/index.js'
*
* Features:
* - Event tracking
* - Page view tracking
* - User behavior tracking
* - Custom events
* - Integration with LiveComponents
* - GDPR compliance
*/
import { Logger } from '../../core/logger.js';
import { Analytics, AnalyticsProvider, GoogleAnalyticsProvider, CustomProvider } from './Analytics.js';
const AnalyticsModule = {
name: 'analytics',
analytics: null,
init(config = {}, state = null) {
Logger.info('[AnalyticsModule] Module initialized');
// Create analytics instance
this.analytics = Analytics.create(config);
// Expose globally for easy access
if (typeof window !== 'undefined') {
window.Analytics = this.analytics;
}
return this;
},
/**
* Get analytics instance
*/
getAnalytics() {
return this.analytics || Analytics.create();
},
/**
* Track event
*/
async track(eventName, properties = {}) {
const analytics = this.getAnalytics();
return await analytics.track(eventName, properties);
},
/**
* Track page view
*/
async trackPageView(path = null, properties = {}) {
const analytics = this.getAnalytics();
return await analytics.trackPageView(path, properties);
},
destroy() {
if (this.analytics) {
this.analytics.destroy();
this.analytics = null;
}
if (typeof window !== 'undefined' && window.Analytics) {
delete window.Analytics;
}
Logger.info('[AnalyticsModule] Module destroyed');
}
};
// Export for direct usage
export { Analytics, AnalyticsProvider, GoogleAnalyticsProvider, CustomProvider };
// Export as default for module system
export default AnalyticsModule;
// Export init function for module system
export const init = AnalyticsModule.init.bind(AnalyticsModule);

View File

@@ -0,0 +1,302 @@
/**
* Unified Animation System
*
* Consolidates all scroll animation modules into a single, unified system.
* Replaces: scrollfx, parallax, scroll-timeline, scroll-loop, scroll-dependent, sticky-fade, sticky-steps
*/
import { Logger } from '../../core/logger.js';
import { ScrollAnimation } from './ScrollAnimation.js';
import { TimelineAnimation } from './TimelineAnimation.js';
/**
* AnimationSystem - Unified animation system
*/
export class AnimationSystem {
constructor(config = {}) {
this.config = {
enabled: config.enabled ?? true,
useIntersectionObserver: config.useIntersectionObserver ?? true,
throttleDelay: config.throttleDelay || 16, // ~60fps
...config
};
this.animations = new Map(); // Map<element, Animation>
this.observers = new Map(); // Map<element, IntersectionObserver>
this.scrollHandler = null;
this.isScrolling = false;
// Initialize
if (this.config.enabled) {
this.init();
}
Logger.info('[AnimationSystem] Initialized', {
enabled: this.config.enabled,
useIntersectionObserver: this.config.useIntersectionObserver
});
}
/**
* Create a new AnimationSystem instance
*/
static create(config = {}) {
return new AnimationSystem(config);
}
/**
* Initialize animation system
*/
init() {
// Set up scroll handler
if (!this.config.useIntersectionObserver) {
this.setupScrollHandler();
}
// Auto-initialize elements with data attributes
this.autoInitialize();
}
/**
* Set up scroll handler
*/
setupScrollHandler() {
let ticking = false;
this.scrollHandler = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
this.updateAnimations();
ticking = false;
});
ticking = true;
}
};
window.addEventListener('scroll', this.scrollHandler, { passive: true });
}
/**
* Auto-initialize elements with data attributes
*/
autoInitialize() {
// Fade in on scroll (scrollfx)
this.initializeFadeIn();
// Parallax
this.initializeParallax();
// Scroll timeline
this.initializeTimeline();
// Sticky fade
this.initializeStickyFade();
// Sticky steps
this.initializeStickySteps();
}
/**
* Initialize fade-in animations (scrollfx)
*/
initializeFadeIn() {
const elements = document.querySelectorAll('.fade-in-on-scroll, .zoom-in, [data-animate="fade-in"]');
elements.forEach(element => {
this.registerAnimation(element, {
type: 'fade-in',
offset: parseFloat(element.dataset.offset) || 0.85,
delay: parseFloat(element.dataset.delay) || 0,
once: element.dataset.once !== 'false'
});
});
}
/**
* Initialize parallax animations
*/
initializeParallax() {
const elements = document.querySelectorAll('[data-parallax], .parallax');
elements.forEach(element => {
const speed = parseFloat(element.dataset.parallax || element.dataset.speed) || 0.5;
this.registerAnimation(element, {
type: 'parallax',
speed
});
});
}
/**
* Initialize timeline animations (scroll-timeline)
*/
initializeTimeline() {
const elements = document.querySelectorAll('[data-scroll-timeline], [data-scroll-step]');
elements.forEach(element => {
this.registerAnimation(element, {
type: 'timeline',
steps: element.dataset.scrollSteps ? parseInt(element.dataset.scrollSteps) : null,
triggerPoint: parseFloat(element.dataset.triggerPoint) || 0.4
});
});
}
/**
* Initialize sticky fade animations
*/
initializeStickyFade() {
const elements = document.querySelectorAll('[data-sticky-fade], .sticky-fade');
elements.forEach(element => {
this.registerAnimation(element, {
type: 'sticky-fade',
fadeStart: parseFloat(element.dataset.fadeStart) || 0,
fadeEnd: parseFloat(element.dataset.fadeEnd) || 1
});
});
}
/**
* Initialize sticky steps animations
*/
initializeStickySteps() {
const elements = document.querySelectorAll('[data-sticky-steps], .sticky-steps');
elements.forEach(element => {
const steps = element.dataset.stickySteps ? parseInt(element.dataset.stickySteps) : 3;
this.registerAnimation(element, {
type: 'sticky-steps',
steps
});
});
}
/**
* Register an animation
*/
registerAnimation(element, config) {
if (this.animations.has(element)) {
Logger.warn('[AnimationSystem] Animation already registered for element', element);
return;
}
let animation;
switch (config.type) {
case 'fade-in':
case 'zoom-in':
animation = new ScrollAnimation(element, {
type: config.type,
offset: config.offset || 0.85,
delay: config.delay || 0,
once: config.once !== false
});
break;
case 'parallax':
animation = new ScrollAnimation(element, {
type: 'parallax',
speed: config.speed || 0.5
});
break;
case 'timeline':
animation = new TimelineAnimation(element, {
steps: config.steps,
triggerPoint: config.triggerPoint || 0.4
});
break;
case 'sticky-fade':
animation = new ScrollAnimation(element, {
type: 'sticky-fade',
fadeStart: config.fadeStart || 0,
fadeEnd: config.fadeEnd || 1
});
break;
case 'sticky-steps':
animation = new ScrollAnimation(element, {
type: 'sticky-steps',
steps: config.steps || 3
});
break;
default:
Logger.warn('[AnimationSystem] Unknown animation type', config.type);
return;
}
this.animations.set(element, animation);
// Set up observer if using IntersectionObserver
if (this.config.useIntersectionObserver) {
this.setupObserver(element, animation);
}
Logger.debug('[AnimationSystem] Animation registered', { element, type: config.type });
}
/**
* Set up IntersectionObserver for element
*/
setupObserver(element, animation) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animation.enter();
} else if (!animation.config.once) {
animation.exit();
}
});
}, {
threshold: animation.config.offset || 0.85,
rootMargin: '0px'
});
observer.observe(element);
this.observers.set(element, observer);
}
/**
* Update all animations (for scroll-based updates)
*/
updateAnimations() {
this.animations.forEach((animation, element) => {
if (animation.needsUpdate) {
animation.update();
}
});
}
/**
* Remove animation
*/
removeAnimation(element) {
const animation = this.animations.get(element);
if (animation) {
animation.destroy();
this.animations.delete(element);
}
const observer = this.observers.get(element);
if (observer) {
observer.disconnect();
this.observers.delete(element);
}
}
/**
* Destroy animation system
*/
destroy() {
// Remove all animations
this.animations.forEach((animation, element) => {
this.removeAnimation(element);
});
// Remove scroll handler
if (this.scrollHandler) {
window.removeEventListener('scroll', this.scrollHandler);
}
Logger.info('[AnimationSystem] Destroyed');
}
}

View File

@@ -0,0 +1,211 @@
/**
* Scroll Animation
*
* Handles various scroll-based animations: fade-in, zoom-in, parallax, sticky-fade, sticky-steps
*/
import { Logger } from '../../core/logger.js';
/**
* ScrollAnimation - Individual scroll animation
*/
export class ScrollAnimation {
constructor(element, config = {}) {
this.element = element;
this.config = {
type: config.type || 'fade-in',
offset: config.offset || 0.85,
delay: config.delay || 0,
once: config.once !== false,
speed: config.speed || 0.5,
fadeStart: config.fadeStart || 0,
fadeEnd: config.fadeEnd || 1,
steps: config.steps || 3,
...config
};
this.triggered = false;
this.needsUpdate = true;
// Initialize based on type
this.init();
}
/**
* Initialize animation
*/
init() {
switch (this.config.type) {
case 'fade-in':
case 'zoom-in':
this.initFadeIn();
break;
case 'parallax':
this.initParallax();
break;
case 'sticky-fade':
this.initStickyFade();
break;
case 'sticky-steps':
this.initStickySteps();
break;
}
}
/**
* Initialize fade-in animation
*/
initFadeIn() {
this.element.style.opacity = '0';
this.element.style.transition = `opacity 0.6s ease, transform 0.6s ease`;
this.element.style.transitionDelay = `${this.config.delay}s`;
if (this.config.type === 'zoom-in') {
this.element.style.transform = 'scale(0.9)';
}
}
/**
* Initialize parallax animation
*/
initParallax() {
// Parallax doesn't need initial setup
this.needsUpdate = true;
}
/**
* Initialize sticky fade animation
*/
initStickyFade() {
this.element.style.position = 'sticky';
this.element.style.top = '0';
}
/**
* Initialize sticky steps animation
*/
initStickySteps() {
this.element.style.position = 'sticky';
this.element.style.top = '0';
}
/**
* Enter animation (element enters viewport)
*/
enter() {
if (this.triggered && this.config.once) {
return;
}
this.triggered = true;
switch (this.config.type) {
case 'fade-in':
this.element.style.opacity = '1';
this.element.classList.add('visible', 'entered');
break;
case 'zoom-in':
this.element.style.opacity = '1';
this.element.style.transform = 'scale(1)';
this.element.classList.add('visible', 'entered');
break;
}
}
/**
* Exit animation (element exits viewport)
*/
exit() {
if (this.config.once) {
return;
}
this.triggered = false;
switch (this.config.type) {
case 'fade-in':
this.element.style.opacity = '0';
this.element.classList.remove('visible', 'entered');
break;
case 'zoom-in':
this.element.style.opacity = '0';
this.element.style.transform = 'scale(0.9)';
this.element.classList.remove('visible', 'entered');
break;
}
}
/**
* Update animation (for scroll-based animations)
*/
update() {
const rect = this.element.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const scrollY = window.scrollY;
switch (this.config.type) {
case 'parallax':
this.updateParallax(rect, scrollY);
break;
case 'sticky-fade':
this.updateStickyFade(rect, viewportHeight);
break;
case 'sticky-steps':
this.updateStickySteps(rect, viewportHeight, scrollY);
break;
}
}
/**
* Update parallax animation
*/
updateParallax(rect, scrollY) {
const elementTop = rect.top + scrollY;
const scrolled = scrollY - elementTop;
const translateY = scrolled * this.config.speed;
this.element.style.transform = `translateY(${translateY}px)`;
}
/**
* Update sticky fade animation
*/
updateStickyFade(rect, viewportHeight) {
const progress = Math.max(0, Math.min(1, (viewportHeight - rect.top) / viewportHeight));
const opacity = this.config.fadeStart + (this.config.fadeEnd - this.config.fadeStart) * progress;
this.element.style.opacity = opacity.toString();
}
/**
* Update sticky steps animation
*/
updateStickySteps(rect, viewportHeight, scrollY) {
const elementTop = rect.top + scrollY - viewportHeight;
const scrollProgress = Math.max(0, Math.min(1, scrollY / (rect.height + viewportHeight)));
const step = Math.floor(scrollProgress * this.config.steps);
this.element.setAttribute('data-step', step.toString());
this.element.classList.remove('step-0', 'step-1', 'step-2', 'step-3', 'step-4', 'step-5');
this.element.classList.add(`step-${step}`);
}
/**
* Destroy animation
*/
destroy() {
// Reset styles
this.element.style.opacity = '';
this.element.style.transform = '';
this.element.style.transition = '';
this.element.style.transitionDelay = '';
this.element.style.position = '';
this.element.style.top = '';
// Remove classes
this.element.classList.remove('visible', 'entered');
this.triggered = false;
}
}

View File

@@ -0,0 +1,157 @@
/**
* Timeline Animation
*
* Handles scroll timeline animations (scroll-timeline, scroll-loop)
*/
import { Logger } from '../../core/logger.js';
/**
* TimelineAnimation - Scroll timeline animation
*/
export class TimelineAnimation {
constructor(element, config = {}) {
this.element = element;
this.config = {
steps: config.steps || null,
triggerPoint: config.triggerPoint || 0.4,
loop: config.loop ?? false,
...config
};
this.currentStep = 0;
this.triggered = false;
// Initialize
this.init();
}
/**
* Initialize timeline animation
*/
init() {
// Set initial step
this.element.setAttribute('data-scroll-step', '0');
this.element.classList.add('scroll-timeline');
}
/**
* Enter animation
*/
enter() {
if (this.triggered && !this.config.loop) {
return;
}
this.triggered = true;
this.update();
}
/**
* Exit animation
*/
exit() {
if (this.config.loop) {
this.currentStep = 0;
this.update();
}
}
/**
* Update animation based on scroll position
*/
update() {
const rect = this.element.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const triggerY = viewportHeight * this.config.triggerPoint;
if (rect.top < triggerY && rect.bottom > 0) {
// Calculate progress
const progress = Math.max(0, Math.min(1, (triggerY - rect.top) / (rect.height + viewportHeight)));
if (this.config.steps) {
// Step-based animation
const step = Math.floor(progress * this.config.steps);
this.setStep(step);
} else {
// Continuous animation
this.setProgress(progress);
}
}
}
/**
* Set step
*/
setStep(step) {
if (step === this.currentStep) {
return;
}
this.currentStep = step;
this.element.setAttribute('data-scroll-step', step.toString());
// Remove old step classes
for (let i = 0; i <= this.config.steps; i++) {
this.element.classList.remove(`step-${i}`);
}
// Add new step class
this.element.classList.add(`step-${step}`);
// Trigger event
this.triggerStepEvent(step);
}
/**
* Set progress (continuous)
*/
setProgress(progress) {
this.element.setAttribute('data-scroll-progress', progress.toString());
this.element.style.setProperty('--scroll-progress', progress.toString());
// Trigger event
this.triggerProgressEvent(progress);
}
/**
* Trigger step event
*/
triggerStepEvent(step) {
const event = new CustomEvent('scroll-timeline:step', {
detail: { step, element: this.element },
bubbles: true
});
this.element.dispatchEvent(event);
}
/**
* Trigger progress event
*/
triggerProgressEvent(progress) {
const event = new CustomEvent('scroll-timeline:progress', {
detail: { progress, element: this.element },
bubbles: true
});
this.element.dispatchEvent(event);
}
/**
* Destroy animation
*/
destroy() {
this.element.removeAttribute('data-scroll-step');
this.element.removeAttribute('data-scroll-progress');
this.element.style.removeProperty('--scroll-progress');
this.element.classList.remove('scroll-timeline');
// Remove step classes
for (let i = 0; i <= 10; i++) {
this.element.classList.remove(`step-${i}`);
}
this.currentStep = 0;
this.triggered = false;
}
}

View File

@@ -0,0 +1,92 @@
/**
* Animation System Module
*
* Unified animation system consolidating all scroll animation modules.
*
* Replaces:
* - scrollfx
* - parallax
* - scroll-timeline
* - scroll-loop
* - scroll-dependent
* - sticky-fade
* - sticky-steps
*
* Usage:
* - Add data-module="animation-system" to enable animations
* - Or import and use directly: import { AnimationSystem } from './modules/animation-system/index.js'
*
* Features:
* - Fade-in animations
* - Parallax effects
* - Scroll timeline animations
* - Sticky fade animations
* - Sticky steps animations
* - IntersectionObserver support
* - Backward compatibility with old modules
*/
import { Logger } from '../../core/logger.js';
import { AnimationSystem } from './AnimationSystem.js';
import { ScrollAnimation } from './ScrollAnimation.js';
import { TimelineAnimation } from './TimelineAnimation.js';
const AnimationSystemModule = {
name: 'animation-system',
animationSystem: null,
init(config = {}, state = null) {
Logger.info('[AnimationSystemModule] Module initialized');
// Create animation system
this.animationSystem = AnimationSystem.create(config);
// Expose globally for easy access
if (typeof window !== 'undefined') {
window.AnimationSystem = this.animationSystem;
}
return this;
},
/**
* Get animation system instance
*/
getAnimationSystem() {
return this.animationSystem || AnimationSystem.create();
},
/**
* Register animation
*/
registerAnimation(element, config) {
const system = this.getAnimationSystem();
system.registerAnimation(element, config);
},
destroy() {
if (this.animationSystem) {
this.animationSystem.destroy();
this.animationSystem = null;
}
if (typeof window !== 'undefined' && window.AnimationSystem) {
delete window.AnimationSystem;
}
Logger.info('[AnimationSystemModule] Module destroyed');
}
};
// Export for direct usage
export { AnimationSystem, ScrollAnimation, TimelineAnimation };
// Export as default for module system
export default AnimationSystemModule;
// Export init function for module system
export const init = AnimationSystemModule.init.bind(AnimationSystemModule);
// Backward compatibility exports
export { createTrigger } from '../scrollfx/index.js';

View File

@@ -6,13 +6,31 @@ import { Logger } from '../../core/logger.js';
*/
export class StorageManager {
constructor(config = {}) {
this.config = config;
this.dbName = config.dbName || 'AppDatabase';
this.dbVersion = config.dbVersion || 1;
this.config = {
dbName: config.dbName || 'AppDatabase',
dbVersion: config.dbVersion || 1,
enableAnalytics: config.enableAnalytics ?? true,
enableQuotaMonitoring: config.enableQuotaMonitoring ?? true,
quotaWarningThreshold: config.quotaWarningThreshold || 0.8, // 80%
...config
};
this.db = null;
this.cache = null;
this.channels = new Map();
// Analytics
this.analytics = {
operations: {
get: 0,
set: 0,
delete: 0,
clear: 0
},
errors: 0,
quotaWarnings: 0
};
// Check API support
this.support = {
indexedDB: 'indexedDB' in window,
@@ -20,7 +38,8 @@ export class StorageManager {
webLocks: 'locks' in navigator,
broadcastChannel: 'BroadcastChannel' in window,
localStorage: 'localStorage' in window,
sessionStorage: 'sessionStorage' in window
sessionStorage: 'sessionStorage' in window,
storageEstimate: 'storage' in navigator && 'estimate' in navigator.storage
};
Logger.info('[StorageManager] Initialized with support:', this.support);
@@ -28,6 +47,11 @@ export class StorageManager {
// Initialize databases
this.initializeDB();
this.initializeCache();
// Start quota monitoring if enabled
if (this.config.enableQuotaMonitoring) {
this.startQuotaMonitoring();
}
}
/**
@@ -755,7 +779,365 @@ export class StorageManager {
activeChannels: this.channels.size,
dbConnected: !!this.db,
cacheConnected: !!this.cache,
channelNames: Array.from(this.channels.keys())
channelNames: Array.from(this.channels.keys()),
analytics: this.config.enableAnalytics ? this.getAnalytics() : null
};
}
/**
* Unified storage API - works with any storage type
*/
storage = {
/**
* Set value in storage
*/
set: async (key, value, options = {}) => {
const {
type = 'auto', // 'auto' | 'localStorage' | 'sessionStorage' | 'indexedDB'
expiration = null,
...rest
} = options;
try {
// Auto-select storage type based on size
let storageType = type;
if (type === 'auto') {
const valueSize = JSON.stringify(value).length;
storageType = valueSize > 5 * 1024 * 1024 ? 'indexedDB' : 'localStorage'; // >5MB use IndexedDB
}
switch (storageType) {
case 'localStorage':
return this.local.set(key, value, expiration);
case 'sessionStorage':
return this.session.set(key, value);
case 'indexedDB':
return await this.db.set(key, value, expiration);
default:
throw new Error(`Unknown storage type: ${storageType}`);
}
} catch (error) {
// Fallback to localStorage if IndexedDB fails
if (type === 'indexedDB' || type === 'auto') {
Logger.warn('[StorageManager] Falling back to localStorage', error);
return this.local.set(key, value, expiration);
}
throw error;
} finally {
if (this.config.enableAnalytics) {
this.analytics.operations.set++;
}
}
},
/**
* Get value from storage
*/
get: async (key, options = {}) => {
const { type = 'auto', ...rest } = options;
try {
// Try different storage types in order
if (type === 'auto') {
// Try localStorage first (fastest)
const localValue = this.local.get(key);
if (localValue !== null) {
if (this.config.enableAnalytics) {
this.analytics.operations.get++;
}
return localValue;
}
// Try sessionStorage
const sessionValue = this.session.get(key);
if (sessionValue !== null) {
if (this.config.enableAnalytics) {
this.analytics.operations.get++;
}
return sessionValue;
}
// Try IndexedDB
if (this.db) {
const dbValue = await this.db.get(key);
if (dbValue !== null) {
if (this.config.enableAnalytics) {
this.analytics.operations.get++;
}
return dbValue;
}
}
return null;
} else {
switch (type) {
case 'localStorage':
const localValue = this.local.get(key);
if (this.config.enableAnalytics) {
this.analytics.operations.get++;
}
return localValue;
case 'sessionStorage':
const sessionValue = this.session.get(key);
if (this.config.enableAnalytics) {
this.analytics.operations.get++;
}
return sessionValue;
case 'indexedDB':
const dbValue = await this.db.get(key);
if (this.config.enableAnalytics) {
this.analytics.operations.get++;
}
return dbValue;
default:
throw new Error(`Unknown storage type: ${type}`);
}
}
} catch (error) {
Logger.error('[StorageManager] Storage get failed', error);
if (this.config.enableAnalytics) {
this.analytics.errors++;
}
throw error;
}
},
/**
* Delete value from storage
*/
delete: async (key, options = {}) => {
const { type = 'all', ...rest } = options;
try {
if (type === 'all') {
// Delete from all storage types
this.local.delete(key);
this.session.delete(key);
if (this.db) {
await this.db.delete(key);
}
} else {
switch (type) {
case 'localStorage':
this.local.delete(key);
break;
case 'sessionStorage':
this.session.delete(key);
break;
case 'indexedDB':
if (this.db) {
await this.db.delete(key);
}
break;
}
}
if (this.config.enableAnalytics) {
this.analytics.operations.delete++;
}
} catch (error) {
Logger.error('[StorageManager] Storage delete failed', error);
if (this.config.enableAnalytics) {
this.analytics.errors++;
}
throw error;
}
},
/**
* Clear all storage
*/
clear: async (options = {}) => {
const { type = 'all', ...rest } = options;
try {
if (type === 'all') {
this.local.clear();
this.session.clear();
if (this.db) {
await this.db.clear();
}
if (this.cache) {
await this.cache.clear();
}
} else {
switch (type) {
case 'localStorage':
this.local.clear();
break;
case 'sessionStorage':
this.session.clear();
break;
case 'indexedDB':
if (this.db) {
await this.db.clear();
}
break;
case 'cache':
if (this.cache) {
await this.cache.clear();
}
break;
}
}
if (this.config.enableAnalytics) {
this.analytics.operations.clear++;
}
} catch (error) {
Logger.error('[StorageManager] Storage clear failed', error);
if (this.config.enableAnalytics) {
this.analytics.errors++;
}
throw error;
}
}
};
/**
* Start quota monitoring
*/
startQuotaMonitoring() {
// Check quota periodically
setInterval(async () => {
try {
const usage = await this.getStorageUsage();
if (usage.percentage >= this.config.quotaWarningThreshold * 100) {
this.analytics.quotaWarnings++;
Logger.warn('[StorageManager] Storage quota warning', {
usage: usage.percentage + '%',
available: this.formatBytes(usage.available)
});
// Trigger event
const event = new CustomEvent('storage:quota-warning', {
detail: usage,
bubbles: true
});
window.dispatchEvent(event);
}
} catch (error) {
Logger.error('[StorageManager] Quota monitoring error', error);
}
}, 60000); // Check every minute
}
/**
* Migrate data between storage types
*/
async migrate(fromType, toType, keys = null) {
Logger.info('[StorageManager] Starting migration', { fromType, toType });
let sourceKeys = keys;
// Get keys from source
if (!sourceKeys) {
switch (fromType) {
case 'localStorage':
sourceKeys = this.local.keys();
break;
case 'sessionStorage':
sourceKeys = Object.keys(sessionStorage);
break;
case 'indexedDB':
if (this.db) {
sourceKeys = await this.db.keys();
}
break;
}
}
if (!sourceKeys || sourceKeys.length === 0) {
Logger.info('[StorageManager] No keys to migrate');
return;
}
let migrated = 0;
let failed = 0;
for (const key of sourceKeys) {
try {
// Get value from source
let value = null;
switch (fromType) {
case 'localStorage':
value = this.local.get(key);
break;
case 'sessionStorage':
value = this.session.get(key);
break;
case 'indexedDB':
if (this.db) {
value = await this.db.get(key);
}
break;
}
if (value === null) {
continue;
}
// Set value in destination
switch (toType) {
case 'localStorage':
this.local.set(key, value);
break;
case 'sessionStorage':
this.session.set(key, value);
break;
case 'indexedDB':
if (this.db) {
await this.db.set(key, value);
}
break;
}
migrated++;
} catch (error) {
Logger.error(`[StorageManager] Failed to migrate key: ${key}`, error);
failed++;
}
}
Logger.info('[StorageManager] Migration completed', { migrated, failed });
return { migrated, failed };
}
/**
* Get storage analytics
*/
getAnalytics() {
return {
...this.analytics,
totalOperations: Object.values(this.analytics.operations).reduce((sum, count) => sum + count, 0),
errorRate: this.analytics.errors / (this.analytics.operations.get + this.analytics.operations.set || 1)
};
}
/**
* Reset analytics
*/
resetAnalytics() {
this.analytics = {
operations: {
get: 0,
set: 0,
delete: 0,
clear: 0
},
errors: 0,
quotaWarnings: 0
};
}
/**
* Format bytes for human reading
*/
formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
}

View File

@@ -0,0 +1,399 @@
/**
* Cache Manager Module
*
* Provides intelligent caching for API responses and computed values.
* Features:
* - Memory cache
* - IndexedDB cache
* - Cache invalidation strategies
* - Cache warming
* - Cache analytics
* - Integration with RequestDeduplicator
*/
import { Logger } from '../../core/logger.js';
/**
* Cache strategies
*/
export const CacheStrategy = {
NO_CACHE: 'no-cache',
CACHE_FIRST: 'cache-first',
NETWORK_FIRST: 'network-first',
STALE_WHILE_REVALIDATE: 'stale-while-revalidate',
NETWORK_ONLY: 'network-only',
CACHE_ONLY: 'cache-only'
};
/**
* CacheManager - Intelligent caching system
*/
export class CacheManager {
constructor(config = {}) {
this.config = {
defaultStrategy: config.defaultStrategy || CacheStrategy.STALE_WHILE_REVALIDATE,
defaultTTL: config.defaultTTL || 3600000, // 1 hour
maxMemorySize: config.maxMemorySize || 50, // Max 50 items in memory
enableIndexedDB: config.enableIndexedDB ?? true,
indexedDBName: config.indexedDBName || 'app-cache',
indexedDBVersion: config.indexedDBVersion || 1,
enableAnalytics: config.enableAnalytics ?? false,
...config
};
this.memoryCache = new Map(); // Map<key, CacheEntry>
this.indexedDBCache = null;
this.analytics = {
hits: 0,
misses: 0,
sets: 0,
deletes: 0
};
// Initialize
this.init();
}
/**
* Create a new CacheManager instance
*/
static create(config = {}) {
return new CacheManager(config);
}
/**
* Initialize cache manager
*/
async init() {
// Initialize IndexedDB if enabled
if (this.config.enableIndexedDB && 'indexedDB' in window) {
try {
await this.initIndexedDB();
} catch (error) {
Logger.error('[CacheManager] Failed to initialize IndexedDB', error);
this.config.enableIndexedDB = false;
}
}
Logger.info('[CacheManager] Initialized', {
strategy: this.config.defaultStrategy,
memoryCache: true,
indexedDB: this.config.enableIndexedDB
});
}
/**
* Initialize IndexedDB
*/
async initIndexedDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.config.indexedDBName, this.config.indexedDBVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.indexedDBCache = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('cache')) {
db.createObjectStore('cache', { keyPath: 'key' });
}
};
});
}
/**
* Get value from cache
*/
async get(key, options = {}) {
const strategy = options.strategy || this.config.defaultStrategy;
const ttl = options.ttl || this.config.defaultTTL;
// Check memory cache first
const memoryEntry = this.memoryCache.get(key);
if (memoryEntry && !this.isExpired(memoryEntry, ttl)) {
this.analytics.hits++;
Logger.debug('[CacheManager] Cache hit (memory)', key);
return memoryEntry.value;
}
// Check IndexedDB if enabled
if (this.config.enableIndexedDB && this.indexedDBCache) {
try {
const indexedDBEntry = await this.getFromIndexedDB(key);
if (indexedDBEntry && !this.isExpired(indexedDBEntry, ttl)) {
// Promote to memory cache
this.memoryCache.set(key, indexedDBEntry);
this.analytics.hits++;
Logger.debug('[CacheManager] Cache hit (IndexedDB)', key);
return indexedDBEntry.value;
}
} catch (error) {
Logger.error('[CacheManager] IndexedDB get error', error);
}
}
this.analytics.misses++;
Logger.debug('[CacheManager] Cache miss', key);
return null;
}
/**
* Set value in cache
*/
async set(key, value, options = {}) {
const ttl = options.ttl || this.config.defaultTTL;
const strategy = options.strategy || this.config.defaultStrategy;
const entry = {
key,
value,
timestamp: Date.now(),
ttl,
strategy
};
// Store in memory cache
this.memoryCache.set(key, entry);
// Limit memory cache size
if (this.memoryCache.size > this.config.maxMemorySize) {
const firstKey = this.memoryCache.keys().next().value;
this.memoryCache.delete(firstKey);
}
// Store in IndexedDB if enabled
if (this.config.enableIndexedDB && this.indexedDBCache) {
try {
await this.setInIndexedDB(entry);
} catch (error) {
Logger.error('[CacheManager] IndexedDB set error', error);
}
}
this.analytics.sets++;
Logger.debug('[CacheManager] Cache set', key);
}
/**
* Get or compute value (cache-aside pattern)
*/
async getOrSet(key, computeFn, options = {}) {
const strategy = options.strategy || this.config.defaultStrategy;
// Try cache first (unless network-only)
if (strategy !== CacheStrategy.NETWORK_ONLY) {
const cached = await this.get(key, options);
if (cached !== null) {
return cached;
}
}
// Compute value
const value = await computeFn();
// Store in cache (unless no-cache)
if (strategy !== CacheStrategy.NO_CACHE) {
await this.set(key, value, options);
}
return value;
}
/**
* Delete value from cache
*/
async delete(key) {
// Delete from memory
this.memoryCache.delete(key);
// Delete from IndexedDB
if (this.config.enableIndexedDB && this.indexedDBCache) {
try {
await this.deleteFromIndexedDB(key);
} catch (error) {
Logger.error('[CacheManager] IndexedDB delete error', error);
}
}
this.analytics.deletes++;
Logger.debug('[CacheManager] Cache delete', key);
}
/**
* Clear all cache
*/
async clear() {
this.memoryCache.clear();
if (this.config.enableIndexedDB && this.indexedDBCache) {
try {
await this.clearIndexedDB();
} catch (error) {
Logger.error('[CacheManager] IndexedDB clear error', error);
}
}
Logger.info('[CacheManager] Cache cleared');
}
/**
* Invalidate cache by pattern
*/
async invalidate(pattern) {
const keysToDelete = [];
// Find matching keys in memory
for (const key of this.memoryCache.keys()) {
if (this.matchesPattern(key, pattern)) {
keysToDelete.push(key);
}
}
// Delete matching keys
for (const key of keysToDelete) {
await this.delete(key);
}
Logger.info('[CacheManager] Invalidated cache', { pattern, count: keysToDelete.length });
}
/**
* Check if key matches pattern
*/
matchesPattern(key, pattern) {
if (typeof pattern === 'string') {
return key.includes(pattern);
}
if (pattern instanceof RegExp) {
return pattern.test(key);
}
if (typeof pattern === 'function') {
return pattern(key);
}
return false;
}
/**
* Check if cache entry is expired
*/
isExpired(entry, ttl) {
if (!entry || !entry.timestamp) {
return true;
}
const age = Date.now() - entry.timestamp;
return age > (entry.ttl || ttl);
}
/**
* Get from IndexedDB
*/
async getFromIndexedDB(key) {
return new Promise((resolve, reject) => {
const transaction = this.indexedDBCache.transaction(['cache'], 'readonly');
const store = transaction.objectStore('cache');
const request = store.get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* Set in IndexedDB
*/
async setInIndexedDB(entry) {
return new Promise((resolve, reject) => {
const transaction = this.indexedDBCache.transaction(['cache'], 'readwrite');
const store = transaction.objectStore('cache');
const request = store.put(entry);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Delete from IndexedDB
*/
async deleteFromIndexedDB(key) {
return new Promise((resolve, reject) => {
const transaction = this.indexedDBCache.transaction(['cache'], 'readwrite');
const store = transaction.objectStore('cache');
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Clear IndexedDB
*/
async clearIndexedDB() {
return new Promise((resolve, reject) => {
const transaction = this.indexedDBCache.transaction(['cache'], 'readwrite');
const store = transaction.objectStore('cache');
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Warm cache (preload values)
*/
async warm(keys, computeFn) {
Logger.info('[CacheManager] Warming cache', { count: keys.length });
for (const key of keys) {
try {
await this.getOrSet(key, () => computeFn(key));
} catch (error) {
Logger.error(`[CacheManager] Failed to warm cache for ${key}`, error);
}
}
}
/**
* Get cache analytics
*/
getAnalytics() {
const total = this.analytics.hits + this.analytics.misses;
const hitRate = total > 0 ? (this.analytics.hits / total) * 100 : 0;
return {
...this.analytics,
total,
hitRate: Math.round(hitRate * 100) / 100,
memorySize: this.memoryCache.size,
memoryMaxSize: this.config.maxMemorySize
};
}
/**
* Reset analytics
*/
resetAnalytics() {
this.analytics = {
hits: 0,
misses: 0,
sets: 0,
deletes: 0
};
}
/**
* Destroy cache manager
*/
destroy() {
this.memoryCache.clear();
this.indexedDBCache = null;
Logger.info('[CacheManager] Destroyed');
}
}

View File

@@ -0,0 +1,53 @@
/**
* Cache Strategy Definitions
*
* Defines different caching strategies for the CacheManager.
*/
export const CacheStrategy = {
/**
* No caching - always fetch from source
*/
NO_CACHE: 'no-cache',
/**
* Cache first - use cache if available, otherwise fetch
*/
CACHE_FIRST: 'cache-first',
/**
* Network first - try network first, fallback to cache
*/
NETWORK_FIRST: 'network-first',
/**
* Stale while revalidate - return cache immediately, update in background
*/
STALE_WHILE_REVALIDATE: 'stale-while-revalidate',
/**
* Network only - always fetch from network, never use cache
*/
NETWORK_ONLY: 'network-only',
/**
* Cache only - only use cache, never fetch from network
*/
CACHE_ONLY: 'cache-only'
};
/**
* Get default strategy for a use case
*/
export function getStrategyForUseCase(useCase) {
const strategies = {
'api-response': CacheStrategy.STALE_WHILE_REVALIDATE,
'user-data': CacheStrategy.CACHE_FIRST,
'real-time': CacheStrategy.NETWORK_ONLY,
'static-content': CacheStrategy.CACHE_ONLY,
'computed-value': CacheStrategy.CACHE_FIRST
};
return strategies[useCase] || CacheStrategy.STALE_WHILE_REVALIDATE;
}

View File

@@ -0,0 +1,96 @@
/**
* Cache Manager Module
*
* Provides intelligent caching for API responses and computed values.
*
* Usage:
* - Add data-module="cache-manager" to enable global cache manager
* - Or import and use directly: import { CacheManager } from './modules/cache-manager/index.js'
*
* Features:
* - Memory cache
* - IndexedDB cache
* - Cache invalidation strategies
* - Cache warming
* - Cache analytics
* - Integration with RequestDeduplicator
*/
import { Logger } from '../../core/logger.js';
import { CacheManager, CacheStrategy } from './CacheManager.js';
import { getStrategyForUseCase } from './CacheStrategy.js';
const CacheManagerModule = {
name: 'cache-manager',
cacheManager: null,
init(config = {}, state = null) {
Logger.info('[CacheManagerModule] Module initialized');
// Create cache manager
this.cacheManager = CacheManager.create(config);
// Expose globally for easy access
if (typeof window !== 'undefined') {
window.CacheManager = this.cacheManager;
window.CacheStrategy = CacheStrategy;
}
return this;
},
/**
* Get cache manager instance
*/
getCacheManager() {
return this.cacheManager || CacheManager.create();
},
/**
* Get value from cache
*/
async get(key, options = {}) {
const cache = this.getCacheManager();
return await cache.get(key, options);
},
/**
* Set value in cache
*/
async set(key, value, options = {}) {
const cache = this.getCacheManager();
return await cache.set(key, value, options);
},
/**
* Get or compute value
*/
async getOrSet(key, computeFn, options = {}) {
const cache = this.getCacheManager();
return await cache.getOrSet(key, computeFn, options);
},
destroy() {
if (this.cacheManager) {
this.cacheManager.destroy();
this.cacheManager = null;
}
if (typeof window !== 'undefined') {
delete window.CacheManager;
delete window.CacheStrategy;
}
Logger.info('[CacheManagerModule] Module destroyed');
}
};
// Export for direct usage
export { CacheManager, CacheStrategy, getStrategyForUseCase };
// Export as default for module system
export default CacheManagerModule;
// Export init function for module system
export const init = CacheManagerModule.init.bind(CacheManagerModule);

View File

@@ -1,414 +0,0 @@
/**
* 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)
* - Support for both regular forms and LiveComponents
* - 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
* });
*
* // Initialize for a LiveComponent
* const liveRefresh = new CsrfAutoRefresh({
* formId: 'counter:demo', // Component ID
* refreshInterval: 105 * 60 * 1000
* });
*
* // Or auto-detect all forms and LiveComponents 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: false, // Disabled to hide browser notifications
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
* Supports both regular forms and LiveComponent data-csrf-token attributes
*/
updateTokenInForms(newToken) {
let updatedCount = 0;
// Update regular form input tokens
const tokenInputs = document.querySelectorAll(this.config.tokenSelector);
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++;
}
}
});
// Update LiveComponent data-csrf-token attributes
// LiveComponents use form ID format: "livecomponent:{componentId}"
const liveComponentFormId = 'livecomponent:' + this.config.formId.replace(/^livecomponent:/, '');
const liveComponents = document.querySelectorAll('[data-live-component][data-csrf-token]');
liveComponents.forEach(component => {
// Check if this component uses our form ID
const componentId = component.dataset.liveComponent;
const expectedFormId = 'livecomponent:' + componentId;
if (expectedFormId === liveComponentFormId || this.config.formId === componentId) {
component.dataset.csrfToken = newToken;
updatedCount++;
this.log(`Updated LiveComponent token: ${componentId}`);
}
});
this.log(`Updated ${updatedCount} token(s) (forms + LiveComponents)`);
if (updatedCount === 0) {
console.warn('CsrfAutoRefresh: No tokens found to update. Check your selectors and formId.');
}
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
* Supports both regular forms and LiveComponents
*/
static initializeAll() {
const formIds = new Set();
// Collect unique form IDs from regular forms
const tokenInputs = document.querySelectorAll('input[name="_token"]');
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);
}
}
});
// Collect unique component IDs from LiveComponents
const liveComponents = document.querySelectorAll('[data-live-component][data-csrf-token]');
liveComponents.forEach(component => {
const componentId = component.dataset.liveComponent;
if (componentId) {
// Use the component ID directly (without "livecomponent:" prefix for config)
formIds.add(componentId);
}
});
// Initialize auto-refresh for each unique form/component ID
const instances = [];
formIds.forEach(formId => {
const instance = new CsrfAutoRefresh({ formId });
instances.push(instance);
});
console.log(`CsrfAutoRefresh: Initialized for ${instances.length} forms/components:`, 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

@@ -0,0 +1,392 @@
/**
* Error Tracking Module
*
* Provides centralized error tracking and reporting.
* Features:
* - Error collection and grouping
* - Error reporting to backend
* - Error analytics
* - Integration with ErrorBoundary
* - Source map support
* - Error filtering and sampling
*/
import { Logger } from '../../core/logger.js';
/**
* ErrorTracker - Centralized error tracking
*/
export class ErrorTracker {
constructor(config = {}) {
this.config = {
endpoint: config.endpoint || '/api/errors',
enabled: config.enabled ?? true,
sampleRate: config.sampleRate ?? 1.0, // 0.0 to 1.0
maxErrors: config.maxErrors || 100,
groupingWindow: config.groupingWindow || 60000, // 1 minute
includeStack: config.includeStack ?? true,
includeContext: config.includeContext ?? true,
includeUserAgent: config.includeUserAgent ?? true,
includeUrl: config.includeUrl ?? true,
filters: config.filters || [],
beforeSend: config.beforeSend || null,
...config
};
this.errors = [];
this.errorGroups = new Map(); // Map<fingerprint, ErrorGroup>
this.reportQueue = [];
this.isReporting = false;
// Initialize error handlers
if (this.config.enabled) {
this.init();
}
Logger.info('[ErrorTracker] Initialized', {
enabled: this.config.enabled,
endpoint: this.config.endpoint,
sampleRate: this.config.sampleRate
});
}
/**
* Create a new ErrorTracker instance
*/
static create(config = {}) {
return new ErrorTracker(config);
}
/**
* Initialize error tracking
*/
init() {
// Global error handler
window.addEventListener('error', (event) => {
this.captureException(event.error || new Error(event.message), {
type: 'unhandled',
filename: event.filename,
lineno: event.lineno,
colno: event.colno
});
});
// Unhandled promise rejection handler
window.addEventListener('unhandledrejection', (event) => {
this.captureException(event.reason, {
type: 'unhandledrejection'
});
});
// Report errors periodically
this.startReporting();
}
/**
* Capture an exception
*/
captureException(error, context = {}) {
if (!this.config.enabled) {
return;
}
// Sample rate check
if (Math.random() > this.config.sampleRate) {
Logger.debug('[ErrorTracker] Error sampled out');
return;
}
// Apply filters
if (this.shouldFilter(error, context)) {
Logger.debug('[ErrorTracker] Error filtered out');
return;
}
// Create error data
const errorData = this.createErrorData(error, context);
// Apply beforeSend hook
if (this.config.beforeSend) {
const modified = this.config.beforeSend(errorData);
if (modified === null || modified === false) {
return; // Blocked by beforeSend
}
if (modified) {
Object.assign(errorData, modified);
}
}
// Add to errors array
this.errors.push(errorData);
// Limit errors array size
if (this.errors.length > this.config.maxErrors) {
this.errors.shift();
}
// Group errors
this.groupError(errorData);
// Queue for reporting
this.reportQueue.push(errorData);
Logger.debug('[ErrorTracker] Error captured', errorData);
// Trigger error event
this.triggerErrorEvent(errorData);
}
/**
* Create error data object
*/
createErrorData(error, context = {}) {
const errorData = {
message: error?.message || String(error),
name: error?.name || 'Error',
stack: this.config.includeStack ? this.getStackTrace(error) : undefined,
timestamp: Date.now(),
type: context.type || 'error',
context: this.config.includeContext ? {
...context,
userAgent: this.config.includeUserAgent ? navigator.userAgent : undefined,
url: this.config.includeUrl ? window.location.href : undefined,
referrer: document.referrer || undefined,
viewport: {
width: window.innerWidth,
height: window.innerHeight
}
} : context
};
// Add additional error properties
if (error && typeof error === 'object') {
Object.keys(error).forEach(key => {
if (!['message', 'name', 'stack'].includes(key)) {
errorData[key] = error[key];
}
});
}
return errorData;
}
/**
* Get stack trace
*/
getStackTrace(error) {
if (error?.stack) {
return error.stack;
}
try {
throw new Error();
} catch (e) {
return e.stack || 'No stack trace available';
}
}
/**
* Generate error fingerprint for grouping
*/
generateFingerprint(errorData) {
// Group by message and stack trace (first few lines)
const message = errorData.message || '';
const stack = errorData.stack || '';
const stackLines = stack.split('\n').slice(0, 3).join('\n');
return `${errorData.name}:${message}:${stackLines}`;
}
/**
* Group errors
*/
groupError(errorData) {
const fingerprint = this.generateFingerprint(errorData);
const now = Date.now();
if (!this.errorGroups.has(fingerprint)) {
this.errorGroups.set(fingerprint, {
fingerprint,
count: 0,
firstSeen: now,
lastSeen: now,
errors: []
});
}
const group = this.errorGroups.get(fingerprint);
group.count++;
group.lastSeen = now;
group.errors.push(errorData);
// Limit errors in group
if (group.errors.length > 10) {
group.errors.shift();
}
// Clean up old groups
this.cleanupGroups(now);
}
/**
* Clean up old error groups
*/
cleanupGroups(now) {
const cutoff = now - this.config.groupingWindow;
for (const [fingerprint, group] of this.errorGroups.entries()) {
if (group.lastSeen < cutoff) {
this.errorGroups.delete(fingerprint);
}
}
}
/**
* Check if error should be filtered
*/
shouldFilter(error, context) {
for (const filter of this.config.filters) {
if (typeof filter === 'function') {
if (filter(error, context) === false) {
return true; // Filter out
}
} else if (filter instanceof RegExp) {
const message = error?.message || String(error);
if (filter.test(message)) {
return true; // Filter out
}
}
}
return false;
}
/**
* Start reporting errors
*/
startReporting() {
// Report errors periodically
setInterval(() => {
this.flushReports();
}, 5000); // Every 5 seconds
}
/**
* Flush error reports to backend
*/
async flushReports() {
if (this.isReporting || this.reportQueue.length === 0) {
return;
}
this.isReporting = true;
try {
const errorsToReport = [...this.reportQueue];
this.reportQueue = [];
if (errorsToReport.length === 0) {
return;
}
// Send errors to backend
const response = await fetch(this.config.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
errors: errorsToReport,
errorGroups: Array.from(this.errorGroups.values()).map(group => ({
fingerprint: group.fingerprint,
count: group.count,
firstSeen: group.firstSeen,
lastSeen: group.lastSeen
}))
})
});
if (!response.ok) {
throw new Error(`Error reporting failed: ${response.status}`);
}
Logger.debug('[ErrorTracker] Errors reported', { count: errorsToReport.length });
} catch (error) {
Logger.error('[ErrorTracker] Failed to report errors', error);
// Re-queue errors for retry
// Note: In production, you might want to limit retries
} finally {
this.isReporting = false;
}
}
/**
* Manually report errors
*/
async report() {
await this.flushReports();
}
/**
* Get error groups
*/
getErrorGroups() {
return Array.from(this.errorGroups.values());
}
/**
* Get errors
*/
getErrors() {
return [...this.errors];
}
/**
* Clear errors
*/
clearErrors() {
this.errors = [];
this.errorGroups.clear();
this.reportQueue = [];
}
/**
* Trigger error event
*/
triggerErrorEvent(errorData) {
const event = new CustomEvent('error-tracker:error', {
detail: errorData,
bubbles: true
});
window.dispatchEvent(event);
}
/**
* Destroy error tracker
*/
destroy() {
// Flush remaining errors
this.flushReports();
// Clear data
this.clearErrors();
Logger.info('[ErrorTracker] Destroyed');
}
}
/**
* Create a global error tracker instance
*/
let globalErrorTracker = null;
/**
* Get or create global error tracker
*/
export function getGlobalErrorTracker(config = {}) {
if (!globalErrorTracker) {
globalErrorTracker = ErrorTracker.create(config);
}
return globalErrorTracker;
}

View File

@@ -0,0 +1,76 @@
/**
* Error Tracking Module
*
* Provides centralized error tracking and reporting.
*
* Usage:
* - Add data-module="error-tracking" to enable global error tracking
* - Or import and use directly: import { ErrorTracker } from './modules/error-tracking/index.js'
*
* Features:
* - Error collection and grouping
* - Error reporting to backend
* - Error analytics
* - Integration with ErrorBoundary
* - Source map support
*/
import { Logger } from '../../core/logger.js';
import { ErrorTracker, getGlobalErrorTracker } from './ErrorTracker.js';
const ErrorTrackingModule = {
name: 'error-tracking',
errorTracker: null,
init(config = {}, state = null) {
Logger.info('[ErrorTrackingModule] Module initialized');
// Create global error tracker
this.errorTracker = getGlobalErrorTracker(config);
// Expose globally for easy access
if (typeof window !== 'undefined') {
window.ErrorTracker = this.errorTracker;
}
return this;
},
/**
* Get error tracker instance
*/
getErrorTracker() {
return this.errorTracker || getGlobalErrorTracker();
},
/**
* Manually capture an error
*/
captureException(error, context = {}) {
const tracker = this.getErrorTracker();
tracker.captureException(error, context);
},
destroy() {
if (this.errorTracker) {
this.errorTracker.destroy();
this.errorTracker = null;
}
if (typeof window !== 'undefined' && window.ErrorTracker) {
delete window.ErrorTracker;
}
Logger.info('[ErrorTrackingModule] Module destroyed');
}
};
// Export for direct usage
export { ErrorTracker, getGlobalErrorTracker };
// Export as default for module system
export default ErrorTrackingModule;
// Export init function for module system
export const init = ErrorTrackingModule.init.bind(ErrorTrackingModule);

View File

@@ -0,0 +1,322 @@
/**
* Event Bus Module
*
* Provides centralized event system for cross-module communication.
* Features:
* - Pub/sub pattern
* - Namespaced events
* - Event filtering
* - Event history
* - Integration with LiveComponents
* - Integration with SSE
*/
import { Logger } from '../../core/logger.js';
/**
* EventBus - Centralized event system
*/
export class EventBus {
constructor(config = {}) {
this.config = {
enableHistory: config.enableHistory ?? false,
maxHistorySize: config.maxHistorySize || 100,
enableWildcards: config.enableWildcards ?? true,
...config
};
this.subscribers = new Map(); // Map<eventName, Set<callback>>
this.history = [];
this.middleware = [];
Logger.info('[EventBus] Initialized', {
enableHistory: this.config.enableHistory,
enableWildcards: this.config.enableWildcards
});
}
/**
* Create a new EventBus instance
*/
static create(config = {}) {
return new EventBus(config);
}
/**
* Subscribe to an event
*/
on(eventName, callback, options = {}) {
if (typeof callback !== 'function') {
throw new Error('Callback must be a function');
}
if (!this.subscribers.has(eventName)) {
this.subscribers.set(eventName, new Set());
}
const subscriber = {
callback,
once: options.once ?? false,
priority: options.priority || 0,
filter: options.filter || null
};
this.subscribers.get(eventName).add(subscriber);
// Sort by priority
const subscribers = Array.from(this.subscribers.get(eventName));
subscribers.sort((a, b) => b.priority - a.priority);
this.subscribers.set(eventName, new Set(subscribers));
// Return unsubscribe function
return () => {
const callbacks = this.subscribers.get(eventName);
if (callbacks) {
callbacks.delete(subscriber);
if (callbacks.size === 0) {
this.subscribers.delete(eventName);
}
}
};
}
/**
* Subscribe to an event (once)
*/
once(eventName, callback, options = {}) {
return this.on(eventName, callback, { ...options, once: true });
}
/**
* Unsubscribe from an event
*/
off(eventName, callback) {
if (!this.subscribers.has(eventName)) {
return;
}
const callbacks = this.subscribers.get(eventName);
for (const subscriber of callbacks) {
if (subscriber.callback === callback) {
callbacks.delete(subscriber);
break;
}
}
if (callbacks.size === 0) {
this.subscribers.delete(eventName);
}
}
/**
* Emit an event
*/
emit(eventName, data = null, options = {}) {
// Apply middleware
let processedData = data;
for (const middleware of this.middleware) {
const result = middleware(eventName, processedData, options);
if (result === null || result === false) {
return; // Middleware blocked the event
}
if (result !== undefined) {
processedData = result;
}
}
// Add to history
if (this.config.enableHistory) {
this.addToHistory(eventName, processedData, options);
}
// Get subscribers for exact event name
const subscribers = this.subscribers.get(eventName);
if (subscribers) {
this.notifySubscribers(subscribers, eventName, processedData, options);
}
// Handle wildcards
if (this.config.enableWildcards) {
this.handleWildcards(eventName, processedData, options);
}
// Handle namespaced events (e.g., 'user:created' triggers 'user:*')
this.handleNamespacedEvents(eventName, processedData, options);
Logger.debug('[EventBus] Event emitted', { eventName, data: processedData });
}
/**
* Notify subscribers
*/
notifySubscribers(subscribers, eventName, data, options) {
const subscribersArray = Array.from(subscribers);
for (const subscriber of subscribersArray) {
// Apply filter
if (subscriber.filter && !subscriber.filter(data, options)) {
continue;
}
try {
subscriber.callback(data, eventName, options);
} catch (error) {
Logger.error('[EventBus] Subscriber error', error);
}
// Remove if once
if (subscriber.once) {
subscribers.delete(subscriber);
}
}
}
/**
* Handle wildcard subscriptions
*/
handleWildcards(eventName, data, options) {
// Check for '*' subscribers
const wildcardSubscribers = this.subscribers.get('*');
if (wildcardSubscribers) {
this.notifySubscribers(wildcardSubscribers, eventName, data, options);
}
// Check for pattern matches (e.g., 'user:*' matches 'user:created')
for (const [pattern, subscribers] of this.subscribers.entries()) {
if (pattern.includes('*') && this.matchesPattern(eventName, pattern)) {
this.notifySubscribers(subscribers, eventName, data, options);
}
}
}
/**
* Handle namespaced events
*/
handleNamespacedEvents(eventName, data, options) {
const parts = eventName.split(':');
if (parts.length > 1) {
// Emit namespace wildcard (e.g., 'user:created' triggers 'user:*')
const namespacePattern = parts[0] + ':*';
const namespaceSubscribers = this.subscribers.get(namespacePattern);
if (namespaceSubscribers) {
this.notifySubscribers(namespaceSubscribers, eventName, data, options);
}
}
}
/**
* Check if event name matches pattern
*/
matchesPattern(eventName, pattern) {
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
return regex.test(eventName);
}
/**
* Add middleware
*/
use(middleware) {
if (typeof middleware !== 'function') {
throw new Error('Middleware must be a function');
}
this.middleware.push(middleware);
}
/**
* Add event to history
*/
addToHistory(eventName, data, options) {
this.history.push({
eventName,
data,
options,
timestamp: Date.now()
});
// Limit history size
if (this.history.length > this.config.maxHistorySize) {
this.history.shift();
}
}
/**
* Get event history
*/
getHistory(filter = null) {
if (!filter) {
return [...this.history];
}
if (typeof filter === 'string') {
// Filter by event name
return this.history.filter(event => event.eventName === filter);
}
if (typeof filter === 'function') {
// Custom filter function
return this.history.filter(filter);
}
return [];
}
/**
* Clear event history
*/
clearHistory() {
this.history = [];
}
/**
* Get all event names
*/
getEventNames() {
return Array.from(this.subscribers.keys());
}
/**
* Get subscriber count for an event
*/
getSubscriberCount(eventName) {
const subscribers = this.subscribers.get(eventName);
return subscribers ? subscribers.size : 0;
}
/**
* Remove all subscribers for an event
*/
removeAllListeners(eventName = null) {
if (eventName) {
this.subscribers.delete(eventName);
} else {
this.subscribers.clear();
}
}
/**
* Destroy event bus
*/
destroy() {
this.subscribers.clear();
this.history = [];
this.middleware = [];
Logger.info('[EventBus] Destroyed');
}
}
/**
* Create a global event bus instance
*/
let globalEventBus = null;
/**
* Get or create global event bus
*/
export function getGlobalEventBus(config = {}) {
if (!globalEventBus) {
globalEventBus = EventBus.create(config);
}
return globalEventBus;
}

View File

@@ -0,0 +1,93 @@
/**
* Event Bus Module
*
* Provides centralized event system for cross-module communication.
*
* Usage:
* - Add data-module="event-bus" to enable global event bus
* - Or import and use directly: import { EventBus } from './modules/event-bus/index.js'
*
* Features:
* - Pub/sub pattern
* - Namespaced events
* - Event filtering
* - Event history
* - Integration with LiveComponents
* - Integration with SSE
*/
import { Logger } from '../../core/logger.js';
import { EventBus, getGlobalEventBus } from './EventBus.js';
const EventBusModule = {
name: 'event-bus',
eventBus: null,
init(config = {}, state = null) {
Logger.info('[EventBusModule] Module initialized');
// Create global event bus
this.eventBus = getGlobalEventBus(config);
// Expose globally for easy access
if (typeof window !== 'undefined') {
window.EventBus = this.eventBus;
}
return this;
},
/**
* Get event bus instance
*/
getEventBus() {
return this.eventBus || getGlobalEventBus();
},
/**
* Emit an event
*/
emit(eventName, data = null, options = {}) {
const bus = this.getEventBus();
bus.emit(eventName, data, options);
},
/**
* Subscribe to an event
*/
on(eventName, callback, options = {}) {
const bus = this.getEventBus();
return bus.on(eventName, callback, options);
},
/**
* Unsubscribe from an event
*/
off(eventName, callback) {
const bus = this.getEventBus();
bus.off(eventName, callback);
},
destroy() {
if (this.eventBus) {
this.eventBus.destroy();
this.eventBus = null;
}
if (typeof window !== 'undefined' && window.EventBus) {
delete window.EventBus;
}
Logger.info('[EventBusModule] Module destroyed');
}
};
// Export for direct usage
export { EventBus, getGlobalEventBus };
// Export as default for module system
export default EventBusModule;
// Export init function for module system
export const init = EventBusModule.init.bind(EventBusModule);

View File

@@ -1,674 +1,53 @@
import { FormHandler } from './form-handling/FormHandler.js';
import { Logger } from '../core/logger.js';
/**
* Form Auto-Save System
* FormAutoSave - Wrapper for FormHandler autosave functionality
*
* 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();
* This module provides a simple interface to initialize autosave
* for all forms on the page. The actual autosave logic is implemented
* in FormHandler.
*/
export class FormAutoSave {
/**
* Default configuration
* Initialize autosave for all forms on the page
* @param {Object} options - Options for autosave initialization
* @returns {Array} Array of initialized FormHandler instances
*/
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"])');
static initializeAll(options = {}) {
const forms = document.querySelectorAll('form[data-autosave]');
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}`;
forms.forEach(form => {
try {
const handler = FormHandler.create(form, {
enableAutosave: true,
...options
});
instances.push(handler);
} catch (error) {
Logger.error('[FormAutoSave] Failed to initialize autosave for form', {
form: form.id || 'unnamed',
error
});
}
instances.push(instance);
});
console.log(`FormAutoSave: Initialized for ${instances.length} forms`);
Logger.info(`[FormAutoSave] Initialized autosave for ${instances.length} forms`);
return instances;
}
/**
* Initialize autosave for a specific form
* @param {HTMLFormElement} form - The form element
* @param {Object} options - Options for autosave
* @returns {FormHandler} The FormHandler instance
*/
static initialize(form, options = {}) {
return FormHandler.create(form, {
enableAutosave: true,
...options
});
}
}
// Export for manual usage
export default FormAutoSave;

View File

@@ -13,6 +13,27 @@ export class FormHandler {
preventSubmitOnError: true,
submitMethod: 'POST',
ajaxSubmit: true,
// Autosave options
enableAutosave: options.enableAutosave ?? form.hasAttribute('data-autosave'),
autosaveInterval: options.autosaveInterval || 30000, // 30 seconds
autosaveRetentionPeriod: options.autosaveRetentionPeriod || 24 * 60 * 60 * 1000, // 24 hours
autosaveStorageKey: options.autosaveStorageKey || null,
autosaveStoragePrefix: options.autosaveStoragePrefix || 'form_draft_',
autosaveVisualFeedback: options.autosaveVisualFeedback ?? true,
autosaveExcludeFields: options.autosaveExcludeFields || [
'input[type="password"]',
'input[type="hidden"][name="_token"]',
'input[type="hidden"][name="_form_id"]',
'input[name*="password"]',
'input[name*="confirm"]',
'input[name*="honeypot"]'
],
autosaveImmediateFields: options.autosaveImmediateFields || [
'textarea',
'input[type="email"]',
'input[type="text"]',
'select'
],
...options
};
@@ -20,6 +41,11 @@ export class FormHandler {
this.state = FormState.create(form);
this.isSubmitting = false;
// Autosave state
this.autosaveTimer = null;
this.lastAutosaveTime = null;
this.autosaveStorageKey = null;
this.init();
}
@@ -31,6 +57,11 @@ export class FormHandler {
this.bindEvents();
this.setupErrorDisplay();
// Initialize autosave if enabled
if (this.options.enableAutosave) {
this.initAutosave();
}
// Mark form as enhanced
this.form.setAttribute('data-enhanced', 'true');
@@ -55,6 +86,26 @@ export class FormHandler {
}
});
}
// Autosave events
if (this.options.enableAutosave) {
this.form.addEventListener('input', (e) => this.onAutosaveFieldChange(e));
this.form.addEventListener('change', (e) => this.onAutosaveFieldChange(e));
// Save on page unload
window.addEventListener('beforeunload', () => {
if (this.state.isDirty()) {
this.saveAutosaveDraft();
}
});
// Handle page visibility changes
document.addEventListener('visibilitychange', () => {
if (document.hidden && this.state.isDirty()) {
this.saveAutosaveDraft();
}
});
}
}
async handleSubmit(event) {
@@ -139,6 +190,11 @@ export class FormHandler {
handleSuccess(data) {
Logger.info('[FormHandler] Form submitted successfully');
// Clear autosave draft on successful submission
if (this.options.enableAutosave) {
this.clearAutosaveDraft();
}
// Clear form if configured
if (data.clearForm !== false) {
this.form.reset();
@@ -345,7 +401,351 @@ export class FormHandler {
this.form.dispatchEvent(event);
}
/**
* Initialize autosave functionality
*/
initAutosave() {
// Generate storage key
this.autosaveStorageKey = this.options.autosaveStorageKey ||
this.options.autosaveStoragePrefix + this.generateFormId();
// Restore draft on init
this.restoreAutosaveDraft();
// Start periodic autosave
this.startAutosave();
Logger.info('[FormHandler] Autosave initialized', { storageKey: this.autosaveStorageKey });
}
/**
* Generate unique form identifier
*/
generateFormId() {
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, '_');
}
/**
* Handle field change for autosave
*/
onAutosaveFieldChange(event) {
const field = event.target;
// Skip excluded fields
if (this.isAutosaveFieldExcluded(field)) {
return;
}
// Save immediately for important fields
if (this.isAutosaveImmediateField(field)) {
this.saveAutosaveDraft();
}
}
/**
* Check if field should be excluded from autosave
*/
isAutosaveFieldExcluded(field) {
return this.options.autosaveExcludeFields.some(selector =>
field.matches(selector)
);
}
/**
* Check if field should trigger immediate save
*/
isAutosaveImmediateField(field) {
return this.options.autosaveImmediateFields.some(selector =>
field.matches(selector)
);
}
/**
* Start periodic autosave
*/
startAutosave() {
if (this.autosaveTimer) {
return;
}
this.autosaveTimer = setInterval(() => {
if (this.state.isDirty()) {
this.saveAutosaveDraft();
}
}, this.options.autosaveInterval);
Logger.debug('[FormHandler] Autosave started', { interval: this.options.autosaveInterval });
}
/**
* Stop periodic autosave
*/
stopAutosave() {
if (this.autosaveTimer) {
clearInterval(this.autosaveTimer);
this.autosaveTimer = null;
}
}
/**
* Save form data as draft
*/
saveAutosaveDraft() {
if (!this.form || !this.state.isDirty()) {
return;
}
try {
const formData = this.extractAutosaveFormData();
const draft = {
data: formData,
timestamp: Date.now(),
formId: this.generateFormId(),
url: window.location.href,
version: '1.0'
};
localStorage.setItem(this.autosaveStorageKey, JSON.stringify(draft));
this.lastAutosaveTime = new Date();
Logger.debug('[FormHandler] Draft saved', {
fields: Object.keys(formData).length,
storageKey: this.autosaveStorageKey
});
if (this.options.autosaveVisualFeedback) {
this.showAutosaveStatus('Draft saved', 'success', 1500);
}
// Trigger event
this.triggerEvent('form:autosave', { draft });
} catch (error) {
Logger.error('[FormHandler] Failed to save draft', error);
if (this.options.autosaveVisualFeedback) {
this.showAutosaveStatus('Failed to save draft', 'error', 3000);
}
}
}
/**
* Restore draft data to form
*/
restoreAutosaveDraft() {
try {
const draftJson = localStorage.getItem(this.autosaveStorageKey);
if (!draftJson) {
return;
}
const draft = JSON.parse(draftJson);
// Check if draft is expired
const age = Date.now() - draft.timestamp;
if (age > this.options.autosaveRetentionPeriod) {
localStorage.removeItem(this.autosaveStorageKey);
return;
}
// Restore form data
this.restoreAutosaveFormData(draft.data);
Logger.info('[FormHandler] Draft restored', {
age: Math.floor(age / 1000) + 's',
fields: Object.keys(draft.data).length
});
if (this.options.autosaveVisualFeedback) {
const ageText = this.formatDuration(age);
this.showAutosaveStatus(`Draft restored from ${ageText} ago`, 'info', 4000);
}
// Trigger event
this.triggerEvent('form:autosave-restored', { draft });
} catch (error) {
Logger.error('[FormHandler] Failed to restore draft', error);
localStorage.removeItem(this.autosaveStorageKey);
}
}
/**
* Extract form data for autosave (excluding sensitive fields)
*/
extractAutosaveFormData() {
const fields = this.getFormFields();
const data = {};
fields.forEach(field => {
if (!this.isAutosaveFieldExcluded(field)) {
const key = field.name || field.id;
const value = this.getAutosaveFieldValue(field);
if (key && value !== null && value !== undefined && value !== '') {
data[key] = value;
}
}
});
return data;
}
/**
* Restore data to form fields
*/
restoreAutosaveFormData(data) {
let restoredCount = 0;
Object.entries(data).forEach(([key, value]) => {
const field = this.form.querySelector(`[name="${key}"], #${key}`);
if (field && !this.isAutosaveFieldExcluded(field)) {
this.setAutosaveFieldValue(field, value);
restoredCount++;
}
});
// Update form state
this.state.captureInitialValues();
return restoredCount;
}
/**
* Get form fields
*/
getFormFields() {
return Array.from(this.form.querySelectorAll(
'input:not([type="submit"]):not([type="button"]):not([type="reset"]), ' +
'textarea, select'
));
}
/**
* Get field value for autosave
*/
getAutosaveFieldValue(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 for autosave
*/
setAutosaveFieldValue(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;
}
}
/**
* Clear autosave draft
*/
clearAutosaveDraft() {
if (this.autosaveStorageKey) {
localStorage.removeItem(this.autosaveStorageKey);
}
Logger.debug('[FormHandler] Draft cleared');
}
/**
* 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 autosave status message
*/
showAutosaveStatus(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;
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);
}
destroy() {
// Stop autosave
if (this.options.enableAutosave) {
this.stopAutosave();
}
// Remove event listeners and clean up
this.form.removeAttribute('data-enhanced');
Logger.info('[FormHandler] Destroyed');

View File

@@ -0,0 +1,256 @@
/**
* Action Loading Manager for LiveComponents
*
* Provides skeleton loading states during component actions with:
* - Automatic skeleton overlay during actions
* - Configurable skeleton templates per component
* - Smooth transitions
* - Fragment-aware loading
*/
export class ActionLoadingManager {
constructor() {
this.loadingStates = new Map(); // componentId → loading state
this.skeletonTemplates = new Map(); // componentId → template
this.config = {
showDelay: 150, // ms before showing skeleton
transitionDuration: 200, // ms for transitions
preserveContent: true, // Keep content visible under skeleton
opacity: 0.6 // Skeleton overlay opacity
};
}
/**
* Show loading state for component
*
* @param {string} componentId - Component ID
* @param {HTMLElement} element - Component element
* @param {Object} options - Loading options
*/
showLoading(componentId, element, options = {}) {
// Check if already loading
if (this.loadingStates.has(componentId)) {
return; // Already showing loading state
}
const showDelay = options.showDelay ?? this.config.showDelay;
const fragments = options.fragments || null;
// Delay showing skeleton (for fast responses)
const timeoutId = setTimeout(() => {
this.createSkeletonOverlay(componentId, element, fragments, options);
}, showDelay);
// Store loading state
this.loadingStates.set(componentId, {
timeoutId,
element,
fragments,
options
});
}
/**
* Create skeleton overlay
*
* @param {string} componentId - Component ID
* @param {HTMLElement} element - Component element
* @param {Array<string>|null} fragments - Fragment names to show skeleton for
* @param {Object} options - Options
*/
createSkeletonOverlay(componentId, element, fragments, options) {
// Get skeleton template
const template = this.getSkeletonTemplate(componentId, element, fragments);
// Create overlay container
const overlay = document.createElement('div');
overlay.className = 'livecomponent-loading-overlay';
overlay.setAttribute('data-component-id', componentId);
overlay.setAttribute('aria-busy', 'true');
overlay.setAttribute('aria-label', 'Loading...');
overlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, ${this.config.opacity});
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity ${this.config.transitionDuration}ms ease;
pointer-events: none;
`;
// Add skeleton content
overlay.innerHTML = template;
// Ensure element has relative positioning
const originalPosition = element.style.position;
if (getComputedStyle(element).position === 'static') {
element.style.position = 'relative';
}
// Append overlay
element.appendChild(overlay);
// Animate in
requestAnimationFrame(() => {
overlay.style.opacity = '1';
});
// Update loading state
const state = this.loadingStates.get(componentId);
if (state) {
state.overlay = overlay;
state.originalPosition = originalPosition;
}
}
/**
* Get skeleton template for component
*
* @param {string} componentId - Component ID
* @param {HTMLElement} element - Component element
* @param {Array<string>|null} fragments - Fragment names
* @returns {string} Skeleton HTML
*/
getSkeletonTemplate(componentId, element, fragments) {
// Check for custom template
const customTemplate = this.skeletonTemplates.get(componentId);
if (customTemplate) {
return typeof customTemplate === 'function'
? customTemplate(element, fragments)
: customTemplate;
}
// If fragments specified, create fragment-specific skeletons
if (fragments && fragments.length > 0) {
return this.createFragmentSkeletons(fragments);
}
// Default skeleton template
return this.createDefaultSkeleton(element);
}
/**
* Create default skeleton template
*
* @param {HTMLElement} element - Component element
* @returns {string} Skeleton HTML
*/
createDefaultSkeleton(element) {
const height = element.offsetHeight || 200;
const width = element.offsetWidth || '100%';
return `
<div class="skeleton-container" style="width: ${width}; height: ${height}px; padding: 1.5rem;">
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text skeleton-text--80" style="margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text skeleton-text--60" style="margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text skeleton-text--80"></div>
</div>
`;
}
/**
* Create fragment-specific skeletons
*
* @param {Array<string>} fragments - Fragment names
* @returns {string} Skeleton HTML
*/
createFragmentSkeletons(fragments) {
return fragments.map(fragmentName => `
<div class="skeleton-fragment" data-fragment="${fragmentName}">
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text skeleton-text--80"></div>
</div>
`).join('');
}
/**
* Hide loading state
*
* @param {string} componentId - Component ID
*/
hideLoading(componentId) {
const state = this.loadingStates.get(componentId);
if (!state) {
return;
}
// Clear timeout if not yet shown
if (state.timeoutId) {
clearTimeout(state.timeoutId);
}
// Remove overlay if exists
if (state.overlay) {
// Animate out
state.overlay.style.opacity = '0';
setTimeout(() => {
if (state.overlay && state.overlay.parentNode) {
state.overlay.parentNode.removeChild(state.overlay);
}
}, this.config.transitionDuration);
}
// Restore original position
if (state.originalPosition !== undefined) {
state.element.style.position = state.originalPosition;
}
// Remove from map
this.loadingStates.delete(componentId);
}
/**
* Register custom skeleton template for component
*
* @param {string} componentId - Component ID
* @param {string|Function} template - Template HTML or function that returns HTML
*/
registerTemplate(componentId, template) {
this.skeletonTemplates.set(componentId, template);
}
/**
* Unregister skeleton template
*
* @param {string} componentId - Component ID
*/
unregisterTemplate(componentId) {
this.skeletonTemplates.delete(componentId);
}
/**
* Check if component is loading
*
* @param {string} componentId - Component ID
* @returns {boolean} True if loading
*/
isLoading(componentId) {
return this.loadingStates.has(componentId);
}
/**
* Update configuration
*
* @param {Object} newConfig - New configuration
*/
updateConfig(newConfig) {
this.config = {
...this.config,
...newConfig
};
}
}
// Create singleton instance
export const actionLoadingManager = new ActionLoadingManager();

View File

@@ -0,0 +1,347 @@
/**
* Error Boundary for LiveComponents
*
* Provides automatic error handling, retry mechanisms, and error recovery
* for LiveComponent operations.
*/
// Note: LiveComponentError type is defined in types/livecomponent.d.ts
// This is a runtime implementation, so we don't need to import the type
export class ErrorBoundary {
constructor(liveComponentManager) {
this.manager = liveComponentManager;
this.retryStrategies = new Map();
this.errorHandlers = new Map();
this.maxRetries = 3;
this.retryDelays = [1000, 2000, 5000]; // Progressive backoff
}
/**
* Handle error from component action
*
* @param {string} componentId - Component ID
* @param {string} action - Action method name
* @param {Error|LiveComponentError} error - Error object
* @param {Object} context - Additional context
* @returns {Promise<boolean>} True if error was handled, false otherwise
*/
async handleError(componentId, action, error, context = {}) {
console.error(`[ErrorBoundary] Error in ${componentId}.${action}:`, error);
// Convert to standardized error format
const standardizedError = this.standardizeError(error, componentId, action);
// Check for custom error handler
const handler = this.errorHandlers.get(componentId);
if (handler) {
try {
const handled = await handler(standardizedError, context);
if (handled) {
return true;
}
} catch (handlerError) {
console.error('[ErrorBoundary] Error handler failed:', handlerError);
}
}
// Check if error is retryable
if (this.isRetryable(standardizedError)) {
const retried = await this.retryOperation(componentId, action, context, standardizedError);
if (retried) {
return true;
}
}
// Show error to user
this.showError(componentId, standardizedError);
// Dispatch error event
this.dispatchErrorEvent(componentId, standardizedError);
return false;
}
/**
* Standardize error format
*
* @param {Error|LiveComponentError|Object} error - Error object
* @param {string} componentId - Component ID
* @param {string} action - Action method name
* @returns {LiveComponentError} Standardized error
*/
standardizeError(error, componentId, action) {
// If already standardized
if (error && typeof error === 'object' && 'code' in error && 'message' in error) {
return {
code: error.code,
message: error.message,
details: error.details || {},
componentId: error.componentId || componentId,
action: error.action || action,
timestamp: error.timestamp || Date.now()
};
}
// If Error object
if (error instanceof Error) {
return {
code: 'INTERNAL_ERROR',
message: error.message,
details: {
stack: error.stack,
name: error.name
},
componentId,
action,
timestamp: Date.now()
};
}
// If string
if (typeof error === 'string') {
return {
code: 'INTERNAL_ERROR',
message: error,
componentId,
action,
timestamp: Date.now()
};
}
// Default
return {
code: 'INTERNAL_ERROR',
message: 'An unknown error occurred',
details: { original: error },
componentId,
action,
timestamp: Date.now()
};
}
/**
* Check if error is retryable
*
* @param {LiveComponentError} error - Standardized error
* @returns {boolean} True if error is retryable
*/
isRetryable(error) {
const retryableCodes = [
'RATE_LIMIT_EXCEEDED',
'STATE_CONFLICT',
'INTERNAL_ERROR'
];
return retryableCodes.includes(error.code);
}
/**
* Retry operation with exponential backoff
*
* @param {string} componentId - Component ID
* @param {string} action - Action method name
* @param {Object} context - Operation context
* @param {LiveComponentError} error - Error that occurred
* @returns {Promise<boolean>} True if retry succeeded
*/
async retryOperation(componentId, action, context, error) {
const retryKey = `${componentId}:${action}`;
const retryCount = this.retryStrategies.get(retryKey) || 0;
if (retryCount >= this.maxRetries) {
console.warn(`[ErrorBoundary] Max retries exceeded for ${retryKey}`);
this.retryStrategies.delete(retryKey);
return false;
}
// Calculate delay (progressive backoff)
const delay = this.retryDelays[retryCount] || this.retryDelays[this.retryDelays.length - 1];
console.log(`[ErrorBoundary] Retrying ${retryKey} in ${delay}ms (attempt ${retryCount + 1}/${this.maxRetries})`);
// Update retry count
this.retryStrategies.set(retryKey, retryCount + 1);
// Wait before retry
await new Promise(resolve => setTimeout(resolve, delay));
try {
// Retry the operation
const result = await this.manager.executeAction(
componentId,
action,
context.params || {},
context.fragments || null
);
// Success - clear retry count
this.retryStrategies.delete(retryKey);
console.log(`[ErrorBoundary] Retry succeeded for ${retryKey}`);
return true;
} catch (retryError) {
// Retry failed - will be handled by next retry or error handler
console.warn(`[ErrorBoundary] Retry failed for ${retryKey}:`, retryError);
return false;
}
}
/**
* Show error to user
*
* @param {string} componentId - Component ID
* @param {LiveComponentError} error - Standardized error
*/
showError(componentId, error) {
const config = this.manager.components.get(componentId);
if (!config) return;
// Remove existing error
const existingError = config.element.querySelector('.livecomponent-error-boundary');
if (existingError) {
existingError.remove();
}
// Create error element
const errorEl = document.createElement('div');
errorEl.className = 'livecomponent-error-boundary';
errorEl.style.cssText = `
padding: 1rem;
background: #fee;
color: #c00;
border: 1px solid #faa;
border-radius: 4px;
margin-bottom: 1rem;
`;
// Error message
const messageEl = document.createElement('div');
messageEl.style.fontWeight = 'bold';
messageEl.textContent = error.message;
errorEl.appendChild(messageEl);
// Error code (if not generic)
if (error.code !== 'INTERNAL_ERROR') {
const codeEl = document.createElement('div');
codeEl.style.fontSize = '0.875rem';
codeEl.style.marginTop = '0.5rem';
codeEl.style.color = '#666';
codeEl.textContent = `Error Code: ${error.code}`;
errorEl.appendChild(codeEl);
}
// Retry button (if retryable)
if (this.isRetryable(error)) {
const retryBtn = document.createElement('button');
retryBtn.textContent = 'Retry';
retryBtn.style.cssText = `
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background: #c00;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
`;
retryBtn.addEventListener('click', async () => {
errorEl.remove();
await this.retryOperation(componentId, error.action || '', {}, error);
});
errorEl.appendChild(retryBtn);
}
// Close button
const closeBtn = document.createElement('button');
closeBtn.textContent = '×';
closeBtn.style.cssText = `
float: right;
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #c00;
`;
closeBtn.addEventListener('click', () => {
errorEl.remove();
});
errorEl.appendChild(closeBtn);
// Insert at top of component
config.element.insertAdjacentElement('afterbegin', errorEl);
// Auto-remove after 10 seconds
setTimeout(() => {
if (errorEl.parentNode) {
errorEl.remove();
}
}, 10000);
}
/**
* Dispatch error event
*
* @param {string} componentId - Component ID
* @param {LiveComponentError} error - Standardized error
*/
dispatchErrorEvent(componentId, error) {
// Dispatch custom DOM event
const event = new CustomEvent('livecomponent:error', {
detail: {
componentId,
error
},
bubbles: true
});
const config = this.manager.components.get(componentId);
if (config) {
config.element.dispatchEvent(event);
}
// Also dispatch on document
document.dispatchEvent(event);
}
/**
* Register custom error handler for component
*
* @param {string} componentId - Component ID
* @param {Function} handler - Error handler function
*/
registerErrorHandler(componentId, handler) {
if (typeof handler !== 'function') {
throw new Error('Error handler must be a function');
}
this.errorHandlers.set(componentId, handler);
}
/**
* Clear error handler for component
*
* @param {string} componentId - Component ID
*/
clearErrorHandler(componentId) {
this.errorHandlers.delete(componentId);
}
/**
* Clear retry strategy for component/action
*
* @param {string} componentId - Component ID
* @param {string} action - Action method name
*/
clearRetryStrategy(componentId, action) {
const retryKey = `${componentId}:${action}`;
this.retryStrategies.delete(retryKey);
}
/**
* Reset all retry strategies
*/
resetRetryStrategies() {
this.retryStrategies.clear();
}
}

View File

@@ -226,7 +226,9 @@ export class LazyComponentLoader {
// Copy state to element
if (data.state) {
config.element.dataset.state = JSON.stringify(data.state);
// Use StateSerializer for type-safe state handling
const stateJson = JSON.stringify(data.state);
config.element.dataset.state = stateJson;
}
// Initialize as regular LiveComponent

View File

@@ -0,0 +1,347 @@
/**
* LiveComponent UI Helper
*
* Provides unified API for UI components (Dialogs, Modals, Notifications)
* integrated with LiveComponents.
*/
import { UIManager } from '../ui/UIManager.js';
export class LiveComponentUIHelper {
constructor(liveComponentManager) {
this.manager = liveComponentManager;
this.uiManager = UIManager;
this.activeDialogs = new Map(); // componentId → dialog instances
this.activeNotifications = new Map(); // componentId → notification instances
}
/**
* Show dialog from LiveComponent action
*
* @param {string} componentId - Component ID
* @param {Object} options - Dialog options
* @returns {Object} Dialog instance
*/
showDialog(componentId, options = {}) {
const {
title = '',
content = '',
size = 'medium',
buttons = [],
closeOnBackdrop = true,
closeOnEscape = true,
onClose = null,
onConfirm = null
} = options;
// Create dialog content
const dialogContent = this.createDialogContent(title, content, buttons, {
onClose,
onConfirm
});
// Show modal via UIManager
const modal = this.uiManager.open('modal', {
content: dialogContent,
className: `livecomponent-dialog livecomponent-dialog--${size}`,
onClose: () => {
if (onClose) {
onClose();
}
this.activeDialogs.delete(componentId);
}
});
// Store reference
this.activeDialogs.set(componentId, modal);
return modal;
}
/**
* Show confirmation dialog
*
* @param {string} componentId - Component ID
* @param {Object} options - Confirmation options
* @returns {Promise<boolean>} Promise resolving to true if confirmed
*/
showConfirm(componentId, options = {}) {
return new Promise((resolve) => {
const {
title = 'Confirm',
message = 'Are you sure?',
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmClass = 'btn-primary',
cancelClass = 'btn-secondary'
} = options;
const buttons = [
{
text: cancelText,
class: cancelClass,
action: () => {
this.closeDialog(componentId);
resolve(false);
}
},
{
text: confirmText,
class: confirmClass,
action: () => {
this.closeDialog(componentId);
resolve(true);
}
}
];
this.showDialog(componentId, {
title,
content: `<p>${message}</p>`,
buttons,
size: 'small'
});
});
}
/**
* Show alert dialog
*
* @param {string} componentId - Component ID
* @param {Object} options - Alert options
*/
showAlert(componentId, options = {}) {
const {
title = 'Alert',
message = '',
buttonText = 'OK',
type = 'info' // info, success, warning, error
} = options;
const buttons = [
{
text: buttonText,
class: `btn-${type}`,
action: () => {
this.closeDialog(componentId);
}
}
];
return this.showDialog(componentId, {
title,
content: `<p class="alert-message alert-message--${type}">${message}</p>`,
buttons,
size: 'small'
});
}
/**
* Close dialog
*
* @param {string} componentId - Component ID
*/
closeDialog(componentId) {
const dialog = this.activeDialogs.get(componentId);
if (dialog && typeof dialog.close === 'function') {
dialog.close();
this.activeDialogs.delete(componentId);
}
}
/**
* Show notification
*
* @param {string} componentId - Component ID
* @param {Object} options - Notification options
*/
showNotification(componentId, options = {}) {
const {
message = '',
type = 'info', // info, success, warning, error
duration = 5000,
position = 'top-right',
action = null
} = options;
// Create notification element
const notification = document.createElement('div');
notification.className = `livecomponent-notification livecomponent-notification--${type} livecomponent-notification--${position}`;
notification.setAttribute('role', 'alert');
notification.setAttribute('aria-live', 'polite');
notification.innerHTML = `
<div class="notification-content">
<span class="notification-message">${message}</span>
${action ? `<button class="notification-action">${action.text}</button>` : ''}
<button class="notification-close" aria-label="Close">×</button>
</div>
`;
// Add styles
notification.style.cssText = `
position: fixed;
${position.includes('top') ? 'top' : 'bottom'}: 1rem;
${position.includes('left') ? 'left' : 'right'}: 1rem;
max-width: 400px;
padding: 1rem;
background: ${this.getNotificationColor(type)};
color: white;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 10000;
opacity: 0;
transform: translateY(${position.includes('top') ? '-20px' : '20px'});
transition: opacity 0.3s ease, transform 0.3s ease;
`;
// Add to DOM
document.body.appendChild(notification);
// Animate in
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
});
// Setup close button
const closeBtn = notification.querySelector('.notification-close');
closeBtn.addEventListener('click', () => {
this.hideNotification(notification);
});
// Setup action button
if (action) {
const actionBtn = notification.querySelector('.notification-action');
actionBtn.addEventListener('click', () => {
if (action.handler) {
action.handler();
}
this.hideNotification(notification);
});
}
// Auto-hide after duration
if (duration > 0) {
setTimeout(() => {
this.hideNotification(notification);
}, duration);
}
// Store reference
this.activeNotifications.set(componentId, notification);
return notification;
}
/**
* Hide notification
*
* @param {HTMLElement|string} notificationOrComponentId - Notification element or component ID
*/
hideNotification(notificationOrComponentId) {
const notification = typeof notificationOrComponentId === 'string'
? this.activeNotifications.get(notificationOrComponentId)
: notificationOrComponentId;
if (!notification) {
return;
}
// Animate out
notification.style.opacity = '0';
notification.style.transform = `translateY(${notification.classList.contains('livecomponent-notification--top') ? '-20px' : '20px'})`;
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
// Remove from map
for (const [componentId, notif] of this.activeNotifications.entries()) {
if (notif === notification) {
this.activeNotifications.delete(componentId);
break;
}
}
}, 300);
}
/**
* Create dialog content HTML
*
* @param {string} title - Dialog title
* @param {string} content - Dialog content
* @param {Array} buttons - Button configurations
* @param {Object} callbacks - Callback functions
* @returns {string} HTML content
*/
createDialogContent(title, content, buttons, callbacks) {
const buttonsHtml = buttons.map((btn, index) => {
const btnClass = btn.class || 'btn-secondary';
const btnAction = btn.action || (() => {});
return `<button class="btn ${btnClass}" data-dialog-action="${index}">${btn.text || 'Button'}</button>`;
}).join('');
return `
<div class="livecomponent-dialog-content">
${title ? `<div class="dialog-header"><h3>${title}</h3></div>` : ''}
<div class="dialog-body">${content}</div>
${buttons.length > 0 ? `<div class="dialog-footer">${buttonsHtml}</div>` : ''}
</div>
`;
}
/**
* Get notification color by type
*
* @param {string} type - Notification type
* @returns {string} Color value
*/
getNotificationColor(type) {
const colors = {
info: '#3b82f6',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444'
};
return colors[type] || colors.info;
}
/**
* Show loading dialog
*
* @param {string} componentId - Component ID
* @param {string} message - Loading message
* @returns {Object} Dialog instance
*/
showLoadingDialog(componentId, message = 'Loading...') {
return this.showDialog(componentId, {
title: '',
content: `
<div class="loading-dialog">
<div class="spinner"></div>
<p>${message}</p>
</div>
`,
size: 'small',
buttons: [],
closeOnBackdrop: false,
closeOnEscape: false
});
}
/**
* Cleanup all UI components for component
*
* @param {string} componentId - Component ID
*/
cleanup(componentId) {
// Close dialogs
this.closeDialog(componentId);
// Hide notifications
this.hideNotification(componentId);
}
}

View File

@@ -0,0 +1,307 @@
/**
* Loading State Manager for LiveComponents
*
* Manages different loading indicators (skeleton, spinner, progress) during actions.
* Integrates with OptimisticStateManager and ActionLoadingManager.
*/
export class LoadingStateManager {
constructor(actionLoadingManager, optimisticStateManager) {
this.actionLoadingManager = actionLoadingManager;
this.optimisticStateManager = optimisticStateManager;
this.loadingConfigs = new Map(); // componentId → loading config
this.config = {
defaultType: 'skeleton', // skeleton, spinner, progress, none
showDelay: 150,
hideDelay: 0
};
}
/**
* Configure loading state for component
*
* @param {string} componentId - Component ID
* @param {Object} config - Loading configuration
*/
configure(componentId, config) {
this.loadingConfigs.set(componentId, {
type: config.type || this.config.defaultType,
showDelay: config.showDelay ?? this.config.showDelay,
hideDelay: config.hideDelay ?? this.config.hideDelay,
template: config.template || null,
...config
});
}
/**
* Show loading state for action
*
* @param {string} componentId - Component ID
* @param {HTMLElement} element - Component element
* @param {Object} options - Loading options
*/
showLoading(componentId, element, options = {}) {
const config = this.loadingConfigs.get(componentId) || {
type: this.config.defaultType,
showDelay: this.config.showDelay
};
const loadingType = options.type || config.type;
// Skip if type is 'none' or optimistic UI is enabled
if (loadingType === 'none') {
return;
}
// Check if optimistic UI is active (no loading needed)
const pendingOps = this.optimisticStateManager.getPendingOperations(componentId);
if (pendingOps.length > 0 && options.optimistic !== false) {
// Optimistic UI is active - skip loading indicator
return;
}
switch (loadingType) {
case 'skeleton':
this.actionLoadingManager.showLoading(componentId, element, {
...options,
showDelay: config.showDelay
});
break;
case 'spinner':
this.showSpinner(componentId, element, config);
break;
case 'progress':
this.showProgress(componentId, element, config);
break;
default:
// Fallback to skeleton
this.actionLoadingManager.showLoading(componentId, element, options);
}
}
/**
* Hide loading state
*
* @param {string} componentId - Component ID
*/
hideLoading(componentId) {
// Hide skeleton loading
this.actionLoadingManager.hideLoading(componentId);
// Hide spinner
this.hideSpinner(componentId);
// Hide progress
this.hideProgress(componentId);
}
/**
* Show spinner loading indicator
*
* @param {string} componentId - Component ID
* @param {HTMLElement} element - Component element
* @param {Object} config - Configuration
*/
showSpinner(componentId, element, config) {
// Remove existing spinner
this.hideSpinner(componentId);
const spinner = document.createElement('div');
spinner.className = 'livecomponent-loading-spinner';
spinner.setAttribute('data-component-id', componentId);
spinner.setAttribute('aria-busy', 'true');
spinner.setAttribute('aria-label', 'Loading...');
spinner.innerHTML = `
<div class="spinner"></div>
<span class="spinner-text">Loading...</span>
`;
spinner.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
z-index: 10;
opacity: 0;
transition: opacity ${this.config.showDelay}ms ease;
`;
// Ensure element has relative positioning
if (getComputedStyle(element).position === 'static') {
element.style.position = 'relative';
}
element.appendChild(spinner);
// Animate in
requestAnimationFrame(() => {
spinner.style.opacity = '1';
});
// Store reference
const loadingConfig = this.loadingConfigs.get(componentId) || {};
loadingConfig.spinner = spinner;
this.loadingConfigs.set(componentId, loadingConfig);
}
/**
* Hide spinner
*
* @param {string} componentId - Component ID
*/
hideSpinner(componentId) {
const config = this.loadingConfigs.get(componentId);
if (!config || !config.spinner) {
return;
}
const spinner = config.spinner;
spinner.style.opacity = '0';
setTimeout(() => {
if (spinner.parentNode) {
spinner.parentNode.removeChild(spinner);
}
delete config.spinner;
}, 200);
}
/**
* Show progress bar
*
* @param {string} componentId - Component ID
* @param {HTMLElement} element - Component element
* @param {Object} config - Configuration
*/
showProgress(componentId, element, config) {
// Remove existing progress
this.hideProgress(componentId);
const progress = document.createElement('div');
progress.className = 'livecomponent-loading-progress';
progress.setAttribute('data-component-id', componentId);
progress.setAttribute('aria-busy', 'true');
progress.innerHTML = `
<div class="progress-bar" style="width: 0%;"></div>
`;
progress.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: #e0e0e0;
z-index: 10;
opacity: 0;
transition: opacity ${this.config.showDelay}ms ease;
`;
const progressBar = progress.querySelector('.progress-bar');
progressBar.style.cssText = `
height: 100%;
background: #2196F3;
transition: width 0.3s ease;
`;
// Ensure element has relative positioning
if (getComputedStyle(element).position === 'static') {
element.style.position = 'relative';
}
element.appendChild(progress);
// Animate in
requestAnimationFrame(() => {
progress.style.opacity = '1';
// Simulate progress (can be updated via updateProgress)
this.updateProgress(componentId, 30);
});
// Store reference
const loadingConfig = this.loadingConfigs.get(componentId) || {};
loadingConfig.progress = progress;
this.loadingConfigs.set(componentId, loadingConfig);
}
/**
* Update progress bar
*
* @param {string} componentId - Component ID
* @param {number} percent - Progress percentage (0-100)
*/
updateProgress(componentId, percent) {
const config = this.loadingConfigs.get(componentId);
if (!config || !config.progress) {
return;
}
const progressBar = config.progress.querySelector('.progress-bar');
if (progressBar) {
progressBar.style.width = `${Math.min(100, Math.max(0, percent))}%`;
}
}
/**
* Hide progress bar
*
* @param {string} componentId - Component ID
*/
hideProgress(componentId) {
const config = this.loadingConfigs.get(componentId);
if (!config || !config.progress) {
return;
}
const progress = config.progress;
// Complete progress bar
this.updateProgress(componentId, 100);
setTimeout(() => {
progress.style.opacity = '0';
setTimeout(() => {
if (progress.parentNode) {
progress.parentNode.removeChild(progress);
}
delete config.progress;
}, 200);
}, 300);
}
/**
* Get loading configuration for component
*
* @param {string} componentId - Component ID
* @returns {Object} Loading configuration
*/
getConfig(componentId) {
return this.loadingConfigs.get(componentId) || {
type: this.config.defaultType,
showDelay: this.config.showDelay
};
}
/**
* Clear configuration for component
*
* @param {string} componentId - Component ID
*/
clearConfig(componentId) {
this.hideLoading(componentId);
this.loadingConfigs.delete(componentId);
}
}

View File

@@ -0,0 +1,180 @@
/**
* Request Deduplication for LiveComponents
*
* Prevents duplicate requests for the same action with the same parameters.
* Useful for preventing race conditions and reducing server load.
*/
export class RequestDeduplicator {
constructor() {
this.pendingRequests = new Map();
this.requestCache = new Map();
this.cacheTimeout = 1000; // 1 second cache
}
/**
* Generate request key for deduplication
*
* @param {string} componentId - Component ID
* @param {string} method - Action method name
* @param {Object} params - Action parameters
* @returns {string} Request key
*/
generateKey(componentId, method, params) {
// Sort params for consistent key generation
const sortedParams = Object.keys(params)
.sort()
.reduce((acc, key) => {
acc[key] = params[key];
return acc;
}, {});
const paramsString = JSON.stringify(sortedParams);
return `${componentId}:${method}:${paramsString}`;
}
/**
* Check if request is already pending
*
* @param {string} componentId - Component ID
* @param {string} method - Action method name
* @param {Object} params - Action parameters
* @returns {Promise|null} Pending request promise or null
*/
getPendingRequest(componentId, method, params) {
const key = this.generateKey(componentId, method, params);
return this.pendingRequests.get(key) || null;
}
/**
* Register pending request
*
* @param {string} componentId - Component ID
* @param {string} method - Action method name
* @param {Object} params - Action parameters
* @param {Promise} promise - Request promise
* @returns {Promise} Request promise
*/
registerPendingRequest(componentId, method, params, promise) {
const key = this.generateKey(componentId, method, params);
// Store pending request
this.pendingRequests.set(key, promise);
// Clean up when request completes
promise
.then(() => {
this.pendingRequests.delete(key);
})
.catch(() => {
this.pendingRequests.delete(key);
});
return promise;
}
/**
* Check if request result is cached
*
* @param {string} componentId - Component ID
* @param {string} method - Action method name
* @param {Object} params - Action parameters
* @returns {Object|null} Cached result or null
*/
getCachedResult(componentId, method, params) {
const key = this.generateKey(componentId, method, params);
const cached = this.requestCache.get(key);
if (!cached) {
return null;
}
// Check if cache is still valid
const now = Date.now();
if (now - cached.timestamp > this.cacheTimeout) {
this.requestCache.delete(key);
return null;
}
return cached.result;
}
/**
* Cache request result
*
* @param {string} componentId - Component ID
* @param {string} method - Action method name
* @param {Object} params - Action parameters
* @param {Object} result - Request result
*/
cacheResult(componentId, method, params, result) {
const key = this.generateKey(componentId, method, params);
this.requestCache.set(key, {
result,
timestamp: Date.now()
});
// Clean up old cache entries
this.cleanupCache();
}
/**
* Clean up expired cache entries
*/
cleanupCache() {
const now = Date.now();
for (const [key, cached] of this.requestCache.entries()) {
if (now - cached.timestamp > this.cacheTimeout) {
this.requestCache.delete(key);
}
}
}
/**
* Clear all pending requests
*/
clearPendingRequests() {
this.pendingRequests.clear();
}
/**
* Clear all cached results
*/
clearCache() {
this.requestCache.clear();
}
/**
* Clear requests and cache for specific component
*
* @param {string} componentId - Component ID
*/
clearComponent(componentId) {
// Clear pending requests
for (const [key] of this.pendingRequests.entries()) {
if (key.startsWith(`${componentId}:`)) {
this.pendingRequests.delete(key);
}
}
// Clear cache
for (const [key] of this.requestCache.entries()) {
if (key.startsWith(`${componentId}:`)) {
this.requestCache.delete(key);
}
}
}
/**
* Get statistics
*
* @returns {Object} Statistics
*/
getStats() {
return {
pendingRequests: this.pendingRequests.size,
cachedResults: this.requestCache.size
};
}
}

View File

@@ -0,0 +1,124 @@
/**
* Shared Configuration between PHP and JavaScript
*
* Provides unified configuration management for LiveComponents
* that can be synchronized between server and client.
*/
export class SharedConfig {
constructor() {
this.config = {
// Request settings
requestTimeout: 30000, // 30 seconds
retryAttempts: 3,
retryDelays: [1000, 2000, 5000], // Progressive backoff
// State settings
stateVersioning: true,
stateValidation: true,
// Performance settings
requestDeduplication: true,
requestCacheTimeout: 1000, // 1 second
batchFlushDelay: 50, // 50ms
// Error handling
errorBoundary: true,
autoRetry: true,
showErrorUI: true,
// DevTools
devToolsEnabled: false
};
}
/**
* Load configuration from server
*
* @param {Object} serverConfig - Configuration from server
*/
loadFromServer(serverConfig) {
if (serverConfig && typeof serverConfig === 'object') {
this.config = {
...this.config,
...serverConfig
};
}
}
/**
* Load configuration from DOM
*
* Looks for data-livecomponent-config attribute on document or body
*/
loadFromDOM() {
const configElement = document.querySelector('[data-livecomponent-config]') || document.body;
const configJson = configElement.dataset.livecomponentConfig;
if (configJson) {
try {
const parsed = JSON.parse(configJson);
this.loadFromServer(parsed);
} catch (error) {
console.warn('[SharedConfig] Failed to parse config from DOM:', error);
}
}
}
/**
* Get configuration value
*
* @param {string} key - Configuration key
* @param {*} defaultValue - Default value if key not found
* @returns {*} Configuration value
*/
get(key, defaultValue = null) {
return this.config[key] ?? defaultValue;
}
/**
* Set configuration value
*
* @param {string} key - Configuration key
* @param {*} value - Configuration value
*/
set(key, value) {
this.config[key] = value;
}
/**
* Get all configuration
*
* @returns {Object} Full configuration object
*/
getAll() {
return { ...this.config };
}
/**
* Merge configuration
*
* @param {Object} newConfig - Configuration to merge
*/
merge(newConfig) {
this.config = {
...this.config,
...newConfig
};
}
}
// Create singleton instance
export const sharedConfig = new SharedConfig();
// Auto-load from DOM on initialization
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
sharedConfig.loadFromDOM();
});
} else {
sharedConfig.loadFromDOM();
}
}

View File

@@ -0,0 +1,245 @@
/**
* State Serializer for LiveComponents
*
* Provides type-safe state serialization/deserialization with versioning
* and validation.
*/
/**
* Serialize component state
*
* @param {LiveComponentState} state - Component state
* @returns {string} Serialized state JSON
*/
export function serializeState(state) {
// Validate state structure
if (!state || typeof state !== 'object') {
throw new Error('State must be an object');
}
if (!state.id || typeof state.id !== 'string') {
throw new Error('State must have a valid id');
}
if (!state.component || typeof state.component !== 'string') {
throw new Error('State must have a valid component name');
}
if (typeof state.version !== 'number' || state.version < 1) {
throw new Error('State must have a valid version number');
}
// Ensure data is an object
const data = state.data || {};
if (typeof data !== 'object' || Array.isArray(data)) {
throw new Error('State data must be an object');
}
// Create serializable state
const serializableState = {
id: state.id,
component: state.component,
data: data,
version: state.version
};
// Serialize to JSON
try {
return JSON.stringify(serializableState);
} catch (error) {
throw new Error(`Failed to serialize state: ${error.message}`);
}
}
/**
* Deserialize component state
*
* @param {string} json - Serialized state JSON
* @returns {LiveComponentState} Deserialized state
*/
export function deserializeState(json) {
if (typeof json !== 'string') {
throw new Error('State JSON must be a string');
}
if (json.trim() === '') {
// Return empty state
return {
id: '',
component: '',
data: {},
version: 1
};
}
try {
const parsed = JSON.parse(json);
// Validate structure
if (!parsed || typeof parsed !== 'object') {
throw new Error('Invalid state structure');
}
// Extract state data
const id = parsed.id || '';
const component = parsed.component || '';
const data = parsed.data || {};
const version = typeof parsed.version === 'number' ? parsed.version : 1;
// Validate data is an object
if (typeof data !== 'object' || Array.isArray(data)) {
throw new Error('State data must be an object');
}
return {
id,
component,
data,
version
};
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`Invalid JSON format: ${error.message}`);
}
throw error;
}
}
/**
* Create state diff (changes between two states)
*
* @param {LiveComponentState} oldState - Previous state
* @param {LiveComponentState} newState - New state
* @returns {Object} State diff
*/
export function createStateDiff(oldState, newState) {
const diff = {
changed: false,
added: {},
removed: {},
modified: {},
versionChange: newState.version - oldState.version
};
const oldData = oldState.data || {};
const newData = newState.data || {};
// Find added keys
for (const key in newData) {
if (!(key in oldData)) {
diff.added[key] = newData[key];
diff.changed = true;
}
}
// Find removed keys
for (const key in oldData) {
if (!(key in newData)) {
diff.removed[key] = oldData[key];
diff.changed = true;
}
}
// Find modified keys
for (const key in newData) {
if (key in oldData) {
const oldValue = oldData[key];
const newValue = newData[key];
// Deep comparison for objects
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
diff.modified[key] = {
old: oldValue,
new: newValue
};
diff.changed = true;
}
}
}
return diff;
}
/**
* Merge state changes
*
* @param {LiveComponentState} baseState - Base state
* @param {Object} changes - State changes to apply
* @returns {LiveComponentState} Merged state
*/
export function mergeStateChanges(baseState, changes) {
const baseData = baseState.data || {};
const mergedData = { ...baseData, ...changes };
return {
...baseState,
data: mergedData,
version: baseState.version + 1
};
}
/**
* Validate state structure
*
* @param {LiveComponentState} state - State to validate
* @returns {boolean} True if valid
*/
export function validateState(state) {
if (!state || typeof state !== 'object') {
return false;
}
if (!state.id || typeof state.id !== 'string') {
return false;
}
if (!state.component || typeof state.component !== 'string') {
return false;
}
if (typeof state.version !== 'number' || state.version < 1) {
return false;
}
if (state.data && (typeof state.data !== 'object' || Array.isArray(state.data))) {
return false;
}
return true;
}
/**
* Get state from DOM element
*
* @param {HTMLElement} element - Component element
* @returns {LiveComponentState|null} State or null if not found
*/
export function getStateFromElement(element) {
const stateJson = element.dataset.state;
if (!stateJson) {
return null;
}
try {
return deserializeState(stateJson);
} catch (error) {
console.error('[StateSerializer] Failed to deserialize state from element:', error);
return null;
}
}
/**
* Set state on DOM element
*
* @param {HTMLElement} element - Component element
* @param {LiveComponentState} state - State to set
*/
export function setStateOnElement(element, state) {
if (!validateState(state)) {
throw new Error('Invalid state structure');
}
const stateJson = serializeState(state);
element.dataset.state = stateJson;
}

View File

@@ -0,0 +1,388 @@
/**
* Tooltip Manager for LiveComponents
*
* Provides tooltip functionality for LiveComponent elements with:
* - Automatic positioning
* - Validation error tooltips
* - Accessibility support
* - Smooth animations
* - Multiple positioning strategies
*/
export class TooltipManager {
constructor() {
this.tooltips = new Map(); // element → tooltip element
this.activeTooltip = null;
this.config = {
delay: 300, // ms before showing tooltip
hideDelay: 100, // ms before hiding tooltip
maxWidth: 300, // px
offset: 8, // px offset from element
animationDuration: 200, // ms
zIndex: 10000
};
}
/**
* Initialize tooltip for element
*
* @param {HTMLElement} element - Element to attach tooltip to
* @param {Object} options - Tooltip options
*/
init(element, options = {}) {
// Get tooltip content from data attributes or options
const content = options.content ||
element.dataset.tooltip ||
element.getAttribute('title') ||
element.getAttribute('aria-label') ||
'';
if (!content) {
return; // No tooltip content
}
// Remove native title attribute to prevent default tooltip
if (element.hasAttribute('title')) {
element.dataset.originalTitle = element.getAttribute('title');
element.removeAttribute('title');
}
// Get positioning
const position = options.position ||
element.dataset.tooltipPosition ||
'top';
// Setup event listeners
this.setupEventListeners(element, content, position, options);
}
/**
* Setup event listeners for tooltip
*
* @param {HTMLElement} element - Element
* @param {string} content - Tooltip content
* @param {string} position - Position (top, bottom, left, right)
* @param {Object} options - Additional options
*/
setupEventListeners(element, content, position, options) {
let showTimeout;
let hideTimeout;
const showTooltip = () => {
clearTimeout(hideTimeout);
showTimeout = setTimeout(() => {
this.show(element, content, position, options);
}, this.config.delay);
};
const hideTooltip = () => {
clearTimeout(showTimeout);
hideTimeout = setTimeout(() => {
this.hide(element);
}, this.config.hideDelay);
};
// Mouse events
element.addEventListener('mouseenter', showTooltip);
element.addEventListener('mouseleave', hideTooltip);
element.addEventListener('focus', showTooltip);
element.addEventListener('blur', hideTooltip);
// Touch events (for mobile)
element.addEventListener('touchstart', showTooltip, { passive: true });
element.addEventListener('touchend', hideTooltip, { passive: true });
// Store cleanup function
element._tooltipCleanup = () => {
clearTimeout(showTimeout);
clearTimeout(hideTimeout);
element.removeEventListener('mouseenter', showTooltip);
element.removeEventListener('mouseleave', hideTooltip);
element.removeEventListener('focus', showTooltip);
element.removeEventListener('blur', hideTooltip);
element.removeEventListener('touchstart', showTooltip);
element.removeEventListener('touchend', hideTooltip);
};
}
/**
* Show tooltip
*
* @param {HTMLElement} element - Element
* @param {string} content - Tooltip content
* @param {string} position - Position
* @param {Object} options - Additional options
*/
show(element, content, position, options = {}) {
// Hide existing tooltip
if (this.activeTooltip) {
this.hide();
}
// Create tooltip element
const tooltip = document.createElement('div');
tooltip.className = 'livecomponent-tooltip';
tooltip.setAttribute('role', 'tooltip');
tooltip.setAttribute('aria-hidden', 'false');
tooltip.textContent = content;
// Apply styles
tooltip.style.cssText = `
position: absolute;
max-width: ${this.config.maxWidth}px;
padding: 0.5rem 0.75rem;
background: #1f2937;
color: white;
border-radius: 0.375rem;
font-size: 0.875rem;
line-height: 1.4;
z-index: ${this.config.zIndex};
pointer-events: none;
opacity: 0;
transition: opacity ${this.config.animationDuration}ms ease;
word-wrap: break-word;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
`;
// Add to DOM
document.body.appendChild(tooltip);
// Position tooltip
this.positionTooltip(tooltip, element, position);
// Animate in
requestAnimationFrame(() => {
tooltip.style.opacity = '1';
});
// Store references
this.tooltips.set(element, tooltip);
this.activeTooltip = tooltip;
// Set aria-describedby on element
const tooltipId = `tooltip-${Date.now()}`;
tooltip.id = tooltipId;
element.setAttribute('aria-describedby', tooltipId);
}
/**
* Position tooltip relative to element
*
* @param {HTMLElement} tooltip - Tooltip element
* @param {HTMLElement} element - Target element
* @param {string} position - Position (top, bottom, left, right)
*/
positionTooltip(tooltip, element, position) {
const rect = element.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
const offset = this.config.offset;
let top, left;
switch (position) {
case 'top':
top = rect.top + scrollY - tooltipRect.height - offset;
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
break;
case 'bottom':
top = rect.bottom + scrollY + offset;
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
break;
case 'left':
top = rect.top + scrollY + (rect.height / 2) - (tooltipRect.height / 2);
left = rect.left + scrollX - tooltipRect.width - offset;
break;
case 'right':
top = rect.top + scrollY + (rect.height / 2) - (tooltipRect.height / 2);
left = rect.right + scrollX + offset;
break;
default:
top = rect.top + scrollY - tooltipRect.height - offset;
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
}
// Keep tooltip within viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Horizontal adjustment
if (left < scrollX) {
left = scrollX + 8;
} else if (left + tooltipRect.width > scrollX + viewportWidth) {
left = scrollX + viewportWidth - tooltipRect.width - 8;
}
// Vertical adjustment
if (top < scrollY) {
top = scrollY + 8;
} else if (top + tooltipRect.height > scrollY + viewportHeight) {
top = scrollY + viewportHeight - tooltipRect.height - 8;
}
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
}
/**
* Hide tooltip
*
* @param {HTMLElement} element - Element (optional, hides active tooltip if not provided)
*/
hide(element = null) {
const tooltip = element ? this.tooltips.get(element) : this.activeTooltip;
if (!tooltip) {
return;
}
// Animate out
tooltip.style.opacity = '0';
// Remove after animation
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
// Remove aria-describedby
if (element) {
element.removeAttribute('aria-describedby');
}
// Clean up references
if (element) {
this.tooltips.delete(element);
}
if (this.activeTooltip === tooltip) {
this.activeTooltip = null;
}
}, this.config.animationDuration);
}
/**
* Show validation error tooltip
*
* @param {HTMLElement} element - Form element
* @param {string} message - Error message
*/
showValidationError(element, message) {
// Remove existing error tooltip
this.hideValidationError(element);
// Show error tooltip with error styling
const tooltip = document.createElement('div');
tooltip.className = 'livecomponent-tooltip livecomponent-tooltip--error';
tooltip.setAttribute('role', 'alert');
tooltip.textContent = message;
tooltip.style.cssText = `
position: absolute;
max-width: ${this.config.maxWidth}px;
padding: 0.5rem 0.75rem;
background: #dc2626;
color: white;
border-radius: 0.375rem;
font-size: 0.875rem;
line-height: 1.4;
z-index: ${this.config.zIndex};
pointer-events: none;
opacity: 0;
transition: opacity ${this.config.animationDuration}ms ease;
word-wrap: break-word;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
`;
document.body.appendChild(tooltip);
this.positionTooltip(tooltip, element, 'top');
// Animate in
requestAnimationFrame(() => {
tooltip.style.opacity = '1';
});
// Store reference
element._validationTooltip = tooltip;
// Add error class to element
element.classList.add('livecomponent-error');
}
/**
* Hide validation error tooltip
*
* @param {HTMLElement} element - Form element
*/
hideValidationError(element) {
const tooltip = element._validationTooltip;
if (!tooltip) {
return;
}
// Animate out
tooltip.style.opacity = '0';
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
delete element._validationTooltip;
element.classList.remove('livecomponent-error');
}, this.config.animationDuration);
}
/**
* Initialize all tooltips in component
*
* @param {HTMLElement} container - Component container
*/
initComponent(container) {
// Find all elements with tooltip attributes
const elements = container.querySelectorAll('[data-tooltip], [title], [aria-label]');
elements.forEach(element => {
this.init(element);
});
}
/**
* Cleanup tooltips for component
*
* @param {HTMLElement} container - Component container
*/
cleanupComponent(container) {
const elements = container.querySelectorAll('[data-tooltip], [title], [aria-label]');
elements.forEach(element => {
// Hide tooltip
this.hide(element);
// Cleanup event listeners
if (element._tooltipCleanup) {
element._tooltipCleanup();
delete element._tooltipCleanup;
}
// Restore original title
if (element.dataset.originalTitle) {
element.setAttribute('title', element.dataset.originalTitle);
delete element.dataset.originalTitle;
}
});
}
/**
* Update configuration
*
* @param {Object} newConfig - New configuration
*/
updateConfig(newConfig) {
this.config = {
...this.config,
...newConfig
};
}
}
// Create singleton instance
export const tooltipManager = new TooltipManager();

View File

@@ -20,6 +20,9 @@ import { ComponentFileUploader } from './ComponentFileUploader.js';
import { FileUploadWidget } from './FileUploadWidget.js';
import { optimisticStateManager } from './OptimisticStateManager.js';
import { accessibilityManager } from './AccessibilityManager.js';
import { ErrorBoundary } from './ErrorBoundary.js';
import { RequestDeduplicator } from './RequestDeduplicator.js';
import * as StateSerializer from './StateSerializer.js';
class LiveComponentManager {
constructor() {
@@ -42,6 +45,30 @@ class LiveComponentManager {
// DevTools Integration
this.devTools = null; // Will be set by DevTools when available
// Error Handling
this.errorBoundary = new ErrorBoundary(this);
// Request Deduplication
this.requestDeduplicator = new RequestDeduplicator();
// Shared Configuration
this.config = sharedConfig;
// Tooltip Manager
this.tooltipManager = tooltipManager;
// Action Loading Manager
this.actionLoadingManager = actionLoadingManager;
// UI Helper
this.uiHelper = new LiveComponentUIHelper(this);
// Loading State Manager
this.loadingStateManager = new LoadingStateManager(
this.actionLoadingManager,
optimisticStateManager
);
}
/**
@@ -140,6 +167,9 @@ class LiveComponentManager {
// Setup accessibility features
this.setupAccessibility(componentId, element);
// Initialize tooltips for component
this.tooltipManager.initComponent(element);
console.log(`[LiveComponent] Initialized: ${componentId}`);
}
@@ -282,9 +312,9 @@ class LiveComponentManager {
this.setupFileUploadHandlers(config.element);
}
// Update state
// Update state using StateSerializer
if (state) {
config.element.dataset.state = JSON.stringify(state);
StateSerializer.setStateOnElement(config.element, state);
}
// Restore focus after update
@@ -614,18 +644,46 @@ class LiveComponentManager {
return;
}
// Check for pending duplicate request
const pendingRequest = this.requestDeduplicator.getPendingRequest(componentId, method, params);
if (pendingRequest) {
console.log(`[LiveComponent] Deduplicating request: ${componentId}.${method}`);
return await pendingRequest;
}
// Check for cached result
const cachedResult = this.requestDeduplicator.getCachedResult(componentId, method, params);
if (cachedResult) {
console.log(`[LiveComponent] Using cached result: ${componentId}.${method}`);
return cachedResult;
}
let operationId = null;
const startTime = performance.now();
try {
// Get current state from element
const stateJson = config.element.dataset.state || '{}';
const stateWrapper = JSON.parse(stateJson);
// Show loading state (uses LoadingStateManager for configurable indicators)
const loadingConfig = this.loadingStateManager.getConfig(componentId);
this.loadingStateManager.showLoading(componentId, config.element, {
fragments,
type: loadingConfig.type,
optimistic: true // Check optimistic UI first
});
// Create request promise
const requestPromise = (async () => {
try {
// Get current state from element using StateSerializer
const stateWrapper = StateSerializer.getStateFromElement(config.element) || {
id: componentId,
component: '',
data: {},
version: 1
};
// Extract actual state data from wrapper format
// Wrapper format: {id, component, data, version}
// Server expects just the data object
const state = stateWrapper.data || stateWrapper;
const state = stateWrapper.data || {};
// Apply optimistic update for immediate UI feedback
// This updates the UI before server confirmation
@@ -741,9 +799,12 @@ class LiveComponentManager {
this.setupActionHandlers(config.element);
}
// Update component state
// Re-initialize tooltips after DOM update
this.tooltipManager.initComponent(config.element);
// Update component state using StateSerializer
if (data.state) {
config.element.dataset.state = JSON.stringify(data.state);
StateSerializer.setStateOnElement(config.element, data.state);
}
// Handle server events
@@ -753,29 +814,50 @@ class LiveComponentManager {
console.log(`[LiveComponent] Action executed: ${componentId}.${method}`, data);
// Log successful action to DevTools
const endTime = performance.now();
this.logActionToDevTools(componentId, method, params, startTime, endTime, true);
// Log successful action to DevTools
const endTime = performance.now();
this.logActionToDevTools(componentId, method, params, startTime, endTime, true);
} catch (error) {
console.error(`[LiveComponent] Action failed:`, error);
// Cache successful result
this.requestDeduplicator.cacheResult(componentId, method, params, data);
// Log failed action to DevTools
const endTime = performance.now();
this.logActionToDevTools(componentId, method, params, startTime, endTime, false, error.message);
// Hide loading state
this.loadingStateManager.hideLoading(componentId);
// Rollback optimistic update on error
if (operationId) {
const snapshot = optimisticStateManager.getSnapshot(componentId);
if (snapshot) {
config.element.dataset.state = JSON.stringify(snapshot);
optimisticStateManager.clearPendingOperations(componentId);
optimisticStateManager.clearSnapshot(componentId);
return data;
} catch (error) {
console.error(`[LiveComponent] Action failed:`, error);
// Log failed action to DevTools
const endTime = performance.now();
this.logActionToDevTools(componentId, method, params, startTime, endTime, false, error.message);
// Rollback optimistic update on error
if (operationId) {
const snapshot = optimisticStateManager.getSnapshot(componentId);
if (snapshot) {
StateSerializer.setStateOnElement(config.element, snapshot);
optimisticStateManager.clearPendingOperations(componentId);
optimisticStateManager.clearSnapshot(componentId);
}
}
}
this.handleError(componentId, error);
}
// Hide loading state on error
this.loadingStateManager.hideLoading(componentId);
// Handle error via ErrorBoundary
await this.errorBoundary.handleError(componentId, method, error, {
params,
fragments
});
throw error;
}
})();
// Register pending request for deduplication
return this.requestDeduplicator.registerPendingRequest(componentId, method, params, requestPromise);
}
/**
@@ -1154,9 +1236,9 @@ class LiveComponentManager {
this.setupFileUploadHandlers(config.element);
}
// Update component state
// Update component state using StateSerializer
if (data.state) {
config.element.dataset.state = JSON.stringify(data.state);
StateSerializer.setStateOnElement(config.element, data.state);
}
// Handle server events
@@ -1260,6 +1342,22 @@ class LiveComponentManager {
// Cleanup accessibility features
this.accessibilityManager.cleanup(componentId);
// Cleanup error handler
this.errorBoundary.clearErrorHandler(componentId);
// Cleanup request deduplication
this.requestDeduplicator.clearComponent(componentId);
// Cleanup tooltips
this.tooltipManager.cleanupComponent(config.element);
// Hide any active loading states
this.loadingStateManager.hideLoading(componentId);
this.loadingStateManager.clearConfig(componentId);
// Cleanup UI components
this.uiHelper.cleanup(componentId);
// Remove from registry
this.components.delete(componentId);
@@ -1413,9 +1511,9 @@ class LiveComponentManager {
this.setupActionHandlers(config.element);
}
// Update state
// Update state using StateSerializer
if (result.state) {
config.element.dataset.state = JSON.stringify(result.state);
StateSerializer.setStateOnElement(config.element, result.state);
}
// Dispatch events
@@ -1584,4 +1682,11 @@ export { ComponentPlayground };
export { ComponentFileUploader } from './ComponentFileUploader.js';
export { FileUploadWidget } from './FileUploadWidget.js';
export { ChunkedUploader } from './ChunkedUploader.js';
// Export UI integration modules
export { tooltipManager } from './TooltipManager.js';
export { actionLoadingManager } from './ActionLoadingManager.js';
export { LiveComponentUIHelper } from './LiveComponentUIHelper.js';
export { LoadingStateManager } from './LoadingStateManager.js';
export default LiveComponent;

View File

@@ -0,0 +1,113 @@
/**
* Route Guard
*
* Provides route-level access control and guards.
*/
import { Logger } from '../../core/logger.js';
/**
* RouteGuard - Route access control
*/
export class RouteGuard {
constructor(name, guardFn) {
this.name = name;
this.guardFn = guardFn;
}
/**
* Create a new RouteGuard
*/
static create(name, guardFn) {
return new RouteGuard(name, guardFn);
}
/**
* Execute guard
*/
async execute(to, from, context = {}) {
try {
const result = await this.guardFn(to, from, context);
return {
allowed: result !== false && result !== null,
redirect: typeof result === 'string' ? result : null,
reason: typeof result === 'object' && result.reason ? result.reason : null
};
} catch (error) {
Logger.error(`[RouteGuard] Guard "${this.name}" error:`, error);
return {
allowed: false,
redirect: null,
reason: error.message
};
}
}
}
/**
* Built-in guards
*/
export const BuiltInGuards = {
/**
* Require authentication
*/
auth: RouteGuard.create('auth', async (to, from) => {
// Check if user is authenticated
// This would need to be implemented based on your auth system
const isAuthenticated = checkAuth(); // Placeholder
if (!isAuthenticated) {
return '/login';
}
return true;
}),
/**
* Require guest (not authenticated)
*/
guest: RouteGuard.create('guest', async (to, from) => {
const isAuthenticated = checkAuth(); // Placeholder
if (isAuthenticated) {
return '/';
}
return true;
}),
/**
* Require specific role
*/
role: (requiredRole) => RouteGuard.create('role', async (to, from) => {
const userRole = getUserRole(); // Placeholder
if (userRole !== requiredRole) {
return '/unauthorized';
}
return true;
}),
/**
* Require permission
*/
permission: (requiredPermission) => RouteGuard.create('permission', async (to, from) => {
const hasPermission = checkPermission(requiredPermission); // Placeholder
if (!hasPermission) {
return '/unauthorized';
}
return true;
})
};
// Placeholder functions (would be implemented based on auth system)
function checkAuth() {
// Implementation depends on auth system
return false;
}
function getUserRole() {
// Implementation depends on auth system
return null;
}
function checkPermission(permission) {
// Implementation depends on auth system
return false;
}

View File

@@ -0,0 +1,78 @@
/**
* Route Middleware
*
* Provides route-level middleware for cross-cutting concerns.
*/
import { Logger } from '../../core/logger.js';
/**
* RouteMiddleware - Route middleware
*/
export class RouteMiddleware {
constructor(name, middlewareFn) {
this.name = name;
this.middlewareFn = middlewareFn;
}
/**
* Create a new RouteMiddleware
*/
static create(name, middlewareFn) {
return new RouteMiddleware(name, middlewareFn);
}
/**
* Execute middleware
*/
async execute(to, from, next, context = {}) {
try {
await this.middlewareFn(to, from, next, context);
} catch (error) {
Logger.error(`[RouteMiddleware] Middleware "${this.name}" error:`, error);
next(false); // Block navigation
}
}
}
/**
* Built-in middleware
*/
export const BuiltInMiddleware = {
/**
* Analytics middleware
*/
analytics: RouteMiddleware.create('analytics', async (to, from, next) => {
// Track page view
if (typeof window !== 'undefined' && window.analytics) {
window.analytics.track('page_view', {
path: to.path,
title: to.title
});
}
next();
}),
/**
* Loading middleware
*/
loading: RouteMiddleware.create('loading', async (to, from, next) => {
// Show loading indicator
document.body.classList.add('route-loading');
next();
// Hide loading indicator after navigation
setTimeout(() => {
document.body.classList.remove('route-loading');
}, 100);
}),
/**
* Scroll to top middleware
*/
scrollToTop: RouteMiddleware.create('scroll-to-top', async (to, from, next) => {
next();
window.scrollTo({ top: 0, behavior: 'smooth' });
})
};

View File

@@ -0,0 +1,378 @@
/**
* Enhanced Router
*
* Provides enhanced routing with guards, middleware, lazy loading, and analytics.
*/
import { Logger } from '../../core/logger.js';
import { RouteGuard, BuiltInGuards } from './RouteGuard.js';
import { RouteMiddleware, BuiltInMiddleware } from './RouteMiddleware.js';
/**
* Router - Enhanced routing system
*/
export class Router {
constructor(config = {}) {
this.config = {
mode: config.mode || 'history', // 'history' | 'hash'
base: config.base || '/',
enableAnalytics: config.enableAnalytics ?? true,
...config
};
this.routes = new Map();
this.guards = new Map();
this.middleware = [];
this.beforeEachHooks = [];
this.afterEachHooks = [];
this.currentRoute = null;
this.analytics = {
navigations: [],
errors: []
};
// Initialize
this.init();
}
/**
* Create a new Router instance
*/
static create(config = {}) {
return new Router(config);
}
/**
* Initialize router
*/
init() {
// Handle browser navigation
window.addEventListener('popstate', (event) => {
this.handlePopState(event);
});
Logger.info('[Router] Initialized', {
mode: this.config.mode,
base: this.config.base
});
}
/**
* Register a route
*/
route(path, config) {
const route = {
path: this.normalizePath(path),
component: config.component,
name: config.name || path,
title: config.title || null,
meta: config.meta || {},
guards: config.guards || [],
middleware: config.middleware || [],
lazy: config.lazy ?? false,
...config
};
this.routes.set(route.path, route);
Logger.debug('[Router] Route registered', route);
return this;
}
/**
* Register multiple routes
*/
routes(routesConfig) {
routesConfig.forEach(route => {
this.route(route.path, route);
});
return this;
}
/**
* Register a guard
*/
guard(name, guardFn) {
const guard = RouteGuard.create(name, guardFn);
this.guards.set(name, guard);
return this;
}
/**
* Register middleware
*/
use(middleware) {
if (typeof middleware === 'function') {
this.middleware.push(RouteMiddleware.create('anonymous', middleware));
} else if (middleware instanceof RouteMiddleware) {
this.middleware.push(middleware);
} else if (typeof middleware === 'string' && BuiltInMiddleware[middleware]) {
this.middleware.push(BuiltInMiddleware[middleware]);
}
return this;
}
/**
* Add beforeEach hook
*/
beforeEach(hook) {
this.beforeEachHooks.push(hook);
return this;
}
/**
* Add afterEach hook
*/
afterEach(hook) {
this.afterEachHooks.push(hook);
return this;
}
/**
* Navigate to a route
*/
async navigate(path, options = {}) {
const normalizedPath = this.normalizePath(path);
const route = this.routes.get(normalizedPath);
if (!route) {
Logger.warn('[Router] Route not found', normalizedPath);
return false;
}
const from = this.currentRoute;
const to = {
path: normalizedPath,
name: route.name,
title: route.title,
meta: route.meta,
component: route.component
};
// Execute beforeEach hooks
for (const hook of this.beforeEachHooks) {
const result = await hook(to, from);
if (result === false || typeof result === 'string') {
if (typeof result === 'string') {
return await this.navigate(result);
}
return false;
}
}
// Execute guards
for (const guardName of route.guards) {
const guard = this.guards.get(guardName) || BuiltInGuards[guardName];
if (!guard) {
Logger.warn('[Router] Guard not found', guardName);
continue;
}
const result = await guard.execute(to, from);
if (!result.allowed) {
if (result.redirect) {
return await this.navigate(result.redirect);
}
Logger.warn('[Router] Navigation blocked by guard', guardName);
return false;
}
}
// Execute middleware
let middlewareBlocked = false;
for (const middleware of [...this.middleware, ...route.middleware]) {
await new Promise((resolve) => {
middleware.execute(to, from, (allowed) => {
if (allowed === false) {
middlewareBlocked = true;
}
resolve();
});
});
if (middlewareBlocked) {
return false;
}
}
// Load component if lazy
if (route.lazy && typeof route.component === 'function') {
try {
route.component = await route.component();
} catch (error) {
Logger.error('[Router] Failed to load lazy component', error);
return false;
}
}
// Update current route
this.currentRoute = to;
// Update browser history
if (this.config.mode === 'history') {
history.pushState({ route: to }, route.title || '', normalizedPath);
} else {
window.location.hash = normalizedPath;
}
// Update page title
if (route.title) {
document.title = route.title;
}
// Track analytics
if (this.config.enableAnalytics) {
this.trackNavigation(to, from);
}
// Execute afterEach hooks
for (const hook of this.afterEachHooks) {
await hook(to, from);
}
// Render component
await this.renderComponent(route, options);
Logger.info('[Router] Navigated', { to: normalizedPath });
return true;
}
/**
* Render component
*/
async renderComponent(route, options = {}) {
const container = options.container || document.querySelector('main');
if (!container) {
Logger.error('[Router] Container not found');
return;
}
if (typeof route.component === 'function') {
// Component is a function (render function)
const content = await route.component(route, options);
if (typeof content === 'string') {
container.innerHTML = content;
} else if (content instanceof HTMLElement) {
container.innerHTML = '';
container.appendChild(content);
}
} else if (typeof route.component === 'string') {
// Component is a selector or HTML
const element = document.querySelector(route.component);
if (element) {
container.innerHTML = '';
container.appendChild(element.cloneNode(true));
} else {
container.innerHTML = route.component;
}
}
// Re-initialize modules in new content
this.reinitializeModules(container);
}
/**
* Re-initialize modules in container
*/
reinitializeModules(container) {
// Trigger module re-initialization
const event = new CustomEvent('router:content-updated', {
detail: { container },
bubbles: true
});
document.dispatchEvent(event);
}
/**
* Handle popstate event
*/
handlePopState(event) {
const path = this.config.mode === 'history'
? window.location.pathname
: window.location.hash.slice(1);
this.navigate(path, { updateHistory: false });
}
/**
* Normalize path
*/
normalizePath(path) {
// Remove base if present
if (path.startsWith(this.config.base)) {
path = path.slice(this.config.base.length);
}
// Ensure leading slash
if (!path.startsWith('/')) {
path = '/' + path;
}
// Remove trailing slash (except root)
if (path !== '/' && path.endsWith('/')) {
path = path.slice(0, -1);
}
return path;
}
/**
* Track navigation for analytics
*/
trackNavigation(to, from) {
const navigation = {
to: to.path,
from: from?.path || null,
timestamp: Date.now(),
duration: from ? Date.now() - (from.timestamp || Date.now()) : 0
};
this.analytics.navigations.push(navigation);
// Limit analytics size
if (this.analytics.navigations.length > 100) {
this.analytics.navigations.shift();
}
// Trigger analytics event
const event = new CustomEvent('router:navigation', {
detail: navigation,
bubbles: true
});
window.dispatchEvent(event);
}
/**
* Get current route
*/
getCurrentRoute() {
return this.currentRoute;
}
/**
* Get analytics
*/
getAnalytics() {
return {
...this.analytics,
totalNavigations: this.analytics.navigations.length,
totalErrors: this.analytics.errors.length
};
}
/**
* Destroy router
*/
destroy() {
this.routes.clear();
this.guards.clear();
this.middleware = [];
this.beforeEachHooks = [];
this.afterEachHooks = [];
this.currentRoute = null;
Logger.info('[Router] Destroyed');
}
}

View File

@@ -0,0 +1,87 @@
/**
* Router Enhancement Module
*
* Provides enhanced routing with guards, middleware, lazy loading, and analytics.
*
* Usage:
* - Add data-module="router" to enable enhanced router
* - Or import and use directly: import { Router } from './modules/router/index.js'
*
* Features:
* - Route guards (auth, permissions)
* - Route middleware
* - Lazy route loading
* - Route analytics
* - Integration with LiveComponents
*/
import { Logger } from '../../core/logger.js';
import { Router } from './Router.js';
import { RouteGuard, BuiltInGuards } from './RouteGuard.js';
import { RouteMiddleware, BuiltInMiddleware } from './RouteMiddleware.js';
const RouterModule = {
name: 'router',
router: null,
init(config = {}, state = null) {
Logger.info('[RouterModule] Module initialized');
// Create router
this.router = Router.create(config);
// Expose globally for easy access
if (typeof window !== 'undefined') {
window.Router = this.router;
}
return this;
},
/**
* Get router instance
*/
getRouter() {
return this.router || Router.create();
},
/**
* Register routes
*/
routes(routesConfig) {
const router = this.getRouter();
router.routes(routesConfig);
return this;
},
/**
* Navigate to route
*/
async navigate(path, options = {}) {
const router = this.getRouter();
return await router.navigate(path, options);
},
destroy() {
if (this.router) {
this.router.destroy();
this.router = null;
}
if (typeof window !== 'undefined' && window.Router) {
delete window.Router;
}
Logger.info('[RouterModule] Module destroyed');
}
};
// Export for direct usage
export { Router, RouteGuard, RouteMiddleware, BuiltInGuards, BuiltInMiddleware };
// Export as default for module system
export default RouterModule;
// Export init function for module system
export const init = RouterModule.init.bind(RouterModule);

View File

@@ -0,0 +1,221 @@
/**
* CSRF Manager
*
* Handles CSRF token management including automatic refresh.
*/
import { Logger } from '../../core/logger.js';
/**
* CsrfManager - CSRF token management
*/
export class CsrfManager {
constructor(config = {}) {
this.config = {
tokenName: config.tokenName || '_token',
headerName: config.headerName || 'X-CSRF-TOKEN',
refreshInterval: config.refreshInterval || 30 * 60 * 1000, // 30 minutes
autoRefresh: config.autoRefresh ?? true,
endpoint: config.endpoint || '/api/csrf-token',
...config
};
this.currentToken = null;
this.refreshTimer = null;
this.isRefreshing = false;
// Initialize
this.init();
}
/**
* Create a new CsrfManager instance
*/
static create(config = {}) {
return new CsrfManager(config);
}
/**
* Initialize CSRF manager
*/
init() {
// Get initial token from meta tag or form
this.currentToken = this.getTokenFromPage();
if (!this.currentToken) {
Logger.warn('[CsrfManager] No CSRF token found on page');
} else {
Logger.info('[CsrfManager] Initialized with token');
}
// Set up auto-refresh if enabled
if (this.config.autoRefresh) {
this.startAutoRefresh();
}
// Update all forms and meta tags
this.updateAllTokens();
}
/**
* Get token from page (meta tag or form)
*/
getTokenFromPage() {
// Try meta tag first
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
// Try form input
const formInput = document.querySelector(`input[name="${this.config.tokenName}"]`);
if (formInput) {
return formInput.value;
}
return null;
}
/**
* Get current CSRF token
*/
getToken() {
return this.currentToken;
}
/**
* Refresh CSRF token
*/
async refreshToken() {
if (this.isRefreshing) {
Logger.debug('[CsrfManager] Token refresh already in progress');
return;
}
this.isRefreshing = true;
try {
const response = await fetch(this.config.endpoint, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to refresh token: ${response.status}`);
}
const data = await response.json();
const newToken = data.token || data.csrf_token || data._token;
if (!newToken) {
throw new Error('No token in response');
}
this.currentToken = newToken;
this.updateAllTokens();
Logger.info('[CsrfManager] Token refreshed');
// Trigger event
this.triggerTokenRefreshedEvent(newToken);
} catch (error) {
Logger.error('[CsrfManager] Failed to refresh token', error);
throw error;
} finally {
this.isRefreshing = false;
}
}
/**
* Update all tokens on the page
*/
updateAllTokens() {
if (!this.currentToken) {
return;
}
// Update meta tag
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
metaTag.setAttribute('content', this.currentToken);
}
// Update all form inputs
const formInputs = document.querySelectorAll(`input[name="${this.config.tokenName}"]`);
formInputs.forEach(input => {
input.value = this.currentToken;
});
// Update LiveComponent tokens
const liveComponents = document.querySelectorAll('[data-live-component]');
liveComponents.forEach(element => {
const tokenInput = element.querySelector(`input[name="${this.config.tokenName}"]`);
if (tokenInput) {
tokenInput.value = this.currentToken;
}
});
}
/**
* Start auto-refresh timer
*/
startAutoRefresh() {
if (this.refreshTimer) {
return;
}
this.refreshTimer = setInterval(() => {
this.refreshToken().catch(error => {
Logger.error('[CsrfManager] Auto-refresh failed', error);
});
}, this.config.refreshInterval);
Logger.debug('[CsrfManager] Auto-refresh started', {
interval: this.config.refreshInterval
});
}
/**
* Stop auto-refresh timer
*/
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
}
/**
* Get token for use in fetch requests
*/
getTokenHeader() {
return {
[this.config.headerName]: this.currentToken
};
}
/**
* Trigger token refreshed event
*/
triggerTokenRefreshedEvent(token) {
const event = new CustomEvent('csrf:token-refreshed', {
detail: { token },
bubbles: true
});
window.dispatchEvent(event);
}
/**
* Destroy CSRF manager
*/
destroy() {
this.stopAutoRefresh();
this.currentToken = null;
Logger.info('[CsrfManager] Destroyed');
}
}

View File

@@ -0,0 +1,254 @@
/**
* Security Manager
*
* Provides security-related utilities including CSRF, XSS protection, and CSP helpers.
*/
import { Logger } from '../../core/logger.js';
import { CsrfManager } from './CsrfManager.js';
/**
* SecurityManager - Centralized security utilities
*/
export class SecurityManager {
constructor(config = {}) {
this.config = {
csrf: config.csrf || {},
xss: {
enabled: config.xss?.enabled ?? true,
sanitizeOnInput: config.xss?.sanitizeOnInput ?? false
},
csp: {
enabled: config.csp?.enabled ?? false,
reportOnly: config.csp?.reportOnly ?? false
},
...config
};
this.csrfManager = null;
// Initialize
this.init();
}
/**
* Create a new SecurityManager instance
*/
static create(config = {}) {
return new SecurityManager(config);
}
/**
* Initialize security manager
*/
init() {
// Initialize CSRF manager
this.csrfManager = CsrfManager.create(this.config.csrf);
// Initialize XSS protection if enabled
if (this.config.xss.enabled) {
this.initXssProtection();
}
// Initialize CSP if enabled
if (this.config.csp.enabled) {
this.initCsp();
}
Logger.info('[SecurityManager] Initialized', {
csrf: !!this.csrfManager,
xss: this.config.xss.enabled,
csp: this.config.csp.enabled
});
}
/**
* Initialize XSS protection
*/
initXssProtection() {
// Add input sanitization if enabled
if (this.config.xss.sanitizeOnInput) {
document.addEventListener('input', (event) => {
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
this.sanitizeInput(event.target);
}
}, true);
}
}
/**
* Sanitize input value
*/
sanitizeInput(element) {
const originalValue = element.value;
const sanitized = this.sanitizeHtml(originalValue);
if (sanitized !== originalValue) {
element.value = sanitized;
Logger.warn('[SecurityManager] Sanitized potentially dangerous input', {
field: element.name || element.id
});
}
}
/**
* Sanitize HTML string
*/
sanitizeHtml(html) {
if (typeof html !== 'string') {
return html;
}
// Remove script tags and event handlers
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/javascript:/gi, '')
.replace(/data:text\/html/gi, '');
}
/**
* Initialize Content Security Policy
*/
initCsp() {
// CSP is typically set server-side, but we can validate it client-side
const cspHeader = this.getCspHeader();
if (cspHeader) {
Logger.debug('[SecurityManager] CSP header found', cspHeader);
this.validateCsp(cspHeader);
} else {
Logger.warn('[SecurityManager] No CSP header found');
}
}
/**
* Get CSP header from meta tag or response headers
*/
getCspHeader() {
// Try meta tag
const metaTag = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
// Note: Response headers are not accessible from JavaScript
// This would need to be passed from server or checked server-side
return null;
}
/**
* Validate CSP header
*/
validateCsp(cspHeader) {
// Basic validation - check for common security directives
const requiredDirectives = ['default-src', 'script-src', 'style-src'];
const directives = cspHeader.split(';').map(d => d.trim().split(' ')[0]);
const missing = requiredDirectives.filter(dir =>
!directives.some(d => d.toLowerCase() === dir.toLowerCase())
);
if (missing.length > 0) {
Logger.warn('[SecurityManager] CSP missing recommended directives', missing);
}
}
/**
* Get CSRF token
*/
getCsrfToken() {
return this.csrfManager?.getToken() || null;
}
/**
* Get CSRF token header
*/
getCsrfTokenHeader() {
return this.csrfManager?.getTokenHeader() || {};
}
/**
* Refresh CSRF token
*/
async refreshCsrfToken() {
if (this.csrfManager) {
return await this.csrfManager.refreshToken();
}
}
/**
* Validate security headers
*/
validateSecurityHeaders() {
const issues = [];
// Check for HTTPS
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
issues.push('Not using HTTPS');
}
// Check for CSP
if (!this.getCspHeader()) {
issues.push('No Content Security Policy header');
}
// Check for X-Frame-Options
// Note: Headers are not accessible from JavaScript, would need server-side check
return {
valid: issues.length === 0,
issues
};
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (typeof text !== 'string') {
return text;
}
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
/**
* Validate URL to prevent XSS
*/
validateUrl(url) {
try {
const parsed = new URL(url, window.location.origin);
// Block javascript: and data: URLs
if (parsed.protocol === 'javascript:' || parsed.protocol === 'data:') {
return false;
}
return true;
} catch {
return false;
}
}
/**
* Destroy security manager
*/
destroy() {
if (this.csrfManager) {
this.csrfManager.destroy();
this.csrfManager = null;
}
Logger.info('[SecurityManager] Destroyed');
}
}

View File

@@ -0,0 +1,86 @@
/**
* Security Module
*
* Provides security-related utilities including CSRF, XSS protection, and CSP helpers.
*
* Usage:
* - Add data-module="security" to enable global security features
* - Or import and use directly: import { SecurityManager } from './modules/security/index.js'
*
* Features:
* - CSRF token management and auto-refresh
* - XSS protection helpers
* - Content Security Policy helpers
* - Security headers validation
*/
import { Logger } from '../../core/logger.js';
import { SecurityManager } from './SecurityManager.js';
import { CsrfManager } from './CsrfManager.js';
const SecurityModule = {
name: 'security',
securityManager: null,
init(config = {}, state = null) {
Logger.info('[SecurityModule] Module initialized');
// Create security manager
this.securityManager = SecurityManager.create(config);
// Expose globally for easy access
if (typeof window !== 'undefined') {
window.SecurityManager = this.securityManager;
window.CsrfManager = this.securityManager.csrfManager;
}
return this;
},
/**
* Get security manager instance
*/
getSecurityManager() {
return this.securityManager || SecurityManager.create();
},
/**
* Get CSRF token
*/
getCsrfToken() {
return this.securityManager?.getCsrfToken() || null;
},
/**
* Refresh CSRF token
*/
async refreshCsrfToken() {
if (this.securityManager) {
return await this.securityManager.refreshCsrfToken();
}
},
destroy() {
if (this.securityManager) {
this.securityManager.destroy();
this.securityManager = null;
}
if (typeof window !== 'undefined') {
delete window.SecurityManager;
delete window.CsrfManager;
}
Logger.info('[SecurityModule] Module destroyed');
}
};
// Export for direct usage
export { SecurityManager, CsrfManager };
// Export as default for module system
export default SecurityModule;
// Export init function for module system
export const init = SecurityModule.init.bind(SecurityModule);

View File

@@ -10,6 +10,9 @@ export class SPARouter {
enableTransitions: true,
transitionDuration: 100, // Beschleunigt von 300ms auf 100ms
skeletonTemplate: this.createSkeletonTemplate(),
// LiveComponent integration options
enableLiveComponentIntegration: options.enableLiveComponentIntegration ?? true,
preserveLiveComponentState: options.preserveLiveComponentState ?? false,
...options
};
@@ -17,6 +20,7 @@ export class SPARouter {
this.isLoading = false;
this.currentUrl = window.location.href;
this.abortController = null;
this.liveComponentManager = null;
// Bind event handlers to preserve context for removal
this.handleLinkClick = this.handleLinkClick.bind(this);
@@ -38,13 +42,44 @@ export class SPARouter {
return;
}
// Initialize LiveComponent integration if enabled
if (this.options.enableLiveComponentIntegration) {
this.initLiveComponentIntegration();
}
this.bindEvents();
this.setupStyles();
// Handle initial page load for history
this.updateHistoryState(window.location.href, document.title);
Logger.info('[SPARouter] Initialized');
Logger.info('[SPARouter] Initialized', {
liveComponentIntegration: this.options.enableLiveComponentIntegration
});
}
/**
* Initialize LiveComponent integration
*/
initLiveComponentIntegration() {
// Try to get LiveComponent instance
// Check if it's available globally or via module system
if (typeof window !== 'undefined' && window.LiveComponent) {
this.liveComponentManager = window.LiveComponent;
} else {
// Try to import dynamically
import('../livecomponent/index.js').then(module => {
if (module.LiveComponent) {
this.liveComponentManager = module.LiveComponent;
} else if (module.default) {
this.liveComponentManager = module.default;
}
}).catch(error => {
Logger.warn('[SPARouter] LiveComponent not available', error);
});
}
Logger.debug('[SPARouter] LiveComponent integration initialized');
}
bindEvents() {
@@ -231,6 +266,12 @@ export class SPARouter {
}
async updateContent(newContent, newTitle) {
// Save LiveComponent state before navigation if enabled
let savedComponentStates = null;
if (this.options.enableLiveComponentIntegration && this.options.preserveLiveComponentState && this.liveComponentManager) {
savedComponentStates = this.saveLiveComponentStates();
}
// Update page title
if (newTitle) {
document.title = newTitle;
@@ -247,6 +288,11 @@ export class SPARouter {
// Re-initialize modules for new content
this.reinitializeModules();
// Initialize LiveComponents in new content
if (this.options.enableLiveComponentIntegration) {
await this.initializeLiveComponents(savedComponentStates);
}
// Smooth transition in
if (this.options.enableTransitions) {
await this.transitionIn();
@@ -260,6 +306,89 @@ export class SPARouter {
// Trigger custom event
this.triggerNavigationEvent();
}
/**
* Save LiveComponent states before navigation
*/
saveLiveComponentStates() {
if (!this.liveComponentManager) {
return null;
}
const states = {};
const components = this.container.querySelectorAll('[data-live-component]');
components.forEach(element => {
const componentId = element.getAttribute('data-live-component');
if (componentId && this.liveComponentManager.components) {
const component = this.liveComponentManager.components.get(componentId);
if (component) {
// Get state from element's dataset
const stateJson = element.dataset.state;
if (stateJson) {
try {
states[componentId] = JSON.parse(stateJson);
} catch (e) {
Logger.warn(`[SPARouter] Failed to parse state for ${componentId}`, e);
}
}
}
}
});
Logger.debug('[SPARouter] Saved LiveComponent states', Object.keys(states));
return states;
}
/**
* Initialize LiveComponents in new content
*/
async initializeLiveComponents(savedStates = null) {
if (!this.liveComponentManager) {
// Try to get LiveComponentManager again
this.initLiveComponentIntegration();
// Wait a bit for async initialization
await new Promise(resolve => setTimeout(resolve, 100));
if (!this.liveComponentManager) {
Logger.warn('[SPARouter] LiveComponentManager not available, skipping initialization');
return;
}
}
// Find all LiveComponent elements in new content
const componentElements = this.container.querySelectorAll('[data-live-component]');
if (componentElements.length === 0) {
return;
}
Logger.info(`[SPARouter] Initializing ${componentElements.length} LiveComponents`);
// Initialize each component
for (const element of componentElements) {
try {
// Restore state if available
if (savedStates) {
const componentId = element.getAttribute('data-live-component');
if (componentId && savedStates[componentId]) {
const stateJson = JSON.stringify(savedStates[componentId]);
element.dataset.state = stateJson;
}
}
// Initialize component using LiveComponent instance
if (this.liveComponentManager && typeof this.liveComponentManager.init === 'function') {
this.liveComponentManager.init(element);
}
} catch (error) {
Logger.error('[SPARouter] Failed to initialize LiveComponent', error);
}
}
Logger.debug('[SPARouter] LiveComponents initialized');
}
showLoadingState() {
document.body.classList.add(this.options.loadingClass);
@@ -326,6 +455,12 @@ export class SPARouter {
moduleElements.forEach(element => {
const moduleName = element.dataset.module;
// Skip LiveComponent initialization here (handled separately)
if (moduleName === 'livecomponent') {
return;
}
Logger.info(`[SPARouter] Re-initializing module "${moduleName}" on new content`);
// Trigger module initialization (would need access to module system)

View File

@@ -0,0 +1,556 @@
/**
* State Management Module
*
* Provides centralized, reactive state management for client-side state.
* Features:
* - Reactive state store (similar to Redux/Vuex)
* - State persistence (localStorage, sessionStorage)
* - State synchronization across tabs (BroadcastChannel)
* - Integration with LiveComponents
* - Time-travel debugging support
*/
import { Logger } from '../../core/logger.js';
/**
* StateManager - Centralized state management
*/
export class StateManager {
constructor(config = {}) {
this.state = config.initialState || {};
this.subscribers = new Map(); // Map<path, Set<callback>>
this.middleware = [];
this.history = []; // For time-travel debugging
this.maxHistorySize = config.maxHistorySize || 50;
this.enableHistory = config.enableHistory ?? false;
// Persistence configuration
this.persistence = {
enabled: config.persistence?.enabled ?? false,
storage: config.persistence?.storage || 'localStorage', // 'localStorage' | 'sessionStorage'
key: config.persistence?.key || 'app-state',
paths: config.persistence?.paths || [] // Only persist these paths
};
// Cross-tab synchronization
this.sync = {
enabled: config.sync?.enabled ?? false,
channel: null
};
// Load persisted state
if (this.persistence.enabled) {
this.loadPersistedState();
}
// Initialize cross-tab sync
if (this.sync.enabled && typeof BroadcastChannel !== 'undefined') {
this.initCrossTabSync();
}
Logger.info('[StateManager] Initialized', {
persistence: this.persistence.enabled,
sync: this.sync.enabled,
history: this.enableHistory
});
}
/**
* Create a new StateManager instance
*/
static create(config = {}) {
return new StateManager(config);
}
/**
* Get current state
*/
getState() {
return this.state;
}
/**
* Get state at a specific path
*/
get(path, defaultValue = undefined) {
const keys = path.split('.');
let value = this.state;
for (const key of keys) {
if (value === null || value === undefined || typeof value !== 'object') {
return defaultValue;
}
value = value[key];
}
return value !== undefined ? value : defaultValue;
}
/**
* Set state at a specific path
*/
set(path, value) {
const keys = path.split('.');
const newState = { ...this.state };
let current = newState;
// Navigate/create nested structure
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
current[key] = {};
} else {
current[key] = { ...current[key] };
}
current = current[key];
}
// Set the value
const lastKey = keys[keys.length - 1];
const oldValue = this.get(path);
current[lastKey] = value;
// Apply middleware
const action = { type: 'SET', path, value, oldValue };
const processedAction = this.applyMiddleware(action);
if (processedAction === null) {
return; // Middleware blocked the action
}
// Update state
this.state = newState;
// Save to history
if (this.enableHistory) {
this.addToHistory(action);
}
// Persist if enabled
if (this.persistence.enabled && this.shouldPersist(path)) {
this.persistState();
}
// Sync across tabs
if (this.sync.enabled) {
this.broadcastStateChange(action);
}
// Notify subscribers
this.notifySubscribers(path, value, oldValue);
Logger.debug('[StateManager] State updated', { path, value });
}
/**
* Dispatch an action (similar to Redux)
*/
dispatch(action) {
if (typeof action === 'function') {
// Thunk support
return action(this.dispatch.bind(this), this.getState.bind(this));
}
if (!action.type) {
throw new Error('Action must have a type property');
}
// Apply middleware
const processedAction = this.applyMiddleware(action);
if (processedAction === null) {
return; // Middleware blocked the action
}
// Execute action (reducer pattern)
const newState = this.reducer(this.state, processedAction);
if (newState !== this.state) {
const oldState = this.state;
this.state = newState;
// Save to history
if (this.enableHistory) {
this.addToHistory(processedAction);
}
// Persist if enabled
if (this.persistence.enabled) {
this.persistState();
}
// Sync across tabs
if (this.sync.enabled) {
this.broadcastStateChange(processedAction);
}
// Notify all subscribers
this.notifyAllSubscribers();
}
Logger.debug('[StateManager] Action dispatched', processedAction);
}
/**
* Default reducer (can be overridden)
*/
reducer(state, action) {
// Default reducer handles SET actions
if (action.type === 'SET' && action.path) {
const keys = action.path.split('.');
const newState = { ...state };
let current = newState;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
current[key] = {};
} else {
current[key] = { ...current[key] };
}
current = current[key];
}
current[keys[keys.length - 1]] = action.value;
return newState;
}
return state;
}
/**
* Subscribe to state changes
*/
subscribe(path, callback) {
if (!this.subscribers.has(path)) {
this.subscribers.set(path, new Set());
}
this.subscribers.get(path).add(callback);
// Return unsubscribe function
return () => {
const callbacks = this.subscribers.get(path);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.subscribers.delete(path);
}
}
};
}
/**
* Subscribe to all state changes
*/
subscribeAll(callback) {
return this.subscribe('*', callback);
}
/**
* Notify subscribers of a state change
*/
notifySubscribers(path, newValue, oldValue) {
// Notify path-specific subscribers
const pathCallbacks = this.subscribers.get(path);
if (pathCallbacks) {
pathCallbacks.forEach(callback => {
try {
callback(newValue, oldValue, path);
} catch (error) {
Logger.error('[StateManager] Subscriber error', error);
}
});
}
// Notify wildcard subscribers
const wildcardCallbacks = this.subscribers.get('*');
if (wildcardCallbacks) {
wildcardCallbacks.forEach(callback => {
try {
callback(this.state, path);
} catch (error) {
Logger.error('[StateManager] Subscriber error', error);
}
});
}
// Notify parent path subscribers
const pathParts = path.split('.');
for (let i = pathParts.length - 1; i > 0; i--) {
const parentPath = pathParts.slice(0, i).join('.');
const parentCallbacks = this.subscribers.get(parentPath);
if (parentCallbacks) {
parentCallbacks.forEach(callback => {
try {
callback(this.get(parentPath), parentPath);
} catch (error) {
Logger.error('[StateManager] Subscriber error', error);
}
});
}
}
}
/**
* Notify all subscribers (for full state changes)
*/
notifyAllSubscribers() {
this.subscribers.forEach((callbacks, path) => {
callbacks.forEach(callback => {
try {
if (path === '*') {
callback(this.state);
} else {
const value = this.get(path);
callback(value, path);
}
} catch (error) {
Logger.error('[StateManager] Subscriber error', error);
}
});
});
}
/**
* Add middleware
*/
use(middleware) {
if (typeof middleware !== 'function') {
throw new Error('Middleware must be a function');
}
this.middleware.push(middleware);
}
/**
* Apply middleware chain
*/
applyMiddleware(action) {
let processedAction = action;
for (const middleware of this.middleware) {
processedAction = middleware(processedAction, this.getState.bind(this));
if (processedAction === null) {
return null; // Middleware blocked the action
}
}
return processedAction;
}
/**
* Load persisted state
*/
loadPersistedState() {
try {
const storage = this.persistence.storage === 'sessionStorage' ? sessionStorage : localStorage;
const stored = storage.getItem(this.persistence.key);
if (stored) {
const parsed = JSON.parse(stored);
this.state = { ...this.state, ...parsed };
Logger.info('[StateManager] Loaded persisted state');
}
} catch (error) {
Logger.error('[StateManager] Failed to load persisted state', error);
}
}
/**
* Persist state
*/
persistState() {
try {
const storage = this.persistence.storage === 'sessionStorage' ? sessionStorage : localStorage;
const stateToPersist = this.persistence.paths.length > 0
? this.getPersistedPaths()
: this.state;
storage.setItem(this.persistence.key, JSON.stringify(stateToPersist));
Logger.debug('[StateManager] State persisted');
} catch (error) {
Logger.error('[StateManager] Failed to persist state', error);
}
}
/**
* Get only paths that should be persisted
*/
getPersistedPaths() {
const result = {};
for (const path of this.persistence.paths) {
const value = this.get(path);
if (value !== undefined) {
const keys = path.split('.');
let current = result;
for (let i = 0; i < keys.length - 1; i++) {
if (!(keys[i] in current)) {
current[keys[i]] = {};
}
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
}
}
return result;
}
/**
* Check if path should be persisted
*/
shouldPersist(path) {
if (this.persistence.paths.length === 0) {
return true; // Persist all if no paths specified
}
return this.persistence.paths.some(p => path.startsWith(p));
}
/**
* Initialize cross-tab synchronization
*/
initCrossTabSync() {
try {
this.sync.channel = new BroadcastChannel('state-manager-sync');
this.sync.channel.addEventListener('message', (event) => {
if (event.data.type === 'STATE_CHANGE') {
this.handleRemoteStateChange(event.data.action);
}
});
Logger.info('[StateManager] Cross-tab sync initialized');
} catch (error) {
Logger.error('[StateManager] Failed to initialize cross-tab sync', error);
this.sync.enabled = false;
}
}
/**
* Broadcast state change to other tabs
*/
broadcastStateChange(action) {
if (this.sync.channel) {
try {
this.sync.channel.postMessage({
type: 'STATE_CHANGE',
action,
timestamp: Date.now()
});
} catch (error) {
Logger.error('[StateManager] Failed to broadcast state change', error);
}
}
}
/**
* Handle remote state change from another tab
*/
handleRemoteStateChange(action) {
// Apply the action without broadcasting (to avoid loops)
const wasSyncEnabled = this.sync.enabled;
this.sync.enabled = false;
if (action.type === 'SET' && action.path) {
this.set(action.path, action.value);
} else {
this.dispatch(action);
}
this.sync.enabled = wasSyncEnabled;
}
/**
* Add action to history
*/
addToHistory(action) {
this.history.push({
action,
state: JSON.parse(JSON.stringify(this.state)),
timestamp: Date.now()
});
// Limit history size
if (this.history.length > this.maxHistorySize) {
this.history.shift();
}
}
/**
* Get history
*/
getHistory() {
return [...this.history];
}
/**
* Time-travel to a specific history point
*/
timeTravel(index) {
if (index < 0 || index >= this.history.length) {
throw new Error('Invalid history index');
}
const historyPoint = this.history[index];
this.state = JSON.parse(JSON.stringify(historyPoint.state));
this.notifyAllSubscribers();
Logger.info('[StateManager] Time-traveled to history point', index);
}
/**
* Reset state to initial state
*/
reset() {
this.state = {};
this.history = [];
this.notifyAllSubscribers();
if (this.persistence.enabled) {
const storage = this.persistence.storage === 'sessionStorage' ? sessionStorage : localStorage;
storage.removeItem(this.persistence.key);
}
Logger.info('[StateManager] State reset');
}
/**
* Destroy state manager
*/
destroy() {
// Unsubscribe all
this.subscribers.clear();
// Close cross-tab sync
if (this.sync.channel) {
this.sync.channel.close();
this.sync.channel = null;
}
// Clear history
this.history = [];
Logger.info('[StateManager] Destroyed');
}
}
/**
* Create a global state manager instance
*/
let globalStateManager = null;
/**
* Get or create global state manager
*/
export function getGlobalStateManager(config = {}) {
if (!globalStateManager) {
globalStateManager = StateManager.create(config);
}
return globalStateManager;
}
/**
* Create a scoped state manager (for component-specific state)
*/
export function createScopedStateManager(config = {}) {
return StateManager.create(config);
}

View File

@@ -0,0 +1,75 @@
/**
* State Manager Module
*
* Provides centralized, reactive state management for client-side state.
*
* Usage:
* - Add data-module="state-manager" to enable global state management
* - Or import and use directly: import { StateManager } from './modules/state-manager/index.js'
*
* Features:
* - Reactive state store
* - State persistence (localStorage, sessionStorage)
* - Cross-tab synchronization
* - Integration with LiveComponents
* - Time-travel debugging
*/
import { Logger } from '../../core/logger.js';
import { StateManager, getGlobalStateManager, createScopedStateManager } from './StateManager.js';
const StateManagerModule = {
name: 'state-manager',
stateManager: null,
init(config = {}, state = null) {
Logger.info('[StateManagerModule] Module initialized');
// Create global state manager
this.stateManager = getGlobalStateManager(config);
// Expose globally for easy access
if (typeof window !== 'undefined') {
window.StateManager = this.stateManager;
}
return this;
},
/**
* Get state manager instance
*/
getStateManager() {
return this.stateManager || getGlobalStateManager();
},
/**
* Create a scoped state manager
*/
createScoped(config = {}) {
return createScopedStateManager(config);
},
destroy() {
if (this.stateManager) {
this.stateManager.destroy();
this.stateManager = null;
}
if (typeof window !== 'undefined' && window.StateManager) {
delete window.StateManager;
}
Logger.info('[StateManagerModule] Module destroyed');
}
};
// Export for direct usage
export { StateManager, getGlobalStateManager, createScopedStateManager };
// Export as default for module system
export default StateManagerModule;
// Export init function for module system
export const init = StateManagerModule.init.bind(StateManagerModule);

View File

@@ -0,0 +1,46 @@
/**
* ValidationRule - Individual validation rule
*
* Represents a single validation rule with its configuration.
*/
export class ValidationRule {
constructor(name, validator, options = {}) {
this.name = name;
this.validator = validator;
this.options = options;
}
/**
* Validate a value against this rule
*/
async validate(value) {
if (typeof this.validator === 'function') {
const result = await this.validator(value, this.options);
return result === true ? true : (result || this.options.message || 'Validation failed');
}
return true;
}
/**
* Get rule name
*/
getName() {
return this.name;
}
/**
* Get rule options
*/
getOptions() {
return { ...this.options };
}
/**
* Create a validation rule
*/
static create(name, validator, options = {}) {
return new ValidationRule(name, validator, options);
}
}

View File

@@ -0,0 +1,467 @@
/**
* Validation Module
*
* Provides standalone validation system for fields, forms, and data.
* Features:
* - Schema-based validation
* - Field-level validation
* - Async validation
* - Custom validation rules
* - Integration with form-handling
* - Integration with LiveComponents
*/
import { Logger } from '../../core/logger.js';
/**
* Built-in validation rules
*/
const builtInRules = {
required: (value, options = {}) => {
if (value === null || value === undefined || value === '') {
return options.message || 'This field is required';
}
return true;
},
email: (value, options = {}) => {
if (!value) return true; // Optional if not required
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
return options.message || 'Invalid email address';
}
return true;
},
url: (value, options = {}) => {
if (!value) return true;
try {
new URL(value);
return true;
} catch {
return options.message || 'Invalid URL';
}
},
min: (value, options = {}) => {
if (!value && value !== 0) return true;
const min = parseFloat(options.value);
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue) || numValue < min) {
return options.message || `Value must be at least ${min}`;
}
return true;
},
max: (value, options = {}) => {
if (!value && value !== 0) return true;
const max = parseFloat(options.value);
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue) || numValue > max) {
return options.message || `Value must be at most ${max}`;
}
return true;
},
minLength: (value, options = {}) => {
if (!value) return true;
const min = parseInt(options.value, 10);
if (typeof value !== 'string' || value.length < min) {
return options.message || `Must be at least ${min} characters`;
}
return true;
},
maxLength: (value, options = {}) => {
if (!value) return true;
const max = parseInt(options.value, 10);
if (typeof value !== 'string' || value.length > max) {
return options.message || `Must be at most ${max} characters`;
}
return true;
},
pattern: (value, options = {}) => {
if (!value) return true;
const regex = new RegExp(options.value);
if (!regex.test(value)) {
return options.message || 'Invalid format';
}
return true;
},
number: (value, options = {}) => {
if (!value && value !== 0) return true;
if (isNaN(parseFloat(value))) {
return options.message || 'Must be a number';
}
return true;
},
integer: (value, options = {}) => {
if (!value && value !== 0) return true;
if (!Number.isInteger(parseFloat(value))) {
return options.message || 'Must be an integer';
}
return true;
},
phone: (value, options = {}) => {
if (!value) return true;
const phoneRegex = /^[\d\s\-\+\(\)]+$/;
if (!phoneRegex.test(value)) {
return options.message || 'Invalid phone number';
}
return true;
},
postalCode: (value, options = {}) => {
if (!value) return true;
const country = options.country || 'DE';
const patterns = {
DE: /^\d{5}$/,
US: /^\d{5}(-\d{4})?$/,
UK: /^[A-Z]{1,2}\d{1,2}[A-Z]?\s?\d[A-Z]{2}$/i,
FR: /^\d{5}$/
};
const pattern = patterns[country] || patterns.DE;
if (!pattern.test(value)) {
return options.message || `Invalid postal code for ${country}`;
}
return true;
},
custom: (value, options = {}) => {
if (!options.validator || typeof options.validator !== 'function') {
return 'Custom validator function required';
}
return options.validator(value, options);
}
};
/**
* Validator - Schema-based validation
*/
export class Validator {
constructor(schema = {}) {
this.schema = schema;
this.customRules = new Map();
this.errors = {};
this.validatedFields = new Set();
}
/**
* Create a new Validator instance
*/
static create(schema = {}) {
return new Validator(schema);
}
/**
* Register a custom validation rule
*/
registerRule(name, rule) {
if (typeof rule !== 'function') {
throw new Error('Validation rule must be a function');
}
this.customRules.set(name, rule);
}
/**
* Validate a single field
*/
async validateField(fieldName, value, schema = null) {
const fieldSchema = schema || this.schema[fieldName];
if (!fieldSchema) {
return { valid: true, errors: [] };
}
const errors = [];
// Handle array of rules
const rules = Array.isArray(fieldSchema) ? fieldSchema : [fieldSchema];
for (const ruleConfig of rules) {
const result = await this.validateRule(value, ruleConfig);
if (result !== true) {
errors.push(result);
}
}
// Store errors
if (errors.length > 0) {
this.errors[fieldName] = errors;
} else {
delete this.errors[fieldName];
}
this.validatedFields.add(fieldName);
return {
valid: errors.length === 0,
errors
};
}
/**
* Validate a single rule
*/
async validateRule(value, ruleConfig) {
if (typeof ruleConfig === 'function') {
// Custom validator function
const result = await ruleConfig(value);
return result === true ? true : (result || 'Validation failed');
}
if (typeof ruleConfig === 'string') {
// Rule name only
return this.executeRule(ruleConfig, value, {});
}
if (typeof ruleConfig === 'object' && ruleConfig !== null) {
// Rule with options
const ruleName = ruleConfig.rule || ruleConfig.type || Object.keys(ruleConfig)[0];
const options = ruleConfig.options || ruleConfig[ruleName] || {};
// Check for async validation
if (ruleConfig.async && typeof ruleConfig.validator === 'function') {
const result = await ruleConfig.validator(value, options);
return result === true ? true : (result || options.message || 'Validation failed');
}
return this.executeRule(ruleName, value, options);
}
return true;
}
/**
* Execute a validation rule
*/
executeRule(ruleName, value, options) {
// Check custom rules first
if (this.customRules.has(ruleName)) {
const result = this.customRules.get(ruleName)(value, options);
return result === true ? true : (result || options.message || 'Validation failed');
}
// Check built-in rules
if (builtInRules[ruleName]) {
const result = builtInRules[ruleName](value, options);
return result === true ? true : (result || options.message || 'Validation failed');
}
Logger.warn(`[Validator] Unknown validation rule: ${ruleName}`);
return true; // Unknown rules pass by default
}
/**
* Validate entire schema
*/
async validate(data) {
this.errors = {};
this.validatedFields.clear();
const results = {};
let isValid = true;
for (const fieldName in this.schema) {
const value = data[fieldName];
const result = await this.validateField(fieldName, value);
results[fieldName] = result;
if (!result.valid) {
isValid = false;
}
}
return {
valid: isValid,
errors: this.errors,
results
};
}
/**
* Validate specific fields
*/
async validateFields(data, fieldNames) {
this.errors = {};
const results = {};
let isValid = true;
for (const fieldName of fieldNames) {
if (!(fieldName in this.schema)) {
continue;
}
const value = data[fieldName];
const result = await this.validateField(fieldName, value);
results[fieldName] = result;
if (!result.valid) {
isValid = false;
}
}
return {
valid: isValid,
errors: this.errors,
results
};
}
/**
* Get errors for a specific field
*/
getFieldErrors(fieldName) {
return this.errors[fieldName] || [];
}
/**
* Get all errors
*/
getErrors() {
return { ...this.errors };
}
/**
* Check if field is valid
*/
isFieldValid(fieldName) {
return !this.errors[fieldName] || this.errors[fieldName].length === 0;
}
/**
* Check if all fields are valid
*/
isValid() {
return Object.keys(this.errors).length === 0;
}
/**
* Clear errors
*/
clearErrors(fieldName = null) {
if (fieldName) {
delete this.errors[fieldName];
} else {
this.errors = {};
}
}
/**
* Reset validator
*/
reset() {
this.errors = {};
this.validatedFields.clear();
}
/**
* Create validator from HTML form
*/
static fromForm(form) {
const schema = {};
const fields = form.querySelectorAll('input, textarea, select');
fields.forEach(field => {
if (!field.name) return;
const rules = [];
// Required
if (field.hasAttribute('required')) {
rules.push('required');
}
// Type-based validation
if (field.type === 'email') {
rules.push('email');
} else if (field.type === 'url') {
rules.push('url');
} else if (field.type === 'number') {
rules.push('number');
}
// Min/Max length
if (field.hasAttribute('minlength')) {
rules.push({
rule: 'minLength',
options: { value: field.getAttribute('minlength') }
});
}
if (field.hasAttribute('maxlength')) {
rules.push({
rule: 'maxLength',
options: { value: field.getAttribute('maxlength') }
});
}
// Min/Max (for numbers)
if (field.hasAttribute('min')) {
rules.push({
rule: 'min',
options: { value: field.getAttribute('min') }
});
}
if (field.hasAttribute('max')) {
rules.push({
rule: 'max',
options: { value: field.getAttribute('max') }
});
}
// Pattern
if (field.hasAttribute('pattern')) {
rules.push({
rule: 'pattern',
options: {
value: field.getAttribute('pattern'),
message: field.getAttribute('data-error-pattern') || 'Invalid format'
}
});
}
// Custom validation
if (field.hasAttribute('data-validate')) {
const validateAttr = field.getAttribute('data-validate');
try {
const customRule = JSON.parse(validateAttr);
rules.push(customRule);
} catch {
// Treat as rule name
rules.push(validateAttr);
}
}
if (rules.length > 0) {
schema[field.name] = rules;
}
});
return new Validator(schema);
}
}
/**
* ValidationRule - Individual validation rule
*/
export class ValidationRule {
constructor(name, validator, options = {}) {
this.name = name;
this.validator = validator;
this.options = options;
}
async validate(value) {
if (typeof this.validator === 'function') {
const result = await this.validator(value, this.options);
return result === true ? true : (result || this.options.message || 'Validation failed');
}
return true;
}
}

View File

@@ -0,0 +1,71 @@
/**
* Validation Module
*
* Provides standalone validation system for fields, forms, and data.
*
* Usage:
* - Import and use directly: import { Validator } from './modules/validation/index.js'
* - Or use with form-handling module
* - Or use with LiveComponents
*
* Features:
* - Schema-based validation
* - Field-level validation
* - Async validation
* - Custom validation rules
* - Integration with form-handling
* - Integration with LiveComponents
*/
import { Logger } from '../../core/logger.js';
import { Validator } from './Validator.js';
import { ValidationRule } from './ValidationRule.js';
const ValidationModule = {
name: 'validation',
init(config = {}, state = null) {
Logger.info('[ValidationModule] Module initialized');
// Expose globally for easy access
if (typeof window !== 'undefined') {
window.Validator = Validator;
window.ValidationRule = ValidationRule;
}
return this;
},
/**
* Create a validator from a schema
*/
create(schema = {}) {
return Validator.create(schema);
},
/**
* Create a validator from a form element
*/
fromForm(form) {
return Validator.fromForm(form);
},
destroy() {
if (typeof window !== 'undefined') {
delete window.Validator;
delete window.ValidationRule;
}
Logger.info('[ValidationModule] Module destroyed');
}
};
// Export for direct usage
export { Validator, ValidationRule };
// Export as default for module system
export default ValidationModule;
// Export init function for module system
export const init = ValidationModule.init.bind(ValidationModule);

View File

@@ -0,0 +1,41 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LiveComponent Action Request",
"description": "Schema for validating LiveComponent action requests",
"type": "object",
"required": ["method", "params", "state", "_csrf_token"],
"properties": {
"method": {
"type": "string",
"description": "Component action method name",
"minLength": 1,
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$"
},
"params": {
"type": "object",
"description": "Action parameters (key-value pairs)",
"additionalProperties": true
},
"state": {
"type": "object",
"description": "Current component state",
"additionalProperties": true
},
"_csrf_token": {
"type": "string",
"description": "CSRF token for security validation",
"minLength": 1
},
"fragments": {
"type": "array",
"description": "Optional fragment names for partial rendering",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,105 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LiveComponent Action Response",
"description": "Schema for validating LiveComponent action responses",
"oneOf": [
{
"type": "object",
"title": "Full HTML Response",
"required": ["html", "state"],
"properties": {
"html": {
"type": "string",
"description": "Rendered component HTML"
},
"state": {
"$ref": "#/definitions/componentState"
},
"events": {
"type": "array",
"description": "Component events to dispatch",
"items": {
"$ref": "#/definitions/componentEvent"
}
}
},
"additionalProperties": false
},
{
"type": "object",
"title": "Fragment Response",
"required": ["fragments", "state"],
"properties": {
"fragments": {
"type": "object",
"description": "Fragment map for partial rendering",
"additionalProperties": {
"type": "string"
}
},
"state": {
"$ref": "#/definitions/componentState"
},
"events": {
"type": "array",
"description": "Component events to dispatch",
"items": {
"$ref": "#/definitions/componentEvent"
}
}
},
"additionalProperties": false
}
],
"definitions": {
"componentState": {
"type": "object",
"required": ["id", "component", "data", "version"],
"properties": {
"id": {
"type": "string",
"description": "Component ID (format: component-name:instance-id)",
"pattern": "^[a-z0-9_-]+:[a-z0-9_.-]+$"
},
"component": {
"type": "string",
"description": "Component name",
"minLength": 1
},
"data": {
"type": "object",
"description": "Component state data",
"additionalProperties": true
},
"version": {
"type": "integer",
"description": "State version number",
"minimum": 1
}
},
"additionalProperties": false
},
"componentEvent": {
"type": "object",
"required": ["name", "payload"],
"properties": {
"name": {
"type": "string",
"description": "Event name",
"minLength": 1
},
"payload": {
"type": "object",
"description": "Event payload data",
"additionalProperties": true
},
"target": {
"type": ["string", "null"],
"description": "Target component ID (null for broadcast)"
}
},
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,59 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LiveComponent Error Response",
"description": "Schema for standardized error responses from LiveComponent API",
"type": "object",
"required": ["success", "error"],
"properties": {
"success": {
"type": "boolean",
"const": false
},
"error": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": {
"type": "string",
"description": "Error code identifier",
"enum": [
"VALIDATION_ERROR",
"COMPONENT_NOT_FOUND",
"ACTION_NOT_FOUND",
"CSRF_TOKEN_INVALID",
"RATE_LIMIT_EXCEEDED",
"STATE_CONFLICT",
"UPLOAD_FAILED",
"PERMISSION_DENIED",
"INTERNAL_ERROR"
]
},
"message": {
"type": "string",
"description": "Human-readable error message",
"minLength": 1
},
"details": {
"type": "object",
"description": "Additional error details",
"additionalProperties": true
},
"componentId": {
"type": "string",
"description": "Component ID where error occurred"
},
"action": {
"type": "string",
"description": "Action method name that caused the error"
},
"timestamp": {
"type": "number",
"description": "Unix timestamp of error occurrence"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}

76
resources/js/types/analytics.d.ts vendored Normal file
View File

@@ -0,0 +1,76 @@
/**
* TypeScript definitions for Analytics Module
*/
export interface AnalyticsConfig {
enabled?: boolean;
providers?: Array<string | AnalyticsProviderConfig | AnalyticsProvider>;
gdprCompliant?: boolean;
requireConsent?: boolean;
consentGiven?: boolean;
anonymizeIp?: boolean;
}
export interface AnalyticsProviderConfig {
type: 'google-analytics' | 'ga' | 'custom';
enabled?: boolean;
measurementId?: string;
gaId?: string;
endpoint?: string;
[key: string]: any;
}
export interface AnalyticsEvent {
name: string;
properties: Record<string, any>;
timestamp: number;
userId?: string | null;
}
export interface PageViewData {
path: string;
title: string;
properties: Record<string, any>;
timestamp: number;
}
export declare class AnalyticsProvider {
constructor(config?: AnalyticsProviderConfig);
enabled: boolean;
track(eventName: string, properties?: Record<string, any>): Promise<void>;
pageView(path: string, properties?: Record<string, any>): Promise<void>;
identify(userId: string, traits?: Record<string, any>): Promise<void>;
}
export declare class GoogleAnalyticsProvider extends AnalyticsProvider {
constructor(config?: AnalyticsProviderConfig);
measurementId?: string;
}
export declare class CustomProvider extends AnalyticsProvider {
constructor(config?: AnalyticsProviderConfig);
endpoint: string;
}
export declare class Analytics {
constructor(config?: AnalyticsConfig);
static create(config?: AnalyticsConfig): Analytics;
track(eventName: string, properties?: Record<string, any>): Promise<void>;
trackPageView(path?: string | null, properties?: Record<string, any>): Promise<void>;
identify(userId: string, traits?: Record<string, any>): Promise<void>;
trackBehavior(action: string, target: string, properties?: Record<string, any>): Promise<void>;
giveConsent(): void;
revokeConsent(): void;
hasConsent(): boolean;
enable(): void;
disable(): void;
destroy(): void;
}

View File

@@ -0,0 +1,67 @@
/**
* TypeScript definitions for Animation System Module
*/
export interface AnimationConfig {
type: 'fade-in' | 'zoom-in' | 'parallax' | 'timeline' | 'sticky-fade' | 'sticky-steps';
offset?: number;
delay?: number;
once?: boolean;
speed?: number;
fadeStart?: number;
fadeEnd?: number;
steps?: number;
triggerPoint?: number;
loop?: boolean;
}
export interface AnimationSystemConfig {
enabled?: boolean;
useIntersectionObserver?: boolean;
throttleDelay?: number;
}
export declare class ScrollAnimation {
constructor(element: HTMLElement, config: AnimationConfig);
element: HTMLElement;
config: AnimationConfig;
triggered: boolean;
needsUpdate: boolean;
init(): void;
enter(): void;
exit(): void;
update(): void;
destroy(): void;
}
export declare class TimelineAnimation {
constructor(element: HTMLElement, config: AnimationConfig);
element: HTMLElement;
config: AnimationConfig;
currentStep: number;
triggered: boolean;
init(): void;
enter(): void;
exit(): void;
update(): void;
setStep(step: number): void;
setProgress(progress: number): void;
destroy(): void;
}
export declare class AnimationSystem {
constructor(config?: AnimationSystemConfig);
static create(config?: AnimationSystemConfig): AnimationSystem;
init(): void;
registerAnimation(element: HTMLElement, config: AnimationConfig): void;
removeAnimation(element: HTMLElement): void;
updateAnimations(): void;
destroy(): void;
}

70
resources/js/types/cache-manager.d.ts vendored Normal file
View File

@@ -0,0 +1,70 @@
/**
* TypeScript definitions for Cache Manager Module
*/
export declare const CacheStrategy: {
readonly NO_CACHE: 'no-cache';
readonly CACHE_FIRST: 'cache-first';
readonly NETWORK_FIRST: 'network-first';
readonly STALE_WHILE_REVALIDATE: 'stale-while-revalidate';
readonly NETWORK_ONLY: 'network-only';
readonly CACHE_ONLY: 'cache-only';
};
export interface CacheManagerConfig {
defaultStrategy?: keyof typeof CacheStrategy;
defaultTTL?: number;
maxMemorySize?: number;
enableIndexedDB?: boolean;
indexedDBName?: string;
indexedDBVersion?: number;
enableAnalytics?: boolean;
}
export interface CacheOptions {
strategy?: keyof typeof CacheStrategy;
ttl?: number;
}
export interface CacheEntry {
key: string;
value: any;
timestamp: number;
ttl: number;
strategy: string;
}
export interface CacheAnalytics {
hits: number;
misses: number;
sets: number;
deletes: number;
total: number;
hitRate: number;
memorySize: number;
memoryMaxSize: number;
}
export type CachePattern = string | RegExp | ((key: string) => boolean);
export type CacheComputeFn = () => Promise<any> | any;
export declare class CacheManager {
constructor(config?: CacheManagerConfig);
static create(config?: CacheManagerConfig): CacheManager;
init(): Promise<void>;
get(key: string, options?: CacheOptions): Promise<any>;
set(key: string, value: any, options?: CacheOptions): Promise<void>;
getOrSet(key: string, computeFn: CacheComputeFn, options?: CacheOptions): Promise<any>;
delete(key: string): Promise<void>;
clear(): Promise<void>;
invalidate(pattern: CachePattern): Promise<void>;
warm(keys: string[], computeFn: (key: string) => Promise<any> | any): Promise<void>;
getAnalytics(): CacheAnalytics;
resetAnalytics(): void;
destroy(): void;
}
export declare function getStrategyForUseCase(useCase: string): keyof typeof CacheStrategy;

70
resources/js/types/error-tracking.d.ts vendored Normal file
View File

@@ -0,0 +1,70 @@
/**
* TypeScript definitions for Error Tracking Module
*/
export interface ErrorTrackerConfig {
endpoint?: string;
enabled?: boolean;
sampleRate?: number; // 0.0 to 1.0
maxErrors?: number;
groupingWindow?: number; // milliseconds
includeStack?: boolean;
includeContext?: boolean;
includeUserAgent?: boolean;
includeUrl?: boolean;
filters?: Array<((error: any, context: ErrorContext) => boolean) | RegExp>;
beforeSend?: (errorData: ErrorData) => ErrorData | null | false | void;
}
export interface ErrorContext {
type?: string;
filename?: string;
lineno?: number;
colno?: number;
userAgent?: string;
url?: string;
referrer?: string;
viewport?: {
width: number;
height: number;
};
[key: string]: any;
}
export interface ErrorData {
message: string;
name: string;
stack?: string;
timestamp: number;
type: string;
context?: ErrorContext;
[key: string]: any;
}
export interface ErrorGroup {
fingerprint: string;
count: number;
firstSeen: number;
lastSeen: number;
errors: ErrorData[];
}
export declare class ErrorTracker {
constructor(config?: ErrorTrackerConfig);
static create(config?: ErrorTrackerConfig): ErrorTracker;
init(): void;
captureException(error: any, context?: ErrorContext): void;
flushReports(): Promise<void>;
report(): Promise<void>;
getErrorGroups(): ErrorGroup[];
getErrors(): ErrorData[];
clearErrors(): void;
destroy(): void;
}
export declare function getGlobalErrorTracker(config?: ErrorTrackerConfig): ErrorTracker;

51
resources/js/types/event-bus.d.ts vendored Normal file
View File

@@ -0,0 +1,51 @@
/**
* TypeScript definitions for Event Bus Module
*/
export interface EventBusConfig {
enableHistory?: boolean;
maxHistorySize?: number;
enableWildcards?: boolean;
}
export interface EventOptions {
once?: boolean;
priority?: number;
filter?: (data: any, options?: EventOptions) => boolean;
}
export interface EventHistoryItem {
eventName: string;
data: any;
options: EventOptions;
timestamp: number;
}
export type EventCallback = (data: any, eventName?: string, options?: EventOptions) => void;
export type EventMiddleware = (eventName: string, data: any, options?: EventOptions) => any | null | false | void;
export type EventFilter = (item: EventHistoryItem) => boolean;
export declare class EventBus {
constructor(config?: EventBusConfig);
static create(config?: EventBusConfig): EventBus;
on(eventName: string, callback: EventCallback, options?: EventOptions): () => void;
once(eventName: string, callback: EventCallback, options?: EventOptions): () => void;
off(eventName: string, callback: EventCallback): void;
emit(eventName: string, data?: any, options?: EventOptions): void;
use(middleware: EventMiddleware): void;
getHistory(filter?: string | EventFilter): EventHistoryItem[];
clearHistory(): void;
getEventNames(): string[];
getSubscriberCount(eventName: string): number;
removeAllListeners(eventName?: string): void;
destroy(): void;
}
export declare function getGlobalEventBus(config?: EventBusConfig): EventBus;

233
resources/js/types/livecomponent.d.ts vendored Normal file
View File

@@ -0,0 +1,233 @@
/**
* LiveComponent TypeScript Definitions
*
* Type-safe definitions for LiveComponent API communication between PHP and JavaScript
*/
/**
* Component ID format: "component-name:instance-id"
*/
export type ComponentId = string;
/**
* Component state structure
*/
export interface LiveComponentState {
id: ComponentId;
component: string;
data: Record<string, unknown>;
version: number;
}
/**
* Action parameters (key-value pairs)
*/
export type ActionParams = Record<string, unknown>;
/**
* Component event payload
*/
export interface EventPayload {
[key: string]: unknown;
}
/**
* Component event structure
*/
export interface ComponentEvent {
name: string;
payload: EventPayload;
target?: ComponentId | null;
}
/**
* Fragment map for partial rendering
*/
export interface FragmentMap {
[fragmentName: string]: string;
}
/**
* Action request payload
*/
export interface ActionRequest {
method: string;
params: ActionParams;
state: Record<string, unknown>;
_csrf_token: string;
fragments?: string[] | null;
}
/**
* Action response with full HTML
*/
export interface ActionResponseHtml {
html: string;
state: LiveComponentState;
events?: ComponentEvent[];
}
/**
* Action response with fragments (partial rendering)
*/
export interface ActionResponseFragments {
fragments: FragmentMap;
state: LiveComponentState;
events?: ComponentEvent[];
}
/**
* Union type for action responses
*/
export type ActionResponse = ActionResponseHtml | ActionResponseFragments;
/**
* Standardized error format
*/
export interface LiveComponentError {
code: string;
message: string;
details?: Record<string, unknown>;
componentId?: ComponentId;
action?: string;
timestamp?: number;
}
/**
* Error response structure
*/
export interface ErrorResponse {
success: false;
error: LiveComponentError;
}
/**
* Upload request payload
*/
export interface UploadRequest {
file: File;
state: Record<string, unknown>;
params?: ActionParams;
_csrf_token: string;
}
/**
* Upload response
*/
export interface UploadResponse {
success: true;
html?: string;
state?: LiveComponentState;
events?: ComponentEvent[];
file?: {
name: string;
size: number;
type: string;
url?: string;
};
}
/**
* Batch operation request
*/
export interface BatchOperation {
componentId: ComponentId;
method: string;
params?: ActionParams;
fragments?: string[] | null;
operationId?: string;
}
/**
* Batch request payload
*/
export interface BatchRequest {
operations: BatchOperation[];
}
/**
* Batch operation result
*/
export interface BatchOperationResult {
success: boolean;
componentId?: ComponentId;
operationId?: string;
html?: string;
fragments?: FragmentMap;
state?: LiveComponentState;
events?: ComponentEvent[];
error?: LiveComponentError;
}
/**
* Batch response
*/
export interface BatchResponse {
success_count: number;
failure_count: number;
results: BatchOperationResult[];
}
/**
* Component configuration
*/
export interface ComponentConfig {
id: ComponentId;
element: HTMLElement;
pollInterval?: number | null;
pollTimer?: number | null;
observer?: MutationObserver | null;
sseChannel?: string | null;
}
/**
* LiveComponent Manager interface
*/
export interface LiveComponentManager {
components: Map<ComponentId, ComponentConfig>;
init(element: HTMLElement): void;
executeAction(
componentId: ComponentId,
method: string,
params?: ActionParams,
fragments?: string[] | null
): Promise<ActionResponse>;
uploadFile(
componentId: ComponentId,
file: File,
params?: ActionParams
): Promise<UploadResponse>;
executeBatch(
operations: BatchOperation[],
options?: { autoApply?: boolean }
): Promise<BatchResponse>;
on(
componentId: ComponentId,
eventName: string,
callback: (payload: EventPayload) => void
): void;
broadcast(eventName: string, payload: EventPayload): void;
startPolling(componentId: ComponentId): void;
stopPolling(componentId: ComponentId): void;
destroy(componentId: ComponentId): void;
}
/**
* Global LiveComponent instance
*/
declare global {
interface Window {
LiveComponent: LiveComponentManager;
liveComponents?: LiveComponentManager; // Legacy support
}
}
export {};

110
resources/js/types/router.d.ts vendored Normal file
View File

@@ -0,0 +1,110 @@
/**
* TypeScript definitions for Router Enhancement Module
*/
export interface RouteConfig {
path: string;
component: string | Function | HTMLElement;
name?: string;
title?: string;
meta?: Record<string, any>;
guards?: string[];
middleware?: (RouteMiddleware | string)[];
lazy?: boolean;
}
export interface Route {
path: string;
component: string | Function | HTMLElement;
name: string;
title: string | null;
meta: Record<string, any>;
guards: string[];
middleware: RouteMiddleware[];
lazy: boolean;
}
export interface RouterConfig {
mode?: 'history' | 'hash';
base?: string;
enableAnalytics?: boolean;
}
export interface NavigationContext {
[key: string]: any;
}
export type GuardFunction = (to: Route, from: Route | null, context?: NavigationContext) => Promise<boolean | string | { allowed: boolean; redirect?: string; reason?: string }>;
export type MiddlewareFunction = (to: Route, from: Route | null, next: (allowed?: boolean) => void, context?: NavigationContext) => Promise<void> | void;
export type BeforeEachHook = (to: Route, from: Route | null) => Promise<boolean | string | void> | boolean | string | void;
export type AfterEachHook = (to: Route, from: Route | null) => Promise<void> | void;
export interface GuardResult {
allowed: boolean;
redirect: string | null;
reason: string | null;
}
export interface RouterAnalytics {
navigations: Array<{
to: string;
from: string | null;
timestamp: number;
duration: number;
}>;
errors: any[];
totalNavigations: number;
totalErrors: number;
}
export declare class RouteGuard {
constructor(name: string, guardFn: GuardFunction);
static create(name: string, guardFn: GuardFunction): RouteGuard;
execute(to: Route, from: Route | null, context?: NavigationContext): Promise<GuardResult>;
}
export declare class RouteMiddleware {
constructor(name: string, middlewareFn: MiddlewareFunction);
static create(name: string, middlewareFn: MiddlewareFunction): RouteMiddleware;
execute(to: Route, from: Route | null, next: (allowed?: boolean) => void, context?: NavigationContext): Promise<void>;
}
export declare class Router {
constructor(config?: RouterConfig);
static create(config?: RouterConfig): Router;
route(path: string, config: RouteConfig): Router;
routes(routesConfig: RouteConfig[]): Router;
guard(name: string, guardFn: GuardFunction): Router;
use(middleware: RouteMiddleware | MiddlewareFunction | string): Router;
beforeEach(hook: BeforeEachHook): Router;
afterEach(hook: AfterEachHook): Router;
navigate(path: string, options?: { container?: HTMLElement; updateHistory?: boolean }): Promise<boolean>;
getCurrentRoute(): Route | null;
getAnalytics(): RouterAnalytics;
destroy(): void;
}
export declare const BuiltInGuards: {
auth: RouteGuard;
guest: RouteGuard;
role: (requiredRole: string) => RouteGuard;
permission: (requiredPermission: string) => RouteGuard;
};
export declare const BuiltInMiddleware: {
analytics: RouteMiddleware;
loading: RouteMiddleware;
scrollToTop: RouteMiddleware;
};

64
resources/js/types/security.d.ts vendored Normal file
View File

@@ -0,0 +1,64 @@
/**
* TypeScript definitions for Security Module
*/
export interface CsrfManagerConfig {
tokenName?: string;
headerName?: string;
refreshInterval?: number;
autoRefresh?: boolean;
endpoint?: string;
}
export interface XssConfig {
enabled?: boolean;
sanitizeOnInput?: boolean;
}
export interface CspConfig {
enabled?: boolean;
reportOnly?: boolean;
}
export interface SecurityManagerConfig {
csrf?: CsrfManagerConfig;
xss?: XssConfig;
csp?: CspConfig;
}
export interface SecurityHeadersValidation {
valid: boolean;
issues: string[];
}
export declare class CsrfManager {
constructor(config?: CsrfManagerConfig);
static create(config?: CsrfManagerConfig): CsrfManager;
init(): void;
getToken(): string | null;
refreshToken(): Promise<void>;
updateAllTokens(): void;
startAutoRefresh(): void;
stopAutoRefresh(): void;
getTokenHeader(): Record<string, string>;
destroy(): void;
}
export declare class SecurityManager {
constructor(config?: SecurityManagerConfig);
static create(config?: SecurityManagerConfig): SecurityManager;
init(): void;
getCsrfToken(): string | null;
getCsrfTokenHeader(): Record<string, string>;
refreshCsrfToken(): Promise<void>;
validateSecurityHeaders(): SecurityHeadersValidation;
escapeHtml(text: string): string;
validateUrl(url: string): boolean;
sanitizeHtml(html: string): string;
destroy(): void;
}

64
resources/js/types/state-manager.d.ts vendored Normal file
View File

@@ -0,0 +1,64 @@
/**
* TypeScript definitions for State Manager Module
*/
export interface StateManagerConfig {
initialState?: Record<string, any>;
maxHistorySize?: number;
enableHistory?: boolean;
persistence?: {
enabled?: boolean;
storage?: 'localStorage' | 'sessionStorage';
key?: string;
paths?: string[];
};
sync?: {
enabled?: boolean;
};
}
export interface StateAction {
type: string;
path?: string;
value?: any;
oldValue?: any;
[key: string]: any;
}
export interface HistoryPoint {
action: StateAction;
state: Record<string, any>;
timestamp: number;
}
export type StateSubscriber = (newValue: any, oldValue?: any, path?: string) => void;
export type StateMiddleware = (action: StateAction, getState: () => Record<string, any>) => StateAction | null;
export type StateReducer = (state: Record<string, any>, action: StateAction) => Record<string, any>;
export type StateThunk = (dispatch: (action: StateAction | StateThunk) => void, getState: () => Record<string, any>) => any;
export declare class StateManager {
constructor(config?: StateManagerConfig);
static create(config?: StateManagerConfig): StateManager;
getState(): Record<string, any>;
get(path: string, defaultValue?: any): any;
set(path: string, value: any): void;
dispatch(action: StateAction | StateThunk): void;
reducer(state: Record<string, any>, action: StateAction): Record<string, any>;
subscribe(path: string, callback: StateSubscriber): () => void;
subscribeAll(callback: StateSubscriber): () => void;
use(middleware: StateMiddleware): void;
getHistory(): HistoryPoint[];
timeTravel(index: number): void;
reset(): void;
destroy(): void;
}
export declare function getGlobalStateManager(config?: StateManagerConfig): StateManager;
export declare function createScopedStateManager(config?: StateManagerConfig): StateManager;

76
resources/js/types/validation.d.ts vendored Normal file
View File

@@ -0,0 +1,76 @@
/**
* TypeScript definitions for Validation Module
*/
export interface ValidationSchema {
[fieldName: string]: ValidationRuleConfig | ValidationRuleConfig[];
}
export type ValidationRuleConfig =
| string
| ValidationRuleObject
| ValidationRuleFunction;
export interface ValidationRuleObject {
rule?: string;
type?: string;
options?: ValidationRuleOptions;
async?: boolean;
validator?: (value: any, options?: ValidationRuleOptions) => boolean | string | Promise<boolean | string>;
message?: string;
[key: string]: any;
}
export interface ValidationRuleOptions {
value?: any;
message?: string;
country?: string;
validator?: (value: any, options?: ValidationRuleOptions) => boolean | string | Promise<boolean | string>;
[key: string]: any;
}
export type ValidationRuleFunction = (value: any) => boolean | string | Promise<boolean | string>;
export interface ValidationResult {
valid: boolean;
errors: string[];
}
export interface ValidationResults {
valid: boolean;
errors: Record<string, string[]>;
results: Record<string, ValidationResult>;
}
export declare class Validator {
constructor(schema?: ValidationSchema);
static create(schema?: ValidationSchema): Validator;
static fromForm(form: HTMLFormElement): Validator;
registerRule(name: string, rule: ValidationRuleFunction): void;
validateField(fieldName: string, value: any, schema?: ValidationRuleConfig | ValidationRuleConfig[]): Promise<ValidationResult>;
validate(data: Record<string, any>): Promise<ValidationResults>;
validateFields(data: Record<string, any>, fieldNames: string[]): Promise<ValidationResults>;
getFieldErrors(fieldName: string): string[];
getErrors(): Record<string, string[]>;
isFieldValid(fieldName: string): boolean;
isValid(): boolean;
clearErrors(fieldName?: string): void;
reset(): void;
}
export declare class ValidationRule {
constructor(name: string, validator: ValidationRuleFunction, options?: ValidationRuleOptions);
static create(name: string, validator: ValidationRuleFunction, options?: ValidationRuleOptions): ValidationRule;
validate(value: any): Promise<boolean | string>;
getName(): string;
getOptions(): ValidationRuleOptions;
}

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Not Found</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background: #f5f5f5;
text-align: center;
}
.error-container {
background: white;
border-radius: 8px;
padding: 3rem 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #d32f2f;
font-size: 4rem;
margin: 0;
}
h2 {
color: #666;
margin-top: 0.5rem;
}
.error-message {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 1rem;
margin: 2rem 0;
}
</style>
</head>
<body>
<div class="error-container">
<h1>404</h1>
<h2>Page Not Found</h2>
<div class="error-message">
<p>The page you are looking for could not be found.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 - Internal Server Error</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background: #f5f5f5;
}
.error-container {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #d32f2f;
margin-top: 0;
}
.error-message {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 1rem;
margin: 1rem 0;
}
</style>
<style if="$isDebugMode">
.debug-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 1rem;
margin-top: 2rem;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.debug-info pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.context-item {
margin: 0.5rem 0;
}
.context-label {
font-weight: bold;
color: #666;
}
</style>
</head>
<body>
<div class="error-container">
<h1>500 - Internal Server Error</h1>
<div class="error-message">
<p>{{ $message }}</p>
</div>
<div class="debug-info" if="$isDebugMode">
<h3>Debug Information</h3>
<div class="context-item">
<span class="context-label">Exception:</span> {{ $exceptionClass }}
</div>
<div class="context-item" if="$debug">
<span class="context-label">File:</span> {{ $debug['file'] }}:{{ $debug['line'] }}
</div>
<div class="context-item" if="$context">
<span class="context-label">Operation:</span> {{ $context['operation'] }}
</div>
<div class="context-item" if="$context">
<span class="context-label">Component:</span> {{ $context['component'] }}
</div>
<div class="context-item" if="$context">
<span class="context-label">Request ID:</span> {{ $context['request_id'] }}
</div>
<div if="$debug['trace']">
<h4>Stack Trace:</h4>
<pre>{{ $debug['trace'] }}</pre>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background: #f5f5f5;
}
.error-container {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #d32f2f;
margin-top: 0;
}
.error-message {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 1rem;
margin: 1rem 0;
}
</style>
<style if="$isDebugMode">
.debug-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 1rem;
margin-top: 2rem;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.debug-info pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.context-item {
margin: 0.5rem 0;
}
.context-label {
font-weight: bold;
color: #666;
}
</style>
</head>
<body>
<div class="error-container">
<h1>{{ $title }}</h1>
<div class="error-message">
<p>{{ $message }}</p>
</div>
<div class="debug-info" if="$isDebugMode">
<h3>Debug Information</h3>
<div class="context-item">
<span class="context-label">Exception:</span> {{ $exceptionClass }}
</div>
<div class="context-item" if="$debug">
<span class="context-label">File:</span> {{ $debug['file'] }}:{{ $debug['line'] }}
</div>
<div class="context-item" if="$context">
<span class="context-label">Operation:</span> {{ $context['operation'] }}
</div>
<div class="context-item" if="$context">
<span class="context-label">Component:</span> {{ $context['component'] }}
</div>
<div class="context-item" if="$context">
<span class="context-label">Request ID:</span> {{ $context['request_id'] }}
</div>
<div class="context-item" if="$context">
<span class="context-label">Occurred At:</span> {{ $context['occurred_at'] }}
</div>
<div if="$debug['trace']">
<h4>Stack Trace:</h4>
<pre>{{ $debug['trace'] }}</pre>
</div>
</div>
</div>
</body>
</html>