feat: Add comprehensive PWA support with offline functionality
- Add manifest.json with full PWA configuration - Support for installation on all platforms (iOS, Android, Desktop) - Custom app icons (72x72 to 512x512) with maskable support - App shortcuts for quick actions (Create/Join Channel) - Protocol handlers for web+securebit:// links - Share target integration - Implement enhanced Service Worker (v4.0) - Smart caching strategies (cache-first, network-first, stale-while-revalidate) - Security-aware caching (excludes sensitive endpoints) - Background sync for failed requests - Offline fallbacks with custom error handling - Response cloning fixes and CORS handling - Add PWA Install Prompt Manager - Cross-platform install detection and prompts - iOS Safari specific installation guide - Smart dismissal logic with retry mechanisms - Install success notifications and user guidance - Persistent install preferences with localStorage - Implement comprehensive Offline Manager - IndexedDB for offline data persistence - Automatic message queuing and sync when online - Session state recovery after connection loss - WebRTC reconnection handling - Real-time connection status indicators - Offline guidance and help system - Add offline-first features - Message queue with priority and retry logic - Session data preservation during disconnection - Application state recovery - Background sync registration - Periodic cleanup of old offline data - Enhanced user experience - Connection status notifications - Offline mode guidance and help - Automatic sync notifications - Reconnection progress indicators - Platform-specific installation instructions This implementation ensures SecureBit.chat works seamlessly offline while maintaining security and providing a native app-like experience across all platforms.
This commit is contained in:
@@ -289,7 +289,7 @@ const EnhancedMinimalHeader = ({
|
||||
React.createElement('p', {
|
||||
key: 'subtitle',
|
||||
className: 'text-xs sm:text-sm text-muted hidden sm:block'
|
||||
}, 'End-to-end freedom. v4.1.1')
|
||||
}, 'End-to-end freedom. v4.01.212')
|
||||
])
|
||||
]),
|
||||
|
||||
@@ -414,4 +414,4 @@ const EnhancedMinimalHeader = ({
|
||||
|
||||
window.EnhancedMinimalHeader = EnhancedMinimalHeader;
|
||||
|
||||
console.log('✅ EnhancedMinimalHeader v4.1.1 loaded with real security status integration');
|
||||
console.log('✅ EnhancedMinimalHeader v4.01.212 loaded with real security status integration');
|
||||
488
src/pwa/install-prompt.js
Normal file
488
src/pwa/install-prompt.js
Normal file
@@ -0,0 +1,488 @@
|
||||
// PWA Install Prompt Manager for SecureBit.chat
|
||||
// Enhanced Security Edition v4.01.212
|
||||
|
||||
class PWAInstallPrompt {
|
||||
constructor() {
|
||||
this.deferredPrompt = null;
|
||||
this.isInstalled = false;
|
||||
this.installButton = null;
|
||||
this.installBanner = null;
|
||||
this.dismissedCount = 0;
|
||||
this.maxDismissals = 3;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
console.log('💿 PWA Install Prompt initializing...');
|
||||
|
||||
this.checkInstallationStatus();
|
||||
this.setupEventListeners();
|
||||
this.createInstallButton();
|
||||
this.loadInstallPreferences();
|
||||
|
||||
console.log('✅ PWA Install Prompt initialized');
|
||||
}
|
||||
|
||||
checkInstallationStatus() {
|
||||
// Check if app is already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone === true) {
|
||||
this.isInstalled = true;
|
||||
console.log('📱 App is already installed as PWA');
|
||||
document.body.classList.add('pwa-installed');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for iOS Safari specific installation
|
||||
if (this.isIOSSafari()) {
|
||||
this.isInstalled = window.navigator.standalone === true;
|
||||
}
|
||||
|
||||
document.body.classList.add(this.isInstalled ? 'pwa-installed' : 'pwa-browser');
|
||||
return this.isInstalled;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Capture the install prompt event
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
console.log('💿 Install prompt event captured');
|
||||
event.preventDefault();
|
||||
this.deferredPrompt = event;
|
||||
|
||||
if (!this.isInstalled && this.shouldShowPrompt()) {
|
||||
this.showInstallOptions();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle successful installation
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('✅ PWA installed successfully');
|
||||
this.isInstalled = true;
|
||||
this.hideInstallPrompts();
|
||||
this.showInstallSuccess();
|
||||
this.saveInstallPreference('installed', true);
|
||||
|
||||
// Update UI for installed state
|
||||
document.body.classList.remove('pwa-browser');
|
||||
document.body.classList.add('pwa-installed');
|
||||
});
|
||||
|
||||
// Handle iOS installation detection
|
||||
if (this.isIOSSafari()) {
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) return;
|
||||
|
||||
setTimeout(() => {
|
||||
if (window.navigator.standalone && !this.isInstalled) {
|
||||
this.isInstalled = true;
|
||||
this.hideInstallPrompts();
|
||||
this.showInstallSuccess();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createInstallButton() {
|
||||
// Create floating install button
|
||||
this.installButton = document.createElement('button');
|
||||
this.installButton.id = 'pwa-install-button';
|
||||
this.installButton.className = 'hidden fixed bottom-6 right-6 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white px-6 py-3 rounded-full shadow-lg transition-all duration-300 z-50 flex items-center space-x-3 group';
|
||||
this.installButton.innerHTML = `
|
||||
<i class="fas fa-download transition-transform group-hover:scale-110"></i>
|
||||
<span class="font-medium">Install App</span>
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
|
||||
`;
|
||||
|
||||
this.installButton.addEventListener('click', () => {
|
||||
this.handleInstallClick();
|
||||
});
|
||||
|
||||
document.body.appendChild(this.installButton);
|
||||
}
|
||||
|
||||
createInstallBanner() {
|
||||
if (this.installBanner) return;
|
||||
|
||||
this.installBanner = document.createElement('div');
|
||||
this.installBanner.id = 'pwa-install-banner';
|
||||
this.installBanner.className = 'pwa-install-banner fixed bottom-0 left-0 right-0 transform translate-y-full transition-transform duration-300 z-40';
|
||||
this.installBanner.innerHTML = `
|
||||
<div class="content">
|
||||
<div class="icon">
|
||||
<i class="fas fa-shield-halved text-2xl"></i>
|
||||
</div>
|
||||
<div class="text">
|
||||
<div class="title">Install SecureBit.chat</div>
|
||||
<div class="subtitle">Get the native app experience with enhanced security</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="install-btn" data-action="install">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Install
|
||||
</button>
|
||||
<button class="dismiss-btn" data-action="dismiss">
|
||||
Later
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Handle banner actions
|
||||
this.installBanner.addEventListener('click', (event) => {
|
||||
const action = event.target.closest('[data-action]')?.dataset.action;
|
||||
|
||||
if (action === 'install') {
|
||||
this.handleInstallClick();
|
||||
} else if (action === 'dismiss') {
|
||||
this.dismissInstallPrompt();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(this.installBanner);
|
||||
}
|
||||
|
||||
showInstallOptions() {
|
||||
if (this.isInstalled) return;
|
||||
|
||||
// For mobile devices, show banner
|
||||
if (this.isMobileDevice()) {
|
||||
this.showInstallBanner();
|
||||
} else {
|
||||
// For desktop, show floating button
|
||||
this.showInstallButton();
|
||||
}
|
||||
}
|
||||
|
||||
showInstallButton() {
|
||||
if (this.installButton && !this.isInstalled) {
|
||||
this.installButton.classList.remove('hidden');
|
||||
|
||||
// Add entrance animation
|
||||
setTimeout(() => {
|
||||
this.installButton.style.transform = 'scale(1.1)';
|
||||
setTimeout(() => {
|
||||
this.installButton.style.transform = 'scale(1)';
|
||||
}, 200);
|
||||
}, 100);
|
||||
|
||||
console.log('💿 Install button shown');
|
||||
}
|
||||
}
|
||||
|
||||
showInstallBanner() {
|
||||
if (!this.installBanner) {
|
||||
this.createInstallBanner();
|
||||
}
|
||||
|
||||
if (this.installBanner && !this.isInstalled) {
|
||||
setTimeout(() => {
|
||||
this.installBanner.classList.add('show');
|
||||
}, 1000);
|
||||
|
||||
console.log('💿 Install banner shown');
|
||||
}
|
||||
}
|
||||
|
||||
hideInstallPrompts() {
|
||||
if (this.installButton) {
|
||||
this.installButton.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (this.installBanner) {
|
||||
this.installBanner.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstallClick() {
|
||||
if (this.isIOSSafari()) {
|
||||
this.showIOSInstructions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.deferredPrompt) {
|
||||
console.warn('⚠️ Install prompt not available');
|
||||
this.showFallbackInstructions();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('💿 Showing install prompt...');
|
||||
|
||||
// Show the install prompt
|
||||
const result = await this.deferredPrompt.prompt();
|
||||
console.log('💿 Install prompt result:', result.outcome);
|
||||
|
||||
if (result.outcome === 'accepted') {
|
||||
console.log('✅ User accepted install prompt');
|
||||
this.hideInstallPrompts();
|
||||
this.saveInstallPreference('accepted', true);
|
||||
} else {
|
||||
console.log('❌ User dismissed install prompt');
|
||||
this.handleInstallDismissal();
|
||||
}
|
||||
|
||||
// Clear the deferred prompt
|
||||
this.deferredPrompt = null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Install prompt failed:', error);
|
||||
this.showFallbackInstructions();
|
||||
}
|
||||
}
|
||||
|
||||
showIOSInstructions() {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-gray-800 rounded-xl p-6 max-w-sm w-full text-center">
|
||||
<div class="w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fab fa-apple text-blue-400 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Install on iOS</h3>
|
||||
<div class="space-y-3 text-left text-sm text-gray-300">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-6 h-6 bg-blue-500 rounded text-white flex items-center justify-center text-xs font-bold">1</div>
|
||||
<span>Tap the Share button <i class="fas fa-share text-blue-400"></i></span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-6 h-6 bg-blue-500 rounded text-white flex items-center justify-center text-xs font-bold">2</div>
|
||||
<span>Select "Add to Home Screen"</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-6 h-6 bg-blue-500 rounded text-white flex items-center justify-center text-xs font-bold">3</div>
|
||||
<span>Tap "Add" to install</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
class="w-full bg-blue-500 hover:bg-blue-600 text-white py-3 px-4 rounded-lg font-medium transition-colors mt-6">
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
showFallbackInstructions() {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full text-center">
|
||||
<div class="w-16 h-16 bg-orange-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-download text-orange-400 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Install SecureBit.chat</h3>
|
||||
<p class="text-gray-300 text-sm mb-6 leading-relaxed">
|
||||
To install this app, look for the install option in your browser menu or address bar.
|
||||
Different browsers have different install methods.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3 text-left text-sm">
|
||||
<div class="bg-gray-700/50 rounded-lg p-3">
|
||||
<div class="font-medium text-white mb-1">Chrome/Edge</div>
|
||||
<div class="text-gray-400">Look for install icon in address bar</div>
|
||||
</div>
|
||||
<div class="bg-gray-700/50 rounded-lg p-3">
|
||||
<div class="font-medium text-white mb-1">Firefox</div>
|
||||
<div class="text-gray-400">Add bookmark to home screen</div>
|
||||
</div>
|
||||
<div class="bg-gray-700/50 rounded-lg p-3">
|
||||
<div class="font-medium text-white mb-1">Safari</div>
|
||||
<div class="text-gray-400">Share → Add to Home Screen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
class="w-full bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-lg font-medium transition-colors mt-6">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
showInstallSuccess() {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'fixed top-4 right-4 bg-green-500 text-white p-4 rounded-lg shadow-lg z-50 max-w-sm transform translate-x-full transition-transform duration-300';
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-check text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">App Installed!</div>
|
||||
<div class="text-sm opacity-90">SecureBit.chat is now on your device</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('translate-x-full');
|
||||
}, 100);
|
||||
|
||||
// Auto-remove after 4 seconds
|
||||
setTimeout(() => {
|
||||
notification.classList.add('translate-x-full');
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
dismissInstallPrompt() {
|
||||
this.dismissedCount++;
|
||||
this.hideInstallPrompts();
|
||||
this.saveInstallPreference('dismissed', this.dismissedCount);
|
||||
|
||||
console.log(`💿 Install prompt dismissed (${this.dismissedCount}/${this.maxDismissals})`);
|
||||
|
||||
// Show encouraging message on final dismissal
|
||||
if (this.dismissedCount >= this.maxDismissals) {
|
||||
this.showFinalDismissalMessage();
|
||||
}
|
||||
}
|
||||
|
||||
handleInstallDismissal() {
|
||||
this.dismissedCount++;
|
||||
this.saveInstallPreference('dismissed', this.dismissedCount);
|
||||
|
||||
if (this.dismissedCount < this.maxDismissals) {
|
||||
// Show reminder after some time
|
||||
setTimeout(() => {
|
||||
if (!this.isInstalled && this.shouldShowPrompt()) {
|
||||
this.showInstallButton();
|
||||
}
|
||||
}, 300000); // 5 minutes
|
||||
}
|
||||
}
|
||||
|
||||
showFinalDismissalMessage() {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'fixed bottom-4 left-4 right-4 bg-blue-500/90 text-white p-4 rounded-lg shadow-lg z-50 backdrop-blur-sm';
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-info text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium mb-1">Install Anytime</div>
|
||||
<div class="text-sm opacity-90 mb-3">
|
||||
You can still install SecureBit.chat from your browser's menu for the best experience.
|
||||
</div>
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
class="text-sm bg-white/20 hover:bg-white/30 px-3 py-1 rounded transition-colors">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
if (notification.parentElement) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
shouldShowPrompt() {
|
||||
const preferences = this.loadInstallPreferences();
|
||||
|
||||
// Don't show if already installed
|
||||
if (this.isInstalled) return false;
|
||||
|
||||
// Don't show if dismissed too many times
|
||||
if (preferences.dismissed >= this.maxDismissals) return false;
|
||||
|
||||
// Don't show if recently dismissed (less than 24 hours)
|
||||
const lastDismissed = preferences.lastDismissed;
|
||||
if (lastDismissed && Date.now() - lastDismissed < 24 * 60 * 60 * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
saveInstallPreference(action, value) {
|
||||
const preferences = this.loadInstallPreferences();
|
||||
preferences[action] = value;
|
||||
|
||||
if (action === 'dismissed') {
|
||||
preferences.lastDismissed = Date.now();
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem('pwa_install_prefs', JSON.stringify(preferences));
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Could not save install preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
loadInstallPreferences() {
|
||||
try {
|
||||
const saved = localStorage.getItem('pwa_install_prefs');
|
||||
return saved ? JSON.parse(saved) : { dismissed: 0, installed: false };
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Could not load install preferences:', error);
|
||||
return { dismissed: 0, installed: false };
|
||||
}
|
||||
}
|
||||
|
||||
isMobileDevice() {
|
||||
return /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
isIOSSafari() {
|
||||
const userAgent = navigator.userAgent;
|
||||
return /iPad|iPhone|iPod/.test(userAgent) && /Safari/.test(userAgent) && !/CriOS|FxiOS/.test(userAgent);
|
||||
}
|
||||
|
||||
// Public API methods
|
||||
showInstallPrompt() {
|
||||
if (this.deferredPrompt && !this.isInstalled) {
|
||||
this.handleInstallClick();
|
||||
} else {
|
||||
this.showFallbackInstructions();
|
||||
}
|
||||
}
|
||||
|
||||
hideInstallPrompt() {
|
||||
this.hideInstallPrompts();
|
||||
}
|
||||
|
||||
getInstallStatus() {
|
||||
return {
|
||||
isInstalled: this.isInstalled,
|
||||
canPrompt: !!this.deferredPrompt,
|
||||
dismissedCount: this.dismissedCount,
|
||||
shouldShowPrompt: this.shouldShowPrompt()
|
||||
};
|
||||
}
|
||||
|
||||
resetDismissals() {
|
||||
this.dismissedCount = 0;
|
||||
this.saveInstallPreference('dismissed', 0);
|
||||
console.log('💿 Install dismissals reset');
|
||||
}
|
||||
}
|
||||
|
||||
// Export for module use
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = PWAInstallPrompt;
|
||||
} else {
|
||||
window.PWAInstallPrompt = PWAInstallPrompt;
|
||||
}
|
||||
|
||||
// Auto-initialize
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (!window.pwaInstallPrompt) {
|
||||
window.pwaInstallPrompt = new PWAInstallPrompt();
|
||||
}
|
||||
});
|
||||
}
|
||||
552
src/pwa/offline-manager.js
Normal file
552
src/pwa/offline-manager.js
Normal file
@@ -0,0 +1,552 @@
|
||||
// PWA Offline Component for SecureBit.chat
|
||||
// Handles offline functionality and user experience
|
||||
|
||||
window.PWAOfflineManager = (() => {
|
||||
'use strict';
|
||||
|
||||
class PWAOfflineManager {
|
||||
constructor() {
|
||||
this.isOnline = navigator.onLine;
|
||||
this.offlineQueue = [];
|
||||
this.syncInProgress = false;
|
||||
this.offlineIndicator = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
console.log('📴 PWA Offline Manager initializing...');
|
||||
|
||||
this.setupEventListeners();
|
||||
this.createOfflineIndicator();
|
||||
this.setupOfflineStorage();
|
||||
this.registerBackgroundSync();
|
||||
|
||||
// Show initial status
|
||||
this.updateConnectionStatus(this.isOnline);
|
||||
|
||||
console.log('✅ PWA Offline Manager initialized');
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
window.addEventListener('online', () => {
|
||||
console.log('🌐 Connection restored');
|
||||
this.isOnline = true;
|
||||
this.updateConnectionStatus(true);
|
||||
this.processOfflineQueue();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
console.log('📴 Connection lost');
|
||||
this.isOnline = false;
|
||||
this.updateConnectionStatus(false);
|
||||
this.showOfflineGuidance();
|
||||
});
|
||||
|
||||
// Monitor WebRTC connection status
|
||||
document.addEventListener('peer-disconnect', () => {
|
||||
if (!this.isOnline) {
|
||||
this.handleOfflineDisconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// Monitor failed network requests
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (this.isNetworkError(event.reason)) {
|
||||
this.handleNetworkFailure(event.reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createOfflineIndicator() {
|
||||
this.offlineIndicator = document.createElement('div');
|
||||
this.offlineIndicator.id = 'pwa-connection-status';
|
||||
this.offlineIndicator.className = 'hidden fixed top-4 left-1/2 transform -translate-x-1/2 z-50 transition-all duration-300';
|
||||
document.body.appendChild(this.offlineIndicator);
|
||||
}
|
||||
|
||||
updateConnectionStatus(isOnline) {
|
||||
if (!this.offlineIndicator) return;
|
||||
|
||||
if (isOnline) {
|
||||
this.offlineIndicator.innerHTML = `
|
||||
<div class="pwa-online-indicator flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span>🌐 Back online</span>
|
||||
</div>
|
||||
`;
|
||||
this.offlineIndicator.classList.remove('hidden');
|
||||
|
||||
// Hide after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.offlineIndicator.classList.add('hidden');
|
||||
}, 3000);
|
||||
} else {
|
||||
this.offlineIndicator.innerHTML = `
|
||||
<div class="pwa-offline-indicator flex items-center space-x-2">
|
||||
<div class="w-2 h-2 bg-red-400 rounded-full"></div>
|
||||
<span>📴 Offline mode</span>
|
||||
</div>
|
||||
`;
|
||||
this.offlineIndicator.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
showOfflineGuidance() {
|
||||
const guidance = document.createElement('div');
|
||||
guidance.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
guidance.innerHTML = `
|
||||
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full text-center">
|
||||
<div class="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-wifi-slash text-red-400 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-3">Connection Lost</h3>
|
||||
<p class="text-gray-300 mb-4 text-sm leading-relaxed">
|
||||
Your internet connection was lost. SecureBit.chat requires an active connection for secure P2P communication.
|
||||
</p>
|
||||
<div class="space-y-3 text-left mb-6">
|
||||
<div class="flex items-center text-sm text-gray-400">
|
||||
<i class="fas fa-info-circle mr-2 text-blue-400"></i>
|
||||
<span>Your session and keys are preserved</span>
|
||||
</div>
|
||||
<div class="flex items-center text-sm text-gray-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-green-400"></i>
|
||||
<span>No data is stored on servers</span>
|
||||
</div>
|
||||
<div class="flex items-center text-sm text-gray-400">
|
||||
<i class="fas fa-sync-alt mr-2 text-yellow-400"></i>
|
||||
<span>Connection will resume automatically</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
class="w-full bg-orange-500 hover:bg-orange-600 text-white py-3 px-4 rounded-lg font-medium transition-colors">
|
||||
Continue in Offline Mode
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(guidance);
|
||||
|
||||
// Auto-remove after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (guidance.parentElement) {
|
||||
guidance.remove();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
setupOfflineStorage() {
|
||||
// Initialize IndexedDB for offline data storage
|
||||
this.initOfflineDB().catch(error => {
|
||||
console.warn('⚠️ Offline storage not available:', error);
|
||||
});
|
||||
}
|
||||
|
||||
async initOfflineDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('SecureBitOffline', 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.offlineDB = request.result;
|
||||
resolve(this.offlineDB);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
|
||||
// Store for offline queue
|
||||
if (!db.objectStoreNames.contains('offlineQueue')) {
|
||||
const queueStore = db.createObjectStore('offlineQueue', {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true
|
||||
});
|
||||
queueStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
queueStore.createIndex('type', 'type', { unique: false });
|
||||
}
|
||||
|
||||
// Store for session recovery
|
||||
if (!db.objectStoreNames.contains('sessionData')) {
|
||||
const sessionStore = db.createObjectStore('sessionData', {
|
||||
keyPath: 'key'
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
registerBackgroundSync() {
|
||||
if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
console.log('📡 Background sync registered');
|
||||
this.swRegistration = registration;
|
||||
});
|
||||
} else {
|
||||
console.warn('⚠️ Background sync not supported');
|
||||
}
|
||||
}
|
||||
|
||||
async queueOfflineAction(action) {
|
||||
if (!this.offlineDB) {
|
||||
console.warn('⚠️ Offline storage not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const queueItem = {
|
||||
...action,
|
||||
timestamp: Date.now(),
|
||||
id: Date.now() + Math.random()
|
||||
};
|
||||
|
||||
try {
|
||||
const transaction = this.offlineDB.transaction(['offlineQueue'], 'readwrite');
|
||||
const store = transaction.objectStore('offlineQueue');
|
||||
await store.add(queueItem);
|
||||
|
||||
console.log('📤 Action queued for when online:', action.type);
|
||||
this.offlineQueue.push(queueItem);
|
||||
|
||||
// Try to sync in background
|
||||
if (this.swRegistration) {
|
||||
await this.swRegistration.sync.register('retry-offline-actions');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to queue offline action:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async processOfflineQueue() {
|
||||
if (this.syncInProgress || !this.isOnline) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncInProgress = true;
|
||||
console.log('🔄 Processing offline queue...');
|
||||
|
||||
try {
|
||||
if (this.offlineDB) {
|
||||
const transaction = this.offlineDB.transaction(['offlineQueue'], 'readwrite');
|
||||
const store = transaction.objectStore('offlineQueue');
|
||||
const allItems = await this.getAllFromStore(store);
|
||||
|
||||
for (const item of allItems) {
|
||||
try {
|
||||
await this.processQueueItem(item);
|
||||
await store.delete(item.id);
|
||||
console.log('✅ Processed offline action:', item.type);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to process offline action:', error);
|
||||
// Keep item in queue for retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process in-memory queue as fallback
|
||||
const memoryQueue = [...this.offlineQueue];
|
||||
this.offlineQueue = [];
|
||||
|
||||
for (const item of memoryQueue) {
|
||||
try {
|
||||
await this.processQueueItem(item);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to process memory queue item:', error);
|
||||
this.offlineQueue.push(item); // Re-queue on failure
|
||||
}
|
||||
}
|
||||
|
||||
if (memoryQueue.length > 0) {
|
||||
this.showSyncNotification(memoryQueue.length);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error processing offline queue:', error);
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllFromStore(store) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async processQueueItem(item) {
|
||||
switch (item.type) {
|
||||
case 'message':
|
||||
return this.retryMessage(item.data);
|
||||
case 'connection':
|
||||
return this.retryConnection(item.data);
|
||||
case 'payment_check':
|
||||
return this.retryPaymentCheck(item.data);
|
||||
default:
|
||||
console.warn('Unknown queue item type:', item.type);
|
||||
}
|
||||
}
|
||||
|
||||
async retryMessage(messageData) {
|
||||
// Retry sending message when back online
|
||||
if (window.webrtcManager && window.webrtcManager.isConnected()) {
|
||||
return window.webrtcManager.sendMessage(messageData.content);
|
||||
}
|
||||
throw new Error('WebRTC not connected');
|
||||
}
|
||||
|
||||
async retryConnection(connectionData) {
|
||||
// Retry connection establishment
|
||||
if (window.webrtcManager) {
|
||||
return window.webrtcManager.retryConnection();
|
||||
}
|
||||
throw new Error('WebRTC manager not available');
|
||||
}
|
||||
|
||||
async retryPaymentCheck(paymentData) {
|
||||
// Retry payment verification
|
||||
if (window.sessionManager) {
|
||||
return window.sessionManager.checkPaymentStatus(paymentData.checkingId);
|
||||
}
|
||||
throw new Error('Session manager not available');
|
||||
}
|
||||
|
||||
showSyncNotification(count) {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'fixed bottom-4 right-4 bg-green-500 text-white p-4 rounded-lg shadow-lg z-50 max-w-sm';
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-sync-alt text-lg"></i>
|
||||
<div>
|
||||
<div class="font-medium">Sync Complete</div>
|
||||
<div class="text-sm opacity-90">${count} offline action(s) processed</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove after 4 seconds
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
handleOfflineDisconnect() {
|
||||
// Handle WebRTC disconnection while offline
|
||||
console.log('🔌 WebRTC disconnected while offline');
|
||||
|
||||
const reconnectBanner = document.createElement('div');
|
||||
reconnectBanner.className = 'fixed top-0 left-0 right-0 bg-yellow-500 text-black p-3 z-50 text-center';
|
||||
reconnectBanner.innerHTML = `
|
||||
<div class="flex items-center justify-center space-x-2">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span>Connection lost. Will attempt to reconnect when online.</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(reconnectBanner);
|
||||
|
||||
setTimeout(() => {
|
||||
if (reconnectBanner.parentElement) {
|
||||
reconnectBanner.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
handleNetworkFailure(error) {
|
||||
console.log('🌐 Network failure detected:', error?.message);
|
||||
|
||||
// Queue the failed action for retry
|
||||
if (this.shouldQueueAction(error)) {
|
||||
this.queueOfflineAction({
|
||||
type: 'network_retry',
|
||||
data: { error: error?.message },
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isNetworkError(error) {
|
||||
if (!error) return false;
|
||||
|
||||
const networkErrorMessages = [
|
||||
'fetch',
|
||||
'network',
|
||||
'connection',
|
||||
'timeout',
|
||||
'offline',
|
||||
'ERR_NETWORK',
|
||||
'ERR_INTERNET_DISCONNECTED'
|
||||
];
|
||||
|
||||
const errorString = error.toString().toLowerCase();
|
||||
return networkErrorMessages.some(msg => errorString.includes(msg));
|
||||
}
|
||||
|
||||
shouldQueueAction(error) {
|
||||
// Determine if the action should be queued for retry
|
||||
return this.isNetworkError(error) && !this.isOnline;
|
||||
}
|
||||
|
||||
async saveSessionForRecovery(sessionData) {
|
||||
if (!this.offlineDB) return;
|
||||
|
||||
try {
|
||||
const transaction = this.offlineDB.transaction(['sessionData'], 'readwrite');
|
||||
const store = transaction.objectStore('sessionData');
|
||||
|
||||
await store.put({
|
||||
key: 'current_session',
|
||||
data: sessionData,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
console.log('💾 Session data saved for offline recovery');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to save session data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async recoverSession() {
|
||||
if (!this.offlineDB) return null;
|
||||
|
||||
try {
|
||||
const transaction = this.offlineDB.transaction(['sessionData'], 'readonly');
|
||||
const store = transaction.objectStore('sessionData');
|
||||
const result = await this.getFromStore(store, 'current_session');
|
||||
|
||||
if (result && Date.now() - result.timestamp < 24 * 60 * 60 * 1000) { // 24 hours
|
||||
console.log('🔄 Session data recovered from offline storage');
|
||||
return result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to recover session data:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async getFromStore(store, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(key);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
clearOfflineData() {
|
||||
if (!this.offlineDB) return;
|
||||
|
||||
try {
|
||||
const transaction = this.offlineDB.transaction(['offlineQueue', 'sessionData'], 'readwrite');
|
||||
transaction.objectStore('offlineQueue').clear();
|
||||
transaction.objectStore('sessionData').clear();
|
||||
|
||||
this.offlineQueue = [];
|
||||
console.log('🗑️ Offline data cleared');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to clear offline data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
getOfflineStatus() {
|
||||
return {
|
||||
isOnline: this.isOnline,
|
||||
queueLength: this.offlineQueue.length,
|
||||
syncInProgress: this.syncInProgress,
|
||||
hasOfflineDB: !!this.offlineDB,
|
||||
lastSync: this.lastSyncTime || null
|
||||
};
|
||||
}
|
||||
|
||||
// Public API methods
|
||||
async addToOfflineQueue(type, data) {
|
||||
return this.queueOfflineAction({ type, data });
|
||||
}
|
||||
|
||||
forceSync() {
|
||||
if (this.isOnline) {
|
||||
return this.processOfflineQueue();
|
||||
} else {
|
||||
console.warn('⚠️ Cannot sync while offline');
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
showOfflineHelp() {
|
||||
const helpModal = document.createElement('div');
|
||||
helpModal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
helpModal.innerHTML = `
|
||||
<div class="bg-gray-800 rounded-xl p-6 max-w-lg w-full">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-blue-500/10 rounded-full flex items-center justify-center mr-4">
|
||||
<i class="fas fa-question-circle text-blue-400 text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white">Offline Mode Help</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 text-gray-300 text-sm">
|
||||
<div>
|
||||
<h4 class="font-medium text-white mb-2">What works offline:</h4>
|
||||
<ul class="space-y-1 ml-4">
|
||||
<li>• App interface and navigation</li>
|
||||
<li>• Previously cached resources</li>
|
||||
<li>• Session data recovery</li>
|
||||
<li>• Offline message queuing</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-white mb-2">What needs internet:</h4>
|
||||
<ul class="space-y-1 ml-4">
|
||||
<li>• P2P connections (WebRTC)</li>
|
||||
<li>• Lightning payments</li>
|
||||
<li>• Real-time messaging</li>
|
||||
<li>• Session verification</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3">
|
||||
<p class="text-blue-300 text-xs">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Your messages and actions will be automatically synced when you're back online.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
class="w-full bg-blue-500 hover:bg-blue-600 text-white py-3 px-4 rounded-lg font-medium transition-colors mt-6">
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(helpModal);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize and return singleton
|
||||
let instance = null;
|
||||
|
||||
return {
|
||||
getInstance() {
|
||||
if (!instance) {
|
||||
instance = new PWAOfflineManager();
|
||||
}
|
||||
return instance;
|
||||
},
|
||||
|
||||
init() {
|
||||
return this.getInstance();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.pwaOfflineManager = window.PWAOfflineManager.init();
|
||||
});
|
||||
} else {
|
||||
window.pwaOfflineManager = window.PWAOfflineManager.init();
|
||||
}
|
||||
1099
src/pwa/pwa-manager.js
Normal file
1099
src/pwa/pwa-manager.js
Normal file
File diff suppressed because it is too large
Load Diff
367
src/styles/pwa.css
Normal file
367
src/styles/pwa.css
Normal file
@@ -0,0 +1,367 @@
|
||||
/* PWA Specific Styles for SecureBit.chat */
|
||||
|
||||
/* PWA Install Button */
|
||||
#pwa-install-button {
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 8px 32px rgba(255, 107, 53, 0.3);
|
||||
border: 1px solid rgba(255, 107, 53, 0.2);
|
||||
animation: pulse-install 2s infinite;
|
||||
}
|
||||
|
||||
#pwa-install-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 40px rgba(255, 107, 53, 0.4);
|
||||
}
|
||||
|
||||
@keyframes pulse-install {
|
||||
0%, 100% {
|
||||
box-shadow: 0 8px 32px rgba(255, 107, 53, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 8px 32px rgba(255, 107, 53, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA Update Banner */
|
||||
#pwa-update-banner {
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA Notifications */
|
||||
.pwa-notification {
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* PWA Specific Layout Adjustments */
|
||||
.pwa-installed {
|
||||
/* Adjustments for when app is installed as PWA */
|
||||
}
|
||||
|
||||
.pwa-installed .header {
|
||||
/* Adjust header for PWA mode */
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.pwa-browser {
|
||||
/* Adjustments for browser mode */
|
||||
}
|
||||
|
||||
/* iOS PWA Status Bar */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.pwa-installed {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.pwa-installed .header {
|
||||
padding-top: calc(env(safe-area-inset-top) + 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA Splash Screen Styles */
|
||||
.pwa-splash {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.5s ease-out, visibility 0.5s ease-out;
|
||||
}
|
||||
|
||||
.pwa-splash.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.pwa-splash .logo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: linear-gradient(135deg, #ff6b35, #f7941d);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
animation: pulse-logo 2s infinite;
|
||||
}
|
||||
|
||||
.pwa-splash .logo i {
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes pulse-logo {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(255, 107, 53, 0.4);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 20px rgba(255, 107, 53, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.pwa-splash .title {
|
||||
color: #ff6b35;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pwa-splash .subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.pwa-splash .loading {
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background: rgba(255, 107, 53, 0.2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pwa-splash .loading::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ff6b35, #f7941d);
|
||||
border-radius: 2px;
|
||||
animation: loading-bar 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-bar {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
|
||||
/* PWA Offline Indicator */
|
||||
.pwa-offline-indicator {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.pwa-online-indicator {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(34, 197, 94, 0.9);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA Safe Area Adjustments */
|
||||
@supports (padding: max(0px)) {
|
||||
.pwa-safe-area {
|
||||
padding-left: max(16px, env(safe-area-inset-left));
|
||||
padding-right: max(16px, env(safe-area-inset-right));
|
||||
padding-bottom: max(16px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.pwa-safe-area-top {
|
||||
padding-top: max(16px, env(safe-area-inset-top));
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA Install Promotion Banner */
|
||||
.pwa-install-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(135deg, #ff6b35, #f7941d);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.pwa-install-banner.show {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.pwa-install-banner .content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pwa-install-banner .icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.pwa-install-banner .text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pwa-install-banner .title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pwa-install-banner .subtitle {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.pwa-install-banner .actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pwa-install-banner button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pwa-install-banner .install-btn {
|
||||
background: white;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.pwa-install-banner .install-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.pwa-install-banner .dismiss-btn {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.pwa-install-banner .dismiss-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* PWA Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.pwa-install-banner {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pwa-install-banner .actions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pwa-install-banner button {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA Dark Mode Support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pwa-notification {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA Animation Classes */
|
||||
.pwa-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.pwa-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.pwa-scale-in {
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user