Les boutons des pages Agents, MCP, Données, Intégrations et Généraux suivent la couleur choisie. Les libellés encore techniques (recherche par le sens, notes proches, connexion unique) sont en langage courant. Co-authored-by: Cursor <cursoragent@cursor.com>
413 lines
19 KiB
TypeScript
413 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays, Mail } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
|
|
export default function IntegrationsPage() {
|
|
const { t } = useLanguage()
|
|
// ── Readwise ───────────────────────────────────────────────────────────
|
|
const [rwToken, setRwToken] = useState('')
|
|
const [rwConnected, setRwConnected] = useState(false)
|
|
const [rwSyncing, setRwSyncing] = useState(false)
|
|
const [rwConnecting, setRwConnecting] = useState(false)
|
|
const [rwLastSync, setRwLastSync] = useState<{ created: number; updated: number } | null>(null)
|
|
|
|
// ── Google Calendar ────────────────────────────────────────────────────
|
|
const [calConnected, setCalConnected] = useState(false)
|
|
const [calLoading, setCalLoading] = useState(true)
|
|
const [calEvents, setCalEvents] = useState<any[]>([])
|
|
const [calFetching, setCalFetching] = useState(false)
|
|
|
|
// ── Gmail ────────────────────────────────────────────────────────────
|
|
const [gmailConnected, setGmailConnected] = useState(false)
|
|
const [gmailLoading, setGmailLoading] = useState(true)
|
|
const [gmailRecent, setGmailRecent] = useState(0)
|
|
const [gmailScanning, setGmailScanning] = useState(false)
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
fetch('/api/integrations/readwise').then((r) => r.json()),
|
|
fetch('/api/integrations/calendar').then((r) => r.json()),
|
|
fetch('/api/integrations/gmail').then((r) => r.json()),
|
|
]).then(([rw, cal, gmail]) => {
|
|
setRwConnected(rw.connected)
|
|
setCalConnected(cal.connected)
|
|
setGmailConnected(gmail.connected)
|
|
setGmailRecent(gmail.recentCaptures ?? 0)
|
|
}).finally(() => {
|
|
setLoading(false)
|
|
setCalLoading(false)
|
|
setGmailLoading(false)
|
|
})
|
|
|
|
// Handle redirect params
|
|
const params = new URLSearchParams(window.location.search)
|
|
if (params.get('connected') === 'calendar') {
|
|
setCalConnected(true)
|
|
toast.success(t('integrations.calendarConnected'))
|
|
window.history.replaceState({}, '', '/settings/integrations')
|
|
}
|
|
if (params.get('connected') === 'gmail') {
|
|
setGmailConnected(true)
|
|
toast.success(t('integrations.gmail.connected'))
|
|
window.history.replaceState({}, '', '/settings/integrations')
|
|
}
|
|
if (params.get('error')) {
|
|
toast.error(t('integrations.errorWith', { error: params.get('error') || '' }))
|
|
window.history.replaceState({}, '', '/settings/integrations')
|
|
}
|
|
}, [t])
|
|
|
|
// ── Readwise handlers ──────────────────────────────────────────────────
|
|
const handleRwConnect = async () => {
|
|
if (!rwToken.trim()) return
|
|
setRwConnecting(true)
|
|
try {
|
|
const res = await fetch('/api/integrations/readwise', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token: rwToken.trim() }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) { toast.error(data.error || t('integrations.readwiseError')); return }
|
|
setRwConnected(true)
|
|
setRwToken('')
|
|
setRwLastSync({ created: data.created, updated: data.updated })
|
|
toast.success(t('integrations.readwiseConnected', { created: data.created, updated: data.updated }))
|
|
} catch { toast.error(t('integrations.readwiseConnectError')) } finally { setRwConnecting(false) }
|
|
}
|
|
|
|
const handleRwSync = async () => {
|
|
setRwSyncing(true)
|
|
try {
|
|
const res = await fetch('/api/integrations/readwise', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) { toast.error(data.error || t('integrations.syncError')); return }
|
|
setRwLastSync({ created: data.created, updated: data.updated })
|
|
toast.success(t('integrations.readwiseSynced', { created: data.created, updated: data.updated }))
|
|
} catch { toast.error(t('integrations.syncFailed')) } finally { setRwSyncing(false) }
|
|
}
|
|
|
|
const handleRwDisconnect = async () => {
|
|
await fetch('/api/integrations/readwise', { method: 'DELETE' })
|
|
setRwConnected(false)
|
|
toast.success(t('integrations.readwiseDisconnected'))
|
|
}
|
|
|
|
// ── Calendar handlers ──────────────────────────────────────────────────
|
|
const handleCalConnect = () => {
|
|
window.location.href = '/api/integrations/calendar?connect=1'
|
|
}
|
|
|
|
const handleCalFetchEvents = async () => {
|
|
setCalFetching(true)
|
|
try {
|
|
const res = await fetch('/api/integrations/calendar?events=1')
|
|
const data = await res.json()
|
|
if (!res.ok) { toast.error(data.error || t('common.error')); return }
|
|
setCalEvents(data.events ?? [])
|
|
if (data.events.length === 0) toast.info(t('integrations.noEvents'))
|
|
} catch { toast.error(t('integrations.eventsLoadError')) } finally { setCalFetching(false) }
|
|
}
|
|
|
|
const handleCreateMeetingNote = async (event: any) => {
|
|
const res = await fetch('/api/integrations/calendar', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ eventId: event.id, summary: event.summary, start: event.start }),
|
|
})
|
|
const data = await res.json()
|
|
if (data.success) {
|
|
toast.success(t('integrations.meetingNoteCreated', { summary: event.summary }), {
|
|
action: { label: t('integrations.open'), onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
|
|
})
|
|
} else {
|
|
toast.error(t('integrations.createNoteError'))
|
|
}
|
|
}
|
|
|
|
const handleCalDisconnect = async () => {
|
|
await fetch('/api/integrations/calendar', { method: 'DELETE' })
|
|
setCalConnected(false)
|
|
setCalEvents([])
|
|
toast.success(t('integrations.calendarDisconnected'))
|
|
}
|
|
|
|
// ── Gmail handlers ─────────────────────────────────────────────────────
|
|
const handleGmailConnect = () => {
|
|
window.location.href = '/api/integrations/gmail?connect=1'
|
|
}
|
|
|
|
const handleGmailScan = async () => {
|
|
setGmailScanning(true)
|
|
try {
|
|
const res = await fetch('/api/integrations/gmail?scan=1')
|
|
const data = await res.json()
|
|
if (!res.ok) { toast.error(data.error || t('common.error')); return }
|
|
setGmailRecent(prev => prev + (data.created ?? 0))
|
|
toast.success(t('integrations.gmail.scanDone', { created: data.created ?? 0 }))
|
|
} catch {
|
|
toast.error(t('integrations.gmailScanError'))
|
|
} finally {
|
|
setGmailScanning(false)
|
|
}
|
|
}
|
|
|
|
const handleGmailDisconnect = async () => {
|
|
await fetch('/api/integrations/gmail', { method: 'DELETE' })
|
|
setGmailConnected(false)
|
|
setGmailRecent(0)
|
|
toast.success(t('integrations.gmail.disconnected'))
|
|
}
|
|
|
|
const StatusBadge = ({ connected }: { connected: boolean }) =>
|
|
connected ? (
|
|
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/50 rounded-full px-2.5 py-1">
|
|
<Check size={11} /> {t('integrations.connected')}
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-concrete bg-border/20 border border-border/40 rounded-full px-2.5 py-1">
|
|
<X size={11} /> {t('integrations.notConnected')}
|
|
</span>
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div>
|
|
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">{t('settings.integrationsTitle')}</h2>
|
|
<p className="text-sm text-concrete mt-1">{t('settings.integrationsDesc')}</p>
|
|
</div>
|
|
|
|
{/* ── Gmail ──────────────────────────────────────────────────────── */}
|
|
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
|
<Mail size={20} className="text-red-600 dark:text-red-400" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-ink text-sm">{t('integrations.gmail.title')}</h3>
|
|
<p className="text-[12px] text-concrete">{t('integrations.gmail.description')}</p>
|
|
</div>
|
|
</div>
|
|
{gmailLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={gmailConnected} />}
|
|
</div>
|
|
|
|
{!gmailConnected ? (
|
|
<div className="space-y-3">
|
|
<SettingsHelpBox
|
|
title={t('integrations.gmail.helpTitle')}
|
|
steps={[
|
|
{ text: t('integrations.gmail.helpStep1') },
|
|
{ text: t('integrations.gmail.helpStep2') },
|
|
{ text: t('integrations.gmail.helpStep3') },
|
|
{ text: t('integrations.gmail.helpStep4') },
|
|
]}
|
|
/>
|
|
<button
|
|
onClick={handleGmailConnect}
|
|
className="px-4 py-2 text-sm font-semibold bg-brand-accent text-white rounded-xl hover:opacity-90 transition-all flex items-center gap-2"
|
|
>
|
|
<Mail size={14} />
|
|
{t('integrations.gmail.connect')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={handleGmailScan}
|
|
disabled={gmailScanning}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
|
|
>
|
|
{gmailScanning ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
|
{t('integrations.gmail.scanNow')}
|
|
</button>
|
|
<button
|
|
onClick={handleGmailDisconnect}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
|
|
>
|
|
<Trash2 size={14} />
|
|
{t('integrations.gmail.disconnect')}
|
|
</button>
|
|
</div>
|
|
{gmailRecent > 0 && (
|
|
<p className="text-[11px] text-concrete">
|
|
{t('integrations.gmail.recentCaptures', { count: gmailRecent })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Google Calendar ────────────────────────────────────────────── */}
|
|
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
|
<CalendarDays size={20} className="text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-ink text-sm">Google Calendar</h3>
|
|
<p className="text-[12px] text-concrete">{t('integrations.calendarDesc')}</p>
|
|
</div>
|
|
</div>
|
|
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
|
|
</div>
|
|
|
|
{!calConnected ? (
|
|
<div className="space-y-3">
|
|
<SettingsHelpBox
|
|
title={t('integrations.calendarInfo')}
|
|
steps={[
|
|
{ text: t('integrations.calendarHelpStep1') },
|
|
{ text: t('integrations.calendarHelpStep2') },
|
|
{ text: t('integrations.calendarHelpStep3') },
|
|
{ text: t('integrations.calendarHelpStep4') },
|
|
]}
|
|
/>
|
|
<button
|
|
onClick={handleCalConnect}
|
|
className="px-4 py-2 text-sm font-semibold bg-brand-accent text-white rounded-xl hover:opacity-90 transition-all flex items-center gap-2"
|
|
>
|
|
<CalendarDays size={14} />
|
|
{t('integrations.connectCalendar')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={handleCalFetchEvents}
|
|
disabled={calFetching}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
|
|
>
|
|
{calFetching ? <Loader2 size={14} className="animate-spin" /> : <CalendarDays size={14} />}
|
|
{t('integrations.todayEvents')}
|
|
</button>
|
|
<button
|
|
onClick={handleCalDisconnect}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
|
|
>
|
|
<Trash2 size={14} />
|
|
{t('integrations.disconnect')}
|
|
</button>
|
|
</div>
|
|
|
|
{calEvents.length > 0 && (
|
|
<div className="space-y-2">
|
|
{calEvents.map((ev) => (
|
|
<div key={ev.id} className="flex items-center justify-between gap-3 px-4 py-3 bg-border/10 rounded-xl border border-border/20">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-ink truncate">{ev.summary}</p>
|
|
<p className="text-[11px] text-concrete">
|
|
{ev.start ? new Date(ev.start).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : ''}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => handleCreateMeetingNote(ev)}
|
|
className="shrink-0 text-[11px] font-semibold px-3 py-1.5 border border-border/40 rounded-full hover:bg-ink/5 transition-all"
|
|
>
|
|
{t('integrations.addNote')}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Readwise ──────────────────────────────────────────────────── */}
|
|
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
|
<BookOpen size={20} className="text-amber-600 dark:text-amber-400" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-ink text-sm">Readwise</h3>
|
|
<p className="text-[12px] text-concrete">{t('integrations.readwiseDesc')}</p>
|
|
</div>
|
|
</div>
|
|
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
|
|
</div>
|
|
|
|
<SettingsHelpBox
|
|
title={t('integrations.readwiseInfo')}
|
|
steps={[
|
|
{ text: t('integrations.readwiseHelpStep1'), link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
|
|
{ text: t('integrations.readwiseHelpStep2') },
|
|
{ text: t('integrations.readwiseHelpStep3') },
|
|
{ text: t('integrations.readwiseHelpStep4') },
|
|
{ icon: '💡', text: t('integrations.readwiseHelpStep5') },
|
|
]}
|
|
/>
|
|
|
|
{!rwConnected && (
|
|
<div className="space-y-3">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="password"
|
|
value={rwToken}
|
|
onChange={(e) => setRwToken(e.target.value)}
|
|
placeholder={t('integrations.readwiseTokenPlaceholder')}
|
|
className="flex-1 text-sm border border-border/40 rounded-xl px-3 py-2 bg-paper text-ink placeholder:text-concrete/50 focus:outline-none focus:ring-1 focus:ring-brand-accent/40"
|
|
onKeyDown={(e) => e.key === 'Enter' && handleRwConnect()}
|
|
/>
|
|
<button
|
|
onClick={handleRwConnect}
|
|
disabled={!rwToken.trim() || rwConnecting}
|
|
className="px-4 py-2 text-sm font-semibold bg-brand-accent text-white rounded-xl hover:opacity-90 transition-all disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
{rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null}
|
|
{t('integrations.connect')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{rwConnected && (
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
onClick={handleRwSync}
|
|
disabled={rwSyncing}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
|
|
>
|
|
{rwSyncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
|
{t('integrations.syncNow')}
|
|
</button>
|
|
<button
|
|
onClick={handleRwDisconnect}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
|
|
>
|
|
<Trash2 size={14} />
|
|
{t('integrations.disconnect')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{rwLastSync && (
|
|
<p className="text-[11px] text-concrete">
|
|
{t('integrations.lastSync')} <strong>{rwLastSync.created}</strong> {t('integrations.createdLabel')}, <strong>{rwLastSync.updated}</strong> {t('integrations.updatedLabel')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Placeholder */}
|
|
<div className="border border-dashed border-border/40 rounded-2xl p-6 text-center">
|
|
<p className="text-sm text-concrete italic">{t('integrations.moreComing')}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|