import { useEffect, useRef, useState } from 'preact/hooks'; import ConfirmDialog from '@/components/ConfirmDialog'; import { type AdminBackupRunResponse, type AdminBackupSettings, type BackupDestinationRecord, type BackupDestinationType, type RemoteBackupBrowserResponse, } from '@/lib/api'; import { REMOTE_BROWSER_ITEMS_PER_PAGE, compareRemoteItems, createDraftBackupSettings, createDraftDestinationRecord, getDestinationById, getFirstVisibleDestinationId, getRemoteBrowserCacheKey, getVisibleDestinations, invalidateRemoteBrowserCacheForDestination, isReplaceRequiredError, loadPersistedRemoteBrowserState, persistRemoteBrowserState, } from '@/lib/backup-center'; import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations'; import { t } from '@/lib/i18n'; import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail'; import { BackupDestinationSidebar } from './backup-center/BackupDestinationSidebar'; import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar'; interface BackupCenterPageProps { currentUserId: string | null; onExport: () => Promise; onImport: (file: File, replaceExisting?: boolean) => Promise; onLoadSettings: () => Promise; onSaveSettings: (settings: AdminBackupSettings) => Promise; onRunRemoteBackup: (destinationId?: string | null) => Promise; onListRemoteBackups: (destinationId: string, path: string) => Promise; onDownloadRemoteBackup: (destinationId: string, path: string) => Promise; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise; onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; onNotify: (type: 'success' | 'error', text: string) => void; } export default function BackupCenterPage(props: BackupCenterPageProps) { const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId)); const persistedRemoteState = persistedRemoteStateRef.current; const fileInputRef = useRef(null); const [selectedFile, setSelectedFile] = useState(null); const [exporting, setExporting] = useState(false); const [importing, setImporting] = useState(false); const [loadingSettings, setLoadingSettings] = useState(true); const [savingSettings, setSavingSettings] = useState(false); const [runningRemoteBackup, setRunningRemoteBackup] = useState(false); const [loadingRemoteBrowser, setLoadingRemoteBrowser] = useState(false); const [downloadingRemotePath, setDownloadingRemotePath] = useState(''); const [restoringRemotePath, setRestoringRemotePath] = useState(''); const [deletingRemotePath, setDeletingRemotePath] = useState(''); const [localError, setLocalError] = useState(''); const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false); const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false); const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false); const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false); const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false); const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState(''); const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState(''); const [savedSettings, setSavedSettings] = useState(null); const [settings, setSettings] = useState(createDraftBackupSettings); const [selectedDestinationId, setSelectedDestinationId] = useState(persistedRemoteState.selectedDestinationId); const [selectedProviderId, setSelectedProviderId] = useState(null); const [remoteBrowserCache, setRemoteBrowserCache] = useState>(persistedRemoteState.cache); const [remoteBrowserPathByDestination, setRemoteBrowserPathByDestination] = useState>(persistedRemoteState.pathByDestination); const [remoteBrowserPageByKey, setRemoteBrowserPageByKey] = useState>(persistedRemoteState.pageByKey); const [showAddChooser, setShowAddChooser] = useState(false); const visibleDestinations = getVisibleDestinations(settings); const selectedDestination = getDestinationById(settings, selectedDestinationId); const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId); const selectedDestinationIsSaved = !!savedSelectedDestination; const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup; const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : ''; const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : ''; const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null; const remoteBrowserItems = remoteBrowser?.items || []; const remoteBrowserTotalPages = Math.max(1, Math.ceil(remoteBrowserItems.length / REMOTE_BROWSER_ITEMS_PER_PAGE)); const currentRemoteBrowserPage = Math.min(remoteBrowserPageByKey[currentRemoteBrowserKey] || 1, remoteBrowserTotalPages); const remoteBrowserVisibleItems = remoteBrowserItems.slice( (currentRemoteBrowserPage - 1) * REMOTE_BROWSER_ITEMS_PER_PAGE, currentRemoteBrowserPage * REMOTE_BROWSER_ITEMS_PER_PAGE ); const selectedRecommendedProvider = RECOMMENDED_PROVIDERS.find((provider) => provider.id === selectedProviderId) || null; const recommendedWebDavProviders = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 'webdav'); const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3'); const canRunSelectedDestination = !!selectedDestination && selectedDestination.type !== 'placeholder' && selectedDestinationIsSaved; const canBrowseSelectedDestination = !!savedSelectedDestination && savedSelectedDestination.type !== 'placeholder'; useEffect(() => { let cancelled = false; setLoadingSettings(true); void props.onLoadSettings() .then((loaded) => { if (cancelled) return; setSavedSettings(loaded); setSettings(loaded); const nextSelectedDestinationId = (persistedRemoteState.selectedDestinationId && getVisibleDestinations(loaded).some((destination) => destination.id === persistedRemoteState.selectedDestinationId) ? persistedRemoteState.selectedDestinationId : null) || getFirstVisibleDestinationId(loaded); setSelectedDestinationId(nextSelectedDestinationId); setLocalError(''); }) .catch((error) => { if (cancelled) return; const message = error instanceof Error ? error.message : t('txt_backup_settings_load_failed'); setLocalError(message); props.onNotify('error', message); }) .finally(() => { if (!cancelled) setLoadingSettings(false); }); return () => { cancelled = true; }; }, []); useEffect(() => { persistRemoteBrowserState(props.currentUserId, { cache: remoteBrowserCache, pathByDestination: remoteBrowserPathByDestination, pageByKey: remoteBrowserPageByKey, selectedDestinationId, }); }, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]); useEffect(() => { if (selectedDestination?.type === 'placeholder') { setSelectedDestinationId(getFirstVisibleDestinationId(settings)); } }, [selectedDestination?.id, selectedDestination?.type, settings]); function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) { setSettings((current) => { const next = mutator(current); if (selectedDestinationId && !next.destinations.some((destination) => destination.id === selectedDestinationId)) { setSelectedDestinationId(getFirstVisibleDestinationId(next)); } return next; }); } function updateSelectedDestination(mutator: (destination: BackupDestinationRecord) => BackupDestinationRecord) { if (!selectedDestinationId) return; updateSettings((current) => ({ ...current, destinations: current.destinations.map((destination) => ( destination.id === selectedDestinationId ? mutator(destination) : destination )), })); } async function loadRemoteBrowser(destinationId: string, path: string = '', options?: { force?: boolean }): Promise { const cacheKey = getRemoteBrowserCacheKey(destinationId, path); setRemoteBrowserPathByDestination((current) => ({ ...current, [destinationId]: path })); if (!options?.force && remoteBrowserCache[cacheKey]) return; setLoadingRemoteBrowser(true); try { const browser = await props.onListRemoteBackups(destinationId, path); const nextBrowser = { ...browser, items: browser.items.slice().sort(compareRemoteItems), }; setRemoteBrowserCache((current) => ({ ...current, [cacheKey]: nextBrowser })); setRemoteBrowserPageByKey((current) => ({ ...current, [cacheKey]: 1 })); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_remote_load_failed'); setLocalError(message); props.onNotify('error', message); } finally { setLoadingRemoteBrowser(false); } } function showRemoteBrowserPath(destinationId: string, path: string = ''): void { setRemoteBrowserPathByDestination((current) => ({ ...current, [destinationId]: path })); } function buildSettingsPayloadForSelectedDestination(): AdminBackupSettings { if (!selectedDestinationId || !selectedDestination) { return savedSettings || { destinations: [] }; } const persistedDestinations = (savedSettings?.destinations || []).filter((destination) => destination.id !== selectedDestinationId); return { destinations: [...persistedDestinations, selectedDestination], }; } function applySavedDestinationToDrafts(saved: AdminBackupSettings, destinationId: string | null) { if (!destinationId) { setSettings((current) => ({ destinations: current.destinations.filter((destination) => !savedSettings?.destinations.some((savedDestination) => savedDestination.id === destination.id)), })); return; } const savedDestination = getDestinationById(saved, destinationId); setSettings((current) => ({ destinations: current.destinations.map((destination) => ( destination.id === destinationId && savedDestination ? savedDestination : destination )), })); } function resetSelectedFile() { setSelectedFile(null); if (fileInputRef.current) fileInputRef.current.value = ''; } function handleAddDestination(type: BackupDestinationType) { updateSettings((current) => { const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1); setSelectedProviderId(null); setSelectedDestinationId(nextDestination.id); return { ...current, destinations: [...current.destinations, nextDestination], }; }); setShowAddChooser(false); } async function handleDeleteDestination() { if (!selectedDestinationId || savingSettings) return; const destinationIdToDelete = selectedDestinationId; const nextSettings: AdminBackupSettings = { destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete), }; setSavingSettings(true); setLocalError(''); try { const saved = await props.onSaveSettings(nextSettings); const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete); const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved); setSavedSettings(saved); setSettings({ destinations: nextDraftDestinations }); setRemoteBrowserCache((current) => invalidateRemoteBrowserCacheForDestination( destinationIdToDelete, current, remoteBrowserPathByDestination, remoteBrowserPageByKey ).cache); setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToDelete))); setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToDelete}:`)))); setSelectedDestinationId(nextSelected); setConfirmDeleteDestinationOpen(false); props.onNotify('success', t('txt_backup_destination_deleted')); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed'); setLocalError(message); props.onNotify('error', message); } finally { setSavingSettings(false); } } async function handleExport() { setLocalError(''); setExporting(true); try { await props.onExport(); props.onNotify('success', t('txt_backup_export_success')); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); setLocalError(message); props.onNotify('error', message); } finally { setExporting(false); } } async function runLocalRestore(replaceExisting: boolean) { if (!selectedFile) { const message = t('txt_backup_file_required'); setLocalError(message); props.onNotify('error', message); return; } setLocalError(''); setImporting(true); try { await props.onImport(selectedFile, replaceExisting); props.onNotify('success', t('txt_backup_restore_success_relogin')); resetSelectedFile(); setConfirmLocalRestoreOpen(false); setConfirmReplaceOpen(false); } catch (error) { if (!replaceExisting && isReplaceRequiredError(error)) { setConfirmLocalRestoreOpen(false); setConfirmReplaceOpen(true); return; } const message = error instanceof Error ? error.message : t('txt_backup_restore_failed'); setLocalError(message); props.onNotify('error', message); } finally { setImporting(false); } } async function handleSaveSettings() { const payload = buildSettingsPayloadForSelectedDestination(); const destinationIdToInvalidate = selectedDestinationId; setSavingSettings(true); setLocalError(''); try { const saved = await props.onSaveSettings(payload); const nextSelected = (selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId) || getFirstVisibleDestinationId(saved) || null; setSavedSettings(saved); applySavedDestinationToDrafts(saved, nextSelected); if (destinationIdToInvalidate) { setRemoteBrowserCache((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`)))); setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToInvalidate))); setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`)))); } setSelectedDestinationId(nextSelected); props.onNotify('success', t('txt_backup_settings_saved')); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed'); setLocalError(message); props.onNotify('error', message); } finally { setSavingSettings(false); } } function handleToggleSelectedSchedule() { if (!selectedDestination) return; updateSelectedDestination((destination) => ({ ...destination, schedule: { ...destination.schedule, enabled: !destination.schedule.enabled, }, })); } async function handleRunRemoteBackup() { if (!selectedDestination) return; setRunningRemoteBackup(true); setLocalError(''); try { const result = await props.onRunRemoteBackup(selectedDestination.id); setSavedSettings(result.settings); setSettings(result.settings); setSelectedDestinationId(selectedDestination.id); await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true }); props.onNotify('success', t('txt_backup_remote_run_success')); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed'); setLocalError(message); props.onNotify('error', message); } finally { setRunningRemoteBackup(false); } } async function handleDownloadRemote(path: string) { if (!savedSelectedDestination) return; setDownloadingRemotePath(path); setLocalError(''); try { await props.onDownloadRemoteBackup(savedSelectedDestination.id, path); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed'); setLocalError(message); props.onNotify('error', message); } finally { setDownloadingRemotePath(''); } } async function handleDeleteRemote(path: string) { if (!savedSelectedDestination) return; setDeletingRemotePath(path); setLocalError(''); try { await props.onDeleteRemoteBackup(savedSelectedDestination.id, path); setConfirmRemoteDeleteOpen(false); setPendingRemoteDeletePath(''); await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true }); props.onNotify('success', t('txt_backup_remote_delete_success')); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed'); setLocalError(message); props.onNotify('error', message); } finally { setDeletingRemotePath(''); } } async function runRemoteRestore(path: string, replaceExisting: boolean) { if (!savedSelectedDestination) return; setRestoringRemotePath(path); setLocalError(''); try { await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); setConfirmRemoteReplaceOpen(false); setPendingRemoteRestorePath(''); props.onNotify('success', t('txt_backup_restore_success_relogin')); } catch (error) { if (!replaceExisting && isReplaceRequiredError(error)) { setPendingRemoteRestorePath(path); setConfirmRemoteReplaceOpen(true); return; } const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed'); setLocalError(message); props.onNotify('error', message); } finally { setRestoringRemotePath(''); } } return (
{ const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null; setSelectedFile(nextFile); setLocalError(''); if (nextFile) setConfirmLocalRestoreOpen(true); }} /> void handleExport()} onImport={() => fileInputRef.current?.click()} onSelectProvider={(providerId) => setSelectedProviderId(providerId)} /> { setSelectedProviderId(null); setSelectedDestinationId(destinationId); }} onToggleAddChooser={() => setShowAddChooser((current) => !current)} onAddDestination={handleAddDestination} /> void handleSaveSettings()} onToggleSchedule={handleToggleSelectedSchedule} onRunRemoteBackup={() => void handleRunRemoteBackup()} onPromptDeleteDestination={() => setConfirmDeleteDestinationOpen(true)} onUpdateDestination={updateSelectedDestination} onRefreshRemoteBrowser={() => { if (savedSelectedDestination) { void loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true }); } }} onShowRemoteBrowserPath={(path) => { if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path); }} onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)} onRestoreRemoteBackup={(path) => void runRemoteRestore(path, false)} onPromptDeleteRemoteBackup={(path) => { setPendingRemoteDeletePath(path); setConfirmRemoteDeleteOpen(true); }} onChangeRemoteBrowserPage={(page) => { if (!currentRemoteBrowserKey) return; setRemoteBrowserPageByKey((current) => ({ ...current, [currentRemoteBrowserKey]: page })); }} /> {localError ?
{localError}
: null} void runLocalRestore(false)} onCancel={() => { setConfirmLocalRestoreOpen(false); resetSelectedFile(); }} /> void runLocalRestore(true)} onCancel={() => { setConfirmReplaceOpen(false); resetSelectedFile(); }} /> void runRemoteRestore(pendingRemoteRestorePath, true)} onCancel={() => { setConfirmRemoteReplaceOpen(false); setPendingRemoteRestorePath(''); }} /> void handleDeleteRemote(pendingRemoteDeletePath)} onCancel={() => { if (deletingRemotePath) return; setConfirmRemoteDeleteOpen(false); setPendingRemoteDeletePath(''); }} /> void handleDeleteDestination()} onCancel={() => { if (savingSettings) return; setConfirmDeleteDestinationOpen(false); }} />
); }