forked from akai/readest
ccb937015d
* fix(sync): record remote-present files for no-source books in the upload cursor With "Upload Book Files" on, a device that holds no local copy of a book (e.g. the web app with a cloud-only library) HEAD-probed the remote for every book on every sync: pushBookFile returned 'no-source' and the hash was never recorded in library.json's uploadedHashes, so needsFilePush stayed true for the whole library and each run (tab focus, Sync Now, library change) issued one Drive/WebDAV request per book, 646 requests per sweep in the reported case. The HEAD probe already answers whether the file is on the remote. Carry that in PushBookFileResult.remoteExists and record the hash when a no-source book's file is already mirrored, so the next incremental sync skips it and stays O(changed). Books absent both locally and remotely stay unrecorded so a device that has the bytes can upload them later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sync): reach the no-source verdict without probing the remote A book file sync with "Upload Book Files" on probed the remote for every book before checking whether this device even holds the bytes. On a device with a cloud-only library (the web app), none of the books have a local source, so every sync run (tab focus, Sync Now, library change) issued one name-lookup request per book against Google Drive, a full per-book request storm that never converged: with no local file there is nothing to upload and nothing to record, so the next run repeated it. Resolve the local source first and return 'no-source' from local state alone; the remote head probe now runs only when there is a local file to compare or upload. The probe keeps its non-NETWORK rethrow semantics so the auth-failure latch (#4981) still stops a run on an expired session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(sync): incremental file sync and per-book transfers for the active provider Cut the redundant remote work a file-sync run does and route explicit per-book uploads/downloads to the active third-party provider (WebDAV / Google Drive) instead of the gated Readest Cloud transfer queue. Engine (services/sync/file/engine.ts, wire.ts): - etag change-probe: one HEAD on library.json, cached per provider for the session. When the etag matches the last successful pull, reuse the cached index and skip both the index download and the discovery scan. An AUTH failure on the probe aborts the run like the full pull does. - emptyDirs memo carried in the index: dirs found to hold no book file are recorded so clients stop re-listing them every run. Re-checked when the file arrives (uploadedHashes), on Full Sync, or when a legacy client drops the record; pruned only against a listing that actually ran. - skip the index re-push when the rebuilt index is semantically identical to the pulled one, so a restamped byte-copy no longer churns the remote and invalidates peers' etag change detection. - downloadBookFile() for the explicit per-book Download action. Provider reuse (services/sync/file/providerRegistry.ts): - memoise one provider per connection key, shared by every surface (reader per-book sync, library auto-sync, Sync now / pull to refresh). Reuses the Drive path->id cache instead of re-resolving /Readest, books/ and library.json by name query on every engine build. Drive connect/disconnect resets the cache since its token source changes identity with no key input changing. Google Drive (services/sync/providers/gdrive/GoogleDriveProvider.ts): - write fast-path: PATCH a path whose id is cached in place with no lookup, falling back to a full resolve on a 404 (stale id). Removes a files.list per PUT in the steady state. - dev-only request diagnostics: one line per provider op and per HTTP attempt so a run's request budget can be attributed from the console. Per-book transfers (services/sync/file/runLibrarySync.ts): - runActiveFileBookUpload / runActiveFileBookDownload build the active provider's engine and push/pull a single book, stamping downloadedAt like the native path. Wired into the reader/library book actions with toasts. UI, status, and i18n: - Readest Cloud sub-page: drop the quota stats and wrap the "Account and Storage" row in the BoxedList primitive so it aligns to the design system. - Shorten provider status/toast copy ("Active", "Google Drive session expired", "Library sync via {{provider}}", "KOReader") and translate the new keys across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
523 lines
19 KiB
TypeScript
523 lines
19 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { PiUserCircle, PiUserCircleCheck, PiGear } from 'react-icons/pi';
|
|
import { PiSun, PiMoon } from 'react-icons/pi';
|
|
import { TbSunMoon } from 'react-icons/tb';
|
|
import { MdCloudSync, MdSync, MdSyncProblem } from 'react-icons/md';
|
|
|
|
import { invoke, PermissionState } from '@tauri-apps/api/core';
|
|
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
|
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
|
import { setBackupDialogVisible } from '@/app/library/components/BackupWindow';
|
|
import { setCacheManagerDialogVisible } from '@/app/library/components/CacheManagerWindow';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useThemeStore } from '@/store/themeStore';
|
|
import { useQuotaStats } from '@/hooks/useQuotaStats';
|
|
import { useFileSyncStore } from '@/store/fileSyncStore';
|
|
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
|
|
import { useLibraryStore } from '@/store/libraryStore';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
|
import { useTransferQueue } from '@/hooks/useTransferQueue';
|
|
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
|
|
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
|
|
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
|
import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow';
|
|
import { requestStoragePermission } from '@/utils/permission';
|
|
import { saveSysSettings } from '@/helpers/settings';
|
|
import {
|
|
getBiometricStatus,
|
|
getBiometryLabelKey,
|
|
isBiometricSupported,
|
|
} from '@/services/biometric';
|
|
import { selectDirectory } from '@/utils/bridge';
|
|
import dayjs from 'dayjs';
|
|
import UserAvatar from '@/components/UserAvatar';
|
|
import MenuItem from '@/components/MenuItem';
|
|
import Quota from '@/components/Quota';
|
|
import Menu from '@/components/Menu';
|
|
import { type AppLockDialogMode, useAppLockStore } from '@/store/appLockStore';
|
|
|
|
interface SettingsMenuProps {
|
|
onPullLibrary: (fullRefresh?: boolean, verbose?: boolean) => void;
|
|
setIsDropdownOpen?: (isOpen: boolean) => void;
|
|
}
|
|
|
|
interface Permissions {
|
|
postNotification: PermissionState;
|
|
manageStorage: PermissionState;
|
|
}
|
|
|
|
const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdownOpen }) => {
|
|
const _ = useTranslation();
|
|
const router = useRouter();
|
|
const { envConfig, appService } = useEnv();
|
|
const { user } = useAuth();
|
|
const { userProfilePlan, quotas } = useQuotaStats(true);
|
|
const { themeMode, setThemeMode } = useThemeStore();
|
|
const { settings, setSettingsDialogOpen } = useSettingsStore();
|
|
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
|
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
|
|
const [isAlwaysShowStatusBar, setIsAlwaysShowStatusBar] = useState(settings.alwaysShowStatusBar);
|
|
const [isOpenLastBooks, setIsOpenLastBooks] = useState(settings.openLastBooks);
|
|
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
|
|
settings.autoImportBooksOnOpen,
|
|
);
|
|
const [alwaysInForeground, setAlwaysInForeground] = useState(settings.alwaysInForeground);
|
|
const [savedBookCoverForLockScreen, setSavedBookCoverForLockScreen] = useState(
|
|
settings.savedBookCoverForLockScreen || '',
|
|
);
|
|
const iconSize = useResponsiveSize(16);
|
|
|
|
const [isRefreshingMetadata, setIsRefreshingMetadata] = useState(false);
|
|
const [refreshMetadataProgress, setRefreshMetadataProgress] = useState('');
|
|
const { openDialog: openAppLockDialogInStore } = useAppLockStore();
|
|
const isPinEnabled = !!settings.pinCodeEnabled;
|
|
const [biometricAvailable, setBiometricAvailable] = useState(false);
|
|
const [biometryLabelKey, setBiometryLabelKey] = useState('');
|
|
const showBiometricToggle = !!appService?.isMobileApp && isPinEnabled && biometricAvailable;
|
|
|
|
useEffect(() => {
|
|
if (!isBiometricSupported(appService) || !isPinEnabled) return;
|
|
let cancelled = false;
|
|
void getBiometricStatus().then(({ available, biometryType }) => {
|
|
if (cancelled) return;
|
|
setBiometricAvailable(available);
|
|
setBiometryLabelKey(getBiometryLabelKey(biometryType));
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [appService, isPinEnabled]);
|
|
|
|
const toggleBiometricUnlock = () => {
|
|
void saveSysSettings(envConfig, 'biometricUnlockEnabled', !settings.biometricUnlockEnabled);
|
|
};
|
|
|
|
const openAppLockDialog = (mode: AppLockDialogMode) => {
|
|
openAppLockDialogInStore(mode);
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
const { isSyncing, setLibrary } = useLibraryStore();
|
|
const fileSyncByKind = useFileSyncStore((s) => s.byKind);
|
|
const fileSyncLastError = useFileSyncStore((s) => s.lastErrorByKind);
|
|
const { stats, hasActiveTransfers, setIsTransferQueueOpen } = useTransferQueue();
|
|
|
|
const openTransferQueue = () => {
|
|
setIsTransferQueueOpen(true);
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const showAboutReadest = () => {
|
|
setAboutDialogVisible(true);
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const downloadReadest = () => {
|
|
window.open(DOWNLOAD_READEST_URL, '_blank');
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const handleUserLogin = () => {
|
|
navigateToLogin(router);
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const handleUserProfile = () => {
|
|
navigateToProfile(router);
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const handleManageSync = () => {
|
|
router.push('/user?section=sync');
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const cycleThemeMode = () => {
|
|
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
|
|
setThemeMode(nextMode);
|
|
};
|
|
|
|
const handleFullScreen = () => {
|
|
tauriHandleToggleFullScreen();
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const toggleOpenInNewWindow = () => {
|
|
saveSysSettings(envConfig, 'openBookInNewWindow', !settings.openBookInNewWindow);
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const toggleAlwaysOnTop = () => {
|
|
const newValue = !settings.alwaysOnTop;
|
|
saveSysSettings(envConfig, 'alwaysOnTop', newValue);
|
|
setIsAlwaysOnTop(newValue);
|
|
tauriHandleSetAlwaysOnTop(newValue);
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const toggleAlwaysShowStatusBar = () => {
|
|
const newValue = !settings.alwaysShowStatusBar;
|
|
saveSysSettings(envConfig, 'alwaysShowStatusBar', newValue);
|
|
setIsAlwaysShowStatusBar(newValue);
|
|
};
|
|
|
|
const toggleAutoUploadBooks = () => {
|
|
const newValue = !settings.autoUpload;
|
|
saveSysSettings(envConfig, 'autoUpload', newValue);
|
|
setIsAutoUpload(newValue);
|
|
|
|
if (newValue && !user) {
|
|
navigateToLogin(router);
|
|
}
|
|
};
|
|
|
|
const toggleAutoImportBooksOnOpen = () => {
|
|
const newValue = !settings.autoImportBooksOnOpen;
|
|
saveSysSettings(envConfig, 'autoImportBooksOnOpen', newValue);
|
|
setIsAutoImportBooksOnOpen(newValue);
|
|
};
|
|
|
|
const toggleOpenLastBooks = () => {
|
|
const newValue = !settings.openLastBooks;
|
|
saveSysSettings(envConfig, 'openLastBooks', newValue);
|
|
setIsOpenLastBooks(newValue);
|
|
};
|
|
|
|
const handleUpgrade = () => {
|
|
navigateToProfile(router);
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const handleSetRootDir = () => {
|
|
setMigrateDataDirDialogVisible(true);
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const handleBackupRestore = () => {
|
|
setIsDropdownOpen?.(false);
|
|
setBackupDialogVisible(true);
|
|
};
|
|
|
|
const handleManageCache = () => {
|
|
setIsDropdownOpen?.(false);
|
|
setCacheManagerDialogVisible(true);
|
|
};
|
|
|
|
const handleRefreshMetadata = async () => {
|
|
if (!appService || isRefreshingMetadata) return;
|
|
setIsRefreshingMetadata(true);
|
|
setRefreshMetadataProgress(_('Loading library...'));
|
|
try {
|
|
const books = await appService.loadLibraryBooks();
|
|
const activeBooks = books.filter((b) => !b.deletedAt);
|
|
let refreshed = 0;
|
|
for (let i = 0; i < activeBooks.length; i++) {
|
|
setRefreshMetadataProgress(`${i + 1} / ${activeBooks.length}`);
|
|
try {
|
|
if (await appService.refreshBookMetadata(activeBooks[i]!)) {
|
|
refreshed++;
|
|
}
|
|
} catch {
|
|
// Skip books whose files can't be opened
|
|
}
|
|
}
|
|
setLibrary(books);
|
|
await appService.saveLibraryBooks(books);
|
|
setRefreshMetadataProgress(_('{{count}} books refreshed', { count: refreshed }));
|
|
onPullLibrary(true);
|
|
setTimeout(() => {
|
|
setIsRefreshingMetadata(false);
|
|
setRefreshMetadataProgress('');
|
|
}, 2000);
|
|
} catch (error) {
|
|
console.error('Failed to refresh metadata:', error);
|
|
setRefreshMetadataProgress(_('Failed to refresh metadata'));
|
|
setTimeout(() => {
|
|
setIsRefreshingMetadata(false);
|
|
setRefreshMetadataProgress('');
|
|
}, 2000);
|
|
}
|
|
};
|
|
|
|
const openSettingsDialog = () => {
|
|
setIsDropdownOpen?.(false);
|
|
setSettingsDialogOpen(true);
|
|
};
|
|
|
|
const handleSetSavedBookCoverForLockScreen = async () => {
|
|
if (!(await requestStoragePermission()) && appService?.distChannel === 'readest') return;
|
|
|
|
const newValue = settings.savedBookCoverForLockScreen ? '' : 'default';
|
|
if (newValue) {
|
|
const response = await selectDirectory();
|
|
if (response.path) {
|
|
saveSysSettings(envConfig, 'savedBookCoverForLockScreenPath', response.path);
|
|
}
|
|
}
|
|
saveSysSettings(envConfig, 'savedBookCoverForLockScreen', newValue);
|
|
setSavedBookCoverForLockScreen(newValue);
|
|
};
|
|
|
|
const toggleAlwaysInForeground = async () => {
|
|
const requestAlwaysInForeground = !settings.alwaysInForeground;
|
|
|
|
if (requestAlwaysInForeground) {
|
|
let permission = await invoke<Permissions>('plugin:native-tts|checkPermissions');
|
|
if (permission.postNotification !== 'granted') {
|
|
permission = await invoke<Permissions>('plugin:native-tts|requestPermissions', {
|
|
permissions: ['postNotification'],
|
|
});
|
|
}
|
|
if (permission.postNotification !== 'granted') return;
|
|
}
|
|
|
|
saveSysSettings(envConfig, 'alwaysInForeground', requestAlwaysInForeground);
|
|
setAlwaysInForeground(requestAlwaysInForeground);
|
|
};
|
|
|
|
const handleSyncLibrary = () => {
|
|
onPullLibrary(true, true);
|
|
setIsDropdownOpen?.(false);
|
|
};
|
|
|
|
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
|
|
const userFullName = user?.user_metadata?.['full_name'];
|
|
const userDisplayName = userFullName ? userFullName.split(' ')[0] : null;
|
|
const themeModeLabel =
|
|
themeMode === 'dark'
|
|
? _('Dark Mode')
|
|
: themeMode === 'light'
|
|
? _('Light Mode')
|
|
: _('Auto Mode');
|
|
|
|
const savedBookCoverPath = settings.savedBookCoverForLockScreenPath;
|
|
const coverDir = savedBookCoverPath ? savedBookCoverPath.split('/').pop() : 'Images';
|
|
const savedBookCoverDescription = `💾 ${coverDir}/last-book-cover.png`;
|
|
|
|
// While a third-party provider is selected the native cursors freeze (the
|
|
// book/progress/note channels are gated), so the sync row must report the
|
|
// file engine's health instead — otherwise it reads "Synced 3 months ago"
|
|
// forever and looks broken.
|
|
const cloudProvider = getCloudSyncProvider(settings);
|
|
const cloudProviderName = cloudProvider === 'gdrive' ? 'Google Drive' : 'WebDAV';
|
|
const providerSyncing = cloudProvider !== 'readest' && !!fileSyncByKind[cloudProvider]?.isSyncing;
|
|
const providerLastError =
|
|
cloudProvider !== 'readest' ? fileSyncLastError[cloudProvider] : undefined;
|
|
const lastSyncTime =
|
|
cloudProvider !== 'readest'
|
|
? (cloudProvider === 'gdrive'
|
|
? settings.googleDrive?.lastSyncedAt
|
|
: settings.webdav?.lastSyncedAt) || 0
|
|
: Math.max(
|
|
settings.lastSyncedAtBooks || 0,
|
|
settings.lastSyncedAtConfigs || 0,
|
|
settings.lastSyncedAtNotes || 0,
|
|
);
|
|
const syncRowLabel =
|
|
cloudProvider !== 'readest'
|
|
? providerLastError
|
|
? _('Sync failed')
|
|
: lastSyncTime
|
|
? _('Synced {{time}}', {
|
|
time: dayjs(lastSyncTime).fromNow(),
|
|
})
|
|
: _('Never synced')
|
|
: lastSyncTime
|
|
? _('Synced {{time}}', { time: dayjs(lastSyncTime).fromNow() })
|
|
: _('Never synced');
|
|
|
|
return (
|
|
<Menu
|
|
className={clsx(
|
|
'settings-menu dropdown-content no-triangle',
|
|
'z-20 mt-2 max-w-[90vw] shadow-2xl',
|
|
)}
|
|
onCancel={() => setIsDropdownOpen?.(false)}
|
|
>
|
|
{user ? (
|
|
<MenuItem
|
|
label={
|
|
userDisplayName
|
|
? _('Logged in as {{userDisplayName}}', { userDisplayName })
|
|
: _('Logged in')
|
|
}
|
|
labelClass='!max-w-40'
|
|
aria-label={_('View account details and quota')}
|
|
Icon={
|
|
avatarUrl ? (
|
|
<UserAvatar url={avatarUrl} size={iconSize} DefaultIcon={PiUserCircleCheck} />
|
|
) : (
|
|
PiUserCircleCheck
|
|
)
|
|
}
|
|
>
|
|
<ul className='ms-0 flex flex-col ps-0 before:hidden'>
|
|
<MenuItem
|
|
label={_('Cloud File Transfers')}
|
|
Icon={MdCloudSync}
|
|
description={
|
|
hasActiveTransfers
|
|
? _('{{activeCount}} active, {{pendingCount}} pending', {
|
|
activeCount: stats.active,
|
|
pendingCount: stats.pending,
|
|
})
|
|
: stats.failed > 0
|
|
? _('{{failedCount}} failed', { failedCount: stats.failed })
|
|
: ''
|
|
}
|
|
onClick={openTransferQueue}
|
|
/>
|
|
<MenuItem
|
|
label={syncRowLabel}
|
|
Icon={user ? MdSync : MdSyncProblem}
|
|
labelClass='ps-2 pe-1 !mx-0'
|
|
iconClassName={(user && isSyncing) || providerSyncing ? 'animate-reverse-spin' : ''}
|
|
onClick={handleSyncLibrary}
|
|
description={
|
|
cloudProvider !== 'readest'
|
|
? _('Library sync via {{provider}}', {
|
|
provider: cloudProviderName,
|
|
})
|
|
: undefined
|
|
}
|
|
/>
|
|
{cloudProvider === 'readest' ? (
|
|
<button
|
|
onClick={handleUserProfile}
|
|
className='hover:bg-base-300 w-full rounded-md'
|
|
style={{
|
|
paddingInlineStart: `${iconSize}px`,
|
|
}}
|
|
>
|
|
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
|
|
</button>
|
|
) : null}
|
|
<MenuItem label={_('Account')} onClick={handleUserProfile} />
|
|
</ul>
|
|
</MenuItem>
|
|
) : (
|
|
<MenuItem label={_('Sign In')} Icon={PiUserCircle} onClick={handleUserLogin}></MenuItem>
|
|
)}
|
|
|
|
{cloudProvider === 'readest' && (
|
|
<MenuItem
|
|
label={_('Auto Upload Books to Cloud')}
|
|
toggled={isAutoUpload}
|
|
onClick={toggleAutoUploadBooks}
|
|
/>
|
|
)}
|
|
|
|
{isTauriAppPlatform() && (
|
|
<MenuItem
|
|
label={_('Auto Import on File Open')}
|
|
toggled={isAutoImportBooksOnOpen}
|
|
onClick={toggleAutoImportBooksOnOpen}
|
|
/>
|
|
)}
|
|
{isTauriAppPlatform() && (
|
|
<MenuItem
|
|
label={_('Open Last Book on Start')}
|
|
toggled={isOpenLastBooks}
|
|
onClick={toggleOpenLastBooks}
|
|
/>
|
|
)}
|
|
<hr aria-hidden='true' className='border-base-200 my-1' />
|
|
{appService?.hasWindow && (
|
|
<MenuItem
|
|
label={_('Open Book in New Window')}
|
|
toggled={settings.openBookInNewWindow}
|
|
onClick={toggleOpenInNewWindow}
|
|
/>
|
|
)}
|
|
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
|
|
{appService?.hasWindow && (
|
|
<MenuItem label={_('Always on Top')} toggled={isAlwaysOnTop} onClick={toggleAlwaysOnTop} />
|
|
)}
|
|
{appService?.isMobileApp && (
|
|
<MenuItem
|
|
label={_('Always Show Status Bar')}
|
|
toggled={isAlwaysShowStatusBar}
|
|
onClick={toggleAlwaysShowStatusBar}
|
|
/>
|
|
)}
|
|
{appService?.isAndroidApp && (
|
|
<MenuItem
|
|
label={_(_('Background Read Aloud'))}
|
|
toggled={alwaysInForeground}
|
|
onClick={toggleAlwaysInForeground}
|
|
/>
|
|
)}
|
|
<MenuItem
|
|
label={themeModeLabel}
|
|
Icon={themeMode === 'dark' ? PiMoon : themeMode === 'light' ? PiSun : TbSunMoon}
|
|
onClick={cycleThemeMode}
|
|
/>
|
|
<MenuItem label={_('Settings')} Icon={PiGear} onClick={openSettingsDialog} />
|
|
<hr aria-hidden='true' className='border-base-200 my-1' />
|
|
<MenuItem label={_('Advanced Settings')}>
|
|
<ul className='ms-0 flex flex-col ps-0 before:hidden'>
|
|
<MenuItem label={_('Backup & Restore')} onClick={handleBackupRestore} />
|
|
{appService?.canCustomizeRootDir && (
|
|
<MenuItem label={_('Change Data Location')} onClick={handleSetRootDir} />
|
|
)}
|
|
{user && <MenuItem label={_('Data Sync')} onClick={handleManageSync} />}
|
|
<MenuItem
|
|
label={_('Refresh Metadata')}
|
|
description={refreshMetadataProgress}
|
|
onClick={handleRefreshMetadata}
|
|
disabled={isRefreshingMetadata}
|
|
/>
|
|
{appService?.isMobileApp && (
|
|
<MenuItem label={_('Manage Cache')} onClick={handleManageCache} />
|
|
)}
|
|
{!isPinEnabled && (
|
|
<MenuItem
|
|
label={_('Set PIN…')}
|
|
tooltip={
|
|
appService?.isMobileApp
|
|
? _('Require a PIN (and biometrics, if available) to open Readest')
|
|
: _('Require a 4-digit PIN to open Readest')
|
|
}
|
|
onClick={() => openAppLockDialog('set')}
|
|
/>
|
|
)}
|
|
{isPinEnabled && (
|
|
<MenuItem label={_('Change PIN…')} onClick={() => openAppLockDialog('change')} />
|
|
)}
|
|
{isPinEnabled && (
|
|
<MenuItem label={_('Disable PIN…')} onClick={() => openAppLockDialog('disable')} />
|
|
)}
|
|
{showBiometricToggle && (
|
|
<MenuItem
|
|
label={_('Unlock with {{biometry}}', { biometry: _(biometryLabelKey) })}
|
|
toggled={!!settings.biometricUnlockEnabled}
|
|
onClick={toggleBiometricUnlock}
|
|
/>
|
|
)}
|
|
{appService?.isAndroidApp && appService?.distChannel !== 'playstore' && (
|
|
<MenuItem
|
|
label={_('Save Book Cover')}
|
|
tooltip={_('Auto-save last book cover')}
|
|
description={savedBookCoverForLockScreen ? savedBookCoverDescription : ''}
|
|
toggled={!!savedBookCoverForLockScreen}
|
|
onClick={handleSetSavedBookCoverForLockScreen}
|
|
/>
|
|
)}
|
|
</ul>
|
|
</MenuItem>
|
|
<hr aria-hidden='true' className='border-base-200 my-1' />
|
|
{user && userProfilePlan === 'free' && (
|
|
<MenuItem label={_('Upgrade to Readest Premium')} onClick={handleUpgrade} />
|
|
)}
|
|
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
|
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
|
|
</Menu>
|
|
);
|
|
};
|
|
|
|
export default SettingsMenu;
|