'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { AttnLine } from '@/components/ui/attn-line' import { SettingsRow, SettingsRowEnd, SettingsSelect, } from '@/components/settings/SettingsRows' import { useToast } from '@/components/ui/use-toast' import { cn } from '@/lib/utils' import { DestructiveConfirmDialog, useDestructiveConfirm, } from '@/components/ui/destructive-confirm-dialog' import { Box, Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react' import type { CloudBackupStatus, CloudLastSync, CloudProviderId, CloudProviderStatus, CloudSchedule, } from '../types' const API_BASE = '/api/extensions/ext/cloud-backup' /** * Provider presentation. The label is the brand name and stays untranslated in * both locales; everything around it is a `{provider}` placeholder so the * copy reads naturally whichever destination the row describes. */ const PROVIDER_META: Record = { google_drive: { label: 'Google Drive', icon: Cloud }, dropbox: { label: 'Dropbox', icon: Box }, } export default function CloudBackupCard() { const t = useTranslations('extensions') const { toast } = useToast() const searchParams = useSearchParams() const [status, setStatus] = useState(null) const [isLoading, setIsLoading] = useState(true) const loadStatus = useCallback(async () => { try { const res = await fetch(`${API_BASE}/status`) if (!res.ok) throw new Error(t('ext_cloud_backup_status_failed')) const { data } = (await res.json()) as { data: CloudBackupStatus } setStatus(data) } finally { setIsLoading(false) } }, [t]) useEffect(() => { loadStatus() }, [loadStatus]) // After a connect redirect the first backup builds in the background: poll // the status a few times so the finished sync shows up without a reload. const pollRef = useRef | null>(null) useEffect(() => { return () => { if (pollRef.current) clearInterval(pollRef.current) } }, []) // Handle OAuth callback redirect params. `provider` tells us which row the // user just came back from; absent means a redirect issued before Dropbox // existed, which can only have been Google Drive. useEffect(() => { const result = searchParams.get('cloud_backup') if (!result) return const providerId = (searchParams.get('provider') as CloudProviderId) || 'google_drive' const providerLabel = PROVIDER_META[providerId]?.label ?? providerId if (result === 'connected' || result === 'connected_first') { toast({ title: t('ext_cloud_backup_connected_title', { provider: providerLabel }), description: t( result === 'connected_first' ? 'ext_cloud_backup_connected_first_description' : 'ext_cloud_backup_connected_description' ), }) let attempts = 0 pollRef.current = setInterval(() => { attempts += 1 loadStatus() if (attempts >= 6 && pollRef.current) { clearInterval(pollRef.current) pollRef.current = null } }, 10_000) } else if (result === 'error') { const reason = searchParams.get('reason') || t('ext_cloud_backup_unknown_error') toast({ title: t('ext_cloud_backup_connect_failed', { provider: providerLabel }), description: reason, variant: 'destructive', }) } // Clean the URL so refresh doesn't re-fire the toast. const url = new URL(window.location.href) url.searchParams.delete('cloud_backup') url.searchParams.delete('provider') url.searchParams.delete('reason') window.history.replaceState({}, '', url.toString()) }, [loadStatus, searchParams, t, toast]) if (isLoading) { return (

{t('ext_cloud_backup_loading')}

) } const providers = status?.providers ?? [] // Configured too: reconnecting a destination whose credentials are gone // would only bounce off `/connect`, so that row keeps its quiet // "not configured" line instead. const reauthProviders = providers.filter((p) => p.configured && p.needs_reauth) // `/status` returns a row for every provider the build knows about, whether // or not this deployment has credentials for it, so the note has to filter: // naming Dropbox where Dropbox is not configured would promise a // destination that does not exist. Configured (not connected) is the right // cut, because the note is the compliance framing for the decision to // connect and has to read before anything is connected. const destinations = providers .filter((p) => p.configured) .map((p) => PROVIDER_META[p.provider]?.label ?? p.provider) .join(' / ') return (
{reauthProviders.length > 0 && }
{providers.map((providerStatus) => ( ))}
{/* The 7-year BFL note is compliance context, so it stays visible rather than hiding behind hover: it is shown once for the whole section instead of once per destination, because the statement is identical for every provider and repeating it was what made the panel bulky. */} {destinations.length > 0 && (

{t('ext_cloud_backup_legal_note', { provider: destinations })}

)}
) } /** * Start the OAuth handshake for one destination. Shared by the connect button * on a row and by the section-level reconnect line, so both take the exact * same path. */ function useConnectProvider(providerId: CloudProviderId, provider: string) { const { toast } = useToast() const t = useTranslations('extensions') const [isConnecting, setIsConnecting] = useState(false) const connect = useCallback(async () => { // The reconnect affordance is a plain inline link (no disabled state), so // the guard lives here instead of on a button. if (isConnecting) return setIsConnecting(true) try { const res = await fetch( `${API_BASE}/connect?provider=${encodeURIComponent(providerId)}`, { method: 'POST' } ) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.error || t('ext_cloud_backup_connect_start_failed')) } const { url } = (await res.json()) as { url: string } window.location.href = url } catch (err) { toast({ title: t('ext_cloud_backup_connect_failed', { provider }), description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'), variant: 'destructive', }) setIsConnecting(false) } }, [isConnecting, provider, providerId, t, toast]) return { isConnecting, connect } } /** * Attention is one ochre sentence, max one per section (convention 6), so the * reconnect notice lives here and not on each row: both destinations can lose * their token at once, and two stacked banners is exactly the shape the * convention forbids. The sentence names every affected destination; the * action reconnects the first, since one redirect can only hand off to one * provider. Once it is back the line returns for the next one. */ function ReauthAttn({ providers }: { providers: CloudProviderStatus[] }) { const t = useTranslations('extensions') const labels = providers.map((p) => PROVIDER_META[p.provider]?.label ?? p.provider) const { isConnecting, connect } = useConnectProvider(providers[0].provider, labels[0]) return ( {t('ext_cloud_backup_reauth_description', { provider: labels.join(' / ') })} ) } interface ProviderRowProps { status: CloudProviderStatus onChanged: () => Promise | void } /** * One destination as a single hairline row: brand mark and name on the left, * the connect action on the right. Connected destinations unfold their detail * (last sync, schedule) as indented settings rows below the same header row. * Every request carries `?provider=`, so the two rows never touch each * other's records. The reconnect notice is deliberately not here: it belongs * to the section (see `ReauthAttn`), one ochre sentence for all destinations. */ function ProviderRow({ status, onChanged }: ProviderRowProps) { const { toast } = useToast() const t = useTranslations('extensions') const { dialogProps, confirm } = useDestructiveConfirm() const [isSyncing, setIsSyncing] = useState(false) const [isDisconnecting, setIsDisconnecting] = useState(false) const providerId = status.provider const meta = PROVIDER_META[providerId] const provider = meta?.label ?? providerId const Icon = meta?.icon ?? Cloud const qs = `?provider=${encodeURIComponent(providerId)}` const { isConnecting, connect: handleConnect } = useConnectProvider(providerId, provider) const handleDisconnect = useCallback(async () => { setIsDisconnecting(true) try { const res = await fetch(`${API_BASE}/disconnect${qs}`, { method: 'POST' }) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.error || t('ext_cloud_backup_disconnect_failed')) } toast({ title: t('ext_cloud_backup_disconnected', { provider }) }) await onChanged() } catch (err) { toast({ title: t('ext_cloud_backup_disconnect_failed'), description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'), variant: 'destructive', }) } finally { setIsDisconnecting(false) } }, [onChanged, provider, qs, t, toast]) type SyncOutcome = | { result: 'ok' | 'error' } | { result: 'too_large'; sizeMb: number | null; limitMb: number | null } const syncOnce = useCallback( async (allowDocumentFallback: boolean): Promise => { setIsSyncing(true) try { const res = await fetch(`${API_BASE}/sync${qs}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ include_documents: true, allow_document_fallback: allowDocumentFallback, }), }) if (!res.ok) { const body = await res.json().catch(() => ({})) if (res.status === 413 && !allowDocumentFallback) { // Handled by the caller: a dialog offers syncing the oversized // archives without their document blobs. return { result: 'too_large', sizeMb: body.size_bytes ? Math.round(body.size_bytes / (1024 * 1024)) : null, limitMb: body.size_limit_bytes ? Math.round(body.size_limit_bytes / (1024 * 1024)) : null, } } if (body.error === 'needs_reauth') { // Refresh status so the row switches to the reconnect state. await onChanged() throw new Error(t('ext_cloud_backup_reauth_description', { provider })) } throw new Error(body.error || t('ext_cloud_backup_sync_failed')) } const { data } = (await res.json()) as { data: CloudLastSync & { web_view_link: string uploaded_count?: number skipped_count?: number } } if (data.uploaded_count === 0) { toast({ title: t('ext_cloud_backup_up_to_date'), description: t('ext_cloud_backup_no_changes'), }) } else { const anyNoDocs = (data.files ?? []).some( (f) => f.kind !== 'readme' && f.included_documents === false ) toast({ title: t('ext_cloud_backup_uploaded', { provider }), description: `${t('ext_cloud_backup_files_updated', { count: data.uploaded_count ?? 0, })} (${formatMb(data.total_size_bytes ?? data.file_size_bytes ?? 0)})${ anyNoDocs ? ` · ${t('ext_cloud_backup_no_documents_note')}` : '' }`, }) } await onChanged() return { result: 'ok' } } catch (err) { toast({ title: t('ext_cloud_backup_sync_failed'), description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'), variant: 'destructive', }) return { result: 'error' } } finally { setIsSyncing(false) } }, [onChanged, provider, qs, t, toast] ) const handleSync = useCallback(async () => { const first = await syncOnce(false) if (first.result !== 'too_large') return const ok = await confirm({ title: t('ext_cloud_backup_too_large_title'), description: t('ext_cloud_backup_too_large_description', { size: first.sizeMb != null ? String(first.sizeMb) : '?', limit: first.limitMb != null ? String(first.limitMb) : '?', }), confirmLabel: t('ext_cloud_backup_too_large_confirm'), variant: 'warning', }) if (ok) await syncOnce(true) }, [confirm, syncOnce, t]) // The scope copy ("only files the app creates" / "its own app folder") is // what earns the OAuth grant, so it stays visible in the state where the // user decides: it replaces the tagline, which only paraphrased the row // title. Connected rows swap it for the account the backup lands in. const scopeKey = providerId === 'dropbox' ? 'ext_cloud_backup_connect_description_dropbox' : 'ext_cloud_backup_connect_description_google' const supportingLine = status.connected ? status.account_email : status.configured ? t(scopeKey) : null return (
{/* Header row: identity left, action right, in every state. */}
{!status.configured ? ( // Not configured is a state, not an action: one quiet line where the // button would sit, free to wrap on narrow screens. {t('ext_cloud_backup_not_configured', { provider })} ) : (
{status.connected ? ( <> ) : ( )}
)}
{/* Both gates, not just `connected`: a deployment that loses its credentials while an account is still stored shows the "not configured" state alone, exactly as the header row does, instead of offering controls next to a line saying the destination is unavailable. `role="group"` ties these controls to the destination they belong to: with two rows open, "Synka nu" is otherwise unattributable. */} {status.configured && status.connected && (
{status.last_sync ? ( ) : ( {t('ext_cloud_backup_never')} )}
)}
) } /** * Last-sync cell. Records written since the Dropbox target landed carry their * own `web_view_link`; older Drive records only have a folder id, so the Drive * URL is reconstructed. Legacy single-ZIP records link to the file itself. */ function LastSyncSummary({ lastSync, providerId, }: { lastSync: CloudLastSync providerId: CloudProviderId }) { const t = useTranslations('extensions') const files = lastSync.files const href = lastSync.web_view_link ?? (providerId === 'google_drive' ? files ? `https://drive.google.com/drive/folders/${lastSync.folder_id}` : `https://drive.google.com/file/d/${lastSync.file_id}/view` : 'https://www.dropbox.com/home/Apps') const sizeBytes = files ? lastSync.total_size_bytes ?? 0 : lastSync.file_size_bytes ?? 0 const anyNoDocs = files ? files.some((f) => f.kind !== 'readme' && f.included_documents === false) : lastSync.included_documents === false const archiveCount = files ? files.filter((f) => f.kind !== 'readme').length : null const verified = files ? files.every((f) => f.sha256) : Boolean(lastSync.sha256) return (
{formatDateTime(lastSync.at)}

{formatMb(sizeBytes)} {archiveCount !== null && ` · ${t('ext_cloud_backup_files_count', { count: archiveCount })}`} {verified && ` · ${t('ext_cloud_backup_verified')}`}

{anyNoDocs && (

{t('ext_cloud_backup_last_sync_no_documents')}

)}
) } function formatMb(bytes: number): string { const mb = bytes / (1024 * 1024) return `${mb.toFixed(1)} MB` } function formatDateTime(iso: string): string { const d = new Date(iso) return d.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) } interface ScheduleSectionProps { providerId: CloudProviderId provider: string schedule: CloudSchedule | null needsReauth: boolean onUpdated: () => Promise | void } function ScheduleSection({ providerId, provider, schedule, needsReauth, onUpdated, }: ScheduleSectionProps) { const { toast } = useToast() const t = useTranslations('extensions') // Prefer the DST-stable Stockholm hour; fall back to converting the legacy // UTC hour through the browser's clock (Swedish users: same thing). const scheduleHour = (s: CloudSchedule | null): number => typeof s?.hour_local === 'number' ? s.hour_local : utcHourToLocalHour(typeof s?.hour_utc === 'number' ? s.hour_utc : 3) const [enabled, setEnabled] = useState(schedule?.enabled ?? false) const [localHour, setLocalHour] = useState(scheduleHour(schedule)) const [isSaving, setIsSaving] = useState(false) // Each provider renders its own controls, so the id must not collide. const hourId = `auto-sync-hour-${providerId}` useEffect(() => { setEnabled(schedule?.enabled ?? false) setLocalHour(scheduleHour(schedule)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [schedule?.enabled, schedule?.hour_utc, schedule?.hour_local]) const save = useCallback( async (nextEnabled: boolean, nextLocalHour: number) => { setIsSaving(true) try { const res = await fetch( `${API_BASE}/schedule?provider=${encodeURIComponent(providerId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: nextEnabled, hour_local: nextLocalHour, }), } ) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.error || t('ext_cloud_backup_schedule_save_failed')) } await onUpdated() } catch (err) { toast({ title: t('ext_cloud_backup_schedule_save_failed'), description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'), variant: 'destructive', }) } finally { setIsSaving(false) } }, [onUpdated, providerId, t, toast] ) const handleToggle = useCallback( (checked: boolean) => { setEnabled(checked) save(checked, localHour) }, [localHour, save] ) const handleHourChange = useCallback( (e: React.ChangeEvent) => { const next = Number(e.target.value) setLocalHour(next) if (enabled) save(enabled, next) }, [enabled, save] ) return ( <> {enabled && ( {Array.from({ length: 24 }, (_, h) => ( ))} {isSaving && } )} {schedule?.last_auto_sync_at && (

{t('ext_cloud_backup_last_auto_sync')} {formatDateTime(schedule.last_auto_sync_at)}{' '} {schedule.last_auto_sync_status === 'success' ? ( · {t('ext_cloud_backup_auto_sync_success')} ) : schedule.last_auto_sync_status === 'error' ? ( · {t('ext_cloud_backup_auto_sync_error')} {needsReauth ? ` (${t('ext_cloud_backup_reauth_needed_short')})` : schedule.last_auto_sync_error ? ` (${schedule.last_auto_sync_error})` : ''} ) : null}

)} ) } /** Convert a UTC hour (0-23) to the browser's local hour. */ function utcHourToLocalHour(hourUtc: number): number { const d = new Date() d.setUTCHours(hourUtc, 0, 0, 0) return d.getHours() }