feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s

Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-07-14 16:50:53 +00:00
parent d38a99586b
commit 30da592ba2
62 changed files with 7741 additions and 335 deletions

View File

@@ -1,11 +1,13 @@
'use client'
import { useState, useEffect } from 'react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays } from 'lucide-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)
@@ -19,32 +21,47 @@ export default function IntegrationsPage() {
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()),
]).then(([rw, cal]) => {
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('Google Calendar connecté !')
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(`Erreur: ${params.get('error')}`)
toast.error(t('integrations.errorWith', { error: params.get('error') || '' }))
window.history.replaceState({}, '', '/settings/integrations')
}
}, [])
}, [t])
// ── Readwise handlers ──────────────────────────────────────────────────
const handleRwConnect = async () => {
@@ -57,12 +74,12 @@ export default function IntegrationsPage() {
body: JSON.stringify({ token: rwToken.trim() }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur Readwise'); return }
if (!res.ok) { toast.error(data.error || t('integrations.readwiseError')); return }
setRwConnected(true)
setRwToken('')
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Readwise connecté — ${data.created} notes créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de connexion Readwise') } finally { setRwConnecting(false) }
toast.success(t('integrations.readwiseConnected', { created: data.created, updated: data.updated }))
} catch { toast.error(t('integrations.readwiseConnectError')) } finally { setRwConnecting(false) }
}
const handleRwSync = async () => {
@@ -74,16 +91,16 @@ export default function IntegrationsPage() {
body: JSON.stringify({}),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur de sync'); return }
if (!res.ok) { toast.error(data.error || t('integrations.syncError')); return }
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Sync Readwise — ${data.created} créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de synchronisation') } finally { setRwSyncing(false) }
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('Readwiseconnecté')
toast.success(t('integrations.readwiseDisconnected'))
}
// ── Calendar handlers ──────────────────────────────────────────────────
@@ -96,10 +113,10 @@ export default function IntegrationsPage() {
try {
const res = await fetch('/api/integrations/calendar?events=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); return }
if (!res.ok) { toast.error(data.error || t('common.error')); return }
setCalEvents(data.events ?? [])
if (data.events.length === 0) toast.info('Aucun événement aujourd\'hui')
} catch { toast.error('Erreur de chargement des événements') } finally { setCalFetching(false) }
if (data.events.length === 0) toast.info(t('integrations.noEvents'))
} catch { toast.error(t('integrations.eventsLoadError')) } finally { setCalFetching(false) }
}
const handleCreateMeetingNote = async (event: any) => {
@@ -110,11 +127,11 @@ export default function IntegrationsPage() {
})
const data = await res.json()
if (data.success) {
toast.success(`Note de réunion créée : ${event.summary}`, {
action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
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('Erreur lors de la création de la note')
toast.error(t('integrations.createNoteError'))
}
}
@@ -122,25 +139,114 @@ export default function IntegrationsPage() {
await fetch('/api/integrations/calendar', { method: 'DELETE' })
setCalConnected(false)
setCalEvents([])
toast.success('Google Calendarconnecté')
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} /> Connecté
<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} /> Non connecté
<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">Intégrations</h2>
<p className="text-sm text-concrete mt-1">Connectez des services externes à Memento.</p>
<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-ink text-paper rounded-xl hover:bg-ink/80 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 ────────────────────────────────────────────── */}
@@ -152,7 +258,7 @@ export default function IntegrationsPage() {
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Google Calendar</h3>
<p className="text-[12px] text-concrete">Accédez à vos événements et créez des notes de réunion</p>
<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} />}
@@ -161,12 +267,12 @@ export default function IntegrationsPage() {
{!calConnected ? (
<div className="space-y-3">
<SettingsHelpBox
title="Comment fonctionne Google Calendar ?"
title={t('integrations.calendarInfo')}
steps={[
{ text: 'Cliquez "Connecter Google Calendar" — vous serez redirigé vers Google pour autoriser l\'accès.' },
{ text: 'Une fois connecté, revenez ici et cliquez "Événements aujourd\'hui" pour voir votre agenda.' },
{ text: 'Sur chaque événement, cliquez "+ Note" pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).' },
{ text: 'La note s\'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.' },
{ text: t('integrations.calendarHelpStep1') },
{ text: t('integrations.calendarHelpStep2') },
{ text: t('integrations.calendarHelpStep3') },
{ text: t('integrations.calendarHelpStep4') },
]}
/>
<button
@@ -174,7 +280,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
>
<CalendarDays size={14} />
Connecter Google Calendar
{t('integrations.connectCalendar')}
</button>
</div>
) : (
@@ -186,14 +292,14 @@ export default function IntegrationsPage() {
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} />}
Événements aujourd&apos;hui
{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} />
Déconnecter
{t('integrations.disconnect')}
</button>
</div>
@@ -211,7 +317,7 @@ export default function IntegrationsPage() {
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"
>
+ Note
{t('integrations.addNote')}
</button>
</div>
))}
@@ -230,20 +336,20 @@ export default function IntegrationsPage() {
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Readwise</h3>
<p className="text-[12px] text-concrete">Importez vos surlignages de livres, articles et Kindle</p>
<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="Comment fonctionne Readwise ?"
title={t('integrations.readwiseInfo')}
steps={[
{ text: 'Copiez votre token d\'accès Readwise.', link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
{ text: 'Collez-le dans le champ ci-dessous et cliquez "Connecter". La première synchronisation importe tous vos livres et articles.' },
{ text: 'Chaque livre devient une note dans un carnet "Readwise 📚" — avec tous vos surlignages organisés.' },
{ text: 'Pour mettre à jour avec de nouveaux surlignages, revenez ici et cliquez "Synchroniser maintenant".' },
{ icon: '💡', text: 'Astuce : créez des flashcards IA depuis une note Readwise (bouton 🎓 dans l\'éditeur) pour réviser vos lectures.' },
{ 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') },
]}
/>
@@ -254,7 +360,7 @@ export default function IntegrationsPage() {
type="password"
value={rwToken}
onChange={(e) => setRwToken(e.target.value)}
placeholder="Token Readwise…"
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()}
/>
@@ -264,7 +370,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all disabled:opacity-50 flex items-center gap-2"
>
{rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null}
Connecter
{t('integrations.connect')}
</button>
</div>
</div>
@@ -278,28 +384,28 @@ export default function IntegrationsPage() {
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} />}
Synchroniser maintenant
{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} />
Déconnecter
{t('integrations.disconnect')}
</button>
</div>
)}
{rwLastSync && (
<p className="text-[11px] text-concrete">
Dernière sync : <strong>{rwLastSync.created}</strong> notes créées, <strong>{rwLastSync.updated}</strong> mises à jour
{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">D'autres intégrations arrivent bientôt Zapier, GitHub, Notion import</p>
<p className="text-sm text-concrete italic">{t('integrations.moreComing')}</p>
</div>
</div>
)