/** * SyncStatusButton - Cloud Sync Status Indicator for Top Bar * * Shows current sync state with cloud icon and colored indicators: * - Green dot: Local changes pending upload * - Blue dot + spin: Syncing in progress * - Red dot: Error * - Gray dot: No providers connected * * Clicking opens a popover with sync status details and history. */ import React, { useState } from 'react'; import { Cloud, CloudOff, Github, Loader2, Plug, RefreshCw, Settings, X, ArrowUp, ArrowDown, Database, Server, } from 'lucide-react'; import { useCloudSync } from '../application/state/useCloudSync'; import { isProviderReadyForSync, type CloudProvider, formatSyncDateTime } from '../domain/sync'; import { isPluginCloudProviderId } from '../domain/cloudProviderIds'; import { useI18n } from '../application/i18n/I18nProvider'; import { cn } from '../lib/utils'; import { Button } from './ui/button'; import { Popover, PopoverContent, PopoverTrigger, } from './ui/popover'; import { toast } from './ui/toast'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; // ============================================================================ // Provider Icons // ============================================================================ const GoogleDriveIcon: React.FC<{ className?: string }> = ({ className }) => ( ); const OneDriveIcon: React.FC<{ className?: string }> = ({ className }) => ( ); const providerIcons: Partial> = { github: , google: , onedrive: , webdav: , s3: , }; const providerNames: Partial> = { github: 'GitHub Gist', google: 'Google Drive', onedrive: 'OneDrive', webdav: 'WebDAV', s3: 'S3 Compatible', }; const resolveProviderIcon = (provider: CloudProvider): React.ReactNode => providerIcons[provider] ?? ; const resolveProviderName = (provider: CloudProvider): string => providerNames[provider] ?? provider; // ============================================================================ // Status Dot Component // ============================================================================ interface StatusIndicatorProps { status: 'synced' | 'syncing' | 'error' | 'none'; size?: 'sm' | 'md'; className?: string; } const StatusIndicator: React.FC = ({ status, size = 'sm', className }) => { const sizeClass = size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5'; const baseClasses = cn( 'rounded-full', sizeClass, status === 'syncing' && 'animate-pulse', className ); const colors = { synced: 'bg-green-500', syncing: 'bg-blue-500', error: 'bg-red-500', none: 'bg-muted-foreground/30', }; return ; }; // ============================================================================ // Main SyncStatusButton Component // ============================================================================ interface SyncStatusButtonProps { onOpenSettings?: () => void; onSyncNow?: () => Promise; // Callback to trigger sync with current data className?: string; style?: React.CSSProperties; } export const SyncStatusButton: React.FC = ({ onOpenSettings, onSyncNow, className, style, }) => { const { t } = useI18n(); const [isOpen, setIsOpen] = useState(false); const [isSyncingManual, setIsSyncingManual] = useState(false); const sync = useCloudSync(); // State is now automatically synced via useSyncExternalStore - no manual refresh needed // Get connected provider (built-ins first, then any namespaced plugin provider). const getConnectedProvider = (): CloudProvider | null => { const preferred: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3']; for (const id of preferred) { const conn = sync.providers[id]; if (conn && isProviderReadyForSync(conn)) return id; } for (const [id, conn] of Object.entries(sync.providers)) { if (preferred.includes(id as CloudProvider)) continue; if (conn && isProviderReadyForSync(conn)) return id as CloudProvider; } return null; }; const connectedProvider = getConnectedProvider(); const providerConnection = connectedProvider ? sync.providers[connectedProvider] : null; const hasVersionMismatch = sync.hasAnyConnectedProvider && sync.localVersion !== sync.remoteVersion; const hasPendingSync = sync.pendingLocalSync || hasVersionMismatch; // Determine overall status for the button indicator const getOverallStatus = (): StatusIndicatorProps['status'] => { if (sync.overallSyncStatus === 'syncing') return 'syncing'; if ( sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict' || sync.overallSyncStatus === 'blocked' ) { return 'error'; } if (hasPendingSync) return 'synced'; return 'none'; }; const overallStatus = getOverallStatus(); // Get the button icon based on state const getButtonIcon = () => { if (sync.isSyncing) return ; if (sync.hasAnyConnectedProvider) return ; return ; }; const formatTime = (timestamp?: number): string => { if (!timestamp) return t('time.never'); const diff = Date.now() - timestamp; if (diff < 60000) return t('time.justNow'); if (diff < 3600000) return t('time.minutesAgo', { minutes: Math.floor(diff / 60000) }); return formatSyncDateTime(timestamp); }; // Create a unique key based on sync state to force re-render const syncStateKey = `${sync.localVersion}-${sync.remoteVersion}-${sync.syncHistory.length}`; return ( {getButtonIcon()} {overallStatus !== 'none' && ( )} {t('sync.cloudSync')} {/* Header */} {overallStatus === 'synced' && hasPendingSync && ( )} {overallStatus === 'syncing' && ( )} {overallStatus === 'error' && ( )} {overallStatus === 'none' && sync.hasAnyConnectedProvider && ( )} {overallStatus === 'none' && !sync.hasAnyConnectedProvider && ( )} {overallStatus === 'synced' && hasPendingSync && t('sync.pending')} {overallStatus === 'syncing' && t('sync.syncing')} {overallStatus === 'error' && t('sync.error')} {overallStatus === 'none' && sync.hasAnyConnectedProvider && t('sync.active')} {overallStatus === 'none' && !sync.hasAnyConnectedProvider && t('sync.notConfigured')} {onOpenSettings && ( { setIsOpen(false); onOpenSettings(); }} className="p-1 rounded hover:bg-muted transition-colors" > {t('sync.settings')} )} {/* Content based on state */} {!sync.hasAnyConnectedProvider ? ( // No provider connected {t('sync.notConfigured')} {t('sync.autoSync.noProvider')} { setIsOpen(false); onOpenSettings?.(); }} > {t('sync.settings')} ) : ( // Provider connected - show details {/* Connected Provider Info */} {connectedProvider && providerConnection && ( {resolveProviderIcon(connectedProvider)} {resolveProviderName(connectedProvider)} {providerConnection.account?.avatarUrl && connectedProvider && !isPluginCloudProviderId(connectedProvider) && ( )} {providerConnection.account?.name || providerConnection.account?.email || t('sync.connected')} )} {/* Version Info */} {t('cloudSync.conflict.local')} v{sync.localVersion} {formatTime(sync.localUpdatedAt)} {t('cloudSync.conflict.cloud')} v{sync.remoteVersion} {formatTime(sync.remoteUpdatedAt)} {/* Recent Sync History */} {sync.syncHistory.length > 0 && ( {t('sync.recentActivity')} {sync.syncHistory.slice(0, 5).map((entry) => ( {entry.success ? ( entry.action === 'upload' ? : ) : ( )} {entry.action === 'upload' ? t('sync.history.uploaded') : entry.action === 'download' ? t('sync.history.downloaded') : t('sync.history.resolved')}{' '} v{entry.localVersion} {formatTime(entry.timestamp)} ))} )} {/* Sync Button */} { if (onSyncNow) { setIsSyncingManual(true); try { await onSyncNow(); toast.success(t('sync.toast.completedMessage'), t('sync.cloudSync')); } catch (error) { toast.error( error instanceof Error ? error.message : t('sync.failed'), t('sync.toast.errorTitle'), ); } finally { setIsSyncingManual(false); } } else { setIsOpen(false); onOpenSettings?.(); } }} > {(sync.isSyncing || isSyncingManual) ? ( ) : ( )} {t('sync.syncNow')} )} ); }; export default SyncStatusButton;
{t('sync.notConfigured')}
{t('sync.autoSync.noProvider')}