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
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:
19
resources/css/admin/01-settings/_generated-tokens.css
Normal file
19
resources/css/admin/01-settings/_generated-tokens.css
Normal 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 */
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
406
resources/js/modules/analytics/Analytics.js
Normal file
406
resources/js/modules/analytics/Analytics.js
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
8
resources/js/modules/analytics/AnalyticsProvider.js
Normal file
8
resources/js/modules/analytics/AnalyticsProvider.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Analytics Provider Base Classes
|
||||
*
|
||||
* Base classes and implementations for analytics providers.
|
||||
*/
|
||||
|
||||
export { AnalyticsProvider, GoogleAnalyticsProvider, CustomProvider } from './Analytics.js';
|
||||
|
||||
85
resources/js/modules/analytics/index.js
Normal file
85
resources/js/modules/analytics/index.js
Normal 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);
|
||||
|
||||
302
resources/js/modules/animation-system/AnimationSystem.js
Normal file
302
resources/js/modules/animation-system/AnimationSystem.js
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
211
resources/js/modules/animation-system/ScrollAnimation.js
Normal file
211
resources/js/modules/animation-system/ScrollAnimation.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
157
resources/js/modules/animation-system/TimelineAnimation.js
Normal file
157
resources/js/modules/animation-system/TimelineAnimation.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
92
resources/js/modules/animation-system/index.js
Normal file
92
resources/js/modules/animation-system/index.js
Normal 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';
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
399
resources/js/modules/cache-manager/CacheManager.js
Normal file
399
resources/js/modules/cache-manager/CacheManager.js
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
53
resources/js/modules/cache-manager/CacheStrategy.js
Normal file
53
resources/js/modules/cache-manager/CacheStrategy.js
Normal 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;
|
||||
}
|
||||
|
||||
96
resources/js/modules/cache-manager/index.js
Normal file
96
resources/js/modules/cache-manager/index.js
Normal 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);
|
||||
|
||||
@@ -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;
|
||||
392
resources/js/modules/error-tracking/ErrorTracker.js
Normal file
392
resources/js/modules/error-tracking/ErrorTracker.js
Normal 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;
|
||||
}
|
||||
|
||||
76
resources/js/modules/error-tracking/index.js
Normal file
76
resources/js/modules/error-tracking/index.js
Normal 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);
|
||||
|
||||
322
resources/js/modules/event-bus/EventBus.js
Normal file
322
resources/js/modules/event-bus/EventBus.js
Normal 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;
|
||||
}
|
||||
|
||||
93
resources/js/modules/event-bus/index.js
Normal file
93
resources/js/modules/event-bus/index.js
Normal 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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
256
resources/js/modules/livecomponent/ActionLoadingManager.js
Normal file
256
resources/js/modules/livecomponent/ActionLoadingManager.js
Normal 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();
|
||||
|
||||
347
resources/js/modules/livecomponent/ErrorBoundary.js
Normal file
347
resources/js/modules/livecomponent/ErrorBoundary.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
347
resources/js/modules/livecomponent/LiveComponentUIHelper.js
Normal file
347
resources/js/modules/livecomponent/LiveComponentUIHelper.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
307
resources/js/modules/livecomponent/LoadingStateManager.js
Normal file
307
resources/js/modules/livecomponent/LoadingStateManager.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
180
resources/js/modules/livecomponent/RequestDeduplicator.js
Normal file
180
resources/js/modules/livecomponent/RequestDeduplicator.js
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
124
resources/js/modules/livecomponent/SharedConfig.js
Normal file
124
resources/js/modules/livecomponent/SharedConfig.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
245
resources/js/modules/livecomponent/StateSerializer.js
Normal file
245
resources/js/modules/livecomponent/StateSerializer.js
Normal 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;
|
||||
}
|
||||
|
||||
388
resources/js/modules/livecomponent/TooltipManager.js
Normal file
388
resources/js/modules/livecomponent/TooltipManager.js
Normal 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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
113
resources/js/modules/router/RouteGuard.js
Normal file
113
resources/js/modules/router/RouteGuard.js
Normal 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;
|
||||
}
|
||||
|
||||
78
resources/js/modules/router/RouteMiddleware.js
Normal file
78
resources/js/modules/router/RouteMiddleware.js
Normal 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' });
|
||||
})
|
||||
};
|
||||
|
||||
378
resources/js/modules/router/Router.js
Normal file
378
resources/js/modules/router/Router.js
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
87
resources/js/modules/router/index.js
Normal file
87
resources/js/modules/router/index.js
Normal 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);
|
||||
|
||||
221
resources/js/modules/security/CsrfManager.js
Normal file
221
resources/js/modules/security/CsrfManager.js
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
254
resources/js/modules/security/SecurityManager.js
Normal file
254
resources/js/modules/security/SecurityManager.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
86
resources/js/modules/security/index.js
Normal file
86
resources/js/modules/security/index.js
Normal 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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
556
resources/js/modules/state-manager/StateManager.js
Normal file
556
resources/js/modules/state-manager/StateManager.js
Normal 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);
|
||||
}
|
||||
|
||||
75
resources/js/modules/state-manager/index.js
Normal file
75
resources/js/modules/state-manager/index.js
Normal 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);
|
||||
|
||||
46
resources/js/modules/validation/ValidationRule.js
Normal file
46
resources/js/modules/validation/ValidationRule.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
467
resources/js/modules/validation/Validator.js
Normal file
467
resources/js/modules/validation/Validator.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
71
resources/js/modules/validation/index.js
Normal file
71
resources/js/modules/validation/index.js
Normal 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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
105
resources/js/schemas/livecomponent-action-response.schema.json
Normal file
105
resources/js/schemas/livecomponent-action-response.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59
resources/js/schemas/livecomponent-error.schema.json
Normal file
59
resources/js/schemas/livecomponent-error.schema.json
Normal 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
76
resources/js/types/analytics.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
||||
67
resources/js/types/animation-system.d.ts
vendored
Normal file
67
resources/js/types/animation-system.d.ts
vendored
Normal 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
70
resources/js/types/cache-manager.d.ts
vendored
Normal 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
70
resources/js/types/error-tracking.d.ts
vendored
Normal 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
51
resources/js/types/event-bus.d.ts
vendored
Normal 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
233
resources/js/types/livecomponent.d.ts
vendored
Normal 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
110
resources/js/types/router.d.ts
vendored
Normal 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
64
resources/js/types/security.d.ts
vendored
Normal 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
64
resources/js/types/state-manager.d.ts
vendored
Normal 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
76
resources/js/types/validation.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
||||
51
resources/views/errors/404.view.php
Normal file
51
resources/views/errors/404.view.php
Normal 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>
|
||||
|
||||
89
resources/views/errors/500.view.php
Normal file
89
resources/views/errors/500.view.php
Normal 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>
|
||||
|
||||
92
resources/views/errors/error.view.php
Normal file
92
resources/views/errors/error.view.php
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user