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:
shuaiplus
2026-03-15 03:34:16 +08:00
parent 33323439cd
commit 05f1b2f9a8
29 changed files with 5662 additions and 951 deletions
+232 -176
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { lazy, Suspense } from 'preact/compat';
import { Link, Route, Switch, useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
@@ -10,11 +11,6 @@ import SendsPage from '@/components/SendsPage';
import PublicSendPage from '@/components/PublicSendPage';
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
import JwtWarningPage from '@/components/JwtWarningPage';
import SettingsPage from '@/components/SettingsPage';
import SecurityDevicesPage from '@/components/SecurityDevicesPage';
import AdminPage from '@/components/AdminPage';
import HelpPage from '@/components/HelpPage';
import ImportPage from '@/components/ImportPage';
import TotpCodesPage from '@/components/TotpCodesPage';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import {
@@ -25,6 +21,7 @@ import {
updateFolder,
deleteCipherAttachment,
deleteFolder,
deleteRemoteBackup,
bulkDeleteCiphers,
bulkPermanentDeleteCiphers,
bulkRestoreCiphers,
@@ -35,6 +32,9 @@ import {
downloadCipherAttachmentDecrypted,
encryptFolderImportName,
exportAdminBackup,
getAdminBackupSettingsRepairState,
getAdminBackupSettings,
downloadRemoteBackup,
importAdminBackup,
importCiphers,
createSend,
@@ -62,14 +62,19 @@ import {
loginWithPassword,
registerAccount,
recoverTwoFactor,
repairAdminBackupSettings,
revokeInvite,
revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust,
restoreRemoteBackup,
runAdminBackupNow,
saveSession,
saveAdminBackupSettings,
setTotp,
setUserStatus,
deleteAllAuthorizedDevices,
deleteAuthorizedDevice,
listRemoteBackups,
uploadCipherAttachment,
updateCipher,
updateSend,
@@ -78,6 +83,7 @@ import {
verifyMasterPassword,
type ImportedCipherMapEntry,
} from '@/lib/api';
import { decryptPortableBackupSettings } from '@/lib/admin-backup-portable';
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto';
import {
attachNodeWardenEncryptedAttachmentPayload,
@@ -96,6 +102,12 @@ import { t } from '@/lib/i18n';
import type { CiphersImportPayload } from '@/lib/api';
import type { AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
const AdminPage = lazy(() => import('@/components/AdminPage'));
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
const ImportPage = lazy(() => import('@/components/ImportPage'));
interface PendingTotp {
email: string;
passwordHash: string;
@@ -136,6 +148,10 @@ function readInviteCodeFromUrl(): string {
return '';
}
function RouteContentFallback() {
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
}
function summarizeImportResult(
ciphers: Array<Record<string, unknown>>,
folderCount: number,
@@ -435,6 +451,21 @@ export default function App() {
saveSession(next);
}
async function silentlyRepairBackupSettingsIfNeeded(activeSession: SessionState, activeProfile: Profile): Promise<void> {
if (activeProfile.role !== 'admin') return;
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
const tempFetch = createAuthedFetch(() => activeSession, () => {});
try {
const state = await getAdminBackupSettingsRepairState(tempFetch);
if (!state.needsRepair || !state.portable) return;
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
await repairAdminBackupSettings(tempFetch, repairedSettings);
} catch (error) {
console.error('Backup settings auto-repair failed:', error);
}
}
function pushToast(type: ToastMessage['type'], text: string) {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
setToasts((prev) => [...prev.slice(-3), { id, type, text }]);
@@ -528,6 +559,7 @@ export default function App() {
const nextSession = { ...baseSession, ...keys };
setSession(nextSession);
setProfile(profileResp);
await silentlyRepairBackupSettingsIfNeeded(nextSession, profileResp);
setPendingTotp(null);
setTotpCode('');
setPhase('app');
@@ -651,7 +683,9 @@ export default function App() {
try {
const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations);
const keys = await unlockVaultKey(profile.key, derived.masterKey);
setSession({ ...session, ...keys });
const nextSession = { ...session, ...keys };
setSession(nextSession);
await silentlyRepairBackupSettingsIfNeeded(nextSession, profile);
setUnlockPassword('');
setPhase('app');
if (location === '/' || location === '/lock') navigate('/vault');
@@ -1808,6 +1842,38 @@ export default function App() {
}, 200);
}
async function handleLoadBackupSettingsAction() {
return getAdminBackupSettings(authedFetch);
}
async function handleSaveBackupSettingsAction(settings: any) {
return saveAdminBackupSettings(authedFetch, settings);
}
async function handleRunRemoteBackupAction(destinationId?: string | null) {
return runAdminBackupNow(authedFetch, destinationId);
}
async function handleListRemoteBackupsAction(destinationId: string, path: string) {
return listRemoteBackups(authedFetch, destinationId, path);
}
async function handleDownloadRemoteBackupAction(destinationId: string, path: string) {
const payload = await downloadRemoteBackup(authedFetch, destinationId, path);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
}
async function handleDeleteRemoteBackupAction(destinationId: string, path: string) {
await deleteRemoteBackup(authedFetch, destinationId, path);
}
async function handleRestoreRemoteBackupAction(destinationId: string, path: string, replaceExisting: boolean = false) {
await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting);
window.setTimeout(() => {
logoutNow();
}, 200);
}
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
@@ -1841,6 +1907,19 @@ export default function App() {
return t('nav_my_vault');
})();
const importPageContent = (
<Suspense fallback={<RouteContentFallback />}>
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
</Suspense>
);
useEffect(() => {
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
}, [phase, location, isPublicSendRoute, navigate]);
@@ -2099,22 +2178,24 @@ export default function App() {
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
{t('txt_back')}
</button>
</div>
)}
<SettingsPage
profile={profile}
totpEnabled={!!totpStatusQuery.data?.enabled}
onChangePassword={changePasswordAction}
onEnableTotp={async (secret, token) => {
await enableTotpAction(secret, token);
await totpStatusQuery.refetch();
}}
onOpenDisableTotp={() => setDisableTotpOpen(true)}
onGetRecoveryCode={getRecoveryCodeAction}
onNotify={pushToast}
/>
<Suspense fallback={<RouteContentFallback />}>
<SettingsPage
profile={profile}
totpEnabled={!!totpStatusQuery.data?.enabled}
onChangePassword={changePasswordAction}
onEnableTotp={async (secret, token) => {
await enableTotpAction(secret, token);
await totpStatusQuery.refetch();
}}
onOpenDisableTotp={() => setDisableTotpOpen(true)}
onGetRecoveryCode={getRecoveryCodeAction}
onNotify={pushToast}
/>
</Suspense>
</div>
)}
</Route>
@@ -2164,55 +2245,57 @@ export default function App() {
</button>
</div>
)}
<SecurityDevicesPage
devices={authorizedDevicesQuery.data || []}
loading={authorizedDevicesQuery.isFetching}
onRefresh={() => void refreshAuthorizedDevices()}
onRevokeTrust={(device) => {
setConfirm({
title: t('txt_revoke_device_authorization'),
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
danger: true,
onConfirm: () => {
setConfirm(null);
void revokeDeviceTrustAction(device);
},
});
}}
onRemoveDevice={(device) => {
setConfirm({
title: t('txt_remove_device'),
message: t('txt_remove_device_and_sign_out_name', { name: device.name }),
danger: true,
onConfirm: () => {
setConfirm(null);
void removeDeviceAction(device);
},
});
}}
onRevokeAll={() => {
setConfirm({
title: t('txt_revoke_all_trusted_devices'),
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
danger: true,
onConfirm: () => {
setConfirm(null);
void revokeAllDeviceTrustAction();
},
});
}}
onRemoveAll={() => {
setConfirm({
title: t('txt_remove_all_devices'),
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
danger: true,
onConfirm: () => {
setConfirm(null);
void removeAllDevicesAction();
},
});
}}
/>
<Suspense fallback={<RouteContentFallback />}>
<SecurityDevicesPage
devices={authorizedDevicesQuery.data || []}
loading={authorizedDevicesQuery.isFetching}
onRefresh={() => void refreshAuthorizedDevices()}
onRevokeTrust={(device) => {
setConfirm({
title: t('txt_revoke_device_authorization'),
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
danger: true,
onConfirm: () => {
setConfirm(null);
void revokeDeviceTrustAction(device);
},
});
}}
onRemoveDevice={(device) => {
setConfirm({
title: t('txt_remove_device'),
message: t('txt_remove_device_and_sign_out_name', { name: device.name }),
danger: true,
onConfirm: () => {
setConfirm(null);
void removeDeviceAction(device);
},
});
}}
onRevokeAll={() => {
setConfirm({
title: t('txt_revoke_all_trusted_devices'),
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
danger: true,
onConfirm: () => {
setConfirm(null);
void revokeAllDeviceTrustAction();
},
});
}}
onRemoveAll={() => {
setConfirm({
title: t('txt_remove_all_devices'),
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
danger: true,
onConfirm: () => {
setConfirm(null);
void removeAllDevicesAction();
},
});
}}
/>
</Suspense>
</div>
</Route>
<Route path="/admin">
@@ -2225,60 +2308,62 @@ export default function App() {
</button>
</div>
)}
<AdminPage
currentUserId={profile?.id || ''}
users={usersQuery.data || []}
invites={invitesQuery.data || []}
onRefresh={() => {
void usersQuery.refetch();
void invitesQuery.refetch();
}}
onCreateInvite={async (hours) => {
await createInvite(authedFetch, hours);
await invitesQuery.refetch();
pushToast('success', t('txt_invite_created'));
}}
onDeleteAllInvites={async () => {
setConfirm({
title: t('txt_delete_all_invites'),
message: t('txt_delete_all_invite_codes_active_inactive'),
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteAllInvites(authedFetch);
await invitesQuery.refetch();
pushToast('success', t('txt_all_invites_deleted'));
})();
},
});
}}
onToggleUserStatus={async (userId, status) => {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await usersQuery.refetch();
pushToast('success', t('txt_user_status_updated'));
}}
onDeleteUser={async (userId) => {
setConfirm({
title: t('txt_delete_user'),
message: t('txt_delete_this_user_and_all_user_data'),
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteUser(authedFetch, userId);
await usersQuery.refetch();
pushToast('success', t('txt_user_deleted'));
})();
},
});
}}
onRevokeInvite={async (code) => {
await revokeInvite(authedFetch, code);
await invitesQuery.refetch();
pushToast('success', t('txt_invite_revoked'));
}}
/>
<Suspense fallback={<RouteContentFallback />}>
<AdminPage
currentUserId={profile?.id || ''}
users={usersQuery.data || []}
invites={invitesQuery.data || []}
onRefresh={() => {
void usersQuery.refetch();
void invitesQuery.refetch();
}}
onCreateInvite={async (hours) => {
await createInvite(authedFetch, hours);
await invitesQuery.refetch();
pushToast('success', t('txt_invite_created'));
}}
onDeleteAllInvites={async () => {
setConfirm({
title: t('txt_delete_all_invites'),
message: t('txt_delete_all_invite_codes_active_inactive'),
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteAllInvites(authedFetch);
await invitesQuery.refetch();
pushToast('success', t('txt_all_invites_deleted'));
})();
},
});
}}
onToggleUserStatus={async (userId, status) => {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await usersQuery.refetch();
pushToast('success', t('txt_user_status_updated'));
}}
onDeleteUser={async (userId) => {
setConfirm({
title: t('txt_delete_user'),
message: t('txt_delete_this_user_and_all_user_data'),
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteUser(authedFetch, userId);
await usersQuery.refetch();
pushToast('success', t('txt_user_deleted'));
})();
},
});
}}
onRevokeInvite={async (code) => {
await revokeInvite(authedFetch, code);
await invitesQuery.refetch();
pushToast('success', t('txt_invite_revoked'));
}}
/>
</Suspense>
</div>
</Route>
<Route path={IMPORT_ROUTE}>
@@ -2291,65 +2376,23 @@ export default function App() {
</button>
</div>
)}
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
{importPageContent}
</div>
</Route>
<Route path="/tools/import">
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
{importPageContent}
</Route>
<Route path="/tools/import-export">
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
{importPageContent}
</Route>
<Route path="/tools/import-data">
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
{importPageContent}
</Route>
<Route path="/import">
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
{importPageContent}
</Route>
<Route path="/import-export">
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
{importPageContent}
</Route>
<Route path="/help">
{profile?.role === 'admin' ? (
@@ -2358,11 +2401,24 @@ export default function App() {
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
{t('txt_back')}
</button>
</div>
)}
<HelpPage onExport={handleBackupExportAction} onImport={handleBackupImportAction} onNotify={pushToast} />
<Suspense fallback={<RouteContentFallback />}>
<BackupCenterPage
onExport={handleBackupExportAction}
onImport={handleBackupImportAction}
onLoadSettings={handleLoadBackupSettingsAction}
onListRemoteBackups={handleListRemoteBackupsAction}
onDownloadRemoteBackup={handleDownloadRemoteBackupAction}
onDeleteRemoteBackup={handleDeleteRemoteBackupAction}
onRestoreRemoteBackup={handleRestoreRemoteBackupAction}
onSaveSettings={handleSaveBackupSettingsAction}
onRunRemoteBackup={handleRunRemoteBackupAction}
onNotify={pushToast}
/>
</Suspense>
</div>
) : null}
</Route>
+590
View File
@@ -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>
);
}
-139
View File
@@ -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>
)}
</>
)}
</>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { base64ToBytes, decryptBw } from './crypto';
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api';
import type { Profile, SessionState } from './types';
const PORTABLE_ALGORITHM = 'RSA-OAEP';
const PORTABLE_HASH = 'SHA-1';
const AES_GCM_ALGORITHM = 'AES-GCM';
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey(
'pkcs8',
pkcs8,
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
false,
['decrypt']
);
}
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', keyBytes, { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
}
export async function decryptPortableBackupSettings(
portable: BackupSettingsPortablePayload,
profile: Profile,
session: SessionState
): Promise<AdminBackupSettings> {
if (!profile.id) {
throw new Error('Current administrator profile is missing an id');
}
if (!profile.privateKey) {
throw new Error('Current administrator profile is missing a private key');
}
if (!session.symEncKey || !session.symMacKey) {
throw new Error('Current session is missing unlocked vault keys');
}
const wrap = portable.wraps.find((entry) => entry.userId === profile.id);
if (!wrap) {
throw new Error('No portable backup settings wrap is available for the current administrator');
}
const privateKeyBytes = await decryptBw(
profile.privateKey,
base64ToBytes(session.symEncKey),
base64ToBytes(session.symMacKey)
);
const privateKey = await importPortablePrivateKey(privateKeyBytes);
const portableDek = new Uint8Array(
await crypto.subtle.decrypt(
{ name: PORTABLE_ALGORITHM },
privateKey,
base64ToBytes(wrap.wrappedKey)
)
);
const aesKey = await importPortableAesKey(portableDek);
const plaintext = new Uint8Array(
await crypto.subtle.decrypt(
{ name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) },
aesKey,
base64ToBytes(portable.ciphertext)
)
);
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
}
+198 -5
View File
@@ -18,6 +18,13 @@ import type {
VaultDraftField,
WebConfigResponse,
} from './types';
import type {
BackupDestinationRecord,
BackupDestinationType,
BackupSettings as AdminBackupSettings,
E3BackupDestination,
WebDavBackupDestination,
} from '@shared/backup';
const SESSION_KEY = 'nodewarden.web.session.v4';
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
@@ -932,6 +939,64 @@ export async function deleteUser(authedFetch: (input: string, init?: RequestInit
if (!resp.ok) throw new Error('Delete user failed');
}
export type {
BackupDestinationConfig,
BackupDestinationRecord,
BackupDestinationType,
BackupRuntimeState,
BackupScheduleConfig,
BackupSettings as AdminBackupSettings,
E3BackupDestination,
PlaceholderBackupDestination,
WebDavBackupDestination,
} from '@shared/backup';
export interface BackupSettingsPortableWrap {
userId: string;
wrappedKey: string;
}
export interface BackupSettingsPortablePayload {
iv: string;
ciphertext: string;
wraps: BackupSettingsPortableWrap[];
}
export interface BackupSettingsRepairStateResponse {
object: 'backup-settings-repair';
needsRepair: boolean;
portable: BackupSettingsPortablePayload | null;
}
export interface AdminBackupRunResponse {
object: 'backup-run';
result: {
fileName: string;
fileSize: number;
provider: string;
remotePath: string;
};
settings: AdminBackupSettings;
}
export interface RemoteBackupItem {
path: string;
name: string;
isDirectory: boolean;
size: number | null;
modifiedAt: string | null;
}
export interface RemoteBackupBrowserResponse {
object: 'backup-remote-browser';
destinationId: string;
destinationName: string;
provider: BackupDestinationType;
currentPath: string;
parentPath: string | null;
items: RemoteBackupItem[];
}
export interface AdminBackupImportCounts {
config: number;
users: number;
@@ -959,21 +1024,149 @@ export async function exportAdminBackup(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
): Promise<AdminBackupExportPayload> {
const resp = await authedFetch('/api/admin/backup/export', { method: 'POST' });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Backup export failed'));
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
const fileName = parseContentDispositionFileName(resp, 'nodewarden_instance_backup.zip');
const fileName = parseContentDispositionFileName(resp, 'nodewarden_backup.zip');
const bytes = new Uint8Array(await resp.arrayBuffer());
return { fileName, mimeType, bytes };
}
export async function getAdminBackupSettings(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings', { method: 'GET' });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_load_failed')));
const body = await parseJson<AdminBackupSettings>(resp);
if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response'));
return body;
}
export async function saveAdminBackupSettings(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
settings: AdminBackupSettings
): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
const body = await parseJson<AdminBackupSettings>(resp);
if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response'));
return body;
}
export async function getAdminBackupSettingsRepairState(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
): Promise<BackupSettingsRepairStateResponse> {
const resp = await authedFetch('/api/admin/backup/settings/repair', { method: 'GET' });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_load_failed')));
const body = await parseJson<BackupSettingsRepairStateResponse>(resp);
if (!body || typeof body.needsRepair !== 'boolean') {
throw new Error(t('txt_backup_settings_invalid_response'));
}
return body;
}
export async function repairAdminBackupSettings(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
settings: AdminBackupSettings
): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings/repair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
const body = await parseJson<AdminBackupSettings>(resp);
if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response'));
return body;
}
export async function runAdminBackupNow(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
destinationId?: string | null
): Promise<AdminBackupRunResponse> {
const resp = await authedFetch('/api/admin/backup/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(destinationId ? { destinationId } : {}),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed')));
const body = await parseJson<AdminBackupRunResponse>(resp);
if (!body?.result || !body?.settings) throw new Error(t('txt_backup_remote_run_invalid_response'));
return body;
}
export async function listRemoteBackups(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
destinationId: string,
path: string = ''
): Promise<RemoteBackupBrowserResponse> {
const params = new URLSearchParams();
params.set('destinationId', destinationId);
if (path) params.set('path', path);
const query = `?${params.toString()}`;
const resp = await authedFetch(`/api/admin/backup/remote${query}`, { method: 'GET' });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_load_failed')));
const body = await parseJson<RemoteBackupBrowserResponse>(resp);
if (!body?.items || typeof body.currentPath !== 'string' || !body.destinationId) throw new Error(t('txt_backup_remote_invalid_response'));
return body;
}
export async function downloadRemoteBackup(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
destinationId: string,
path: string
): Promise<AdminBackupExportPayload> {
const params = new URLSearchParams();
params.set('destinationId', destinationId);
params.set('path', path);
const resp = await authedFetch(`/api/admin/backup/remote/download?${params.toString()}`, { method: 'GET' });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip');
const bytes = new Uint8Array(await resp.arrayBuffer());
return { fileName, mimeType, bytes };
}
export async function deleteRemoteBackup(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
destinationId: string,
path: string
): Promise<void> {
const params = new URLSearchParams();
params.set('destinationId', destinationId);
params.set('path', path);
const resp = await authedFetch(`/api/admin/backup/remote/file?${params.toString()}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
}
export async function restoreRemoteBackup(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
destinationId: string,
path: string,
replaceExisting: boolean = false
): Promise<AdminBackupImportResponse> {
const resp = await authedFetch('/api/admin/backup/remote/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, path, replaceExisting }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
const body = await parseJson<AdminBackupImportResponse>(resp);
if (!body?.imported) throw new Error(t('txt_backup_remote_restore_invalid_response'));
return body;
}
export async function importAdminBackup(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
file: File,
replaceExisting: boolean = false
): Promise<AdminBackupImportResponse> {
const formData = new FormData();
formData.set('file', file, file.name || 'nodewarden_instance_backup.zip');
formData.set('file', file, file.name || 'nodewarden_backup.zip');
if (replaceExisting) {
formData.set('replaceExisting', '1');
}
@@ -982,10 +1175,10 @@ export async function importAdminBackup(
method: 'POST',
body: formData,
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Backup import failed'));
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_import_failed')));
const body = await parseJson<AdminBackupImportResponse>(resp);
if (!body?.imported) throw new Error('Invalid backup import response');
if (!body?.imported) throw new Error(t('txt_backup_import_invalid_response'));
return body;
}
+204
View File
@@ -0,0 +1,204 @@
import {
type BackupDestinationRecord,
type BackupDestinationType,
type BackupRuntimeState,
type BackupSettings,
createBackupDestinationRecord,
createDefaultBackupSettings,
} from '@shared/backup';
import type { RemoteBackupBrowserResponse, RemoteBackupItem } from './api';
import { t } from './i18n';
export interface PersistedRemoteBrowserState {
cache: Record<string, RemoteBackupBrowserResponse>;
pathByDestination: Record<string, string>;
pageByKey: Record<string, number>;
selectedDestinationId: string | null;
}
export const REMOTE_BROWSER_STORAGE_KEY = 'nodewarden.backup.remote-browser.v1';
export const REMOTE_BROWSER_ITEMS_PER_PAGE = 10;
export const COMMON_TIME_ZONES = [
'UTC',
'Asia/Shanghai',
'Asia/Tokyo',
'Asia/Singapore',
'Europe/London',
'Europe/Berlin',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
];
export const WEEKDAY_OPTIONS = [
{ value: 1, label: 'txt_backup_weekday_monday' },
{ value: 2, label: 'txt_backup_weekday_tuesday' },
{ value: 3, label: 'txt_backup_weekday_wednesday' },
{ value: 4, label: 'txt_backup_weekday_thursday' },
{ value: 5, label: 'txt_backup_weekday_friday' },
{ value: 6, label: 'txt_backup_weekday_saturday' },
{ value: 0, label: 'txt_backup_weekday_sunday' },
] as const;
export function detectBrowserTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}
function createLocalizedDestinationName(type: BackupDestinationType, index: number): string {
if (type === 'e3') return t('txt_backup_destination_name_default_e3', { index: String(index) });
if (type === 'placeholder') return `${t('txt_backup_destination_reserved')} ${index}`;
return t('txt_backup_destination_name_default_webdav', { index: String(index) });
}
export function createDraftDestinationRecord(type: BackupDestinationType, index: number): BackupDestinationRecord {
return createBackupDestinationRecord(type, index, {
timezone: detectBrowserTimeZone(),
name: createLocalizedDestinationName(type, index),
});
}
export function createDraftBackupSettings(): BackupSettings {
return createDefaultBackupSettings(detectBrowserTimeZone(), {
destinationName: createLocalizedDestinationName('webdav', 1),
});
}
export function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_backup_never');
const parsed = new Date(value);
if (!Number.isFinite(parsed.getTime())) return value;
return parsed.toLocaleString();
}
export function formatBytes(value: number | null | undefined): string {
const n = Number(value || 0);
if (!Number.isFinite(n) || n <= 0) return t('txt_backup_unknown_size');
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function isReplaceRequiredError(error: unknown): boolean {
const message = error instanceof Error ? String(error.message || '') : '';
return message.toLowerCase().includes('fresh instance');
}
export function isZipCandidate(item: RemoteBackupItem): boolean {
return !item.isDirectory && /\.zip$/i.test(item.name || '');
}
function getRemoteItemSortTime(item: RemoteBackupItem): number {
if (!item.modifiedAt) return 0;
const parsed = new Date(item.modifiedAt);
return Number.isFinite(parsed.getTime()) ? parsed.getTime() : 0;
}
export function compareRemoteItems(a: RemoteBackupItem, b: RemoteBackupItem): number {
const timeDiff = getRemoteItemSortTime(b) - getRemoteItemSortTime(a);
if (timeDiff !== 0) return timeDiff;
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return b.name.localeCompare(a.name, 'en');
}
export function getRemoteBrowserCacheKey(destinationId: string, path: string = ''): string {
return `${destinationId}:${path}`;
}
function getRemoteBrowserStorage(): Storage | null {
try {
if (typeof window !== 'undefined' && window.localStorage) {
return window.localStorage;
}
} catch {
// Ignore storage access failures.
}
try {
if (typeof window !== 'undefined' && window.sessionStorage) {
return window.sessionStorage;
}
} catch {
// Ignore storage access failures.
}
return null;
}
export function loadPersistedRemoteBrowserState(): PersistedRemoteBrowserState {
try {
const storage = getRemoteBrowserStorage();
const raw = storage?.getItem(REMOTE_BROWSER_STORAGE_KEY);
if (!raw) {
return {
cache: {},
pathByDestination: {},
pageByKey: {},
selectedDestinationId: null,
};
}
const parsed = JSON.parse(raw) as Partial<PersistedRemoteBrowserState>;
return {
cache: parsed.cache && typeof parsed.cache === 'object' ? parsed.cache : {},
pathByDestination: parsed.pathByDestination && typeof parsed.pathByDestination === 'object' ? parsed.pathByDestination : {},
pageByKey: parsed.pageByKey && typeof parsed.pageByKey === 'object' ? parsed.pageByKey : {},
selectedDestinationId: typeof parsed.selectedDestinationId === 'string' ? parsed.selectedDestinationId : null,
};
} catch {
return {
cache: {},
pathByDestination: {},
pageByKey: {},
selectedDestinationId: null,
};
}
}
export function persistRemoteBrowserState(state: PersistedRemoteBrowserState): void {
try {
const storage = getRemoteBrowserStorage();
storage?.setItem(REMOTE_BROWSER_STORAGE_KEY, JSON.stringify(state));
} catch {
// Ignore cache persistence failures.
}
}
export function invalidateRemoteBrowserCacheForDestination(
destinationId: string,
cache: Record<string, RemoteBackupBrowserResponse>,
pathByDestination: Record<string, string>,
pageByKey: Record<string, number>
): PersistedRemoteBrowserState {
return {
cache: Object.fromEntries(Object.entries(cache).filter(([key]) => !key.startsWith(`${destinationId}:`))),
pathByDestination: Object.fromEntries(Object.entries(pathByDestination).filter(([key]) => key !== destinationId)),
pageByKey: Object.fromEntries(Object.entries(pageByKey).filter(([key]) => !key.startsWith(`${destinationId}:`))),
selectedDestinationId: destinationId,
};
}
export function getDestinationById(
settings: BackupSettings | null,
destinationId: string | null | undefined
): BackupDestinationRecord | null {
if (!settings || !destinationId) return null;
return settings.destinations.find((destination) => destination.id === destinationId) || null;
}
export function getVisibleDestinations(settings: BackupSettings | null | undefined): BackupDestinationRecord[] {
return (settings?.destinations || []).filter((destination) => destination.type !== 'placeholder');
}
export function getFirstVisibleDestinationId(settings: BackupSettings | null | undefined): string | null {
return getVisibleDestinations(settings)[0]?.id || null;
}
export function getDestinationTypeLabel(type: BackupDestinationType): string {
if (type === 'e3') return t('txt_backup_protocol_e3');
if (type === 'placeholder') return t('txt_backup_destination_reserved');
return t('txt_backup_protocol_webdav');
}
+68
View File
@@ -0,0 +1,68 @@
export interface RecommendedStorageLink {
name: string;
capacity: string;
}
export interface RecommendedProviderBase {
id: 'infinicloud' | 'koofr' | 'pcloud';
name: string;
capacity: string;
protocol: 'webdav' | 's3';
signupUrl: string;
hasAffiliateLink?: boolean;
}
export interface InfinicloudProvider extends RecommendedProviderBase {
id: 'infinicloud';
referralCode: string;
}
export interface KoofrProvider extends RecommendedProviderBase {
id: 'koofr';
passwordUrl: string;
storageUrl: string;
linkedStorages: RecommendedStorageLink[];
}
export interface PcloudProvider extends RecommendedProviderBase {
id: 'pcloud';
}
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider;
export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
{
id: 'infinicloud',
name: 'InfiniCLOUD',
capacity: '25G',
protocol: 'webdav',
signupUrl: 'https://infini-cloud.net/en/',
referralCode: '2HC5E',
},
{
id: 'koofr',
name: 'Koofr',
capacity: '10G',
protocol: 'webdav',
signupUrl: 'https://app.koofr.net/signup',
passwordUrl: 'https://app.koofr.net/app/admin/preferences/password',
storageUrl: 'https://app.koofr.net/app/storage/',
linkedStorages: [
{ name: 'Google Drive', capacity: '15G' },
{ name: 'OneDrive', capacity: '5G' },
{ name: 'Dropbox', capacity: '2G' },
],
},
{
id: 'pcloud',
name: 'pCloud',
capacity: '10G',
protocol: 'webdav',
signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7',
hasAffiliateLink: true,
},
];
export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider {
return provider.id === 'koofr';
}
+330 -18
View File
@@ -9,29 +9,185 @@ const messages: Record<Locale, Record<string, string>> = {
nav_device_management: "Device Management",
nav_my_vault: "My Vault",
nav_sends: "Sends",
nav_backup_strategy: "Backup Strategy",
nav_backup_strategy: "Backup Center",
nav_import_export: "Import & Export",
backup_strategy_title: "Backup Strategy",
backup_strategy_title: "Backup Center",
backup_strategy_under_construction: "Under construction.",
import_export_title: "Import & Export",
import_export_under_construction: "Under construction.",
txt_backup_export: "Backup Export",
txt_backup_import: "Backup Import",
txt_backup_export: "Export Backup",
txt_backup_import: "Restore",
txt_backup_export_description: "Download a full instance backup ZIP for manual safekeeping.",
txt_backup_import_description: "Upload a previously exported backup ZIP and restore it into a fresh instance shell.",
txt_backup_import_description: "Upload a previously exported backup ZIP and restore it into this instance.",
txt_backup_exporting: "Exporting...",
txt_backup_importing: "Importing...",
txt_backup_importing: "Restoring...",
txt_backup_restoring: "Restoring...",
txt_backup_export_success: "Backup exported",
txt_backup_import_success_relogin: "Backup imported. Please sign in again.",
txt_backup_import_success_relogin: "Backup restored. Please sign in again.",
txt_backup_restore_success_relogin: "Backup restored. Please sign in again.",
txt_backup_export_failed: "Backup export failed",
txt_backup_import_failed: "Backup import failed",
txt_backup_import_failed: "Backup restore failed",
txt_backup_restore_failed: "Backup restore failed",
txt_backup_center_title: "Instance Backup",
txt_backup_center_description: "Keep local exports for manual restore, and configure one daily remote backup target for unattended protection.",
txt_backup_restore_note: "Restoring will overwrite the current instance if you choose the replace flow.",
txt_backup_manual: "Manual Backup",
txt_backup_manual_description: "Export a ZIP right now, or import a ZIP back into this instance.",
txt_backup_destinations_title: "Backup Destinations",
txt_backup_destinations_description: "Keep multiple WebDAV and E3 targets here. Select one on the left to edit or browse it.",
txt_backup_recommend_title: "Recommended Storage",
txt_backup_recommend_open_signup: "Open Signup",
txt_backup_recommend_open_signup_aff: "Open Signup (AFF)",
txt_backup_recommend_open_guide: "Open Guide",
txt_backup_recommend_empty: "No recommendations yet.",
txt_backup_recommend_referral_label: "Referral Code",
txt_backup_recommend_referral_note: "Use it during signup to get 5 GB extra. The author receives 2 GB.",
txt_backup_recommend_infinicloud_summary: "Only an email address is needed. 20 GB free, 25 GB total with the referral code.",
txt_backup_recommend_infinicloud_step_1: "Register an InfiniCLOUD account with just your email address.",
txt_backup_recommend_infinicloud_step_2_prefix: "Open",
txt_backup_recommend_infinicloud_step_2_suffix: "and turn on Apps Connection.",
txt_backup_recommend_infinicloud_step_3: "Use Connection ID as your WebDAV username and Apps Password as your WebDAV password.",
txt_backup_recommend_infinicloud_step_4: "Enter referral code 2HC5E in Referral Bonus at the bottom of My Page to receive 5 GB extra.",
txt_backup_recommend_open_password: "Password Settings",
txt_backup_recommend_open_storage: "Open Storage",
txt_backup_recommend_koofr_summary: "Only an email address is needed. 10 GB free, and it can bridge Google Drive, OneDrive, and Dropbox through WebDAV.",
txt_backup_recommend_koofr_password_link: "Password Settings",
txt_backup_recommend_koofr_storage_link: "Storage",
txt_backup_recommend_koofr_step_1: "Register a Koofr account with just your email address.",
txt_backup_recommend_koofr_step_2_prefix: "Open",
txt_backup_recommend_koofr_step_2_suffix: ", generate a new app password, use your email address as the WebDAV username, and use the app password as the WebDAV password.",
txt_backup_recommend_koofr_step_3: "Koofr's own WebDAV address is https://app.koofr.net/dav/Koofr.",
txt_backup_recommend_koofr_step_4: "Koofr can also connect Google Drive, OneDrive, and Dropbox. Free users can connect up to two storage accounts.",
txt_backup_recommend_koofr_step_5_prefix: "Open",
txt_backup_recommend_koofr_step_5_suffix: ", click Connect in the left sidebar, and choose the cloud storage you want to attach.",
txt_backup_recommend_koofr_dav_intro: "After a storage account is connected, keep the same email and app password, and only switch the WebDAV address:",
txt_backup_recommend_koofr_dav_self: "Koofr",
txt_backup_recommend_pcloud_summary: "Only an email address is needed. Up to 10 GB free, with standard WebDAV access.",
txt_backup_recommend_pcloud_step_1: "Register a pCloud account with just your email address.",
txt_backup_recommend_pcloud_step_2: "Use https://webdav.pcloud.com/ as the WebDAV server URL.",
txt_backup_recommend_pcloud_step_3: "Use your registration email as the WebDAV username and your account password as the WebDAV password.",
txt_backup_add_destination: "Add Destination",
txt_backup_schedule_panel_title: "Automatic Schedule",
txt_backup_schedule_panel_note: "Each destination can keep its own daily backup schedule.",
txt_backup_scheduled_target: "Scheduled Target",
txt_backup_destination_active_badge: "Auto On",
txt_backup_destination_idle_badge: "Auto Off",
txt_backup_destination_last_success: "Last success: {time}",
txt_backup_destination_never_run: "No successful run yet",
txt_backup_destination_detail_title: "Destination Details",
txt_backup_destination_detail_note: "",
txt_backup_destination_name: "Destination Name",
txt_backup_set_scheduled_target: "Use For Daily Backup",
txt_backup_delete_destination: "Delete",
txt_backup_destination_deleted: "Backup destination deleted",
txt_backup_delete_destination_confirm_message: "Delete backup destination \"{name}\"? This cannot be undone.",
txt_backup_select_destination: "Select a backup destination from the list first.",
txt_backup_remote_save_first: "Save this destination first before browsing its remote backup files.",
txt_backup_automation: "Automatic Backup",
txt_backup_automation_description: "Pick a destination, save the credentials, and let the worker upload one backup every day.",
txt_backup_settings_saved: "Backup settings saved",
txt_backup_settings_save_failed: "Saving backup settings failed",
txt_backup_settings_load_failed: "Loading backup settings failed",
txt_backup_save_settings: "Save Settings",
txt_backup_saving: "Saving...",
txt_backup_enable_action: "Enable",
txt_backup_disable_action: "Disable",
txt_backup_run_now: "Run Remote Backup Now",
txt_backup_run_manual: "Run Manually",
txt_backup_running_now: "Running...",
txt_backup_remote_run_success: "Remote backup completed",
txt_backup_remote_run_failed: "Remote backup failed",
txt_backup_remote_title: "Remote Backups",
txt_backup_remote_note: "Browse the saved destination and choose a backup ZIP to download or restore.",
txt_backup_remote_saved_basis: "Remote browsing uses the last saved destination settings, not unsaved form edits.",
txt_backup_remote_refresh: "Refresh",
txt_backup_remote_root: "Root",
txt_backup_remote_up: "Up",
txt_backup_remote_open: "Open",
txt_backup_remote_download: "Download",
txt_backup_remote_downloading: "Downloading...",
txt_backup_remote_restore: "Restore",
txt_backup_remote_loading: "Loading remote backups...",
txt_backup_remote_cached_empty: "Click Refresh to load this destination.",
txt_backup_remote_empty: "No backup files found in this folder.",
txt_backup_remote_folder: "Folder",
txt_backup_remote_unknown_time: "Unknown time",
txt_backup_remote_current_path: "Current Folder",
txt_backup_remote_load_failed: "Loading remote backups failed",
txt_backup_remote_invalid_response: "Invalid remote backup response",
txt_backup_remote_download_failed: "Downloading remote backup failed",
txt_backup_remote_delete_success: "Remote backup deleted",
txt_backup_remote_delete_failed: "Deleting remote backup failed",
txt_backup_remote_delete_confirm_message: "Delete backup file \"{name}\"? This cannot be undone.",
txt_backup_remote_deleting: "Deleting...",
txt_backup_remote_restore_failed: "Restoring remote backup failed",
txt_backup_remote_restore_invalid_response: "Invalid remote backup restore response",
txt_backup_remote_run_invalid_response: "Invalid remote backup run response",
txt_backup_settings_invalid_response: "Invalid backup settings response",
txt_backup_import_invalid_response: "Invalid backup import response",
txt_backup_destination: "Backup Destination",
txt_backup_protocol_webdav: "WebDAV",
txt_backup_protocol_e3: "E3",
txt_backup_recommend_group_webdav: "WebDAV",
txt_backup_recommend_group_s3: "S3",
txt_backup_destination_name_default_webdav: "WebDAV {index}",
txt_backup_destination_name_default_e3: "E3 {index}",
txt_backup_type: "Backup Type",
txt_backup_destination_reserved: "Reserved Slot",
txt_backup_time: "Backup Time",
txt_backup_timezone: "Timezone",
txt_backup_frequency: "Frequency",
txt_backup_frequency_daily: "Daily",
txt_backup_frequency_weekly: "Weekly",
txt_backup_frequency_monthly: "Monthly",
txt_backup_day_of_week: "Day of Week",
txt_backup_day_of_month: "Day of Month",
txt_backup_weekday_monday: "Monday",
txt_backup_weekday_tuesday: "Tuesday",
txt_backup_weekday_wednesday: "Wednesday",
txt_backup_weekday_thursday: "Thursday",
txt_backup_weekday_friday: "Friday",
txt_backup_weekday_saturday: "Saturday",
txt_backup_weekday_sunday: "Sunday",
txt_backup_retention_count: "Keep",
txt_backup_retention_count_suffix: "items",
txt_backup_retention_count_hint: "Leave empty to keep all backup files. New destinations default to 30.",
txt_backup_enable_schedule: "Enable automatic daily backup",
txt_backup_schedule_note: "The worker checks the schedule every 5 minutes and runs the backup as soon as the selected time window is reached.",
txt_backup_schedule_disabled: "Disabled",
txt_backup_schedule_status: "Schedule",
txt_backup_schedule_summary: "Daily at {time} ({timezone})",
txt_backup_schedule_empty: "No automatic backup plans are enabled yet.",
txt_backup_last_success: "Last Success",
txt_backup_last_target: "Last Target",
txt_backup_last_file: "Last File",
txt_backup_last_error_prefix: "Last Error",
txt_backup_none_yet: "No remote backup has completed yet",
txt_backup_not_configured: "Not configured",
txt_backup_never: "Never",
txt_backup_unknown_size: "Unknown size",
txt_backup_webdav_url: "WebDAV Server URL",
txt_backup_webdav_username: "WebDAV Username",
txt_backup_webdav_password: "WebDAV Password",
txt_backup_webdav_path: "Remote Folder",
txt_backup_e3_endpoint: "E3 Endpoint",
txt_backup_e3_bucket: "Bucket",
txt_backup_e3_region: "Region",
txt_backup_e3_access_key: "Access Key",
txt_backup_e3_secret_key: "Secret Key",
txt_backup_e3_path: "Remote Path",
txt_backup_reserved_name: "Reserved Provider Name",
txt_backup_reserved_notes: "Reserved Notes",
txt_backup_reserved_notes_placeholder: "Leave a note for the next destination type",
txt_backup_reserved_hint: "This slot is reserved for a future destination. You can save notes now, but automatic uploads stay disabled.",
txt_backup_file: "Backup File",
txt_backup_file_required: "Please select a backup file",
txt_backup_no_file_selected: "No backup file selected",
txt_backup_selected_file_name: "Selected file: {name}",
txt_backup_replace_confirm_title: "Replace Current Instance Data",
txt_backup_replace_confirm_message: "The current instance already contains data. Clear it and import the new backup?",
txt_backup_replace_confirm_message: "The current instance already contains data. Clear it and restore the selected backup?",
txt_backup_clear_and_import: "Clear and Import",
txt_backup_clear_and_restore: "Clear and Restore",
txt_access_count: "Access Count",
txt_accessed_count_times: "Accessed {count} times",
txt_actions: "Actions",
@@ -425,29 +581,185 @@ const zhCNOverrides: Record<string, string> = {
nav_admin_panel: '用户管理',
nav_account_settings: '账户设置',
nav_device_management: '设备管理',
nav_backup_strategy: '备份策略',
nav_backup_strategy: '备份中心',
nav_import_export: '导入导出',
backup_strategy_title: '备份策略',
backup_strategy_title: '备份中心',
backup_strategy_under_construction: '正在搭建中',
import_export_title: '导入导出',
import_export_under_construction: '正在搭建中',
txt_backup_export: '备份导出',
txt_backup_import: '备份导入',
txt_backup_export: '导出备份',
txt_backup_import: '还原',
txt_backup_export_description: '下载一个完整的实例备份 ZIP,手动保管即可。',
txt_backup_import_description: '上传之前导出的备份 ZIP,并恢复到全新实例空壳。',
txt_backup_import_description: '上传之前导出的备份 ZIP,并还原到当前实例。',
txt_backup_exporting: '正在导出...',
txt_backup_importing: '正在导入...',
txt_backup_importing: '正在还原...',
txt_backup_restoring: '正在还原...',
txt_backup_export_success: '备份已导出',
txt_backup_import_success_relogin: '备份已导入,请重新登录',
txt_backup_import_success_relogin: '备份已还原,请重新登录',
txt_backup_restore_success_relogin: '备份已还原,请重新登录',
txt_backup_export_failed: '备份导出失败',
txt_backup_import_failed: '备份导入失败',
txt_backup_import_failed: '备份还原失败',
txt_backup_restore_failed: '备份还原失败',
txt_backup_center_title: '实例备份',
txt_backup_center_description: '把本地导出和远程自动备份放在一起管理,既方便手动恢复,也能每天自动留一份。',
txt_backup_restore_note: '还原会覆盖当前实例;如果当前已有数据,系统会要求你确认“清空后还原”。',
txt_backup_manual: '手动备份',
txt_backup_manual_description: '现在就导出 ZIP,或者把之前导出的 ZIP 恢复到当前实例。',
txt_backup_destinations_title: '备份地点',
txt_backup_destinations_description: '把多个 WebDAV、E3 地点统一放在这里。左侧选一个,右侧编辑和浏览它。',
txt_backup_recommend_title: '推荐储存库',
txt_backup_recommend_open_signup: '前往注册',
txt_backup_recommend_open_signup_aff: '前往注册(含 AFF',
txt_backup_recommend_open_guide: '查看教程',
txt_backup_recommend_empty: '暂时没有推荐',
txt_backup_recommend_referral_label: '推荐码',
txt_backup_recommend_referral_note: '注册时填写可额外获得 5 GB,作者会收到 2 GB。',
txt_backup_recommend_infinicloud_summary: '只需邮箱即可注册。免费 20 GB;填写推荐码后总计 25 GB。',
txt_backup_recommend_infinicloud_step_1: '先用邮箱注册一个 InfiniCLOUD 账号。',
txt_backup_recommend_infinicloud_step_2_prefix: '进入',
txt_backup_recommend_infinicloud_step_2_suffix: ',然后开启 Turn on Apps Connection。',
txt_backup_recommend_infinicloud_step_3: 'Connection ID 用作 WebDAV 用户名,Apps Password 用作 WebDAV 密码。',
txt_backup_recommend_infinicloud_step_4: '在 My Page 最下面的 Referral Bonus 填入推荐码 2HC5E,可额外获得 5 GB。',
txt_backup_recommend_open_password: '密码设置',
txt_backup_recommend_open_storage: '打开储存连接',
txt_backup_recommend_koofr_summary: '只需邮箱即可注册使用。免费 10 GB,并且可以通过 WebDAV 接到 Google Drive、OneDrive、Dropbox。',
txt_backup_recommend_koofr_password_link: '密码设置',
txt_backup_recommend_koofr_storage_link: 'Storage',
txt_backup_recommend_koofr_step_1: '先用邮箱注册一个 Koofr 账号。',
txt_backup_recommend_koofr_step_2_prefix: '打开',
txt_backup_recommend_koofr_step_2_suffix: ',生成新的应用密码。注册邮箱用作 WebDAV 用户名,应用密码用作 WebDAV 密码。',
txt_backup_recommend_koofr_step_3: 'Koofr 自己的 WebDAV 地址是 https://app.koofr.net/dav/Koofr。',
txt_backup_recommend_koofr_step_4: 'Koofr 最方便的地方,是还能接 Google Drive、OneDrive、Dropbox 这三大云盘;免费用户最多能连接两个。',
txt_backup_recommend_koofr_step_5_prefix: '打开',
txt_backup_recommend_koofr_step_5_suffix: ',在左侧栏点击“连接”,选择你要连接的储存即可。',
txt_backup_recommend_koofr_dav_intro: '连接好储存后,账号和应用密码都不变,只需要切换 WebDAV 地址:',
txt_backup_recommend_koofr_dav_self: 'Koofr',
txt_backup_recommend_pcloud_summary: '只需邮箱即可注册。免费最高 10 GB,并且自带标准 WebDAV 访问。',
txt_backup_recommend_pcloud_step_1: '先用邮箱注册一个 pCloud 账号。',
txt_backup_recommend_pcloud_step_2: 'WebDAV 地址填写 https://webdav.pcloud.com/ 。',
txt_backup_recommend_pcloud_step_3: '注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。',
txt_backup_add_destination: '新增地点',
txt_backup_schedule_panel_title: '自动备份计划',
txt_backup_schedule_panel_note: '每个备份地点都可以单独配置自己的每日自动备份计划。',
txt_backup_scheduled_target: '当前计划目标',
txt_backup_destination_active_badge: '已启用计划',
txt_backup_destination_idle_badge: '未启用计划',
txt_backup_destination_last_success: '上次成功:{time}',
txt_backup_destination_never_run: '还没有成功执行过',
txt_backup_destination_detail_title: '地点详情',
txt_backup_destination_detail_note: '',
txt_backup_destination_name: '地点名称',
txt_backup_set_scheduled_target: '设为每日备份目标',
txt_backup_delete_destination: '删除',
txt_backup_destination_deleted: '备份地点已删除',
txt_backup_delete_destination_confirm_message: '删除备份地点“{name}”?此操作不可撤销。',
txt_backup_select_destination: '请先从左侧列表选择一个备份地点',
txt_backup_remote_save_first: '请先保存这个备份地点,再浏览它的远端备份文件',
txt_backup_automation: '自动备份',
txt_backup_automation_description: '选择备份地点,保存连接信息后,系统会按设定时间每天自动上传一份备份。',
txt_backup_settings_saved: '备份设置已保存',
txt_backup_settings_save_failed: '备份设置保存失败',
txt_backup_settings_load_failed: '备份设置加载失败',
txt_backup_save_settings: '保存设置',
txt_backup_saving: '正在保存...',
txt_backup_enable_action: '启用',
txt_backup_disable_action: '停用',
txt_backup_run_now: '立即执行远程备份',
txt_backup_run_manual: '手动执行',
txt_backup_running_now: '执行中...',
txt_backup_remote_run_success: '远程备份已完成',
txt_backup_remote_run_failed: '远程备份失败',
txt_backup_remote_title: '远端备份',
txt_backup_remote_note: '浏览已保存的备份地点,选择某个备份 ZIP 后可以下载,也可以直接还原。',
txt_backup_remote_saved_basis: '远端浏览使用的是“已保存”的备份地点配置,不会读取你当前未保存的表单内容。',
txt_backup_remote_refresh: '刷新',
txt_backup_remote_root: '根目录',
txt_backup_remote_up: '上一级',
txt_backup_remote_open: '打开',
txt_backup_remote_download: '下载',
txt_backup_remote_downloading: '下载中...',
txt_backup_remote_restore: '还原',
txt_backup_remote_loading: '正在读取远端备份...',
txt_backup_remote_cached_empty: '点击“刷新”后读取',
txt_backup_remote_empty: '这个目录下还没有备份文件',
txt_backup_remote_folder: '文件夹',
txt_backup_remote_unknown_time: '未知时间',
txt_backup_remote_current_path: '当前目录',
txt_backup_remote_load_failed: '读取远端备份失败',
txt_backup_remote_invalid_response: '远端备份响应无效',
txt_backup_remote_download_failed: '下载远端备份失败',
txt_backup_remote_delete_success: '远端备份已删除',
txt_backup_remote_delete_failed: '删除远端备份失败',
txt_backup_remote_delete_confirm_message: '删除备份文件“{name}”?此操作不可撤销。',
txt_backup_remote_deleting: '删除中...',
txt_backup_remote_restore_failed: '还原远端备份失败',
txt_backup_remote_restore_invalid_response: '远端备份还原响应无效',
txt_backup_remote_run_invalid_response: '远端备份执行响应无效',
txt_backup_settings_invalid_response: '备份设置响应无效',
txt_backup_import_invalid_response: '备份还原响应无效',
txt_backup_destination: '备份地点',
txt_backup_protocol_webdav: 'WebDAV',
txt_backup_protocol_e3: 'E3',
txt_backup_recommend_group_webdav: 'WebDAV',
txt_backup_recommend_group_s3: 'S3',
txt_backup_destination_name_default_webdav: 'WebDAV {index}',
txt_backup_destination_name_default_e3: 'E3 {index}',
txt_backup_type: '备份类型',
txt_backup_destination_reserved: '预留位置',
txt_backup_time: '备份时间',
txt_backup_timezone: '时区',
txt_backup_frequency: '备份频率',
txt_backup_frequency_daily: '每天',
txt_backup_frequency_weekly: '每周',
txt_backup_frequency_monthly: '每月',
txt_backup_day_of_week: '星期',
txt_backup_day_of_month: '日期',
txt_backup_weekday_monday: '周一',
txt_backup_weekday_tuesday: '周二',
txt_backup_weekday_wednesday: '周三',
txt_backup_weekday_thursday: '周四',
txt_backup_weekday_friday: '周五',
txt_backup_weekday_saturday: '周六',
txt_backup_weekday_sunday: '周日',
txt_backup_retention_count: '只保留',
txt_backup_retention_count_suffix: '个',
txt_backup_retention_count_hint: '留空表示不限,新建备份地点默认保留 30 个',
txt_backup_enable_schedule: '启用每日自动备份',
txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划,到达你设定的时间窗口后会尽快执行备份。',
txt_backup_schedule_disabled: '未启用',
txt_backup_schedule_status: '计划状态',
txt_backup_schedule_summary: '每天 {time}{timezone}',
txt_backup_schedule_empty: '还没有启用任何自动备份计划',
txt_backup_last_success: '上次成功时间',
txt_backup_last_target: '上次备份位置',
txt_backup_last_file: '上次备份文件',
txt_backup_last_error_prefix: '上次错误',
txt_backup_none_yet: '还没有成功完成过远程备份',
txt_backup_not_configured: '尚未配置',
txt_backup_never: '从未',
txt_backup_unknown_size: '大小未知',
txt_backup_webdav_url: 'WebDAV 服务地址',
txt_backup_webdav_username: 'WebDAV 用户名',
txt_backup_webdav_password: 'WebDAV 密码',
txt_backup_webdav_path: '远程目录',
txt_backup_e3_endpoint: 'E3 Endpoint',
txt_backup_e3_bucket: 'Bucket',
txt_backup_e3_region: 'Region',
txt_backup_e3_access_key: 'Access Key',
txt_backup_e3_secret_key: 'Secret Key',
txt_backup_e3_path: '远程路径',
txt_backup_reserved_name: '预留类型名称',
txt_backup_reserved_notes: '预留备注',
txt_backup_reserved_notes_placeholder: '给下一个备份地点先留个说明',
txt_backup_reserved_hint: '这个位置先预留给后续备份地点。你现在可以先保存备注,但自动上传不会启用。',
txt_backup_file: '备份文件',
txt_backup_file_required: '请选择备份文件',
txt_backup_no_file_selected: '尚未选择备份文件',
txt_backup_selected_file_name: '已选择文件:{name}',
txt_backup_replace_confirm_title: '替换当前实例数据',
txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再导入新的备份吗?',
txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再还原所选备份吗?',
txt_backup_clear_and_import: '清空后导入',
txt_backup_clear_and_restore: '清空后还原',
txt_sign_out: '退出登录',
txt_log_in: '登录',
txt_log_out: '退出',
+2
View File
@@ -13,6 +13,8 @@ export interface Profile {
email: string;
name: string;
key: string;
privateKey?: string | null;
publicKey?: string | null;
role: 'admin' | 'user';
[k: string]: unknown;
}
+439 -8
View File
@@ -311,6 +311,7 @@ input[type='file'].input::file-selector-button:hover {
align-items: center;
justify-content: center;
gap: 6px;
text-decoration: none;
}
.btn-icon {
@@ -1341,8 +1342,401 @@ input[type='file'].input::file-selector-button:hover {
align-items: start;
}
.backup-panel {
min-height: 100%;
.backup-grid {
display: grid;
grid-template-columns: 280px 280px minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 2px;
}
.backup-operations-sidebar,
.backup-destination-sidebar,
.backup-detail-panel {
min-width: 0;
background: #fff;
border: 1px solid #d8dee8;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
padding: 12px;
}
.backup-actions-stack {
display: grid;
gap: 10px;
}
.backup-manual-inline-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.backup-schedule-list {
display: grid;
gap: 8px;
}
.backup-recommendation-list {
display: grid;
gap: 8px;
}
.backup-recommendation-group + .backup-recommendation-group {
margin-top: 12px;
}
.backup-recommendation-group-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.backup-recommendation-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.backup-recommendation-linked {
display: grid;
gap: 4px;
}
.backup-recommendation-linked-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: #475467;
}
.backup-recommendation-card {
border: 1px solid var(--line);
border-radius: 12px;
background: #f8fbff;
padding: 14px;
display: grid;
gap: 12px;
}
.backup-recommendation-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.backup-recommendation-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.backup-recommendation-steps {
display: grid;
gap: 8px;
}
.backup-recommendation-step {
color: #475467;
line-height: 1.5;
}
.backup-recommendation-inline-note {
color: #475467;
line-height: 1.5;
}
.backup-recommendation-dav-list {
display: grid;
gap: 8px;
}
.backup-recommendation-dav-item {
display: grid;
gap: 4px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
}
.backup-recommendation-dav-item code {
overflow-wrap: anywhere;
}
.backup-recommendation-referral {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: #475467;
}
.backup-destination-list {
display: grid;
gap: 8px;
}
.backup-destination-item {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
padding: 12px;
text-align: left;
display: grid;
gap: 6px;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.backup-destination-item:hover {
border-color: #93c5fd;
background: #f8fbff;
}
.backup-destination-item.active {
border-color: var(--primary);
background: #eff6ff;
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.08);
}
.backup-destination-top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.backup-destination-name {
font-weight: 700;
color: #0f172a;
overflow-wrap: anywhere;
}
.backup-destination-type {
border-radius: 999px;
padding: 2px 8px;
background: #e2e8f0;
color: #334155;
font-size: 12px;
white-space: nowrap;
}
.backup-destination-meta {
color: #64748b;
font-size: 13px;
line-height: 1.4;
}
.backup-destination-addbar {
margin-top: 10px;
}
.backup-add-chooser {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.backup-schedule-current {
display: grid;
gap: 4px;
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: #f8fafc;
color: #475467;
}
.backup-schedule-current strong {
color: #0f172a;
}
.backup-name-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
align-items: end;
margin-bottom: 8px;
}
.backup-name-field {
margin: 0;
grid-column: 1 / span 3;
}
.backup-type-field {
margin: 0;
grid-column: 4;
}
.backup-detail-schedule-grid {
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
margin-bottom: 12px;
}
.backup-retention-input {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
}
.backup-retention-input .input {
min-width: 0;
width: 100%;
}
.backup-retention-suffix {
color: #475467;
white-space: nowrap;
}
.backup-combined-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.backup-status-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: #f8fbff;
margin-bottom: 12px;
}
.backup-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 14px;
color: #475467;
line-height: 1.45;
}
.backup-status-grid strong {
display: block;
margin-bottom: 4px;
color: #0f172a;
}
.backup-status-error {
margin-top: 12px;
}
.backup-divider {
height: 1px;
background: var(--line);
margin: 14px 0;
}
.backup-remote-panel {
margin-top: 0;
}
.backup-browser-path {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #f8fafc;
margin-bottom: 10px;
overflow-wrap: anywhere;
}
.backup-browser-path strong {
color: #0f172a;
}
.backup-browser-nav {
margin-bottom: 10px;
}
.backup-browser-list {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
background: #fff;
}
.backup-browser-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-top: 10px;
}
.backup-browser-page-indicator {
min-width: 48px;
text-align: center;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.backup-browser-row + .backup-browser-row {
border-top: 1px solid var(--line);
}
.backup-browser-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
padding: 10px 12px;
}
.backup-browser-entry {
border: none;
background: transparent;
text-align: left;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0;
color: #0f172a;
cursor: pointer;
}
.backup-browser-entry.file {
cursor: default;
}
.backup-browser-name {
font-weight: 700;
overflow-wrap: anywhere;
}
.backup-browser-meta {
display: grid;
justify-items: end;
gap: 4px;
color: #64748b;
font-size: 13px;
text-align: right;
}
.backup-browser-actions {
justify-content: flex-end;
}
.backup-browser-empty {
border: 1px dashed var(--line);
border-radius: 12px;
padding: 18px 14px;
text-align: center;
color: #64748b;
}
.backup-list {
@@ -1921,7 +2315,10 @@ input[type='file'].input::file-selector-button:hover {
}
.import-export-feature-grid,
.import-export-panels {
.import-export-panels,
.backup-combined-grid,
.backup-status-grid,
.backup-browser-row {
grid-template-columns: 1fr;
}
@@ -2386,6 +2783,8 @@ input[type='file'].input::file-selector-button:hover {
}
.import-export-panels,
.backup-combined-grid,
.backup-status-grid,
.settings-twofactor-grid {
gap: 10px;
}
@@ -2541,10 +2940,42 @@ input[type='file'].input::file-selector-button:hover {
font-size: 16px;
}
.toast-stack {
top: 10px;
left: 10px;
right: 10px;
width: auto;
.toast-stack {
top: 10px;
left: 10px;
right: 10px;
width: auto;
}
}
@media (max-width: 900px) {
.backup-grid {
grid-template-columns: 1fr;
}
.backup-operations-sidebar,
.backup-destination-sidebar {
position: static;
}
}
@media (max-width: 640px) {
.backup-status-grid,
.backup-browser-row,
.field-grid {
grid-template-columns: 1fr;
}
.backup-destination-top {
align-items: flex-start;
flex-direction: column;
}
.backup-add-chooser {
flex-direction: column;
}
.backup-name-row {
grid-template-columns: 1fr;
}
}
+3 -2
View File
@@ -8,7 +8,8 @@
"jsxImportSource": "preact",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["src/*"],
"@shared/*": ["../shared/*"]
},
"strict": true,
"noEmit": true,
@@ -18,5 +19,5 @@
"resolveJsonModule": true,
"types": ["vite/client"]
},
"include": ["src/**/*", "vite.config.ts"]
"include": ["src/**/*", "../shared/**/*", "vite.config.ts"]
}
+4
View File
@@ -11,6 +11,7 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(rootDir, 'src'),
'@shared': path.resolve(rootDir, '../shared'),
},
},
build: {
@@ -30,6 +31,9 @@ export default defineConfig({
},
server: {
port: 5173,
fs: {
allow: [path.resolve(rootDir, '..')],
},
proxy: {
'/api': 'http://127.0.0.1:8787',
'/identity': 'http://127.0.0.1:8787',