import { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { Github, Play, Clock, CheckCircle, XCircle, Loader2, ExternalLink, RefreshCw, Download, Upload, Database, History, SkipForward, AlertTriangle, Trash2, RotateCcw, } from 'lucide-react'; import { api } from '../api/client'; import type { GitHubBackupConfig, GitHubBackupConfigCreate, GitHubBackupLog, GitHubBackupStatus, GitHubBackupTriggerResponse, ScheduleType, CloudAuthStatus, Printer, } from '../api/client'; import { Card, CardContent, CardHeader } from './Card'; import { Button } from './Button'; import { Toggle } from './Toggle'; import { ConfirmModal } from './ConfirmModal'; import { useToast } from '../contexts/ToastContext'; import { formatRelativeTime } from '../utils/date'; interface StatusBadgeProps { status: string | null; } function StatusBadge({ status }: StatusBadgeProps) { if (!status) return null; const styles: Record = { success: 'bg-green-500/20 text-green-400', failed: 'bg-red-500/20 text-red-400', skipped: 'bg-yellow-500/20 text-yellow-400', running: 'bg-blue-500/20 text-blue-400', }; const icons: Record = { success: , failed: , skipped: , running: , }; return ( {icons[status]} {status.charAt(0).toUpperCase() + status.slice(1)} ); } function formatDateTime(dateStr: string | null): string { if (!dateStr) return '-'; const date = new Date(dateStr); return date.toLocaleString(); } export function GitHubBackupSettings() { const queryClient = useQueryClient(); const { showToast } = useToast(); const { t } = useTranslation(); // Local state for form const [repoUrl, setRepoUrl] = useState(''); const [accessToken, setAccessToken] = useState(''); const [branch, setBranch] = useState('main'); const [scheduleEnabled, setScheduleEnabled] = useState(false); const [scheduleType, setScheduleType] = useState('daily'); const [backupKProfiles, setBackupKProfiles] = useState(true); const [backupCloudProfiles, setBackupCloudProfiles] = useState(true); const [backupSettings, setBackupSettings] = useState(false); const [enabled, setEnabled] = useState(true); // Local backup state const [isExporting, setIsExporting] = useState(false); const [isRestoring, setIsRestoring] = useState(false); const [operationStatus, setOperationStatus] = useState(''); const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); const [restoreFile, setRestoreFile] = useState(null); const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null); const fileInputRef = useRef(null); // Block navigation while backup/restore is in progress useEffect(() => { const isOperationInProgress = isExporting || isRestoring; if (isOperationInProgress) { const handleBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?'; return e.returnValue; }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); } }, [isExporting, isRestoring]); // Test connection state const [testLoading, setTestLoading] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); // Auto-save debounce const autoSaveTimerRef = useRef(null); const isInitializedRef = useRef(false); // Queries const { data: config, isLoading: configLoading } = useQuery({ queryKey: ['github-backup-config'], queryFn: api.getGitHubBackupConfig, }); const { data: status } = useQuery({ queryKey: ['github-backup-status'], queryFn: api.getGitHubBackupStatus, refetchInterval: (query) => query.state.data?.is_running ? 500 : 10000, // Poll fast during backup }); const { data: logs } = useQuery({ queryKey: ['github-backup-logs'], queryFn: () => api.getGitHubBackupLogs(20), }); const { data: cloudStatus } = useQuery({ queryKey: ['cloud-status'], queryFn: api.getCloudStatus, }); // Fetch printers and their statuses for K-profile availability const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); // Fetch printer statuses from API (not just cache) to get accurate connection status const printerStatusQueries = useQueries({ queries: (printers ?? []).map(printer => ({ queryKey: ['printerStatus', printer.id], queryFn: () => api.getPrinterStatus(printer.id), staleTime: 10000, // Consider stale after 10s refetchInterval: 30000, // Refresh every 30s })), }); const printerStatuses = (printers ?? []).map((printer, index) => ({ printer, connected: printerStatusQueries[index]?.data?.connected ?? false, })); const totalPrinters = printerStatuses.length; const connectedPrinters = printerStatuses.filter(p => p.connected).length; const noPrintersConnected = totalPrinters > 0 && connectedPrinters === 0; const somePrintersDisconnected = connectedPrinters > 0 && connectedPrinters < totalPrinters; // Initialize form from config useEffect(() => { if (config) { setRepoUrl(config.repository_url); setBranch(config.branch); setScheduleEnabled(config.schedule_enabled); setScheduleType(config.schedule_type); setBackupKProfiles(config.backup_kprofiles); setBackupCloudProfiles(config.backup_cloud_profiles); setBackupSettings(config.backup_settings); setEnabled(config.enabled); setAccessToken(''); // Don't show stored token // Mark as initialized after a tick to avoid auto-save on initial load setTimeout(() => { isInitializedRef.current = true; }, 100); } }, [config]); // Auto-save function for existing configs const autoSave = useCallback(async (includeToken: boolean = false) => { if (!config?.has_token) return; // Only auto-save if config already exists try { if (includeToken && accessToken) { // Full save with new token await api.saveGitHubBackupConfig({ repository_url: repoUrl, access_token: accessToken, branch, schedule_enabled: scheduleEnabled, schedule_type: scheduleType, backup_kprofiles: backupKProfiles, backup_cloud_profiles: backupCloudProfiles, backup_settings: backupSettings, enabled, }); setAccessToken(''); // Clear after save showToast('Token updated'); } else { // Update without token await api.updateGitHubBackupConfig({ repository_url: repoUrl, branch, schedule_enabled: scheduleEnabled, schedule_type: scheduleType, backup_kprofiles: backupKProfiles, backup_cloud_profiles: backupCloudProfiles, backup_settings: backupSettings, enabled, }); showToast('Settings saved'); } queryClient.invalidateQueries({ queryKey: ['github-backup-config'] }); queryClient.invalidateQueries({ queryKey: ['github-backup-status'] }); } catch (error) { showToast(`Failed to save: ${(error as Error).message}`, 'error'); } }, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, enabled, queryClient, showToast]); // Auto-save effect for existing configs (debounced) useEffect(() => { if (!isInitializedRef.current || !config?.has_token) return; if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); } autoSaveTimerRef.current = setTimeout(() => { autoSave(false); }, 500); return () => { if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); } }; }, [repoUrl, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, enabled, autoSave, config?.has_token]); // Auto-save token when it changes (with longer debounce) useEffect(() => { if (!isInitializedRef.current || !config?.has_token || !accessToken) return; if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); } autoSaveTimerRef.current = setTimeout(() => { autoSave(true); }, 1000); return () => { if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); } }; }, [accessToken, autoSave, config?.has_token]); // Mutations const saveConfigMutation = useMutation({ mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['github-backup-config'] }); queryClient.invalidateQueries({ queryKey: ['github-backup-status'] }); showToast('GitHub backup enabled'); setAccessToken(''); isInitializedRef.current = true; }, onError: (error: Error) => { showToast(`Failed to save: ${error.message}`, 'error'); }, }); const triggerBackupMutation = useMutation({ mutationFn: api.triggerGitHubBackup, onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['github-backup-status'] }); queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] }); if (result.success) { if (result.files_changed > 0) { showToast(`Backup complete - ${result.files_changed} files updated`); } else { showToast('Backup skipped - no changes'); } } else { showToast(`Backup failed: ${result.message}`, 'error'); } }, onError: (error: Error) => { showToast(`Backup failed: ${error.message}`, 'error'); }, }); const clearLogsMutation = useMutation<{ deleted: number; message: string }, Error>({ mutationFn: () => api.clearGitHubBackupLogs(0), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] }); showToast(`Cleared ${result.deleted} logs`); }, onError: (error: Error) => { showToast(`Failed to clear logs: ${error.message}`, 'error'); }, }); const handleTestConnection = async () => { setTestLoading(true); setTestResult(null); try { let result; // If user entered a new token, test with those credentials if (accessToken) { if (!repoUrl) { showToast('Enter repository URL', 'error'); setTestLoading(false); return; } result = await api.testGitHubConnection(repoUrl, accessToken); } else if (config?.has_token) { // Use stored credentials result = await api.testGitHubStoredConnection(); } else { showToast('Enter repository URL and access token', 'error'); setTestLoading(false); return; } setTestResult({ success: result.success, message: result.message }); } catch (error) { setTestResult({ success: false, message: (error as Error).message }); } finally { setTestLoading(false); } }; // Initial setup save (only for new configs) const handleInitialSetup = () => { if (!repoUrl) { showToast('Repository URL is required', 'error'); return; } if (!accessToken) { showToast('Access token is required', 'error'); return; } saveConfigMutation.mutate({ repository_url: repoUrl, access_token: accessToken, branch, schedule_enabled: scheduleEnabled, schedule_type: scheduleType, backup_kprofiles: backupKProfiles, backup_cloud_profiles: backupCloudProfiles, backup_settings: backupSettings, enabled, }); }; if (configLoading) { return (
); } return (
{/* Left Column - GitHub Backup */}

GitHub Backup

{config && cloudStatus?.is_authenticated && (
Enabled
)}
{/* Bambu Cloud required message */} {!cloudStatus?.is_authenticated ? (

Bambu Cloud login required. Sign in under Profiles → Cloud Profiles to enable GitHub backup.

) : ( <>

Automatically sync your profiles to a private GitHub repository for backup and version history.

{/* Repository URL */}
{ setRepoUrl(e.target.value); setTestResult(null); }} placeholder="https://github.com/username/bambuddy-backup" className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
{/* Access Token */}
{ setAccessToken(e.target.value); setTestResult(null); }} placeholder={config?.has_token ? 'Enter new token to update' : 'ghp_xxxxxxxxxxxx'} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />

Fine-grained token with Contents read/write permission

{/* Branch - inline with schedule */}
setBranch(e.target.value)} placeholder="main" className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
{/* What to backup */}
{/* Test + Status + Actions */}
{/* Status line */} {status?.configured && (
{status.last_backup_at ? ( <> Last backup: {formatRelativeTime(status.last_backup_at, 'system', t)} ) : ( No backups yet )}
{status.next_scheduled_run && ( Next: {formatRelativeTime(status.next_scheduled_run, 'system', t)} )}
)} {/* Test result */} {testResult && (
{testResult.success ? : } {testResult.message}
)} {/* Action buttons */}
{status?.configured ? ( <> {(triggerBackupMutation.isPending || status.is_running) ? (
{status.progress || 'Starting backup...'}
) : ( <> )} ) : ( <> )}
)}
{/* Backup History - only show if configured and has logs */} {logs && logs.length > 0 && (

History

{logs.slice(0, 10).map((log) => ( ))}
Date Status Commit
{formatDateTime(log.started_at)} {log.commit_sha ? ( {log.commit_sha.substring(0, 7)} ) : ( - )}
)}
{/* Right Column - Local Backup */}

