import { useEffect, useMemo, useState } from 'react' import { AlertCircle, ArrowRight, Check, Loader2, Plus } from 'lucide-react' import Modal from '../../shared/Modal' import { useTranslation } from '../../../i18n/TranslationContext' import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons' import { StateBadge } from './DocSyncBits' import { useConflicts } from './useConflicts' import { storeName, useDocSync, type DocSyncLink, type DocSyncProvider } from './useDocSync' import DocSyncBinding from './DocSyncBinding' import DocSyncConnectModal from './DocSyncConnectModal' import DocSyncScopeModal from './DocSyncScopeModal' /** * Document sync for one trip, as a dialog with the stores down one side and the * selected one open beside it. * * Two columns rather than a stack, because the two questions are different * shapes: "which store" is a short list somebody scans, and "how does this one * behave" is a screenful. Stacked, the second answer pushed the first one off * the top every time a binding was added. * * It opens from the file manager rather than from settings: this is the one * screen where a person has the context that makes the folder choice obvious. * Only the trip owner can change a binding (the credential usually reaches * their whole archive), but every member sees where their documents go, which * is the minimum a shared folder owes the people sharing it. */ export default function DocSyncPanel({ tripId, tripTitle, canManage, onClose, }: { tripId: number | string tripTitle?: string canManage: boolean onClose: () => void }) { const { t } = useTranslation() const sync = useDocSync(tripId, true) const [selected, setSelected] = useState(null) const [connecting, setConnecting] = useState(null) const [scopeFor, setScopeFor] = useState(null) // The credential form again, for a store this trip is already bound to. Kept // apart from `connecting`, whose next step is the folder picker: here the // folder is known and the next step is the run that the refusal stopped. const [reconnecting, setReconnecting] = useState(null) const bound = useMemo(() => new Set(sync.links.map(l => l.providerId)), [sync.links]) const available = sync.providers.filter(p => !bound.has(p.id)) const active = sync.links.find(l => l.id === selected) ?? sync.links[0] ?? null // Undefined once an admin has switched the store off: the form needs the // provider's field list, and the providers route no longer carries it. const activeProvider = active ? sync.providers.find(p => p.id === active.providerId) : undefined // Follow the list: a freshly bound store should be the one on screen, and a // removed one must not leave the detail column pointing at nothing. useEffect(() => { if (sync.links.length === 0) setSelected(null) else if (!sync.links.some(l => l.id === selected)) setSelected(sync.links[0].id) }, [sync.links, selected]) const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0) return ( <> {t('docsync.title')} {tripTitle && ( {tripTitle} )} } > {sync.loading ? (
) : sync.providers.length === 0 && sync.links.length === 0 ? ( // Only with nothing bound: a binding outlives the admin switching its // provider off, and the people sharing it still need to see it. ) : (
(sync.connectionFor(p.id) ? setScopeFor(p.id) : setConnecting(p))} />
{active ? (
setReconnecting(activeProvider) : undefined} /> {/* Every action in this column reports here: a refused run, a switch that flipped back, a conflict the server would not settle. The two dialogs say their own, but nothing said what happened to a click on the card itself. */} {sync.error && (

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

)} {attention > 0 && ( )}
) : ( )}
)}
{connecting && ( setConnecting(null)} onConnected={id => { setConnecting(null); setScopeFor(id) }} /> )} {reconnecting && ( setReconnecting(null)} onConnected={id => { setReconnecting(null) // Run straight away, as binding does: the card still reports the // refusal until a run says otherwise, and the person has just // done the one thing that could change the answer. const rebound = sync.links.find(l => l.providerId === id) if (rebound) void sync.syncNow(rebound.id) }} /> )} {scopeFor && sync.connectionFor(scopeFor) && ( p.id === scopeFor)?.name ?? scopeFor} suggestedName={slugFor(tripTitle, tripId)} sync={sync} onClose={() => setScopeFor(null)} onBound={() => setScopeFor(null)} /> )} ) } const ATTENTION_STATES = ['conflict', 'remote_missing', 'rejected_type', 'too_large', 'error'] as const /** The stores: the ones this trip uses, then the ones it could. */ function Sidebar({ links, providers, available, activeId, canManage, onSelect, onAdd, }: { links: DocSyncLink[] providers: DocSyncProvider[] available: DocSyncProvider[] activeId: number | null canManage: boolean onSelect: (id: number) => void onAdd: (p: DocSyncProvider) => void }) { const { t } = useTranslation() return ( ) } /** * One row in the store list. * * The selected row is marked with the accent on a subtle fill rather than a * heavy border, so a list of five does not turn into five competing boxes. */ function StoreButton({ providerId, title, subtitle, state, active, addable, onClick, }: { providerId: string title: string subtitle: string state?: string active?: boolean addable?: boolean onClick: () => void }) { const Icon = DOCUMENT_PROVIDER_ICONS[providerId] return ( ) } /** What a person still has to decide about, named rather than counted in a chip. */ function Attention({ counts, tripId, sync, canManage, }: { counts: Record tripId: number | string sync: ReturnType canManage: boolean }) { const { t } = useTranslation() const rows = ATTENTION_STATES.filter(k => (counts[k] ?? 0) > 0) const [showConflicts, setShowConflicts] = useState(false) const conflicts = useConflicts(tripId, sync, showConflicts) return (

{t('docsync.issues.title')}

    {rows.map(k => (
  • {t(`docsync.state.${k}`)} {t(`docsync.issues.${k}`)} {/* A conflict is the one of these a person can act on, so it is the one that opens. The rest are a state to read. Only for the owner: the server refuses a member's choice, so a member gets the count, like every other row. */} {k === 'conflict' && canManage ? ( ) : ( {counts[k]} )}
    {k === 'conflict' && canManage && showConflicts && (
      {conflicts.items === null && (
    • )} {conflicts.items?.map(item => (
    • {item.name}

      {t('docsync.conflict.title')}

      {(['trek', 'provider', 'both'] as const).map(keep => ( ))}
    • ))}
    )}
  • ))}
) } /** The detail column before anything is bound: an invitation, not a blank. */ function NothingBound({ canManage }: { canManage: boolean }) { const { t } = useTranslation() return (

{t('docsync.empty.title')}

{canManage ? t('docsync.empty.hintOwner') : t('docsync.empty.hintMember')}

) } function Empty() { const { t } = useTranslation() return (

{t('docsync.noProviders')}

{t('docsync.noProvidersHint')}

) } /** * A folder name from the trip's own title, so nobody has to invent one. * * The id is appended because two trips can share a title and a folder cannot. */ function slugFor(title: string | undefined, tripId: number | string): string { const base = (title || 'trek') .toLowerCase() .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 40) return `${base || 'trek'}-${tripId}` }