mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add backup recommendations and update backup strategy UI
- Introduced new backup recommendations feature with interfaces for recommended storage providers. - Updated i18n translations for backup strategy to reflect new terminology and improved descriptions. - Enhanced types with optional private and public keys in user profiles. - Redesigned backup-related styles for better layout and responsiveness. - Updated TypeScript configuration to include shared modules. - Configured Vite to resolve shared modules and allow filesystem access. - Added cron triggers for periodic tasks in Wrangler configuration.
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
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 {
|
||||
onExport: () => Promise<void>;
|
||||
onImport: (file: File, replaceExisting?: boolean) => Promise<void>;
|
||||
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
onDownloadRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState());
|
||||
const persistedRemoteState = persistedRemoteStateRef.current;
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(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<AdminBackupSettings | null>(null);
|
||||
const [settings, setSettings] = useState<AdminBackupSettings>(createDraftBackupSettings);
|
||||
const [selectedDestinationId, setSelectedDestinationId] = useState<string | null>(persistedRemoteState.selectedDestinationId);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
|
||||
const [remoteBrowserCache, setRemoteBrowserCache] = useState<Record<string, RemoteBackupBrowserResponse>>(persistedRemoteState.cache);
|
||||
const [remoteBrowserPathByDestination, setRemoteBrowserPathByDestination] = useState<Record<string, string>>(persistedRemoteState.pathByDestination);
|
||||
const [remoteBrowserPageByKey, setRemoteBrowserPageByKey] = useState<Record<string, number>>(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({
|
||||
cache: remoteBrowserCache,
|
||||
pathByDestination: remoteBrowserPathByDestination,
|
||||
pageByKey: remoteBrowserPageByKey,
|
||||
selectedDestinationId,
|
||||
});
|
||||
}, [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<void> {
|
||||
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 (
|
||||
<div className="backup-grid">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
hidden
|
||||
accept=".zip,application/zip"
|
||||
disabled={disableWhileBusy}
|
||||
onChange={(event) => {
|
||||
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
||||
setSelectedFile(nextFile);
|
||||
setLocalError('');
|
||||
if (nextFile) setConfirmLocalRestoreOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<BackupOperationsSidebar
|
||||
disableWhileBusy={disableWhileBusy}
|
||||
exporting={exporting}
|
||||
importing={importing}
|
||||
selectedProviderId={selectedProviderId}
|
||||
recommendedWebDavProviders={recommendedWebDavProviders}
|
||||
recommendedS3Providers={recommendedS3Providers}
|
||||
onExport={() => void handleExport()}
|
||||
onImport={() => fileInputRef.current?.click()}
|
||||
onSelectProvider={(providerId) => setSelectedProviderId(providerId)}
|
||||
/>
|
||||
|
||||
<BackupDestinationSidebar
|
||||
destinations={visibleDestinations}
|
||||
selectedDestinationId={selectedDestinationId}
|
||||
disableWhileBusy={disableWhileBusy}
|
||||
showAddChooser={showAddChooser}
|
||||
onSelectDestination={(destinationId) => {
|
||||
setSelectedProviderId(null);
|
||||
setSelectedDestinationId(destinationId);
|
||||
}}
|
||||
onToggleAddChooser={() => setShowAddChooser((current) => !current)}
|
||||
onAddDestination={handleAddDestination}
|
||||
/>
|
||||
|
||||
<BackupDestinationDetail
|
||||
selectedRecommendedProvider={selectedRecommendedProvider}
|
||||
selectedDestination={selectedDestination}
|
||||
selectedDestinationIsSaved={selectedDestinationIsSaved}
|
||||
canRunSelectedDestination={canRunSelectedDestination}
|
||||
canBrowseSelectedDestination={canBrowseSelectedDestination}
|
||||
disableWhileBusy={disableWhileBusy}
|
||||
loadingSettings={loadingSettings}
|
||||
savingSettings={savingSettings}
|
||||
runningRemoteBackup={runningRemoteBackup}
|
||||
availableTimeZones={selectedDestination?.schedule.timezone ? [selectedDestination.schedule.timezone] : []}
|
||||
remoteBrowser={remoteBrowser}
|
||||
remoteBrowserVisibleItems={remoteBrowserVisibleItems}
|
||||
remoteBrowserCurrentPage={currentRemoteBrowserPage}
|
||||
remoteBrowserTotalPages={remoteBrowserTotalPages}
|
||||
loadingRemoteBrowser={loadingRemoteBrowser}
|
||||
downloadingRemotePath={downloadingRemotePath}
|
||||
restoringRemotePath={restoringRemotePath}
|
||||
deletingRemotePath={deletingRemotePath}
|
||||
onSaveSettings={() => 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 ? <div className="local-error">{localError}</div> : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmLocalRestoreOpen}
|
||||
title={t('txt_backup_import')}
|
||||
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
|
||||
confirmText={t('txt_backup_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={() => void runLocalRestore(false)}
|
||||
onCancel={() => {
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
resetSelectedFile();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmReplaceOpen}
|
||||
title={t('txt_backup_replace_confirm_title')}
|
||||
message={t('txt_backup_replace_confirm_message')}
|
||||
confirmText={t('txt_backup_clear_and_restore')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={() => void runLocalRestore(true)}
|
||||
onCancel={() => {
|
||||
setConfirmReplaceOpen(false);
|
||||
resetSelectedFile();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmRemoteReplaceOpen}
|
||||
title={t('txt_backup_replace_confirm_title')}
|
||||
message={t('txt_backup_replace_confirm_message')}
|
||||
confirmText={t('txt_backup_clear_and_restore')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={() => void runRemoteRestore(pendingRemoteRestorePath, true)}
|
||||
onCancel={() => {
|
||||
setConfirmRemoteReplaceOpen(false);
|
||||
setPendingRemoteRestorePath('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmRemoteDeleteOpen}
|
||||
title={t('txt_delete')}
|
||||
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
|
||||
onCancel={() => {
|
||||
if (deletingRemotePath) return;
|
||||
setConfirmRemoteDeleteOpen(false);
|
||||
setPendingRemoteDeletePath('');
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteDestinationOpen}
|
||||
title={t('txt_delete')}
|
||||
message={t('txt_backup_delete_destination_confirm_message', {
|
||||
name: selectedDestination?.name || t('txt_backup_delete_destination'),
|
||||
})}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={() => void handleDeleteDestination()}
|
||||
onCancel={() => {
|
||||
if (savingSettings) return;
|
||||
setConfirmDeleteDestinationOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface HelpPageProps {
|
||||
onExport: () => Promise<void>;
|
||||
onImport: (file: File, replaceExisting?: boolean) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
export default function HelpPage(props: HelpPageProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
|
||||
|
||||
function isReplaceRequiredError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? String(error.message || '') : '';
|
||||
return message.toLowerCase().includes('fresh instance');
|
||||
}
|
||||
|
||||
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 runImport(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_import_success_relogin'));
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setConfirmReplaceOpen(false);
|
||||
} catch (error) {
|
||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||
setConfirmReplaceOpen(true);
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_import_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
await runImport(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack backup-page">
|
||||
<div className="import-export-panels">
|
||||
<section className="card backup-panel">
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_export')}</h3>
|
||||
</div>
|
||||
<p className="backup-inline-note">{t('txt_backup_export_description')}</p>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={exporting || importing} onClick={() => void handleExport()}>
|
||||
<Download size={14} className="btn-icon" />
|
||||
{exporting ? t('txt_backup_exporting') : t('txt_backup_export')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card backup-panel">
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_import')}</h3>
|
||||
</div>
|
||||
<p className="backup-inline-note">{t('txt_backup_import_description')}</p>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_file')}</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
className="input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
disabled={importing || exporting}
|
||||
onChange={(event) => {
|
||||
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
||||
setSelectedFile(nextFile);
|
||||
setLocalError('');
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="backup-file-meta">
|
||||
{selectedFile ? (
|
||||
<span>{t('txt_backup_selected_file_name', { name: selectedFile.name })}</span>
|
||||
) : (
|
||||
<span>{t('txt_backup_no_file_selected')}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="backup-inline-note">{t('txt_backup_restore_note')}</p>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={importing || exporting} onClick={() => void handleImport()}>
|
||||
<FileUp size={14} className="btn-icon" />
|
||||
{importing ? t('txt_backup_importing') : t('txt_backup_import')}
|
||||
</button>
|
||||
</div>
|
||||
{localError && <div className="local-error">{localError}</div>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmReplaceOpen}
|
||||
title={t('txt_backup_replace_confirm_title')}
|
||||
message={t('txt_backup_replace_confirm_message')}
|
||||
confirmText={t('txt_backup_clear_and_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={() => void runImport(true)}
|
||||
onCancel={() => setConfirmReplaceOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import { CloudUpload, Save, Trash2 } from 'lucide-preact';
|
||||
import type {
|
||||
BackupDestinationRecord,
|
||||
E3BackupDestination,
|
||||
RemoteBackupBrowserResponse,
|
||||
WebDavBackupDestination,
|
||||
} from '@/lib/api';
|
||||
import { COMMON_TIME_ZONES, WEEKDAY_OPTIONS, getDestinationTypeLabel } from '@/lib/backup-center';
|
||||
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||
import { RemoteBackupBrowser } from './RemoteBackupBrowser';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface BackupDestinationDetailProps {
|
||||
selectedRecommendedProvider: RecommendedProvider | null;
|
||||
selectedDestination: BackupDestinationRecord | null;
|
||||
selectedDestinationIsSaved: boolean;
|
||||
canRunSelectedDestination: boolean;
|
||||
canBrowseSelectedDestination: boolean;
|
||||
disableWhileBusy: boolean;
|
||||
loadingSettings: boolean;
|
||||
savingSettings: boolean;
|
||||
runningRemoteBackup: boolean;
|
||||
availableTimeZones: string[];
|
||||
remoteBrowser: RemoteBackupBrowserResponse | null;
|
||||
remoteBrowserVisibleItems: RemoteBackupBrowserResponse['items'];
|
||||
remoteBrowserCurrentPage: number;
|
||||
remoteBrowserTotalPages: number;
|
||||
loadingRemoteBrowser: boolean;
|
||||
downloadingRemotePath: string;
|
||||
restoringRemotePath: string;
|
||||
deletingRemotePath: string;
|
||||
onSaveSettings: () => void;
|
||||
onToggleSchedule: () => void;
|
||||
onRunRemoteBackup: () => void;
|
||||
onPromptDeleteDestination: () => void;
|
||||
onUpdateDestination: (mutator: (destination: BackupDestinationRecord) => BackupDestinationRecord) => void;
|
||||
onRefreshRemoteBrowser: () => void;
|
||||
onShowRemoteBrowserPath: (path: string) => void;
|
||||
onDownloadRemoteBackup: (path: string) => void;
|
||||
onRestoreRemoteBackup: (path: string) => void;
|
||||
onPromptDeleteRemoteBackup: (path: string) => void;
|
||||
onChangeRemoteBrowserPage: (page: number) => void;
|
||||
}
|
||||
|
||||
function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
||||
switch (provider.id) {
|
||||
case 'koofr':
|
||||
return (
|
||||
<>
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
|
||||
<a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
|
||||
{t('txt_backup_recommend_koofr_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_3')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_4')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>5.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
|
||||
<a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
|
||||
{t('txt_backup_recommend_koofr_step_5_suffix')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="backup-recommendation-inline-note">{t('txt_backup_recommend_koofr_dav_intro')}</div>
|
||||
<div className="backup-recommendation-dav-list">
|
||||
<div className="backup-recommendation-dav-item">
|
||||
<strong>{t('txt_backup_recommend_koofr_dav_self')}</strong>
|
||||
<code>https://app.koofr.net/dav/Koofr</code>
|
||||
</div>
|
||||
<div className="backup-recommendation-dav-item">
|
||||
<strong>Google Drive</strong>
|
||||
<code>https://app.koofr.net/dav/Google Drive</code>
|
||||
</div>
|
||||
<div className="backup-recommendation-dav-item">
|
||||
<strong>OneDrive</strong>
|
||||
<code>https://app.koofr.net/dav/OneDrive</code>
|
||||
</div>
|
||||
<div className="backup-recommendation-dav-item">
|
||||
<strong>Dropbox</strong>
|
||||
<code>https://app.koofr.net/dav/Dropbox</code>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'pcloud':
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_1')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'infinicloud':
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
|
||||
<a href="https://infini-cloud.net/en/modules/mypage/usage/" target="_blank" rel="noreferrer">My Page</a>
|
||||
{t('txt_backup_recommend_infinicloud_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
const timeZones = Array.from(new Set([
|
||||
...COMMON_TIME_ZONES,
|
||||
...props.availableTimeZones,
|
||||
]));
|
||||
|
||||
if (props.selectedRecommendedProvider) {
|
||||
return (
|
||||
<section className="backup-detail-panel">
|
||||
<div className="backup-recommendation-card">
|
||||
<div className="backup-recommendation-header">
|
||||
<div>
|
||||
<strong>{props.selectedRecommendedProvider.name}</strong>
|
||||
<div className="backup-inline-note">
|
||||
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
|
||||
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary')
|
||||
: t('txt_backup_recommend_pcloud_summary')}
|
||||
</div>
|
||||
</div>
|
||||
<span className="backup-destination-type">{props.selectedRecommendedProvider.capacity}</span>
|
||||
</div>
|
||||
<div className="backup-recommendation-actions">
|
||||
<a className="btn btn-primary small" href={props.selectedRecommendedProvider.signupUrl} target="_blank" rel="noreferrer">
|
||||
{props.selectedRecommendedProvider.hasAffiliateLink ? t('txt_backup_recommend_open_signup_aff') : t('txt_backup_recommend_open_signup')}
|
||||
</a>
|
||||
</div>
|
||||
{renderRecommendedProviderDetails(props.selectedRecommendedProvider)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="backup-detail-panel">
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_destination_detail_title')}</h3>
|
||||
{props.selectedDestination ? (
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onSaveSettings}>
|
||||
<Save size={14} className="btn-icon" />
|
||||
{props.savingSettings ? t('txt_backup_saving') : t('txt_backup_save_settings')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onToggleSchedule}>
|
||||
{props.selectedDestination.schedule.enabled ? t('txt_backup_disable_action') : t('txt_backup_enable_action')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || !props.canRunSelectedDestination} onClick={props.onRunRemoteBackup}>
|
||||
<CloudUpload size={14} className="btn-icon" />
|
||||
{props.runningRemoteBackup ? t('txt_backup_running_now') : t('txt_backup_run_manual')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onPromptDeleteDestination}>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_backup_delete_destination')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!props.selectedDestination ? (
|
||||
<div className="backup-browser-empty">{t('txt_backup_select_destination')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="backup-name-row">
|
||||
<label className="field backup-name-field">
|
||||
<span>{t('txt_backup_destination_name')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={props.selectedDestination.name}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({ ...destination, name: (event.currentTarget as HTMLInputElement).value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field backup-type-field">
|
||||
<span>{t('txt_backup_type')}</span>
|
||||
<input className="input" value={getDestinationTypeLabel(props.selectedDestination.type)} disabled />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="field-grid backup-detail-schedule-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_frequency')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={props.selectedDestination.schedule.frequency}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
frequency: (event.currentTarget as HTMLSelectElement).value as 'daily' | 'weekly' | 'monthly',
|
||||
dayOfWeek: destination.schedule.dayOfWeek ?? 1,
|
||||
dayOfMonth: destination.schedule.dayOfMonth ?? 1,
|
||||
},
|
||||
}))}
|
||||
>
|
||||
<option value="daily">{t('txt_backup_frequency_daily')}</option>
|
||||
<option value="weekly">{t('txt_backup_frequency_weekly')}</option>
|
||||
<option value="monthly">{t('txt_backup_frequency_monthly')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_time')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="time"
|
||||
value={props.selectedDestination.schedule.scheduleTime}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
scheduleTime: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_timezone')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={props.selectedDestination.schedule.timezone}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
timezone: (event.currentTarget as HTMLSelectElement).value,
|
||||
},
|
||||
}))}
|
||||
>
|
||||
{timeZones.map((timezone) => (
|
||||
<option key={timezone} value={timezone}>{timezone}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_retention_count')}</span>
|
||||
<div className="backup-retention-input">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={props.selectedDestination.schedule.retentionCount === null ? '' : String(props.selectedDestination.schedule.retentionCount)}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="30"
|
||||
onInput={(event) => {
|
||||
const nextValue = (event.currentTarget as HTMLInputElement).value.trim();
|
||||
props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
retentionCount: nextValue ? Number(nextValue) : null,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<span className="backup-retention-suffix">{t('txt_backup_retention_count_suffix')}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{props.selectedDestination.schedule.frequency === 'weekly' ? (
|
||||
<div className="field-grid backup-detail-schedule-extra-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_day_of_week')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={String(props.selectedDestination.schedule.dayOfWeek)}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
dayOfWeek: Number((event.currentTarget as HTMLSelectElement).value),
|
||||
},
|
||||
}))}
|
||||
>
|
||||
{WEEKDAY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={String(option.value)}>{t(option.label)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.selectedDestination.schedule.frequency === 'monthly' ? (
|
||||
<div className="field-grid backup-detail-schedule-extra-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_day_of_month')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
step="1"
|
||||
value={String(props.selectedDestination.schedule.dayOfMonth || 1)}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
dayOfMonth: Math.min(31, Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1)),
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.selectedDestination.type === 'webdav' ? (
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_backup_webdav_url')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as WebDavBackupDestination).baseUrl}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="https://dav.example.com/remote.php/dav/files/admin"
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as WebDavBackupDestination),
|
||||
baseUrl: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_webdav_username')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as WebDavBackupDestination).username}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as WebDavBackupDestination),
|
||||
username: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_webdav_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={(props.selectedDestination.destination as WebDavBackupDestination).password}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as WebDavBackupDestination),
|
||||
password: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_backup_webdav_path')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="nodewarden/backups"
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as WebDavBackupDestination),
|
||||
remotePath: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.selectedDestination.type === 'e3' ? (
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_backup_e3_endpoint')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).endpoint}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="https://s3.example.com"
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
endpoint: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_e3_bucket')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).bucket}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
bucket: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_e3_region')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).region}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="auto"
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
region: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_e3_access_key')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).accessKeyId}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
accessKeyId: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_e3_secret_key')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).secretAccessKey}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
secretAccessKey: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_backup_e3_path')}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as E3BackupDestination).rootPath}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="nodewarden/backups"
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as E3BackupDestination),
|
||||
rootPath: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RemoteBackupBrowser
|
||||
canBrowse={props.canBrowseSelectedDestination}
|
||||
destinationIsSaved={props.selectedDestinationIsSaved}
|
||||
disableWhileBusy={props.disableWhileBusy}
|
||||
loadingRemoteBrowser={props.loadingRemoteBrowser}
|
||||
remoteBrowser={props.remoteBrowser}
|
||||
visibleItems={props.remoteBrowserVisibleItems}
|
||||
currentPage={props.remoteBrowserCurrentPage}
|
||||
totalPages={props.remoteBrowserTotalPages}
|
||||
downloadingRemotePath={props.downloadingRemotePath}
|
||||
restoringRemotePath={props.restoringRemotePath}
|
||||
deletingRemotePath={props.deletingRemotePath}
|
||||
onRefresh={props.onRefreshRemoteBrowser}
|
||||
onShowPath={props.onShowRemoteBrowserPath}
|
||||
onDownload={props.onDownloadRemoteBackup}
|
||||
onRestore={props.onRestoreRemoteBackup}
|
||||
onPromptDelete={props.onPromptDeleteRemoteBackup}
|
||||
onChangePage={props.onChangeRemoteBrowserPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Plus } from 'lucide-preact';
|
||||
import type { BackupDestinationRecord, BackupDestinationType } from '@/lib/api';
|
||||
import { formatDateTime, getDestinationTypeLabel } from '@/lib/backup-center';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface BackupDestinationSidebarProps {
|
||||
destinations: BackupDestinationRecord[];
|
||||
selectedDestinationId: string | null;
|
||||
disableWhileBusy: boolean;
|
||||
showAddChooser: boolean;
|
||||
onSelectDestination: (destinationId: string) => void;
|
||||
onToggleAddChooser: () => void;
|
||||
onAddDestination: (type: BackupDestinationType) => void;
|
||||
}
|
||||
|
||||
export function BackupDestinationSidebar(props: BackupDestinationSidebarProps) {
|
||||
return (
|
||||
<aside className="backup-destination-sidebar">
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_destinations_title')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="backup-destination-list">
|
||||
{props.destinations.map((destination) => {
|
||||
const isSelected = destination.id === props.selectedDestinationId;
|
||||
const isScheduled = destination.schedule.enabled;
|
||||
return (
|
||||
<button
|
||||
key={destination.id}
|
||||
type="button"
|
||||
className={`backup-destination-item ${isSelected ? 'active' : ''}`}
|
||||
onClick={() => props.onSelectDestination(destination.id)}
|
||||
>
|
||||
<span className="backup-destination-top">
|
||||
<span className="backup-destination-name">{destination.name || getDestinationTypeLabel(destination.type)}</span>
|
||||
<span className="backup-destination-type">{getDestinationTypeLabel(destination.type)}</span>
|
||||
</span>
|
||||
<span className="backup-destination-meta">
|
||||
{isScheduled ? t('txt_backup_destination_active_badge') : t('txt_backup_destination_idle_badge')}
|
||||
</span>
|
||||
<span className="backup-destination-meta">
|
||||
{destination.runtime.lastSuccessAt
|
||||
? t('txt_backup_destination_last_success', { time: formatDateTime(destination.runtime.lastSuccessAt) })
|
||||
: t('txt_backup_destination_never_run')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="actions backup-destination-addbar">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy} onClick={props.onToggleAddChooser}>
|
||||
<Plus size={14} className="btn-icon" />
|
||||
{t('txt_backup_add_destination')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{props.showAddChooser ? (
|
||||
<div className="backup-add-chooser">
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('webdav')}>
|
||||
{t('txt_backup_protocol_webdav')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('e3')}>
|
||||
{t('txt_backup_protocol_e3')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||
import { hasLinkedStorages } from '@/lib/backup-recommendations';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface BackupOperationsSidebarProps {
|
||||
disableWhileBusy: boolean;
|
||||
exporting: boolean;
|
||||
importing: boolean;
|
||||
selectedProviderId: string | null;
|
||||
recommendedWebDavProviders: RecommendedProvider[];
|
||||
recommendedS3Providers: RecommendedProvider[];
|
||||
onExport: () => void;
|
||||
onImport: () => void;
|
||||
onSelectProvider: (providerId: string) => void;
|
||||
}
|
||||
|
||||
export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
||||
return (
|
||||
<aside className="backup-operations-sidebar">
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_manual')}</h3>
|
||||
</div>
|
||||
<div className="backup-actions-stack">
|
||||
<button type="button" className="btn btn-primary" disabled={props.disableWhileBusy} onClick={props.onExport}>
|
||||
<Download size={14} className="btn-icon" />
|
||||
{props.exporting ? t('txt_backup_exporting') : t('txt_backup_export')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={props.disableWhileBusy} onClick={props.onImport}>
|
||||
<FileUp size={14} className="btn-icon" />
|
||||
{props.importing ? t('txt_backup_restoring') : t('txt_backup_import')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="backup-divider" />
|
||||
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_recommend_title')}</h3>
|
||||
</div>
|
||||
<div className="backup-recommendation-group">
|
||||
<h4 className="backup-recommendation-group-title">{t('txt_backup_recommend_group_webdav')}</h4>
|
||||
<div className="backup-recommendation-list">
|
||||
{props.recommendedWebDavProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
className={`backup-destination-item ${props.selectedProviderId === provider.id ? 'active' : ''}`}
|
||||
onClick={() => props.onSelectProvider(provider.id)}
|
||||
>
|
||||
<span className="backup-recommendation-row">
|
||||
<span className="backup-destination-name">{provider.name}</span>
|
||||
<span className="backup-destination-meta">{provider.capacity}</span>
|
||||
</span>
|
||||
{hasLinkedStorages(provider) && provider.linkedStorages.length ? (
|
||||
<span className="backup-recommendation-linked">
|
||||
{provider.linkedStorages.map((storage) => (
|
||||
<span key={`${provider.id}-${storage.name}`} className="backup-recommendation-linked-item">
|
||||
<span>{storage.name}</span>
|
||||
<span>{storage.capacity}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="backup-recommendation-group">
|
||||
<h4 className="backup-recommendation-group-title">{t('txt_backup_recommend_group_s3')}</h4>
|
||||
{props.recommendedS3Providers.length ? (
|
||||
<div className="backup-recommendation-list">
|
||||
{props.recommendedS3Providers.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
className={`backup-destination-item ${props.selectedProviderId === provider.id ? 'active' : ''}`}
|
||||
onClick={() => props.onSelectProvider(provider.id)}
|
||||
>
|
||||
<span className="backup-recommendation-row">
|
||||
<span className="backup-destination-name">{provider.name}</span>
|
||||
<span className="backup-destination-meta">{provider.capacity}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="backup-browser-empty">{t('txt_backup_recommend_empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact';
|
||||
import type { RemoteBackupBrowserResponse } from '@/lib/api';
|
||||
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface RemoteBackupBrowserProps {
|
||||
canBrowse: boolean;
|
||||
destinationIsSaved: boolean;
|
||||
disableWhileBusy: boolean;
|
||||
loadingRemoteBrowser: boolean;
|
||||
remoteBrowser: RemoteBackupBrowserResponse | null;
|
||||
visibleItems: RemoteBackupBrowserResponse['items'];
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
downloadingRemotePath: string;
|
||||
restoringRemotePath: string;
|
||||
deletingRemotePath: string;
|
||||
onRefresh: () => void;
|
||||
onShowPath: (path: string) => void;
|
||||
onDownload: (path: string) => void;
|
||||
onRestore: (path: string) => void;
|
||||
onPromptDelete: (path: string) => void;
|
||||
onChangePage: (page: number) => void;
|
||||
}
|
||||
|
||||
export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="backup-divider" />
|
||||
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_remote_title')}</h3>
|
||||
{props.canBrowse ? (
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_backup_remote_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!props.destinationIsSaved ? (
|
||||
<div className="backup-browser-empty">{t('txt_backup_remote_save_first')}</div>
|
||||
) : !props.remoteBrowser ? (
|
||||
<div className="backup-browser-empty">{t('txt_backup_remote_cached_empty')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="backup-browser-path">
|
||||
<strong>{t('txt_backup_remote_current_path')}</strong>
|
||||
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
|
||||
</div>
|
||||
|
||||
<div className="actions backup-browser-nav">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
|
||||
<FolderOpen size={14} className="btn-icon" />
|
||||
{t('txt_backup_remote_root')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
|
||||
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
|
||||
>
|
||||
<RotateCcw size={14} className="btn-icon" />
|
||||
{t('txt_backup_remote_up')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{props.loadingRemoteBrowser ? (
|
||||
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
|
||||
) : props.remoteBrowser.items.length ? (
|
||||
<>
|
||||
<div className="backup-browser-list">
|
||||
{props.visibleItems.map((item) => (
|
||||
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`backup-browser-entry ${item.isDirectory ? 'dir' : 'file'}`}
|
||||
onClick={() => {
|
||||
if (item.isDirectory) props.onShowPath(item.path);
|
||||
}}
|
||||
>
|
||||
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
|
||||
<span className="backup-browser-name">{item.name}</span>
|
||||
</button>
|
||||
<div className="backup-browser-meta">
|
||||
<span>{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}</span>
|
||||
<span>{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}</span>
|
||||
</div>
|
||||
<div className="actions backup-browser-actions">
|
||||
{item.isDirectory ? (
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
|
||||
<FolderOpen size={14} className="btn-icon" />
|
||||
{t('txt_backup_remote_open')}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || props.downloadingRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onDownload(item.path)}>
|
||||
<Download size={14} className="btn-icon" />
|
||||
{props.downloadingRemotePath === item.path ? t('txt_backup_remote_downloading') : t('txt_backup_remote_download')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary small" disabled={props.disableWhileBusy || props.restoringRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onRestore(item.path)}>
|
||||
<RotateCcw size={14} className="btn-icon" />
|
||||
{props.restoringRemotePath === item.path ? t('txt_backup_restoring') : t('txt_backup_remote_restore')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" disabled={props.disableWhileBusy || props.deletingRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onPromptDelete(item.path)}>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{props.deletingRemotePath === item.path ? t('txt_backup_remote_deleting') : t('txt_delete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{props.totalPages > 1 ? (
|
||||
<div className="backup-browser-pagination">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.currentPage <= 1} onClick={() => props.onChangePage(props.currentPage - 1)}>
|
||||
{t('txt_prev')}
|
||||
</button>
|
||||
<span className="backup-browser-page-indicator">
|
||||
{props.currentPage} / {props.totalPages}
|
||||
</span>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.currentPage >= props.totalPages} onClick={() => props.onChangePage(props.currentPage + 1)}>
|
||||
{t('txt_next')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="backup-browser-empty">{t('txt_backup_remote_empty')}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user