Local Backup

Create a complete backup of your Bambuddy data including the database, archives, uploads, and all files.

{/* Export */}

Download Backup

Complete backup: database + all files (ZIP)

{/* Import */}

{t('backup.restoreBackup')}

{t('backup.restoreDescription')}

{t('backup.restoreNote')}

{ const file = e.target.files?.[0]; if (file) { setRestoreFile(file); setShowRestoreConfirm(true); } e.target.value = ''; }} />
{/* Restore result message */} {restoreResult && (
{restoreResult.success ? ( ) : ( )}
{restoreResult.message} {restoreResult.success && (
)}
)} {/* Warning */}
Restore replaces all data.{' '} Your current database and files will be completely replaced. A restart is required after restore.
{/* Restore Confirmation Modal */} {showRestoreConfirm && restoreFile && ( { setShowRestoreConfirm(false); setIsRestoring(true); setRestoreResult(null); try { setOperationStatus('Uploading backup file...'); const result = await api.importBackup(restoreFile); setRestoreResult(result); if (result.success) { showToast('Backup restored. Please restart Bambuddy.', 'success'); } else { showToast(result.message, 'error'); } } catch (e) { const message = e instanceof Error ? e.message : 'Failed to restore backup'; setRestoreResult({ success: false, message }); showToast(message, 'error'); } finally { setIsRestoring(false); setOperationStatus(''); setRestoreFile(null); } }} onCancel={() => { setShowRestoreConfirm(false); setRestoreFile(null); }} /> )} {/* Blocking overlay during backup/restore operations */} {(isExporting || isRestoring) && (

{isExporting ? 'Creating Backup' : 'Restoring Backup'}

{operationStatus || (isExporting ? 'Preparing...' : 'Processing...')}

Please do not close this page or navigate away. This operation may take several minutes for large backups.

)}
); }