import clsx from 'clsx'; import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { MdChevronRight } from 'react-icons/md'; import { RiBookOpenLine, RiRssLine, RiBookReadLine, RiBook3Line, RiDiscordLine, RiSendPlaneLine, RiCloudLine, RiGoogleLine, } from 'react-icons/ri'; import { useEnv } from '@/context/EnvContext'; import { useAuth } from '@/context/AuthContext'; import { useTranslation } from '@/hooks/useTranslation'; import { useKeyDownActions } from '@/hooks/useKeyDownActions'; import { useQuotaStats } from '@/hooks/useQuotaStats'; import { useSettingsStore } from '@/store/settingsStore'; import { useCustomOPDSStore } from '@/store/customOPDSStore'; import { useFileSyncStore } from '@/store/fileSyncStore'; import { CatalogManager } from '@/app/opds/components/CatalogManager'; import { saveSysSettings } from '@/helpers/settings'; import { isCloudSyncInPlan } from '@/utils/access'; import { navigateToLogin, navigateToProfile } from '@/utils/nav'; import KOSyncForm from './integrations/KOSyncForm'; import ReadwiseForm from './integrations/ReadwiseForm'; import HardcoverForm from './integrations/HardcoverForm'; import SendToReadestForm from './integrations/SendToReadestForm'; import WebDAVForm from './integrations/WebDAVForm'; import GoogleDriveForm from './integrations/GoogleDriveForm'; import { withActiveCloudProvider } from './integrations/cloudSync'; import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry'; import SubPageHeader from './SubPageHeader'; import { SectionTitle, SettingLabel } from './primitives'; type SubPage = 'kosync' | 'webdav' | 'gdrive' | 'readwise' | 'hardcover' | 'opds' | 'send' | null; /** * Integrations panel — single point of discovery for external service config: * KOReader Sync, Readwise, Hardcover, and OPDS Catalogs. * * Pattern: boxed list of NavigationRows. Each row pushes the panel into an * inline sub-page (with breadcrumb back-navigation matching the Dictionaries * pattern) — no nested modals. * * TODO(design-system): Once we extract BoxedList / NavigationRow primitives, * this panel and CustomDictionaries should both consume them instead of * inlining the chassis. */ const IntegrationsPanel: React.FC = () => { const _ = useTranslation(); const router = useRouter(); const { envConfig, appService } = useEnv(); const { user } = useAuth(); const { settings, setSettings, saveSettings, requestedSubPage, setRequestedSubPage } = useSettingsStore(); const opdsCatalogs = useCustomOPDSStore((s) => s.catalogs); const opdsCount = opdsCatalogs.filter((c) => !c.deletedAt).length; // Surface a library-wide WebDAV sync that's mid-flight in the row's // status line. Keeps the user from feeling like the run was lost // when they back out of the WebDAV sub-page or close the dialog. const isWebDAVSyncing = useFileSyncStore((s) => s.byKind.webdav?.isSyncing ?? false); const isGDriveSyncing = useFileSyncStore((s) => s.byKind.gdrive?.isSyncing ?? false); // Third-party cloud sync is a premium feature (any paid plan). `undefined` // while the plan is still loading — treated as not-yet-premium. const { userProfilePlan } = useQuotaStats(); const isCloudSyncPremium = !!userProfilePlan && isCloudSyncInPlan(userProfilePlan); const [subPage, setSubPage] = useState(null); // Android Back / Esc: when any integrations sub-page (KOSync, WebDAV, // Readwise, Hardcover, OPDS, Send-to-Readest) is open, intercept and // step back to the integrations list instead of letting 's // listener close the whole Settings dialog. The hook registers its // sync `native-key-down` listener *after* 's, and // `dispatchSync` walks listeners LIFO — so this one claims Back first // when enabled and `return true` consumes the event. When subPage is // null the hook is disabled and Back falls through to close the dialog // as before. useKeyDownActions({ enabled: subPage !== null, onCancel: () => setSubPage(null), }); const toggleDiscordPresence = () => { const discordRichPresenceEnabled = !settings.discordRichPresenceEnabled; saveSysSettings(envConfig, 'discordRichPresenceEnabled', discordRichPresenceEnabled); if (discordRichPresenceEnabled && !user) { navigateToLogin(router); } }; // Deep-link consumption: when a caller (e.g. OPDS browser close handler) // sets `requestedSubPage` in the store before opening the dialog, drill // straight into that sub-page on mount and clear the request so it doesn't // stick to the next open. Recognised values match the SubPage union. useEffect(() => { if (!requestedSubPage) return; const isCloudRequest = requestedSubPage === 'webdav' || requestedSubPage === 'gdrive' || requestedSubPage === 'cloudsync'; // Cloud-sync sub-pages are premium-gated. If the plan is still loading, wait // (don't consume the request); once known, only honor it for paid plans. if (isCloudRequest && !isCloudSyncPremium) { if (userProfilePlan === undefined) return; setRequestedSubPage(null); return; } if ( requestedSubPage === 'kosync' || requestedSubPage === 'webdav' || requestedSubPage === 'gdrive' || requestedSubPage === 'readwise' || requestedSubPage === 'hardcover' || requestedSubPage === 'opds' || requestedSubPage === 'send' ) { setSubPage(requestedSubPage); } else if (requestedSubPage === 'cloudsync') { // Back-compat with the brief unified "Cloud Sync" page. setSubPage('gdrive'); } setRequestedSubPage(null); }, [requestedSubPage, setRequestedSubPage, isCloudSyncPremium, userProfilePlan]); // Sub-page wrapper matches the list-view's `my-4 w-full` so the // SubPageHeader's "Integrations" label lands at the exact same Y position // as the list-view's h2 — clicking a row reads as a navigation morph // rather than a layout shift. if (subPage === 'kosync') return (
setSubPage(null)} />
); if (subPage === 'webdav') return (
setSubPage(null)} />
); if (subPage === 'gdrive') return (
setSubPage(null)} />
); if (subPage === 'readwise') return (
setSubPage(null)} />
); if (subPage === 'hardcover') return (
setSubPage(null)} />
); if (subPage === 'opds') return (
setSubPage(null)} />
); if (subPage === 'send') return (
setSubPage(null)} />
); const koSyncStatus = settings.kosync?.enabled ? settings.kosync.username ? _('Connected as {{user}}', { user: settings.kosync.username }) : _('Connected') : _('Not connected'); const readwiseStatus = settings.readwise?.enabled ? _('Connected') : _('Not connected'); const hardcoverStatus = settings.hardcover?.enabled ? _('Connected') : _('Not connected'); // Third-party cloud providers are mutually exclusive: at most one is the // active sync target. A "configured" provider (WebDAV creds / a Drive token) // can be switched on inline; an unconfigured one must be opened to connect. const activeCloudKind: FileSyncBackendKind | null = settings.webdav?.enabled ? 'webdav' : settings.googleDrive?.enabled ? 'gdrive' : null; const webdavConfigured = !!(settings.webdav?.serverUrl && settings.webdav?.username); const gdriveConfigured = !!settings.googleDrive?.accountLabel; const webdavStatus = settings.webdav?.enabled ? isWebDAVSyncing ? _('Syncing…') : _('Active') : webdavConfigured ? _('Configured') : _('Not connected'); const gdriveStatus = settings.googleDrive?.enabled ? isGDriveSyncing ? _('Syncing…') : _('Active') : gdriveConfigured ? _('Configured') : _('Not connected'); const activateCloudProvider = async (kind: FileSyncBackendKind) => { const latest = useSettingsStore.getState().settings; const next = withActiveCloudProvider(latest, kind); setSettings(next); await saveSettings(envConfig, next); }; const opdsStatus = opdsCount > 0 ? _('{{count}} catalog', { count: opdsCount }) : _('No catalogs'); return (

