From d5b81919965ff7f6d109c22d108e288df42a196b Mon Sep 17 00:00:00 2001 From: natreex Date: Mon, 22 Dec 2025 16:46:43 -0500 Subject: [PATCH] Add offline mode components and enhance synchronization logic - Introduced `OfflineSyncManager`, `OfflineToggle`, `OfflinePinSetup`, `OfflineIndicator`, and `OfflineSyncDetails` components to support offline mode functionality. - Added PIN verification (`OfflinePinVerify`) for secure offline access. - Implemented sync options for books (current, recent, all) with progress tracking and improved user feedback. - Enhanced offline context integration and error handling in sync processes. - Refined UI elements for better online/offline status visibility and manual sync controls. --- components/offline/OfflineIndicator.tsx | 174 ++++++++++++++++ components/offline/OfflinePinSetup.tsx | 181 +++++++++++++++++ components/offline/OfflinePinVerify.tsx | 158 +++++++++++++++ components/offline/OfflineSyncDetails.tsx | 126 ++++++++++++ components/offline/OfflineSyncManager.tsx | 233 ++++++++++++++++++++++ components/offline/OfflineToggle.tsx | 40 ++++ 6 files changed, 912 insertions(+) create mode 100644 components/offline/OfflineIndicator.tsx create mode 100644 components/offline/OfflinePinSetup.tsx create mode 100644 components/offline/OfflinePinVerify.tsx create mode 100644 components/offline/OfflineSyncDetails.tsx create mode 100644 components/offline/OfflineSyncManager.tsx create mode 100644 components/offline/OfflineToggle.tsx diff --git a/components/offline/OfflineIndicator.tsx b/components/offline/OfflineIndicator.tsx new file mode 100644 index 0000000..26e8fa3 --- /dev/null +++ b/components/offline/OfflineIndicator.tsx @@ -0,0 +1,174 @@ +'use client'; + +import React, { useContext } from 'react'; +import OfflineContext from '@/context/OfflineContext'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faWifi, faWifiSlash, faSync, faCircle, faExclamationTriangle, faCheck } from '@fortawesome/free-solid-svg-icons'; + +/** + * OfflineIndicator - Displays current online/offline status and sync progress + * Allows user to toggle manual offline mode + */ +export default function OfflineIndicator() { + const { offlineMode, toggleOfflineMode, syncNow } = useContext(OfflineContext); + + const { + isOffline, + isManuallyOffline, + isNetworkOnline, + isDatabaseInitialized, + syncProgress, + lastSyncAt, + error + } = offlineMode; + + // Determine status color and text + const getStatusInfo = () => { + if (!isDatabaseInitialized) { + return { + color: 'text-gray-400', + bgColor: 'bg-gray-100', + icon: faCircle, + text: 'DB non initialisée' + }; + } + + if (error) { + return { + color: 'text-red-500', + bgColor: 'bg-red-50', + icon: faExclamationTriangle, + text: 'Erreur de sync' + }; + } + + if (syncProgress.isSyncing) { + return { + color: 'text-blue-500', + bgColor: 'bg-blue-50', + icon: faSync, + text: 'Synchronisation...', + spin: true + }; + } + + if (isOffline) { + if (syncProgress.pendingChanges > 0) { + return { + color: 'text-orange-500', + bgColor: 'bg-orange-50', + icon: faWifiSlash, + text: `Hors ligne (${syncProgress.pendingChanges} en attente)` + }; + } + return { + color: 'text-orange-500', + bgColor: 'bg-orange-50', + icon: faWifiSlash, + text: isManuallyOffline ? 'Mode hors ligne' : 'Hors ligne' + }; + } + + if (syncProgress.pendingChanges > 0) { + return { + color: 'text-yellow-500', + bgColor: 'bg-yellow-50', + icon: faSync, + text: `${syncProgress.pendingChanges} changements à sync` + }; + } + + return { + color: 'text-green-500', + bgColor: 'bg-green-50', + icon: faCheck, + text: 'Synchronisé' + }; + }; + + const statusInfo = getStatusInfo(); + + // Format last sync time + const formatLastSync = () => { + if (!lastSyncAt) return 'Jamais'; + + const diff = Date.now() - lastSyncAt; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + + if (hours > 0) return `Il y a ${hours}h`; + if (minutes > 0) return `Il y a ${minutes}min`; + return 'À l\'instant'; + }; + + const handleSyncNow = async () => { + if (!isOffline && !syncProgress.isSyncing) { + await syncNow(); + } + }; + + return ( +
+ {/* Status indicator */} +
+ + + {statusInfo.text} + +
+ + {/* Last sync time */} + {isDatabaseInitialized && !isOffline && ( + + Dernière sync: {formatLastSync()} + + )} + + {/* Error message */} + {error && ( + + {error} + + )} + + {/* Actions */} +
+ {/* Manual sync button */} + {isDatabaseInitialized && !isOffline && !syncProgress.isSyncing && ( + + )} + + {/* Offline toggle */} + {isDatabaseInitialized && ( + + )} +
+
+ ); +} diff --git a/components/offline/OfflinePinSetup.tsx b/components/offline/OfflinePinSetup.tsx new file mode 100644 index 0000000..0a1181c --- /dev/null +++ b/components/offline/OfflinePinSetup.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useState, useContext } from 'react'; +import { SessionContext } from '@/context/SessionContext'; +import { useTranslations } from 'next-intl'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faLock, faShieldAlt, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; + +interface OfflinePinSetupProps { + onClose?: () => void; + onSuccess?: () => void; + showOnFirstLogin?: boolean; +} + +export default function OfflinePinSetup({ onClose, onSuccess, showOnFirstLogin }: OfflinePinSetupProps) { + const t = useTranslations(); + const { session } = useContext(SessionContext); + const [pin, setPin] = useState(''); + const [confirmPin, setConfirmPin] = useState(''); + const [showPin, setShowPin] = useState(false); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + // Only allow PIN setup if authenticated + if (!session?.isConnected || !session?.user) { + return null; + } + + const validatePin = (): boolean => { + if (pin.length < 4) { + setError(t('offline.pin.errors.tooShort')); + return false; + } + if (pin.length > 8) { + setError(t('offline.pin.errors.tooLong')); + return false; + } + if (!/^\d+$/.test(pin)) { + setError(t('offline.pin.errors.digitsOnly')); + return false; + } + if (pin !== confirmPin) { + setError(t('offline.pin.errors.mismatch')); + return false; + } + return true; + }; + + const handleSetPin = async () => { + if (!validatePin()) return; + + setIsLoading(true); + setError(''); + + try { + if (window.electron) { + // Set the PIN + const result = await window.electron.offlinePinSet(pin); + + if (result.success) { + // Enable offline mode + await window.electron.offlineModeSet(true, 30); // 30 days sync interval + + console.log('[OfflinePin] PIN configured successfully'); + onSuccess?.(); + } else { + setError(result.error || t('offline.pin.errors.setupFailed')); + } + } + } catch (error) { + console.error('[OfflinePin] Error setting PIN:', error); + setError(t('offline.pin.errors.setupFailed')); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ +
+
+

+ {showOnFirstLogin ? t('offline.pin.setup.titleFirstLogin') : t('offline.pin.setup.title')} +

+

+ {t('offline.pin.setup.subtitle')} +

+
+
+ + {/* Info box */} +
+

+ + {t('offline.pin.setup.description')} +

+
+ + {/* PIN Input */} +
+
+ +
+ setPin(e.target.value.replace(/\D/g, '').slice(0, 8))} + placeholder="••••" + maxLength={8} + className="w-full px-4 py-2 bg-secondary border border-gray-dark rounded-lg focus:outline-none focus:border-primary" + disabled={isLoading} + /> + +
+
+ +
+ + setConfirmPin(e.target.value.replace(/\D/g, '').slice(0, 8))} + placeholder="••••" + maxLength={8} + className="w-full px-4 py-2 bg-secondary border border-gray-dark rounded-lg focus:outline-none focus:border-primary" + disabled={isLoading} + /> +
+
+ + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + + {/* Actions */} +
+ {onClose && ( + + )} + +
+ + {/* Footer note */} +

