'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 { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' import { DestructiveConfirmDialog, useDestructiveConfirm, } from '@/components/ui/destructive-confirm-dialog' import { AlertTriangle, 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')}

) } return (
{(status?.providers ?? []).map((providerStatus) => ( ))}
) } interface ProviderRowProps { status: CloudProviderStatus onChanged: () => Promise | void } /** * One destination: identity on the left, its own connection state, schedule * and actions on the right. Every request carries `?provider=`, so the two * rows never touch each other's records. */ function ProviderRow({ status, onChanged }: ProviderRowProps) { const { toast } = useToast() const t = useTranslations('extensions') const { dialogProps, confirm } = useDestructiveConfirm() const [isConnecting, setIsConnecting] = useState(false) 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 handleConnect = useCallback(async () => { setIsConnecting(true) try { const res = await fetch(`${API_BASE}/connect${qs}`, { 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) } }, [provider, qs, t, toast]) 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]) return (
{/* Identity */}

{provider}

{t('ext_cloud_backup_card_tagline', { provider })}

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

{/* Controls */}
{!status.configured ? (

{t('ext_cloud_backup_not_configured', { provider })}

) : status.connected ? ( <> {status.needs_reauth && (

{t('ext_cloud_backup_reauth_title', { provider })}

{t('ext_cloud_backup_reauth_description', { provider })}

)}
{t('ext_cloud_backup_account_label')}
{status.account_email}
{t('ext_cloud_backup_last_sync_label')}
{status.last_sync ? ( ) : ( {t('ext_cloud_backup_never')} )}
) : ( <>

{t( providerId === 'dropbox' ? 'ext_cloud_backup_connect_description_dropbox' : 'ext_cloud_backup_connect_description_google' )}

)}
) } /** * 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 ids must not collide. const toggleId = `auto-sync-toggle-${providerId}` 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 (

{t('ext_cloud_backup_auto_sync_description', { provider })}

{enabled && (
{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() }