{_('Integrations')}

{_('Connect Readest to external services for sync, highlights, and catalogs.')}

{_('Reading Sync')}
setSubPage('kosync')} /> setSubPage('readwise')} /> setSubPage('hardcover')} />
{_('Third-party Cloud Sync')}
{isCloudSyncPremium ? ( <> activateCloudProvider('webdav')} onOpen={() => setSubPage('webdav')} activateLabel={_('Use WebDAV')} /> {(appService?.isDesktopApp || appService?.isAndroidApp || appService?.isIOSApp) && ( activateCloudProvider('gdrive')} onOpen={() => setSubPage('gdrive')} activateLabel={_('Use Google Drive')} /> )} ) : ( // Premium-gated: free users get an upgrade prompt instead of the // provider rows. Tapping opens the plans page. navigateToProfile(router)} /> )}
{_('Content Sources')}
setSubPage('opds')} /> setSubPage('send')} />
{appService?.isDesktopApp && (
{_('Discord')}
)}
); }; interface IntegrationRowProps { icon: React.ElementType; title: string; status: string; onClick: () => void; } const IntegrationRow: React.FC = ({ icon: Icon, title, status, onClick }) => { return ( ); }; interface CloudProviderRowProps { icon: React.ElementType; title: string; status: string; /** This provider is the active sync target. */ isActive: boolean; /** Configured (credentials / token present) — can be switched on inline. */ canActivate: boolean; onActivate: () => void; onOpen: () => void; /** Accessible label for the activate radio (e.g. "Use WebDAV"). */ activateLabel: string; } /** * A third-party cloud-sync provider row. Two controls: a trailing radio that * makes this provider the (single) active one inline — enabled only when it's * already configured — and the row body / chevron that opens its config * sub-page (connect, sync options, disconnect). */ const CloudProviderRow: React.FC = ({ icon: Icon, title, status, isActive, canActivate, onActivate, onOpen, activateLabel, }) => { return (
); }; interface IntegrationToggleRowProps { icon: React.ElementType; title: string; description: string; checked: boolean; onChange: () => void; } /** * Sibling of IntegrationRow for settings that are a simple on/off toggle * (no sub-page). Keeps the same circular-badge chassis so toggle and * navigation rows read as one consistent list. */ const IntegrationToggleRow: React.FC = ({ icon: Icon, title, description, checked, onChange, }) => { return ( ); }; export default IntegrationsPanel;