📝 Commit Details:
This commit is contained in:
521
backend/static/js/offline-app.js
Normal file
521
backend/static/js/offline-app.js
Normal file
@ -0,0 +1,521 @@
|
||||
/**
|
||||
* MYP Platform Offline-First Application
|
||||
* Version: 2.0.0
|
||||
* Description: Offline-fähige PWA für die MYP Plattform
|
||||
*/
|
||||
|
||||
class MYPApp {
|
||||
constructor() {
|
||||
this.isOffline = !navigator.onLine;
|
||||
this.registerTime = new Date().toISOString();
|
||||
this.darkMode = document.documentElement.classList.contains('dark');
|
||||
this.setupOfflineDetection();
|
||||
this.setupServiceWorker();
|
||||
this.setupLocalStorage();
|
||||
this.setupUI();
|
||||
this.setupThemeListeners();
|
||||
|
||||
// Debug-Info für die Entwicklung
|
||||
console.log(`MYP App initialisiert um ${this.registerTime}`);
|
||||
console.log(`Initiale Netzwerkverbindung: ${navigator.onLine ? 'Online' : 'Offline'}`);
|
||||
console.log(`Aktueller Modus: ${this.darkMode ? 'Dark Mode' : 'Light Mode'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Überwacht die Netzwerkverbindung
|
||||
*/
|
||||
setupOfflineDetection() {
|
||||
window.addEventListener('online', () => {
|
||||
this.isOffline = false;
|
||||
document.body.classList.remove('offline-mode');
|
||||
console.log('Netzwerkverbindung wiederhergestellt!');
|
||||
|
||||
// Bei Wiederverbindung: Synchronisiere Daten
|
||||
this.syncOfflineData();
|
||||
|
||||
// Event an andere Komponenten senden
|
||||
window.dispatchEvent(new CustomEvent('networkStatusChange', {
|
||||
detail: { isOffline: false }
|
||||
}));
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOffline = true;
|
||||
document.body.classList.add('offline-mode');
|
||||
console.log('Netzwerkverbindung verloren!');
|
||||
|
||||
// Event an andere Komponenten senden
|
||||
window.dispatchEvent(new CustomEvent('networkStatusChange', {
|
||||
detail: { isOffline: true }
|
||||
}));
|
||||
});
|
||||
|
||||
// Klasse setzen, wenn initial offline
|
||||
if (this.isOffline) {
|
||||
document.body.classList.add('offline-mode');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert den Service Worker für Offline-Funktionalität
|
||||
*/
|
||||
setupServiceWorker() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const swPath = '/sw.js';
|
||||
|
||||
navigator.serviceWorker.register(swPath, {
|
||||
scope: '/'
|
||||
}).then(registration => {
|
||||
console.log('Service Worker erfolgreich registriert mit Scope:', registration.scope);
|
||||
|
||||
// Status des Service Workers überprüfen
|
||||
if (registration.installing) {
|
||||
console.log('Service Worker wird installiert');
|
||||
} else if (registration.waiting) {
|
||||
console.log('Service Worker wartet auf Aktivierung');
|
||||
} else if (registration.active) {
|
||||
console.log('Service Worker ist aktiv');
|
||||
}
|
||||
|
||||
// Auf Updates überwachen
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
console.log(`Service Worker Status: ${newWorker.state}`);
|
||||
|
||||
// Bei Aktivierung Cache aktualisieren
|
||||
if (newWorker.state === 'activated') {
|
||||
this.fetchAndCacheAppData();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Prüfen auf Update beim App-Start
|
||||
registration.update();
|
||||
|
||||
}).catch(error => {
|
||||
console.error('Service Worker Registrierung fehlgeschlagen:', error);
|
||||
// Fallback auf lokalen Cache wenn Service Worker fehlschlägt
|
||||
this.setupLocalCache();
|
||||
});
|
||||
|
||||
// Kontrolltransfer abfangen
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('Service Worker Controller hat gewechselt');
|
||||
});
|
||||
|
||||
} else {
|
||||
console.warn('Service Worker werden von diesem Browser nicht unterstützt');
|
||||
// Fallback auf lokalen Cache wenn Service Worker nicht unterstützt werden
|
||||
this.setupLocalCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lokaler Cache-Fallback wenn Service Worker nicht verfügbar
|
||||
*/
|
||||
setupLocalCache() {
|
||||
console.log('Verwende lokalen Cache als Fallback');
|
||||
|
||||
// Wichtige App-Daten sofort laden und cachen
|
||||
this.fetchAndCacheAppData();
|
||||
|
||||
// Timer für regelmäßige Aktualisierung des Cache
|
||||
setInterval(() => {
|
||||
if (navigator.onLine) {
|
||||
this.fetchAndCacheAppData();
|
||||
}
|
||||
}, 15 * 60 * 1000); // Alle 15 Minuten
|
||||
}
|
||||
|
||||
/**
|
||||
* Laden und Cachen wichtiger API-Endpunkte
|
||||
*/
|
||||
fetchAndCacheAppData() {
|
||||
if (!navigator.onLine) return;
|
||||
|
||||
const endpoints = [
|
||||
'/api/printers',
|
||||
'/api/jobs',
|
||||
'/api/schedule',
|
||||
'/api/status'
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
fetch(endpoint)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
localStorage.setItem(`cache_${endpoint}`, JSON.stringify({
|
||||
timestamp: new Date().getTime(),
|
||||
data: data
|
||||
}));
|
||||
console.log(`Daten für ${endpoint} gecached`);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Fehler beim Cachen von ${endpoint}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lokaler Speicher für Offline-Daten
|
||||
*/
|
||||
setupLocalStorage() {
|
||||
// Speicher für Offline-Bearbeitung einrichten
|
||||
if (!localStorage.getItem('offlineChanges')) {
|
||||
localStorage.setItem('offlineChanges', JSON.stringify([]));
|
||||
}
|
||||
|
||||
// Speicher für App-Konfiguration einrichten
|
||||
if (!localStorage.getItem('appConfig')) {
|
||||
const defaultConfig = {
|
||||
theme: 'system',
|
||||
notifications: true,
|
||||
dataSync: true,
|
||||
lastSync: null
|
||||
};
|
||||
localStorage.setItem('appConfig', JSON.stringify(defaultConfig));
|
||||
}
|
||||
|
||||
// Aufräumen alter Cache-Einträge (älter als 7 Tage)
|
||||
this.cleanupOldCache(7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt alte Cache-Einträge
|
||||
* @param {number} daysOld - Alter in Tagen
|
||||
*/
|
||||
cleanupOldCache(daysOld) {
|
||||
const now = new Date().getTime();
|
||||
const maxAge = daysOld * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
|
||||
// Nur Cache-Einträge prüfen
|
||||
if (key.startsWith('cache_')) {
|
||||
try {
|
||||
const item = JSON.parse(localStorage.getItem(key));
|
||||
if (item && item.timestamp && (now - item.timestamp > maxAge)) {
|
||||
localStorage.removeItem(key);
|
||||
console.log(`Alter Cache-Eintrag entfernt: ${key}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Fehler beim Verarbeiten von Cache-Eintrag ${key}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronisiert offline getätigte Änderungen
|
||||
*/
|
||||
syncOfflineData() {
|
||||
if (!navigator.onLine) return;
|
||||
|
||||
const offlineChanges = JSON.parse(localStorage.getItem('offlineChanges') || '[]');
|
||||
|
||||
if (offlineChanges.length === 0) {
|
||||
console.log('Keine Offline-Änderungen zu synchronisieren');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${offlineChanges.length} Offline-Änderungen werden synchronisiert...`);
|
||||
|
||||
// Status-Indikator für Synchronisierung anzeigen
|
||||
document.body.classList.add('syncing');
|
||||
|
||||
const syncPromises = offlineChanges.map(change => {
|
||||
return fetch(change.url, {
|
||||
method: change.method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Offline-Change': 'true'
|
||||
},
|
||||
body: JSON.stringify(change.data)
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fehler ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log(`Änderung erfolgreich synchronisiert: ${change.url}`);
|
||||
return { success: true, change };
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Synchronisierung fehlgeschlagen für ${change.url}:`, error);
|
||||
return { success: false, change, error };
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(syncPromises)
|
||||
.then(results => {
|
||||
// Fehlgeschlagene Änderungen behalten
|
||||
const failedChanges = results
|
||||
.filter(result => !result.success)
|
||||
.map(result => result.change);
|
||||
|
||||
localStorage.setItem('offlineChanges', JSON.stringify(failedChanges));
|
||||
|
||||
// App-Konfiguration aktualisieren
|
||||
const appConfig = JSON.parse(localStorage.getItem('appConfig') || '{}');
|
||||
appConfig.lastSync = new Date().toISOString();
|
||||
localStorage.setItem('appConfig', JSON.stringify(appConfig));
|
||||
|
||||
// Status-Indikator entfernen
|
||||
document.body.classList.remove('syncing');
|
||||
|
||||
// Event-Benachrichtigung
|
||||
const syncEvent = new CustomEvent('offlineDataSynced', {
|
||||
detail: {
|
||||
total: offlineChanges.length,
|
||||
succeeded: offlineChanges.length - failedChanges.length,
|
||||
failed: failedChanges.length
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(syncEvent);
|
||||
|
||||
console.log(`Synchronisierung abgeschlossen: ${offlineChanges.length - failedChanges.length} erfolgreich, ${failedChanges.length} fehlgeschlagen`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt Offline-Änderung zum Synchronisierungsstapel hinzu
|
||||
* @param {string} url - API-Endpunkt
|
||||
* @param {string} method - HTTP-Methode (POST, PUT, DELETE)
|
||||
* @param {object} data - Zu sendende Daten
|
||||
*/
|
||||
addOfflineChange(url, method, data) {
|
||||
const offlineChanges = JSON.parse(localStorage.getItem('offlineChanges') || '[]');
|
||||
|
||||
offlineChanges.push({
|
||||
id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5),
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
localStorage.setItem('offlineChanges', JSON.stringify(offlineChanges));
|
||||
console.log(`Offline-Änderung gespeichert: ${method} ${url}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-Wechsel überwachen und reagieren
|
||||
*/
|
||||
setupThemeListeners() {
|
||||
// Auf Dark Mode Änderungen reagieren
|
||||
window.addEventListener('darkModeChanged', (e) => {
|
||||
this.darkMode = e.detail.isDark;
|
||||
this.updateAppTheme();
|
||||
|
||||
// App-Konfiguration aktualisieren
|
||||
const appConfig = JSON.parse(localStorage.getItem('appConfig') || '{}');
|
||||
appConfig.theme = this.darkMode ? 'dark' : 'light';
|
||||
localStorage.setItem('appConfig', JSON.stringify(appConfig));
|
||||
|
||||
console.log(`Theme geändert: ${this.darkMode ? 'Dark Mode' : 'Light Mode'}`);
|
||||
});
|
||||
|
||||
// Bei Systemthemen-Änderung prüfen, ob wir automatisch wechseln sollen
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
const appConfig = JSON.parse(localStorage.getItem('appConfig') || '{}');
|
||||
|
||||
// Nur reagieren, wenn der Nutzer "system" als Theme gewählt hat
|
||||
if (appConfig.theme === 'system') {
|
||||
this.darkMode = e.matches;
|
||||
this.updateAppTheme();
|
||||
console.log(`Systemthema geändert: ${this.darkMode ? 'Dark Mode' : 'Light Mode'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme im UI aktualisieren
|
||||
*/
|
||||
updateAppTheme() {
|
||||
// Theme-color Meta-Tag aktualisieren für Mobile Browser
|
||||
const metaThemeColor = document.getElementById('metaThemeColor') ||
|
||||
document.querySelector('meta[name="theme-color"]');
|
||||
if (metaThemeColor) {
|
||||
metaThemeColor.setAttribute('content', this.darkMode ? '#0f172a' : '#ffffff');
|
||||
}
|
||||
|
||||
// Dynamische UI-Elemente anpassen
|
||||
this.updateUIForTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-Elemente für das aktuelle Theme anpassen
|
||||
*/
|
||||
updateUIForTheme() {
|
||||
// Offline-Banner und Sync-Indikator anpassen
|
||||
const offlineBanner = document.getElementById('offline-banner');
|
||||
const syncIndicator = document.getElementById('sync-indicator');
|
||||
|
||||
if (offlineBanner) {
|
||||
if (this.darkMode) {
|
||||
offlineBanner.classList.add('dark-theme');
|
||||
offlineBanner.classList.remove('light-theme');
|
||||
} else {
|
||||
offlineBanner.classList.add('light-theme');
|
||||
offlineBanner.classList.remove('dark-theme');
|
||||
}
|
||||
}
|
||||
|
||||
if (syncIndicator) {
|
||||
if (this.darkMode) {
|
||||
syncIndicator.classList.add('dark-theme');
|
||||
syncIndicator.classList.remove('light-theme');
|
||||
} else {
|
||||
syncIndicator.classList.add('light-theme');
|
||||
syncIndicator.classList.remove('dark-theme');
|
||||
}
|
||||
}
|
||||
|
||||
// Andere dynamische UI-Elemente hier anpassen
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-Komponenten initialisieren
|
||||
*/
|
||||
setupUI() {
|
||||
// Offline-Modus Banner einfügen wenn nicht vorhanden
|
||||
if (!document.getElementById('offline-banner')) {
|
||||
const banner = document.createElement('div');
|
||||
banner.id = 'offline-banner';
|
||||
banner.className = `hidden fixed top-0 left-0 right-0 bg-amber-500 dark:bg-amber-600 text-white text-center py-2 px-4 z-50 ${this.darkMode ? 'dark-theme' : 'light-theme'}`;
|
||||
banner.textContent = 'Sie sind offline. Einige Funktionen sind eingeschränkt.';
|
||||
document.body.prepend(banner);
|
||||
|
||||
// Anzeigen wenn offline
|
||||
if (this.isOffline) {
|
||||
banner.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Event-Listener für Online/Offline-Status
|
||||
window.addEventListener('online', () => banner.classList.add('hidden'));
|
||||
window.addEventListener('offline', () => banner.classList.remove('hidden'));
|
||||
}
|
||||
|
||||
// Synchronisierungs-Indikator einfügen wenn nicht vorhanden
|
||||
if (!document.getElementById('sync-indicator')) {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.id = 'sync-indicator';
|
||||
indicator.className = `hidden fixed bottom-4 right-4 bg-indigo-600 dark:bg-indigo-700 text-white text-sm rounded-full py-1 px-3 z-50 flex items-center ${this.darkMode ? 'dark-theme' : 'light-theme'}`;
|
||||
|
||||
const spinnerSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
spinnerSvg.setAttribute('class', 'animate-spin -ml-1 mr-2 h-4 w-4 text-white');
|
||||
spinnerSvg.setAttribute('fill', 'none');
|
||||
spinnerSvg.setAttribute('viewBox', '0 0 24 24');
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('class', 'opacity-25');
|
||||
circle.setAttribute('cx', '12');
|
||||
circle.setAttribute('cy', '12');
|
||||
circle.setAttribute('r', '10');
|
||||
circle.setAttribute('stroke', 'currentColor');
|
||||
circle.setAttribute('stroke-width', '4');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('class', 'opacity-75');
|
||||
path.setAttribute('fill', 'currentColor');
|
||||
path.setAttribute('d', 'M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z');
|
||||
|
||||
spinnerSvg.appendChild(circle);
|
||||
spinnerSvg.appendChild(path);
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = 'Synchronisiere...';
|
||||
|
||||
indicator.appendChild(spinnerSvg);
|
||||
indicator.appendChild(text);
|
||||
document.body.appendChild(indicator);
|
||||
|
||||
// Anzeigen wenn synchronisiert wird
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
if (document.body.classList.contains('syncing')) {
|
||||
indicator.classList.remove('hidden');
|
||||
} else {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, { attributes: true });
|
||||
}
|
||||
|
||||
// Mobile Optimierungen
|
||||
this.setupMobileOptimizations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile Optimierungen
|
||||
*/
|
||||
setupMobileOptimizations() {
|
||||
// Service Worker-Status auf mobilen Geräten überwachen und UI entsprechend anpassen
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
// Verbessert Touch-Handhabung auf Mobilgeräten
|
||||
this.setupTouchFeedback();
|
||||
|
||||
// Viewport-Höhe für mobile Browser anpassen (Adressleisten-Problem)
|
||||
this.fixMobileViewportHeight();
|
||||
|
||||
// Scroll-Wiederherstellung deaktivieren für eine bessere UX auf Mobilgeräten
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = 'manual';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch-Feedback für mobile Geräte
|
||||
*/
|
||||
setupTouchFeedback() {
|
||||
// Aktive Klasse für Touch-Feedback hinzufügen
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' ||
|
||||
e.target.closest('button') || e.target.closest('a')) {
|
||||
const element = e.target.tagName === 'BUTTON' || e.target.tagName === 'A' ?
|
||||
e.target : (e.target.closest('button') || e.target.closest('a'));
|
||||
element.classList.add('touch-active');
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', function() {
|
||||
const activeElements = document.querySelectorAll('.touch-active');
|
||||
activeElements.forEach(el => el.classList.remove('touch-active'));
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport-Höhe für mobile Browser fixieren
|
||||
*/
|
||||
fixMobileViewportHeight() {
|
||||
// Mobile Viewport-Höhe berechnen und als CSS-Variable setzen
|
||||
const setViewportHeight = () => {
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
};
|
||||
|
||||
// Initial setzen und bei Größenänderung aktualisieren
|
||||
setViewportHeight();
|
||||
window.addEventListener('resize', setViewportHeight);
|
||||
|
||||
// Auch bei Orientierungsänderung aktualisieren
|
||||
window.addEventListener('orientationchange', () => {
|
||||
setTimeout(setViewportHeight, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// App initialisieren wenn DOM geladen ist
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.myp = new MYPApp();
|
||||
});
|
Reference in New Issue
Block a user