tududi/frontend/components/Backup/BackupRestore.tsx
Chris bf281b740d
Feat backups (#686)
* Scaffold backups

* Add FFlags

* fixup! Add FFlags

* fixup! fixup! Add FFlags

* fixup! fixup! fixup! Add FFlags
2025-12-09 08:00:46 +02:00

688 lines
39 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { useToast } from '../Shared/ToastContext';
import { useTranslation } from 'react-i18next';
import ConfirmDialog from '../Shared/ConfirmDialog';
import {
createBackup,
listSavedBackups,
downloadSavedBackup,
restoreSavedBackup,
deleteSavedBackup,
importBackup,
validateBackup,
ValidationResult,
SavedBackup,
} from '../../utils/backupService';
import {
ArrowDownTrayIcon,
ArrowUpTrayIcon,
DocumentCheckIcon,
TrashIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
interface BackupRestoreProps {
onImportSuccess?: () => void;
}
type TabType = 'export' | 'import';
interface ConfirmDialogState {
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
}
const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>('export');
const [isExporting, setIsExporting] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isLoadingBackups, setIsLoadingBackups] = useState(false);
const [savedBackups, setSavedBackups] = useState<SavedBackup[]>([]);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [validationResult, setValidationResult] =
useState<ValidationResult | null>(null);
const [appVersion, setAppVersion] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState>({
isOpen: false,
title: '',
message: '',
onConfirm: () => {},
});
const { showSuccessToast, showErrorToast } = useToast();
// Load saved backups and app version on mount
useEffect(() => {
loadBackups();
fetchAppVersion();
}, []);
const fetchAppVersion = async () => {
try {
const response = await fetch('/api/version', {
credentials: 'include',
});
const data = await response.json();
setAppVersion(data.version);
} catch (error) {
console.error('Error fetching app version:', error);
}
};
const loadBackups = async () => {
setIsLoadingBackups(true);
try {
const backups = await listSavedBackups();
setSavedBackups(backups);
} catch (error) {
console.error('Error loading backups:', error);
} finally {
setIsLoadingBackups(false);
}
};
const handleCreateBackup = async () => {
setIsExporting(true);
try {
await createBackup();
showSuccessToast(
t('backup.exportSuccess', 'Backup created successfully!')
);
// Reload the backup list
await loadBackups();
} catch (error) {
console.error('Export error:', error);
showErrorToast(
t('backup.exportError', 'Failed to create backup')
);
} finally {
setIsExporting(false);
}
};
const handleDownloadBackup = async (backupUid: string) => {
try {
await downloadSavedBackup(backupUid);
showSuccessToast(
t('backup.downloadSuccess', 'Backup downloaded successfully!')
);
} catch (error) {
console.error('Download error:', error);
showErrorToast(
t('backup.downloadError', 'Failed to download backup')
);
}
};
const handleRestoreBackup = (backupUid: string) => {
setConfirmDialog({
isOpen: true,
title: t('backup.confirmRestore', 'Restore Backup'),
message: t(
'backup.confirmRestoreMessage',
'Are you sure you want to restore this backup? This will merge the backed up data with your current data.'
),
onConfirm: async () => {
setConfirmDialog({ ...confirmDialog, isOpen: false });
try {
const result = await restoreSavedBackup(backupUid, true);
showSuccessToast(
t('backup.restoreSuccess', {
tasks: result.stats.tasks.created,
projects: result.stats.projects.created,
notes: result.stats.notes.created,
})
);
if (onImportSuccess) {
onImportSuccess();
}
} catch (error) {
console.error('Restore error:', error);
showErrorToast(
t('backup.restoreError', 'Failed to restore backup')
);
}
},
});
};
const handleDeleteBackup = (backupUid: string) => {
setConfirmDialog({
isOpen: true,
title: t('backup.confirmDelete', 'Delete Backup'),
message: t(
'backup.confirmDeleteMessage',
'Are you sure you want to delete this backup? This action cannot be undone.'
),
onConfirm: async () => {
setConfirmDialog({ ...confirmDialog, isOpen: false });
try {
await deleteSavedBackup(backupUid);
showSuccessToast(
t('backup.deleteSuccess', 'Backup deleted successfully!')
);
// Reload the backup list
await loadBackups();
} catch (error) {
console.error('Delete error:', error);
showErrorToast(
t('backup.deleteError', 'Failed to delete backup')
);
}
},
});
};
const handleFileSelect = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (!file) return;
setSelectedFile(file);
setValidationResult(null);
// Auto-validate on file selection
setIsValidating(true);
try {
const result = await validateBackup(file);
setValidationResult(result);
if (!result.valid) {
showErrorToast(
t(
'backup.validationError',
'The selected file is not a valid backup'
)
);
}
} catch (error) {
console.error('Validation error:', error);
showErrorToast(
t('backup.validationError', 'Failed to validate backup file')
);
} finally {
setIsValidating(false);
}
};
const handleImport = async () => {
if (!selectedFile || !validationResult?.valid) return;
setIsImporting(true);
try {
const result = await importBackup(selectedFile, true);
showSuccessToast(
t('backup.importSuccess', {
tasks: result.stats.tasks.created,
projects: result.stats.projects.created,
notes: result.stats.notes.created,
})
);
// Reset file selection
setSelectedFile(null);
setValidationResult(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
if (onImportSuccess) {
onImportSuccess();
}
} catch (error) {
console.error('Import error:', error);
showErrorToast(
t('backup.importError', 'Failed to import backup')
);
} finally {
setIsImporting(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString();
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
};
return (
<>
{confirmDialog.isOpen && (
<ConfirmDialog
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
/>
)}
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{t('backup.title', 'Backup & Restore')}
</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{t(
'backup.description',
'Create backups or restore from previous backups. Your last 5 backups are automatically saved.'
)}
</p>
</div>
{/* Tabs */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<div className="border-b border-gray-200 dark:border-gray-700">
<div className="flex">
<button
onClick={() => setActiveTab('export')}
className={`flex-1 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'export'
? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/20'
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div className="flex items-center justify-center space-x-2">
<ArrowDownTrayIcon className="h-5 w-5" />
<span>{t('backup.createBackup', 'Create Backup')}</span>
</div>
</button>
<button
onClick={() => setActiveTab('import')}
className={`flex-1 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'import'
? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/20'
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div className="flex items-center justify-center space-x-2">
<ArrowUpTrayIcon className="h-5 w-5" />
<span>{t('backup.importFromFile', 'Import from File')}</span>
</div>
</button>
</div>
</div>
{/* Content */}
<div className="p-6 sm:p-8">
{activeTab === 'export' ? (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('backup.createNewBackup', 'Create New Backup')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t(
'backup.createDescription',
'Create a new backup of all your data. Backups are saved on the server and you can restore them later.'
)}
</p>
</div>
<button
onClick={handleCreateBackup}
disabled={isExporting}
className="w-full flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed text-base font-medium"
>
{isExporting ? (
<>
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('backup.creating', 'Creating backup...')}
</>
) : (
<>
<ArrowDownTrayIcon className="h-5 w-5 mr-2" />
{t('backup.createBackupNow', 'Create Backup Now')}
</>
)}
</button>
{/* Saved Backups Table */}
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
{t('backup.savedBackups', 'Saved Backups')}
</h3>
<button
onClick={loadBackups}
disabled={isLoadingBackups}
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center"
>
<ArrowPathIcon className={`h-4 w-4 mr-1 ${isLoadingBackups ? 'animate-spin' : ''}`} />
{t('common.refresh', 'Refresh')}
</button>
</div>
{isLoadingBackups ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
{t('common.loading', 'Loading...')}
</div>
) : savedBackups.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
{t('backup.noBackups', 'No backups found. Create your first backup above.')}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t('backup.createdAt', 'Created')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t('backup.version', 'Version')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t('backup.size', 'Size')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t('backup.contents', 'Contents')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t('backup.actions', 'Actions')}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{savedBackups.map((backup) => (
<tr key={backup.uid} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatDate(backup.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{backup.version}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{formatFileSize(backup.file_size)}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{backup.item_counts.tasks} tasks
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
{backup.item_counts.projects} projects
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
{backup.item_counts.notes} notes
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<button
onClick={() => handleRestoreBackup(backup.uid)}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
title={t('backup.restore', 'Restore')}
>
<ArrowPathIcon className="h-5 w-5" />
</button>
<button
onClick={() => handleDownloadBackup(backup.uid)}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
title={t('backup.download', 'Download')}
>
<ArrowDownTrayIcon className="h-5 w-5" />
</button>
<button
onClick={() => handleDeleteBackup(backup.uid)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title={t('backup.delete', 'Delete')}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
) : (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('backup.importTitle', 'Import from File')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t(
'backup.importDescription',
'Upload a backup file to restore your data. Your existing data will be preserved, and new items from the backup will be added.'
)}
</p>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
<h4 className="text-sm font-medium text-yellow-900 dark:text-yellow-200 mb-2">
{t('backup.importNote', 'Important:')}
</h4>
<p className="text-sm text-yellow-800 dark:text-yellow-300">
{t(
'backup.importNoteDescription',
'Import will merge data with your existing items. Duplicate items (same UID) will be skipped.'
)}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json,.gz,.json.gz,application/gzip"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center px-6 py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:border-blue-500 dark:hover:border-blue-400 hover:text-blue-600 dark:hover:text-blue-400 transition duration-150 ease-in-out"
>
<div className="text-center">
<ArrowUpTrayIcon className="h-12 w-12 mx-auto mb-2" />
<p className="text-base font-medium">
{t('backup.selectFile', 'Select Backup File')}
</p>
<p className="text-sm mt-1">
{t('backup.clickToUpload', 'Click to browse files')}
</p>
</div>
</button>
{selectedFile && (
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 border border-gray-200 dark:border-gray-600">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<p className="text-base font-medium text-gray-900 dark:text-white">
{selectedFile.name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{(selectedFile.size / 1024).toFixed(2)} KB
</p>
</div>
{isValidating && (
<svg
className="animate-spin h-6 w-6 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
{validationResult?.valid && (
<DocumentCheckIcon className="h-6 w-6 text-green-600" />
)}
</div>
{validationResult?.valid && validationResult.summary && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('backup.backupContents', 'Backup contents:')}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.tasks} tasks
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.projects} projects
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.notes} notes
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.tags} tags
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.areas} areas
</div>
<div className="flex items-center">
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
{validationResult.summary.views} views
</div>
</div>
</div>
)}
{validationResult && !validationResult.valid && (
<div className="mt-4 pt-4 border-t border-red-200 dark:border-red-800">
{validationResult.versionIncompatible ? (
<>
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
{t('backup.versionIncompatible', 'Version Incompatible')}
</p>
<p className="text-sm text-red-600 dark:text-red-400">
{validationResult.message}
</p>
<p className="text-sm text-red-600 dark:text-red-400 mt-2">
{t('backup.backupVersion', 'Backup version')}: {validationResult.backupVersion}
</p>
<p className="text-sm text-red-600 dark:text-red-400">
{t('backup.currentVersion', 'Current version')}: {appVersion}
</p>
</>
) : (
<>
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
{t('backup.validationErrors', 'Validation errors:')}
</p>
<ul className="text-sm text-red-600 dark:text-red-400 space-y-1">
{validationResult.errors?.map((error, index) => (
<li key={index}> {error}</li>
))}
</ul>
</>
)}
</div>
)}
</div>
)}
{selectedFile && validationResult?.valid && (
<button
onClick={handleImport}
disabled={isImporting}
className="w-full flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed text-base font-medium"
>
{isImporting ? (
<>
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('backup.importing', 'Importing...')}
</>
) : (
<>
<ArrowUpTrayIcon className="h-5 w-5 mr-2" />
{t('backup.restoreBackup', 'Restore Backup')}
</>
)}
</button>
)}
</div>
)}
</div>
</div>
</div>
</>
);
};
const CheckIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
);
export default BackupRestore;