d932444b78
* fix(settings): clamp option-row description to a single line SettingsRow descriptions wrapped to multiple lines on narrow (mobile) widths, giving boxed-list rows uneven heights (e.g. "Uploads book files to your other devices." in the Cloud Sync panel). Clamp the description to one line with ellipsis in the shared primitive so every option row stays uniform; the description is a hint, not a paragraph (longer copy belongs in a Tips block). Codified in DESIGN.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(settings): shorten sync strategy labels to "Send only" / "Receive only" Rename the Sync Strategy options (shared by the Cloud Sync and KOReader Sync forms). Keys renamed in every locale, preserving existing translations. * feat(sync): temporarily ungate third-party cloud sync from premium Cloud sync (WebDAV / Google Drive) ships available to every plan, incl. free, while the feature stabilises. Gated behind a single CLOUD_SYNC_REQUIRES_PREMIUM flag (off) via isCloudSyncAllowed; the paywall code (CLOUD_SYNC_PLANS / isCloudSyncInPlan) is intact, so re-gating in an upcoming release is a one-line flip. Applies to the Settings provider rows and the reader auto-sync gate. * fix(settings): polish cloud-sync connect buttons Use btn-contrast for the WebDAV and Google Drive Connect CTAs (theme-neutral, e-ink correct); rename "Connect Google Drive" to "Connect"; move the Google Drive sign-in tips below the Connect button. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import { md5 } from 'js-md5';
|
|
import { type as osType } from '@tauri-apps/plugin-os';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { KOSyncClient } from '@/services/sync/KOSyncClient';
|
|
import { KOSyncChecksumMethod, KOSyncStrategy } from '@/types/settings';
|
|
import { debounce } from '@/utils/debounce';
|
|
import { getOSPlatform } from '@/utils/misc';
|
|
import SubPageHeader from '../SubPageHeader';
|
|
import { SectionTitle, SettingLabel, SettingsSelect } from '../primitives';
|
|
|
|
interface KOSyncFormProps {
|
|
onBack: () => void;
|
|
}
|
|
|
|
const KOSyncForm: React.FC<KOSyncFormProps> = ({ onBack }) => {
|
|
const _ = useTranslation();
|
|
const { settings, setSettings, saveSettings } = useSettingsStore();
|
|
const { envConfig, appService } = useEnv();
|
|
|
|
const [url, setUrl] = useState(settings.kosync.serverUrl || '');
|
|
const [username, setUsername] = useState(settings.kosync.username || '');
|
|
const [password, setPassword] = useState('');
|
|
const [isConnecting, setIsConnecting] = useState(false);
|
|
const [deviceName, setDeviceName] = useState('');
|
|
const [osName, setOsName] = useState('');
|
|
|
|
useEffect(() => {
|
|
const formatOsName = (name: string): string => {
|
|
if (!name) return '';
|
|
if (name.toLowerCase() === 'macos') return 'macOS';
|
|
if (name.toLowerCase() === 'ios') return 'iOS';
|
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
};
|
|
|
|
const getOsName = async () => {
|
|
let name = '';
|
|
if (appService?.appPlatform === 'tauri') {
|
|
name = await osType();
|
|
} else {
|
|
const platform = getOSPlatform();
|
|
if (platform !== 'unknown') {
|
|
name = platform;
|
|
}
|
|
}
|
|
setOsName(formatOsName(name));
|
|
};
|
|
getOsName();
|
|
}, [appService]);
|
|
|
|
useEffect(() => {
|
|
const defaultName = osName ? `Readest (${osName})` : 'Readest';
|
|
setDeviceName(settings.kosync.deviceName || defaultName);
|
|
}, [settings.kosync.deviceName, osName]);
|
|
|
|
const isConfigured = useMemo(() => !!settings.kosync.userkey, [settings.kosync.userkey]);
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const debouncedSaveDeviceName = useCallback(
|
|
debounce((newDeviceName: string) => {
|
|
const newSettings = {
|
|
...settings,
|
|
kosync: { ...settings.kosync, deviceName: newDeviceName },
|
|
};
|
|
setSettings(newSettings);
|
|
saveSettings(envConfig, newSettings);
|
|
}, 500),
|
|
[settings, setSettings, saveSettings, envConfig],
|
|
);
|
|
|
|
const handleDeviceNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newName = e.target.value;
|
|
setDeviceName(newName);
|
|
debouncedSaveDeviceName(newName);
|
|
};
|
|
|
|
const handleConnect = async () => {
|
|
setIsConnecting(true);
|
|
const config = {
|
|
...settings.kosync,
|
|
serverUrl: url,
|
|
username,
|
|
userkey: md5(password),
|
|
password,
|
|
deviceName,
|
|
enabled: true,
|
|
};
|
|
const client = new KOSyncClient(config);
|
|
const result = await client.connect(username, password);
|
|
|
|
if (result.success) {
|
|
const newSettings = { ...settings, kosync: config };
|
|
setSettings(newSettings);
|
|
await saveSettings(envConfig, newSettings);
|
|
} else {
|
|
eventDispatcher.dispatch('toast', {
|
|
message: `${_('Failed to connect')}: ${_(result.message || 'Connection error')}`,
|
|
type: 'error',
|
|
});
|
|
}
|
|
setIsConnecting(false);
|
|
setPassword('');
|
|
};
|
|
|
|
const handleDisconnect = async () => {
|
|
const kosync = { ...settings.kosync, userkey: '', enabled: false };
|
|
const newSettings = { ...settings, kosync };
|
|
setSettings(newSettings);
|
|
await saveSettings(envConfig, newSettings);
|
|
setUsername('');
|
|
eventDispatcher.dispatch('toast', { message: _('Disconnected'), type: 'info' });
|
|
};
|
|
|
|
const handleToggleEnabled = async () => {
|
|
const kosync = { ...settings.kosync, enabled: !settings.kosync.enabled };
|
|
const newSettings = { ...settings, kosync };
|
|
setSettings(newSettings);
|
|
await saveSettings(envConfig, newSettings);
|
|
};
|
|
|
|
const handleStrategyChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
const kosync = { ...settings.kosync, strategy: e.target.value as KOSyncStrategy };
|
|
const newSettings = { ...settings, kosync };
|
|
setSettings(newSettings);
|
|
await saveSettings(envConfig, newSettings);
|
|
};
|
|
|
|
const handleChecksumMethodChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
const kosync = {
|
|
...settings.kosync,
|
|
checksumMethod: e.target.value as KOSyncChecksumMethod,
|
|
};
|
|
const newSettings = { ...settings, kosync };
|
|
setSettings(newSettings);
|
|
await saveSettings(envConfig, newSettings);
|
|
};
|
|
|
|
const description: string = isConfigured
|
|
? _('Sync as {{userDisplayName}}', { userDisplayName: settings.kosync.username })
|
|
: _('Connect to your KOReader Sync server.');
|
|
|
|
return (
|
|
<div className='w-full'>
|
|
<SubPageHeader
|
|
parentLabel={_('Integrations')}
|
|
currentLabel={_('KOReader Sync')}
|
|
description={description}
|
|
onBack={onBack}
|
|
/>
|
|
|
|
{isConfigured ? (
|
|
<div className='space-y-5'>
|
|
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
|
|
<div className='divide-base-200 divide-y'>
|
|
{/* Each row uses min-h-14 + items-center so toggle/select/input
|
|
rows render at a uniform height regardless of the embedded
|
|
control's intrinsic size. Selects and inputs are end-aligned
|
|
to match modern preferences-panel convention. */}
|
|
<label className='flex min-h-14 items-center justify-between px-4'>
|
|
<SettingLabel>{_('Sync Server Connected')}</SettingLabel>
|
|
<input
|
|
type='checkbox'
|
|
className='toggle'
|
|
checked={settings.kosync.enabled}
|
|
onChange={handleToggleEnabled}
|
|
/>
|
|
</label>
|
|
{/* SettingsSelect handles the chromeless treatment, the
|
|
custom MdArrowDropDown icon at the trailing edge (so the
|
|
chevron lands at the same X as the toggle's right edge),
|
|
and the focus/hover signal — see DESIGN.md §5. */}
|
|
<div className='flex min-h-14 items-center justify-between gap-3 px-4'>
|
|
<SettingLabel>{_('Sync Strategy')}</SettingLabel>
|
|
<SettingsSelect
|
|
value={settings.kosync.strategy}
|
|
onChange={handleStrategyChange}
|
|
ariaLabel={_('Sync Strategy')}
|
|
options={[
|
|
{ value: 'prompt', label: _('Ask on conflict') },
|
|
{ value: 'silent', label: _('Always use latest') },
|
|
{ value: 'send', label: _('Send only') },
|
|
{ value: 'receive', label: _('Receive only') },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className='flex min-h-14 items-center justify-between gap-3 px-4'>
|
|
<SettingLabel>{_('Checksum Method')}</SettingLabel>
|
|
<SettingsSelect
|
|
value={settings.kosync.checksumMethod}
|
|
onChange={handleChecksumMethodChange}
|
|
ariaLabel={_('Checksum Method')}
|
|
options={[{ value: 'binary', label: _('File Content') }]}
|
|
/>
|
|
</div>
|
|
<div className='-me-2 flex min-h-14 items-center justify-between gap-3 px-4'>
|
|
<SettingLabel>{_('Device Name')}</SettingLabel>
|
|
<input
|
|
type='text'
|
|
placeholder={osName ? `Readest (${osName})` : 'Readest'}
|
|
className='input h-9 max-w-[60%] rounded-md !border-0 !bg-transparent !pe-3 !ps-2 text-end text-sm hover:!bg-transparent focus:!border-0 focus:!bg-transparent focus:!shadow-none focus:!outline-none focus:!ring-0'
|
|
value={deviceName}
|
|
onChange={handleDeviceNameChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='flex justify-end'>
|
|
<button
|
|
type='button'
|
|
onClick={handleDisconnect}
|
|
className={clsx(
|
|
'eink-bordered',
|
|
'h-10 rounded-lg px-4 text-sm font-medium',
|
|
'text-error hover:bg-error/10',
|
|
'transition-colors duration-150',
|
|
'focus-visible:ring-error/40 focus-visible:outline-none focus-visible:ring-2',
|
|
)}
|
|
>
|
|
{_('Disconnect')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className='space-y-5'>
|
|
<form
|
|
className='space-y-4'
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
handleConnect();
|
|
}}
|
|
>
|
|
<div className='space-y-1.5'>
|
|
<SectionTitle as='label' htmlFor='kosync-server-url' className='block'>
|
|
{_('Server URL')}
|
|
</SectionTitle>
|
|
<input
|
|
id='kosync-server-url'
|
|
type='text'
|
|
placeholder='https://koreader.sync.server'
|
|
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
|
|
spellCheck='false'
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className='space-y-1.5'>
|
|
<SectionTitle as='label' htmlFor='kosync-username' className='block'>
|
|
{_('Username')}
|
|
</SectionTitle>
|
|
<input
|
|
id='kosync-username'
|
|
type='text'
|
|
placeholder={_('Your Username')}
|
|
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
|
|
spellCheck='false'
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
autoComplete='username'
|
|
/>
|
|
</div>
|
|
|
|
<div className='space-y-1.5'>
|
|
<SectionTitle as='label' htmlFor='kosync-password' className='block'>
|
|
{_('Password')}
|
|
</SectionTitle>
|
|
<input
|
|
id='kosync-password'
|
|
type='password'
|
|
placeholder={_('Your Password')}
|
|
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
autoComplete='current-password'
|
|
/>
|
|
</div>
|
|
|
|
<div className='flex justify-end pt-1'>
|
|
<button
|
|
type='submit'
|
|
disabled={isConnecting || !url || !username || !password}
|
|
className={clsx(
|
|
'btn btn-primary',
|
|
'h-10 min-h-10 rounded-lg border-0 px-5 text-sm font-medium',
|
|
'focus-visible:ring-primary/40 focus-visible:outline-none focus-visible:ring-2',
|
|
isConnecting && 'opacity-60',
|
|
)}
|
|
>
|
|
{isConnecting ? (
|
|
<span className='loading loading-spinner loading-sm' />
|
|
) : (
|
|
_('Connect')
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default KOSyncForm;
|