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 && (
+
+ )}
+
+ {/* 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 && (
+
+ )}
+
+ {/* 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 (
+
+ );
+}