/** * 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 ( {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 && ( {t('sync.settings')} )}
{/* Content based on state */}
{!sync.hasAnyConnectedProvider ? ( // No provider connected

{t('sync.notConfigured')}

{t('sync.autoSync.noProvider')}

) : ( // 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 */}
)}
); }; export default SyncStatusButton;