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