import { Logger } from '../../core/logger.js'; export class SPARouter { constructor(options = {}) { this.options = { containerSelector: 'main', linkSelector: 'a[href^="/"]', // All internal links loadingClass: 'spa-loading', excludeSelector: '[data-spa="false"], [download], [target="_blank"], [href^="mailto:"], [href^="tel:"], [href^="#"]', enableTransitions: true, transitionDuration: 100, // Beschleunigt von 300ms auf 100ms skeletonTemplate: this.createSkeletonTemplate(), // LiveComponent integration options enableLiveComponentIntegration: options.enableLiveComponentIntegration ?? true, preserveLiveComponentState: options.preserveLiveComponentState ?? false, ...options }; this.container = null; 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); this.handlePopState = this.handlePopState.bind(this); this.handleFormSubmit = this.handleFormSubmit.bind(this); this.init(); } static create(options = {}) { return new SPARouter(options); } init() { this.container = document.querySelector(this.options.containerSelector); if (!this.container) { Logger.error(`[SPARouter] Container "${this.options.containerSelector}" not found`); return; } // 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', { 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() { // Intercept link clicks document.addEventListener('click', this.handleLinkClick); // Handle browser back/forward window.addEventListener('popstate', this.handlePopState); // Handle form submissions that might redirect document.addEventListener('submit', this.handleFormSubmit); } handleLinkClick(event) { const link = event.target.closest(this.options.linkSelector); if (!link) return; // Check for exclusions if (link.matches(this.options.excludeSelector)) return; // Check for modifier keys (Ctrl, Cmd, etc.) if (event.ctrlKey || event.metaKey || event.shiftKey) return; event.preventDefault(); const href = link.href; const title = link.title || link.textContent.trim(); this.navigate(href, title); } handlePopState(event) { const url = window.location.href; if (url !== this.currentUrl) { this.loadContent(url, false); // Don't update history on popstate } } handleFormSubmit(event) { const form = event.target; // Only handle forms that might redirect (non-AJAX forms) if (form.hasAttribute('data-spa') && form.getAttribute('data-spa') === 'false') return; if (form._moduleInstance) return; // Skip forms with form-handling module // For now, let forms submit normally // Could be enhanced later to handle form submissions via SPA } async navigate(url, title = '') { if (this.isLoading) { Logger.warn(`[SPARouter] Already loading, aborting previous request`); this.abortController?.abort(); } if (url === this.currentUrl) { Logger.info(`[SPARouter] Already at ${url}, skipping navigation`); return; } Logger.info(`[SPARouter] Navigating to: ${url}`); try { await this.loadContent(url, true, title); } catch (error) { if (error.name !== 'AbortError') { Logger.error('[SPARouter] Navigation failed:', error); // Fallback to normal navigation window.location.href = url; } } } async loadContent(url, updateHistory = true, title = '') { // Prevent loading the same URL that's already current if (url === this.currentUrl && !updateHistory) { return; } if (this.isLoading) { this.abortController?.abort(); } this.isLoading = true; this.abortController = new AbortController(); try { // Show loading state this.showLoadingState(); // Request only the main content const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-SPA-Request': 'true', // Signal to backend this is an SPA request 'Accept': 'application/json, text/html' }, signal: this.abortController.signal }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } // Check content type to determine how to parse response const contentType = response.headers.get('content-type'); let newContent, newTitle; if (contentType?.includes('application/json')) { // Backend sent JSON SPA response const jsonData = await response.json(); newContent = jsonData.html; newTitle = jsonData.title || title; // Update meta tags if provided if (jsonData.meta) { this.updateMetaTags(jsonData.meta); } } else { // Backend sent full HTML response - extract content const html = await response.text(); newContent = this.extractMainContent(html); newTitle = this.extractTitle(html) || title; } // Update content await this.updateContent(newContent, newTitle); // Update browser history if (updateHistory) { this.updateHistoryState(url, newTitle); } this.currentUrl = url; Logger.info(`[SPARouter] Successfully loaded: ${url}`); } catch (error) { if (error.name !== 'AbortError') { this.hideLoadingState(); throw error; } } finally { this.isLoading = false; this.abortController = null; } } extractMainContent(html) { // Create a temporary DOM to parse the response const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const mainElement = doc.querySelector('main'); if (mainElement) { return mainElement.innerHTML; } // Fallback: try to find content in common containers const fallbackSelectors = ['[role="main"]', '.main-content', '#main', '.content']; for (const selector of fallbackSelectors) { const element = doc.querySelector(selector); if (element) { Logger.warn(`[SPARouter] Using fallback selector: ${selector}`); return element.innerHTML; } } // Last resort: use the entire body Logger.warn('[SPARouter] No main element found, using entire body'); return doc.body.innerHTML; } extractTitle(html) { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const titleElement = doc.querySelector('title'); return titleElement ? titleElement.textContent.trim() : ''; } async updateContent(newContent, newTitle) { // 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; } // Smooth transition if (this.options.enableTransitions) { await this.transitionOut(); } // Update content this.container.innerHTML = newContent; // 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(); } this.hideLoadingState(); // Scroll to top window.scrollTo({ top: 0, behavior: 'smooth' }); // 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); // Show skeleton loader if (this.options.enableTransitions) { this.container.classList.add('spa-transitioning-out'); } } hideLoadingState() { document.body.classList.remove(this.options.loadingClass); } async transitionOut() { return new Promise(resolve => { // Verwende schnellere cubic-bezier für snappigere Animation this.container.style.transition = `opacity ${this.options.transitionDuration}ms cubic-bezier(0.4, 0, 1, 1)`; this.container.style.opacity = '0'; setTimeout(() => { resolve(); }, this.options.transitionDuration); }); } async transitionIn() { return new Promise(resolve => { this.container.style.opacity = '0'; // Reduziere Verzögerung von 50ms auf 10ms setTimeout(() => { // Verwende schnellere cubic-bezier für snappigere Animation this.container.style.transition = `opacity ${this.options.transitionDuration}ms cubic-bezier(0, 0, 0.2, 1)`; this.container.style.opacity = '1'; setTimeout(() => { this.container.style.transition = ''; this.container.classList.remove('spa-transitioning-out'); resolve(); }, this.options.transitionDuration); }, 10); // Von 50ms auf 10ms reduziert }); } updateHistoryState(url, title) { const state = { url, title, timestamp: Date.now() }; if (url !== window.location.href) { history.pushState(state, title, url); } else { history.replaceState(state, title, url); } } reinitializeModules() { // Re-run form auto-enhancement for new content if (window.initAutoFormHandling) { window.initAutoFormHandling(); } // Re-initialize any data-module elements in new content const moduleElements = this.container.querySelectorAll('[data-module]'); moduleElements.forEach(element => { const moduleName = element.dataset.module; // 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) const event = new CustomEvent('spa:reinit-module', { detail: { element, moduleName }, bubbles: true }); element.dispatchEvent(event); }); } createSkeletonTemplate() { return `
`; } setupStyles() { if (document.getElementById('spa-router-styles')) return; const styles = document.createElement('style'); styles.id = 'spa-router-styles'; styles.textContent = ` /* SPA Router Transitions */ .spa-loading { cursor: progress; } .spa-transitioning-out { pointer-events: none; } /* Skeleton Loading Styles */ .spa-skeleton { animation: spa-pulse 1.5s ease-in-out infinite alternate; } .spa-skeleton-header { height: 2rem; background: #e5e7eb; border-radius: 0.375rem; margin-bottom: 1rem; width: 60%; } .spa-skeleton-content { space-y: 0.75rem; } .spa-skeleton-line { height: 1rem; background: #e5e7eb; border-radius: 0.375rem; margin-bottom: 0.75rem; } .spa-skeleton-line.short { width: 75%; } @keyframes spa-pulse { 0% { opacity: 1; } 100% { opacity: 0.4; } } /* Dark mode support */ @media (prefers-color-scheme: dark) { .spa-skeleton-header, .spa-skeleton-line { background: #374151; } } `; document.head.appendChild(styles); } updateMetaTags(metaData) { // Update meta description if (metaData.description) { let metaDesc = document.querySelector('meta[name="description"]'); if (metaDesc) { metaDesc.content = metaData.description; } else { metaDesc = document.createElement('meta'); metaDesc.name = 'description'; metaDesc.content = metaData.description; document.head.appendChild(metaDesc); } } // Add other meta tag updates as needed Object.entries(metaData).forEach(([key, value]) => { if (key !== 'description' && value) { let metaTag = document.querySelector(`meta[name="${key}"]`); if (metaTag) { metaTag.content = value; } else { metaTag = document.createElement('meta'); metaTag.name = key; metaTag.content = value; document.head.appendChild(metaTag); } } }); } triggerNavigationEvent() { const event = new CustomEvent('spa:navigated', { detail: { url: this.currentUrl, container: this.container, timestamp: Date.now() }, bubbles: true }); document.dispatchEvent(event); } // Public API navigateTo(url, title) { return this.navigate(url, title); } getCurrentUrl() { return this.currentUrl; } isNavigating() { return this.isLoading; } destroy() { this.abortController?.abort(); // Remove event listeners document.removeEventListener('click', this.handleLinkClick); window.removeEventListener('popstate', this.handlePopState); document.removeEventListener('submit', this.handleFormSubmit); // Remove styles const styles = document.getElementById('spa-router-styles'); if (styles) { styles.remove(); } Logger.info('[SPARouter] Destroyed'); } }