import { useEffect, useMemo, useState } from 'react' import { FolderPlus, Loader2, Search } from 'lucide-react' import Modal from '../../shared/Modal' import { useTranslation } from '../../../i18n/TranslationContext' import type { DocSyncConnection, DocSyncScope, useDocSync } from './useDocSync' /** * Picking the container a trip lives in. * * The list is filtered client-side: these are a person's own folders or tags, * so there are tens of them, not thousands, and a round trip per keystroke * would be slower than the filter it replaces. * * "Create" is a first-class action rather than a fallback at the bottom, * because for a new trip it is the common case: there is no folder yet. */ export default function DocSyncScopeModal({ connection, providerName, suggestedName, sync, onClose, onBound, }: { connection: DocSyncConnection providerName: string /** Pre-filled name for a new container, from the trip's title. */ suggestedName: string sync: ReturnType onClose: () => void onBound: () => void }) { const { t } = useTranslation() const [scopes, setScopes] = useState(null) const [error, setError] = useState(null) const [query, setQuery] = useState('') const [newName, setNewName] = useState(suggestedName) const [working, setWorking] = useState(null) // `loadScopes`, not `sync`: the hook hands back a fresh object on every // render of the panel above, so depending on it re-listed the provider's // folders each time anything up there changed. The callback itself is // stable. Taken out of `sync` first, because calling it as `sync.loadScopes` // inside the effect makes the whole object a dependency again. const { loadScopes } = sync useEffect(() => { let cancelled = false void (async () => { const res = await loadScopes(connection.id) if (cancelled) return setScopes(res.scopes) setError(res.error ?? null) })() return () => { cancelled = true } }, [connection.id, loadScopes]) const shown = useMemo(() => { const needle = query.trim().toLowerCase() if (!needle || !scopes) return scopes ?? [] return scopes.filter(s => s.label.toLowerCase().includes(needle)) }, [scopes, query]) const bind = async (scope: DocSyncScope) => { setWorking(scope.scopeKey) const ok = await sync.createLink({ connectionId: connection.id, scopeKey: scope.scopeKey, remoteRootId: scope.remoteRootId, remoteRootPath: scope.remoteRootPath, remoteLabel: scope.label, direction: 'both', deletePolicy: 'unlink', conflictPolicy: 'manual', syncEnabled: true, }) setWorking(null) if (ok) onBound() } const createAndBind = async () => { const name = newName.trim() if (!name) return setWorking('__new__') try { // A refused create leaves nothing to bind; the hook has already put the // reason where the dialog shows it. const scope = await sync.createScope(connection.id, name) if (scope) await bind(scope) } finally { setWorking(null) } } return (

{t('docsync.scope.intro')}

{t('docsync.scope.createTitle')}
setNewName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void createAndBind() }} placeholder={t('docsync.newFolderPlaceholder')} className="min-w-0 flex-1 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content ring-accent focus:outline-none focus:ring-2" />
{t('docsync.scope.pickTitle')} {scopes && scopes.length > 6 && ( setQuery(e.target.value)} placeholder={t('docsync.scope.search')} className="w-44 rounded-lg border border-edge bg-surface-input py-1.5 pl-8 pr-2.5 text-caption text-content ring-accent focus:outline-none focus:ring-2" /> )}
{sync.error && (

{t(`docsync.error.${sync.error}`)}

)} {scopes === null ? (
) : error ? (

{t(`docsync.error.${error}`)}

) : shown.length === 0 ? (

{query ? t('docsync.scope.noMatch') : t('docsync.noFolders')}

) : (
    {shown.map(s => (
  • ))}
)}
) }