feat: Tier 1 & 2 — Daily Note, Voice, Flashcard quota, Readwise, Calendar, Agent Gallery
Tier 1: - BASIC tier: chat (10/mo) + reformulate (10/mo) désormais accessibles - Nouveaux quotas: ai_flashcard + voice_transcribe dans tous les tiers - /api/notes/daily : note du jour auto-créée (find or create) - Bouton Note du Jour dans la sidebar (CalendarDays) - Voice-to-Text dans l'éditeur (Web Speech API, bouton Mic toolbar) - Flashcard generation → quota ai_flashcard (au lieu de reformulate) Tier 2: - Intégration Readwise: GET/POST/DELETE /api/integrations/readwise - Intégration Google Calendar: OAuth flow + today's events + meeting notes - /api/integrations/calendar + /callback - Page /settings/integrations avec cards Calendar + Readwise - SettingsNav: onglet Intégrations - AgentTemplates: catégories + 4 nouveaux templates (Digest/Recap/AutoTagger/Synthesis) Schema: - UserAISettings.integrationTokens Json? (migration 20260529160000) - prisma generate + migrate deploy appliqués Fix: - SpeechRecognition types (triple-slash @types/dom-speech-recognition) - Notebook.create: suppression champ 'description' inexistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
289
memento-note/app/(main)/settings/integrations/page.tsx
Normal file
289
memento-note/app/(main)/settings/integrations/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
// ── 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)
|
||||
|
||||
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]) => {
|
||||
setRwConnected(rw.connected)
|
||||
setCalConnected(cal.connected)
|
||||
}).finally(() => {
|
||||
setLoading(false)
|
||||
setCalLoading(false)
|
||||
})
|
||||
|
||||
// Handle redirect params
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('connected') === 'calendar') {
|
||||
setCalConnected(true)
|
||||
toast.success('Google Calendar connecté !')
|
||||
window.history.replaceState({}, '', '/settings/integrations')
|
||||
}
|
||||
if (params.get('error')) {
|
||||
toast.error(`Erreur: ${params.get('error')}`)
|
||||
window.history.replaceState({}, '', '/settings/integrations')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── 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 || 'Erreur Readwise'); 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) }
|
||||
}
|
||||
|
||||
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 || 'Erreur de sync'); 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) }
|
||||
}
|
||||
|
||||
const handleRwDisconnect = async () => {
|
||||
await fetch('/api/integrations/readwise', { method: 'DELETE' })
|
||||
setRwConnected(false)
|
||||
toast.success('Readwise déconnecté')
|
||||
}
|
||||
|
||||
// ── 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 || 'Erreur'); 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) }
|
||||
}
|
||||
|
||||
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(`Note de réunion créée : ${event.summary}`, {
|
||||
action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
|
||||
})
|
||||
} else {
|
||||
toast.error('Erreur lors de la création de la note')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCalDisconnect = async () => {
|
||||
await fetch('/api/integrations/calendar', { method: 'DELETE' })
|
||||
setCalConnected(false)
|
||||
setCalEvents([])
|
||||
toast.success('Google Calendar déconnecté')
|
||||
}
|
||||
|
||||
const StatusBadge = ({ connected }: { connected: boolean }) =>
|
||||
connected ? (
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800/40 rounded-full px-2.5 py-1">
|
||||
<Check size={11} /> Connecté
|
||||
</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é
|
||||
</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 à Momento.</p>
|
||||
</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">Accédez à vos événements et créez des notes de réunion</p>
|
||||
</div>
|
||||
</div>
|
||||
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
|
||||
</div>
|
||||
|
||||
{!calConnected ? (
|
||||
<button
|
||||
onClick={handleCalConnect}
|
||||
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
|
||||
</button>
|
||||
) : (
|
||||
<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} />}
|
||||
Événements aujourd'hui
|
||||
</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
|
||||
</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"
|
||||
>
|
||||
+ Note
|
||||
</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">Importez vos surlignages de livres, articles et Kindle</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
|
||||
</div>
|
||||
|
||||
{!rwConnected && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[12px] text-concrete">
|
||||
Trouvez votre token sur{' '}
|
||||
<a href="https://readwise.io/access_token" target="_blank" rel="noopener noreferrer" className="text-brand-accent underline">
|
||||
readwise.io/access_token
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={rwToken}
|
||||
onChange={(e) => setRwToken(e.target.value)}
|
||||
placeholder="Token Readwise…"
|
||||
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-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
|
||||
</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} />}
|
||||
Synchroniser maintenant
|
||||
</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
|
||||
</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
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user