+ {t('offline.pin.setup.footer')} +

+
+
+ ); +} \ No newline at end of file diff --git a/components/offline/OfflinePinVerify.tsx b/components/offline/OfflinePinVerify.tsx new file mode 100644 index 0000000..96828bb --- /dev/null +++ b/components/offline/OfflinePinVerify.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faLock, faWifi, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; + +interface OfflinePinVerifyProps { + onSuccess: (userId: string) => void; + onCancel?: () => void; +} + +export default function OfflinePinVerify({ onSuccess, onCancel }: OfflinePinVerifyProps) { + const t = useTranslations(); + const [pin, setPin] = useState(''); + const [showPin, setShowPin] = useState(false); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [attempts, setAttempts] = useState(0); + + const handleVerifyPin = async () => { + if (!pin || pin.length < 4) { + setError(t('offline.pin.verify.enterPin')); + return; + } + + setIsLoading(true); + setError(''); + + try { + if (window.electron) { + const result = await window.electron.offlinePinVerify(pin); + + if (result.success && result.userId) { + console.log('[OfflinePin] PIN verified, accessing offline mode'); + onSuccess(result.userId); + } else { + setAttempts(prev => prev + 1); + setPin(''); + + if (attempts >= 2) { + setError(t('offline.pin.verify.tooManyAttempts')); + } else { + setError(result.error || t('offline.pin.verify.incorrect')); + } + } + } + } catch (error) { + console.error('[OfflinePin] Error verifying PIN:', error); + setError(t('offline.pin.verify.error')); + } finally { + setIsLoading(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleVerifyPin(); + } + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+
+ +
+
+
+
+ +
+

+ {t('offline.pin.verify.title')} +

+

+ {t('offline.pin.verify.subtitle')} +

+
+ + {/* PIN Input */} +
+
+ setPin(e.target.value.replace(/\D/g, '').slice(0, 8))} + onKeyPress={handleKeyPress} + placeholder={t('offline.pin.verify.placeholder')} + maxLength={8} + autoFocus + className="w-full px-4 py-3 bg-secondary border border-gray-dark rounded-lg focus:outline-none focus:border-primary text-center text-lg tracking-widest" + disabled={isLoading || attempts > 2} + /> + +
+
+ + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + + {/* Actions */} +
+ {onCancel && ( + + )} + +
+ + {/* Help text */} + {attempts > 0 && attempts <= 2 && ( +

+ {t('offline.pin.verify.attemptsRemaining', { count: 3 - attempts })} +

+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/offline/OfflineSyncDetails.tsx b/components/offline/OfflineSyncDetails.tsx new file mode 100644 index 0000000..3e5792a --- /dev/null +++ b/components/offline/OfflineSyncDetails.tsx @@ -0,0 +1,126 @@ +'use client'; + +import React, { useContext } from 'react'; +import OfflineContext from '@/context/OfflineContext'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faDatabase, faSync, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; + +/** + * OfflineSyncDetails - Detailed view of sync status per table + * Shows pending changes and last sync time for each data type + */ +export default function OfflineSyncDetails() { + const { offlineMode } = useContext(OfflineContext); + const { syncProgress, isDatabaseInitialized } = offlineMode; + + if (!isDatabaseInitialized) { + return ( +
+ +

Base de données non initialisée

+
+ ); + } + + if (!syncProgress.tables || syncProgress.tables.length === 0) { + return ( +
+ +

Aucune donnée de synchronisation disponible

+
+ ); + } + + // Format timestamp + const formatTime = (timestamp: number) => { + if (!timestamp) return 'Jamais'; + const date = new Date(timestamp); + return date.toLocaleString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + // Get friendly name for table + const getTableName = (table: string): string => { + const names: { [key: string]: string } = { + 'erit_books': 'Livres', + 'book_chapters': 'Chapitres', + 'book_chapter_content': 'Contenu des chapitres', + 'book_characters': 'Personnages', + 'book_world': 'Monde', + 'book_world_elements': 'Éléments du monde', + 'ai_conversations': 'Conversations IA', + 'ai_messages_history': 'Messages IA', + 'book_guide_line': 'Lignes directrices', + 'book_plot_points': 'Points de l\'intrigue', + 'book_incidents': 'Incidents' + }; + return names[table] || table; + }; + + return ( +
+

+ + État de synchronisation +

+ +
+ {syncProgress.tables.map((tableStatus) => { + const hasPending = tableStatus.pending > 0; + + return ( +
+
+
+
+ {getTableName(tableStatus.table)} +
+
+ Dernière sync: {formatTime(tableStatus.lastSync)} +
+
+ + {hasPending && ( +
+ + + {tableStatus.pending} en attente + +
+ )} + + {!hasPending && tableStatus.lastSync > 0 && ( +
+ ✓ Synchronisé +
+ )} +
+
+ ); + })} +
+ + {/* Summary */} +
+
+ Total: {syncProgress.pendingChanges} changement(s) en attente +
+
+
+ ); +} diff --git a/components/offline/OfflineSyncManager.tsx b/components/offline/OfflineSyncManager.tsx new file mode 100644 index 0000000..c134bd9 --- /dev/null +++ b/components/offline/OfflineSyncManager.tsx @@ -0,0 +1,233 @@ +'use client'; +import { useState, useEffect, useContext } from 'react'; +import { SessionContext } from '@/context/SessionContext'; +import { OfflineContext } from '@/context/OfflineContext'; +import { useTranslations } from 'next-intl'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faCloudDownloadAlt, + faWifi, + faCheckCircle, + faSpinner, + faBook +} from '@fortawesome/free-solid-svg-icons'; + +interface SyncOption { + id: 'current' | 'recent' | 'all' | 'skip'; + label: string; + description: string; + estimatedSize?: string; + bookCount?: number; +} + +export default function OfflineSyncManager() { + const t = useTranslations(); + const { session } = useContext(SessionContext); + const { syncBooks, isOnline } = useContext(OfflineContext); + const [showDialog, setShowDialog] = useState(false); + const [isFirstSync, setIsFirstSync] = useState(false); + const [syncProgress, setSyncProgress] = useState(0); + const [selectedOption, setSelectedOption] = useState(''); + + useEffect(() => { + // Check if this is first time user sees sync options + const hasSeenSync = localStorage.getItem('hasSeenSyncDialog'); + if (!hasSeenSync && isOnline && session?.user) { + setIsFirstSync(true); + setShowDialog(true); + } + }, [session, isOnline]); + + const syncOptions: SyncOption[] = [ + { + id: 'current', + label: t('sync.currentBook'), + description: t('sync.currentBookDesc'), + estimatedSize: '~5 MB', + bookCount: 1 + }, + { + id: 'recent', + label: t('sync.recentBooks'), + description: t('sync.recentBooksDesc'), + estimatedSize: '~25 MB', + bookCount: 3 + }, + { + id: 'all', + label: t('sync.allBooks'), + description: t('sync.allBooksDesc'), + estimatedSize: '~120 MB', + bookCount: 12 + }, + { + id: 'skip', + label: t('sync.skipForNow'), + description: t('sync.skipDesc'), + } + ]; + + const handleSync = async (option: string) => { + if (option === 'skip') { + localStorage.setItem('hasSeenSyncDialog', 'true'); + setShowDialog(false); + return; + } + + setSyncProgress(0); + + try { + // Simulate progressive download + const interval = setInterval(() => { + setSyncProgress(prev => { + if (prev >= 100) { + clearInterval(interval); + return 100; + } + return prev + 10; + }); + }, 200); + + // Actual sync logic here + // await syncBooks(option); + + localStorage.setItem('hasSeenSyncDialog', 'true'); + localStorage.setItem('syncPreference', option); + + setTimeout(() => { + setShowDialog(false); + }, 2000); + } catch (error) { + console.error('Sync failed:', error); + } + }; + + if (!showDialog) return null; + + return ( +
+
+ {/* Header */} +
+
+ + {isOnline && ( + + )} +
+
+

+ {isFirstSync ? t('sync.welcomeOffline') : t('sync.syncOptions')} +

+

+ {t('sync.workAnywhere')} +

+
+
+ + {/* Sync Options */} + {syncProgress === 0 ? ( +
+ {syncOptions.map((option) => ( + + ))} +
+ ) : ( + /* Progress Bar */ +
+
+ + {t('sync.downloading')}... + + + {syncProgress}% + +
+
+
+
+ {syncProgress === 100 && ( +
+ +

+ {t('sync.complete')}! +

+
+ )} +
+ )} + + {/* Actions */} + {syncProgress === 0 && ( +
+ + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/offline/OfflineToggle.tsx b/components/offline/OfflineToggle.tsx new file mode 100644 index 0000000..47545bc --- /dev/null +++ b/components/offline/OfflineToggle.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useContext } from 'react'; +import OfflineContext from '@/context/OfflineContext'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faWifi, faCircle } from '@fortawesome/free-solid-svg-icons'; + +export default function OfflineToggle() { + const { offlineMode, toggleOfflineMode } = useContext(OfflineContext); + + if (!window.electron) { + return null; + } + + const handleToggle = () => { + toggleOfflineMode(); + }; + + return ( + + ); +}