|
@@ -16,6 +16,7 @@ import {
|
|
|
SkipForward,
|
|
SkipForward,
|
|
|
AlertTriangle,
|
|
AlertTriangle,
|
|
|
Trash2,
|
|
Trash2,
|
|
|
|
|
+ RotateCcw,
|
|
|
} from 'lucide-react';
|
|
} from 'lucide-react';
|
|
|
import { api } from '../api/client';
|
|
import { api } from '../api/client';
|
|
|
import type {
|
|
import type {
|
|
@@ -31,8 +32,7 @@ import type {
|
|
|
import { Card, CardContent, CardHeader } from './Card';
|
|
import { Card, CardContent, CardHeader } from './Card';
|
|
|
import { Button } from './Button';
|
|
import { Button } from './Button';
|
|
|
import { Toggle } from './Toggle';
|
|
import { Toggle } from './Toggle';
|
|
|
-import { BackupModal } from './BackupModal';
|
|
|
|
|
-import { RestoreModal } from './RestoreModal';
|
|
|
|
|
|
|
+import { ConfirmModal } from './ConfirmModal';
|
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
|
|
|
|
|
|
interface StatusBadgeProps {
|
|
interface StatusBadgeProps {
|
|
@@ -108,9 +108,30 @@ export function GitHubBackupSettings() {
|
|
|
const [backupSettings, setBackupSettings] = useState(false);
|
|
const [backupSettings, setBackupSettings] = useState(false);
|
|
|
const [enabled, setEnabled] = useState(true);
|
|
const [enabled, setEnabled] = useState(true);
|
|
|
|
|
|
|
|
- // Local backup modals
|
|
|
|
|
- const [showBackupModal, setShowBackupModal] = useState(false);
|
|
|
|
|
- const [showRestoreModal, setShowRestoreModal] = useState(false);
|
|
|
|
|
|
|
+ // Local backup state
|
|
|
|
|
+ const [isExporting, setIsExporting] = useState(false);
|
|
|
|
|
+ const [isRestoring, setIsRestoring] = useState(false);
|
|
|
|
|
+ const [operationStatus, setOperationStatus] = useState<string>('');
|
|
|
|
|
+ const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
|
|
|
|
|
+ const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
|
|
|
|
+ const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
|
|
|
|
|
+ const fileInputRef = useRef<HTMLInputElement>(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
|
|
// Test connection state
|
|
|
const [testLoading, setTestLoading] = useState(false);
|
|
const [testLoading, setTestLoading] = useState(false);
|
|
@@ -696,80 +717,185 @@ export function GitHubBackupSettings() {
|
|
|
</CardHeader>
|
|
</CardHeader>
|
|
|
<CardContent className="space-y-4">
|
|
<CardContent className="space-y-4">
|
|
|
<p className="text-sm text-bambu-gray">
|
|
<p className="text-sm text-bambu-gray">
|
|
|
- Export or import your Bambuddy data as a local file for manual backup or migration.
|
|
|
|
|
|
|
+ Create a complete backup of your Bambuddy data including the database, archives, uploads, and all files.
|
|
|
</p>
|
|
</p>
|
|
|
|
|
|
|
|
|
|
+ {/* Export */}
|
|
|
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
|
|
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
|
|
|
<div>
|
|
<div>
|
|
|
- <p className="text-white">Export Data</p>
|
|
|
|
|
|
|
+ <p className="text-white">Download Backup</p>
|
|
|
<p className="text-sm text-bambu-gray">
|
|
<p className="text-sm text-bambu-gray">
|
|
|
- Download all settings, printers, and profiles
|
|
|
|
|
|
|
+ Complete backup: database + all files (ZIP)
|
|
|
</p>
|
|
</p>
|
|
|
</div>
|
|
</div>
|
|
|
<Button
|
|
<Button
|
|
|
variant="secondary"
|
|
variant="secondary"
|
|
|
size="sm"
|
|
size="sm"
|
|
|
- onClick={() => setShowBackupModal(true)}
|
|
|
|
|
|
|
+ disabled={isExporting || isRestoring}
|
|
|
|
|
+ onClick={async () => {
|
|
|
|
|
+ setIsExporting(true);
|
|
|
|
|
+ setOperationStatus('Preparing backup...');
|
|
|
|
|
+ try {
|
|
|
|
|
+ setOperationStatus('Creating backup archive... This may take a while for large archives.');
|
|
|
|
|
+ const { blob, filename } = await api.exportBackup();
|
|
|
|
|
+ setOperationStatus('Downloading backup file...');
|
|
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
|
|
+ const a = document.createElement('a');
|
|
|
|
|
+ a.href = url;
|
|
|
|
|
+ a.download = filename;
|
|
|
|
|
+ a.click();
|
|
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
|
|
+ showToast('Backup downloaded successfully');
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ showToast(`Failed to create backup: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setIsExporting(false);
|
|
|
|
|
+ setOperationStatus('');
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
>
|
|
>
|
|
|
<Download className="w-4 h-4" />
|
|
<Download className="w-4 h-4" />
|
|
|
- Export
|
|
|
|
|
|
|
+ Download
|
|
|
</Button>
|
|
</Button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div className="flex items-center justify-between py-3">
|
|
|
|
|
|
|
+ {/* Import */}
|
|
|
|
|
+ <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
|
|
|
<div>
|
|
<div>
|
|
|
- <p className="text-white">Import Backup</p>
|
|
|
|
|
|
|
+ <p className="text-white">Restore Backup</p>
|
|
|
<p className="text-sm text-bambu-gray">
|
|
<p className="text-sm text-bambu-gray">
|
|
|
- Restore from a previous export file
|
|
|
|
|
|
|
+ Replace all data from a backup file
|
|
|
</p>
|
|
</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <input
|
|
|
|
|
+ ref={fileInputRef}
|
|
|
|
|
+ type="file"
|
|
|
|
|
+ accept=".zip"
|
|
|
|
|
+ className="hidden"
|
|
|
|
|
+ onChange={(e) => {
|
|
|
|
|
+ const file = e.target.files?.[0];
|
|
|
|
|
+ if (file) {
|
|
|
|
|
+ setRestoreFile(file);
|
|
|
|
|
+ setShowRestoreConfirm(true);
|
|
|
|
|
+ }
|
|
|
|
|
+ e.target.value = '';
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
<Button
|
|
<Button
|
|
|
variant="secondary"
|
|
variant="secondary"
|
|
|
size="sm"
|
|
size="sm"
|
|
|
- onClick={() => setShowRestoreModal(true)}
|
|
|
|
|
|
|
+ disabled={isRestoring || isExporting}
|
|
|
|
|
+ onClick={() => fileInputRef.current?.click()}
|
|
|
>
|
|
>
|
|
|
<Upload className="w-4 h-4" />
|
|
<Upload className="w-4 h-4" />
|
|
|
- Import
|
|
|
|
|
|
|
+ Restore
|
|
|
</Button>
|
|
</Button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Restore result message */}
|
|
|
|
|
+ {restoreResult && (
|
|
|
|
|
+ <div className={`p-3 rounded-lg ${restoreResult.success ? 'bg-green-500/10 border border-green-500/30' : 'bg-red-500/10 border border-red-500/30'}`}>
|
|
|
|
|
+ <div className="flex items-start gap-2 text-sm">
|
|
|
|
|
+ {restoreResult.success ? (
|
|
|
|
|
+ <CheckCircle className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <XCircle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div className={restoreResult.success ? 'text-green-200' : 'text-red-200'}>
|
|
|
|
|
+ {restoreResult.message}
|
|
|
|
|
+ {restoreResult.success && (
|
|
|
|
|
+ <div className="mt-2">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => window.location.reload()}
|
|
|
|
|
+ >
|
|
|
|
|
+ <RotateCcw className="w-3 h-3" />
|
|
|
|
|
+ Reload Now
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Warning */}
|
|
|
|
|
+ <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
|
|
|
|
|
+ <div className="flex items-start gap-2 text-sm">
|
|
|
|
|
+ <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
|
|
|
|
|
+ <div className="text-yellow-200">
|
|
|
|
|
+ <span className="font-medium">Restore replaces all data.</span>{' '}
|
|
|
|
|
+ <span className="text-yellow-200/70">Your current database and files will be completely replaced. A restart is required after restore.</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
</CardContent>
|
|
</CardContent>
|
|
|
</Card>
|
|
</Card>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Modals */}
|
|
|
|
|
- {showBackupModal && (
|
|
|
|
|
- <BackupModal
|
|
|
|
|
- onClose={() => setShowBackupModal(false)}
|
|
|
|
|
- onExport={async (categories) => {
|
|
|
|
|
- setShowBackupModal(false);
|
|
|
|
|
|
|
+ {/* Restore Confirmation Modal */}
|
|
|
|
|
+ {showRestoreConfirm && restoreFile && (
|
|
|
|
|
+ <ConfirmModal
|
|
|
|
|
+ title="Restore Backup"
|
|
|
|
|
+ message={`Are you sure you want to restore from "${restoreFile.name}"? This will completely replace your current database and all files. The application will need to be restarted after restore.`}
|
|
|
|
|
+ confirmText="Restore Backup"
|
|
|
|
|
+ variant="danger"
|
|
|
|
|
+ onConfirm={async () => {
|
|
|
|
|
+ setShowRestoreConfirm(false);
|
|
|
|
|
+ setIsRestoring(true);
|
|
|
|
|
+ setRestoreResult(null);
|
|
|
try {
|
|
try {
|
|
|
- const { blob, filename } = await api.exportBackup(categories);
|
|
|
|
|
- const url = URL.createObjectURL(blob);
|
|
|
|
|
- const a = document.createElement('a');
|
|
|
|
|
- a.href = url;
|
|
|
|
|
- a.download = filename;
|
|
|
|
|
- a.click();
|
|
|
|
|
- URL.revokeObjectURL(url);
|
|
|
|
|
- showToast('Backup downloaded successfully');
|
|
|
|
|
- } catch {
|
|
|
|
|
- showToast('Failed to create backup', 'error');
|
|
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }}
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {showRestoreModal && (
|
|
|
|
|
- <RestoreModal
|
|
|
|
|
- onClose={() => setShowRestoreModal(false)}
|
|
|
|
|
- onRestore={async (file, overwrite) => {
|
|
|
|
|
- return await api.importBackup(file, overwrite);
|
|
|
|
|
- }}
|
|
|
|
|
- onSuccess={() => {
|
|
|
|
|
- setShowRestoreModal(false);
|
|
|
|
|
- showToast('Backup restored successfully');
|
|
|
|
|
- queryClient.invalidateQueries();
|
|
|
|
|
- }}
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ {/* Blocking overlay during backup/restore operations */}
|
|
|
|
|
+ {(isExporting || isRestoring) && (
|
|
|
|
|
+ <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[100]">
|
|
|
|
|
+ <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-8 max-w-md w-full mx-4 text-center">
|
|
|
|
|
+ <div className="flex justify-center mb-4">
|
|
|
|
|
+ <div className="relative">
|
|
|
|
|
+ <div className="w-16 h-16 border-4 border-bambu-dark-tertiary rounded-full"></div>
|
|
|
|
|
+ <div className="w-16 h-16 border-4 border-bambu-green border-t-transparent rounded-full absolute inset-0 animate-spin"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <h3 className="text-xl font-semibold text-white mb-2">
|
|
|
|
|
+ {isExporting ? 'Creating Backup' : 'Restoring Backup'}
|
|
|
|
|
+ </h3>
|
|
|
|
|
+ <p className="text-bambu-gray mb-4">
|
|
|
|
|
+ {operationStatus || (isExporting ? 'Preparing...' : 'Processing...')}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
|
|
|
|
|
+ <div className="flex items-start gap-2 text-sm">
|
|
|
|
|
+ <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
|
|
|
|
|
+ <p className="text-yellow-200 text-left">
|
|
|
|
|
+ Please do not close this page or navigate away. This operation may take several minutes for large backups.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|