feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
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:
@@ -1,11 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
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 { toast } from 'sonner'
|
||||||
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
|
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export default function IntegrationsPage() {
|
export default function IntegrationsPage() {
|
||||||
|
const { t } = useLanguage()
|
||||||
// ── Readwise ───────────────────────────────────────────────────────────
|
// ── Readwise ───────────────────────────────────────────────────────────
|
||||||
const [rwToken, setRwToken] = useState('')
|
const [rwToken, setRwToken] = useState('')
|
||||||
const [rwConnected, setRwConnected] = useState(false)
|
const [rwConnected, setRwConnected] = useState(false)
|
||||||
@@ -19,32 +21,47 @@ export default function IntegrationsPage() {
|
|||||||
const [calEvents, setCalEvents] = useState<any[]>([])
|
const [calEvents, setCalEvents] = useState<any[]>([])
|
||||||
const [calFetching, setCalFetching] = useState(false)
|
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)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch('/api/integrations/readwise').then((r) => r.json()),
|
fetch('/api/integrations/readwise').then((r) => r.json()),
|
||||||
fetch('/api/integrations/calendar').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)
|
setRwConnected(rw.connected)
|
||||||
setCalConnected(cal.connected)
|
setCalConnected(cal.connected)
|
||||||
|
setGmailConnected(gmail.connected)
|
||||||
|
setGmailRecent(gmail.recentCaptures ?? 0)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setCalLoading(false)
|
setCalLoading(false)
|
||||||
|
setGmailLoading(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle redirect params
|
// Handle redirect params
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
if (params.get('connected') === 'calendar') {
|
if (params.get('connected') === 'calendar') {
|
||||||
setCalConnected(true)
|
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')
|
window.history.replaceState({}, '', '/settings/integrations')
|
||||||
}
|
}
|
||||||
if (params.get('error')) {
|
if (params.get('error')) {
|
||||||
toast.error(`Erreur: ${params.get('error')}`)
|
toast.error(t('integrations.errorWith', { error: params.get('error') || '' }))
|
||||||
window.history.replaceState({}, '', '/settings/integrations')
|
window.history.replaceState({}, '', '/settings/integrations')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
// ── Readwise handlers ──────────────────────────────────────────────────
|
// ── Readwise handlers ──────────────────────────────────────────────────
|
||||||
const handleRwConnect = async () => {
|
const handleRwConnect = async () => {
|
||||||
@@ -57,12 +74,12 @@ export default function IntegrationsPage() {
|
|||||||
body: JSON.stringify({ token: rwToken.trim() }),
|
body: JSON.stringify({ token: rwToken.trim() }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
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)
|
setRwConnected(true)
|
||||||
setRwToken('')
|
setRwToken('')
|
||||||
setRwLastSync({ created: data.created, updated: data.updated })
|
setRwLastSync({ created: data.created, updated: data.updated })
|
||||||
toast.success(`Readwise connecté — ${data.created} notes créées, ${data.updated} mises à jour`)
|
toast.success(t('integrations.readwiseConnected', { created: data.created, updated: data.updated }))
|
||||||
} catch { toast.error('Erreur de connexion Readwise') } finally { setRwConnecting(false) }
|
} catch { toast.error(t('integrations.readwiseConnectError')) } finally { setRwConnecting(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRwSync = async () => {
|
const handleRwSync = async () => {
|
||||||
@@ -74,16 +91,16 @@ export default function IntegrationsPage() {
|
|||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
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 })
|
setRwLastSync({ created: data.created, updated: data.updated })
|
||||||
toast.success(`Sync Readwise — ${data.created} créées, ${data.updated} mises à jour`)
|
toast.success(t('integrations.readwiseSynced', { created: data.created, updated: data.updated }))
|
||||||
} catch { toast.error('Erreur de synchronisation') } finally { setRwSyncing(false) }
|
} catch { toast.error(t('integrations.syncFailed')) } finally { setRwSyncing(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRwDisconnect = async () => {
|
const handleRwDisconnect = async () => {
|
||||||
await fetch('/api/integrations/readwise', { method: 'DELETE' })
|
await fetch('/api/integrations/readwise', { method: 'DELETE' })
|
||||||
setRwConnected(false)
|
setRwConnected(false)
|
||||||
toast.success('Readwise déconnecté')
|
toast.success(t('integrations.readwiseDisconnected'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Calendar handlers ──────────────────────────────────────────────────
|
// ── Calendar handlers ──────────────────────────────────────────────────
|
||||||
@@ -96,10 +113,10 @@ export default function IntegrationsPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/integrations/calendar?events=1')
|
const res = await fetch('/api/integrations/calendar?events=1')
|
||||||
const data = await res.json()
|
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 ?? [])
|
setCalEvents(data.events ?? [])
|
||||||
if (data.events.length === 0) toast.info('Aucun événement aujourd\'hui')
|
if (data.events.length === 0) toast.info(t('integrations.noEvents'))
|
||||||
} catch { toast.error('Erreur de chargement des événements') } finally { setCalFetching(false) }
|
} catch { toast.error(t('integrations.eventsLoadError')) } finally { setCalFetching(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateMeetingNote = async (event: any) => {
|
const handleCreateMeetingNote = async (event: any) => {
|
||||||
@@ -110,11 +127,11 @@ export default function IntegrationsPage() {
|
|||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success(`Note de réunion créée : ${event.summary}`, {
|
toast.success(t('integrations.meetingNoteCreated', { summary: event.summary }), {
|
||||||
action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
|
action: { label: t('integrations.open'), onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
|
||||||
})
|
})
|
||||||
} else {
|
} 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' })
|
await fetch('/api/integrations/calendar', { method: 'DELETE' })
|
||||||
setCalConnected(false)
|
setCalConnected(false)
|
||||||
setCalEvents([])
|
setCalEvents([])
|
||||||
toast.success('Google Calendar déconnecté')
|
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 }) =>
|
const StatusBadge = ({ connected }: { connected: boolean }) =>
|
||||||
connected ? (
|
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">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">Intégrations</h2>
|
<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">Connectez des services externes à Memento.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* ── Google Calendar ────────────────────────────────────────────── */}
|
{/* ── Google Calendar ────────────────────────────────────────────── */}
|
||||||
@@ -152,7 +258,7 @@ export default function IntegrationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-ink text-sm">Google Calendar</h3>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
|
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
|
||||||
@@ -161,12 +267,12 @@ export default function IntegrationsPage() {
|
|||||||
{!calConnected ? (
|
{!calConnected ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<SettingsHelpBox
|
<SettingsHelpBox
|
||||||
title="Comment fonctionne Google Calendar ?"
|
title={t('integrations.calendarInfo')}
|
||||||
steps={[
|
steps={[
|
||||||
{ text: 'Cliquez "Connecter Google Calendar" — vous serez redirigé vers Google pour autoriser l\'accès.' },
|
{ text: t('integrations.calendarHelpStep1') },
|
||||||
{ text: 'Une fois connecté, revenez ici et cliquez "Événements aujourd\'hui" pour voir votre agenda.' },
|
{ text: t('integrations.calendarHelpStep2') },
|
||||||
{ text: 'Sur chaque événement, cliquez "+ Note" pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).' },
|
{ text: t('integrations.calendarHelpStep3') },
|
||||||
{ text: 'La note s\'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.' },
|
{ text: t('integrations.calendarHelpStep4') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<button
|
<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"
|
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} />
|
<CalendarDays size={14} />
|
||||||
Connecter Google Calendar
|
{t('integrations.connectCalendar')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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"
|
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} />}
|
{calFetching ? <Loader2 size={14} className="animate-spin" /> : <CalendarDays size={14} />}
|
||||||
Événements aujourd'hui
|
{t('integrations.todayEvents')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCalDisconnect}
|
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"
|
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} />
|
<Trash2 size={14} />
|
||||||
Déconnecter
|
{t('integrations.disconnect')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,7 +317,7 @@ export default function IntegrationsPage() {
|
|||||||
onClick={() => handleCreateMeetingNote(ev)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -230,20 +336,20 @@ export default function IntegrationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-ink text-sm">Readwise</h3>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
|
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsHelpBox
|
<SettingsHelpBox
|
||||||
title="Comment fonctionne Readwise ?"
|
title={t('integrations.readwiseInfo')}
|
||||||
steps={[
|
steps={[
|
||||||
{ text: 'Copiez votre token d\'accès Readwise.', link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
|
{ text: t('integrations.readwiseHelpStep1'), 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: t('integrations.readwiseHelpStep2') },
|
||||||
{ text: 'Chaque livre devient une note dans un carnet "Readwise 📚" — avec tous vos surlignages organisés.' },
|
{ text: t('integrations.readwiseHelpStep3') },
|
||||||
{ text: 'Pour mettre à jour avec de nouveaux surlignages, revenez ici et cliquez "Synchroniser maintenant".' },
|
{ text: t('integrations.readwiseHelpStep4') },
|
||||||
{ icon: '💡', text: 'Astuce : créez des flashcards IA depuis une note Readwise (bouton 🎓 dans l\'éditeur) pour réviser vos lectures.' },
|
{ icon: '💡', text: t('integrations.readwiseHelpStep5') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -254,7 +360,7 @@ export default function IntegrationsPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={rwToken}
|
value={rwToken}
|
||||||
onChange={(e) => setRwToken(e.target.value)}
|
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"
|
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()}
|
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"
|
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}
|
{rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||||
Connecter
|
{t('integrations.connect')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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} />}
|
{rwSyncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||||
Synchroniser maintenant
|
{t('integrations.syncNow')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRwDisconnect}
|
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"
|
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} />
|
<Trash2 size={14} />
|
||||||
Déconnecter
|
{t('integrations.disconnect')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rwLastSync && (
|
{rwLastSync && (
|
||||||
<p className="text-[11px] text-concrete">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder */}
|
{/* Placeholder */}
|
||||||
<div className="border border-dashed border-border/40 rounded-2xl p-6 text-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1082,6 +1082,27 @@ export async function getRecentNotes(limit: number = 3) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get count of unassigned notes (Inbox count) — notes without a notebook
|
||||||
|
export async function getInboxCount() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const count = await prisma.note.count({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
notebookId: null,
|
||||||
|
isArchived: false,
|
||||||
|
trashedAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error counting inbox notes:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dismiss a note from Recent section
|
// Dismiss a note from Recent section
|
||||||
export async function dismissFromRecent(id: string) {
|
export async function dismissFromRecent(id: string) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|||||||
21
memento-note/app/api/agents/suggestions/[id]/accept/route.ts
Normal file
21
memento-note/app/api/agents/suggestions/[id]/accept/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const result = await agentSuggestionService.accept(session.user.id, id)
|
||||||
|
if (!result) {
|
||||||
|
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, agentId: result.agentId })
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const result = await agentSuggestionService.dismiss(session.user.id, id)
|
||||||
|
if (!result) {
|
||||||
|
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
30
memento-note/app/api/agents/suggestions/route.ts
Normal file
30
memento-note/app/api/agents/suggestions/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = await agentSuggestionService.getPending(session.user.id, 3)
|
||||||
|
return NextResponse.json({
|
||||||
|
suggestions: suggestions.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
topic: s.topic,
|
||||||
|
reason: s.reason,
|
||||||
|
suggestedType: s.suggestedType,
|
||||||
|
suggestedFrequency: s.suggestedFrequency,
|
||||||
|
relatedNoteCount: (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s.relatedNoteIds).length
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
clusterId: s.clusterId,
|
||||||
|
createdAt: s.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
204
memento-note/app/api/briefing/paths/route.ts
Normal file
204
memento-note/app/api/briefing/paths/route.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
|
||||||
|
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
|
||||||
|
import { buildDashboardPaths, buildOpenLoops } from '@/lib/dashboard/paths.service'
|
||||||
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
|
|
||||||
|
function excerptNoteContent(content: string | null | undefined, max = 200): string {
|
||||||
|
if (!content) return ''
|
||||||
|
const plain = content.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
if (plain.length <= max) return plain
|
||||||
|
return `${plain.slice(0, max)}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PathsBriefingSnapshot {
|
||||||
|
recentNote?: {
|
||||||
|
id: string
|
||||||
|
title: string | null
|
||||||
|
content: string
|
||||||
|
notebookId: string | null
|
||||||
|
} | null
|
||||||
|
inboxCount?: number
|
||||||
|
dueFlashcards?: number
|
||||||
|
insights?: Array<{
|
||||||
|
id: string
|
||||||
|
insight: string
|
||||||
|
score: number
|
||||||
|
viewed: boolean
|
||||||
|
note1: { id: string; title: string | null }
|
||||||
|
note2: { id: string; title: string | null }
|
||||||
|
note1Excerpt?: string
|
||||||
|
note2Excerpt?: string
|
||||||
|
}>
|
||||||
|
bridgeSuggestions?: Array<{
|
||||||
|
clusterAId: number
|
||||||
|
clusterBId: number
|
||||||
|
clusterAName: string
|
||||||
|
clusterBName: string
|
||||||
|
suggestedTitle: string
|
||||||
|
suggestedContent: string
|
||||||
|
justification: string
|
||||||
|
}>
|
||||||
|
agentSuggestions?: Array<{
|
||||||
|
id: string
|
||||||
|
topic: string
|
||||||
|
reason: string
|
||||||
|
clusterId: number | null
|
||||||
|
}>
|
||||||
|
includeOpenLoops?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePathsPayload(userId: string, snapshot?: PathsBriefingSnapshot | null) {
|
||||||
|
const now = new Date()
|
||||||
|
const hasSnapshot = !!snapshot?.recentNote
|
||||||
|
|
||||||
|
const needsAgents = !snapshot?.agentSuggestions
|
||||||
|
const needsBridges = !snapshot?.bridgeSuggestions
|
||||||
|
const needsInsights = !snapshot?.insights
|
||||||
|
const needsCounts = snapshot?.inboxCount === undefined || snapshot?.dueFlashcards === undefined
|
||||||
|
const needsFocusNote = !snapshot?.recentNote
|
||||||
|
|
||||||
|
const [
|
||||||
|
aiConsent,
|
||||||
|
aiSettings,
|
||||||
|
systemConfig,
|
||||||
|
recentNotes,
|
||||||
|
inboxCount,
|
||||||
|
dueFlashcards,
|
||||||
|
unviewedInsights,
|
||||||
|
agentSuggestions,
|
||||||
|
bridgeSuggestions,
|
||||||
|
] = await Promise.all([
|
||||||
|
hasUserAiConsent(),
|
||||||
|
prisma.userAISettings.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { memoryEcho: true },
|
||||||
|
}),
|
||||||
|
getSystemConfig(),
|
||||||
|
needsFocusNote
|
||||||
|
? prisma.note.findMany({
|
||||||
|
where: { userId, trashedAt: null, isArchived: false },
|
||||||
|
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
needsCounts
|
||||||
|
? prisma.note.count({
|
||||||
|
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
|
||||||
|
})
|
||||||
|
: Promise.resolve(snapshot!.inboxCount!),
|
||||||
|
needsCounts
|
||||||
|
? prisma.flashcard.count({
|
||||||
|
where: { deck: { userId }, nextReviewAt: { lte: now } },
|
||||||
|
})
|
||||||
|
: Promise.resolve(snapshot!.dueFlashcards!),
|
||||||
|
needsInsights
|
||||||
|
? prisma.memoryEchoInsight.findMany({
|
||||||
|
where: { userId, dismissed: false },
|
||||||
|
select: {
|
||||||
|
id: true, insight: true, similarityScore: true, viewed: true,
|
||||||
|
note1: { select: { id: true, title: true, content: true } },
|
||||||
|
note2: { select: { id: true, title: true, content: true } },
|
||||||
|
},
|
||||||
|
orderBy: { insightDate: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
}).catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
|
needsAgents
|
||||||
|
? agentSuggestionService.getPending(userId, 3).catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
|
needsBridges
|
||||||
|
? bridgeNotesService.getBridgeSuggestions(userId).catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
|
])
|
||||||
|
|
||||||
|
const aiProviderReady = !!getChatProvider(systemConfig)
|
||||||
|
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
|
||||||
|
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
|
||||||
|
|
||||||
|
const focusNotes = hasSnapshot && snapshot?.recentNote
|
||||||
|
? [{
|
||||||
|
...snapshot.recentNote,
|
||||||
|
updatedAt: now,
|
||||||
|
}]
|
||||||
|
: recentNotes
|
||||||
|
|
||||||
|
const insights = snapshot?.insights ?? unviewedInsights.map(i => ({
|
||||||
|
id: i.id,
|
||||||
|
insight: i.insight,
|
||||||
|
score: i.similarityScore,
|
||||||
|
viewed: i.viewed,
|
||||||
|
note1: { id: i.note1.id, title: i.note1.title },
|
||||||
|
note2: { id: i.note2.id, title: i.note2.title },
|
||||||
|
note1Excerpt: excerptNoteContent(i.note1.content),
|
||||||
|
note2Excerpt: excerptNoteContent(i.note2.content),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const resolvedBridges = snapshot?.bridgeSuggestions
|
||||||
|
?? bridgeSuggestions.slice(0, 3).map(s => ({
|
||||||
|
clusterAId: s.clusterAId,
|
||||||
|
clusterBId: s.clusterBId,
|
||||||
|
clusterAName: s.clusterAName,
|
||||||
|
clusterBName: s.clusterBName,
|
||||||
|
suggestedTitle: s.suggestedTitle,
|
||||||
|
suggestedContent: s.suggestedContent,
|
||||||
|
justification: s.justification,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const resolvedAgents = snapshot?.agentSuggestions
|
||||||
|
?? agentSuggestions.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
topic: s.topic,
|
||||||
|
reason: s.reason,
|
||||||
|
clusterId: s.clusterId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const paths = await buildDashboardPaths({
|
||||||
|
userId,
|
||||||
|
aiActive,
|
||||||
|
recentNotes: focusNotes,
|
||||||
|
inboxCount: needsCounts ? inboxCount : snapshot!.inboxCount!,
|
||||||
|
dueFlashcards: needsCounts ? dueFlashcards : snapshot!.dueFlashcards!,
|
||||||
|
insights,
|
||||||
|
bridgeSuggestions: resolvedBridges,
|
||||||
|
agentSuggestions: resolvedAgents,
|
||||||
|
}).catch(() => [])
|
||||||
|
|
||||||
|
const openLoops = snapshot?.includeOpenLoops === false
|
||||||
|
? []
|
||||||
|
: await buildOpenLoops(userId).catch(() => [])
|
||||||
|
|
||||||
|
return { paths, openLoops }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/briefing/paths — pistes enrichies (fallback sans snapshot client).
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await resolvePathsPayload(session.user.id)
|
||||||
|
return NextResponse.json(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/briefing/paths — enrichissement avec snapshot briefing (évite re-fetch agents/ponts).
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await request.json().catch(() => null) as PathsBriefingSnapshot | null
|
||||||
|
const payload = await resolvePathsPayload(session.user.id, snapshot)
|
||||||
|
return NextResponse.json(payload)
|
||||||
|
}
|
||||||
240
memento-note/app/api/briefing/route.ts
Normal file
240
memento-note/app/api/briefing/route.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
|
||||||
|
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
|
||||||
|
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
|
||||||
|
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
|
|
||||||
|
function excerptNoteContent(content: string | null | undefined, max = 200): string {
|
||||||
|
if (!content) return ''
|
||||||
|
const plain = content.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
if (plain.length <= max) return plain
|
||||||
|
return `${plain.slice(0, max)}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWritingActivity(userId: string, since: Date) {
|
||||||
|
const rows = await prisma.$queryRaw<Array<{ date: string; count: number }>>`
|
||||||
|
SELECT to_char("updatedAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
|
||||||
|
COUNT(*)::int AS count
|
||||||
|
FROM "Note"
|
||||||
|
WHERE "userId" = ${userId}
|
||||||
|
AND "trashedAt" IS NULL
|
||||||
|
AND "updatedAt" >= ${since}
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 ASC
|
||||||
|
`
|
||||||
|
return rows.map(r => ({ date: r.date, count: Number(r.count) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/briefing — agrégat rapide pour le tableau de bord.
|
||||||
|
* Pas de génération IA synchrone ; les pistes sont dans GET /api/briefing/paths.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id
|
||||||
|
const now = new Date()
|
||||||
|
const ninetyDaysAgo = new Date(now)
|
||||||
|
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
||||||
|
|
||||||
|
const [aiConsent, aiSettings, systemConfig] = await Promise.all([
|
||||||
|
hasUserAiConsent(),
|
||||||
|
prisma.userAISettings.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { memoryEcho: true },
|
||||||
|
}),
|
||||||
|
getSystemConfig(),
|
||||||
|
])
|
||||||
|
const aiProviderReady = !!getChatProvider(systemConfig)
|
||||||
|
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
|
||||||
|
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
|
||||||
|
|
||||||
|
const [
|
||||||
|
recentNotes,
|
||||||
|
inboxCount,
|
||||||
|
dueFlashcards,
|
||||||
|
upcomingReminders,
|
||||||
|
unviewedInsights,
|
||||||
|
recentAgentActions,
|
||||||
|
pinnedNotes,
|
||||||
|
writingActivity,
|
||||||
|
gmailStatus,
|
||||||
|
agentSuggestions,
|
||||||
|
bridgeSuggestions,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.note.findMany({
|
||||||
|
where: { userId, trashedAt: null, isArchived: false },
|
||||||
|
select: {
|
||||||
|
id: true, title: true, content: true, color: true,
|
||||||
|
notebookId: true, updatedAt: true, createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: 6,
|
||||||
|
}),
|
||||||
|
|
||||||
|
prisma.note.count({
|
||||||
|
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
|
||||||
|
}),
|
||||||
|
|
||||||
|
prisma.flashcard.count({
|
||||||
|
where: { deck: { userId }, nextReviewAt: { lte: now } },
|
||||||
|
}),
|
||||||
|
|
||||||
|
prisma.note.findMany({
|
||||||
|
where: {
|
||||||
|
userId, reminder: { gte: now }, isReminderDone: false, trashedAt: null,
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, reminder: true, notebookId: true },
|
||||||
|
orderBy: { reminder: 'asc' },
|
||||||
|
take: 5,
|
||||||
|
}),
|
||||||
|
|
||||||
|
prisma.memoryEchoInsight.findMany({
|
||||||
|
where: { userId, viewed: false, dismissed: false },
|
||||||
|
select: {
|
||||||
|
id: true, insight: true, similarityScore: true,
|
||||||
|
insightDate: true, viewed: true,
|
||||||
|
note1: { select: { id: true, title: true, content: true } },
|
||||||
|
note2: { select: { id: true, title: true, content: true } },
|
||||||
|
},
|
||||||
|
orderBy: { insightDate: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
}).catch(() => []),
|
||||||
|
|
||||||
|
prisma.agentAction.findMany({
|
||||||
|
where: {
|
||||||
|
agent: { userId },
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: { gte: new Date(now.getTime() - 48 * 60 * 60 * 1000) },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true, result: true, createdAt: true,
|
||||||
|
agent: { select: { id: true, name: true, type: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 3,
|
||||||
|
}).catch(() => []),
|
||||||
|
|
||||||
|
prisma.note.findMany({
|
||||||
|
where: { userId, isPinned: true, trashedAt: null, isArchived: false },
|
||||||
|
select: { id: true, title: true, notebookId: true, updatedAt: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
}),
|
||||||
|
|
||||||
|
fetchWritingActivity(userId, ninetyDaysAgo).catch(() => []),
|
||||||
|
|
||||||
|
gmailScannerService.getStatus(userId).catch(() => ({
|
||||||
|
connected: false,
|
||||||
|
recentCaptures: 0,
|
||||||
|
})),
|
||||||
|
|
||||||
|
agentSuggestionService.getPending(userId, 3).catch(() => []),
|
||||||
|
|
||||||
|
bridgeNotesService.getBridgeSuggestions(userId).catch(() => []),
|
||||||
|
])
|
||||||
|
|
||||||
|
let insightRows = unviewedInsights
|
||||||
|
if (insightRows.length < 5) {
|
||||||
|
const viewedRecent = await prisma.memoryEchoInsight.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
dismissed: false,
|
||||||
|
viewed: true,
|
||||||
|
id: { notIn: insightRows.map(i => i.id) },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true, insight: true, similarityScore: true,
|
||||||
|
insightDate: true, viewed: true,
|
||||||
|
note1: { select: { id: true, title: true, content: true } },
|
||||||
|
note2: { select: { id: true, title: true, content: true } },
|
||||||
|
},
|
||||||
|
orderBy: { insightDate: 'desc' },
|
||||||
|
take: 5 - insightRows.length,
|
||||||
|
}).catch(() => [])
|
||||||
|
insightRows = [...insightRows, ...viewedRecent]
|
||||||
|
}
|
||||||
|
|
||||||
|
const notebookIds = [...new Set(recentNotes.map(n => n.notebookId).filter(Boolean))] as string[]
|
||||||
|
const notebooks = notebookIds.length > 0
|
||||||
|
? await prisma.notebook.findMany({
|
||||||
|
where: { id: { in: notebookIds } },
|
||||||
|
select: { id: true, name: true, color: true, icon: true },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
const notebookMap = new Map(notebooks.map(n => [n.id, n]))
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
recentNotes: recentNotes.map(n => ({
|
||||||
|
...n,
|
||||||
|
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
|
||||||
|
})),
|
||||||
|
inboxCount,
|
||||||
|
dueFlashcards,
|
||||||
|
upcomingReminders: upcomingReminders.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
reminder: r.reminder?.toISOString() || null,
|
||||||
|
notebookId: r.notebookId,
|
||||||
|
})),
|
||||||
|
insights: insightRows.map(i => ({
|
||||||
|
id: i.id,
|
||||||
|
insight: i.insight,
|
||||||
|
score: i.similarityScore,
|
||||||
|
date: i.insightDate.toISOString(),
|
||||||
|
viewed: i.viewed,
|
||||||
|
note1: { id: i.note1.id, title: i.note1.title },
|
||||||
|
note2: { id: i.note2.id, title: i.note2.title },
|
||||||
|
note1Excerpt: excerptNoteContent(i.note1.content),
|
||||||
|
note2Excerpt: excerptNoteContent(i.note2.content),
|
||||||
|
})),
|
||||||
|
bridgeSuggestions: bridgeSuggestions.slice(0, 3).map(s => ({
|
||||||
|
clusterAId: s.clusterAId,
|
||||||
|
clusterBId: s.clusterBId,
|
||||||
|
clusterAName: s.clusterAName,
|
||||||
|
clusterBName: s.clusterBName,
|
||||||
|
suggestedTitle: s.suggestedTitle,
|
||||||
|
suggestedContent: s.suggestedContent,
|
||||||
|
justification: s.justification,
|
||||||
|
})),
|
||||||
|
ai: {
|
||||||
|
consent: aiConsent,
|
||||||
|
memoryEchoEnabled,
|
||||||
|
providerReady: aiProviderReady,
|
||||||
|
active: aiActive,
|
||||||
|
},
|
||||||
|
agentActions: recentAgentActions.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
agentName: a.agent?.name || 'Agent',
|
||||||
|
agentType: a.agent?.type || 'custom',
|
||||||
|
result: a.result ? String(a.result).slice(0, 200) : null,
|
||||||
|
createdAt: a.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
gmail: gmailStatus,
|
||||||
|
pinnedNotes: pinnedNotes.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
notebookId: n.notebookId,
|
||||||
|
updatedAt: n.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
writingActivity,
|
||||||
|
agentSuggestions: agentSuggestions.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
topic: s.topic,
|
||||||
|
reason: s.reason,
|
||||||
|
suggestedType: s.suggestedType,
|
||||||
|
suggestedFrequency: s.suggestedFrequency,
|
||||||
|
relatedNoteCount: (() => {
|
||||||
|
try { return JSON.parse(s.relatedNoteIds).length } catch { return 0 }
|
||||||
|
})(),
|
||||||
|
clusterId: s.clusterId,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
87
memento-note/app/api/briefing/sentiment/route.ts
Normal file
87
memento-note/app/api/briefing/sentiment/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { redis } from '@/lib/redis'
|
||||||
|
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
|
||||||
|
|
||||||
|
const CACHE_TTL_SEC = 3600
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/briefing/sentiment
|
||||||
|
* Analyzes the emotional tone of recent notes (last 7 days) using LLM.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id
|
||||||
|
const locale = await detectUserLanguage()
|
||||||
|
const cacheKey = `briefing:sentiment:${userId}:${locale}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = await redis.get(cacheKey)
|
||||||
|
if (cached) return NextResponse.json(JSON.parse(cached))
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const weekAgo = new Date()
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 7)
|
||||||
|
|
||||||
|
const recentNotes = await prisma.note.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
trashedAt: null,
|
||||||
|
isArchived: false,
|
||||||
|
updatedAt: { gte: weekAgo },
|
||||||
|
},
|
||||||
|
select: { title: true, content: true },
|
||||||
|
take: 20,
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (recentNotes.length < 3) {
|
||||||
|
return NextResponse.json({
|
||||||
|
available: false,
|
||||||
|
reason: 'Not enough notes this week',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippets = recentNotes.map(n => {
|
||||||
|
const text = n.content.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
return `${n.title || ''}: ${text.slice(0, 200)}`
|
||||||
|
}).join('\n---\n')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getChatProvider(config)
|
||||||
|
if (!provider) {
|
||||||
|
return NextResponse.json({ available: false, reason: 'No AI provider' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale
|
||||||
|
const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences).
|
||||||
|
Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep dominantEmotion keys in English as listed.
|
||||||
|
|
||||||
|
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence describing the emotional pattern","topTopic":"most discussed topic"}
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
${snippets.slice(0, 3000)}`
|
||||||
|
|
||||||
|
const result = await provider.generateText(prompt)
|
||||||
|
const jsonMatch = result.match(/\{[\s\S]*\}/)
|
||||||
|
if (!jsonMatch) {
|
||||||
|
return NextResponse.json({ available: false, reason: 'Parse error' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonMatch[0])
|
||||||
|
const payload = { available: true, ...parsed }
|
||||||
|
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
|
||||||
|
return NextResponse.json(payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[briefing/sentiment]', error)
|
||||||
|
return NextResponse.json({ available: false, reason: 'Analysis failed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,54 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// Check for stored results (even if stale/périmés)
|
// Check for stored results (even if stale/périmés)
|
||||||
const stored = await clusteringService.getStoredClusters(userId)
|
const stored = await clusteringService.getStoredClusters(userId)
|
||||||
|
const lite = request.nextUrl.searchParams.get('lite') === '1'
|
||||||
|
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const cached = stored.clusters
|
const cached = stored.clusters
|
||||||
|
|
||||||
|
if (lite) {
|
||||||
|
const [bridgeNotesData, totalNotes] = await Promise.all([
|
||||||
|
prisma.bridgeNote.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { bridgeScore: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
select: { noteId: true, bridgeScore: true, clustersConnected: true },
|
||||||
|
}),
|
||||||
|
prisma.note.count({ where: { userId, trashedAt: null } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
let enrichedBridgeNotes: object[] = []
|
||||||
|
if (bridgeNotesData.length > 0) {
|
||||||
|
const bridgeNoteDetails = await prisma.note.findMany({
|
||||||
|
where: { id: { in: bridgeNotesData.map(b => b.noteId) } },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
})
|
||||||
|
const bridgeNoteDetailsMap = new Map(bridgeNoteDetails.map(n => [n.id, n]))
|
||||||
|
|
||||||
|
enrichedBridgeNotes = bridgeNotesData.map(b => {
|
||||||
|
const clustersConnected = JSON.parse(b.clustersConnected) as number[]
|
||||||
|
return {
|
||||||
|
noteId: b.noteId,
|
||||||
|
bridgeScore: b.bridgeScore,
|
||||||
|
clustersConnected,
|
||||||
|
clusterNames: clustersConnected.map(
|
||||||
|
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`,
|
||||||
|
),
|
||||||
|
note: bridgeNoteDetailsMap.get(b.noteId),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
clusters: cached,
|
||||||
|
bridgeNotes: enrichedBridgeNotes,
|
||||||
|
cached: true,
|
||||||
|
stale: stored.stale,
|
||||||
|
lastCalculated: stored.lastCalculated,
|
||||||
|
totalNotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch notes with their cluster assignments
|
// Fetch notes with their cluster assignments
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where: { userId, trashedAt: null },
|
where: { userId, trashedAt: null },
|
||||||
|
|||||||
41
memento-note/app/api/cron/agent-suggestions/route.ts
Normal file
41
memento-note/app/api/cron/agent-suggestions/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
function authorizeCron(request: NextRequest): boolean {
|
||||||
|
const cronSecret = process.env.CRON_SECRET
|
||||||
|
if (!cronSecret) return false
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||||
|
const querySecret = new URL(request.url).searchParams.get('secret')
|
||||||
|
return bearer === cronSecret || querySecret === cronSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cron/agent-suggestions
|
||||||
|
* Génère des suggestions d'agents à partir des clusters sémantiques existants.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!authorizeCron(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { noteClusters: { some: {} } },
|
||||||
|
select: { id: true },
|
||||||
|
take: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
let total = 0
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
total += await agentSuggestionService.generateForUser(user.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CronAgentSuggestions] user', user.id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, users: users.length, suggestionsUpserted: total })
|
||||||
|
}
|
||||||
49
memento-note/app/api/cron/gmail/route.ts
Normal file
49
memento-note/app/api/cron/gmail/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
function authorize(request: NextRequest): boolean {
|
||||||
|
const cronSecret = process.env.CRON_SECRET
|
||||||
|
if (!cronSecret) return false
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||||
|
return bearer === cronSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/cron/gmail — scan Gmail for connected users (1×/jour en prod via entrypoint) */
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!authorize(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await prisma.userAISettings.findMany({
|
||||||
|
select: { userId: true, integrationTokens: true },
|
||||||
|
take: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
const connected = users.filter(u => {
|
||||||
|
if (!u.integrationTokens) return false
|
||||||
|
try {
|
||||||
|
const meta = typeof u.integrationTokens === 'string'
|
||||||
|
? JSON.parse(u.integrationTokens)
|
||||||
|
: u.integrationTokens
|
||||||
|
return !!(meta?.gmailAccessToken && meta?.gmailRefreshToken)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let totalCreated = 0
|
||||||
|
for (const row of connected) {
|
||||||
|
try {
|
||||||
|
const { created } = await gmailScannerService.scanUser(row.userId)
|
||||||
|
totalCreated += created
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CronGmail]', row.userId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, users: connected.length, notesCreated: totalCreated })
|
||||||
|
}
|
||||||
49
memento-note/app/api/dashboard/layout/route.ts
Normal file
49
memento-note/app/api/dashboard/layout/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
getDefaultDashboardLayout,
|
||||||
|
normalizeDashboardLayout,
|
||||||
|
type DashboardLayout,
|
||||||
|
} from '@/lib/dashboard/layout'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await prisma.userAISettings.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: { dashboardLayout: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const layout = normalizeDashboardLayout(settings?.dashboardLayout ?? getDefaultDashboardLayout())
|
||||||
|
return NextResponse.json({ layout })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { layout?: DashboardLayout }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = normalizeDashboardLayout(body.layout ?? getDefaultDashboardLayout())
|
||||||
|
const layoutJson = layout as unknown as Prisma.InputJsonValue
|
||||||
|
|
||||||
|
await prisma.userAISettings.upsert({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
create: { userId: session.user.id, dashboardLayout: layoutJson },
|
||||||
|
update: { dashboardLayout: layoutJson },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ layout, ok: true })
|
||||||
|
}
|
||||||
38
memento-note/app/api/integrations/gmail/callback/route.ts
Normal file
38
memento-note/app/api/integrations/gmail/callback/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getGoogleOAuthCredentials, readIntegrationMeta, writeIntegrationMeta } from '@/lib/integrations/google-integration-tokens'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const code = url.searchParams.get('code')
|
||||||
|
const userId = url.searchParams.get('state')
|
||||||
|
const error = url.searchParams.get('error')
|
||||||
|
|
||||||
|
if (error || !code || !userId) {
|
||||||
|
return NextResponse.redirect(`${url.origin}/settings/integrations?error=gmail_auth_failed`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId, clientSecret } = getGoogleOAuthCredentials()
|
||||||
|
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code,
|
||||||
|
redirect_uri: `${url.origin}/api/integrations/gmail/callback`,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const tokenData = await tokenRes.json()
|
||||||
|
if (!tokenData.access_token) {
|
||||||
|
return NextResponse.redirect(`${url.origin}/settings/integrations?error=gmail_token_failed`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await readIntegrationMeta(userId)
|
||||||
|
meta.gmailAccessToken = tokenData.access_token
|
||||||
|
if (tokenData.refresh_token) meta.gmailRefreshToken = tokenData.refresh_token
|
||||||
|
await writeIntegrationMeta(userId, meta)
|
||||||
|
|
||||||
|
return NextResponse.redirect(`${url.origin}/settings/integrations?connected=gmail`)
|
||||||
|
}
|
||||||
67
memento-note/app/api/integrations/gmail/route.ts
Normal file
67
memento-note/app/api/integrations/gmail/route.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Google Gmail Integration (OAuth + scan trigger)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import {
|
||||||
|
getGoogleOAuthCredentials,
|
||||||
|
getGoogleTokens,
|
||||||
|
readIntegrationMeta,
|
||||||
|
writeIntegrationMeta,
|
||||||
|
} from '@/lib/integrations/google-integration-tokens'
|
||||||
|
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
|
||||||
|
|
||||||
|
const GMAIL_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'
|
||||||
|
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||||
|
|
||||||
|
function redirectUri(origin: string) {
|
||||||
|
return `${origin}/api/integrations/gmail/callback`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
|
if (url.searchParams.get('connect') === '1') {
|
||||||
|
const { clientId } = getGoogleOAuthCredentials()
|
||||||
|
if (!clientId) {
|
||||||
|
return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 503 })
|
||||||
|
}
|
||||||
|
const authUrl = new URL(GOOGLE_AUTH_URL)
|
||||||
|
authUrl.searchParams.set('client_id', clientId)
|
||||||
|
authUrl.searchParams.set('redirect_uri', redirectUri(url.origin))
|
||||||
|
authUrl.searchParams.set('response_type', 'code')
|
||||||
|
authUrl.searchParams.set('scope', GMAIL_SCOPE)
|
||||||
|
authUrl.searchParams.set('access_type', 'offline')
|
||||||
|
authUrl.searchParams.set('prompt', 'consent')
|
||||||
|
authUrl.searchParams.set('state', userId)
|
||||||
|
return NextResponse.redirect(authUrl.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.searchParams.get('scan') === '1') {
|
||||||
|
const result = await gmailScannerService.scanUser(userId)
|
||||||
|
return NextResponse.json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await gmailScannerService.getStatus(userId)
|
||||||
|
return NextResponse.json(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await readIntegrationMeta(session.user.id)
|
||||||
|
delete meta.gmailAccessToken
|
||||||
|
delete meta.gmailRefreshToken
|
||||||
|
await writeIntegrationMeta(session.user.id, meta)
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
@@ -24,14 +24,14 @@ const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
|
|||||||
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
|
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
|
||||||
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
|
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
|
||||||
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
|
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
|
||||||
openrouter: { name: 'OpenRouter', hint: 'Accès multi-fournisseurs' },
|
openrouter: { name: 'OpenRouter', hint: 'Multi-provider access' },
|
||||||
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
|
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
|
||||||
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
|
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
|
||||||
zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' },
|
zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' },
|
||||||
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' },
|
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' },
|
||||||
custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' },
|
custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' },
|
||||||
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' },
|
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' },
|
||||||
custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' },
|
custom: { name: 'Custom API', hint: 'Your own endpoint' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayName(provider: string): string {
|
function displayName(provider: string): string {
|
||||||
@@ -59,7 +59,7 @@ type SavedKey = {
|
|||||||
|
|
||||||
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
|
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
|
||||||
const res = await fetch('/api/user/api-keys')
|
const res = await fetch('/api/user/api-keys')
|
||||||
if (!res.ok) throw new Error('Erreur de chargement')
|
if (!res.ok) throw new Error('Failed to load')
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +92,8 @@ function EditKeyForm({
|
|||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
|
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
|
||||||
|
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const needsUrl = NEEDS_BASE_URL.has(savedKey.provider)
|
const needsUrl = NEEDS_BASE_URL.has(savedKey.provider)
|
||||||
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
|
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
|
||||||
|
|
||||||
@@ -118,10 +120,10 @@ function EditKeyForm({
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
const body = await res.json().catch(() => ({}))
|
const body = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
|
if (!res.ok) throw new Error(body.message ?? body.error ?? t('common.error'))
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`${displayName(savedKey.provider)} mis à jour ✓`)
|
toast.success(t('byok.updated', { name: displayName(savedKey.provider) }))
|
||||||
onInvalidate()
|
onInvalidate()
|
||||||
onDone()
|
onDone()
|
||||||
},
|
},
|
||||||
@@ -132,7 +134,7 @@ function EditKeyForm({
|
|||||||
const keyToUse = newKey.length >= 8 ? newKey : ''
|
const keyToUse = newKey.length >= 8 ? newKey : ''
|
||||||
if (!keyToUse && !model) return
|
if (!keyToUse && !model) return
|
||||||
if (!keyToUse) {
|
if (!keyToUse) {
|
||||||
toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"')
|
toast.info(t('byok.testHint'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTesting(true)
|
setTesting(true)
|
||||||
@@ -143,7 +145,7 @@ function EditKeyForm({
|
|||||||
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
|
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
|
||||||
setTestResult(await res.json())
|
setTestResult(await res.json())
|
||||||
} catch {
|
} catch {
|
||||||
setTestResult({ ok: false, error: 'Impossible de contacter le serveur' })
|
setTestResult({ ok: false, error: t('byok.contactError') })
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false)
|
setTesting(false)
|
||||||
}
|
}
|
||||||
@@ -155,18 +157,18 @@ function EditKeyForm({
|
|||||||
return (
|
return (
|
||||||
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
|
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
|
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
|
||||||
Modifier — {displayName(savedKey.provider)}
|
{t('byok.editLabel', { name: displayName(savedKey.provider) })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Alias */}
|
{/* Alias */}
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Alias</Label>
|
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')}</Label>
|
||||||
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" />
|
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
|
||||||
</div>
|
</div>
|
||||||
{needsUrl && (
|
{needsUrl && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
|
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
|
||||||
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
|
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -174,21 +176,21 @@ function EditKeyForm({
|
|||||||
|
|
||||||
{/* Model */}
|
{/* Model */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
|
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
|
||||||
{showModelDropdown ? (
|
{showModelDropdown ? (
|
||||||
<Select value={model} onValueChange={setModel}>
|
<Select value={model} onValueChange={setModel}>
|
||||||
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder="Choisir..." /></SelectTrigger>
|
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder={t('byok.choose')} /></SelectTrigger>
|
||||||
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : showModelInput ? (
|
) : showModelInput ? (
|
||||||
<Input id={`edit-model-${savedKey.provider}`} value={model} onChange={(e) => setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" />
|
<Input id={`edit-model-${savedKey.provider}`} value={model} onChange={(e) => setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" />
|
||||||
) : <div className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />Chargement...</div>}
|
) : <div className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />{t('common.loading')}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key rotation (optional) */}
|
{/* Key rotation (optional) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
|
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
|
||||||
Nouvelle clé API <span className="normal-case font-normal text-concrete">(laisser vide pour conserver l'actuelle)</span>
|
{t('byok.newKey')} <span className="normal-case font-normal text-concrete">{t('byok.newKeyHint')}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`edit-key-${savedKey.provider}`}
|
id={`edit-key-${savedKey.provider}`}
|
||||||
@@ -196,7 +198,7 @@ function EditKeyForm({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={newKey}
|
value={newKey}
|
||||||
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
|
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
|
||||||
placeholder="sk-... (optionnel)"
|
placeholder={t('byok.keyPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,8 +211,8 @@ function EditKeyForm({
|
|||||||
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
|
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
|
||||||
<span>
|
<span>
|
||||||
{testResult.ok
|
{testResult.ok
|
||||||
? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
|
? `${t('byok.operational')}${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
|
||||||
: testResult.error ?? 'Échec'
|
: testResult.error ?? t('common.error')
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +227,7 @@ function EditKeyForm({
|
|||||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||||
>
|
>
|
||||||
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
|
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
|
||||||
Tester
|
{t('byok.test')}
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -233,7 +235,7 @@ function EditKeyForm({
|
|||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
|
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Enregistrer les modifications'}
|
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('byok.saveChanges')}
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -305,7 +307,7 @@ export function ByokSettingsPanel() {
|
|||||||
|
|
||||||
async function verifyKey() {
|
async function verifyKey() {
|
||||||
if (!provider || apiKey.length < 8) return
|
if (!provider || apiKey.length < 8) return
|
||||||
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error("Veuillez renseigner l'URL de l'API"); return }
|
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error(t('byok.baseUrlRequired')); return }
|
||||||
setVerifying(true)
|
setVerifying(true)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
try {
|
try {
|
||||||
@@ -316,12 +318,12 @@ export function ByokSettingsPanel() {
|
|||||||
if (res.ok && body.valid) {
|
if (res.ok && body.valid) {
|
||||||
setKeyOk(true)
|
setKeyOk(true)
|
||||||
if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) }
|
if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) }
|
||||||
toast.success('Clé API valide ✓')
|
toast.success(t('byok.keyValid'))
|
||||||
} else {
|
} else {
|
||||||
setKeyOk(false)
|
setKeyOk(false)
|
||||||
toast.error(body.message ?? 'Clé API invalide')
|
toast.error(body.message ?? t('byok.keyInvalid'))
|
||||||
}
|
}
|
||||||
} catch { setKeyOk(false); toast.error('Erreur de vérification') }
|
} catch { setKeyOk(false); toast.error(t('byok.verifyError')) }
|
||||||
finally { setVerifying(false) }
|
finally { setVerifying(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +336,7 @@ export function ByokSettingsPanel() {
|
|||||||
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
|
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
|
||||||
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
|
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
|
||||||
setTestResult(await res.json())
|
setTestResult(await res.json())
|
||||||
} catch { setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) }
|
} catch { setTestResult({ ok: false, error: t('byok.contactError') }) }
|
||||||
finally { setTesting(false) }
|
finally { setTesting(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,10 +354,10 @@ export function ByokSettingsPanel() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
const body = await res.json().catch(() => ({}))
|
const body = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
|
if (!res.ok) throw new Error(body.message ?? body.error ?? t('common.error'))
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`Clé ${displayName(provider)} enregistrée ✓`)
|
toast.success(t('byok.keySaved', { name: displayName(provider) }))
|
||||||
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
|
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
|
||||||
setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
|
setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
|
||||||
invalidate()
|
invalidate()
|
||||||
@@ -369,7 +371,7 @@ export function ByokSettingsPanel() {
|
|||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ isActive }),
|
body: JSON.stringify({ isActive }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Erreur')
|
if (!res.ok) throw new Error(t('common.error'))
|
||||||
},
|
},
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
onError: () => toast.error(t('byokSettings.error')),
|
onError: () => toast.error(t('byokSettings.error')),
|
||||||
@@ -378,9 +380,9 @@ export function ByokSettingsPanel() {
|
|||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (p: string) => {
|
mutationFn: async (p: string) => {
|
||||||
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' })
|
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' })
|
||||||
if (!res.ok) throw new Error('Erreur')
|
if (!res.ok) throw new Error(t('common.error'))
|
||||||
},
|
},
|
||||||
onSuccess: () => { toast.success('Clé supprimée'); invalidate() },
|
onSuccess: () => { toast.success(t('byok.keyDeleted')); invalidate() },
|
||||||
onError: () => toast.error(t('byokSettings.error')),
|
onError: () => toast.error(t('byokSettings.error')),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -417,9 +419,9 @@ export function ByokSettingsPanel() {
|
|||||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
|
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
|
||||||
)}>
|
)}>
|
||||||
{activeKey ? (
|
{activeKey ? (
|
||||||
<><Zap size={14} className="shrink-0" /><span>BYOK actif · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
|
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
|
||||||
) : (
|
) : (
|
||||||
<><Shield size={14} className="shrink-0" /><span>Aucune clé active — utilisation des quotas Memento</span></>
|
<><Shield size={14} className="shrink-0" /><span>{t('byok.noActiveKey')}</span></>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -431,7 +433,7 @@ export function ByokSettingsPanel() {
|
|||||||
{/* Saved keys */}
|
{/* Saved keys */}
|
||||||
{keys.length > 0 && (
|
{keys.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">Clés enregistrées</p>
|
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byok.savedKeys')}</p>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{keys.map((key) => (
|
{keys.map((key) => (
|
||||||
<li key={key.provider}>
|
<li key={key.provider}>
|
||||||
@@ -448,7 +450,7 @@ export function ByokSettingsPanel() {
|
|||||||
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
|
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
|
||||||
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
|
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
|
||||||
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
|
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
|
||||||
{key.isActive ? '● Actif' : '○ Inactif'}
|
{key.isActive ? t('byok.activeStatus') : t('byok.inactiveStatus')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -481,7 +483,7 @@ export function ByokSettingsPanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="h-7 w-7 rounded-lg flex items-center justify-center text-destructive/50 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
|
className="h-7 w-7 rounded-lg flex items-center justify-center text-destructive/50 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
onClick={() => { if (confirm(`Supprimer la clé ${displayName(key.provider)} ?`)) deleteMutation.mutate(key.provider) }}
|
onClick={() => { if (confirm(t('byok.confirmDelete', { name: displayName(key.provider) }))) deleteMutation.mutate(key.provider) }}
|
||||||
>
|
>
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
@@ -507,14 +509,14 @@ export function ByokSettingsPanel() {
|
|||||||
{/* Add key form */}
|
{/* Add key form */}
|
||||||
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
|
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||||
{keys.length > 0 ? 'Ajouter / remplacer une clé' : 'Connecter votre fournisseur IA'}
|
{keys.length > 0 ? t('byok.addOrReplace') : t('byok.connectProvider')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Fournisseur</Label>
|
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
|
||||||
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
|
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
|
||||||
<SelectTrigger id="byok-provider"><SelectValue placeholder="Choisir un fournisseur..." /></SelectTrigger>
|
<SelectTrigger id="byok-provider"><SelectValue placeholder={t('byok.chooseProvider')} /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{allowed.map((p) => (
|
{allowed.map((p) => (
|
||||||
<SelectItem key={p} value={p}>
|
<SelectItem key={p} value={p}>
|
||||||
@@ -526,20 +528,20 @@ export function ByokSettingsPanel() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Alias <span className="normal-case font-normal">(optionnel)</span></Label>
|
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
|
||||||
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" disabled={saveMutation.isPending} />
|
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{NEEDS_BASE_URL.has(provider) && (
|
{NEEDS_BASE_URL.has(provider) && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
|
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
|
||||||
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
|
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Clé API</Label>
|
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiKey')}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
@@ -555,19 +557,19 @@ export function ByokSettingsPanel() {
|
|||||||
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
|
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
|
||||||
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
|
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
|
||||||
>
|
>
|
||||||
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : 'Vérifier'}
|
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : t('byok.verify')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{provider && (
|
{provider && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
|
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
|
||||||
{loadingModels ? (
|
{loadingModels ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />Récupération des modèles...</div>
|
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />{t('byok.fetchingModels')}</div>
|
||||||
) : showModelDropdown ? (
|
) : showModelDropdown ? (
|
||||||
<Select value={model} onValueChange={(v) => { setModel(v); setTestResult(null) }} disabled={saveMutation.isPending}>
|
<Select value={model} onValueChange={(v) => { setModel(v); setTestResult(null) }} disabled={saveMutation.isPending}>
|
||||||
<SelectTrigger id="byok-model"><SelectValue placeholder="Choisir un modèle..." /></SelectTrigger>
|
<SelectTrigger id="byok-model"><SelectValue placeholder={t('byok.chooseModel')} /></SelectTrigger>
|
||||||
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : showModelInput ? (
|
) : showModelInput ? (
|
||||||
@@ -582,8 +584,8 @@ export function ByokSettingsPanel() {
|
|||||||
{testResult.ok ? <CheckCircle2 size={14} className="shrink-0 mt-0.5" /> : <XCircle size={14} className="shrink-0 mt-0.5" />}
|
{testResult.ok ? <CheckCircle2 size={14} className="shrink-0 mt-0.5" /> : <XCircle size={14} className="shrink-0 mt-0.5" />}
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{testResult.ok ? (
|
{testResult.ok ? (
|
||||||
<><p className="font-semibold">Modèle opérationnel ✓{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">Réponse : {testResult.reply}</p>}</>
|
<><p className="font-semibold">{t('byok.modelOperational')}{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">{t('byok.reply')} {testResult.reply}</p>}</>
|
||||||
) : <p className="font-semibold">{testResult.error ?? 'Échec du test'}</p>}
|
) : <p className="font-semibold">{testResult.error ?? t('byok.testFailed')}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -593,7 +595,7 @@ export function ByokSettingsPanel() {
|
|||||||
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
|
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
|
||||||
>
|
>
|
||||||
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}Tester
|
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}{t('byok.test')}
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button
|
||||||
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
|
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
|
||||||
@@ -601,7 +603,7 @@ export function ByokSettingsPanel() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{provider ? `Enregistrer — ${displayName(provider)}` : 'Enregistrer'}
|
{provider ? t('byok.saveWithName', { name: displayName(provider) }) : t('byok.save')}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react'
|
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export interface BlockSuggestion {
|
export interface BlockSuggestion {
|
||||||
blockId: string
|
blockId: string
|
||||||
@@ -22,6 +23,7 @@ interface BlockPickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) {
|
export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions')
|
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions')
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [suggestions, setSuggestions] = useState<BlockSuggestion[]>([])
|
const [suggestions, setSuggestions] = useState<BlockSuggestion[]>([])
|
||||||
@@ -134,7 +136,7 @@ export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: B
|
|||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
placeholder="Rechercher un extrait de note..."
|
placeholder={t('blockPicker.searchPlaceholder')}
|
||||||
className="w-full bg-white dark:bg-zinc-850 border border-[#D5D2CD] dark:border-neutral-800 rounded-xl pl-9 pr-4 py-2 text-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-all font-sans"
|
className="w-full bg-white dark:bg-zinc-850 border border-[#D5D2CD] dark:border-neutral-800 rounded-xl pl-9 pr-4 py-2 text-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-all font-sans"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { Zap, Lightbulb, Trophy } from 'lucide-react'
|
import { Zap, Lightbulb, Trophy } from 'lucide-react'
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ interface BridgeNotesDashboardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) {
|
export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
|
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
|
||||||
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
|
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -109,14 +111,14 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-indigo-500 mb-2">
|
<div className="flex items-center gap-2 text-indigo-500 mb-2">
|
||||||
<Trophy size={14} />
|
<Trophy size={14} />
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest">Bridges</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest">{t('bridgeNotes.bridges')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-ochre mb-2">
|
<div className="flex items-center gap-2 text-ochre mb-2">
|
||||||
<Lightbulb size={14} />
|
<Lightbulb size={14} />
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest">Suggestions</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest">{t('bridgeNotes.suggestions')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{suggestions.length}</div>
|
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{suggestions.length}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +128,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-2 mb-6 px-1">
|
<div className="flex items-center gap-2 mb-6 px-1">
|
||||||
<Zap size={16} className="text-ochre" />
|
<Zap size={16} className="text-ochre" />
|
||||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Powerful Bridge Notes</h3>
|
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">{t('bridgeNotes.powerfulTitle')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{bridgeNotes.map((bridge) => (
|
{bridgeNotes.map((bridge) => (
|
||||||
@@ -138,10 +140,10 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
|
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
|
||||||
{bridge.note?.title || 'Untitled'}
|
{bridge.note?.title || t('common.untitled')}
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
||||||
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
|
{t('bridgeNotes.score')}: {(bridge.bridgeScore * 100).toFixed(0)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -158,7 +160,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
{bridgeNotes.length === 0 && (
|
{bridgeNotes.length === 0 && (
|
||||||
<div className="text-xs text-concrete italic p-4">No significant bridge notes found yet. Deepen your research to find new connections.</div>
|
<div className="text-xs text-concrete italic p-4">{t('bridgeNotes.empty')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -167,7 +169,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-2 mb-6 px-1">
|
<div className="flex items-center gap-2 mb-6 px-1">
|
||||||
<Lightbulb size={16} className="text-indigo-500" />
|
<Lightbulb size={16} className="text-indigo-500" />
|
||||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Missing Links (AI Generated)</h3>
|
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">{t('bridgeNotes.missingLinks')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{suggestions.map((s, idx) => (
|
{suggestions.map((s, idx) => (
|
||||||
@@ -180,7 +182,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
|
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
|
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
|
||||||
Bridging {s.clusterAName} & {s.clusterBName}
|
{t('bridgeNotes.bridging')} {s.clusterAName} & {s.clusterBName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-base font-memento-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
|
<h4 className="text-base font-memento-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
|
||||||
@@ -193,7 +195,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
<button
|
<button
|
||||||
onClick={() => dismissSuggestion(s.clusterAId, s.clusterBId)}
|
onClick={() => dismissSuggestion(s.clusterAId, s.clusterBId)}
|
||||||
className="ml-4 p-2 text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-concrete/10 rounded-lg transition-colors"
|
className="ml-4 p-2 text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-concrete/10 rounded-lg transition-colors"
|
||||||
title="Dismiss suggestion"
|
title={t('bridgeNotes.dismiss')}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -203,13 +205,13 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
|
|||||||
{suggestions.length === 0 && !loading && (
|
{suggestions.length === 0 && !loading && (
|
||||||
<div className="text-center py-8 text-concrete">
|
<div className="text-center py-8 text-concrete">
|
||||||
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
|
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
|
||||||
<p className="text-sm">No connection suggestions yet</p>
|
<p className="text-sm">{t('bridgeNotes.noSuggestions')}</p>
|
||||||
<p className="text-xs mt-1">All your clusters may already be connected!</p>
|
<p className="text-xs mt-1">{t('bridgeNotes.allConnected')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={generateNewSuggestions}
|
onClick={generateNewSuggestions}
|
||||||
className="mt-4 px-4 py-2 bg-indigo-500 text-white text-xs rounded-lg hover:bg-indigo-600 transition-colors"
|
className="mt-4 px-4 py-2 bg-indigo-500 text-white text-xs rounded-lg hover:bg-indigo-600 transition-colors"
|
||||||
>
|
>
|
||||||
Generate Suggestions
|
{t('bridgeNotes.generate')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { NoteChart } from './note-chart'
|
|||||||
import { suggestCharts, chartSuggestionToMarkdown, type ChartSuggestion, type SuggestChartsResponse } from '@/lib/ai/services/chart-suggestion.service'
|
import { suggestCharts, chartSuggestionToMarkdown, type ChartSuggestion, type SuggestChartsResponse } from '@/lib/ai/services/chart-suggestion.service'
|
||||||
import { BarChart3, X, Search, AlertCircle, Sparkles } from 'lucide-react'
|
import { BarChart3, X, Search, AlertCircle, Sparkles } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
// Chart type to color mapping for visual variety
|
// Chart type to color mapping for visual variety
|
||||||
const CHART_TYPE_COLORS: Record<string, string> = {
|
const CHART_TYPE_COLORS: Record<string, string> = {
|
||||||
@@ -36,6 +37,7 @@ export function ChartSuggestionsDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onSelectChart,
|
onSelectChart,
|
||||||
}: ChartSuggestionsDialogProps) {
|
}: ChartSuggestionsDialogProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [response, setResponse] = useState<SuggestChartsResponse | null>(null)
|
const [response, setResponse] = useState<SuggestChartsResponse | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||||
@@ -127,12 +129,12 @@ export function ChartSuggestionsDialog({
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Search className="w-3 h-3 animate-spin" />
|
<Search className="w-3 h-3 animate-spin" />
|
||||||
Analyzing {isAnalyzingSelection ? 'selection' : 'note'} ({wordCount} words)...
|
{isAnalyzingSelection ? t('chart.analyzingSelection') : t('chart.analyzingNote')} ({wordCount} {t('chart.words')})...
|
||||||
</span>
|
</span>
|
||||||
) : response?.hasData ? (
|
) : response?.hasData ? (
|
||||||
`Found ${response?.detectedData || 'data'}`
|
`${t('chart.found')} ${response?.detectedData || ''}`
|
||||||
) : (
|
) : (
|
||||||
'No data detected'
|
t('chart.noDataDetected')
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +142,7 @@ export function ChartSuggestionsDialog({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-muted rounded-lg transition-colors"
|
className="p-2 hover:bg-muted rounded-lg transition-colors"
|
||||||
aria-label="Close"
|
aria-label={t('common.close')}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -152,7 +154,7 @@ export function ChartSuggestionsDialog({
|
|||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
|
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
|
||||||
<p className="text-muted-foreground">Analyzing your content for chart data...</p>
|
<p className="text-muted-foreground">{t('chart.analyzing')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : response?.quotaExceeded ? (
|
) : response?.quotaExceeded ? (
|
||||||
@@ -167,7 +169,7 @@ export function ChartSuggestionsDialog({
|
|||||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||||
onClick={() => (window.location.href = '/settings/billing')}
|
onClick={() => (window.location.href = '/settings/billing')}
|
||||||
>
|
>
|
||||||
Upgrade Plan
|
{t('ai.featureLocked')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,10 +177,10 @@ export function ChartSuggestionsDialog({
|
|||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
|
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
|
||||||
<h3 className="text-lg font-semibold mb-2">Error</h3>
|
<h3 className="text-lg font-semibold mb-2">{t('common.error')}</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-2">{response.error}</p>
|
<p className="text-sm text-muted-foreground mb-2">{response.error}</p>
|
||||||
<details className="text-left text-xs text-muted-foreground mt-4">
|
<details className="text-left text-xs text-muted-foreground mt-4">
|
||||||
<summary className="cursor-pointer hover:text-foreground">Debug info</summary>
|
<summary className="cursor-pointer hover:text-foreground">{t('chart.debugInfo')}</summary>
|
||||||
<pre className="mt-2 bg-muted p-2 rounded overflow-auto max-h-32">
|
<pre className="mt-2 bg-muted p-2 rounded overflow-auto max-h-32">
|
||||||
analyzedText: {response.analyzedText}
|
analyzedText: {response.analyzedText}
|
||||||
{'\n'}detectedData: {response.detectedData}
|
{'\n'}detectedData: {response.detectedData}
|
||||||
@@ -190,20 +192,20 @@ export function ChartSuggestionsDialog({
|
|||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
<h3 className="text-lg font-semibold mb-2">No Data Detected</h3>
|
<h3 className="text-lg font-semibold mb-2">{t('chart.noDataDetected')}</h3>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
Try adding numerical data to your note. Charts work best with:
|
{t('chart.noDataHint')}
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-muted-foreground text-left inline-block">
|
<ul className="text-sm text-muted-foreground text-left inline-block">
|
||||||
<li>• Sales figures, metrics, or measurements</li>
|
<li>{t('chart.dataHint1')}</li>
|
||||||
<li>• Lists with values (e.g., "Jan: 5000, Feb: 7500")</li>
|
<li>{t('chart.dataHint2')}</li>
|
||||||
<li>• Percentages or proportions</li>
|
<li>{t('chart.dataHint3')}</li>
|
||||||
<li>• Time-series data</li>
|
<li>{t('chart.dataHint4')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-4 pt-4 border-t border-border/50">
|
<div className="mt-4 pt-4 border-t border-border/50">
|
||||||
<details className="text-left">
|
<details className="text-left">
|
||||||
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
|
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
|
||||||
Debug: Show what was analyzed
|
{t('chart.debugShowAnalyzed')}
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto max-h-32">
|
<pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto max-h-32">
|
||||||
{textToAnalyze.substring(0, 500)}
|
{textToAnalyze.substring(0, 500)}
|
||||||
@@ -276,7 +278,7 @@ export function ChartSuggestionsDialog({
|
|||||||
{/* Data preview */}
|
{/* Data preview */}
|
||||||
<div className="mt-2 pt-2 border-t border-border/50">
|
<div className="mt-2 pt-2 border-t border-border/50">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{suggestion.data.length} data points
|
{suggestion.data.length} {t('chart.dataPoints')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -286,9 +288,9 @@ export function ChartSuggestionsDialog({
|
|||||||
|
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
<div className="flex items-center justify-center gap-4 pt-4 text-xs text-muted-foreground">
|
<div className="flex items-center justify-center gap-4 pt-4 text-xs text-muted-foreground">
|
||||||
<span>← → Navigate</span>
|
<span>{t('chart.navigateHint')}</span>
|
||||||
<span>↵ Select</span>
|
<span>{t('chart.selectHint')}</span>
|
||||||
<span>Esc Cancel</span>
|
<span>{t('chart.escCancel')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import ReactFlow, {
|
import ReactFlow, {
|
||||||
Node,
|
Node,
|
||||||
Edge,
|
Edge,
|
||||||
@@ -47,6 +48,7 @@ export function ClusterVisualization({
|
|||||||
bridgeNotes,
|
bridgeNotes,
|
||||||
onNodeClick
|
onNodeClick
|
||||||
}: ClusterVisualizationProps) {
|
}: ClusterVisualizationProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||||
const [selectedCluster, setSelectedCluster] = useState<number | null>(null)
|
const [selectedCluster, setSelectedCluster] = useState<number | null>(null)
|
||||||
@@ -79,8 +81,8 @@ export function ClusterVisualization({
|
|||||||
data: {
|
data: {
|
||||||
label: (
|
label: (
|
||||||
<div className="px-3 py-1 rounded-full text-sm font-medium" style={{ backgroundColor: color }}>
|
<div className="px-3 py-1 rounded-full text-sm font-medium" style={{ backgroundColor: color }}>
|
||||||
{cluster.name || `Cluster ${cluster.clusterId}`}
|
{cluster.name || t('clusters.clusterLabel', { id: cluster.clusterId })}
|
||||||
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} notes)</span>
|
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} {t('clusters.notes')})</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -174,8 +176,8 @@ export function ClusterVisualization({
|
|||||||
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
</svg>
|
</svg>
|
||||||
<p>No clusters to display</p>
|
<p>{t('clusters.empty')}</p>
|
||||||
<p className="text-sm mt-2">Create more notes to generate clusters</p>
|
<p className="text-sm mt-2">{t('clusters.emptyHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -200,10 +202,10 @@ export function ClusterVisualization({
|
|||||||
{selectedCluster !== null && (
|
{selectedCluster !== null && (
|
||||||
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
|
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
|
||||||
<h3 className="font-semibold mb-2">
|
<h3 className="font-semibold mb-2">
|
||||||
{clusters.find(c => c.clusterId === selectedCluster)?.name || `Cluster ${selectedCluster}`}
|
{clusters.find(c => c.clusterId === selectedCluster)?.name || t('clusters.clusterLabel', { id: selectedCluster })}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} notes
|
{clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} {t('clusters.notes')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -212,11 +214,11 @@ export function ClusterVisualization({
|
|||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-4 h-4 rounded-full bg-yellow-400 border-2 border-yellow-600"></div>
|
<div className="w-4 h-4 rounded-full bg-yellow-400 border-2 border-yellow-600"></div>
|
||||||
<span>Bridge note</span>
|
<span>{t('clusters.bridgeNote')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
|
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
|
||||||
<span>Regular note</span>
|
<span>{t('clusters.regularNote')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
126
memento-note/components/dashboard-action-strip.tsx
Normal file
126
memento-note/components/dashboard-action-strip.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { Inbox, GraduationCap, Bell, Sparkles, Layers } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
|
export interface DashboardActionStripProps {
|
||||||
|
inboxCount: number
|
||||||
|
dueFlashcards: number
|
||||||
|
reminderCount: number
|
||||||
|
discoveryCount: number
|
||||||
|
themeCount: number
|
||||||
|
inboxPulse?: number
|
||||||
|
onInbox: () => void
|
||||||
|
onReview: () => void
|
||||||
|
onReminders: () => void
|
||||||
|
onDiscoveries: () => void
|
||||||
|
onThemes: () => void
|
||||||
|
prefersReducedMotion?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardActionStrip({
|
||||||
|
inboxCount,
|
||||||
|
dueFlashcards,
|
||||||
|
reminderCount,
|
||||||
|
discoveryCount,
|
||||||
|
themeCount,
|
||||||
|
inboxPulse = 0,
|
||||||
|
onInbox,
|
||||||
|
onReview,
|
||||||
|
onReminders,
|
||||||
|
onDiscoveries,
|
||||||
|
onThemes,
|
||||||
|
prefersReducedMotion,
|
||||||
|
}: DashboardActionStripProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: 'inbox',
|
||||||
|
icon: Inbox,
|
||||||
|
label: t('homeDashboard.inbox'),
|
||||||
|
value: inboxCount,
|
||||||
|
onClick: onInbox,
|
||||||
|
accent: inboxCount > 0,
|
||||||
|
pulse: inboxPulse > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'review',
|
||||||
|
icon: GraduationCap,
|
||||||
|
label: t('homeDashboard.review'),
|
||||||
|
value: dueFlashcards,
|
||||||
|
onClick: onReview,
|
||||||
|
accent: dueFlashcards > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reminders',
|
||||||
|
icon: Bell,
|
||||||
|
label: t('homeDashboard.reminders'),
|
||||||
|
value: reminderCount,
|
||||||
|
onClick: onReminders,
|
||||||
|
accent: reminderCount > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'discoveries',
|
||||||
|
icon: Sparkles,
|
||||||
|
label: t('homeDashboard.aiFound'),
|
||||||
|
value: discoveryCount,
|
||||||
|
onClick: onDiscoveries,
|
||||||
|
accent: discoveryCount > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'themes',
|
||||||
|
icon: Layers,
|
||||||
|
label: t('homeDashboard.themes'),
|
||||||
|
value: themeCount,
|
||||||
|
onClick: onThemes,
|
||||||
|
accent: themeCount > 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 overflow-x-auto custom-scrollbar pb-0.5 -mx-0.5 px-0.5">
|
||||||
|
{items.map(item => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const Wrapper = item.pulse && !prefersReducedMotion ? motion.button : 'button'
|
||||||
|
const motionProps = item.pulse && !prefersReducedMotion
|
||||||
|
? {
|
||||||
|
key: `inbox-pulse-${inboxPulse}`,
|
||||||
|
animate: { scale: [1, 1.03, 1] },
|
||||||
|
transition: { duration: 0.45 },
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={item.onClick}
|
||||||
|
{...motionProps}
|
||||||
|
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[108px] ${
|
||||||
|
item.accent
|
||||||
|
? 'border-brand-accent/30 bg-brand-accent/[0.06] hover:border-brand-accent/50 hover:bg-brand-accent/10 shadow-sm'
|
||||||
|
: 'border-border/25 bg-white/60 dark:bg-zinc-900/40 hover:border-border/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${
|
||||||
|
item.accent ? 'bg-brand-accent/15 text-brand-accent' : 'bg-stone-100 dark:bg-zinc-800 text-concrete'
|
||||||
|
}`}>
|
||||||
|
<Icon size={13} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={`text-lg font-serif font-bold leading-none ${
|
||||||
|
item.accent ? 'text-ink dark:text-dark-ink' : 'text-concrete/80'
|
||||||
|
}`}>
|
||||||
|
{item.value}
|
||||||
|
</p>
|
||||||
|
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete truncate mt-0.5">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
158
memento-note/components/dashboard-agent-carousel.tsx
Normal file
158
memento-note/components/dashboard-agent-carousel.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||||
|
|
||||||
|
export interface AgentSuggestion {
|
||||||
|
id: string
|
||||||
|
topic: string
|
||||||
|
reason: string
|
||||||
|
relatedNoteCount: number
|
||||||
|
suggestedFrequency: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardAgentCarouselProps {
|
||||||
|
suggestions: AgentSuggestion[]
|
||||||
|
loading?: boolean
|
||||||
|
actingId: string | null
|
||||||
|
formatFrequency: (f: string) => string
|
||||||
|
onAccept: (id: string) => void
|
||||||
|
onDismiss: (id: string) => void
|
||||||
|
prefersReducedMotion?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardAgentCarousel({
|
||||||
|
suggestions,
|
||||||
|
loading,
|
||||||
|
actingId,
|
||||||
|
formatFrequency,
|
||||||
|
onAccept,
|
||||||
|
onDismiss,
|
||||||
|
prefersReducedMotion,
|
||||||
|
}: DashboardAgentCarouselProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const [idx, setIdx] = useState(0)
|
||||||
|
|
||||||
|
const navActions = suggestions.length > 1 ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIdx(i => Math.max(0, i - 1))}
|
||||||
|
disabled={idx === 0}
|
||||||
|
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||||
|
aria-label={t('homeDashboard.intelPrev')}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={12} />
|
||||||
|
</button>
|
||||||
|
<span className="text-[8px] font-mono text-concrete px-1">
|
||||||
|
{idx + 1}/{suggestions.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))}
|
||||||
|
disabled={idx >= suggestions.length - 1}
|
||||||
|
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||||
|
aria-label={t('homeDashboard.intelNext')}
|
||||||
|
>
|
||||||
|
<ChevronRight size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="h-[140px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
|
||||||
|
<DashboardWidgetTitleRow
|
||||||
|
widgetId="agents"
|
||||||
|
icon={<Bot size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.suggestedResearch')}
|
||||||
|
actions={navActions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{suggestions.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-concrete italic leading-relaxed py-2">
|
||||||
|
{t('homeDashboard.agentsEmpty')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<AgentSlide
|
||||||
|
current={suggestions[idx]}
|
||||||
|
actingId={actingId}
|
||||||
|
formatFrequency={formatFrequency}
|
||||||
|
onAccept={onAccept}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
prefersReducedMotion={prefersReducedMotion}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentSlide({
|
||||||
|
current,
|
||||||
|
actingId,
|
||||||
|
formatFrequency,
|
||||||
|
onAccept,
|
||||||
|
onDismiss,
|
||||||
|
prefersReducedMotion,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
current: AgentSuggestion
|
||||||
|
actingId: string | null
|
||||||
|
formatFrequency: (f: string) => string
|
||||||
|
onAccept: (id: string) => void
|
||||||
|
onDismiss: (id: string) => void
|
||||||
|
prefersReducedMotion?: boolean
|
||||||
|
t: (key: string) => string
|
||||||
|
}) {
|
||||||
|
const slide = prefersReducedMotion
|
||||||
|
? { initial: {}, animate: {}, exit: {} }
|
||||||
|
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 } }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={current.id}
|
||||||
|
initial={slide.initial}
|
||||||
|
animate={slide.animate}
|
||||||
|
exit={slide.exit}
|
||||||
|
transition={{ duration: prefersReducedMotion ? 0 : 0.18 }}
|
||||||
|
className="p-3.5 rounded-xl border border-border/25 bg-stone-50/60 dark:bg-zinc-950/40"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 mb-2">
|
||||||
|
<Search size={12} className="text-brand-accent shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm font-semibold text-ink dark:text-dark-ink leading-snug">{current.topic}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-concrete leading-relaxed line-clamp-2 mb-3">{current.reason}</p>
|
||||||
|
<p className="text-[8px] font-mono uppercase text-concrete mb-3">
|
||||||
|
{current.relatedNoteCount} {t('homeDashboard.notes')} · {formatFrequency(current.suggestedFrequency)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actingId === current.id}
|
||||||
|
onClick={() => onDismiss(current.id)}
|
||||||
|
className="text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg border border-border/40 text-concrete hover:text-ink transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('homeDashboard.dismiss')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actingId === current.id}
|
||||||
|
onClick={() => onAccept(current.id)}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black font-bold hover:opacity-90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{actingId === current.id ? <Loader2 size={10} className="animate-spin" /> : null}
|
||||||
|
{t('homeDashboard.createAgent')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
371
memento-note/components/dashboard-catalog-widgets.tsx
Normal file
371
memento-note/components/dashboard-catalog-widgets.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
|
||||||
|
import { UsageMeter } from '@/components/usage-meter'
|
||||||
|
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||||
|
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
|
||||||
|
|
||||||
|
interface DashboardWidgetShellProps {
|
||||||
|
widgetId: DashboardWidgetId
|
||||||
|
icon: React.ReactNode
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
compact?: boolean
|
||||||
|
headerActions?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardWidgetShell({
|
||||||
|
widgetId,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
compact,
|
||||||
|
headerActions,
|
||||||
|
}: DashboardWidgetShellProps) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 h-full ${
|
||||||
|
compact ? 'p-3' : 'p-4'
|
||||||
|
}`}>
|
||||||
|
<DashboardWidgetTitleRow
|
||||||
|
widgetId={widgetId}
|
||||||
|
icon={icon}
|
||||||
|
title={title}
|
||||||
|
actions={headerActions}
|
||||||
|
className={compact ? 'mb-2' : 'mb-3'}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardInboxWidget({
|
||||||
|
count,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
count: number
|
||||||
|
loading: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="inbox"
|
||||||
|
icon={<Inbox size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.inbox')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
|
||||||
|
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} →</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardRevisionWidget({
|
||||||
|
dueCount,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
dueCount: number
|
||||||
|
loading: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="revision"
|
||||||
|
icon={<GraduationCap size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.review')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{dueCount}</p>
|
||||||
|
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.cardsDue')}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} →</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardStatsWidget({
|
||||||
|
clusterCount,
|
||||||
|
bridgeCount,
|
||||||
|
noteCount,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
clusterCount: number
|
||||||
|
bridgeCount: number
|
||||||
|
noteCount: number
|
||||||
|
loading: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="stats"
|
||||||
|
icon={<BarChart3 size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.stats')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={onOpen} className="w-full text-start">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ label: t('homeDashboard.themes'), value: clusterCount },
|
||||||
|
{ label: t('homeDashboard.widgetStatsBridges'), value: bridgeCount },
|
||||||
|
{ label: t('homeDashboard.widgetStatsNotes'), value: noteCount },
|
||||||
|
].map(item => (
|
||||||
|
<div key={item.label} className="p-2 rounded-xl border border-border/20 bg-stone-50/50 dark:bg-zinc-950/30 text-center">
|
||||||
|
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink leading-none">{item.value}</p>
|
||||||
|
<p className="text-[8px] font-mono uppercase text-concrete mt-1 truncate">{item.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardAgentActivityWidget({
|
||||||
|
actions,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
actions: { id: string; agentName: string; result: string | null; createdAt: string }[]
|
||||||
|
loading: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="agent-activity"
|
||||||
|
icon={<Bot size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.agent-activity')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : actions.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetAgentActivityEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{actions.slice(0, 3).map(a => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full p-2 rounded-xl border border-border/20 hover:border-brand-accent/25 text-start transition-all"
|
||||||
|
>
|
||||||
|
<p className="text-[10px] font-mono font-bold text-ink dark:text-dark-ink truncate">{a.agentName}</p>
|
||||||
|
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||||
|
{(a.result || t('homeDashboard.intelAgentNoResult')).slice(0, 80)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardGmailWidget({
|
||||||
|
connected,
|
||||||
|
recentCaptures,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
connected: boolean
|
||||||
|
recentCaptures: number
|
||||||
|
loading: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="gmail"
|
||||||
|
icon={<Mail size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.gmailCaptures')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
) : connected ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 transition-all text-start"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-ink dark:text-dark-ink">{t('homeDashboard.gmailRecent', { count: recentCaptures })}</span>
|
||||||
|
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">Gmail</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full text-[10px] font-mono uppercase tracking-wider text-concrete hover:text-brand-accent transition-colors text-start py-2"
|
||||||
|
>
|
||||||
|
{t('homeDashboard.gmailConnect')} →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPinnedWidget({
|
||||||
|
notes,
|
||||||
|
loading,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
notes: { id: string; title: string | null; notebookId: string | null }[]
|
||||||
|
loading: boolean
|
||||||
|
onSelect: (id: string, notebookId: string | null) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="pinned"
|
||||||
|
icon={<Pin size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.pinned')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{notes.map(n => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(n.id, n.notebookId)}
|
||||||
|
className="w-full text-[10px] text-ink dark:text-dark-ink truncate text-start p-2 rounded-lg hover:bg-brand-accent/[0.04] transition-colors"
|
||||||
|
>
|
||||||
|
{n.title || t('homeDashboard.untitled')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardActivityWidget({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
data: { date: string; count: number }[]
|
||||||
|
loading: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="activity"
|
||||||
|
icon={<BarChart3 size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.activity')}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-24 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<RevisionHeatmap data={data} />
|
||||||
|
)}
|
||||||
|
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardUsageWidget() {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="usage"
|
||||||
|
icon={<BarChart3 size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.usage')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<UsageMeter className="px-0 py-0" />
|
||||||
|
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetUsageHint')}</p>
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardFlashcardsProgressWidget({
|
||||||
|
retentionRate,
|
||||||
|
streak,
|
||||||
|
totalCards,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
retentionRate: number
|
||||||
|
streak: number
|
||||||
|
totalCards: number
|
||||||
|
loading?: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="flashcards-progress"
|
||||||
|
icon={<GraduationCap size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.flashcards-progress')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-14 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={onOpen} className="w-full text-start">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="text-center p-2 rounded-xl border border-border/20">
|
||||||
|
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{retentionRate}%</p>
|
||||||
|
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashRetention')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 rounded-xl border border-border/20">
|
||||||
|
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{streak}</p>
|
||||||
|
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashStreak')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 rounded-xl border border-border/20">
|
||||||
|
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{totalCards}</p>
|
||||||
|
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashTotal')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
memento-note/components/dashboard-mind-orbit.tsx
Normal file
139
memento-note/components/dashboard-mind-orbit.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { Layers, Zap, ArrowUpRight } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||||
|
|
||||||
|
interface Cluster {
|
||||||
|
clusterId: number
|
||||||
|
name?: string
|
||||||
|
noteIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BridgeNote {
|
||||||
|
noteId: string
|
||||||
|
bridgeScore: number
|
||||||
|
clusterNames?: string[]
|
||||||
|
note?: { id: string; title: string | null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
|
||||||
|
|
||||||
|
export interface DashboardMindOrbitProps {
|
||||||
|
clusters: Cluster[]
|
||||||
|
bridgeNotes: BridgeNote[]
|
||||||
|
loading?: boolean
|
||||||
|
onOpenInsights: () => void
|
||||||
|
onNoteSelect: (id: string) => void
|
||||||
|
prefersReducedMotion?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardMindOrbit({
|
||||||
|
clusters,
|
||||||
|
bridgeNotes,
|
||||||
|
loading,
|
||||||
|
onOpenInsights,
|
||||||
|
onNoteSelect,
|
||||||
|
prefersReducedMotion,
|
||||||
|
}: DashboardMindOrbitProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const topClusters = [...clusters]
|
||||||
|
.sort((a, b) => b.noteIds.length - a.noteIds.length)
|
||||||
|
.slice(0, 5)
|
||||||
|
const maxCount = topClusters[0]?.noteIds.length || 1
|
||||||
|
const topBridge = bridgeNotes[0]
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topClusters.length === 0) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenInsights}
|
||||||
|
className="w-full h-[180px] rounded-2xl border border-dashed border-border/35 bg-white/50 dark:bg-zinc-900/50 flex flex-col items-center justify-center gap-2 p-6 text-center hover:border-brand-accent/30 transition-all"
|
||||||
|
>
|
||||||
|
<Layers size={22} className="text-concrete/35" />
|
||||||
|
<p className="text-xs text-concrete italic max-w-[220px]">{t('homeDashboard.mindMapEmpty')}</p>
|
||||||
|
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.mindMapOpen')}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
|
||||||
|
<DashboardWidgetTitleRow
|
||||||
|
widgetId="mind-map"
|
||||||
|
icon={<Layers size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.mindMap')}
|
||||||
|
actions={(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenInsights}
|
||||||
|
className="inline-flex items-center gap-0.5 text-[8px] font-mono uppercase font-bold text-brand-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t('homeDashboard.fullMap')}
|
||||||
|
<ArrowUpRight size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3 min-h-[100px] py-2">
|
||||||
|
{topClusters.map((cluster, idx) => {
|
||||||
|
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
|
||||||
|
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
|
||||||
|
const size = Math.round(56 * scale)
|
||||||
|
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={cluster.clusterId}
|
||||||
|
type="button"
|
||||||
|
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }}
|
||||||
|
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
|
||||||
|
onClick={onOpenInsights}
|
||||||
|
className="relative flex flex-col items-center gap-1.5 group"
|
||||||
|
style={{ width: size + 16 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
backgroundColor: `${color}cc`,
|
||||||
|
borderColor: `${color}40`,
|
||||||
|
fontSize: Math.max(9, size * 0.22),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cluster.noteIds.length}
|
||||||
|
</div>
|
||||||
|
<span className="text-[8px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-tight max-w-[72px] group-hover:text-brand-accent transition-colors">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{topBridge?.note && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNoteSelect(topBridge.noteId)}
|
||||||
|
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
|
||||||
|
>
|
||||||
|
<Zap size={11} className="text-brand-accent shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
|
||||||
|
{topBridge.note.title || t('homeDashboard.untitled')}
|
||||||
|
</p>
|
||||||
|
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.bridgeNote')}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
|
||||||
|
{Math.round(topBridge.bridgeScore * 100)}%
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
165
memento-note/components/dashboard-next-paths.tsx
Normal file
165
memento-note/components/dashboard-next-paths.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
ArrowRight, GitBranch, Lightbulb, Link2, BookOpen, Inbox,
|
||||||
|
GraduationCap, Compass, Plus, Sparkles, PenLine,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||||
|
import type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
|
||||||
|
|
||||||
|
const TYPE_META: Record<DashboardPathType, { Icon: LucideIcon; accent: string }> = {
|
||||||
|
continue: { Icon: PenLine, accent: 'text-ink' },
|
||||||
|
connect: { Icon: Link2, accent: 'text-brand-accent' },
|
||||||
|
'add-link': { Icon: Plus, accent: 'text-emerald-600' },
|
||||||
|
bridge: { Icon: GitBranch, accent: 'text-violet-600' },
|
||||||
|
research: { Icon: Sparkles, accent: 'text-sky-600' },
|
||||||
|
explore: { Icon: Compass, accent: 'text-amber-600' },
|
||||||
|
organize: { Icon: Inbox, accent: 'text-brand-accent' },
|
||||||
|
review: { Icon: GraduationCap, accent: 'text-brand-accent' },
|
||||||
|
resurface: { Icon: Lightbulb, accent: 'text-brand-accent' },
|
||||||
|
daily: { Icon: BookOpen, accent: 'text-concrete' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNextPathsProps {
|
||||||
|
paths: DashboardPath[]
|
||||||
|
loading?: boolean
|
||||||
|
enriching?: boolean
|
||||||
|
focusNoteTitle?: string | null
|
||||||
|
onAction: (path: DashboardPath) => void
|
||||||
|
prefersReducedMotion?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardNextPaths({
|
||||||
|
paths,
|
||||||
|
loading,
|
||||||
|
enriching,
|
||||||
|
focusNoteTitle,
|
||||||
|
onAction,
|
||||||
|
prefersReducedMotion,
|
||||||
|
}: DashboardNextPathsProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
|
||||||
|
<div className="px-5 pt-4 pb-3 border-b border-border/15">
|
||||||
|
<DashboardWidgetTitleRow
|
||||||
|
widgetId="next-paths"
|
||||||
|
title={t('homeDashboard.pathsTitle')}
|
||||||
|
className="mb-0"
|
||||||
|
/>
|
||||||
|
{focusNoteTitle && (
|
||||||
|
<p className="text-[11px] text-concrete mt-1">
|
||||||
|
{t('homeDashboard.pathsFromNote', { title: focusNoteTitle })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 px-5 py-10">
|
||||||
|
<Loader2 size={22} className="text-brand-accent animate-spin" strokeWidth={2} />
|
||||||
|
<p className="text-[11px] text-concrete text-center leading-relaxed max-w-[240px]">
|
||||||
|
{t('homeDashboard.pathsLoading')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paths.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/25 bg-white/60 dark:bg-zinc-900/60 p-5">
|
||||||
|
<DashboardWidgetTitleRow
|
||||||
|
widgetId="next-paths"
|
||||||
|
title={t('homeDashboard.pathsTitle')}
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-concrete italic">{t('homeDashboard.pathsEmpty')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hero = paths[0]
|
||||||
|
const rest = paths.slice(1, 5)
|
||||||
|
const HeroIcon = TYPE_META[hero.type].Icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
|
||||||
|
<div className="px-5 pt-4 pb-3 border-b border-border/15">
|
||||||
|
<DashboardWidgetTitleRow
|
||||||
|
widgetId="next-paths"
|
||||||
|
title={t('homeDashboard.pathsTitle')}
|
||||||
|
className="mb-0"
|
||||||
|
actions={enriching ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[8px] font-mono uppercase text-concrete">
|
||||||
|
<Loader2 size={10} className="text-brand-accent animate-spin" />
|
||||||
|
{t('homeDashboard.pathsEnriching')}
|
||||||
|
</span>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
|
{focusNoteTitle && (
|
||||||
|
<p className="text-[11px] text-concrete mt-1">
|
||||||
|
{t('homeDashboard.pathsFromNote', { title: focusNoteTitle })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAction(hero)}
|
||||||
|
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
||||||
|
className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`w-9 h-9 rounded-xl bg-brand-accent/10 flex items-center justify-center shrink-0 ${TYPE_META[hero.type].accent}`}>
|
||||||
|
<HeroIcon size={16} strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete mb-0.5">
|
||||||
|
{t(`homeDashboard.pathTypes.${hero.type}`)}
|
||||||
|
{hero.score ? ` · ${hero.score}%` : ''}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-serif font-semibold text-ink dark:text-dark-ink leading-snug">
|
||||||
|
{hero.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-concrete leading-relaxed mt-1 line-clamp-2">
|
||||||
|
{hero.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 inline-flex items-center gap-1 text-[9px] font-mono font-bold uppercase text-brand-accent mt-1">
|
||||||
|
{t(`homeDashboard.pathActions.${hero.actionKey}`)}
|
||||||
|
<ArrowRight size={10} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<div className="divide-y divide-border/10">
|
||||||
|
{rest.map(path => {
|
||||||
|
const meta = TYPE_META[path.type]
|
||||||
|
const Icon = meta.Icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={path.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAction(path)}
|
||||||
|
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[11px] font-medium text-ink dark:text-dark-ink truncate">{path.title}</p>
|
||||||
|
<p className="text-[9px] text-concrete truncate">{path.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
|
||||||
|
{t(`homeDashboard.pathActions.${path.actionKey}`)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
258
memento-note/components/dashboard-path-widgets.tsx
Normal file
258
memento-note/components/dashboard-path-widgets.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { CheckCircle2, Circle } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { DashboardWidgetShell } from '@/components/dashboard-catalog-widgets'
|
||||||
|
|
||||||
|
export interface DailyReviewItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
done: boolean
|
||||||
|
count?: number
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardDailyReview({
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
items: DailyReviewItem[]
|
||||||
|
loading?: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="daily-review"
|
||||||
|
icon={<CheckCircle2 size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.daily-review')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map(i => <div key={i} className="h-8 rounded-lg bg-stone-50 animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{items.map(item => (
|
||||||
|
<li key={item.key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={item.onClick}
|
||||||
|
className="w-full flex items-center gap-2.5 p-2 rounded-xl hover:bg-brand-accent/[0.04] transition-colors text-start"
|
||||||
|
>
|
||||||
|
{item.done ? (
|
||||||
|
<CheckCircle2 size={14} className="text-brand-accent shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Circle size={14} className="text-concrete/50 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className={`text-[10px] flex-1 ${item.done ? 'text-concrete line-through' : 'text-ink dark:text-dark-ink'}`}>
|
||||||
|
{item.label}
|
||||||
|
{item.count !== undefined && item.count > 0 ? ` (${item.count})` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="text-[9px] text-concrete mt-2 leading-relaxed">{t('homeDashboard.dailyReviewHint')}</p>
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardOpenLoops({
|
||||||
|
loops,
|
||||||
|
loading,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
loops: Array<{ id: string; title: string | null; notebookId: string | null; daysStale: number }>
|
||||||
|
loading?: boolean
|
||||||
|
onSelect: (id: string, notebookId: string | null) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="open-loops"
|
||||||
|
icon={<Circle size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.open-loops')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-16 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
) : loops.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.openLoopsEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{loops.map(loop => (
|
||||||
|
<button
|
||||||
|
key={loop.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(loop.id, loop.notebookId)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-brand-accent/[0.04] text-start transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-ink dark:text-dark-ink truncate flex-1">
|
||||||
|
{loop.title || t('homeDashboard.untitled')}
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] font-mono text-concrete shrink-0">
|
||||||
|
{t('homeDashboard.openLoopsStale', { days: loop.daysStale })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardDailyNoteWidget({
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
loading?: boolean
|
||||||
|
onOpen: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="daily-note"
|
||||||
|
icon={<CheckCircle2 size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.widgets.daily-note')}
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 text-start transition-all"
|
||||||
|
>
|
||||||
|
<p className="text-[11px] font-serif font-semibold text-ink dark:text-dark-ink">
|
||||||
|
{new Date().toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-[9px] font-mono uppercase text-brand-accent mt-1">{t('homeDashboard.dailyNoteOpen')} →</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardLinkSuggestions({
|
||||||
|
paths,
|
||||||
|
loading,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
paths: Array<{ id: string; title: string; description: string; score?: number }>
|
||||||
|
loading?: boolean
|
||||||
|
onAction: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="link-suggestions"
|
||||||
|
icon={<Circle size={12} className="text-emerald-600" />}
|
||||||
|
title={t('homeDashboard.widgets.link-suggestions')}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-12 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : paths.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-concrete italic">{t('homeDashboard.linkSuggestionsEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{paths.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAction(p.id)}
|
||||||
|
className="w-full p-3 rounded-xl border border-border/20 hover:border-emerald-500/30 text-start transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<p className="text-[11px] font-mono font-bold text-ink dark:text-dark-ink truncate">{p.title}</p>
|
||||||
|
{p.score ? (
|
||||||
|
<span className="text-[8px] font-mono text-emerald-600 shrink-0">{p.score}%</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-concrete line-clamp-2">{p.description}</p>
|
||||||
|
<p className="text-[8px] font-mono uppercase text-emerald-600 mt-2">{t('homeDashboard.pathActions.addLink')} →</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardBridgesWidget({
|
||||||
|
suggestions,
|
||||||
|
loading,
|
||||||
|
actingKey,
|
||||||
|
onCreate,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
suggestions: Array<{
|
||||||
|
clusterAId: number
|
||||||
|
clusterBId: number
|
||||||
|
clusterAName: string
|
||||||
|
clusterBName: string
|
||||||
|
suggestedTitle: string
|
||||||
|
justification: string
|
||||||
|
}>
|
||||||
|
loading?: boolean
|
||||||
|
actingKey?: string | null
|
||||||
|
onCreate: (s: { clusterAId: number; clusterBId: number; clusterAName: string; clusterBName: string; suggestedTitle: string; suggestedContent: string; justification: string }) => void
|
||||||
|
onDismiss: (s: { clusterAId: number; clusterBId: number }) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardWidgetShell
|
||||||
|
widgetId="bridges"
|
||||||
|
icon={<Circle size={12} className="text-violet-600" />}
|
||||||
|
title={t('homeDashboard.widgets.bridges')}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-20 rounded-lg bg-stone-50 animate-pulse" />
|
||||||
|
) : suggestions.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-concrete italic">{t('homeDashboard.bridgesEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{suggestions.slice(0, 3).map(s => {
|
||||||
|
const key = `${s.clusterAId}-${s.clusterBId}`
|
||||||
|
return (
|
||||||
|
<div key={key} className="p-3 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30">
|
||||||
|
<p className="text-[10px] font-mono font-bold text-ink dark:text-dark-ink">{s.suggestedTitle}</p>
|
||||||
|
<p className="text-[9px] text-concrete mt-1">
|
||||||
|
{t('homeDashboard.suggestedBridge', { clusterA: s.clusterAName, clusterB: s.clusterBName })}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-concrete mt-1 line-clamp-2">{s.justification}</p>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actingKey === key}
|
||||||
|
onClick={() => onCreate({ ...s, suggestedContent: s.justification })}
|
||||||
|
className="text-[8px] font-mono uppercase font-bold text-brand-accent hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('homeDashboard.createBridgeNote')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actingKey === key}
|
||||||
|
onClick={() => onDismiss(s)}
|
||||||
|
className="text-[8px] font-mono uppercase text-concrete hover:text-rose-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('homeDashboard.dismissConnection')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DashboardWidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
memento-note/components/dashboard-resume-hero.tsx
Normal file
123
memento-note/components/dashboard-resume-hero.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { Clock, ChevronRight, Play } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||||
|
|
||||||
|
export interface ResumeNote {
|
||||||
|
id: string
|
||||||
|
title: string | null
|
||||||
|
excerpt: string
|
||||||
|
notebookName: string
|
||||||
|
notebookColor: string
|
||||||
|
updatedAt: string
|
||||||
|
notebookId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardResumeHeroProps {
|
||||||
|
notes: ResumeNote[]
|
||||||
|
loading?: boolean
|
||||||
|
onSelect: (id: string, notebookId: string | null) => void
|
||||||
|
formatRelativeTime: (date: string) => string
|
||||||
|
prefersReducedMotion?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardResumeHero({
|
||||||
|
notes,
|
||||||
|
loading,
|
||||||
|
onSelect,
|
||||||
|
formatRelativeTime,
|
||||||
|
prefersReducedMotion,
|
||||||
|
}: DashboardResumeHeroProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const hero = notes[0]
|
||||||
|
const rest = notes.slice(1, 4)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[200px] animate-pulse">
|
||||||
|
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded mb-4" />
|
||||||
|
<div className="h-6 w-3/4 bg-stone-100 dark:bg-zinc-800 rounded mb-3" />
|
||||||
|
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hero) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-border/40 bg-white/50 dark:bg-zinc-900/50 p-8 text-center">
|
||||||
|
<Clock size={24} className="mx-auto text-concrete/30 mb-3" strokeWidth={1.25} />
|
||||||
|
<p className="text-xs text-concrete italic">{t('homeDashboard.noRecentNotes')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 overflow-hidden">
|
||||||
|
<div className="px-5 pt-4">
|
||||||
|
<DashboardWidgetTitleRow
|
||||||
|
widgetId="resume"
|
||||||
|
icon={<Play size={11} className="text-brand-accent" fill="currentColor" />}
|
||||||
|
title={t('homeDashboard.continue')}
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
||||||
|
onClick={() => onSelect(hero.id, hero.notebookId)}
|
||||||
|
className="w-full text-start px-5 pb-5 group cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="p-4 rounded-xl border border-border/25 bg-gradient-to-br from-stone-50/80 to-white dark:from-zinc-950/50 dark:to-zinc-900/80 group-hover:border-brand-accent/35 group-hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
|
<span
|
||||||
|
className="text-[8px] font-mono font-bold uppercase px-2 py-0.5 rounded text-white shrink-0"
|
||||||
|
style={{ backgroundColor: hero.notebookColor }}
|
||||||
|
>
|
||||||
|
{hero.notebookName}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] font-mono text-concrete/70 shrink-0">
|
||||||
|
{formatRelativeTime(hero.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base sm:text-lg font-serif font-semibold text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors leading-snug mb-2 line-clamp-2">
|
||||||
|
{hero.title || t('homeDashboard.untitled')}
|
||||||
|
</h3>
|
||||||
|
{hero.excerpt && (
|
||||||
|
<p className="text-[11px] text-concrete leading-relaxed line-clamp-3">
|
||||||
|
{hero.excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 mt-3 text-[9px] font-mono font-bold uppercase text-brand-accent opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{t('homeDashboard.resumeOpen')}
|
||||||
|
<ChevronRight size={12} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<div className="flex gap-2 px-5 pb-4 overflow-x-auto custom-scrollbar">
|
||||||
|
{rest.map(note => (
|
||||||
|
<button
|
||||||
|
key={note.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(note.id, note.notebookId)}
|
||||||
|
className="shrink-0 max-w-[200px] p-2.5 rounded-xl border border-border/20 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-brand-accent/30 transition-all text-start group"
|
||||||
|
>
|
||||||
|
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
|
||||||
|
{note.title || t('homeDashboard.untitled')}
|
||||||
|
</p>
|
||||||
|
<p className="text-[8px] font-mono text-concrete/60 mt-0.5">
|
||||||
|
{formatRelativeTime(note.updatedAt)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
memento-note/components/dashboard-sentiment-chip.tsx
Normal file
113
memento-note/components/dashboard-sentiment-chip.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Heart } from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||||
|
|
||||||
|
interface EmotionMeta {
|
||||||
|
Icon: LucideIcon
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSentimentChipProps {
|
||||||
|
available: boolean
|
||||||
|
loading?: boolean
|
||||||
|
dominantEmotion?: string
|
||||||
|
summary?: string
|
||||||
|
emotions?: Record<string, number>
|
||||||
|
emotionMeta: Record<string, EmotionMeta>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardSentimentChip({
|
||||||
|
available,
|
||||||
|
loading,
|
||||||
|
dominantEmotion,
|
||||||
|
summary,
|
||||||
|
emotions,
|
||||||
|
emotionMeta,
|
||||||
|
}: DashboardSentimentChipProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
|
||||||
|
<div className="h-24 rounded-xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4 min-h-[140px]">
|
||||||
|
<DashboardWidgetTitleRow
|
||||||
|
widgetId="sentiment"
|
||||||
|
icon={<Heart size={12} className="text-brand-accent" />}
|
||||||
|
title={t('homeDashboard.sentiment')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!available || !dominantEmotion ? (
|
||||||
|
<p className="text-[11px] text-concrete italic leading-relaxed">
|
||||||
|
{t('homeDashboard.notEnoughNotes')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{(() => {
|
||||||
|
const meta = emotionMeta[dominantEmotion] || emotionMeta.reflective
|
||||||
|
const Icon = meta?.Icon
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
|
||||||
|
style={{ backgroundColor: `${meta.color}18`, color: meta.color }}
|
||||||
|
>
|
||||||
|
{Icon && <Icon size={18} strokeWidth={1.75} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-lg font-serif font-semibold text-ink dark:text-dark-ink">
|
||||||
|
{t(`homeDashboard.emotions.${dominantEmotion}`)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[9px] font-mono uppercase tracking-wider text-concrete">
|
||||||
|
{t('homeDashboard.sentimentDominant')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary && (
|
||||||
|
<p className="text-[11px] text-concrete leading-relaxed border-s-2 border-brand-accent/30 ps-3">
|
||||||
|
{summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emotions && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
{Object.entries(emotions)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(([key, val]) => {
|
||||||
|
const em = emotionMeta[key]
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-2">
|
||||||
|
<span className="text-[8px] font-mono uppercase text-concrete w-16 truncate">
|
||||||
|
{t(`homeDashboard.emotions.${key}`)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-stone-100 dark:bg-zinc-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(val * 100, 100)}%`,
|
||||||
|
backgroundColor: em?.color || 'var(--color-brand-accent)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1052
memento-note/components/dashboard-view.tsx
Normal file
1052
memento-note/components/dashboard-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
402
memento-note/components/dashboard-widget-grid.tsx
Normal file
402
memento-note/components/dashboard-widget-grid.tsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
rectSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { GripVertical, X, Plus, RotateCcw, Check, LayoutGrid, Eye, EyeOff } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import {
|
||||||
|
getDefaultDashboardLayout,
|
||||||
|
resetDashboardLayout,
|
||||||
|
DASHBOARD_CATEGORY_ORDER,
|
||||||
|
DASHBOARD_WIDGET_META,
|
||||||
|
catalogByCategory,
|
||||||
|
hiddenWidgetIds,
|
||||||
|
isWidgetVisible,
|
||||||
|
normalizeDashboardLayout,
|
||||||
|
reorderWidgets,
|
||||||
|
setWidgetVisibility,
|
||||||
|
visibleWidgetsInZone,
|
||||||
|
type DashboardLayout,
|
||||||
|
type DashboardWidgetId,
|
||||||
|
type DashboardWidgetPlacement,
|
||||||
|
type DashboardWidgetZone,
|
||||||
|
} from '@/lib/dashboard/layout'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface DashboardWidgetGridProps {
|
||||||
|
renderWidget: (id: DashboardWidgetId) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableWidget({
|
||||||
|
placement,
|
||||||
|
editMode,
|
||||||
|
onHide,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
placement: DashboardWidgetPlacement
|
||||||
|
editMode: boolean
|
||||||
|
onHide: (id: DashboardWidgetId) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const meta = DASHBOARD_WIDGET_META[placement.id]
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: placement.id, disabled: !editMode })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 20 : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`relative w-full ${
|
||||||
|
isDragging ? 'opacity-90 scale-[1.01]' : ''
|
||||||
|
} ${editMode ? 'ring-2 ring-brand-accent/25 ring-offset-2 ring-offset-[#F9F8F6] dark:ring-offset-[#0D0D0D] rounded-2xl' : ''}`}
|
||||||
|
>
|
||||||
|
{editMode && (
|
||||||
|
<div className="absolute top-2 end-2 z-20 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1.5 rounded-lg bg-white/95 dark:bg-zinc-900/95 border border-border/40 shadow-sm cursor-grab active:cursor-grabbing text-concrete hover:text-ink"
|
||||||
|
aria-label={t('homeDashboard.widgetDrag')}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={12} />
|
||||||
|
</button>
|
||||||
|
{!meta.required && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onHide(placement.id)}
|
||||||
|
className="p-1.5 rounded-lg bg-white/95 dark:bg-zinc-900/95 border border-border/40 shadow-sm text-concrete hover:text-rose-500"
|
||||||
|
aria-label={t('homeDashboard.widgetHide')}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZoneColumn({
|
||||||
|
zone,
|
||||||
|
placements,
|
||||||
|
editMode,
|
||||||
|
onHide,
|
||||||
|
renderWidget,
|
||||||
|
}: {
|
||||||
|
zone: DashboardWidgetZone
|
||||||
|
placements: DashboardWidgetPlacement[]
|
||||||
|
editMode: boolean
|
||||||
|
onHide: (id: DashboardWidgetId) => void
|
||||||
|
renderWidget: (id: DashboardWidgetId) => React.ReactNode
|
||||||
|
}) {
|
||||||
|
if (placements.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableContext items={placements.map(w => w.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{placements.map(placement => (
|
||||||
|
<SortableWidget
|
||||||
|
key={placement.id}
|
||||||
|
placement={placement}
|
||||||
|
editMode={editMode}
|
||||||
|
onHide={onHide}
|
||||||
|
>
|
||||||
|
{renderWidget(placement.id)}
|
||||||
|
</SortableWidget>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
|
||||||
|
const [editMode, setEditMode] = useState(false)
|
||||||
|
const [catalogOpen, setCatalogOpen] = useState(false)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/dashboard/layout', { cache: 'no-store' })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(json => {
|
||||||
|
if (json?.layout) setLayout(normalizeDashboardLayout(json.layout))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoaded(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const persistLayout = useCallback((next: DashboardLayout, immediate = false) => {
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current)
|
||||||
|
const save = () => {
|
||||||
|
fetch('/api/dashboard/layout', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ layout: next }),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (immediate) save()
|
||||||
|
else saveTimer.current = setTimeout(save, 600)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const applyLayout = useCallback((next: DashboardLayout, immediate = false) => {
|
||||||
|
const normalized = normalizeDashboardLayout(next)
|
||||||
|
setLayout(normalized)
|
||||||
|
persistLayout(normalized, immediate)
|
||||||
|
}, [persistLayout])
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
applyLayout(reorderWidgets(layout, active.id as DashboardWidgetId, over.id as DashboardWidgetId))
|
||||||
|
}, [applyLayout, layout])
|
||||||
|
|
||||||
|
const handleHide = useCallback((id: DashboardWidgetId) => {
|
||||||
|
applyLayout(setWidgetVisibility(layout, id, false))
|
||||||
|
}, [applyLayout, layout])
|
||||||
|
|
||||||
|
const handleToggle = useCallback((id: DashboardWidgetId) => {
|
||||||
|
const visible = isWidgetVisible(layout, id)
|
||||||
|
if (visible) {
|
||||||
|
applyLayout(setWidgetVisibility(layout, id, false))
|
||||||
|
} else {
|
||||||
|
const maxOrder = Math.max(...layout.widgets.map(w => w.order), 0)
|
||||||
|
applyLayout({
|
||||||
|
...layout,
|
||||||
|
widgets: layout.widgets.map(w =>
|
||||||
|
w.id === id ? { ...w, visible: true, order: maxOrder + 1 } : w,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [applyLayout, layout])
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
const fresh = resetDashboardLayout()
|
||||||
|
setLayout(fresh)
|
||||||
|
persistLayout(fresh, true)
|
||||||
|
setCatalogOpen(false)
|
||||||
|
toast.success(t('homeDashboard.widgetResetDone'))
|
||||||
|
}, [persistLayout, t])
|
||||||
|
|
||||||
|
const fullWidgets = visibleWidgetsInZone(layout, 'full')
|
||||||
|
const mainWidgets = visibleWidgetsInZone(layout, 'main')
|
||||||
|
const sideWidgets = visibleWidgetsInZone(layout, 'side')
|
||||||
|
const hidden = hiddenWidgetIds(layout)
|
||||||
|
const catalog = catalogByCategory(layout)
|
||||||
|
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loaded ? (
|
||||||
|
hasVisibleWidgets ? (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
{fullWidgets.length > 0 && (
|
||||||
|
<SortableContext items={fullWidgets.map(w => w.id)} strategy={rectSortingStrategy}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{fullWidgets.map(placement => (
|
||||||
|
<SortableWidget
|
||||||
|
key={placement.id}
|
||||||
|
placement={placement}
|
||||||
|
editMode={editMode}
|
||||||
|
onHide={handleHide}
|
||||||
|
>
|
||||||
|
{renderWidget(placement.id)}
|
||||||
|
</SortableWidget>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(mainWidgets.length > 0 || sideWidgets.length > 0) && (
|
||||||
|
<div className="grid grid-cols-12 gap-4 items-start">
|
||||||
|
{mainWidgets.length > 0 && (
|
||||||
|
<div className="col-span-12 lg:col-span-8">
|
||||||
|
<ZoneColumn
|
||||||
|
zone="main"
|
||||||
|
placements={mainWidgets}
|
||||||
|
editMode={editMode}
|
||||||
|
onHide={handleHide}
|
||||||
|
renderWidget={renderWidget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sideWidgets.length > 0 && (
|
||||||
|
<div className="col-span-12 lg:col-span-4">
|
||||||
|
<ZoneColumn
|
||||||
|
zone="side"
|
||||||
|
placements={sideWidgets}
|
||||||
|
editMode={editMode}
|
||||||
|
onHide={handleHide}
|
||||||
|
renderWidget={renderWidget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DndContext>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-border/40 bg-white/60 dark:bg-zinc-900/60 p-8 text-center">
|
||||||
|
<p className="text-sm font-serif text-ink dark:text-dark-ink mb-2">{t('homeDashboard.layoutEmptyTitle')}</p>
|
||||||
|
<p className="text-[11px] text-concrete mb-4 max-w-md mx-auto">{t('homeDashboard.layoutEmptyHint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-brand-accent text-white text-[10px] font-mono uppercase font-bold hover:bg-brand-accent/90 transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} />
|
||||||
|
{t('homeDashboard.widgetReset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-12 gap-5">
|
||||||
|
<div className="col-span-12 h-20 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||||
|
<div className="col-span-12 lg:col-span-8 h-48 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||||
|
<div className="col-span-12 lg:col-span-4 h-48 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 px-3 py-2 rounded-2xl bg-ink/92 dark:bg-zinc-900/95 text-white shadow-xl border border-white/10 backdrop-blur-md">
|
||||||
|
{editMode ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCatalogOpen(v => !v)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
{t('homeDashboard.widgetCatalog')}
|
||||||
|
{hidden.length > 0 && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-md bg-brand-accent/90 text-[9px]">{hidden.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} />
|
||||||
|
{t('homeDashboard.widgetReset')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditMode(false); setCatalogOpen(false) }}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Check size={12} />
|
||||||
|
{t('homeDashboard.widgetDone')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditMode(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
|
||||||
|
>
|
||||||
|
<LayoutGrid size={12} />
|
||||||
|
{t('homeDashboard.widgetCustomize')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editMode && catalogOpen && (
|
||||||
|
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-40 w-[min(520px,calc(100vw-2rem))] max-h-[min(60vh,480px)] overflow-y-auto custom-scrollbar p-4 rounded-2xl bg-white dark:bg-zinc-900 border border-border/40 shadow-2xl">
|
||||||
|
<p className="text-[9px] font-mono font-bold uppercase tracking-wider text-concrete mb-3">
|
||||||
|
{t('homeDashboard.widgetCatalogTitle')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{DASHBOARD_CATEGORY_ORDER.map(category => {
|
||||||
|
const ids = catalog[category]
|
||||||
|
if (ids.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div key={category}>
|
||||||
|
<p className="text-[8px] font-mono font-bold uppercase tracking-[0.15em] text-brand-accent mb-2">
|
||||||
|
{t(`homeDashboard.widgetCategories.${category}`)}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{ids.map(id => {
|
||||||
|
const meta = DASHBOARD_WIDGET_META[id]
|
||||||
|
const active = isWidgetVisible(layout, id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => !meta.required && handleToggle(id)}
|
||||||
|
disabled={meta.required}
|
||||||
|
className={`w-full flex items-start gap-3 p-2.5 rounded-xl border text-start transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-brand-accent/30 bg-brand-accent/[0.04]'
|
||||||
|
: 'border-border/25 bg-stone-50/80 dark:bg-zinc-950/40 hover:border-brand-accent/25'
|
||||||
|
} ${meta.required ? 'opacity-80 cursor-default' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<div className={`mt-0.5 shrink-0 w-6 h-6 rounded-lg flex items-center justify-center ${
|
||||||
|
active ? 'bg-brand-accent/15 text-brand-accent' : 'bg-stone-200/60 dark:bg-zinc-800 text-concrete'
|
||||||
|
}`}>
|
||||||
|
{active ? <Eye size={11} /> : <EyeOff size={11} />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[11px] font-mono font-bold uppercase tracking-wide text-ink dark:text-dark-ink">
|
||||||
|
{t(`homeDashboard.widgets.${id}`)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-concrete leading-snug mt-0.5">
|
||||||
|
{t(`homeDashboard.widgetDescriptions.${id}`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!meta.required && (
|
||||||
|
<span className={`shrink-0 text-[9px] font-mono font-bold uppercase px-2 py-1 rounded-lg ${
|
||||||
|
active ? 'text-concrete' : 'text-brand-accent bg-brand-accent/10'
|
||||||
|
}`}>
|
||||||
|
{active ? t('homeDashboard.widgetActive') : t('homeDashboard.widgetAddShort')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
memento-note/components/dashboard-widget-help.tsx
Normal file
57
memento-note/components/dashboard-widget-help.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { HelpCircle, X } from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
|
||||||
|
|
||||||
|
interface DashboardWidgetHelpProps {
|
||||||
|
widgetId: DashboardWidgetId
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardWidgetHelp({ widgetId, className = '' }: DashboardWidgetHelpProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const helpKey = `homeDashboard.widgetHelp.${widgetId}`
|
||||||
|
const text = t(helpKey)
|
||||||
|
|
||||||
|
if (text === helpKey) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className={`p-1 rounded-full border transition-colors ${
|
||||||
|
open
|
||||||
|
? 'border-brand-accent/40 bg-brand-accent/10 text-brand-accent'
|
||||||
|
: 'border-border/30 bg-white/90 dark:bg-zinc-900/90 text-concrete hover:text-brand-accent hover:border-brand-accent/30'
|
||||||
|
}`}
|
||||||
|
aria-label={t('homeDashboard.widgetHelpLabel')}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<HelpCircle size={12} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full end-0 mt-1.5 z-30 w-[min(280px,calc(100vw-3rem))] p-3 rounded-xl border border-brand-accent/20 bg-white dark:bg-zinc-900 shadow-lg">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-brand-accent">
|
||||||
|
{t(`homeDashboard.widgets.${widgetId}`)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="text-concrete hover:text-ink shrink-0"
|
||||||
|
aria-label={t('homeDashboard.widgetHelpClose')}
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-concrete leading-relaxed">{text}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
memento-note/components/dashboard-widget-title-row.tsx
Normal file
37
memento-note/components/dashboard-widget-title-row.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { DashboardWidgetHelp } from '@/components/dashboard-widget-help'
|
||||||
|
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
|
||||||
|
|
||||||
|
interface DashboardWidgetTitleRowProps {
|
||||||
|
widgetId: DashboardWidgetId
|
||||||
|
icon?: ReactNode
|
||||||
|
title: string
|
||||||
|
actions?: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Titre widget : actions d’abord, aide « ? » en dernier — ne masque jamais la navigation. */
|
||||||
|
export function DashboardWidgetTitleRow({
|
||||||
|
widgetId,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
actions,
|
||||||
|
className = 'mb-3',
|
||||||
|
}: DashboardWidgetTitleRowProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-between gap-2 ${className}`}>
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
{icon}
|
||||||
|
<h2 className="text-[10px] font-mono font-bold uppercase tracking-widest text-ink dark:text-dark-ink truncate">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{actions}
|
||||||
|
<DashboardWidgetHelp widgetId={widgetId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import { StudyPlannerDialog } from '@/components/wizard/study-planner-dialog'
|
|||||||
import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-dialog'
|
import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
|
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
|
||||||
|
|
||||||
|
|
||||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||||
@@ -58,6 +59,10 @@ const OrganizeNotebookDialog = dynamic(
|
|||||||
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
|
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
)
|
)
|
||||||
|
const DashboardView = dynamic(
|
||||||
|
() => import('@/components/dashboard-view').then(m => ({ default: m.DashboardView })),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
const NotebookSiteDialog = dynamic(
|
const NotebookSiteDialog = dynamic(
|
||||||
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
|
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
@@ -225,15 +230,12 @@ export function HomeClient({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
|
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
|
||||||
|
// On GARDE forceList dans l'URL pour distinguer "liste" du "dashboard"
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get('forceList') === '1') {
|
if (searchParams.get('forceList') === '1') {
|
||||||
setEditingNote(null)
|
setEditingNote(null)
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
params.delete('forceList')
|
|
||||||
const newUrl = params.toString() ? `/home?${params.toString()}` : '/home'
|
|
||||||
router.replace(newUrl, { scroll: false })
|
|
||||||
}
|
}
|
||||||
}, [searchParams, router])
|
}, [searchParams])
|
||||||
|
|
||||||
const fetchNotesForCurrentView = useCallback(
|
const fetchNotesForCurrentView = useCallback(
|
||||||
async (options?: { silent?: boolean }) => {
|
async (options?: { silent?: boolean }) => {
|
||||||
@@ -821,6 +823,16 @@ export function HomeClient({
|
|||||||
emitNoteChange({ type: 'updated', note: savedNote })
|
emitNoteChange({ type: 'updated', note: savedNote })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Show dashboard when no active filter/view params
|
||||||
|
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
|
||||||
|
|
||||||
|
const handleDashboardNoteSelect = useCallback((noteId: string, notebookId: string | null) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('openNote', noteId)
|
||||||
|
if (notebookId) params.set('notebook', notebookId)
|
||||||
|
router.push(`/home?${params.toString()}`)
|
||||||
|
}, [router])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -838,6 +850,8 @@ export function HomeClient({
|
|||||||
fullPage
|
fullPage
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : showDashboard ? (
|
||||||
|
<DashboardView onNoteSelect={handleDashboardNoteSelect} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
|
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
|
||||||
<div
|
<div
|
||||||
|
|||||||
729
memento-note/components/intelligence-hub.tsx
Normal file
729
memento-note/components/intelligence-hub.tsx
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import {
|
||||||
|
Sparkles, Zap, Lightbulb, Bot, ChevronLeft, ChevronRight,
|
||||||
|
ExternalLink, GitCompare, X, RefreshCw, Loader2, Link2, Brain, ArrowRight,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||||
|
import { MEMORY_ECHO_LEGACY_EN_FALLBACKS } from '@/lib/ai/memory-echo-i18n'
|
||||||
|
|
||||||
|
// ─── Types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IntelBriefingInsight {
|
||||||
|
id: string
|
||||||
|
insight: string
|
||||||
|
score: number
|
||||||
|
date: string
|
||||||
|
viewed: boolean
|
||||||
|
note1: { id: string; title: string | null }
|
||||||
|
note2: { id: string; title: string | null }
|
||||||
|
note1Excerpt?: string
|
||||||
|
note2Excerpt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntelBridgeNote {
|
||||||
|
noteId: string
|
||||||
|
bridgeScore: number
|
||||||
|
clusterNames?: string[]
|
||||||
|
note?: { id: string; title: string | null; content?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntelBridgeSuggestion {
|
||||||
|
clusterAId: number
|
||||||
|
clusterBId: number
|
||||||
|
clusterAName: string
|
||||||
|
clusterBName: string
|
||||||
|
suggestedTitle: string
|
||||||
|
suggestedContent: string
|
||||||
|
justification: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntelAgentAction {
|
||||||
|
id: string
|
||||||
|
agentName: string
|
||||||
|
result: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntelFilter = 'all' | 'connections' | 'bridges' | 'ideas' | 'agents'
|
||||||
|
|
||||||
|
type IntelItem =
|
||||||
|
| { kind: 'insight'; id: string; priority: number; insight: IntelBriefingInsight }
|
||||||
|
| { kind: 'bridge'; id: string; priority: number; bridge: IntelBridgeNote }
|
||||||
|
| { kind: 'suggestion'; id: string; priority: number; suggestion: IntelBridgeSuggestion }
|
||||||
|
| { kind: 'agent'; id: string; priority: number; agent: IntelAgentAction }
|
||||||
|
|
||||||
|
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
|
||||||
|
|
||||||
|
const FILTER_KINDS: Record<IntelFilter, IntelItem['kind'][] | null> = {
|
||||||
|
all: null,
|
||||||
|
connections: ['insight'],
|
||||||
|
bridges: ['bridge'],
|
||||||
|
ideas: ['suggestion'],
|
||||||
|
agents: ['agent'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(
|
||||||
|
dateStr: string,
|
||||||
|
t: (key: string, params?: Record<string, string | number>) => string,
|
||||||
|
): string {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime()
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
const hours = Math.floor(diff / 3600000)
|
||||||
|
const days = Math.floor(diff / 86400000)
|
||||||
|
if (mins < 1) return t('time.justNow')
|
||||||
|
if (mins < 60) return t('time.minutesAgo', { count: mins })
|
||||||
|
if (hours < 24) return t('time.hoursAgo', { count: hours })
|
||||||
|
return t('time.daysAgo', { count: days })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Visual: lien entre deux notes ─────────────────────
|
||||||
|
|
||||||
|
function ConnectionDiagram({
|
||||||
|
note1Title,
|
||||||
|
note2Title,
|
||||||
|
score,
|
||||||
|
color = '#6366F1',
|
||||||
|
}: {
|
||||||
|
note1Title: string
|
||||||
|
note2Title: string
|
||||||
|
score: number
|
||||||
|
color?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-3">
|
||||||
|
<div
|
||||||
|
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
|
||||||
|
style={{ borderColor: `${color}30` }}
|
||||||
|
>
|
||||||
|
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
|
||||||
|
{note1Title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 flex flex-col items-center gap-0.5 px-1">
|
||||||
|
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-mono font-bold px-2 py-0.5 rounded-full"
|
||||||
|
style={{ color, backgroundColor: `${color}14`, border: `1px solid ${color}25` }}
|
||||||
|
>
|
||||||
|
{Math.round(score * 100)}%
|
||||||
|
</span>
|
||||||
|
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
|
||||||
|
style={{ borderColor: `${color}30` }}
|
||||||
|
>
|
||||||
|
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
|
||||||
|
{note2Title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IntelligenceHubProps {
|
||||||
|
loading: boolean
|
||||||
|
aiActive: boolean
|
||||||
|
hasAiConsent: boolean
|
||||||
|
providerReady: boolean
|
||||||
|
memoryEchoEnabled: boolean
|
||||||
|
insights: IntelBriefingInsight[]
|
||||||
|
bridgeNotes: IntelBridgeNote[]
|
||||||
|
bridgeSuggestions: IntelBridgeSuggestion[]
|
||||||
|
agentActions: IntelAgentAction[]
|
||||||
|
onNoteSelect: (noteId: string) => void
|
||||||
|
onRefreshEcho: () => void
|
||||||
|
onEnableAi: () => void
|
||||||
|
echoRefreshing: boolean
|
||||||
|
onDismissInsight: (insight: IntelBriefingInsight) => void
|
||||||
|
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
|
||||||
|
onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void
|
||||||
|
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string) => void
|
||||||
|
dismissingInsightId: string | null
|
||||||
|
actingBridgeSuggestionKey: string | null
|
||||||
|
prefersReducedMotion: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export function IntelligenceHub({
|
||||||
|
loading,
|
||||||
|
aiActive,
|
||||||
|
hasAiConsent,
|
||||||
|
providerReady,
|
||||||
|
memoryEchoEnabled,
|
||||||
|
insights,
|
||||||
|
bridgeNotes,
|
||||||
|
bridgeSuggestions,
|
||||||
|
agentActions,
|
||||||
|
onNoteSelect,
|
||||||
|
onRefreshEcho,
|
||||||
|
onEnableAi,
|
||||||
|
echoRefreshing,
|
||||||
|
onDismissInsight,
|
||||||
|
onDismissBridgeSuggestion,
|
||||||
|
onCreateBridgeSuggestion,
|
||||||
|
onOpenInsightNote,
|
||||||
|
dismissingInsightId,
|
||||||
|
actingBridgeSuggestionKey,
|
||||||
|
prefersReducedMotion,
|
||||||
|
}: IntelligenceHubProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const [filter, setFilter] = useState<IntelFilter>('all')
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
|
|
||||||
|
const localizeInsightText = useCallback((insight: IntelBriefingInsight) => {
|
||||||
|
const text = insight.insight.trim()
|
||||||
|
if (MEMORY_ECHO_LEGACY_EN_FALLBACKS.has(text)) {
|
||||||
|
if (insight.note1Excerpt && insight.note2Excerpt) {
|
||||||
|
return t('homeDashboard.connectionBetween', {
|
||||||
|
note1: insight.note1.title || insight.note1Excerpt.slice(0, 50),
|
||||||
|
note2: insight.note2.title || insight.note2Excerpt.slice(0, 50),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return t('memoryEcho.defaultInsight')
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const allItems = useMemo(() => {
|
||||||
|
const insightNoteIds = new Set<string>()
|
||||||
|
for (const i of insights) {
|
||||||
|
insightNoteIds.add(i.note1.id)
|
||||||
|
insightNoteIds.add(i.note2.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: IntelItem[] = []
|
||||||
|
|
||||||
|
for (const insight of insights) {
|
||||||
|
items.push({
|
||||||
|
kind: 'insight',
|
||||||
|
id: `insight-${insight.id}`,
|
||||||
|
priority: (insight.viewed ? 40 : 100) + insight.score * 10,
|
||||||
|
insight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bridge of bridgeNotes) {
|
||||||
|
if (insightNoteIds.has(bridge.noteId)) continue
|
||||||
|
items.push({
|
||||||
|
kind: 'bridge',
|
||||||
|
id: `bridge-${bridge.noteId}`,
|
||||||
|
priority: 60 + bridge.bridgeScore * 10,
|
||||||
|
bridge,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const suggestion of bridgeSuggestions) {
|
||||||
|
items.push({
|
||||||
|
kind: 'suggestion',
|
||||||
|
id: `suggestion-${suggestion.clusterAId}-${suggestion.clusterBId}`,
|
||||||
|
priority: 80,
|
||||||
|
suggestion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of agentActions) {
|
||||||
|
items.push({
|
||||||
|
kind: 'agent',
|
||||||
|
id: `agent-${agent.id}`,
|
||||||
|
priority: 30,
|
||||||
|
agent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.sort((a, b) => b.priority - a.priority).slice(0, 10)
|
||||||
|
}, [insights, bridgeNotes, bridgeSuggestions, agentActions])
|
||||||
|
|
||||||
|
const counts = useMemo(() => ({
|
||||||
|
all: allItems.length,
|
||||||
|
connections: allItems.filter(i => i.kind === 'insight').length,
|
||||||
|
bridges: allItems.filter(i => i.kind === 'bridge').length,
|
||||||
|
ideas: allItems.filter(i => i.kind === 'suggestion').length,
|
||||||
|
agents: allItems.filter(i => i.kind === 'agent').length,
|
||||||
|
}), [allItems])
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const kinds = FILTER_KINDS[filter]
|
||||||
|
if (!kinds) return allItems
|
||||||
|
return allItems.filter(i => kinds.includes(i.kind))
|
||||||
|
}, [allItems, filter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveIndex(0)
|
||||||
|
}, [filter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeIndex >= filteredItems.length && filteredItems.length > 0) {
|
||||||
|
setActiveIndex(filteredItems.length - 1)
|
||||||
|
}
|
||||||
|
}, [activeIndex, filteredItems.length])
|
||||||
|
|
||||||
|
const activeItem = filteredItems[activeIndex] ?? null
|
||||||
|
const newCount = insights.filter(i => !i.viewed).length
|
||||||
|
|
||||||
|
const goPrev = () => setActiveIndex(i => Math.max(0, i - 1))
|
||||||
|
const goNext = () => setActiveIndex(i => Math.min(filteredItems.length - 1, i + 1))
|
||||||
|
|
||||||
|
const filters: { key: IntelFilter; label: string }[] = [
|
||||||
|
{ key: 'all', label: t('homeDashboard.intelFilterAll') },
|
||||||
|
{ key: 'connections', label: t('homeDashboard.intelFilterConnections') },
|
||||||
|
{ key: 'bridges', label: t('homeDashboard.intelFilterBridges') },
|
||||||
|
{ key: 'ideas', label: t('homeDashboard.intelFilterIdeas') },
|
||||||
|
{ key: 'agents', label: t('homeDashboard.intelFilterAgents') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const slideVariants = prefersReducedMotion
|
||||||
|
? { initial: {}, animate: {}, exit: {} }
|
||||||
|
: {
|
||||||
|
initial: { opacity: 0, x: 24, scale: 0.98 },
|
||||||
|
animate: { opacity: 1, x: 0, scale: 1 },
|
||||||
|
exit: { opacity: 0, x: -24, scale: 0.98 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSpotlight = (item: IntelItem) => {
|
||||||
|
switch (item.kind) {
|
||||||
|
case 'insight': {
|
||||||
|
const { insight } = item
|
||||||
|
const text = localizeInsightText(insight)
|
||||||
|
const excerpt = insight.note1Excerpt || insight.note2Excerpt
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Sparkles size={12} className="text-indigo-500" />
|
||||||
|
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
|
||||||
|
{t('homeDashboard.semanticConnection')}
|
||||||
|
</span>
|
||||||
|
{!insight.viewed && (
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-ochre animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ConnectionDiagram
|
||||||
|
note1Title={insight.note1.title || t('homeDashboard.untitled')}
|
||||||
|
note2Title={insight.note2.title || t('homeDashboard.untitled')}
|
||||||
|
score={insight.score}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-ink/75 dark:text-dark-ink/75 font-serif italic leading-relaxed line-clamp-3 px-1">
|
||||||
|
« {text} »
|
||||||
|
</p>
|
||||||
|
{excerpt && (
|
||||||
|
<p className="text-[9px] text-concrete/80 line-clamp-2 mt-2 px-1 border-s-2 border-indigo-500/20 ps-2">
|
||||||
|
{excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 mt-auto pt-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenInsightNote(insight, insight.note1.id)}
|
||||||
|
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={9} />
|
||||||
|
{t('homeDashboard.intelOpenNote')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenInsightNote(insight, insight.note2.id)}
|
||||||
|
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors"
|
||||||
|
>
|
||||||
|
<GitCompare size={9} />
|
||||||
|
{t('homeDashboard.intelCompare')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={dismissingInsightId === insight.id}
|
||||||
|
onClick={() => onDismissInsight(insight)}
|
||||||
|
className="p-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 ms-auto disabled:opacity-40"
|
||||||
|
title={t('homeDashboard.dismissConnection')}
|
||||||
|
>
|
||||||
|
{dismissingInsightId === insight.id
|
||||||
|
? <Loader2 size={10} className="animate-spin" />
|
||||||
|
: <X size={10} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'bridge': {
|
||||||
|
const { bridge } = item
|
||||||
|
const title = bridge.note?.title || t('homeDashboard.untitled')
|
||||||
|
const excerpt = bridge.note?.content ? stripHtml(bridge.note.content).slice(0, 160) : ''
|
||||||
|
const names = bridge.clusterNames ?? []
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap size={12} className="text-ochre" />
|
||||||
|
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
|
||||||
|
{t('homeDashboard.bridgeNote')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-mono font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
||||||
|
{Math.round(bridge.bridgeScore * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNoteSelect(bridge.noteId)}
|
||||||
|
className="text-start group flex-1"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold text-ink dark:text-dark-ink group-hover:text-ochre transition-colors mb-2 leading-snug">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
{excerpt && (
|
||||||
|
<p className="text-[10px] text-concrete leading-relaxed line-clamp-3 mb-3">{excerpt}</p>
|
||||||
|
)}
|
||||||
|
{names.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{names.map((name, i) => (
|
||||||
|
<span
|
||||||
|
key={`${bridge.noteId}-${name}`}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded-full border border-border/25 bg-white/70 dark:bg-zinc-900/50"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: CLUSTER_COLORS[i % CLUSTER_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className="text-[8px] font-mono uppercase text-concrete">{name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNoteSelect(bridge.noteId)}
|
||||||
|
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-ochre/90 text-white hover:bg-ochre transition-colors w-fit"
|
||||||
|
>
|
||||||
|
<ExternalLink size={9} />
|
||||||
|
{t('homeDashboard.intelOpenNote')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'suggestion': {
|
||||||
|
const { suggestion } = item
|
||||||
|
const key = `${suggestion.clusterAId}-${suggestion.clusterBId}`
|
||||||
|
const busy = actingBridgeSuggestionKey === key
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Lightbulb size={12} className="text-violet-500" />
|
||||||
|
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-violet-600 dark:text-violet-400 truncate">
|
||||||
|
{t('homeDashboard.intelMissingLink')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="px-2 py-1 rounded-lg bg-violet-500/10 text-[9px] font-mono font-bold text-violet-700 dark:text-violet-300 truncate max-w-[45%]">
|
||||||
|
{suggestion.clusterAName}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-gradient-to-r from-violet-400/40 via-ochre/60 to-violet-400/40" />
|
||||||
|
<span className="px-2 py-1 rounded-lg bg-ochre/10 text-[9px] font-mono font-bold text-ochre truncate max-w-[45%]">
|
||||||
|
{suggestion.clusterBName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-ink dark:text-dark-ink mb-1.5">{suggestion.suggestedTitle}</p>
|
||||||
|
<p className="text-[10px] text-concrete leading-relaxed line-clamp-2 flex-1">{suggestion.suggestedContent}</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border/15">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onCreateBridgeSuggestion(suggestion)}
|
||||||
|
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-violet-600 text-white hover:bg-violet-700 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 size={9} className="animate-spin" /> : <Link2 size={9} />}
|
||||||
|
{t('homeDashboard.createBridgeNote')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDismissBridgeSuggestion(suggestion)}
|
||||||
|
className="text-[8.5px] font-mono uppercase px-2 py-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('homeDashboard.dismiss')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'agent': {
|
||||||
|
const { agent } = item
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Bot size={12} className="text-ochre" />
|
||||||
|
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
|
||||||
|
{agent.agentName}
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] font-mono text-concrete/60 ms-auto">
|
||||||
|
{formatRelativeTime(agent.createdAt, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{agent.result ? (
|
||||||
|
<p className="text-[11px] text-concrete leading-relaxed line-clamp-4 flex-1">
|
||||||
|
{agent.result}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-concrete/60 italic flex-1">{t('homeDashboard.intelAgentNoResult')}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/agents')}
|
||||||
|
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-colors w-fit"
|
||||||
|
>
|
||||||
|
{t('homeDashboard.intelViewAgent')}
|
||||||
|
<ArrowRight size={9} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotlightAccent = (item: IntelItem | null) => {
|
||||||
|
if (!item) return 'from-stone-100/50 to-transparent border-border/30'
|
||||||
|
switch (item.kind) {
|
||||||
|
case 'insight': return 'from-indigo-500/[0.06] via-transparent to-transparent border-indigo-400/25'
|
||||||
|
case 'bridge': return 'from-ochre/[0.06] via-transparent to-transparent border-ochre/25'
|
||||||
|
case 'suggestion': return 'from-violet-500/[0.06] via-transparent to-transparent border-violet-400/25'
|
||||||
|
case 'agent': return 'from-ochre/[0.04] via-transparent to-transparent border-border/30'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="p-5 rounded-2xl bg-white dark:bg-zinc-900 border border-border/30 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<DashboardWidgetTitleRow
|
||||||
|
widgetId="intelligence"
|
||||||
|
icon={(
|
||||||
|
<div className="w-5 h-5 rounded bg-brand-accent/10 flex items-center justify-center text-brand-accent shrink-0">
|
||||||
|
<Sparkles size={12} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
title={t('homeDashboard.aiFound')}
|
||||||
|
actions={(
|
||||||
|
<>
|
||||||
|
{newCount > 0 && (
|
||||||
|
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-2 py-0.5 rounded uppercase">
|
||||||
|
{newCount} {t('homeDashboard.new')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{aiActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefreshEcho}
|
||||||
|
disabled={echoRefreshing}
|
||||||
|
title={t('homeDashboard.analyzeNotes')}
|
||||||
|
className="p-1.5 rounded-lg border border-border/30 hover:border-brand-accent/40 hover:bg-brand-accent/5 transition-all disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{echoRefreshing
|
||||||
|
? <Loader2 size={11} className="animate-spin text-brand-accent" />
|
||||||
|
: <RefreshCw size={11} className="text-concrete hover:text-brand-accent" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-[220px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||||
|
) : !aiActive ? (
|
||||||
|
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
|
||||||
|
<p className="text-xs text-concrete leading-relaxed">
|
||||||
|
{!hasAiConsent
|
||||||
|
? t('homeDashboard.aiConsentRequired')
|
||||||
|
: !providerReady
|
||||||
|
? t('homeDashboard.aiProviderUnavailable')
|
||||||
|
: t('homeDashboard.memoryEchoDisabled')}
|
||||||
|
</p>
|
||||||
|
{!hasAiConsent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEnableAi}
|
||||||
|
className="text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 transition-opacity mx-auto"
|
||||||
|
>
|
||||||
|
{t('homeDashboard.enableAi')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : allItems.length === 0 ? (
|
||||||
|
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
|
||||||
|
<Brain size={22} className="mx-auto text-concrete/40" strokeWidth={1.25} />
|
||||||
|
<p className="text-xs text-concrete leading-relaxed">{t('homeDashboard.noConnections')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefreshEcho}
|
||||||
|
disabled={echoRefreshing}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-all disabled:opacity-40 mx-auto"
|
||||||
|
>
|
||||||
|
{echoRefreshing ? <Loader2 size={11} className="animate-spin" /> : <Sparkles size={11} />}
|
||||||
|
{t('homeDashboard.analyzeNotes')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Filtres */}
|
||||||
|
<div className="flex gap-1 overflow-x-auto custom-scrollbar pb-1 -mx-0.5 px-0.5 mb-3">
|
||||||
|
{filters.map(f => {
|
||||||
|
const count = counts[f.key]
|
||||||
|
if (f.key !== 'all' && count === 0) return null
|
||||||
|
const active = filter === f.key
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(f.key)}
|
||||||
|
className={`shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[8.5px] font-mono font-bold uppercase tracking-wider transition-all ${
|
||||||
|
active
|
||||||
|
? 'bg-ink text-white dark:bg-white dark:text-black shadow-sm'
|
||||||
|
: 'bg-stone-100 dark:bg-zinc-800 text-concrete hover:text-ink dark:hover:text-dark-ink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
{count > 0 && (
|
||||||
|
<span className={`text-[7px] px-1 py-px rounded-full ${active ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<p className="text-xs text-concrete italic text-center py-8">{t('homeDashboard.intelFilterEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Spotlight carousel */}
|
||||||
|
<div className="relative">
|
||||||
|
{filteredItems.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goPrev}
|
||||||
|
disabled={activeIndex === 0}
|
||||||
|
className="absolute start-0 top-1/2 -translate-y-1/2 -translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
|
||||||
|
aria-label={t('homeDashboard.intelPrev')}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={activeIndex >= filteredItems.length - 1}
|
||||||
|
className="absolute end-0 top-1/2 -translate-y-1/2 translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
|
||||||
|
aria-label={t('homeDashboard.intelNext')}
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`min-h-[200px] mx-5 p-4 rounded-2xl border bg-gradient-to-br ${spotlightAccent(activeItem)} transition-colors`}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{activeItem && (
|
||||||
|
<motion.div
|
||||||
|
key={activeItem.id}
|
||||||
|
initial={slideVariants.initial}
|
||||||
|
animate={slideVariants.animate}
|
||||||
|
exit={slideVariants.exit}
|
||||||
|
transition={{ duration: prefersReducedMotion ? 0 : 0.22, ease: 'easeOut' }}
|
||||||
|
className="min-h-[180px] flex flex-col"
|
||||||
|
>
|
||||||
|
{renderSpotlight(activeItem)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation dots + position */}
|
||||||
|
{filteredItems.length > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-3 mt-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{filteredItems.map((item, idx) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveIndex(idx)}
|
||||||
|
className={`rounded-full transition-all ${
|
||||||
|
idx === activeIndex
|
||||||
|
? 'w-5 h-1.5 bg-ochre'
|
||||||
|
: 'w-1.5 h-1.5 bg-concrete/25 hover:bg-concrete/50'
|
||||||
|
}`}
|
||||||
|
aria-label={t('homeDashboard.intelGoTo', { index: idx + 1 })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[8px] font-mono text-concrete/60 uppercase">
|
||||||
|
{t('homeDashboard.intelPosition', { current: activeIndex + 1, total: filteredItems.length })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File d'attente compacte */}
|
||||||
|
{filteredItems.length > 1 && (
|
||||||
|
<div className="flex gap-1.5 mt-3 overflow-x-auto custom-scrollbar pt-1">
|
||||||
|
{filteredItems.map((item, idx) => {
|
||||||
|
const isActive = idx === activeIndex
|
||||||
|
let label = ''
|
||||||
|
let Icon = Sparkles
|
||||||
|
let accent = 'text-indigo-500'
|
||||||
|
if (item.kind === 'insight') {
|
||||||
|
label = item.insight.note1.title?.slice(0, 28) || t('homeDashboard.untitled')
|
||||||
|
Icon = Sparkles
|
||||||
|
accent = 'text-indigo-500'
|
||||||
|
} else if (item.kind === 'bridge') {
|
||||||
|
label = item.bridge.note?.title?.slice(0, 28) || t('homeDashboard.untitled')
|
||||||
|
Icon = Zap
|
||||||
|
accent = 'text-ochre'
|
||||||
|
} else if (item.kind === 'suggestion') {
|
||||||
|
label = item.suggestion.suggestedTitle.slice(0, 28)
|
||||||
|
Icon = Lightbulb
|
||||||
|
accent = 'text-violet-500'
|
||||||
|
} else {
|
||||||
|
label = item.agent.agentName
|
||||||
|
Icon = Bot
|
||||||
|
accent = 'text-ochre'
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveIndex(idx)}
|
||||||
|
className={`shrink-0 flex items-center gap-1.5 px-2 py-1.5 rounded-lg border text-start max-w-[130px] transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'border-ochre/40 bg-ochre/5 shadow-sm'
|
||||||
|
: 'border-border/25 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-border/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={9} className={`shrink-0 ${accent}`} />
|
||||||
|
<span className="text-[8px] font-medium text-ink dark:text-dark-ink truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
|
|||||||
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
|
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
|
||||||
import '@excalidraw/excalidraw/index.css'
|
import '@excalidraw/excalidraw/index.css'
|
||||||
import type { PresentationSpec } from '@/lib/types/presentation'
|
import type { PresentationSpec } from '@/lib/types/presentation'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
|
function SlidesLoadingFallback() {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">{t('lab.loadingSlides')}</div>
|
||||||
|
}
|
||||||
|
|
||||||
const Excalidraw = dynamic(
|
const Excalidraw = dynamic(
|
||||||
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
|
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
|
||||||
@@ -18,7 +24,7 @@ const Excalidraw = dynamic(
|
|||||||
|
|
||||||
const SlidesRenderer = dynamic(
|
const SlidesRenderer = dynamic(
|
||||||
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })),
|
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })),
|
||||||
{ ssr: false, loading: () => <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">Chargement des slides…</div> }
|
{ ssr: false, loading: () => <SlidesLoadingFallback /> }
|
||||||
)
|
)
|
||||||
|
|
||||||
interface CanvasBoardProps {
|
interface CanvasBoardProps {
|
||||||
@@ -62,6 +68,7 @@ function parseCanvasScene(initialData?: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
try {
|
try {
|
||||||
const byteCharacters = atob(data.base64)
|
const byteCharacters = atob(data.base64)
|
||||||
@@ -83,7 +90,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[PptxViewer] Download failed:', e)
|
console.error('[PptxViewer] Download failed:', e)
|
||||||
toast.error('Failed to download presentation')
|
toast.error(t('lab.downloadFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +113,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
|||||||
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-colors font-medium shadow-sm"
|
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-colors font-medium shadow-sm"
|
||||||
>
|
>
|
||||||
<Download className="w-5 h-5" />
|
<Download className="w-5 h-5" />
|
||||||
Download .pptx
|
{t('lab.exportPpt')}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-muted-foreground max-w-sm text-center">
|
<p className="text-xs text-muted-foreground max-w-sm text-center">
|
||||||
This file can be opened in Microsoft PowerPoint, Google Slides, or Keynote.
|
This file can be opened in Microsoft PowerPoint, Google Slides, or Keynote.
|
||||||
@@ -117,6 +124,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
|
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
const [currentSlide, setCurrentSlide] = useState(1)
|
const [currentSlide, setCurrentSlide] = useState(1)
|
||||||
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
|
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
|
||||||
@@ -194,27 +202,27 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
|||||||
href={`/api/canvas/slides/pptx?id=${canvasId}`}
|
href={`/api/canvas/slides/pptx?id=${canvasId}`}
|
||||||
download
|
download
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
||||||
title="Exporter en PowerPoint"
|
title={t('lab.exportPpt')}
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
Export PPTX
|
{t('lab.exportPpt')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={downloadHtml}
|
onClick={downloadHtml}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
||||||
title="Télécharger le HTML"
|
title={t('lab.downloadHtml')}
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
Export HTML
|
{t('lab.downloadHtml')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={openFullscreen}
|
onClick={openFullscreen}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
||||||
title="Ouvrir en plein écran"
|
title={t('lab.openFullscreen')}
|
||||||
>
|
>
|
||||||
<Maximize2 className="w-3.5 h-3.5" />
|
<Maximize2 className="w-3.5 h-3.5" />
|
||||||
Plein écran
|
{t('lab.openFullscreen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +236,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
|||||||
{!isLoaded && (
|
{!isLoaded && (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
|
||||||
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
|
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
|
||||||
<span className="text-xs text-white/30">Chargement de la présentation…</span>
|
<span className="text-xs text-white/30">{t('lab.loadingPresentation')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<iframe
|
<iframe
|
||||||
@@ -244,7 +252,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
|||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||||
aria-label="Slide précédent"
|
aria-label={t('lab.previousSlide')}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -252,7 +260,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
|||||||
<button
|
<button
|
||||||
onClick={() => navigate(1)}
|
onClick={() => navigate(1)}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||||
aria-label="Slide suivant"
|
aria-label={t('lab.nextSlide')}
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,10 +11,16 @@ import {
|
|||||||
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||||
FunnelChart, Funnel, LabelList,
|
FunnelChart, Funnel, LabelList,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
|
function MermaidLoadingFallback() {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
return <div style={{ color: 'rgba(255,255,255,0.3)', padding: 24 }}>{t('common.loading')}</div>
|
||||||
|
}
|
||||||
|
|
||||||
const MermaidDiagram = dynamic(
|
const MermaidDiagram = dynamic(
|
||||||
() => import('./mermaid-diagram').then(m => ({ default: m.MermaidDiagram })),
|
() => import('./mermaid-diagram').then(m => ({ default: m.MermaidDiagram })),
|
||||||
{ ssr: false, loading: () => <div style={{ color: 'rgba(255,255,255,0.3)', padding: 24 }}>Chargement…</div> }
|
{ ssr: false, loading: () => <MermaidLoadingFallback /> }
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||||
@@ -688,6 +694,7 @@ export interface SlidesRendererProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SlidesRenderer({ spec }: SlidesRendererProps) {
|
export function SlidesRenderer({ spec }: SlidesRendererProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [current, setCurrent] = useState(0)
|
const [current, setCurrent] = useState(0)
|
||||||
const [showNotes, setShowNotes] = useState(false)
|
const [showNotes, setShowNotes] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -795,12 +802,12 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
|
|||||||
|
|
||||||
{/* Navigation buttons */}
|
{/* Navigation buttons */}
|
||||||
{current > 0 && (
|
{current > 0 && (
|
||||||
<button style={navBtn(true)} onClick={prev} aria-label="Précédent">
|
<button style={navBtn(true)} onClick={prev} aria-label={t('lab.previous')}>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{current < total - 1 && (
|
{current < total - 1 && (
|
||||||
<button style={navBtn(false)} onClick={next} aria-label="Suivant">
|
<button style={navBtn(false)} onClick={next} aria-label={t('lab.next')}>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 6 15 12 9 18" /></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 6 15 12 9 18" /></svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -830,12 +837,12 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
|
|||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: palette.accent }}>
|
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: palette.accent }}>
|
||||||
Notes de présentation
|
{t('lab.notes')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNotes(false)}
|
onClick={() => setShowNotes(false)}
|
||||||
style={{ background: 'none', border: 'none', color: isDark ? '#94a3b8' : '#64748b', cursor: 'pointer', fontSize: 13, padding: 4 }}
|
style={{ background: 'none', border: 'none', color: isDark ? '#94a3b8' : '#64748b', cursor: 'pointer', fontSize: 13, padding: 4 }}
|
||||||
title="Masquer les notes"
|
title={t('lab.hideNotes')}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -860,10 +867,10 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
|
|||||||
display: 'flex', alignItems: 'center', gap: 6, transition: 'all 0.2s',
|
display: 'flex', alignItems: 'center', gap: 6, transition: 'all 0.2s',
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(4px)',
|
||||||
}}
|
}}
|
||||||
title="Bascule des notes de présentation (Raccourci: N)"
|
title={t('lab.toggleNotes')}
|
||||||
>
|
>
|
||||||
<span>📝</span>
|
<span>📝</span>
|
||||||
<span>Notes</span>
|
<span>{t('lab.notes')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.35)', background: isDark ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.8)', padding: '3px 10px', borderRadius: 100, backdropFilter: 'blur(4px)', border: `1px solid ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
|
<div style={{ fontSize: 12, fontWeight: 600, color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.35)', background: isDark ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.8)', padding: '3px 10px', borderRadius: 100, backdropFilter: 'blur(4px)', border: `1px solid ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function MobileActionSheet({
|
|||||||
const end = $pos.end(1)
|
const end = $pos.end(1)
|
||||||
|
|
||||||
editor.chain().focus().setTextSelection({ from: start, to: end }).run()
|
editor.chain().focus().setTextSelection({ from: start, to: end }).run()
|
||||||
toast.success(t('richTextEditor.blockSelected') || 'Bloc sélectionné en entier')
|
toast.success(t('richTextEditor.blockSelected'))
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export function MobileActionSheet({
|
|||||||
const nodeText = editor.state.doc.slice(start, end)
|
const nodeText = editor.state.doc.slice(start, end)
|
||||||
|
|
||||||
editor.chain().focus().insertContentAt(end, nodeText.content.toJSON()).run()
|
editor.chain().focus().insertContentAt(end, nodeText.content.toJSON()).run()
|
||||||
toast.success(t('richTextEditor.blockDuplicated') || 'Bloc dupliqué')
|
toast.success(t('richTextEditor.blockDuplicated'))
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export function MobileActionSheet({
|
|||||||
const start = $pos.before(1)
|
const start = $pos.before(1)
|
||||||
const end = $pos.after(1)
|
const end = $pos.after(1)
|
||||||
editor.chain().focus().deleteRange({ from: start, to: end }).run()
|
editor.chain().focus().deleteRange({ from: start, to: end }).run()
|
||||||
toast.success(t('richTextEditor.blockDeleted') || 'Bloc supprimé')
|
toast.success(t('richTextEditor.blockDeleted'))
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export function MobileActionSheet({
|
|||||||
onClose()
|
onClose()
|
||||||
const tab = action === 'improve' ? 'actions' : 'chat'
|
const tab = action === 'improve' ? 'actions' : 'chat'
|
||||||
window.dispatchEvent(new CustomEvent('memento-open-ai', { detail: { tab, scroll: action } }))
|
window.dispatchEvent(new CustomEvent('memento-open-ai', { detail: { tab, scroll: action } }))
|
||||||
toast.info(t('richTextEditor.aiActionStarted') || 'IA Note sollicitée...')
|
toast.info(t('richTextEditor.aiActionStarted'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -101,26 +101,26 @@ export function MobileActionSheet({
|
|||||||
<div className="mobile-action-sheet-body">
|
<div className="mobile-action-sheet-body">
|
||||||
{/* Section 1 : Actions de bloc */}
|
{/* Section 1 : Actions de bloc */}
|
||||||
<div className="mobile-action-sheet-section">
|
<div className="mobile-action-sheet-section">
|
||||||
<h4 className="section-title">{t('mobile.blockActions') || 'Actions sur le bloc'}</h4>
|
<h4 className="section-title">{t('mobile.blockActions')}</h4>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<button type="button" className="action-tile-btn" onClick={handleSelectAllBlock}>
|
<button type="button" className="action-tile-btn" onClick={handleSelectAllBlock}>
|
||||||
<FileText size={20} className="text-blue-500" />
|
<FileText size={20} className="text-blue-500" />
|
||||||
<span>{t('mobile.selectAll') || 'Sélectionner tout'}</span>
|
<span>{t('mobile.selectAll')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="action-tile-btn" onClick={handleDuplicateBlock}>
|
<button type="button" className="action-tile-btn" onClick={handleDuplicateBlock}>
|
||||||
<Copy size={20} className="text-emerald-500" />
|
<Copy size={20} className="text-emerald-500" />
|
||||||
<span>{t('mobile.duplicate') || 'Dupliquer'}</span>
|
<span>{t('mobile.duplicate')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="action-tile-btn text-rose-500" onClick={handleDeleteBlock}>
|
<button type="button" className="action-tile-btn text-rose-500" onClick={handleDeleteBlock}>
|
||||||
<Trash2 size={20} className="text-rose-500" />
|
<Trash2 size={20} className="text-rose-500" />
|
||||||
<span>{t('mobile.delete') || 'Supprimer'}</span>
|
<span>{t('mobile.delete')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 2 : IA Note */}
|
{/* Section 2 : IA Note */}
|
||||||
<div className="mobile-action-sheet-section">
|
<div className="mobile-action-sheet-section">
|
||||||
<h4 className="section-title">{t('mobile.aiNote') || 'IA Note'}</h4>
|
<h4 className="section-title">{t('mobile.aiNote')}</h4>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
<button type="button" className="action-tile-btn" onClick={() => handleAiAction('improve')}>
|
<button type="button" className="action-tile-btn" onClick={() => handleAiAction('improve')}>
|
||||||
<Wand2 size={18} className="text-purple-500 animate-pulse" />
|
<Wand2 size={18} className="text-purple-500 animate-pulse" />
|
||||||
@@ -143,42 +143,42 @@ export function MobileActionSheet({
|
|||||||
|
|
||||||
{/* Section 3 : Format de bloc */}
|
{/* Section 3 : Format de bloc */}
|
||||||
<div className="mobile-action-sheet-section">
|
<div className="mobile-action-sheet-section">
|
||||||
<h4 className="section-title">{t('mobile.convertFormat') || 'Convertir le format'}</h4>
|
<h4 className="section-title">{t('mobile.convertFormat')}</h4>
|
||||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
|
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="format-pill-btn"
|
className="format-pill-btn"
|
||||||
onClick={() => { editor.chain().focus().setParagraph().run(); onClose() }}
|
onClick={() => { editor.chain().focus().setParagraph().run(); onClose() }}
|
||||||
>
|
>
|
||||||
{t('mobile.paragraph') || 'Paragraphe'}
|
{t('mobile.paragraph')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="format-pill-btn"
|
className="format-pill-btn"
|
||||||
onClick={() => { editor.chain().focus().toggleHeading({ level: 1 }).run(); onClose() }}
|
onClick={() => { editor.chain().focus().toggleHeading({ level: 1 }).run(); onClose() }}
|
||||||
>
|
>
|
||||||
<Heading1 size={14} className="inline mr-1" /> {t('notes.heading1') || 'Titre 1'}
|
<Heading1 size={14} className="inline mr-1" /> {t('notes.heading1')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="format-pill-btn"
|
className="format-pill-btn"
|
||||||
onClick={() => { editor.chain().focus().toggleHeading({ level: 2 }).run(); onClose() }}
|
onClick={() => { editor.chain().focus().toggleHeading({ level: 2 }).run(); onClose() }}
|
||||||
>
|
>
|
||||||
<Heading2 size={14} className="inline mr-1" /> {t('notes.heading2') || 'Titre 2'}
|
<Heading2 size={14} className="inline mr-1" /> {t('notes.heading2')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="format-pill-btn"
|
className="format-pill-btn"
|
||||||
onClick={() => { editor.chain().focus().toggleHeading({ level: 3 }).run(); onClose() }}
|
onClick={() => { editor.chain().focus().toggleHeading({ level: 3 }).run(); onClose() }}
|
||||||
>
|
>
|
||||||
<Heading3 size={14} className="inline mr-1" /> {t('notes.heading3') || 'Titre 3'}
|
<Heading3 size={14} className="inline mr-1" /> {t('notes.heading3')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="format-pill-btn"
|
className="format-pill-btn"
|
||||||
onClick={() => { editor.chain().focus().toggleBlockquote().run(); onClose() }}
|
onClick={() => { editor.chain().focus().toggleBlockquote().run(); onClose() }}
|
||||||
>
|
>
|
||||||
<Quote size={14} className="inline mr-1" /> {t('mobile.quote') || 'Citation'}
|
<Quote size={14} className="inline mr-1" /> {t('mobile.quote')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Heading, Code, Sparkles, MessageSquare, Quote, AlignLeft
|
Heading, Code, Sparkles, MessageSquare, Quote, AlignLeft
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
export type MobileEditorToolbarProps = {
|
export type MobileEditorToolbarProps = {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
@@ -19,6 +20,7 @@ export function MobileEditorToolbar({
|
|||||||
onOpenActionSheet,
|
onOpenActionSheet,
|
||||||
onInsertImage,
|
onInsertImage,
|
||||||
}: MobileEditorToolbarProps) {
|
}: MobileEditorToolbarProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
if (!editor) return null
|
if (!editor) return null
|
||||||
|
|
||||||
// Format states
|
// Format states
|
||||||
@@ -61,7 +63,7 @@ export function MobileEditorToolbar({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn('mobile-toolbar-btn', isBold && 'active')}
|
className={cn('mobile-toolbar-btn', isBold && 'active')}
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
aria-label="Bold"
|
aria-label={t('editor.bold')}
|
||||||
>
|
>
|
||||||
<Bold size={18} />
|
<Bold size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -70,7 +72,7 @@ export function MobileEditorToolbar({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn('mobile-toolbar-btn', isItalic && 'active')}
|
className={cn('mobile-toolbar-btn', isItalic && 'active')}
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
aria-label="Italic"
|
aria-label={t('editor.italic')}
|
||||||
>
|
>
|
||||||
<Italic size={18} />
|
<Italic size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -79,7 +81,7 @@ export function MobileEditorToolbar({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn('mobile-toolbar-btn', isHighlight && 'active')}
|
className={cn('mobile-toolbar-btn', isHighlight && 'active')}
|
||||||
onClick={() => editor.chain().focus().toggleHighlight().run()}
|
onClick={() => editor.chain().focus().toggleHighlight().run()}
|
||||||
aria-label="Highlight"
|
aria-label={t('editor.highlight')}
|
||||||
>
|
>
|
||||||
<Highlighter size={18} />
|
<Highlighter size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -88,7 +90,7 @@ export function MobileEditorToolbar({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn('mobile-toolbar-btn', isLink && 'active')}
|
className={cn('mobile-toolbar-btn', isLink && 'active')}
|
||||||
onClick={handleLinkPress}
|
onClick={handleLinkPress}
|
||||||
aria-label="Link"
|
aria-label={t('editor.link')}
|
||||||
>
|
>
|
||||||
<Link2 size={18} />
|
<Link2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -97,7 +99,7 @@ export function MobileEditorToolbar({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn('mobile-toolbar-btn', isBulletList && 'active')}
|
className={cn('mobile-toolbar-btn', isBulletList && 'active')}
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
aria-label="Bullet List"
|
aria-label={t('editor.bulletList')}
|
||||||
>
|
>
|
||||||
<List size={18} />
|
<List size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -106,7 +108,7 @@ export function MobileEditorToolbar({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn('mobile-toolbar-btn', isTaskList && 'active')}
|
className={cn('mobile-toolbar-btn', isTaskList && 'active')}
|
||||||
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||||
aria-label="Task List"
|
aria-label={t('editor.taskList')}
|
||||||
>
|
>
|
||||||
<CheckSquare size={18} />
|
<CheckSquare size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -115,7 +117,7 @@ export function MobileEditorToolbar({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn('mobile-toolbar-btn', isHeading && 'active')}
|
className={cn('mobile-toolbar-btn', isHeading && 'active')}
|
||||||
onClick={toggleHeadingCycle}
|
onClick={toggleHeadingCycle}
|
||||||
aria-label="Heading"
|
aria-label={t('editor.heading')}
|
||||||
>
|
>
|
||||||
<Heading size={18} />
|
<Heading size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -124,7 +126,7 @@ export function MobileEditorToolbar({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn('mobile-toolbar-btn', isCodeBlock && 'active')}
|
className={cn('mobile-toolbar-btn', isCodeBlock && 'active')}
|
||||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
aria-label="Code Block"
|
aria-label={t('editor.codeBlock')}
|
||||||
>
|
>
|
||||||
<Code size={18} />
|
<Code size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -133,7 +135,7 @@ export function MobileEditorToolbar({
|
|||||||
type="button"
|
type="button"
|
||||||
className="mobile-toolbar-btn highlight-btn"
|
className="mobile-toolbar-btn highlight-btn"
|
||||||
onClick={onOpenActionSheet}
|
onClick={onOpenActionSheet}
|
||||||
aria-label="Plus"
|
aria-label={t('editor.plus')}
|
||||||
>
|
>
|
||||||
<Sparkles size={18} className="text-amber-500 animate-pulse" />
|
<Sparkles size={18} className="text-amber-500 animate-pulse" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
fetch('/api/graph')
|
fetch('/api/graph')
|
||||||
.then(r => { if (!r.ok) throw new Error('Erreur réseau'); return r.json() })
|
.then(r => { if (!r.ok) throw new Error(t('errors.network')); return r.json() })
|
||||||
.then(d => setRawData(d))
|
.then(d => setRawData(d))
|
||||||
.catch(e => setError(e.message))
|
.catch(e => setError(e.message))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ function NoteGridThumbnail({
|
|||||||
await updateNote(note.id, { illustrationSvg: null })
|
await updateNote(note.id, { illustrationSvg: null })
|
||||||
await onNoteIllustrationDeleted?.(note.id)
|
await onNoteIllustrationDeleted?.(note.id)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Erreur lors de la suppression')
|
toast.error(t('notes.deleteError'))
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function OrganizeNotebookDialog({
|
|||||||
const handleAnalyze = useCallback(async () => {
|
const handleAnalyze = useCallback(async () => {
|
||||||
setStep('analyzing'); setError(null); setPlan(null)
|
setStep('analyzing'); setError(null); setPlan(null)
|
||||||
const res = await analyzeNotebookForOrganization(notebookId, language)
|
const res = await analyzeNotebookForOrganization(notebookId, language)
|
||||||
if (!res.success || !res.plan) { setError(res.error ?? 'Erreur'); setStep('idle'); return }
|
if (!res.success || !res.plan) { setError(res.error ?? t('common.error')); setStep('idle'); return }
|
||||||
setPlan(res.plan)
|
setPlan(res.plan)
|
||||||
setEditableGroups(res.plan.groups.map(g => ({ ...g, notes: [...g.notes] })))
|
setEditableGroups(res.plan.groups.map(g => ({ ...g, notes: [...g.notes] })))
|
||||||
setExpandedGroups(new Set(res.plan.groups.map((_, i) => i)))
|
setExpandedGroups(new Set(res.plan.groups.map((_, i) => i)))
|
||||||
@@ -74,7 +74,7 @@ export function OrganizeNotebookDialog({
|
|||||||
groups: editableGroups.filter(g => g.notes.length > 0 && g.name.trim()),
|
groups: editableGroups.filter(g => g.notes.length > 0 && g.name.trim()),
|
||||||
}
|
}
|
||||||
const res = await executeNotebookOrganization(finalPlan)
|
const res = await executeNotebookOrganization(finalPlan)
|
||||||
if (!res.success) { setError(res.error ?? 'Erreur'); setStep('preview'); return }
|
if (!res.success) { setError(res.error ?? t('common.error')); setStep('preview'); return }
|
||||||
setResult({ created: res.created, moved: res.moved })
|
setResult({ created: res.created, moved: res.moved })
|
||||||
setStep('done')
|
setStep('done')
|
||||||
toast.success(t('organizeNotebook.toastSuccess', { created: res.created, moved: res.moved }))
|
toast.success(t('organizeNotebook.toastSuccess', { created: res.created, moved: res.moved }))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { MarkdownContent } from '@/components/markdown-content'
|
import { MarkdownContent } from '@/components/markdown-content'
|
||||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
// ── Personas definition ──────────────────────────────────────────────────────
|
// ── Personas definition ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ interface PersonasPanelProps {
|
|||||||
|
|
||||||
export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
|
export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
|
||||||
const { requestAiConsent } = useAiConsent()
|
const { requestAiConsent } = useAiConsent()
|
||||||
|
const { t } = useLanguage()
|
||||||
const [loadingId, setLoadingId] = useState<PersonaId | null>(null)
|
const [loadingId, setLoadingId] = useState<PersonaId | null>(null)
|
||||||
const [results, setResults] = useState<Map<PersonaId, PersonaResult>>(new Map())
|
const [results, setResults] = useState<Map<PersonaId, PersonaResult>>(new Map())
|
||||||
const [expanded, setExpanded] = useState<PersonaId | null>(null)
|
const [expanded, setExpanded] = useState<PersonaId | null>(null)
|
||||||
@@ -122,7 +124,7 @@ export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
|
|||||||
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
|
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'Erreur')
|
if (!res.ok) throw new Error(data.error || t('common.error'))
|
||||||
setResults(prev => new Map(prev).set(personaId, data))
|
setResults(prev => new Map(prev).set(personaId, data))
|
||||||
setExpanded(personaId)
|
setExpanded(personaId)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ export function ProvidersWrapper({
|
|||||||
return (
|
return (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<NoteRefreshProvider>
|
<NoteRefreshProvider>
|
||||||
<NotebooksProvider>
|
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
|
||||||
<EditorUIProvider>
|
<NotebooksProvider>
|
||||||
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
|
<EditorUIProvider>
|
||||||
<AiConsentProvider initialPersistentConsent={initialAiProcessingConsent}>
|
<AiConsentProvider initialPersistentConsent={initialAiProcessingConsent}>
|
||||||
<DirWrapper>
|
<DirWrapper>
|
||||||
<SearchModalProvider>
|
<SearchModalProvider>
|
||||||
@@ -49,9 +49,9 @@ export function ProvidersWrapper({
|
|||||||
</SearchModalProvider>
|
</SearchModalProvider>
|
||||||
</DirWrapper>
|
</DirWrapper>
|
||||||
</AiConsentProvider>
|
</AiConsentProvider>
|
||||||
</LanguageProvider>
|
</EditorUIProvider>
|
||||||
</EditorUIProvider>
|
</NotebooksProvider>
|
||||||
</NotebooksProvider>
|
</LanguageProvider>
|
||||||
</NoteRefreshProvider>
|
</NoteRefreshProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,12 +35,13 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
LayoutGrid,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useSearchModal } from '@/context/search-modal-context'
|
import { useSearchModal } from '@/context/search-modal-context'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { applyDocumentTheme } from '@/lib/apply-document-theme'
|
import { applyDocumentTheme } from '@/lib/apply-document-theme'
|
||||||
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
|
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone, getInboxCount } from '@/app/actions/notes'
|
||||||
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { Notebook, Note } from '@/lib/types'
|
import { Notebook, Note } from '@/lib/types'
|
||||||
@@ -62,9 +63,10 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { performSignOut } from '@/lib/auth-client'
|
import { performSignOut } from '@/lib/auth-client'
|
||||||
|
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
|
||||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||||
|
|
||||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
|
type NavigationView = 'dashboard' | 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
|
||||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||||
|
|
||||||
const NOTEBOOKS_PANEL_HEIGHT_KEY = 'memento-sidebar-notebooks-height'
|
const NOTEBOOKS_PANEL_HEIGHT_KEY = 'memento-sidebar-notebooks-height'
|
||||||
@@ -639,6 +641,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
const [showSortMenu, setShowSortMenu] = useState(false)
|
const [showSortMenu, setShowSortMenu] = useState(false)
|
||||||
const [notebookSearchQuery, setNotebookSearchQuery] = useState('')
|
const [notebookSearchQuery, setNotebookSearchQuery] = useState('')
|
||||||
const [trashCount, setTrashCount] = useState(0)
|
const [trashCount, setTrashCount] = useState(0)
|
||||||
|
const [inboxCount, setInboxCount] = useState(0)
|
||||||
|
|
||||||
const notebooksContainerRef = useRef<HTMLDivElement>(null)
|
const notebooksContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const notebooksPanelRef = useRef<HTMLDivElement>(null)
|
const notebooksPanelRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -804,8 +807,11 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
})
|
})
|
||||||
}, [currentNotebookId, notebooks])
|
}, [currentNotebookId, notebooks])
|
||||||
|
|
||||||
|
const isDashboardRoute = isDashboardHomeRoute(pathname, searchParams)
|
||||||
|
|
||||||
const isInboxActive =
|
const isInboxActive =
|
||||||
pathname === '/home' &&
|
pathname === '/home' &&
|
||||||
|
searchParams.get('forceList') === '1' &&
|
||||||
!searchParams.get('notebook') &&
|
!searchParams.get('notebook') &&
|
||||||
!searchParams.get('labels') &&
|
!searchParams.get('labels') &&
|
||||||
!searchParams.get('archived') &&
|
!searchParams.get('archived') &&
|
||||||
@@ -817,8 +823,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
else if (pathname === '/insights') setActiveView('insights')
|
else if (pathname === '/insights') setActiveView('insights')
|
||||||
else if (pathname.startsWith('/revision')) setActiveView('revision')
|
else if (pathname.startsWith('/revision')) setActiveView('revision')
|
||||||
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
|
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
|
||||||
|
else if (isDashboardRoute) setActiveView('dashboard')
|
||||||
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
|
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
|
||||||
}, [pathname, searchParams])
|
}, [pathname, searchParams, isDashboardRoute])
|
||||||
|
|
||||||
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
|
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
|
||||||
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
|
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
|
||||||
@@ -848,7 +855,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
(pathname === '/home' || pathname.startsWith('/notes')) &&
|
(pathname === '/home' || pathname.startsWith('/notes')) &&
|
||||||
!pathname.startsWith('/settings') &&
|
!pathname.startsWith('/settings') &&
|
||||||
!isRemindersRoute &&
|
!isRemindersRoute &&
|
||||||
!isSharedRoute
|
!isSharedRoute &&
|
||||||
|
!isDashboardRoute
|
||||||
|
|
||||||
const displayName = user?.name || user?.email || ''
|
const displayName = user?.name || user?.email || ''
|
||||||
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
|
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
|
||||||
@@ -872,6 +880,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
getTrashCount().then(count => { if (!cancelled) setTrashCount(count) })
|
getTrashCount().then(count => { if (!cancelled) setTrashCount(count) })
|
||||||
|
getInboxCount().then((count: number) => { if (!cancelled) setInboxCount(count) })
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -902,6 +911,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onNoteChange = (e: Event) => {
|
const onNoteChange = (e: Event) => {
|
||||||
const detail = (e as CustomEvent<NoteChangeEvent>).detail
|
const detail = (e as CustomEvent<NoteChangeEvent>).detail
|
||||||
|
getInboxCount().then((count: number) => setInboxCount(count))
|
||||||
if (detail.type === 'deleted') {
|
if (detail.type === 'deleted') {
|
||||||
setNotebookNotes((prev) => {
|
setNotebookNotes((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
@@ -1340,6 +1350,16 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
{/* Boutons de navigation principaux */}
|
{/* Boutons de navigation principaux */}
|
||||||
<div className="flex flex-col gap-2 w-full px-1.5">
|
<div className="flex flex-col gap-2 w-full px-1.5">
|
||||||
{([
|
{([
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
icon: LayoutGrid,
|
||||||
|
label: t('nav.dashboard') || 'Dashboard',
|
||||||
|
onClick: () => {
|
||||||
|
setActiveView('dashboard')
|
||||||
|
router.push('/home')
|
||||||
|
},
|
||||||
|
isActive: isDashboardRoute,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'notebooks',
|
id: 'notebooks',
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
@@ -1517,7 +1537,78 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
>
|
>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{activeView === 'notebooks' ? (
|
{activeView === 'dashboard' ? (
|
||||||
|
<motion.div
|
||||||
|
key="dashboard"
|
||||||
|
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="px-4 pt-4 space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<LayoutGrid size={14} className="text-brand-accent" />
|
||||||
|
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
||||||
|
{t('nav.dashboard')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
||||||
|
{t('sidebar.dashboardPanelBody')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleInboxClick}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
|
<Inbox size={14} className="text-brand-accent shrink-0" />
|
||||||
|
{t('homeDashboard.inbox')}
|
||||||
|
</span>
|
||||||
|
{inboxCount > 0 && (
|
||||||
|
<span className="text-[10px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">
|
||||||
|
{inboxCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/revision')}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||||
|
>
|
||||||
|
<GraduationCap size={14} className="text-brand-accent shrink-0" />
|
||||||
|
{t('homeDashboard.review')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemindersClick}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||||
|
>
|
||||||
|
<Bell size={14} className="text-brand-accent shrink-0" />
|
||||||
|
{t('homeDashboard.reminders')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/insights')}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} className="text-brand-accent shrink-0" />
|
||||||
|
{t('homeDashboard.themes')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveView('notebooks')
|
||||||
|
router.push('/home?forceList=1')
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||||
|
>
|
||||||
|
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
||||||
|
{t('sidebar.backToNotebooks')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : activeView === 'notebooks' ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="notebooks"
|
key="notebooks"
|
||||||
ref={notebooksContainerRef}
|
ref={notebooksContainerRef}
|
||||||
@@ -1641,12 +1732,22 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[13px] font-medium truncate',
|
'text-[13px] font-medium truncate flex-1',
|
||||||
isInboxActive ? 'text-ink' : 'text-muted-ink',
|
isInboxActive ? 'text-ink' : 'text-muted-ink',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t('sidebar.inbox')}
|
{t('sidebar.inbox')}
|
||||||
</span>
|
</span>
|
||||||
|
{inboxCount > 0 && (
|
||||||
|
<span className={cn(
|
||||||
|
'text-[10px] font-bold min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full transition-colors',
|
||||||
|
isInboxActive
|
||||||
|
? 'bg-brand-accent text-white'
|
||||||
|
: 'bg-brand-accent/10 text-brand-accent',
|
||||||
|
)}>
|
||||||
|
{inboxCount > 99 ? '99+' : inboxCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { NoteChartFromCode } from './note-chart'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Code, BarChart3, AlertCircle } from 'lucide-react'
|
import { Code, BarChart3, AlertCircle } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ChartExtension - TipTap Node extension for rendering chart blocks
|
* ChartExtension - TipTap Node extension for rendering chart blocks
|
||||||
@@ -107,6 +108,7 @@ export const ChartExtension = Node.create({
|
|||||||
* - Error handling for invalid chart data
|
* - Error handling for invalid chart data
|
||||||
*/
|
*/
|
||||||
function ChartBlockView(props: any) {
|
function ChartBlockView(props: any) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [parseError, setParseError] = useState(false)
|
const [parseError, setParseError] = useState(false)
|
||||||
|
|
||||||
@@ -120,7 +122,7 @@ function ChartBlockView(props: any) {
|
|||||||
<NodeViewWrapper className="notion-chart-code-block my-4">
|
<NodeViewWrapper className="notion-chart-code-block my-4">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border-b border-border">
|
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border-b border-border">
|
||||||
<Code className="w-4 h-4 text-muted-foreground" />
|
<Code className="w-4 h-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">Chart Code</span>
|
<span className="text-sm text-muted-foreground">{t('chart.code')}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="ml-auto text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
className="ml-auto text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||||
@@ -144,8 +146,8 @@ function ChartBlockView(props: any) {
|
|||||||
<div className="flex items-center gap-3 text-muted-foreground">
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
<AlertCircle className="w-5 h-5" />
|
<AlertCircle className="w-5 h-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Invalid Chart</p>
|
<p className="font-medium">{t('chart.invalid')}</p>
|
||||||
<p className="text-sm">This chart block contains invalid or empty data.</p>
|
<p className="text-sm">{t('chart.invalidDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +160,7 @@ function ChartBlockView(props: any) {
|
|||||||
'absolute top-2 right-2 p-2 rounded-lg bg-background/80 backdrop-blur border border-border shadow-sm opacity-0 group-hover:opacity-100 transition-opacity',
|
'absolute top-2 right-2 p-2 rounded-lg bg-background/80 backdrop-blur border border-border shadow-sm opacity-0 group-hover:opacity-100 transition-opacity',
|
||||||
'hover:bg-background hover:shadow-md'
|
'hover:bg-background hover:shadow-md'
|
||||||
)}
|
)}
|
||||||
title="Edit chart source"
|
title={t('chart.editSource')}
|
||||||
>
|
>
|
||||||
<Code className="w-4 h-4 text-muted-foreground" />
|
<Code className="w-4 h-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getNoteById } from '@/app/actions/notes'
|
|||||||
import { useNoteRefresh } from './NoteRefreshContext'
|
import { useNoteRefresh } from './NoteRefreshContext'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { queryKeys } from '@/lib/query-keys'
|
import { queryKeys } from '@/lib/query-keys'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
// ===== INPUT TYPES =====
|
// ===== INPUT TYPES =====
|
||||||
export interface CreateNotebookInput {
|
export interface CreateNotebookInput {
|
||||||
@@ -101,6 +102,7 @@ interface NotebooksProviderProps {
|
|||||||
export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) {
|
export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) {
|
||||||
// ===== BASE STATE =====
|
// ===== BASE STATE =====
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const { t } = useLanguage()
|
||||||
const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks)
|
const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks)
|
||||||
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
|
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@@ -401,14 +403,12 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
|
|||||||
emitNoteChange({ type: 'updated', note: movedNote })
|
emitNoteChange({ type: 'updated', note: movedNote })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to move note. Please try again.')
|
toast.error(t('notes.moveFailed'))
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
setIsMovingNote(false)
|
setIsMovingNote(false)
|
||||||
}
|
}
|
||||||
}, [queryClient, triggerNotebooksRefresh])
|
}, [queryClient, triggerNotebooksRefresh, t])
|
||||||
|
|
||||||
// ===== ACTIONS: AI (STUBS) =====
|
|
||||||
const suggestNotebookForNote = useCallback(async (_noteContent: string) => {
|
const suggestNotebookForNote = useCallback(async (_noteContent: string) => {
|
||||||
// Stub pour l'instant - retourne null
|
// Stub pour l'instant - retourne null
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -117,17 +117,48 @@ fi
|
|||||||
# --- Background cron scheduler ---
|
# --- Background cron scheduler ---
|
||||||
(
|
(
|
||||||
sleep 60
|
sleep 60
|
||||||
|
CRON_SECRET="${CRON_SECRET:-}"
|
||||||
|
tick=0
|
||||||
while true; do
|
while true; do
|
||||||
node -e "
|
if [ -n "$CRON_SECRET" ]; then
|
||||||
const http = require('http');
|
CRON_TICK="$tick" node -e "
|
||||||
const req = http.request('http://localhost:3000/api/cron/agents', { method: 'POST' }, (res) => {
|
const http = require('http');
|
||||||
let body = '';
|
const secret = process.env.CRON_SECRET || '';
|
||||||
res.on('data', (d) => body += d);
|
const tick = Number(process.env.CRON_TICK || '0');
|
||||||
res.on('end', () => console.log('[Scheduler] Cron response:', res.statusCode, body));
|
const call = (path, method) => new Promise((resolve) => {
|
||||||
});
|
const req = http.request({
|
||||||
req.on('error', (e) => console.error('[Scheduler] Cron error:', e.message));
|
hostname: 'localhost',
|
||||||
req.end();
|
port: 3000,
|
||||||
" 2>&1 || true
|
path,
|
||||||
|
method: method || 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + secret },
|
||||||
|
}, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (d) => body += d);
|
||||||
|
res.on('end', () => { console.log('[Scheduler]', path, res.statusCode, body.slice(0, 200)); resolve(); });
|
||||||
|
});
|
||||||
|
req.on('error', (e) => { console.error('[Scheduler]', path, e.message); resolve(); });
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
(async () => {
|
||||||
|
await call('/api/cron/agents');
|
||||||
|
await call('/api/cron/agent-suggestions');
|
||||||
|
if (tick % 12 === 0) {
|
||||||
|
await call('/api/cron/reminders');
|
||||||
|
await call('/api/cron/sync-usage');
|
||||||
|
}
|
||||||
|
if (tick % 72 === 0) {
|
||||||
|
await call('/api/cron/clusters', 'GET');
|
||||||
|
}
|
||||||
|
if (tick % 288 === 0) {
|
||||||
|
await call('/api/cron/gmail');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
" 2>&1 || true
|
||||||
|
else
|
||||||
|
warn "CRON_SECRET unset — scheduled crons disabled"
|
||||||
|
fi
|
||||||
|
tick=$((tick + 1))
|
||||||
sleep 300
|
sleep 300
|
||||||
done
|
done
|
||||||
) &
|
) &
|
||||||
|
|||||||
116
memento-note/lib/ai/memory-echo-i18n.ts
Normal file
116
memento-note/lib/ai/memory-echo-i18n.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||||
|
|
||||||
|
/** Texte de secours quand le LLM ne renvoie rien — une entrée par locale supportée. */
|
||||||
|
export const MEMORY_ECHO_INSIGHT_FALLBACKS: Record<SupportedLanguage, string> = {
|
||||||
|
en: 'These notes appear to be semantically related.',
|
||||||
|
fr: 'Ces notes semblent sémantiquement liées.',
|
||||||
|
es: 'Estas notas parecen estar relacionadas semánticamente.',
|
||||||
|
de: 'Diese Notizen scheinen semantisch miteinander verbunden zu sein.',
|
||||||
|
it: 'Queste note sembrano semanticamente collegate.',
|
||||||
|
pt: 'Estas notas parecem estar semanticamente relacionadas.',
|
||||||
|
nl: 'Deze notities lijken semantisch met elkaar verbonden.',
|
||||||
|
pl: 'Te notatki wydają się semantycznie powiązane.',
|
||||||
|
ru: 'Эти заметки кажутся семантическement связанными.',
|
||||||
|
zh: '这些笔记在语义上似乎相关。',
|
||||||
|
ja: 'これらのノートは意味的に関連しているようです。',
|
||||||
|
ko: '이 노트들은 의미적으로 관련된 것으로 보입니다.',
|
||||||
|
ar: 'يبدو أن هذه الملاحظات مرتبطة دلاليًا.',
|
||||||
|
fa: 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.',
|
||||||
|
hi: 'ये नोट्स अर्थ के हिसाब से जुड़े हुए लगते हैं।',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anciens insights stockés en anglais — à remapper côté UI. */
|
||||||
|
export const MEMORY_ECHO_LEGACY_EN_FALLBACKS = new Set([
|
||||||
|
'These notes appear to be semantically related.',
|
||||||
|
])
|
||||||
|
|
||||||
|
const PROMPT_LANGUAGE_LABEL: Record<SupportedLanguage, string> = {
|
||||||
|
en: 'English',
|
||||||
|
fr: 'French',
|
||||||
|
es: 'Spanish',
|
||||||
|
de: 'German',
|
||||||
|
it: 'Italian',
|
||||||
|
pt: 'Portuguese',
|
||||||
|
nl: 'Dutch',
|
||||||
|
pl: 'Polish',
|
||||||
|
ru: 'Russian',
|
||||||
|
zh: 'Chinese',
|
||||||
|
ja: 'Japanese',
|
||||||
|
ko: 'Korean',
|
||||||
|
ar: 'Arabic',
|
||||||
|
fa: 'Persian',
|
||||||
|
hi: 'Hindi',
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNTITLED_NOTE: Record<SupportedLanguage, string> = {
|
||||||
|
en: 'Untitled note',
|
||||||
|
fr: 'Note sans titre',
|
||||||
|
es: 'Nota sin título',
|
||||||
|
de: 'Unbenannte Notiz',
|
||||||
|
it: 'Nota senza titolo',
|
||||||
|
pt: 'Nota sem título',
|
||||||
|
nl: 'Naamloze notitie',
|
||||||
|
pl: 'Notatka bez tytułu',
|
||||||
|
ru: 'Без названия',
|
||||||
|
zh: '无标题笔记',
|
||||||
|
ja: '無題のノート',
|
||||||
|
ko: '제목 없는 노트',
|
||||||
|
ar: 'ملاحظة بدون عنوان',
|
||||||
|
fa: 'یادداشت بدون عنوان',
|
||||||
|
hi: 'शीर्षक रहित नोट',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemoryEchoInsightFallback(language: SupportedLanguage): string {
|
||||||
|
return MEMORY_ECHO_INSIGHT_FALLBACKS[language] ?? MEMORY_ECHO_INSIGHT_FALLBACKS.en
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUntitledNoteLabel(language: SupportedLanguage): string {
|
||||||
|
return UNTITLED_NOTE[language] ?? UNTITLED_NOTE.en
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMemoryEchoInsightPrompt(
|
||||||
|
language: SupportedLanguage,
|
||||||
|
note1Title: string | null,
|
||||||
|
excerpt1: string,
|
||||||
|
note2Title: string | null,
|
||||||
|
excerpt2: string,
|
||||||
|
): string {
|
||||||
|
const untitled = getUntitledNoteLabel(language)
|
||||||
|
const note1Desc = note1Title || untitled
|
||||||
|
const note2Desc = note2Title || untitled
|
||||||
|
const langLabel = PROMPT_LANGUAGE_LABEL[language] ?? 'English'
|
||||||
|
|
||||||
|
if (language === 'fa' || language === 'ar') {
|
||||||
|
return `تو یک دستیار هستی که ارتباط بین یادداشتها را تحلیل میکنی.
|
||||||
|
|
||||||
|
یادداشت ۱: «${note1Desc}»
|
||||||
|
متن: ${excerpt1}
|
||||||
|
|
||||||
|
یادداشت ۲: «${note2Desc}»
|
||||||
|
متن: ${excerpt2}
|
||||||
|
|
||||||
|
در یک جمله کوتاه (حداکثر ۱۵ کلمه) به ${language === 'fa' ? 'فارسی' : 'العربية'} توضیح بده چرا این دو یادداشت به هم مرتبطاند. فقط رابطه معنایی را بگو.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (language === 'fr') {
|
||||||
|
return `Tu analyses les connexions entre notes.
|
||||||
|
|
||||||
|
Note 1 : « ${note1Desc} »
|
||||||
|
Contenu : ${excerpt1}
|
||||||
|
|
||||||
|
Note 2 : « ${note2Desc} »
|
||||||
|
Contenu : ${excerpt2}
|
||||||
|
|
||||||
|
En une phrase courte (15 mots max), en français, explique pourquoi ces notes sont liées. Concentre-toi sur le lien sémantique.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `You analyze connections between notes.
|
||||||
|
|
||||||
|
Note 1: "${note1Desc}"
|
||||||
|
Content: ${excerpt1}
|
||||||
|
|
||||||
|
Note 2: "${note2Desc}"
|
||||||
|
Content: ${excerpt2}
|
||||||
|
|
||||||
|
Reply in ${langLabel} only. One brief sentence (max 15 words) explaining the semantic relationship between these notes.`
|
||||||
|
}
|
||||||
216
memento-note/lib/ai/services/agent-suggestion.service.ts
Normal file
216
memento-note/lib/ai/services/agent-suggestion.service.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* Agent Suggestion Service
|
||||||
|
*
|
||||||
|
* Proposes research/monitor agents from existing semantic clusters (no duplicate DBSCAN).
|
||||||
|
* Runs after clustering; compares cluster themes vs existing agents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { clusteringService } from './clustering.service'
|
||||||
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { calculateNextRun } from '@/lib/agents/schedule'
|
||||||
|
|
||||||
|
const MIN_CLUSTER_NOTES = 3
|
||||||
|
const MAX_SUGGESTIONS_PER_RUN = 3
|
||||||
|
|
||||||
|
export interface AgentSuggestionPayload {
|
||||||
|
topic: string
|
||||||
|
reason: string
|
||||||
|
suggestedRole: string
|
||||||
|
suggestedType: string
|
||||||
|
suggestedFrequency: string
|
||||||
|
relatedNoteIds: string[]
|
||||||
|
clusterId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArray(raw: string | null | undefined): string[] {
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clusterCoveredByAgent(
|
||||||
|
topic: string,
|
||||||
|
noteIds: string[],
|
||||||
|
agent: { name: string; role: string; description: string | null; sourceNoteIds: string | null },
|
||||||
|
): boolean {
|
||||||
|
const topicLower = topic.toLowerCase()
|
||||||
|
const haystack = `${agent.name} ${agent.role} ${agent.description || ''}`.toLowerCase()
|
||||||
|
const topicWords = topicLower.split(/\s+/).filter(w => w.length > 3)
|
||||||
|
if (topicWords.some(w => haystack.includes(w))) return true
|
||||||
|
|
||||||
|
const agentNoteIds = parseJsonArray(agent.sourceNoteIds)
|
||||||
|
if (agentNoteIds.length === 0 || noteIds.length === 0) return false
|
||||||
|
const overlap = agentNoteIds.filter(id => noteIds.includes(id)).length
|
||||||
|
return overlap / noteIds.length >= 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSuggestionWithLlm(
|
||||||
|
topic: string,
|
||||||
|
noteTitles: string[],
|
||||||
|
noteCount: number,
|
||||||
|
): Promise<Pick<AgentSuggestionPayload, 'reason' | 'suggestedRole' | 'suggestedType' | 'suggestedFrequency'>> {
|
||||||
|
const fallback = {
|
||||||
|
reason: `${noteCount} notes récentes sur ce thème — aucun agent ne le couvre encore.`,
|
||||||
|
suggestedRole: `Recherche et synthétise les dernières informations sur « ${topic} ». Croise avec les notes existantes de l'utilisateur et produis un résumé actionnable.`,
|
||||||
|
suggestedType: 'researcher',
|
||||||
|
suggestedFrequency: 'weekly',
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getChatProvider(config)
|
||||||
|
if (!provider) return fallback
|
||||||
|
|
||||||
|
const prompt = `Tu proposes un agent IA pour un second cerveau (prise de notes).
|
||||||
|
Thème détecté: "${topic}"
|
||||||
|
Notes liées (titres): ${noteTitles.slice(0, 5).join(' | ') || 'sans titre'}
|
||||||
|
Nombre de notes: ${noteCount}
|
||||||
|
|
||||||
|
Retourne UNIQUEMENT du JSON valide:
|
||||||
|
{"reason":"1 phrase pourquoi un agent est utile","suggestedRole":"prompt système de l'agent (2-3 phrases, français)","suggestedType":"researcher|monitor","suggestedFrequency":"daily|weekly"}`
|
||||||
|
|
||||||
|
const raw = await provider.generateText(prompt)
|
||||||
|
const match = raw.match(/\{[\s\S]*\}/)
|
||||||
|
if (!match) return fallback
|
||||||
|
const parsed = JSON.parse(match[0])
|
||||||
|
return {
|
||||||
|
reason: String(parsed.reason || fallback.reason).slice(0, 500),
|
||||||
|
suggestedRole: String(parsed.suggestedRole || fallback.suggestedRole).slice(0, 2000),
|
||||||
|
suggestedType: parsed.suggestedType === 'monitor' ? 'monitor' : 'researcher',
|
||||||
|
suggestedFrequency: ['daily', 'weekly'].includes(parsed.suggestedFrequency) ? parsed.suggestedFrequency : 'weekly',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AgentSuggestionService {
|
||||||
|
async generateForUser(userId: string): Promise<number> {
|
||||||
|
const stored = await clusteringService.getStoredClusters(userId)
|
||||||
|
if (!stored || stored.clusters.length === 0) return 0
|
||||||
|
|
||||||
|
const agents = await prisma.agent.findMany({
|
||||||
|
where: { userId, isEnabled: true },
|
||||||
|
select: { name: true, role: true, description: true, sourceNoteIds: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const candidates = stored.clusters
|
||||||
|
.filter(c => c.noteIds.length >= MIN_CLUSTER_NOTES)
|
||||||
|
.sort((a, b) => b.noteIds.length - a.noteIds.length)
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
for (const cluster of candidates) {
|
||||||
|
if (created >= MAX_SUGGESTIONS_PER_RUN) break
|
||||||
|
|
||||||
|
const topic = cluster.name || `Thème ${cluster.clusterId + 1}`
|
||||||
|
const covered = agents.some(a => clusterCoveredByAgent(topic, cluster.noteIds, a))
|
||||||
|
if (covered) continue
|
||||||
|
|
||||||
|
const existing = await prisma.agentSuggestion.findUnique({
|
||||||
|
where: { userId_clusterId: { userId, clusterId: cluster.clusterId } },
|
||||||
|
})
|
||||||
|
if (existing && existing.status !== 'pending') continue
|
||||||
|
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
where: { id: { in: cluster.noteIds.slice(0, 8) }, userId },
|
||||||
|
select: { title: true },
|
||||||
|
})
|
||||||
|
const llm = await buildSuggestionWithLlm(
|
||||||
|
topic,
|
||||||
|
notes.map(n => n.title || 'Sans titre'),
|
||||||
|
cluster.noteIds.length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
topic,
|
||||||
|
reason: llm.reason,
|
||||||
|
suggestedRole: llm.suggestedRole,
|
||||||
|
suggestedType: llm.suggestedType,
|
||||||
|
suggestedFrequency: llm.suggestedFrequency,
|
||||||
|
relatedNoteIds: JSON.stringify(cluster.noteIds.slice(0, 20)),
|
||||||
|
status: 'pending' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.agentSuggestion.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: payload,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await prisma.agentSuggestion.create({
|
||||||
|
data: { userId, clusterId: cluster.clusterId, ...payload },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPending(userId: string, limit = 3) {
|
||||||
|
return prisma.agentSuggestion.findMany({
|
||||||
|
where: { userId, status: 'pending' },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismiss(userId: string, id: string) {
|
||||||
|
const row = await prisma.agentSuggestion.findFirst({ where: { id, userId } })
|
||||||
|
if (!row) return null
|
||||||
|
return prisma.agentSuggestion.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'dismissed' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async accept(userId: string, id: string): Promise<{ agentId: string } | null> {
|
||||||
|
const row = await prisma.agentSuggestion.findFirst({ where: { id, userId, status: 'pending' } })
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
const noteIds = parseJsonArray(row.relatedNoteIds)
|
||||||
|
const agent = await prisma.agent.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name: `Recherche — ${row.topic}`.slice(0, 80),
|
||||||
|
description: row.reason,
|
||||||
|
type: row.suggestedType,
|
||||||
|
role: row.suggestedRole,
|
||||||
|
frequency: row.suggestedFrequency,
|
||||||
|
sourceNoteIds: noteIds.length > 0 ? JSON.stringify(noteIds) : null,
|
||||||
|
tools: JSON.stringify(['web_search', 'read_url']),
|
||||||
|
isEnabled: true,
|
||||||
|
scheduledTime: '08:00',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (row.suggestedFrequency !== 'manual') {
|
||||||
|
const nextRun = calculateNextRun({
|
||||||
|
frequency: row.suggestedFrequency,
|
||||||
|
scheduledTime: '08:00',
|
||||||
|
})
|
||||||
|
if (nextRun) {
|
||||||
|
await prisma.agent.update({ where: { id: agent.id }, data: { nextRun } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.agentSuggestion.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { status: 'accepted' },
|
||||||
|
})
|
||||||
|
|
||||||
|
import('@/lib/ai/services/agent-executor.service')
|
||||||
|
.then(({ executeAgent }) => executeAgent(agent.id, userId))
|
||||||
|
.catch(err => console.error('[AgentSuggestion] execute failed:', err))
|
||||||
|
|
||||||
|
return { agentId: agent.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentSuggestionService = new AgentSuggestionService()
|
||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
prepareNoteTextForEmbedding,
|
prepareNoteTextForEmbedding,
|
||||||
} from '@/lib/text/plain-text'
|
} from '@/lib/text/plain-text'
|
||||||
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||||
|
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
|
||||||
|
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||||
|
import {
|
||||||
|
buildMemoryEchoInsightPrompt,
|
||||||
|
getMemoryEchoInsightFallback,
|
||||||
|
} from '@/lib/ai/memory-echo-i18n'
|
||||||
import {
|
import {
|
||||||
SEMANTIC_SIMILARITY_FLOOR_CLIP,
|
SEMANTIC_SIMILARITY_FLOOR_CLIP,
|
||||||
SEMANTIC_SIMILARITY_FLOOR_DEMO,
|
SEMANTIC_SIMILARITY_FLOOR_DEMO,
|
||||||
@@ -334,38 +340,16 @@ export class MemoryEchoService {
|
|||||||
note1Title: string | null,
|
note1Title: string | null,
|
||||||
note1Content: string,
|
note1Content: string,
|
||||||
note2Title: string | null,
|
note2Title: string | null,
|
||||||
note2Content: string
|
note2Content: string,
|
||||||
|
language: SupportedLanguage = 'en',
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const provider = getChatProvider(config)
|
const provider = getChatProvider(config)
|
||||||
|
|
||||||
const note1Desc = note1Title || 'Untitled note'
|
|
||||||
const note2Desc = note2Title || 'Untitled note'
|
|
||||||
const excerpt1 = excerptPlainNoteContent(note1Title, note1Content, 1200)
|
const excerpt1 = excerptPlainNoteContent(note1Title, note1Content, 1200)
|
||||||
const excerpt2 = excerptPlainNoteContent(note2Title, note2Content, 1200)
|
const excerpt2 = excerptPlainNoteContent(note2Title, note2Content, 1200)
|
||||||
const directionSample = `${note1Desc}\n${excerpt1}\n${note2Desc}\n${excerpt2}`
|
const prompt = buildMemoryEchoInsightPrompt(language, note1Title, excerpt1, note2Title, excerpt2)
|
||||||
const isRtl = detectTextDirection(directionSample) === 'rtl'
|
|
||||||
|
|
||||||
const prompt = isRtl
|
|
||||||
? `تو یک دستیار هستی که ارتباط بین یادداشتها را تحلیل میکنی.
|
|
||||||
|
|
||||||
یادداشت ۱: «${note1Desc}»
|
|
||||||
متن: ${excerpt1}
|
|
||||||
|
|
||||||
یادداشت ۲: «${note2Desc}»
|
|
||||||
متن: ${excerpt2}
|
|
||||||
|
|
||||||
در یک جمله کوتاه (حداکثر ۱۵ کلمه) به فارسی توضیح بده چرا این دو یادداشت به هم مرتبطاند. فقط رابطه معنایی را بگو.`
|
|
||||||
: `You are a helpful assistant analyzing connections between notes.
|
|
||||||
|
|
||||||
Note 1: "${note1Desc}"
|
|
||||||
Content: ${excerpt1}
|
|
||||||
|
|
||||||
Note 2: "${note2Desc}"
|
|
||||||
Content: ${excerpt2}
|
|
||||||
|
|
||||||
Explain in one brief sentence (max 15 words) why these notes are connected. Focus on the semantic relationship.`
|
|
||||||
|
|
||||||
const response = await provider.generateText(prompt)
|
const response = await provider.generateText(prompt)
|
||||||
|
|
||||||
@@ -375,20 +359,15 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
|||||||
.trim()
|
.trim()
|
||||||
.substring(0, 150)
|
.substring(0, 150)
|
||||||
|
|
||||||
const fallback = isRtl
|
return insight || getMemoryEchoInsightFallback(language)
|
||||||
? 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.'
|
|
||||||
: 'These notes appear to be semantically related.'
|
|
||||||
|
|
||||||
return insight || fallback
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MemoryEcho] Failed to generate insight:', error)
|
console.error('[MemoryEcho] Failed to generate insight:', error)
|
||||||
const sample = excerptPlainNoteContent(note1Title, note1Content, 200)
|
const sample = excerptPlainNoteContent(note1Title, note1Content, 200)
|
||||||
+ excerptPlainNoteContent(note2Title, note2Content, 200)
|
+ excerptPlainNoteContent(note2Title, note2Content, 200)
|
||||||
if (detectTextDirection(sample) === 'rtl') {
|
const rtl = detectTextDirection(sample) === 'rtl'
|
||||||
return 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.'
|
if (rtl) return getMemoryEchoInsightFallback(language === 'fa' || language === 'ar' ? language : 'fa')
|
||||||
}
|
return getMemoryEchoInsightFallback(language)
|
||||||
return 'These notes appear to be semantically related.'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,12 +463,14 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
|||||||
return null // All connections already shown
|
return null // All connections already shown
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate AI insight
|
// Generate AI insight in user's language
|
||||||
|
const userLanguage = await detectUserLanguage()
|
||||||
const insightText = await this.generateInsight(
|
const insightText = await this.generateInsight(
|
||||||
newConnection.note1.title,
|
newConnection.note1.title,
|
||||||
newConnection.note1.content,
|
newConnection.note1.content,
|
||||||
newConnection.note2.title,
|
newConnection.note2.title,
|
||||||
newConnection.note2.content || ''
|
newConnection.note2.content || '',
|
||||||
|
userLanguage,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store insight in database
|
// Store insight in database
|
||||||
|
|||||||
17
memento-note/lib/dashboard/home-route.ts
Normal file
17
memento-note/lib/dashboard/home-route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/** True when /home shows the Second Brain dashboard (not list, editor, or filters). */
|
||||||
|
export function isDashboardHomeRoute(
|
||||||
|
pathname: string,
|
||||||
|
searchParams: Pick<URLSearchParams, 'get'>,
|
||||||
|
): boolean {
|
||||||
|
if (pathname !== '/home') return false
|
||||||
|
return (
|
||||||
|
searchParams.get('forceList') !== '1' &&
|
||||||
|
!searchParams.get('notebook') &&
|
||||||
|
!searchParams.get('search') &&
|
||||||
|
!searchParams.get('labels') &&
|
||||||
|
!searchParams.get('shared') &&
|
||||||
|
!searchParams.get('reminders') &&
|
||||||
|
!searchParams.get('color') &&
|
||||||
|
!searchParams.get('openNote')
|
||||||
|
)
|
||||||
|
}
|
||||||
297
memento-note/lib/dashboard/layout.ts
Normal file
297
memento-note/lib/dashboard/layout.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
export const DASHBOARD_LAYOUT_VERSION = 5 as const
|
||||||
|
|
||||||
|
/** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */
|
||||||
|
export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
|
||||||
|
'capture',
|
||||||
|
'next-paths',
|
||||||
|
'intelligence',
|
||||||
|
'resume',
|
||||||
|
'mind-map',
|
||||||
|
'sentiment',
|
||||||
|
'inbox',
|
||||||
|
'revision',
|
||||||
|
'stats',
|
||||||
|
'reminders',
|
||||||
|
'flashcards-progress',
|
||||||
|
'agents',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type DashboardWidgetId =
|
||||||
|
| 'capture'
|
||||||
|
| 'next-paths'
|
||||||
|
| 'resume'
|
||||||
|
| 'intelligence'
|
||||||
|
| 'reminders'
|
||||||
|
| 'mind-map'
|
||||||
|
| 'agents'
|
||||||
|
| 'sentiment'
|
||||||
|
| 'inbox'
|
||||||
|
| 'revision'
|
||||||
|
| 'stats'
|
||||||
|
| 'agent-activity'
|
||||||
|
| 'gmail'
|
||||||
|
| 'activity'
|
||||||
|
| 'pinned'
|
||||||
|
| 'usage'
|
||||||
|
| 'daily-review'
|
||||||
|
| 'open-loops'
|
||||||
|
| 'daily-note'
|
||||||
|
| 'link-suggestions'
|
||||||
|
| 'bridges'
|
||||||
|
| 'flashcards-progress'
|
||||||
|
|
||||||
|
export type DashboardWidgetZone = 'full' | 'main' | 'side'
|
||||||
|
|
||||||
|
export type DashboardWidgetCategory =
|
||||||
|
| 'essential'
|
||||||
|
| 'paths'
|
||||||
|
| 'cognition'
|
||||||
|
| 'productivity'
|
||||||
|
| 'agents'
|
||||||
|
|
||||||
|
export interface DashboardWidgetPlacement {
|
||||||
|
id: DashboardWidgetId
|
||||||
|
visible: boolean
|
||||||
|
order: number
|
||||||
|
zone: DashboardWidgetZone
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardLayout {
|
||||||
|
version: typeof DASHBOARD_LAYOUT_VERSION
|
||||||
|
widgets: DashboardWidgetPlacement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DASHBOARD_WIDGET_META: Record<DashboardWidgetId, {
|
||||||
|
required: boolean
|
||||||
|
zone: DashboardWidgetZone
|
||||||
|
category: DashboardWidgetCategory
|
||||||
|
defaultVisible: boolean
|
||||||
|
}> = {
|
||||||
|
capture: { required: true, zone: 'full', category: 'essential', defaultVisible: true },
|
||||||
|
'next-paths': { required: false, zone: 'main', category: 'paths', defaultVisible: true },
|
||||||
|
resume: { required: true, zone: 'main', category: 'essential', defaultVisible: true },
|
||||||
|
intelligence: { required: true, zone: 'main', category: 'essential', defaultVisible: true },
|
||||||
|
reminders: { required: false, zone: 'side', category: 'productivity', defaultVisible: true },
|
||||||
|
'mind-map': { required: false, zone: 'main', category: 'cognition', defaultVisible: true },
|
||||||
|
agents: { required: false, zone: 'side', category: 'agents', defaultVisible: true },
|
||||||
|
sentiment: { required: false, zone: 'side', category: 'agents', defaultVisible: true },
|
||||||
|
inbox: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
|
||||||
|
revision: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
|
||||||
|
stats: { required: false, zone: 'side', category: 'cognition', defaultVisible: false },
|
||||||
|
'agent-activity': { required: false, zone: 'side', category: 'agents', defaultVisible: false },
|
||||||
|
gmail: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
|
||||||
|
activity: { required: false, zone: 'main', category: 'cognition', defaultVisible: false },
|
||||||
|
pinned: { required: false, zone: 'side', category: 'cognition', defaultVisible: false },
|
||||||
|
usage: { required: false, zone: 'side', category: 'agents', defaultVisible: false },
|
||||||
|
'daily-review': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
|
||||||
|
'open-loops': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
|
||||||
|
'daily-note': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
|
||||||
|
'link-suggestions': { required: false, zone: 'main', category: 'paths', defaultVisible: false },
|
||||||
|
bridges: { required: false, zone: 'main', category: 'cognition', defaultVisible: false },
|
||||||
|
'flashcards-progress': { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DASHBOARD_CATEGORY_ORDER: DashboardWidgetCategory[] = [
|
||||||
|
'essential',
|
||||||
|
'paths',
|
||||||
|
'cognition',
|
||||||
|
'productivity',
|
||||||
|
'agents',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||||
|
version: DASHBOARD_LAYOUT_VERSION,
|
||||||
|
widgets: [
|
||||||
|
// Pleine largeur
|
||||||
|
{ id: 'capture', visible: true, order: 0, zone: 'full' },
|
||||||
|
// Colonne principale (gauche) — ordre vertical
|
||||||
|
{ id: 'next-paths', visible: true, order: 1, zone: 'main' },
|
||||||
|
{ id: 'intelligence', visible: true, order: 2, zone: 'main' },
|
||||||
|
{ id: 'resume', visible: true, order: 3, zone: 'main' },
|
||||||
|
{ id: 'mind-map', visible: true, order: 4, zone: 'main' },
|
||||||
|
// Colonne latérale (droite) — cartes compactes + widgets IA
|
||||||
|
{ id: 'sentiment', visible: true, order: 5, zone: 'side' },
|
||||||
|
{ id: 'inbox', visible: true, order: 6, zone: 'side' },
|
||||||
|
{ id: 'revision', visible: true, order: 7, zone: 'side' },
|
||||||
|
{ id: 'stats', visible: true, order: 8, zone: 'side' },
|
||||||
|
{ id: 'reminders', visible: true, order: 9, zone: 'side' },
|
||||||
|
{ id: 'flashcards-progress', visible: true, order: 10, zone: 'side' },
|
||||||
|
{ id: 'agents', visible: true, order: 11, zone: 'side' },
|
||||||
|
// Catalogue — masqués par défaut, ajoutables via « Personnaliser »
|
||||||
|
{ id: 'daily-review', visible: false, order: 12, zone: 'side' },
|
||||||
|
{ id: 'agent-activity', visible: false, order: 13, zone: 'side' },
|
||||||
|
{ id: 'gmail', visible: false, order: 14, zone: 'side' },
|
||||||
|
{ id: 'activity', visible: false, order: 15, zone: 'main' },
|
||||||
|
{ id: 'pinned', visible: false, order: 16, zone: 'side' },
|
||||||
|
{ id: 'usage', visible: false, order: 17, zone: 'side' },
|
||||||
|
{ id: 'open-loops', visible: false, order: 18, zone: 'side' },
|
||||||
|
{ id: 'daily-note', visible: false, order: 19, zone: 'side' },
|
||||||
|
{ id: 'link-suggestions', visible: false, order: 20, zone: 'main' },
|
||||||
|
{ id: 'bridges', visible: false, order: 21, zone: 'main' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clone frais — évite de muter le constant partagé via useState. */
|
||||||
|
export function getDefaultDashboardLayout(): DashboardLayout {
|
||||||
|
return structuredClone(DEFAULT_DASHBOARD_LAYOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetDashboardLayout(): DashboardLayout {
|
||||||
|
return getDefaultDashboardLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_WIDGET_IDS = Object.keys(DASHBOARD_WIDGET_META) as DashboardWidgetId[]
|
||||||
|
|
||||||
|
const LEGACY_SPAN_ZONE: Record<string, DashboardWidgetZone> = {
|
||||||
|
full: 'full',
|
||||||
|
large: 'main',
|
||||||
|
medium: 'main',
|
||||||
|
small: 'side',
|
||||||
|
compact: 'side',
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidZone(zone: unknown): zone is DashboardWidgetZone {
|
||||||
|
return zone === 'full' || zone === 'main' || zone === 'side'
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyZoneFromItem(item: Record<string, unknown>, id: DashboardWidgetId): DashboardWidgetZone {
|
||||||
|
if (isValidZone(item.zone)) return item.zone
|
||||||
|
const span = item.span
|
||||||
|
if (typeof span === 'string' && LEGACY_SPAN_ZONE[span]) return LEGACY_SPAN_ZONE[span]
|
||||||
|
return DASHBOARD_WIDGET_META[id].zone
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDashboardLayout(raw: unknown): DashboardLayout {
|
||||||
|
if (!raw || typeof raw !== 'object') return getDefaultDashboardLayout()
|
||||||
|
|
||||||
|
const data = raw as Partial<DashboardLayout> & { version?: number }
|
||||||
|
if (!Array.isArray(data.widgets) || data.widgets.length === 0) return getDefaultDashboardLayout()
|
||||||
|
|
||||||
|
// Layout périmé ou vide → preset canonique
|
||||||
|
const incomingVersion = typeof data.version === 'number' ? data.version : 0
|
||||||
|
if (incomingVersion < DASHBOARD_LAYOUT_VERSION) {
|
||||||
|
return getDefaultDashboardLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map<DashboardWidgetId, DashboardWidgetPlacement>()
|
||||||
|
|
||||||
|
for (const item of data.widgets) {
|
||||||
|
if (!item || typeof item !== 'object') continue
|
||||||
|
const id = (item as DashboardWidgetPlacement).id
|
||||||
|
if (!ALL_WIDGET_IDS.includes(id)) continue
|
||||||
|
const meta = DASHBOARD_WIDGET_META[id]
|
||||||
|
byId.set(id, {
|
||||||
|
id,
|
||||||
|
visible: meta.required ? true : Boolean((item as DashboardWidgetPlacement).visible),
|
||||||
|
order: Number.isFinite((item as DashboardWidgetPlacement).order)
|
||||||
|
? Number((item as DashboardWidgetPlacement).order)
|
||||||
|
: 0,
|
||||||
|
zone: legacyZoneFromItem(item as unknown as Record<string, unknown>, id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of ALL_WIDGET_IDS) {
|
||||||
|
if (!byId.has(id)) {
|
||||||
|
const meta = DASHBOARD_WIDGET_META[id]
|
||||||
|
const fallback = DEFAULT_DASHBOARD_LAYOUT.widgets.find(w => w.id === id)
|
||||||
|
byId.set(id, {
|
||||||
|
id,
|
||||||
|
visible: meta.defaultVisible,
|
||||||
|
order: fallback?.order ?? 99,
|
||||||
|
zone: meta.zone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgets = [...byId.values()]
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((w, index) => ({ ...w, order: index }))
|
||||||
|
|
||||||
|
const layout = { version: DASHBOARD_LAYOUT_VERSION, widgets }
|
||||||
|
const visibleCount = layout.widgets.filter(w => w.visible).length
|
||||||
|
if (visibleCount === 0) return getDefaultDashboardLayout()
|
||||||
|
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reorderWidgets(
|
||||||
|
layout: DashboardLayout,
|
||||||
|
activeId: DashboardWidgetId,
|
||||||
|
overId: DashboardWidgetId,
|
||||||
|
): DashboardLayout {
|
||||||
|
const activeMeta = DASHBOARD_WIDGET_META[activeId]
|
||||||
|
const overMeta = DASHBOARD_WIDGET_META[overId]
|
||||||
|
if (activeMeta.zone !== overMeta.zone) return layout
|
||||||
|
|
||||||
|
const visible = layout.widgets
|
||||||
|
.filter(w => w.visible && w.zone === activeMeta.zone)
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
const activeIdx = visible.findIndex(w => w.id === activeId)
|
||||||
|
const overIdx = visible.findIndex(w => w.id === overId)
|
||||||
|
if (activeIdx === -1 || overIdx === -1 || activeIdx === overIdx) return layout
|
||||||
|
|
||||||
|
const next = [...visible]
|
||||||
|
const [moved] = next.splice(activeIdx, 1)
|
||||||
|
next.splice(overIdx, 0, moved)
|
||||||
|
|
||||||
|
const orderMap = new Map(next.map((w, i) => [w.id, i]))
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
widgets: layout.widgets.map(w => {
|
||||||
|
if (w.zone !== activeMeta.zone || !w.visible) return w
|
||||||
|
return { ...w, order: orderMap.has(w.id) ? orderMap.get(w.id)! : w.order + next.length }
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWidgetVisibility(
|
||||||
|
layout: DashboardLayout,
|
||||||
|
id: DashboardWidgetId,
|
||||||
|
visible: boolean,
|
||||||
|
): DashboardLayout {
|
||||||
|
if (DASHBOARD_WIDGET_META[id].required && !visible) return layout
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
widgets: layout.widgets.map(w => w.id === id ? { ...w, visible } : w),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleWidgets(layout: DashboardLayout): DashboardWidgetPlacement[] {
|
||||||
|
return layout.widgets.filter(w => w.visible).sort((a, b) => a.order - b.order)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleWidgetsInZone(
|
||||||
|
layout: DashboardLayout,
|
||||||
|
zone: DashboardWidgetZone,
|
||||||
|
): DashboardWidgetPlacement[] {
|
||||||
|
return visibleWidgets(layout).filter(w => w.zone === zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hiddenWidgetIds(layout: DashboardLayout): DashboardWidgetId[] {
|
||||||
|
return layout.widgets.filter(w => !w.visible).map(w => w.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogByCategory(layout: DashboardLayout): Record<DashboardWidgetCategory, DashboardWidgetId[]> {
|
||||||
|
const result = Object.fromEntries(
|
||||||
|
DASHBOARD_CATEGORY_ORDER.map(c => [c, [] as DashboardWidgetId[]]),
|
||||||
|
) as Record<DashboardWidgetCategory, DashboardWidgetId[]>
|
||||||
|
|
||||||
|
for (const id of ALL_WIDGET_IDS) {
|
||||||
|
result[DASHBOARD_WIDGET_META[id].category].push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cat of DASHBOARD_CATEGORY_ORDER) {
|
||||||
|
result[cat].sort((a, b) => {
|
||||||
|
const aVis = layout.widgets.find(w => w.id === a)?.visible ? 0 : 1
|
||||||
|
const bVis = layout.widgets.find(w => w.id === b)?.visible ? 0 : 1
|
||||||
|
if (aVis !== bVis) return aVis - bVis
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWidgetVisible(layout: DashboardLayout, id: DashboardWidgetId): boolean {
|
||||||
|
return layout.widgets.find(w => w.id === id)?.visible ?? DASHBOARD_WIDGET_META[id].defaultVisible
|
||||||
|
}
|
||||||
36
memento-note/lib/dashboard/path-types.ts
Normal file
36
memento-note/lib/dashboard/path-types.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export type DashboardPathType =
|
||||||
|
| 'continue'
|
||||||
|
| 'connect'
|
||||||
|
| 'bridge'
|
||||||
|
| 'research'
|
||||||
|
| 'organize'
|
||||||
|
| 'explore'
|
||||||
|
| 'review'
|
||||||
|
| 'resurface'
|
||||||
|
| 'daily'
|
||||||
|
| 'add-link'
|
||||||
|
|
||||||
|
export interface DashboardPath {
|
||||||
|
id: string
|
||||||
|
type: DashboardPathType
|
||||||
|
priority: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
actionKey: string
|
||||||
|
noteId?: string
|
||||||
|
note2Id?: string
|
||||||
|
notebookId?: string
|
||||||
|
clusterAId?: number
|
||||||
|
clusterBId?: number
|
||||||
|
agentSuggestionId?: string
|
||||||
|
insightId?: string
|
||||||
|
snippet?: string
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardOpenLoop {
|
||||||
|
id: string
|
||||||
|
title: string | null
|
||||||
|
notebookId: string | null
|
||||||
|
daysStale: number
|
||||||
|
}
|
||||||
156
memento-note/lib/dashboard/paths-fast.ts
Normal file
156
memento-note/lib/dashboard/paths-fast.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import type { DashboardPath } from '@/lib/dashboard/path-types'
|
||||||
|
|
||||||
|
function excerpt(text: string, max = 120): string {
|
||||||
|
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
if (plain.length <= max) return plain
|
||||||
|
return `${plain.slice(0, max)}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BriefingPathsInput {
|
||||||
|
recentNotes: Array<{
|
||||||
|
id: string
|
||||||
|
title: string | null
|
||||||
|
content: string
|
||||||
|
notebookId: string | null
|
||||||
|
}>
|
||||||
|
inboxCount: number
|
||||||
|
dueFlashcards: number
|
||||||
|
insights: Array<{
|
||||||
|
id: string
|
||||||
|
insight: string
|
||||||
|
score: number
|
||||||
|
viewed: boolean
|
||||||
|
note1: { id: string; title: string | null }
|
||||||
|
note2: { id: string; title: string | null }
|
||||||
|
note1Excerpt?: string
|
||||||
|
note2Excerpt?: string
|
||||||
|
}>
|
||||||
|
bridgeSuggestions: Array<{
|
||||||
|
clusterAId: number
|
||||||
|
clusterBId: number
|
||||||
|
clusterAName: string
|
||||||
|
clusterBName: string
|
||||||
|
suggestedTitle: string
|
||||||
|
suggestedContent: string
|
||||||
|
justification: string
|
||||||
|
}>
|
||||||
|
agentSuggestions: Array<{
|
||||||
|
id: string
|
||||||
|
topic: string
|
||||||
|
reason: string
|
||||||
|
clusterId: number | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pistes instantanées à partir du briefing déjà chargé — sans requête lourde. */
|
||||||
|
export function buildFastPathsFromBriefing(input: BriefingPathsInput): DashboardPath[] {
|
||||||
|
const paths: DashboardPath[] = []
|
||||||
|
const focus = input.recentNotes[0]
|
||||||
|
|
||||||
|
if (focus) {
|
||||||
|
paths.push({
|
||||||
|
id: `continue-${focus.id}`,
|
||||||
|
type: 'continue',
|
||||||
|
priority: 100,
|
||||||
|
title: focus.title || 'Untitled',
|
||||||
|
description: excerpt(focus.content, 140),
|
||||||
|
actionKey: 'continue',
|
||||||
|
noteId: focus.id,
|
||||||
|
notebookId: focus.notebookId ?? undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const ins of input.insights
|
||||||
|
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
|
||||||
|
.slice(0, 2)) {
|
||||||
|
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
|
||||||
|
paths.push({
|
||||||
|
id: `connect-${focus.id}-${other.id}`,
|
||||||
|
type: 'connect',
|
||||||
|
priority: 88,
|
||||||
|
title: other.title || 'Untitled',
|
||||||
|
description: ins.insight,
|
||||||
|
actionKey: 'compare',
|
||||||
|
noteId: focus.id,
|
||||||
|
note2Id: other.id,
|
||||||
|
notebookId: focus.notebookId ?? undefined,
|
||||||
|
score: Math.round(ins.score * 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshInsight = input.insights.find(i => !i.viewed)
|
||||||
|
if (freshInsight) {
|
||||||
|
paths.push({
|
||||||
|
id: `resurface-${freshInsight.id}`,
|
||||||
|
type: 'resurface',
|
||||||
|
priority: 85,
|
||||||
|
title: `${freshInsight.note1.title || '…'} ↔ ${freshInsight.note2.title || '…'}`,
|
||||||
|
description: freshInsight.insight,
|
||||||
|
actionKey: 'openInsight',
|
||||||
|
insightId: freshInsight.id,
|
||||||
|
noteId: freshInsight.note1.id,
|
||||||
|
note2Id: freshInsight.note2.id,
|
||||||
|
score: Math.round(freshInsight.score * 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bridge of input.bridgeSuggestions.slice(0, 2)) {
|
||||||
|
paths.push({
|
||||||
|
id: `bridge-${bridge.clusterAId}-${bridge.clusterBId}`,
|
||||||
|
type: 'bridge',
|
||||||
|
priority: 70,
|
||||||
|
title: bridge.suggestedTitle,
|
||||||
|
description: bridge.justification || bridge.suggestedContent.slice(0, 140),
|
||||||
|
actionKey: 'createBridge',
|
||||||
|
clusterAId: bridge.clusterAId,
|
||||||
|
clusterBId: bridge.clusterBId,
|
||||||
|
notebookId: focus?.notebookId ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of input.agentSuggestions.slice(0, 2)) {
|
||||||
|
paths.push({
|
||||||
|
id: `research-${agent.id}`,
|
||||||
|
type: 'research',
|
||||||
|
priority: 65,
|
||||||
|
title: agent.topic,
|
||||||
|
description: agent.reason,
|
||||||
|
actionKey: 'createAgent',
|
||||||
|
agentSuggestionId: agent.id,
|
||||||
|
notebookId: focus?.notebookId ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.inboxCount > 0) {
|
||||||
|
paths.push({
|
||||||
|
id: 'organize-inbox',
|
||||||
|
type: 'organize',
|
||||||
|
priority: 60,
|
||||||
|
title: `${input.inboxCount} notes`,
|
||||||
|
description: 'inbox',
|
||||||
|
actionKey: 'organizeInbox',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.dueFlashcards > 0) {
|
||||||
|
paths.push({
|
||||||
|
id: 'review-flashcards',
|
||||||
|
type: 'review',
|
||||||
|
priority: 55,
|
||||||
|
title: `${input.dueFlashcards}`,
|
||||||
|
description: 'flashcards',
|
||||||
|
actionKey: 'reviewCards',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
paths.push({
|
||||||
|
id: 'daily-journal',
|
||||||
|
type: 'daily',
|
||||||
|
priority: 40,
|
||||||
|
title: new Date().toISOString().slice(0, 10),
|
||||||
|
description: 'daily',
|
||||||
|
actionKey: 'openDaily',
|
||||||
|
})
|
||||||
|
|
||||||
|
return paths.sort((a, b) => b.priority - a.priority).slice(0, 8)
|
||||||
|
}
|
||||||
297
memento-note/lib/dashboard/paths.service.ts
Normal file
297
memento-note/lib/dashboard/paths.service.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import 'server-only'
|
||||||
|
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import type { DashboardPath } from '@/lib/dashboard/path-types'
|
||||||
|
|
||||||
|
export type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
|
||||||
|
|
||||||
|
interface BuildPathsInput {
|
||||||
|
userId: string
|
||||||
|
aiActive: boolean
|
||||||
|
recentNotes: Array<{
|
||||||
|
id: string
|
||||||
|
title: string | null
|
||||||
|
notebookId: string | null
|
||||||
|
updatedAt: Date
|
||||||
|
content: string
|
||||||
|
}>
|
||||||
|
inboxCount: number
|
||||||
|
dueFlashcards: number
|
||||||
|
insights: Array<{
|
||||||
|
id: string
|
||||||
|
insight: string
|
||||||
|
score: number
|
||||||
|
viewed: boolean
|
||||||
|
note1: { id: string; title: string | null }
|
||||||
|
note2: { id: string; title: string | null }
|
||||||
|
note1Excerpt?: string
|
||||||
|
note2Excerpt?: string
|
||||||
|
}>
|
||||||
|
bridgeSuggestions: Array<{
|
||||||
|
clusterAId: number
|
||||||
|
clusterBId: number
|
||||||
|
clusterAName: string
|
||||||
|
clusterBName: string
|
||||||
|
suggestedTitle: string
|
||||||
|
suggestedContent: string
|
||||||
|
justification: string
|
||||||
|
}>
|
||||||
|
agentSuggestions: Array<{
|
||||||
|
id: string
|
||||||
|
topic: string
|
||||||
|
reason: string
|
||||||
|
clusterId: number | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function excerpt(text: string, max = 120): string {
|
||||||
|
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
if (plain.length <= max) return plain
|
||||||
|
return `${plain.slice(0, max)}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
function jaccardSimilarity(a: string, b: string): number {
|
||||||
|
const tokenize = (s: string) =>
|
||||||
|
new Set(s.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 3))
|
||||||
|
const A = tokenize(a)
|
||||||
|
const B = tokenize(b)
|
||||||
|
if (A.size === 0 || B.size === 0) return 0
|
||||||
|
let intersection = 0
|
||||||
|
A.forEach(w => { if (B.has(w)) intersection++ })
|
||||||
|
return intersection / (A.size + B.size - intersection)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findLinkSuggestions(
|
||||||
|
userId: string,
|
||||||
|
sourceNote: { id: string; title: string | null; content: string },
|
||||||
|
limit = 2,
|
||||||
|
): Promise<Array<{ noteId: string; noteTitle: string; snippet: string; score: number }>> {
|
||||||
|
const sourcePlain = excerpt(sourceNote.content, 400)
|
||||||
|
const sourceText = `${sourceNote.title || ''} ${sourcePlain}`.toLowerCase()
|
||||||
|
|
||||||
|
const candidates = await prisma.note.findMany({
|
||||||
|
where: { userId, id: { not: sourceNote.id }, isArchived: false, trashedAt: null },
|
||||||
|
select: { id: true, title: true, content: true },
|
||||||
|
take: 12,
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const scored: Array<{ noteId: string; noteTitle: string; snippet: string; score: number }> = []
|
||||||
|
for (const note of candidates) {
|
||||||
|
const notePlain = excerpt(note.content, 300)
|
||||||
|
const sim = jaccardSimilarity(sourceText, `${note.title || ''} ${notePlain}`)
|
||||||
|
if (sim < 0.06) continue
|
||||||
|
scored.push({
|
||||||
|
noteId: note.id,
|
||||||
|
noteTitle: note.title || '',
|
||||||
|
snippet: notePlain.slice(0, 100),
|
||||||
|
score: Math.round(sim * 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathsFromInsights(
|
||||||
|
focus: BuildPathsInput['recentNotes'][0],
|
||||||
|
insights: BuildPathsInput['insights'],
|
||||||
|
paths: DashboardPath[],
|
||||||
|
): void {
|
||||||
|
for (const ins of insights
|
||||||
|
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
|
||||||
|
.slice(0, 2)) {
|
||||||
|
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
|
||||||
|
if (paths.some(p => p.note2Id === other.id && p.type === 'connect')) continue
|
||||||
|
paths.push({
|
||||||
|
id: `connect-${focus.id}-${other.id}`,
|
||||||
|
type: 'connect',
|
||||||
|
priority: 90 - paths.length,
|
||||||
|
title: other.title || 'Untitled',
|
||||||
|
description: ins.insight || ins.note2Excerpt || ins.note1Excerpt || '',
|
||||||
|
actionKey: 'compare',
|
||||||
|
noteId: focus.id,
|
||||||
|
note2Id: other.id,
|
||||||
|
notebookId: focus.notebookId ?? undefined,
|
||||||
|
score: Math.round(ins.score * 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOpenLoops(userId: string, limit = 3): Promise<Array<{ id: string; title: string | null; notebookId: string | null; daysStale: number }>> {
|
||||||
|
const threeDaysAgo = new Date()
|
||||||
|
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
|
||||||
|
const thirtyDaysAgo = new Date()
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
|
||||||
|
|
||||||
|
const stale = await prisma.note.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
trashedAt: null,
|
||||||
|
isArchived: false,
|
||||||
|
updatedAt: { lt: threeDaysAgo, gte: thirtyDaysAgo },
|
||||||
|
OR: [
|
||||||
|
{ notebookId: null },
|
||||||
|
{ isPinned: true },
|
||||||
|
{ reminder: { not: null }, isReminderDone: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, notebookId: true, updatedAt: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
return stale.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
notebookId: n.notebookId,
|
||||||
|
daysStale: Math.floor((now - n.updatedAt.getTime()) / (1000 * 60 * 60 * 24)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildDashboardPaths(input: BuildPathsInput): Promise<DashboardPath[]> {
|
||||||
|
const paths: DashboardPath[] = []
|
||||||
|
const focus = input.recentNotes[0]
|
||||||
|
|
||||||
|
let focusClusterId: number | null = null
|
||||||
|
if (focus) {
|
||||||
|
const member = await prisma.clusterMember.findFirst({
|
||||||
|
where: { userId: input.userId, noteId: focus.id },
|
||||||
|
select: { clusterId: true },
|
||||||
|
})
|
||||||
|
focusClusterId = member?.clusterId ?? null
|
||||||
|
|
||||||
|
paths.push({
|
||||||
|
id: `continue-${focus.id}`,
|
||||||
|
type: 'continue',
|
||||||
|
priority: 100,
|
||||||
|
title: focus.title || 'Untitled',
|
||||||
|
description: excerpt(focus.content, 140),
|
||||||
|
actionKey: 'continue',
|
||||||
|
noteId: focus.id,
|
||||||
|
notebookId: focus.notebookId ?? undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
pathsFromInsights(focus, input.insights, paths)
|
||||||
|
|
||||||
|
const linkSuggestions = await findLinkSuggestions(input.userId, focus, 2)
|
||||||
|
for (const link of linkSuggestions) {
|
||||||
|
paths.push({
|
||||||
|
id: `add-link-${focus.id}-${link.noteId}`,
|
||||||
|
type: 'add-link',
|
||||||
|
priority: 75,
|
||||||
|
title: link.noteTitle || 'Untitled',
|
||||||
|
description: link.snippet,
|
||||||
|
actionKey: 'addLink',
|
||||||
|
noteId: focus.id,
|
||||||
|
note2Id: link.noteId,
|
||||||
|
notebookId: focus.notebookId ?? undefined,
|
||||||
|
score: link.score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshInsight = input.insights.find(i => !i.viewed)
|
||||||
|
if (freshInsight) {
|
||||||
|
paths.push({
|
||||||
|
id: `resurface-${freshInsight.id}`,
|
||||||
|
type: 'resurface',
|
||||||
|
priority: 85,
|
||||||
|
title: `${freshInsight.note1.title || '…'} ↔ ${freshInsight.note2.title || '…'}`,
|
||||||
|
description: freshInsight.insight,
|
||||||
|
actionKey: 'openInsight',
|
||||||
|
insightId: freshInsight.id,
|
||||||
|
noteId: freshInsight.note1.id,
|
||||||
|
note2Id: freshInsight.note2.id,
|
||||||
|
score: Math.round(freshInsight.score * 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bridge of input.bridgeSuggestions.slice(0, 2)) {
|
||||||
|
const relevant = focusClusterId === null
|
||||||
|
|| bridge.clusterAId === focusClusterId
|
||||||
|
|| bridge.clusterBId === focusClusterId
|
||||||
|
paths.push({
|
||||||
|
id: `bridge-${bridge.clusterAId}-${bridge.clusterBId}`,
|
||||||
|
type: 'bridge',
|
||||||
|
priority: relevant ? 80 : 55,
|
||||||
|
title: bridge.suggestedTitle,
|
||||||
|
description: bridge.justification || bridge.suggestedContent.slice(0, 140),
|
||||||
|
actionKey: 'createBridge',
|
||||||
|
clusterAId: bridge.clusterAId,
|
||||||
|
clusterBId: bridge.clusterBId,
|
||||||
|
notebookId: focus?.notebookId ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of input.agentSuggestions.slice(0, 2)) {
|
||||||
|
const relevant = focusClusterId !== null && agent.clusterId === focusClusterId
|
||||||
|
paths.push({
|
||||||
|
id: `research-${agent.id}`,
|
||||||
|
type: 'research',
|
||||||
|
priority: relevant ? 78 : 50,
|
||||||
|
title: agent.topic,
|
||||||
|
description: agent.reason,
|
||||||
|
actionKey: 'createAgent',
|
||||||
|
agentSuggestionId: agent.id,
|
||||||
|
notebookId: focus?.notebookId ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusClusterId !== null) {
|
||||||
|
const cluster = await prisma.noteCluster.findFirst({
|
||||||
|
where: { userId: input.userId, clusterId: focusClusterId },
|
||||||
|
select: { name: true, noteCount: true },
|
||||||
|
})
|
||||||
|
if (cluster) {
|
||||||
|
paths.push({
|
||||||
|
id: `explore-cluster-${focusClusterId}`,
|
||||||
|
type: 'explore',
|
||||||
|
priority: 60,
|
||||||
|
title: cluster.name || `Cluster ${focusClusterId}`,
|
||||||
|
description: `${cluster.noteCount} notes in this theme`,
|
||||||
|
actionKey: 'exploreTheme',
|
||||||
|
clusterAId: focusClusterId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.inboxCount > 0) {
|
||||||
|
paths.push({
|
||||||
|
id: 'organize-inbox',
|
||||||
|
type: 'organize',
|
||||||
|
priority: 70,
|
||||||
|
title: `${input.inboxCount} notes`,
|
||||||
|
description: 'inbox',
|
||||||
|
actionKey: 'organizeInbox',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.dueFlashcards > 0) {
|
||||||
|
paths.push({
|
||||||
|
id: 'review-flashcards',
|
||||||
|
type: 'review',
|
||||||
|
priority: 65,
|
||||||
|
title: `${input.dueFlashcards}`,
|
||||||
|
description: 'flashcards',
|
||||||
|
actionKey: 'reviewCards',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
paths.push({
|
||||||
|
id: 'daily-journal',
|
||||||
|
type: 'daily',
|
||||||
|
priority: 40,
|
||||||
|
title: new Date().toISOString().slice(0, 10),
|
||||||
|
description: 'daily',
|
||||||
|
actionKey: 'openDaily',
|
||||||
|
})
|
||||||
|
|
||||||
|
return paths
|
||||||
|
.sort((a, b) => b.priority - a.priority)
|
||||||
|
.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildOpenLoops(userId: string) {
|
||||||
|
return findOpenLoops(userId, 5)
|
||||||
|
}
|
||||||
204
memento-note/lib/integrations/gmail-scanner.service.ts
Normal file
204
memento-note/lib/integrations/gmail-scanner.service.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Gmail Scanner — extrait vols, colis, abonnements → notes + rappels
|
||||||
|
*/
|
||||||
|
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { getGoogleTokens, refreshGoogleAccessToken } from '@/lib/integrations/google-integration-tokens'
|
||||||
|
|
||||||
|
const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1/users/me'
|
||||||
|
|
||||||
|
const SCAN_QUERIES: { category: string; q: string }[] = [
|
||||||
|
{ category: 'flight', q: 'subject:(flight OR boarding OR vol OR "boarding pass") newer_than:7d' },
|
||||||
|
{ category: 'package', q: 'from:(amazon OR ups OR fedex OR dhl OR laposte OR colissimo) newer_than:7d' },
|
||||||
|
{ category: 'subscription', q: 'subject:(renewal OR reconduction OR renouvellement OR abonnement) newer_than:30d' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function decodeBase64Url(data: string): string {
|
||||||
|
const normalized = data.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
return Buffer.from(normalized, 'base64').toString('utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBody(payload: any): string {
|
||||||
|
if (!payload) return ''
|
||||||
|
if (payload.body?.data) {
|
||||||
|
const raw = decodeBase64Url(payload.body.data)
|
||||||
|
return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 4000)
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.parts)) {
|
||||||
|
for (const part of payload.parts) {
|
||||||
|
if (part.mimeType === 'text/plain' && part.body?.data) {
|
||||||
|
return decodeBase64Url(part.body.data).slice(0, 4000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const part of payload.parts) {
|
||||||
|
const nested = extractBody(part)
|
||||||
|
if (nested) return nested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gmailFetch(userId: string, path: string, accessToken: string, refreshToken: string) {
|
||||||
|
let token = accessToken
|
||||||
|
let res = await fetch(`${GMAIL_API}${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.status === 401 && refreshToken) {
|
||||||
|
const refreshed = await refreshGoogleAccessToken(userId, 'gmail', refreshToken)
|
||||||
|
if (refreshed) {
|
||||||
|
token = refreshed
|
||||||
|
res = await fetch(`${GMAIL_API}${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractStructured(
|
||||||
|
category: string,
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getChatProvider(config)
|
||||||
|
if (!provider) return null
|
||||||
|
|
||||||
|
const schemas: Record<string, string> = {
|
||||||
|
flight: '{"airline":"","flightNumber":"","departureDate":"ISO8601","departureAirport":"","arrivalAirport":"","confirmationCode":""}',
|
||||||
|
package: '{"carrier":"","trackingNumber":"","expectedDelivery":"ISO8601","status":""}',
|
||||||
|
subscription: '{"serviceName":"","renewalDate":"ISO8601","amount":0,"currency":"EUR"}',
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `Extrais les données structurées de cet email (${category}). Retourne UNIQUEMENT du JSON valide selon ce schéma: ${schemas[category] || '{}'}
|
||||||
|
Sujet: ${subject}
|
||||||
|
Corps: ${body.slice(0, 2500)}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await provider.generateText(prompt)
|
||||||
|
const match = raw.match(/\{[\s\S]*\}/)
|
||||||
|
if (!match) return null
|
||||||
|
return JSON.parse(match[0])
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNoteFromExtraction(category: string, subject: string, data: Record<string, unknown>) {
|
||||||
|
if (category === 'flight') {
|
||||||
|
const title = `Vol ${data.flightNumber || data.airline || ''}`.trim() || subject.slice(0, 80)
|
||||||
|
const dep = data.departureDate ? new Date(String(data.departureDate)) : null
|
||||||
|
const reminder = dep && !Number.isNaN(dep.getTime())
|
||||||
|
? new Date(dep.getTime() - 3 * 60 * 60 * 1000)
|
||||||
|
: null
|
||||||
|
const html = `<p><strong>${title}</strong></p><p>${data.departureAirport || ''} → ${data.arrivalAirport || ''}</p><p>Confirmation: ${data.confirmationCode || '—'}</p>`
|
||||||
|
return { title, content: html, reminder }
|
||||||
|
}
|
||||||
|
if (category === 'package') {
|
||||||
|
const title = `Colis ${data.trackingNumber || data.carrier || ''}`.trim() || subject.slice(0, 80)
|
||||||
|
const del = data.expectedDelivery ? new Date(String(data.expectedDelivery)) : null
|
||||||
|
const reminder = del && !Number.isNaN(del.getTime()) ? del : null
|
||||||
|
const html = `<p><strong>${title}</strong></p><p>Transporteur: ${data.carrier || '—'}</p><p>Suivi: ${data.trackingNumber || '—'}</p><p>Statut: ${data.status || '—'}</p>`
|
||||||
|
return { title, content: html, reminder }
|
||||||
|
}
|
||||||
|
const title = `Abonnement ${data.serviceName || ''}`.trim() || subject.slice(0, 80)
|
||||||
|
const ren = data.renewalDate ? new Date(String(data.renewalDate)) : null
|
||||||
|
const reminder = ren && !Number.isNaN(ren.getTime())
|
||||||
|
? new Date(ren.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
: null
|
||||||
|
const html = `<p><strong>${title}</strong></p><p>Renouvellement: ${data.renewalDate || '—'}</p><p>Montant: ${data.amount ?? '—'} ${data.currency || ''}</p>`
|
||||||
|
return { title, content: html, reminder }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GmailScannerService {
|
||||||
|
async scanUser(userId: string): Promise<{ processed: number; created: number }> {
|
||||||
|
const tokens = await getGoogleTokens(userId, 'gmail')
|
||||||
|
if (!tokens) return { processed: 0, created: 0 }
|
||||||
|
|
||||||
|
let processed = 0
|
||||||
|
let created = 0
|
||||||
|
|
||||||
|
for (const { category, q } of SCAN_QUERIES) {
|
||||||
|
const listRes = await gmailFetch(
|
||||||
|
userId,
|
||||||
|
`/messages?maxResults=5&q=${encodeURIComponent(q)}`,
|
||||||
|
tokens.accessToken,
|
||||||
|
tokens.refreshToken,
|
||||||
|
)
|
||||||
|
if (!listRes.ok) continue
|
||||||
|
const listJson = await listRes.json()
|
||||||
|
const ids: string[] = (listJson.messages || []).map((m: { id: string }) => m.id)
|
||||||
|
|
||||||
|
for (const messageId of ids) {
|
||||||
|
const exists = await prisma.gmailScanHistory.findUnique({
|
||||||
|
where: { userId_messageId: { userId, messageId } },
|
||||||
|
})
|
||||||
|
if (exists) continue
|
||||||
|
|
||||||
|
const msgRes = await gmailFetch(
|
||||||
|
userId,
|
||||||
|
`/messages/${messageId}?format=full`,
|
||||||
|
tokens.accessToken,
|
||||||
|
tokens.refreshToken,
|
||||||
|
)
|
||||||
|
if (!msgRes.ok) continue
|
||||||
|
processed++
|
||||||
|
|
||||||
|
const msg = await msgRes.json()
|
||||||
|
const headers: { name: string; value: string }[] = msg.payload?.headers || []
|
||||||
|
const subject = headers.find(h => h.name.toLowerCase() === 'subject')?.value || '(Sans objet)'
|
||||||
|
const body = extractBody(msg.payload)
|
||||||
|
|
||||||
|
const extracted = await extractStructured(category, subject, body)
|
||||||
|
if (!extracted) {
|
||||||
|
await prisma.gmailScanHistory.create({
|
||||||
|
data: { userId, messageId, category, data: { subject, skipped: true } },
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const built = buildNoteFromExtraction(category, subject, extracted)
|
||||||
|
const note = await prisma.note.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
title: built.title,
|
||||||
|
content: built.content,
|
||||||
|
type: 'richtext',
|
||||||
|
labels: JSON.stringify(['gmail', category]),
|
||||||
|
reminder: built.reminder,
|
||||||
|
autoGenerated: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.gmailScanHistory.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
messageId,
|
||||||
|
category,
|
||||||
|
data: extracted as object,
|
||||||
|
noteId: note.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processed, created }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(userId: string) {
|
||||||
|
const tokens = await getGoogleTokens(userId, 'gmail')
|
||||||
|
const recent = await prisma.gmailScanHistory.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
scannedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||||
|
noteId: { not: null },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { connected: !!tokens, recentCaptures: recent }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gmailScannerService = new GmailScannerService()
|
||||||
66
memento-note/lib/integrations/google-integration-tokens.ts
Normal file
66
memento-note/lib/integrations/google-integration-tokens.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
|
export function getGoogleOAuthCredentials() {
|
||||||
|
return {
|
||||||
|
clientId: process.env.GOOGLE_CALENDAR_CLIENT_ID || process.env.AUTH_GOOGLE_ID || '',
|
||||||
|
clientSecret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET || process.env.AUTH_GOOGLE_SECRET || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readIntegrationMeta(userId: string): Promise<Record<string, unknown>> {
|
||||||
|
const aiSettings = await prisma.userAISettings.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { integrationTokens: true },
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const raw = aiSettings?.integrationTokens
|
||||||
|
if (!raw) return {}
|
||||||
|
return typeof raw === 'string' ? JSON.parse(raw) : (raw as Record<string, unknown>)
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeIntegrationMeta(userId: string, meta: Record<string, unknown>) {
|
||||||
|
await prisma.userAISettings.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: { integrationTokens: JSON.stringify(meta) },
|
||||||
|
create: { userId, integrationTokens: JSON.stringify(meta) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGoogleTokens(
|
||||||
|
userId: string,
|
||||||
|
prefix: 'calendar' | 'gmail',
|
||||||
|
): Promise<{ accessToken: string; refreshToken: string } | null> {
|
||||||
|
const meta = await readIntegrationMeta(userId)
|
||||||
|
const accessToken = meta[`${prefix}AccessToken`] as string | undefined
|
||||||
|
const refreshToken = meta[`${prefix}RefreshToken`] as string | undefined
|
||||||
|
if (accessToken && refreshToken) return { accessToken, refreshToken }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshGoogleAccessToken(
|
||||||
|
userId: string,
|
||||||
|
prefix: 'calendar' | 'gmail',
|
||||||
|
refreshToken: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { clientId, clientSecret } = getGoogleOAuthCredentials()
|
||||||
|
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!data.access_token) return null
|
||||||
|
|
||||||
|
const meta = await readIntegrationMeta(userId)
|
||||||
|
meta[`${prefix}AccessToken`] = data.access_token
|
||||||
|
await writeIntegrationMeta(userId, meta)
|
||||||
|
return data.access_token as string
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@
|
|||||||
"dropToRoot": "Drop here to move to root",
|
"dropToRoot": "Drop here to move to root",
|
||||||
"noReminders": "No active reminders.",
|
"noReminders": "No active reminders.",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
|
"dashboardPanelBody": "Your second brain at a glance: AI suggestions, quick capture, and next steps. Use the shortcuts below to jump into action.",
|
||||||
"searchNotebooksPlaceholder": "Search notebooks…",
|
"searchNotebooksPlaceholder": "Search notebooks…",
|
||||||
"clearSearch": "Clear search",
|
"clearSearch": "Clear search",
|
||||||
"insightsPanelBody": "Semantic map of your notes: thematic clusters, bridge notes, and connection suggestions.",
|
"insightsPanelBody": "Semantic map of your notes: thematic clusters, bridge notes, and connection suggestions.",
|
||||||
@@ -82,7 +83,10 @@
|
|||||||
"resizeSidebar": "Resize sidebar width",
|
"resizeSidebar": "Resize sidebar width",
|
||||||
"dailyNote": "Daily Note",
|
"dailyNote": "Daily Note",
|
||||||
"dailyNoteError": "Could not open today's note",
|
"dailyNoteError": "Could not open today's note",
|
||||||
"dailyNoteTooltip": "Open or create your personal daily journal note for today"
|
"dailyNoteTooltip": "Open or create your personal daily journal note for today",
|
||||||
|
"lightMode": "Light mode",
|
||||||
|
"darkMode": "Dark mode",
|
||||||
|
"searchShortcut": "Search (Ctrl+K)"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"title": "Notes",
|
"title": "Notes",
|
||||||
@@ -318,7 +322,12 @@
|
|||||||
"unarchived": "Unarchived",
|
"unarchived": "Unarchived",
|
||||||
"savedJustNow": "Saved",
|
"savedJustNow": "Saved",
|
||||||
"unsaved": "Unsaved changes",
|
"unsaved": "Unsaved changes",
|
||||||
"historyEnabledTooltip": "Version history enabled — changes are tracked"
|
"historyEnabledTooltip": "Version history enabled — changes are tracked",
|
||||||
|
"heading1": "Heading 1",
|
||||||
|
"heading2": "Heading 2",
|
||||||
|
"heading3": "Heading 3",
|
||||||
|
"importSuccess": "{{count}} notes imported!",
|
||||||
|
"unpublished": "Note unpublished"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "←",
|
"previous": "←",
|
||||||
@@ -360,7 +369,9 @@
|
|||||||
"confirmDeleteShort": "Confirm?",
|
"confirmDeleteShort": "Confirm?",
|
||||||
"labelRemoved": "Label \"{label}\" removed",
|
"labelRemoved": "Label \"{label}\" removed",
|
||||||
"filterByTags": "Filter by tags",
|
"filterByTags": "Filter by tags",
|
||||||
"searchTags": "Search tags"
|
"searchTags": "Search tags",
|
||||||
|
"activeCount": "{{count}} active",
|
||||||
|
"showMoreCount": "+ {{count}} more"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search",
|
"placeholder": "Search",
|
||||||
@@ -769,6 +780,7 @@
|
|||||||
"match": "{percentage}% match",
|
"match": "{percentage}% match",
|
||||||
"fused": "Fused",
|
"fused": "Fused",
|
||||||
"clickToView": "Click to view note →",
|
"clickToView": "Click to view note →",
|
||||||
|
"defaultInsight": "These notes appear to be semantically related.",
|
||||||
"overlay": {
|
"overlay": {
|
||||||
"title": "Connected Notes",
|
"title": "Connected Notes",
|
||||||
"searchPlaceholder": "Search connections...",
|
"searchPlaceholder": "Search connections...",
|
||||||
@@ -938,6 +950,8 @@
|
|||||||
"graphView": "Link map",
|
"graphView": "Link map",
|
||||||
"insights": "Semantic themes",
|
"insights": "Semantic themes",
|
||||||
"revision": "Review",
|
"revision": "Review",
|
||||||
|
"dailyNotes": "Daily Notes",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
"userManagement": "User Management",
|
"userManagement": "User Management",
|
||||||
"accountSettings": "Account Settings",
|
"accountSettings": "Account Settings",
|
||||||
"manageAISettings": "Manage AI Settings",
|
"manageAISettings": "Manage AI Settings",
|
||||||
@@ -1017,7 +1031,19 @@
|
|||||||
"autoSave": "Auto-save",
|
"autoSave": "Auto-save",
|
||||||
"autoSaveDesc": "Automatically save changes while typing",
|
"autoSaveDesc": "Automatically save changes while typing",
|
||||||
"autoSaveEnabled": "Auto-save enabled!",
|
"autoSaveEnabled": "Auto-save enabled!",
|
||||||
"autoSaveDisabled": "Auto-save disabled"
|
"autoSaveDisabled": "Auto-save disabled",
|
||||||
|
"publishedTitle": "My Published Pages",
|
||||||
|
"publishedDesc": "Manage notes you have published publicly.",
|
||||||
|
"publishedEmpty": "No published notes yet.",
|
||||||
|
"notesViewLabel": "Notes View",
|
||||||
|
"notesViewDescription": "Choose how notes are displayed in the list.",
|
||||||
|
"notesViewList": "List",
|
||||||
|
"notesViewMasonry": "Masonry",
|
||||||
|
"notesViewTable": "Table",
|
||||||
|
"importExportHelp": "Import / Export — how does it work?",
|
||||||
|
"aiConfigHelp": "How to configure your AI?",
|
||||||
|
"integrationsTitle": "Integrations",
|
||||||
|
"integrationsDesc": "Connect external services to Memento."
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
@@ -1224,7 +1250,8 @@
|
|||||||
"dismissIn": "Dismiss (closes in {timeLeft}s)",
|
"dismissIn": "Dismiss (closes in {timeLeft}s)",
|
||||||
"moveToNotebook": "Move to notebook",
|
"moveToNotebook": "Move to notebook",
|
||||||
"generalNotes": "General Notes",
|
"generalNotes": "General Notes",
|
||||||
"movedToNotebook": "Moved to notebook"
|
"movedToNotebook": "Moved to notebook",
|
||||||
|
"filterNotebooks": "Filter notebooks…"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Admin Dashboard",
|
"title": "Admin Dashboard",
|
||||||
@@ -1956,7 +1983,7 @@
|
|||||||
"off": "Off",
|
"off": "Off",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"notAvailable": "N/A",
|
"notAvailable": "N/A",
|
||||||
"loading": "Loading...",
|
"loading": "Loading…",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
@@ -1970,7 +1997,14 @@
|
|||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
"noResults": "No notes found",
|
"noResults": "No notes found",
|
||||||
"required": "Required",
|
"required": "Required",
|
||||||
"optional": "Optional"
|
"optional": "Optional",
|
||||||
|
"notes": "notes",
|
||||||
|
"openMenu": "Open menu",
|
||||||
|
"openNav": "Open navigation",
|
||||||
|
"linkCopied": "Link copied!",
|
||||||
|
"copyLink": "Copy link",
|
||||||
|
"untitled": "Untitled",
|
||||||
|
"selectPlaceholder": "Select…"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"justNow": "just now",
|
"justNow": "just now",
|
||||||
@@ -2391,7 +2425,20 @@
|
|||||||
},
|
},
|
||||||
"lab": {
|
"lab": {
|
||||||
"initializing": "Initializing workspace",
|
"initializing": "Initializing workspace",
|
||||||
"loadingIdeas": "Loading your ideas..."
|
"loadingIdeas": "Loading your ideas...",
|
||||||
|
"loadingSlides": "Loading slides…",
|
||||||
|
"downloadFailed": "Failed to download presentation",
|
||||||
|
"exportPpt": "Export to PowerPoint",
|
||||||
|
"downloadHtml": "Download HTML",
|
||||||
|
"openFullscreen": "Open in fullscreen",
|
||||||
|
"loadingPresentation": "Loading presentation…",
|
||||||
|
"previousSlide": "Previous slide",
|
||||||
|
"nextSlide": "Next slide",
|
||||||
|
"hideNotes": "Hide notes",
|
||||||
|
"toggleNotes": "Toggle presentation notes (Shortcut: N)",
|
||||||
|
"notes": "Notes",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next"
|
||||||
},
|
},
|
||||||
"richTextEditor": {
|
"richTextEditor": {
|
||||||
"slashHint": "↑↓ navigate · Enter insert · Tab switch section",
|
"slashHint": "↑↓ navigate · Enter insert · Tab switch section",
|
||||||
@@ -2690,7 +2737,15 @@
|
|||||||
"previewDiagramTip": "Sketch concepts or generate diagrams via AI.",
|
"previewDiagramTip": "Sketch concepts or generate diagrams via AI.",
|
||||||
"previewLivingBlockTip": "Sync content between multiple notes.",
|
"previewLivingBlockTip": "Sync content between multiple notes.",
|
||||||
"previewSlidesTip": "Create interactive exportable presentations.",
|
"previewSlidesTip": "Create interactive exportable presentations.",
|
||||||
"previewTableTip": "Organize your data in rows and columns."
|
"previewTableTip": "Organize your data in rows and columns.",
|
||||||
|
"aiActionStarted": "AI Note invoked…",
|
||||||
|
"blockDeleted": "Block deleted",
|
||||||
|
"blockDuplicated": "Block duplicated",
|
||||||
|
"blockSelected": "Entire block selected",
|
||||||
|
"slashVideo": "Video",
|
||||||
|
"startVoice": "Start voice input",
|
||||||
|
"stopVoice": "Stop voice input",
|
||||||
|
"collapsibleSection": "Collapsible section"
|
||||||
},
|
},
|
||||||
"flashcards": {
|
"flashcards": {
|
||||||
"generateTitle": "Generate flashcards",
|
"generateTitle": "Generate flashcards",
|
||||||
@@ -2784,7 +2839,8 @@
|
|||||||
"cardSaved": "Card saved",
|
"cardSaved": "Card saved",
|
||||||
"cardTypeBadge": "{type}",
|
"cardTypeBadge": "{type}",
|
||||||
"reviewNow": "Review now",
|
"reviewNow": "Review now",
|
||||||
"deleteFailed": "Could not delete"
|
"deleteFailed": "Could not delete",
|
||||||
|
"toolbarGenerateHint": "SM-2 Spaced Repetition"
|
||||||
},
|
},
|
||||||
"structuredViews": {
|
"structuredViews": {
|
||||||
"enableTitle": "Organize this notebook (table, kanban, gallery…)",
|
"enableTitle": "Organize this notebook (table, kanban, gallery…)",
|
||||||
@@ -2916,7 +2972,14 @@
|
|||||||
"tagApplied": "applied",
|
"tagApplied": "applied",
|
||||||
"filterDone": "Done",
|
"filterDone": "Done",
|
||||||
"filterTodo": "To do",
|
"filterTodo": "To do",
|
||||||
"propertyStatus": "Status"
|
"propertyStatus": "Status",
|
||||||
|
"exportCsv": "Export CSV",
|
||||||
|
"importCsv": "Import CSV",
|
||||||
|
"relationEmpty": "Link a note…",
|
||||||
|
"relationNoResults": "No note found",
|
||||||
|
"relationSearch": "Search a note…",
|
||||||
|
"semanticResonances": "Semantic resonances",
|
||||||
|
"insertLink": "Insert link in editor"
|
||||||
},
|
},
|
||||||
"brainstorm": {
|
"brainstorm": {
|
||||||
"title": "Waves of Thought",
|
"title": "Waves of Thought",
|
||||||
@@ -3200,7 +3263,19 @@
|
|||||||
"instantActivation": "Instant activation",
|
"instantActivation": "Instant activation",
|
||||||
"secureDesc": "Secure payments by Stripe",
|
"secureDesc": "Secure payments by Stripe",
|
||||||
"secureTransactions": "Secure transactions",
|
"secureTransactions": "Secure transactions",
|
||||||
"satisfactionGuarantee": "30-day satisfaction guarantee"
|
"satisfactionGuarantee": "30-day satisfaction guarantee",
|
||||||
|
"cancelConfirm": "Are you sure you want to cancel your subscription? You will keep your Pro/Business access until the end of the current billing period.",
|
||||||
|
"cancelSuccess": "Your subscription has been successfully cancelled. It will end at the end of the current billing period.",
|
||||||
|
"invoices": "Invoices & Receipts",
|
||||||
|
"invoiceAmount": "Amount",
|
||||||
|
"invoiceDate": "Date",
|
||||||
|
"invoiceNumber": "Number",
|
||||||
|
"invoiceStatus": "Status",
|
||||||
|
"noInvoices": "No invoices available.",
|
||||||
|
"checkoutFailed": "Failed to start checkout. Please try again.",
|
||||||
|
"portalFailed": "Failed to open billing portal.",
|
||||||
|
"cancelFailed": "Failed to cancel subscription.",
|
||||||
|
"downloadPdf": "Download PDF"
|
||||||
},
|
},
|
||||||
"quotaPaywall": {
|
"quotaPaywall": {
|
||||||
"title": "Monthly limit reached",
|
"title": "Monthly limit reached",
|
||||||
@@ -3689,7 +3764,9 @@
|
|||||||
"openFully": "Open full screen",
|
"openFully": "Open full screen",
|
||||||
"openFullyHelp": "Replace the current note with this one",
|
"openFullyHelp": "Replace the current note with this one",
|
||||||
"readOnlyHint": "Read-only preview — open full screen to edit.",
|
"readOnlyHint": "Read-only preview — open full screen to edit.",
|
||||||
"loadFailed": "Could not open the linked note preview."
|
"loadFailed": "Could not open the linked note preview.",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"untitled": "Untitled"
|
||||||
},
|
},
|
||||||
"structuredViewBlock": {
|
"structuredViewBlock": {
|
||||||
"insertLabel": "Structured View",
|
"insertLabel": "Structured View",
|
||||||
@@ -3856,11 +3933,317 @@
|
|||||||
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
"hint_insights_refresh_desc": "If you've added new notes, click the refresh button to recalculate the clusters with the latest content."
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"title": "Integrations"
|
"title": "Integrations",
|
||||||
|
"gmail": {
|
||||||
|
"title": "Gmail",
|
||||||
|
"description": "Automatically capture flights, packages and subscriptions from your emails",
|
||||||
|
"connect": "Connect Gmail",
|
||||||
|
"disconnect": "Disconnect",
|
||||||
|
"scanNow": "Scan now",
|
||||||
|
"connected": "Gmail connected!",
|
||||||
|
"disconnected": "Gmail disconnected",
|
||||||
|
"scanDone": "{created} note(s) created from emails",
|
||||||
|
"recentCaptures": "{count} capture(s) this week",
|
||||||
|
"helpTitle": "How does Gmail work?",
|
||||||
|
"helpStep1": "Connect Gmail — you'll be redirected to Google to authorize read-only access.",
|
||||||
|
"helpStep2": "Memento scans recent emails for flights, packages and subscription renewals.",
|
||||||
|
"helpStep3": "Each match becomes a note with an automatic reminder when relevant.",
|
||||||
|
"helpStep4": "Scan runs daily in the background; you can also trigger a manual scan here."
|
||||||
|
},
|
||||||
|
"calendarConnected": "Google Calendar connected!",
|
||||||
|
"errorWith": "Error: {{error}}",
|
||||||
|
"readwiseError": "Readwise error",
|
||||||
|
"readwiseConnected": "Readwise connected — {{created}} notes created, {{updated}} updated",
|
||||||
|
"readwiseConnectError": "Readwise connection error",
|
||||||
|
"syncError": "Sync error",
|
||||||
|
"readwiseSynced": "Readwise sync — {{created}} created, {{updated}} updated",
|
||||||
|
"syncFailed": "Synchronization failed",
|
||||||
|
"readwiseDisconnected": "Readwise disconnected",
|
||||||
|
"noEvents": "No events today",
|
||||||
|
"eventsLoadError": "Failed to load events",
|
||||||
|
"meetingNoteCreated": "Meeting note created: {{summary}}",
|
||||||
|
"createNoteError": "Failed to create note",
|
||||||
|
"calendarDisconnected": "Google Calendar disconnected",
|
||||||
|
"gmailScanError": "Gmail scan error",
|
||||||
|
"calendarInfo": "How does Google Calendar work?",
|
||||||
|
"readwiseInfo": "How does Readwise work?",
|
||||||
|
"readwiseTokenPlaceholder": "Readwise token…",
|
||||||
|
"connected": "Connected",
|
||||||
|
"notConnected": "Not connected",
|
||||||
|
"connectCalendar": "Connect Google Calendar",
|
||||||
|
"todayEvents": "Today's events",
|
||||||
|
"disconnect": "Disconnect",
|
||||||
|
"connect": "Connect",
|
||||||
|
"syncNow": "Sync now",
|
||||||
|
"addNote": "+ Note",
|
||||||
|
"open": "Open",
|
||||||
|
"moreComing": "More integrations coming soon — Zapier, GitHub, Notion import…",
|
||||||
|
"calendarDesc": "Access your events and create meeting notes",
|
||||||
|
"readwiseDesc": "Import highlights from books, articles, and Kindle",
|
||||||
|
"lastSync": "Last sync:",
|
||||||
|
"createdLabel": "notes created",
|
||||||
|
"updatedLabel": "updated",
|
||||||
|
"calendarHelpStep1": "Click \"Connect Google Calendar\" — you will be redirected to Google to authorize access.",
|
||||||
|
"calendarHelpStep2": "Once connected, come back here and click \"Today's events\" to see your agenda.",
|
||||||
|
"calendarHelpStep3": "On each event, click \"+ Note\" to automatically create a meeting note with a template (Agenda / Notes / Actions).",
|
||||||
|
"calendarHelpStep4": "The note opens directly in Memento — add your notes in real time during the meeting.",
|
||||||
|
"readwiseHelpStep1": "Copy your Readwise access token.",
|
||||||
|
"readwiseHelpStep2": "Paste it in the field below and click \"Connect\". The first sync imports all your books and articles.",
|
||||||
|
"readwiseHelpStep3": "Each book becomes a note in a \"Readwise 📚\" notebook — with all your highlights organized.",
|
||||||
|
"readwiseHelpStep4": "To update with new highlights, come back here and click \"Sync now\".",
|
||||||
|
"readwiseHelpStep5": "💡 Tip: create AI flashcards from a Readwise note (🎓 button in the editor) to review your readings."
|
||||||
|
},
|
||||||
|
"homeDashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"quickCapture": "Quick Capture",
|
||||||
|
"quickCapturePlaceholder": "An idea, a thought… Enter to capture to Inbox.",
|
||||||
|
"captured": "Captured to Inbox",
|
||||||
|
"captureError": "Failed",
|
||||||
|
"mindMap": "Mind map",
|
||||||
|
"fullMap": "Full map →",
|
||||||
|
"mindMapEmpty": "No themes detected yet. Semantic analysis groups your notes by topic.",
|
||||||
|
"mindMapOpen": "Open insights map →",
|
||||||
|
"mindMapUnavailable": "Mind map unavailable.",
|
||||||
|
"themes": "themes",
|
||||||
|
"bridges": "bridges",
|
||||||
|
"aiEchoes": "AI echoes",
|
||||||
|
"bridgeNote": "Bridge note",
|
||||||
|
"theme": "Theme",
|
||||||
|
"suggestedResearch": "Suggested research",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"createAgent": "Create agent",
|
||||||
|
"agentsEmpty": "No research agents suggested yet. Keep writing — Memento will propose synthesis topics when your notes cluster.",
|
||||||
|
"agentCreated": "Agent created and started",
|
||||||
|
"agentFailed": "Failed to create agent",
|
||||||
|
"notes": "notes",
|
||||||
|
"continue": "Continue",
|
||||||
|
"noRecentNotes": "No recent notes.",
|
||||||
|
"untitled": "Untitled",
|
||||||
|
"inbox": "Inbox",
|
||||||
|
"sentiment": "Sentiment",
|
||||||
|
"analyzing": "Analyzing…",
|
||||||
|
"notEnoughNotes": "Not enough notes this week.",
|
||||||
|
"toReview": "To review",
|
||||||
|
"allCaughtUp": "All caught up.",
|
||||||
|
"toOrganize": "to organize",
|
||||||
|
"review": "Review",
|
||||||
|
"cardsDue": "cards due",
|
||||||
|
"reminders": "Reminders",
|
||||||
|
"aiFound": "AI found",
|
||||||
|
"new": "new",
|
||||||
|
"noConnections": "No connections yet. AI analyzes your notes.",
|
||||||
|
"match": "Match",
|
||||||
|
"semanticConnection": "Semantic affinity",
|
||||||
|
"suggestedBridge": "Link {clusterA} & {clusterB}",
|
||||||
|
"createBridgeNote": "Create bridge note",
|
||||||
|
"bridgeNoteCreated": "Bridge note created",
|
||||||
|
"agentDiscovery": "Agent",
|
||||||
|
"connectionBetween": "Link between \"{note1}\" and \"{note2}\"",
|
||||||
|
"intelFilterAll": "All",
|
||||||
|
"intelFilterConnections": "Links",
|
||||||
|
"intelFilterBridges": "Bridges",
|
||||||
|
"intelFilterIdeas": "Ideas",
|
||||||
|
"intelFilterAgents": "Agents",
|
||||||
|
"intelFilterEmpty": "Nothing in this category yet.",
|
||||||
|
"intelOpenNote": "Open",
|
||||||
|
"intelCompare": "Compare",
|
||||||
|
"intelMissingLink": "Missing link",
|
||||||
|
"intelViewAgent": "View agent",
|
||||||
|
"intelAgentNoResult": "Research complete — see details.",
|
||||||
|
"intelPrev": "Previous",
|
||||||
|
"intelNext": "Next",
|
||||||
|
"intelGoTo": "Discovery {index}",
|
||||||
|
"intelPosition": "{current} / {total}",
|
||||||
|
"resumeOpen": "Resume",
|
||||||
|
"pulseInbox": "{count} to organize",
|
||||||
|
"pulseReview": "{count} to review",
|
||||||
|
"pulseDiscoveries": "{count} discoveries",
|
||||||
|
"pulseClear": "All caught up — enjoy your session.",
|
||||||
|
"widgetCustomize": "Customize",
|
||||||
|
"widgetDone": "Done",
|
||||||
|
"widgetAdd": "Add widget",
|
||||||
|
"widgetReset": "Reset layout",
|
||||||
|
"widgetResetDone": "Default dashboard restored.",
|
||||||
|
"layoutEmptyTitle": "Your dashboard is empty",
|
||||||
|
"layoutEmptyHint": "Restore the recommended layout to get started with your second brain.",
|
||||||
|
"widgetCatalog": "Widget catalog",
|
||||||
|
"widgetCatalogTitle": "Customize your dashboard",
|
||||||
|
"widgetActive": "Visible",
|
||||||
|
"widgetAddShort": "Add",
|
||||||
|
"widgetOpen": "Open",
|
||||||
|
"widgetDrag": "Drag widget",
|
||||||
|
"widgetHide": "Hide widget",
|
||||||
|
"widgetStatsBridges": "Bridges",
|
||||||
|
"widgetStatsNotes": "Notes",
|
||||||
|
"widgetAgentActivityEmpty": "No agent activity in the last 48 hours.",
|
||||||
|
"widgetPinnedEmpty": "No pinned notes yet.",
|
||||||
|
"widgetActivityHint": "Notes edited over the last 90 days.",
|
||||||
|
"widgetUsageHint": "Monthly AI usage by feature.",
|
||||||
|
"widgetCategories": {
|
||||||
|
"essential": "Essentials",
|
||||||
|
"paths": "Paths & review",
|
||||||
|
"cognition": "Knowledge map",
|
||||||
|
"productivity": "Productivity",
|
||||||
|
"agents": "AI & agents"
|
||||||
|
},
|
||||||
|
"widgets": {
|
||||||
|
"capture": "Quick capture",
|
||||||
|
"resume": "Resume here",
|
||||||
|
"intelligence": "AI found",
|
||||||
|
"reminders": "Reminders",
|
||||||
|
"mind-map": "Mind map",
|
||||||
|
"agents": "Suggested research",
|
||||||
|
"sentiment": "Sentiment",
|
||||||
|
"inbox": "Inbox",
|
||||||
|
"revision": "Flashcards",
|
||||||
|
"stats": "Semantic stats",
|
||||||
|
"agent-activity": "Agent activity",
|
||||||
|
"gmail": "Gmail captures",
|
||||||
|
"activity": "Writing activity",
|
||||||
|
"pinned": "Pinned notes",
|
||||||
|
"usage": "AI quota",
|
||||||
|
"next-paths": "Next paths",
|
||||||
|
"daily-review": "Daily review",
|
||||||
|
"open-loops": "Open loops",
|
||||||
|
"daily-note": "Daily journal",
|
||||||
|
"link-suggestions": "Link suggestions",
|
||||||
|
"bridges": "Bridge opportunities",
|
||||||
|
"flashcards-progress": "Learning progress"
|
||||||
|
},
|
||||||
|
"widgetDescriptions": {
|
||||||
|
"capture": "Capture a thought instantly into your inbox.",
|
||||||
|
"resume": "Pick up your most recent notes where you left off.",
|
||||||
|
"intelligence": "Semantic links, bridge ideas, and agent discoveries.",
|
||||||
|
"reminders": "Upcoming note reminders at a glance.",
|
||||||
|
"mind-map": "Theme clusters sized by note volume.",
|
||||||
|
"agents": "AI-suggested research agents for your topics.",
|
||||||
|
"sentiment": "Emotional tone of your notes this week.",
|
||||||
|
"inbox": "Notes waiting to be filed into notebooks.",
|
||||||
|
"revision": "Flashcards due for spaced repetition review.",
|
||||||
|
"stats": "Clusters, bridge notes, and total indexed notes.",
|
||||||
|
"agent-activity": "Latest completed agent runs.",
|
||||||
|
"gmail": "Email captures synced from Gmail.",
|
||||||
|
"activity": "GitHub-style heatmap of your writing rhythm.",
|
||||||
|
"pinned": "Quick access to your pinned notes.",
|
||||||
|
"usage": "Remaining AI credits and monthly limits.",
|
||||||
|
"next-paths": "AI-suggested next steps from your latest work.",
|
||||||
|
"daily-review": "10-minute morning checklist (inbox, discoveries, one link).",
|
||||||
|
"open-loops": "Notes started but left unfinished.",
|
||||||
|
"daily-note": "Today's journal entry.",
|
||||||
|
"link-suggestions": "Passages to link into your current note.",
|
||||||
|
"bridges": "Missing links between your knowledge themes.",
|
||||||
|
"flashcards-progress": "Retention, streak, and total cards."
|
||||||
|
},
|
||||||
|
"pathsTitle": "Your next paths",
|
||||||
|
"pathsFromNote": "Based on \"{title}\"",
|
||||||
|
"pathsLoading": "Analyzing your recent work…",
|
||||||
|
"pathsEnriching": "Refining suggestions…",
|
||||||
|
"pathsEmpty": "Work on a note to unlock personalized paths.",
|
||||||
|
"pathCompareHint": "Also related: {title} — open Memory Echo to compare.",
|
||||||
|
"pathAddLinkHint": "Consider linking to \"{link}\" in your note.",
|
||||||
|
"dailyReviewHint": "10 min each morning — the habit that makes your second brain compound.",
|
||||||
|
"dailyReviewInbox": "Process inbox",
|
||||||
|
"dailyReviewDiscoveries": "Review AI discoveries",
|
||||||
|
"dailyReviewConnection": "Explore one connection",
|
||||||
|
"dailyReviewCards": "Review flashcards",
|
||||||
|
"openLoopsEmpty": "No stalled notes — you're on track.",
|
||||||
|
"openLoopsStale": "{days}d ago",
|
||||||
|
"dailyNoteOpen": "Open journal",
|
||||||
|
"linkSuggestionsEmpty": "Edit a note to get link suggestions.",
|
||||||
|
"bridgesEmpty": "No bridge opportunities right now.",
|
||||||
|
"flashRetention": "Mastered",
|
||||||
|
"flashStreak": "Streak",
|
||||||
|
"flashTotal": "Cards",
|
||||||
|
"pathTypes": {
|
||||||
|
"continue": "Continue",
|
||||||
|
"connect": "Semantic link",
|
||||||
|
"add-link": "Add to note",
|
||||||
|
"bridge": "Bridge idea",
|
||||||
|
"research": "Research agent",
|
||||||
|
"explore": "Explore theme",
|
||||||
|
"organize": "Organize",
|
||||||
|
"review": "Review",
|
||||||
|
"resurface": "Resurface",
|
||||||
|
"daily": "Journal"
|
||||||
|
},
|
||||||
|
"widgetHelpLabel": "Widget help",
|
||||||
|
"widgetHelpClose": "Close",
|
||||||
|
"sentimentDominant": "Dominant tone this week",
|
||||||
|
"widgetHelp": {
|
||||||
|
"capture": "Jot down a thought in one line. It lands in your Inbox — classify it during your daily review.",
|
||||||
|
"next-paths": "Suggested next steps based on your latest edited note: resume, link, bridge, or research.",
|
||||||
|
"resume": "Your most recently updated notes. Pick up where you left off.",
|
||||||
|
"intelligence": "AI discoveries: semantic links between notes, bridge ideas, and agent findings.",
|
||||||
|
"reminders": "Upcoming note reminders. Empty when everything is on schedule.",
|
||||||
|
"mind-map": "Theme clusters sized by note volume. Click to explore in Insights.",
|
||||||
|
"agents": "AI-suggested research agents for topics you write about often.",
|
||||||
|
"sentiment": "Emotional tone of notes edited in the last 7 days. Requires at least 3 recent notes and AI enabled.",
|
||||||
|
"inbox": "Notes without a notebook yet. File them to keep your second brain tidy.",
|
||||||
|
"revision": "Flashcards due for spaced-repetition review today.",
|
||||||
|
"stats": "Semantic index stats: active themes, bridge notes, total indexed notes.",
|
||||||
|
"agent-activity": "Agents that completed a run in the last 48 hours.",
|
||||||
|
"gmail": "Email captures synced from Gmail integration.",
|
||||||
|
"activity": "Heatmap of notes you edited over the last 90 days.",
|
||||||
|
"pinned": "Quick access to notes you pinned.",
|
||||||
|
"usage": "Monthly AI credit usage by feature.",
|
||||||
|
"daily-review": "10-minute morning checklist: inbox, discoveries, one connection, flashcards.",
|
||||||
|
"open-loops": "Notes you started but haven't touched in 3+ days.",
|
||||||
|
"daily-note": "Today's journal entry — one note per day.",
|
||||||
|
"link-suggestions": "Passages from other notes worth linking into your current work.",
|
||||||
|
"bridges": "AI suggestions to connect two separate knowledge themes with a bridge note.",
|
||||||
|
"flashcards-progress": "Learning retention rate, review streak, and total cards."
|
||||||
|
},
|
||||||
|
"pathActions": {
|
||||||
|
"continue": "Resume",
|
||||||
|
"compare": "Compare",
|
||||||
|
"addLink": "Open & link",
|
||||||
|
"openInsight": "Explore",
|
||||||
|
"createBridge": "Create bridge",
|
||||||
|
"createAgent": "Launch agent",
|
||||||
|
"exploreTheme": "View themes",
|
||||||
|
"organizeInbox": "Open inbox",
|
||||||
|
"reviewCards": "Review",
|
||||||
|
"openDaily": "Open journal"
|
||||||
|
},
|
||||||
|
"gmailCaptures": "Gmail captures",
|
||||||
|
"gmailConnect": "Connect Gmail",
|
||||||
|
"gmailRecent": "{count} this week",
|
||||||
|
"aiConsentRequired": "Enable AI to discover connections between your notes.",
|
||||||
|
"aiProviderUnavailable": "AI is temporarily unavailable. Check your provider settings.",
|
||||||
|
"memoryEchoDisabled": "Memory Echo is disabled in your AI settings.",
|
||||||
|
"enableAi": "Enable AI",
|
||||||
|
"analyzeNotes": "Analyze my notes",
|
||||||
|
"echoFound": "New connection found!",
|
||||||
|
"echoNone": "No new connections right now. Try again later.",
|
||||||
|
"echoFailed": "Analysis failed",
|
||||||
|
"alreadySeen": "seen",
|
||||||
|
"dismissConnection": "Dismiss this connection",
|
||||||
|
"loading": "…",
|
||||||
|
"emotions": {
|
||||||
|
"focused": "Focused",
|
||||||
|
"curious": "Curious",
|
||||||
|
"enthusiastic": "Enthusiastic",
|
||||||
|
"frustrated": "Frustrated",
|
||||||
|
"calm": "Calm",
|
||||||
|
"anxious": "Anxious",
|
||||||
|
"creative": "Creative",
|
||||||
|
"reflective": "Reflective"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"voiceStart": "Dictate text (microphone)",
|
"voiceStart": "Dictate text (microphone)",
|
||||||
"voiceStop": "Stop dictation"
|
"voiceStop": "Stop dictation",
|
||||||
|
"backlinks": "Backlinks",
|
||||||
|
"clickToEdit": "Click to edit",
|
||||||
|
"bold": "Bold",
|
||||||
|
"italic": "Italic",
|
||||||
|
"highlight": "Highlight",
|
||||||
|
"link": "Link",
|
||||||
|
"bulletList": "Bullet List",
|
||||||
|
"taskList": "Task List",
|
||||||
|
"heading": "Heading",
|
||||||
|
"codeBlock": "Code Block",
|
||||||
|
"plus": "Plus"
|
||||||
},
|
},
|
||||||
"wizard": {
|
"wizard": {
|
||||||
"title": "Create a notebook with AI",
|
"title": "Create a notebook with AI",
|
||||||
@@ -3933,28 +4316,148 @@
|
|||||||
"published": "Site published!",
|
"published": "Site published!",
|
||||||
"openSite": "Open site",
|
"openSite": "Open site",
|
||||||
"updateSite": "Update site",
|
"updateSite": "Update site",
|
||||||
"unpublish": "Unpublish site"
|
"unpublish": "Unpublish site",
|
||||||
|
"selectAtLeastOne": "Select at least one note",
|
||||||
|
"unpublished": "Site unpublished",
|
||||||
|
"online": "Site online",
|
||||||
|
"unpublishing": "Unpublishing…"
|
||||||
},
|
},
|
||||||
"searchModal": {
|
"searchModal": {
|
||||||
"search_ariaLabel": "Search",
|
"ariaLabel": "Search",
|
||||||
"search_placeholder": "Search across all your notes…",
|
"placeholder": "Search all notes…",
|
||||||
"search_caseSensitive": "Case sensitive",
|
"caseSensitive": "Case sensitive",
|
||||||
"search_regexMode": "Regex mode",
|
"regexMode": "Regex mode",
|
||||||
"search_trashIncluded": "Trash included",
|
"trashIncluded": "Trash included",
|
||||||
"search_openInEditor": "Open in editor",
|
"openInEditor": "Open in editor",
|
||||||
"search_title": "Memento Search",
|
"title": "Memento Search",
|
||||||
"search_favorites": "Favorites:",
|
"favorites": "Favorites:",
|
||||||
"search_searching": "Searching…",
|
"searching": "Searching…",
|
||||||
"search_noResults": "No results",
|
"noResults": "No results",
|
||||||
"search_typeToSearch": "Type to search",
|
"typeToSearch": "Type to search",
|
||||||
"search_aiAnalysis": "Analyzing…",
|
"aiAnalysis": "Analyzing…",
|
||||||
"search_aiResponse": "AI Response",
|
"aiResponse": "AI Response",
|
||||||
"search_resultsSummary": "Results summary…",
|
"resultsSummary": "Results summary…",
|
||||||
"search_documentPreview": "Document preview",
|
"documentPreview": "Document preview",
|
||||||
"search_noMatch": "No note matches.",
|
"noMatch": "No note matches your search.",
|
||||||
"search_typeForResults": "Type to get instant results.",
|
"typeForResults": "Type for instant results.",
|
||||||
"search_hintNavigate": "navigate",
|
"hintNavigate": "navigate",
|
||||||
"search_hintOpen": "open",
|
"hintOpen": "open",
|
||||||
"search_hintClose": "close"
|
"hintClose": "close"
|
||||||
|
},
|
||||||
|
"mobile": {
|
||||||
|
"aiNote": "AI Note",
|
||||||
|
"blockActions": "Block Actions",
|
||||||
|
"convertFormat": "Convert Format",
|
||||||
|
"delete": "Delete",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"paragraph": "Paragraph",
|
||||||
|
"quote": "Quote",
|
||||||
|
"selectAll": "Select All"
|
||||||
|
},
|
||||||
|
"clusters": {
|
||||||
|
"empty": "No clusters to display",
|
||||||
|
"emptyHint": "Create more notes to generate clusters",
|
||||||
|
"bridgeNote": "Bridge note",
|
||||||
|
"regularNote": "Regular note",
|
||||||
|
"notes": "notes"
|
||||||
|
},
|
||||||
|
"bridgeNotes": {
|
||||||
|
"bridges": "Bridges",
|
||||||
|
"suggestions": "Suggestions",
|
||||||
|
"powerfulTitle": "Powerful Bridge Notes",
|
||||||
|
"score": "Score",
|
||||||
|
"empty": "No significant bridge notes found yet. Deepen your research to find new connections.",
|
||||||
|
"missingLinks": "Missing Links (AI Generated)",
|
||||||
|
"bridging": "Bridging",
|
||||||
|
"dismiss": "Dismiss suggestion",
|
||||||
|
"noSuggestions": "No connection suggestions yet",
|
||||||
|
"allConnected": "All your clusters may already be connected!",
|
||||||
|
"generate": "Generate Suggestions"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"code": "Chart Code",
|
||||||
|
"invalid": "Invalid Chart",
|
||||||
|
"invalidDescription": "This chart block contains invalid or empty data.",
|
||||||
|
"editSource": "Edit chart source",
|
||||||
|
"suggestions": "Chart Suggestions",
|
||||||
|
"analyzing": "Analyzing your content for chart data…",
|
||||||
|
"debugInfo": "Debug info",
|
||||||
|
"escCancel": "Esc Cancel",
|
||||||
|
"insertFailed": "Failed to insert chart: ",
|
||||||
|
"noDataDetected": "No Data Detected",
|
||||||
|
"noDataHint": "Try adding numerical data to your note. Charts work best with:",
|
||||||
|
"dataHint1": "• Sales figures, metrics, or measurements",
|
||||||
|
"dataHint2": "• Lists with values (e.g., \"Jan: 5000, Feb: 7500\")",
|
||||||
|
"dataHint3": "• Percentages or proportions",
|
||||||
|
"dataHint4": "• Time-series data",
|
||||||
|
"debugShowAnalyzed": "Debug: Show what was analyzed",
|
||||||
|
"navigateHint": "← → Navigate",
|
||||||
|
"selectHint": "↵ Select",
|
||||||
|
"dataPoints": "data points",
|
||||||
|
"analyzingSelection": "selection",
|
||||||
|
"analyzingNote": "note",
|
||||||
|
"words": "words",
|
||||||
|
"found": "Found"
|
||||||
|
},
|
||||||
|
"byok": {
|
||||||
|
"loadError": "Loading error",
|
||||||
|
"updated": "{{name}} updated ✓",
|
||||||
|
"testHint": "To test, enter your API key…",
|
||||||
|
"aliasPlaceholder": "e.g. My pro key",
|
||||||
|
"choose": "Choose…",
|
||||||
|
"keyPlaceholder": "sk-… (optional)",
|
||||||
|
"keyValid": "Valid API key ✓",
|
||||||
|
"verifyError": "Verification error",
|
||||||
|
"keySaved": "Key {{name}} saved ✓",
|
||||||
|
"keyDeleted": "Key deleted",
|
||||||
|
"noActiveKey": "No active key — using Memento quotas",
|
||||||
|
"savedKeys": "Saved keys",
|
||||||
|
"chooseProvider": "Choose a provider…",
|
||||||
|
"apiKey": "API Key",
|
||||||
|
"fetchingModels": "Fetching models…",
|
||||||
|
"chooseModel": "Choose a model…",
|
||||||
|
"editLabel": "Edit — {{name}}",
|
||||||
|
"aliasLabel": "Alias",
|
||||||
|
"apiUrl": "API URL",
|
||||||
|
"model": "Model",
|
||||||
|
"newKey": "New API key",
|
||||||
|
"newKeyHint": "(leave empty to keep current)",
|
||||||
|
"operational": "✓ Operational",
|
||||||
|
"test": "Test",
|
||||||
|
"saveChanges": "Save changes",
|
||||||
|
"contactError": "Could not contact server",
|
||||||
|
"baseUrlRequired": "Please provide the API URL",
|
||||||
|
"keyInvalid": "Invalid API key",
|
||||||
|
"byokActive": "BYOK active",
|
||||||
|
"activeStatus": "● Active",
|
||||||
|
"inactiveStatus": "○ Inactive",
|
||||||
|
"confirmDelete": "Delete the {{name}} key?",
|
||||||
|
"addOrReplace": "Add / replace a key",
|
||||||
|
"connectProvider": "Connect your AI provider",
|
||||||
|
"optional": "(optional)",
|
||||||
|
"verify": "Verify",
|
||||||
|
"modelOperational": "Model operational ✓",
|
||||||
|
"reply": "Reply:",
|
||||||
|
"testFailed": "Test failed",
|
||||||
|
"save": "Save",
|
||||||
|
"saveWithName": "Save — {{name}}"
|
||||||
|
},
|
||||||
|
"blockPicker": {
|
||||||
|
"searchPlaceholder": "Search for a note excerpt…"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"network": "Network error"
|
||||||
|
},
|
||||||
|
"pdf": {
|
||||||
|
"exportError": "PDF export error"
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"explainProblem": "Explain the problem…"
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"defaultTitle": "Published note",
|
||||||
|
"tagline": "Your memory, augmented by AI",
|
||||||
|
"aiSheet": "AI-generated sheet",
|
||||||
|
"publishedOn": "Published on"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"dropToRoot": "Déposez ici pour déplacer à la racine",
|
"dropToRoot": "Déposez ici pour déplacer à la racine",
|
||||||
"noReminders": "Aucun rappel actif.",
|
"noReminders": "Aucun rappel actif.",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
|
"dashboardPanelBody": "Votre second brain en un coup d'œil : suggestions IA, capture rapide et prochaines pistes. Raccourcis pour agir tout de suite.",
|
||||||
"searchNotebooksPlaceholder": "Rechercher un carnet…",
|
"searchNotebooksPlaceholder": "Rechercher un carnet…",
|
||||||
"clearSearch": "Effacer la recherche",
|
"clearSearch": "Effacer la recherche",
|
||||||
"insightsPanelBody": "Cartographie sémantique de vos notes : clusters thématiques, notes-ponts et suggestions de connexion.",
|
"insightsPanelBody": "Cartographie sémantique de vos notes : clusters thématiques, notes-ponts et suggestions de connexion.",
|
||||||
@@ -82,7 +83,10 @@
|
|||||||
"resizeSidebar": "Redimensionner la largeur de la barre latérale",
|
"resizeSidebar": "Redimensionner la largeur de la barre latérale",
|
||||||
"dailyNote": "Note du jour",
|
"dailyNote": "Note du jour",
|
||||||
"dailyNoteError": "Impossible d'ouvrir la note du jour",
|
"dailyNoteError": "Impossible d'ouvrir la note du jour",
|
||||||
"dailyNoteTooltip": "Ouvrir ou créer la note personnelle pour le journal d'aujourd'hui"
|
"dailyNoteTooltip": "Ouvrir ou créer la note personnelle pour le journal d'aujourd'hui",
|
||||||
|
"lightMode": "Mode clair",
|
||||||
|
"darkMode": "Mode sombre",
|
||||||
|
"searchShortcut": "Recherche (Ctrl+K)"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"title": "Notes",
|
"title": "Notes",
|
||||||
@@ -324,7 +328,12 @@
|
|||||||
"unarchived": "Désarchivée",
|
"unarchived": "Désarchivée",
|
||||||
"savedJustNow": "Sauvegardé",
|
"savedJustNow": "Sauvegardé",
|
||||||
"unsaved": "Non sauvegardé",
|
"unsaved": "Non sauvegardé",
|
||||||
"historyEnabledTooltip": "Historique des versions activé — les modifications sont suivies"
|
"historyEnabledTooltip": "Historique des versions activé — les modifications sont suivies",
|
||||||
|
"heading1": "Titre 1",
|
||||||
|
"heading2": "Titre 2",
|
||||||
|
"heading3": "Titre 3",
|
||||||
|
"importSuccess": "{{count}} notes importées !",
|
||||||
|
"unpublished": "Note dépubliée"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "←",
|
"previous": "←",
|
||||||
@@ -366,7 +375,9 @@
|
|||||||
"confirmDeleteShort": "Confirmer ?",
|
"confirmDeleteShort": "Confirmer ?",
|
||||||
"labelRemoved": "Étiquette \"{label}\" supprimée",
|
"labelRemoved": "Étiquette \"{label}\" supprimée",
|
||||||
"filterByTags": "Filtrer par étiquettes",
|
"filterByTags": "Filtrer par étiquettes",
|
||||||
"searchTags": "Rechercher des étiquettes"
|
"searchTags": "Rechercher des étiquettes",
|
||||||
|
"activeCount": "{{count}} actifs",
|
||||||
|
"showMoreCount": "+ {{count}} de plus"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Rechercher",
|
"placeholder": "Rechercher",
|
||||||
@@ -775,6 +786,7 @@
|
|||||||
"match": "{percentage}% correspondance",
|
"match": "{percentage}% correspondance",
|
||||||
"fused": "Fusionné",
|
"fused": "Fusionné",
|
||||||
"clickToView": "Cliquer pour voir la note →",
|
"clickToView": "Cliquer pour voir la note →",
|
||||||
|
"defaultInsight": "Ces notes semblent sémantiquement liées.",
|
||||||
"overlay": {
|
"overlay": {
|
||||||
"title": "Notes connectées",
|
"title": "Notes connectées",
|
||||||
"searchPlaceholder": "Rechercher des connexions...",
|
"searchPlaceholder": "Rechercher des connexions...",
|
||||||
@@ -944,6 +956,8 @@
|
|||||||
"graphView": "Carte des liens",
|
"graphView": "Carte des liens",
|
||||||
"insights": "Thèmes sémantiques",
|
"insights": "Thèmes sémantiques",
|
||||||
"revision": "Révisions",
|
"revision": "Révisions",
|
||||||
|
"dailyNotes": "Notes du jour",
|
||||||
|
"dashboard": "Tableau de bord",
|
||||||
"userManagement": "Gestion des utilisateurs",
|
"userManagement": "Gestion des utilisateurs",
|
||||||
"accountSettings": "Paramètres du compte",
|
"accountSettings": "Paramètres du compte",
|
||||||
"manageAISettings": "Gérer les paramètres IA",
|
"manageAISettings": "Gérer les paramètres IA",
|
||||||
@@ -1023,7 +1037,19 @@
|
|||||||
"autoSave": "Auto-enregistrement",
|
"autoSave": "Auto-enregistrement",
|
||||||
"autoSaveDesc": "Enregistrer automatiquement les modifications pendant la frappe",
|
"autoSaveDesc": "Enregistrer automatiquement les modifications pendant la frappe",
|
||||||
"autoSaveEnabled": "Auto-enregistrement activé !",
|
"autoSaveEnabled": "Auto-enregistrement activé !",
|
||||||
"autoSaveDisabled": "Auto-enregistrement désactivé"
|
"autoSaveDisabled": "Auto-enregistrement désactivé",
|
||||||
|
"publishedTitle": "Mes pages publiées",
|
||||||
|
"publishedDesc": "Gérez les notes que vous avez publiées publiquement.",
|
||||||
|
"publishedEmpty": "Aucune note publiée pour le moment.",
|
||||||
|
"notesViewLabel": "Affichage des notes",
|
||||||
|
"notesViewDescription": "Choisissez comment les notes sont affichées dans la liste.",
|
||||||
|
"notesViewList": "Liste",
|
||||||
|
"notesViewMasonry": "Mosaïque",
|
||||||
|
"notesViewTable": "Tableau",
|
||||||
|
"importExportHelp": "Import / Export — comment ça fonctionne ?",
|
||||||
|
"aiConfigHelp": "Comment configurer votre IA ?",
|
||||||
|
"integrationsTitle": "Intégrations",
|
||||||
|
"integrationsDesc": "Connectez des services externes à Memento."
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
@@ -1230,7 +1256,8 @@
|
|||||||
"dismissIn": "Rejeter (ferme dans {timeLeft}s)",
|
"dismissIn": "Rejeter (ferme dans {timeLeft}s)",
|
||||||
"moveToNotebook": "Déplacer vers un carnet",
|
"moveToNotebook": "Déplacer vers un carnet",
|
||||||
"generalNotes": "Notes générales",
|
"generalNotes": "Notes générales",
|
||||||
"movedToNotebook": "Déplacé vers le carnet"
|
"movedToNotebook": "Déplacé vers le carnet",
|
||||||
|
"filterNotebooks": "Filtrer les carnets…"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Tableau de bord Admin",
|
"title": "Tableau de bord Admin",
|
||||||
@@ -1922,7 +1949,9 @@
|
|||||||
"outboundList": "Depuis cette note ({count})",
|
"outboundList": "Depuis cette note ({count})",
|
||||||
"unlinkedList": "Mentions non reliées ({count})",
|
"unlinkedList": "Mentions non reliées ({count})",
|
||||||
"refBadge": "Entrant",
|
"refBadge": "Entrant",
|
||||||
"toBadge": "Sortant"
|
"toBadge": "Sortant",
|
||||||
|
"noInbound": "Aucun lien wiki ne pointe vers cette note.",
|
||||||
|
"noOutbound": "Cette note ne contient pas encore de liens vers d'autres notes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
@@ -1960,7 +1989,7 @@
|
|||||||
"off": "Désactivé",
|
"off": "Désactivé",
|
||||||
"unknown": "Inconnu",
|
"unknown": "Inconnu",
|
||||||
"notAvailable": "N/D",
|
"notAvailable": "N/D",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement…",
|
||||||
"error": "Erreur",
|
"error": "Erreur",
|
||||||
"success": "Succès",
|
"success": "Succès",
|
||||||
"confirm": "Confirmer",
|
"confirm": "Confirmer",
|
||||||
@@ -1974,7 +2003,14 @@
|
|||||||
"search": "Rechercher...",
|
"search": "Rechercher...",
|
||||||
"noResults": "Aucune note trouvée",
|
"noResults": "Aucune note trouvée",
|
||||||
"required": "Requis",
|
"required": "Requis",
|
||||||
"optional": "Optionnel"
|
"optional": "Optionnel",
|
||||||
|
"notes": "notes",
|
||||||
|
"openMenu": "Ouvrir le menu",
|
||||||
|
"openNav": "Ouvrir la navigation",
|
||||||
|
"linkCopied": "Lien copié !",
|
||||||
|
"copyLink": "Copier le lien",
|
||||||
|
"untitled": "Sans titre",
|
||||||
|
"selectPlaceholder": "Sélectionner…"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"justNow": "à l'instant",
|
"justNow": "à l'instant",
|
||||||
@@ -2395,7 +2431,20 @@
|
|||||||
},
|
},
|
||||||
"lab": {
|
"lab": {
|
||||||
"initializing": "Initialisation de l'espace de travail",
|
"initializing": "Initialisation de l'espace de travail",
|
||||||
"loadingIdeas": "Chargement de vos idées..."
|
"loadingIdeas": "Chargement de vos idées...",
|
||||||
|
"loadingSlides": "Chargement des slides…",
|
||||||
|
"downloadFailed": "Échec du téléchargement de la présentation",
|
||||||
|
"exportPpt": "Exporter en PowerPoint",
|
||||||
|
"downloadHtml": "Télécharger le HTML",
|
||||||
|
"openFullscreen": "Ouvrir en plein écran",
|
||||||
|
"loadingPresentation": "Chargement de la présentation…",
|
||||||
|
"previousSlide": "Slide précédent",
|
||||||
|
"nextSlide": "Slide suivant",
|
||||||
|
"hideNotes": "Masquer les notes",
|
||||||
|
"toggleNotes": "Bascule des notes de présentation (Raccourci : N)",
|
||||||
|
"notes": "Notes",
|
||||||
|
"previous": "Précédent",
|
||||||
|
"next": "Suivant"
|
||||||
},
|
},
|
||||||
"richTextEditor": {
|
"richTextEditor": {
|
||||||
"slashHint": "↑↓ naviguer · Entrée insérer · Tab changer de section",
|
"slashHint": "↑↓ naviguer · Entrée insérer · Tab changer de section",
|
||||||
@@ -2694,7 +2743,15 @@
|
|||||||
"previewDiagramTip": "Esquissez des concepts ou générez des diagrammes via IA.",
|
"previewDiagramTip": "Esquissez des concepts ou générez des diagrammes via IA.",
|
||||||
"previewLivingBlockTip": "Synchronisez du contenu entre plusieurs notes.",
|
"previewLivingBlockTip": "Synchronisez du contenu entre plusieurs notes.",
|
||||||
"previewSlidesTip": "Créez des présentations interactives exportables.",
|
"previewSlidesTip": "Créez des présentations interactives exportables.",
|
||||||
"previewTableTip": "Organisez vos données en lignes et colonnes."
|
"previewTableTip": "Organisez vos données en lignes et colonnes.",
|
||||||
|
"aiActionStarted": "IA Note sollicitée…",
|
||||||
|
"blockDeleted": "Bloc supprimé",
|
||||||
|
"blockDuplicated": "Bloc dupliqué",
|
||||||
|
"blockSelected": "Bloc sélectionné en entier",
|
||||||
|
"slashVideo": "Vidéo",
|
||||||
|
"startVoice": "Démarrer la dictée vocale",
|
||||||
|
"stopVoice": "Arrêter la dictée vocale",
|
||||||
|
"collapsibleSection": "Section repliable"
|
||||||
},
|
},
|
||||||
"flashcards": {
|
"flashcards": {
|
||||||
"generateTitle": "Générer des flashcards",
|
"generateTitle": "Générer des flashcards",
|
||||||
@@ -2788,7 +2845,8 @@
|
|||||||
"cardSaved": "Carte enregistrée",
|
"cardSaved": "Carte enregistrée",
|
||||||
"cardTypeBadge": "{type}",
|
"cardTypeBadge": "{type}",
|
||||||
"reviewNow": "Réviser maintenant",
|
"reviewNow": "Réviser maintenant",
|
||||||
"deleteFailed": "Impossible de supprimer"
|
"deleteFailed": "Impossible de supprimer",
|
||||||
|
"toolbarGenerateHint": "Révision espacée SM-2"
|
||||||
},
|
},
|
||||||
"structuredViews": {
|
"structuredViews": {
|
||||||
"enableTitle": "Organiser ce carnet (tableau, kanban, galerie…)",
|
"enableTitle": "Organiser ce carnet (tableau, kanban, galerie…)",
|
||||||
@@ -2920,7 +2978,14 @@
|
|||||||
"tagApplied": "appliqué",
|
"tagApplied": "appliqué",
|
||||||
"filterDone": "Fait",
|
"filterDone": "Fait",
|
||||||
"filterTodo": "À faire",
|
"filterTodo": "À faire",
|
||||||
"propertyStatus": "Statut"
|
"propertyStatus": "Statut",
|
||||||
|
"exportCsv": "Exporter CSV",
|
||||||
|
"importCsv": "Importer CSV",
|
||||||
|
"relationEmpty": "Lier une note…",
|
||||||
|
"relationNoResults": "Aucune note trouvée",
|
||||||
|
"relationSearch": "Rechercher une note…",
|
||||||
|
"semanticResonances": "Résonances sémantiques",
|
||||||
|
"insertLink": "Insérer le lien dans l'éditeur"
|
||||||
},
|
},
|
||||||
"brainstorm": {
|
"brainstorm": {
|
||||||
"title": "Vagues de pensée",
|
"title": "Vagues de pensée",
|
||||||
@@ -3167,8 +3232,8 @@
|
|||||||
"planSince": "Membre depuis",
|
"planSince": "Membre depuis",
|
||||||
"checkoutSuccessTitle": "Abonnement activé !",
|
"checkoutSuccessTitle": "Abonnement activé !",
|
||||||
"checkoutSuccessBody": "Bienvenue sur {tier}. Vos fonctionnalités sont maintenant débloquées.",
|
"checkoutSuccessBody": "Bienvenue sur {tier}. Vos fonctionnalités sont maintenant débloquées.",
|
||||||
"subscriptionType": "subscriptionType",
|
"subscriptionType": "Type d'abonnement",
|
||||||
"renewalDate": "renewalDate",
|
"renewalDate": "Date de renouvellement",
|
||||||
"noRenewalDate": "—",
|
"noRenewalDate": "—",
|
||||||
"tab": "Facturation",
|
"tab": "Facturation",
|
||||||
"currentUsage": "Utilisation actuelle",
|
"currentUsage": "Utilisation actuelle",
|
||||||
@@ -3204,7 +3269,19 @@
|
|||||||
"instantActivation": "Activation instantanée",
|
"instantActivation": "Activation instantanée",
|
||||||
"secureDesc": "Paiements sécurisés par Stripe",
|
"secureDesc": "Paiements sécurisés par Stripe",
|
||||||
"secureTransactions": "Transactions sécurisées",
|
"secureTransactions": "Transactions sécurisées",
|
||||||
"satisfactionGuarantee": "Satisfait ou remboursé 30 jours"
|
"satisfactionGuarantee": "Satisfait ou remboursé 30 jours",
|
||||||
|
"cancelConfirm": "Êtes-vous sûr de vouloir résilier votre abonnement ? Vous conserverez vos accès Pro/Business jusqu'à la fin de la période en cours.",
|
||||||
|
"cancelSuccess": "Votre abonnement a été résilié avec succès. Il prendra fin à la fin de la période de facturation en cours.",
|
||||||
|
"invoices": "Factures & reçus",
|
||||||
|
"invoiceAmount": "Montant",
|
||||||
|
"invoiceDate": "Date",
|
||||||
|
"invoiceNumber": "Numéro",
|
||||||
|
"invoiceStatus": "Statut",
|
||||||
|
"noInvoices": "Aucune facture disponible.",
|
||||||
|
"checkoutFailed": "Échec du démarrage du paiement. Veuillez réessayer.",
|
||||||
|
"portalFailed": "Échec de l'ouverture du portail de facturation.",
|
||||||
|
"cancelFailed": "Échec de l'annulation de l'abonnement.",
|
||||||
|
"downloadPdf": "Télécharger le PDF"
|
||||||
},
|
},
|
||||||
"quotaPaywall": {
|
"quotaPaywall": {
|
||||||
"title": "Limite mensuelle atteinte",
|
"title": "Limite mensuelle atteinte",
|
||||||
@@ -3693,7 +3770,9 @@
|
|||||||
"openFully": "Ouvrir en plein écran",
|
"openFully": "Ouvrir en plein écran",
|
||||||
"openFullyHelp": "Remplacer la note courante par cette note",
|
"openFullyHelp": "Remplacer la note courante par cette note",
|
||||||
"readOnlyHint": "Aperçu en lecture seule — ouvrez en plein écran pour modifier.",
|
"readOnlyHint": "Aperçu en lecture seule — ouvrez en plein écran pour modifier.",
|
||||||
"loadFailed": "Impossible d'ouvrir l'aperçu de la note liée."
|
"loadFailed": "Impossible d'ouvrir l'aperçu de la note liée.",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"untitled": "Sans titre"
|
||||||
},
|
},
|
||||||
"structuredViewBlock": {
|
"structuredViewBlock": {
|
||||||
"insertLabel": "Vue structurée",
|
"insertLabel": "Vue structurée",
|
||||||
@@ -3860,11 +3939,317 @@
|
|||||||
"hint_insights_refresh_desc": "Si vous avez ajouté de nouvelles notes, cliquez sur le bouton de rafraîchissement pour recalculer les clusters avec le contenu le plus récent."
|
"hint_insights_refresh_desc": "Si vous avez ajouté de nouvelles notes, cliquez sur le bouton de rafraîchissement pour recalculer les clusters avec le contenu le plus récent."
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"title": "Intégrations"
|
"title": "Intégrations",
|
||||||
|
"gmail": {
|
||||||
|
"title": "Gmail",
|
||||||
|
"description": "Capture automatique des vols, colis et abonnements depuis vos e-mails",
|
||||||
|
"connect": "Connecter Gmail",
|
||||||
|
"disconnect": "Déconnecter",
|
||||||
|
"scanNow": "Scanner maintenant",
|
||||||
|
"connected": "Gmail connecté !",
|
||||||
|
"disconnected": "Gmail déconnecté",
|
||||||
|
"scanDone": "{created} note(s) créée(s) depuis les e-mails",
|
||||||
|
"recentCaptures": "{count} capture(s) cette semaine",
|
||||||
|
"helpTitle": "Comment fonctionne Gmail ?",
|
||||||
|
"helpStep1": "Connectez Gmail — vous serez redirigé vers Google pour autoriser un accès en lecture seule.",
|
||||||
|
"helpStep2": "Memento analyse vos e-mails récents : vols, colis, renouvellements d'abonnement.",
|
||||||
|
"helpStep3": "Chaque correspondance devient une note, avec rappel automatique si pertinent.",
|
||||||
|
"helpStep4": "Le scan tourne chaque jour en arrière-plan ; vous pouvez aussi lancer un scan manuel ici."
|
||||||
|
},
|
||||||
|
"calendarConnected": "Google Calendar connecté !",
|
||||||
|
"errorWith": "Erreur : {{error}}",
|
||||||
|
"readwiseError": "Erreur Readwise",
|
||||||
|
"readwiseConnected": "Readwise connecté — {{created}} notes créées, {{updated}} mises à jour",
|
||||||
|
"readwiseConnectError": "Erreur de connexion Readwise",
|
||||||
|
"syncError": "Erreur de synchronisation",
|
||||||
|
"readwiseSynced": "Sync Readwise — {{created}} créées, {{updated}} mises à jour",
|
||||||
|
"syncFailed": "Erreur de synchronisation",
|
||||||
|
"readwiseDisconnected": "Readwise déconnecté",
|
||||||
|
"noEvents": "Aucun événement aujourd'hui",
|
||||||
|
"eventsLoadError": "Erreur de chargement des événements",
|
||||||
|
"meetingNoteCreated": "Note de réunion créée : {{summary}}",
|
||||||
|
"createNoteError": "Erreur lors de la création de la note",
|
||||||
|
"calendarDisconnected": "Google Calendar déconnecté",
|
||||||
|
"gmailScanError": "Erreur de scan Gmail",
|
||||||
|
"calendarInfo": "Comment fonctionne Google Calendar ?",
|
||||||
|
"readwiseInfo": "Comment fonctionne Readwise ?",
|
||||||
|
"readwiseTokenPlaceholder": "Token Readwise…",
|
||||||
|
"connected": "Connecté",
|
||||||
|
"notConnected": "Non connecté",
|
||||||
|
"connectCalendar": "Connecter Google Calendar",
|
||||||
|
"todayEvents": "Événements aujourd'hui",
|
||||||
|
"disconnect": "Déconnecter",
|
||||||
|
"connect": "Connecter",
|
||||||
|
"syncNow": "Synchroniser maintenant",
|
||||||
|
"addNote": "+ Note",
|
||||||
|
"open": "Ouvrir",
|
||||||
|
"moreComing": "D'autres intégrations arrivent bientôt — Zapier, GitHub, Notion import…",
|
||||||
|
"calendarDesc": "Accédez à vos événements et créez des notes de réunion",
|
||||||
|
"readwiseDesc": "Importez vos surlignages de livres, articles et Kindle",
|
||||||
|
"lastSync": "Dernière sync :",
|
||||||
|
"createdLabel": "notes créées",
|
||||||
|
"updatedLabel": "mises à jour",
|
||||||
|
"calendarHelpStep1": "Cliquez « Connecter Google Calendar » — vous serez redirigé vers Google pour autoriser l'accès.",
|
||||||
|
"calendarHelpStep2": "Une fois connecté, revenez ici et cliquez « Événements aujourd'hui » pour voir votre agenda.",
|
||||||
|
"calendarHelpStep3": "Sur chaque événement, cliquez « + Note » pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).",
|
||||||
|
"calendarHelpStep4": "La note s'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.",
|
||||||
|
"readwiseHelpStep1": "Copiez votre token d'accès Readwise.",
|
||||||
|
"readwiseHelpStep2": "Collez-le dans le champ ci-dessous et cliquez « Connecter ». La première synchronisation importe tous vos livres et articles.",
|
||||||
|
"readwiseHelpStep3": "Chaque livre devient une note dans un carnet « Readwise 📚 » — avec tous vos surlignages organisés.",
|
||||||
|
"readwiseHelpStep4": "Pour mettre à jour avec de nouveaux surlignages, revenez ici et cliquez « Synchroniser maintenant ».",
|
||||||
|
"readwiseHelpStep5": "💡 Astuce : créez des flashcards IA depuis une note Readwise (bouton 🎓 dans l'éditeur) pour réviser vos lectures."
|
||||||
|
},
|
||||||
|
"homeDashboard": {
|
||||||
|
"title": "Tableau de bord",
|
||||||
|
"quickCapture": "Capture rapide",
|
||||||
|
"quickCapturePlaceholder": "Une idée, une pensée… Entrée pour capturer dans l'Inbox.",
|
||||||
|
"captured": "Capturé dans l'Inbox",
|
||||||
|
"captureError": "Erreur",
|
||||||
|
"mindMap": "Carte mentale",
|
||||||
|
"fullMap": "Vue complète →",
|
||||||
|
"mindMapEmpty": "Pas encore de thèmes détectés. L'analyse sémantique regroupe vos notes par sujets.",
|
||||||
|
"mindMapOpen": "Ouvrir la cartographie →",
|
||||||
|
"mindMapUnavailable": "Cartographie indisponible.",
|
||||||
|
"themes": "thèmes",
|
||||||
|
"bridges": "ponts",
|
||||||
|
"aiEchoes": "échos IA",
|
||||||
|
"bridgeNote": "Note-pont",
|
||||||
|
"theme": "Thème",
|
||||||
|
"suggestedResearch": "Recherches suggérées",
|
||||||
|
"dismiss": "Ignorer",
|
||||||
|
"createAgent": "Créer l'agent",
|
||||||
|
"agentsEmpty": "Aucune recherche suggérée pour l'instant. Continuez à écrire — Memento proposera des synthèses quand vos notes formeront des thèmes.",
|
||||||
|
"agentCreated": "Agent créé et lancé",
|
||||||
|
"agentFailed": "Échec de la création",
|
||||||
|
"notes": "notes",
|
||||||
|
"continue": "Reprendre ici",
|
||||||
|
"noRecentNotes": "Aucune note récente.",
|
||||||
|
"untitled": "Sans titre",
|
||||||
|
"inbox": "Inbox",
|
||||||
|
"sentiment": "Analyse émotionnelle",
|
||||||
|
"analyzing": "Analyse en cours…",
|
||||||
|
"notEnoughNotes": "Pas assez de notes cette semaine.",
|
||||||
|
"toReview": "À traiter",
|
||||||
|
"allCaughtUp": "Tout est à jour.",
|
||||||
|
"toOrganize": "à organiser",
|
||||||
|
"review": "Révisions",
|
||||||
|
"cardsDue": "cartes dues",
|
||||||
|
"reminders": "Rappels",
|
||||||
|
"aiFound": "L'IA a trouvé",
|
||||||
|
"new": "nouveaux",
|
||||||
|
"noConnections": "Aucune connexion trouvée. L'IA analyse vos notes.",
|
||||||
|
"match": "Connexion",
|
||||||
|
"semanticConnection": "Affinité sémantique",
|
||||||
|
"suggestedBridge": "Relier {clusterA} et {clusterB}",
|
||||||
|
"createBridgeNote": "Créer la note-pont",
|
||||||
|
"bridgeNoteCreated": "Note-pont créée",
|
||||||
|
"agentDiscovery": "Agent",
|
||||||
|
"connectionBetween": "Lien entre « {note1} » et « {note2} »",
|
||||||
|
"intelFilterAll": "Tout",
|
||||||
|
"intelFilterConnections": "Liens",
|
||||||
|
"intelFilterBridges": "Ponts",
|
||||||
|
"intelFilterIdeas": "Idées",
|
||||||
|
"intelFilterAgents": "Agents",
|
||||||
|
"intelFilterEmpty": "Rien dans cette catégorie pour l'instant.",
|
||||||
|
"intelOpenNote": "Ouvrir",
|
||||||
|
"intelCompare": "Comparer",
|
||||||
|
"intelMissingLink": "Lien manquant",
|
||||||
|
"intelViewAgent": "Voir l'agent",
|
||||||
|
"intelAgentNoResult": "Recherche terminée — voir le détail.",
|
||||||
|
"intelPrev": "Précédent",
|
||||||
|
"intelNext": "Suivant",
|
||||||
|
"intelGoTo": "Découverte {index}",
|
||||||
|
"intelPosition": "{current} / {total}",
|
||||||
|
"resumeOpen": "Reprendre",
|
||||||
|
"pulseInbox": "{count} à classer",
|
||||||
|
"pulseReview": "{count} à réviser",
|
||||||
|
"pulseDiscoveries": "{count} découvertes",
|
||||||
|
"pulseClear": "Tout est à jour — bonne session.",
|
||||||
|
"widgetCustomize": "Personnaliser",
|
||||||
|
"widgetDone": "Terminé",
|
||||||
|
"widgetAdd": "Ajouter",
|
||||||
|
"widgetReset": "Réinitialiser",
|
||||||
|
"widgetResetDone": "Tableau de bord par défaut restauré.",
|
||||||
|
"layoutEmptyTitle": "Votre tableau de bord est vide",
|
||||||
|
"layoutEmptyHint": "Restaurez la mise en page recommandée pour démarrer votre second brain.",
|
||||||
|
"widgetCatalog": "Catalogue",
|
||||||
|
"widgetCatalogTitle": "Personnaliser le tableau de bord",
|
||||||
|
"widgetActive": "Visible",
|
||||||
|
"widgetAddShort": "Ajouter",
|
||||||
|
"widgetOpen": "Ouvrir",
|
||||||
|
"widgetDrag": "Déplacer le widget",
|
||||||
|
"widgetHide": "Masquer le widget",
|
||||||
|
"widgetStatsBridges": "Ponts",
|
||||||
|
"widgetStatsNotes": "Notes",
|
||||||
|
"widgetAgentActivityEmpty": "Aucune activité d'agent sur les 48 dernières heures.",
|
||||||
|
"widgetPinnedEmpty": "Aucune note épinglée pour l'instant.",
|
||||||
|
"widgetActivityHint": "Notes modifiées sur les 90 derniers jours.",
|
||||||
|
"widgetUsageHint": "Consommation IA mensuelle par fonctionnalité.",
|
||||||
|
"widgetCategories": {
|
||||||
|
"essential": "Essentiels",
|
||||||
|
"paths": "Pistes & revue",
|
||||||
|
"cognition": "Carte de connaissances",
|
||||||
|
"productivity": "Productivité",
|
||||||
|
"agents": "IA & agents"
|
||||||
|
},
|
||||||
|
"widgets": {
|
||||||
|
"capture": "Capture rapide",
|
||||||
|
"resume": "Reprendre ici",
|
||||||
|
"intelligence": "L'IA a trouvé",
|
||||||
|
"reminders": "Rappels",
|
||||||
|
"mind-map": "Carte mentale",
|
||||||
|
"agents": "Recherches suggérées",
|
||||||
|
"sentiment": "Analyse émotionnelle",
|
||||||
|
"inbox": "Inbox",
|
||||||
|
"revision": "Flashcards",
|
||||||
|
"stats": "Statistiques sémantiques",
|
||||||
|
"agent-activity": "Activité agents",
|
||||||
|
"gmail": "Captures Gmail",
|
||||||
|
"activity": "Activité d'écriture",
|
||||||
|
"pinned": "Notes épinglées",
|
||||||
|
"usage": "Quota IA",
|
||||||
|
"next-paths": "Prochaines pistes",
|
||||||
|
"daily-review": "Revue quotidienne",
|
||||||
|
"open-loops": "Boucles ouvertes",
|
||||||
|
"daily-note": "Journal du jour",
|
||||||
|
"link-suggestions": "Liens à ajouter",
|
||||||
|
"bridges": "Opportunités de pont",
|
||||||
|
"flashcards-progress": "Progression apprentissage"
|
||||||
|
},
|
||||||
|
"widgetDescriptions": {
|
||||||
|
"capture": "Saisir une pensée immédiatement dans l'inbox.",
|
||||||
|
"resume": "Reprendre vos notes les plus récentes.",
|
||||||
|
"intelligence": "Liens sémantiques, idées-ponts et découvertes d'agents.",
|
||||||
|
"reminders": "Rappels de notes à venir en un coup d'œil.",
|
||||||
|
"mind-map": "Thèmes regroupés, taille proportionnelle au volume.",
|
||||||
|
"agents": "Agents de recherche suggérés par l'IA.",
|
||||||
|
"sentiment": "Tonalité émotionnelle de vos notes cette semaine.",
|
||||||
|
"inbox": "Notes en attente de classement dans un carnet.",
|
||||||
|
"revision": "Cartes à réviser (répétition espacée).",
|
||||||
|
"stats": "Clusters, notes-ponts et total de notes indexées.",
|
||||||
|
"agent-activity": "Dernières exécutions d'agents terminées.",
|
||||||
|
"gmail": "Captures e-mail synchronisées depuis Gmail.",
|
||||||
|
"activity": "Heatmap de votre rythme d'écriture.",
|
||||||
|
"pinned": "Accès rapide à vos notes épinglées.",
|
||||||
|
"usage": "Crédits IA restants et limites mensuelles.",
|
||||||
|
"next-paths": "Prochaines étapes suggérées à partir de votre travail récent.",
|
||||||
|
"daily-review": "Checklist matinale 10 min (inbox, découvertes, un lien).",
|
||||||
|
"open-loops": "Notes commencées mais laissées en suspens.",
|
||||||
|
"daily-note": "Entrée de journal du jour.",
|
||||||
|
"link-suggestions": "Passages à lier dans votre note en cours.",
|
||||||
|
"bridges": "Liens manquants entre vos thèmes de connaissance.",
|
||||||
|
"flashcards-progress": "Rétention, série et total de cartes."
|
||||||
|
},
|
||||||
|
"pathsTitle": "Vos prochaines pistes",
|
||||||
|
"pathsFromNote": "À partir de « {title} »",
|
||||||
|
"pathsLoading": "Analyse de votre activité récente…",
|
||||||
|
"pathsEnriching": "Affinage des suggestions…",
|
||||||
|
"pathsEmpty": "Travaillez une note pour débloquer des pistes personnalisées.",
|
||||||
|
"pathCompareHint": "Également lié : {title} — ouvrez Memory Echo pour comparer.",
|
||||||
|
"pathAddLinkHint": "Pensez à lier « {link} » dans votre note.",
|
||||||
|
"dailyReviewHint": "10 min chaque matin — l'habitude qui fait composer votre second brain.",
|
||||||
|
"dailyReviewInbox": "Traiter l'inbox",
|
||||||
|
"dailyReviewDiscoveries": "Revoir les découvertes IA",
|
||||||
|
"dailyReviewConnection": "Explorer une connexion",
|
||||||
|
"dailyReviewCards": "Réviser les flashcards",
|
||||||
|
"openLoopsEmpty": "Aucune note en suspens — vous êtes à jour.",
|
||||||
|
"openLoopsStale": "il y a {days} j",
|
||||||
|
"dailyNoteOpen": "Ouvrir le journal",
|
||||||
|
"linkSuggestionsEmpty": "Modifiez une note pour obtenir des suggestions de liens.",
|
||||||
|
"bridgesEmpty": "Pas d'opportunité de pont pour l'instant.",
|
||||||
|
"flashRetention": "Maîtrisées",
|
||||||
|
"flashStreak": "Série",
|
||||||
|
"flashTotal": "Cartes",
|
||||||
|
"pathTypes": {
|
||||||
|
"continue": "Continuer",
|
||||||
|
"connect": "Lien sémantique",
|
||||||
|
"add-link": "Ajouter à la note",
|
||||||
|
"bridge": "Idée-pont",
|
||||||
|
"research": "Agent de recherche",
|
||||||
|
"explore": "Explorer le thème",
|
||||||
|
"organize": "Organiser",
|
||||||
|
"review": "Réviser",
|
||||||
|
"resurface": "Resurfacer",
|
||||||
|
"daily": "Journal"
|
||||||
|
},
|
||||||
|
"widgetHelpLabel": "Aide du widget",
|
||||||
|
"widgetHelpClose": "Fermer",
|
||||||
|
"sentimentDominant": "Tonalité dominante cette semaine",
|
||||||
|
"widgetHelp": {
|
||||||
|
"capture": "Saisissez une pensée en une ligne. Elle arrive dans l'Inbox — classez-la lors de votre revue quotidienne.",
|
||||||
|
"next-paths": "Prochaines étapes suggérées à partir de votre dernière note : reprendre, lier, créer un pont ou lancer une recherche.",
|
||||||
|
"resume": "Vos notes les plus récemment modifiées. Reprenez là où vous vous êtes arrêté.",
|
||||||
|
"intelligence": "Découvertes IA : liens sémantiques, idées-ponts et résultats d'agents.",
|
||||||
|
"reminders": "Rappels de notes à venir. Vide quand tout est à jour.",
|
||||||
|
"mind-map": "Thèmes regroupés, taille proportionnelle au volume de notes. Cliquez pour explorer dans Insights.",
|
||||||
|
"agents": "Agents de recherche suggérés par l'IA pour vos sujets récurrents.",
|
||||||
|
"sentiment": "Tonalité émotionnelle des notes modifiées sur les 7 derniers jours. Nécessite au moins 3 notes récentes et l'IA activée.",
|
||||||
|
"inbox": "Notes sans carnet. Classez-les pour garder un second brain ordonné.",
|
||||||
|
"revision": "Cartes flashcards à réviser aujourd'hui (répétition espacée).",
|
||||||
|
"stats": "Statistiques de l'index sémantique : thèmes, notes-ponts, total indexé.",
|
||||||
|
"agent-activity": "Agents ayant terminé une exécution dans les 48 dernières heures.",
|
||||||
|
"gmail": "Captures e-mail synchronisées depuis l'intégration Gmail.",
|
||||||
|
"activity": "Heatmap des notes modifiées sur les 90 derniers jours.",
|
||||||
|
"pinned": "Accès rapide à vos notes épinglées.",
|
||||||
|
"usage": "Consommation mensuelle des crédits IA par fonctionnalité.",
|
||||||
|
"daily-review": "Checklist matinale 10 min : inbox, découvertes, une connexion, flashcards.",
|
||||||
|
"open-loops": "Notes commencées mais sans modification depuis 3+ jours.",
|
||||||
|
"daily-note": "Entrée de journal du jour — une note par jour.",
|
||||||
|
"link-suggestions": "Passages d'autres notes à lier dans votre travail en cours.",
|
||||||
|
"bridges": "Suggestions IA pour relier deux thèmes distincts par une note-pont.",
|
||||||
|
"flashcards-progress": "Taux de rétention, série de révisions et total de cartes."
|
||||||
|
},
|
||||||
|
"pathActions": {
|
||||||
|
"continue": "Reprendre",
|
||||||
|
"compare": "Comparer",
|
||||||
|
"addLink": "Ouvrir & lier",
|
||||||
|
"openInsight": "Explorer",
|
||||||
|
"createBridge": "Créer le pont",
|
||||||
|
"createAgent": "Lancer l'agent",
|
||||||
|
"exploreTheme": "Voir les thèmes",
|
||||||
|
"organizeInbox": "Ouvrir l'inbox",
|
||||||
|
"reviewCards": "Réviser",
|
||||||
|
"openDaily": "Ouvrir le journal"
|
||||||
|
},
|
||||||
|
"gmailCaptures": "Captures Gmail",
|
||||||
|
"gmailConnect": "Connecter Gmail",
|
||||||
|
"gmailRecent": "{count} cette semaine",
|
||||||
|
"aiConsentRequired": "Activez l'IA pour découvrir les connexions entre vos notes.",
|
||||||
|
"aiProviderUnavailable": "L'IA est temporairement indisponible. Vérifiez la configuration du fournisseur.",
|
||||||
|
"memoryEchoDisabled": "Memory Echo est désactivé dans vos paramètres IA.",
|
||||||
|
"enableAi": "Activer l'IA",
|
||||||
|
"analyzeNotes": "Analyser mes notes",
|
||||||
|
"echoFound": "Nouvelle connexion trouvée !",
|
||||||
|
"echoNone": "Pas de nouvelle connexion pour l'instant. Réessayez plus tard.",
|
||||||
|
"echoFailed": "Échec de l'analyse",
|
||||||
|
"alreadySeen": "vu",
|
||||||
|
"dismissConnection": "Ignorer cette connexion",
|
||||||
|
"loading": "…",
|
||||||
|
"emotions": {
|
||||||
|
"focused": "Concentré",
|
||||||
|
"curious": "Curieux",
|
||||||
|
"enthusiastic": "Enthousiaste",
|
||||||
|
"frustrated": "Frustré",
|
||||||
|
"calm": "Calme",
|
||||||
|
"anxious": "Anxieux",
|
||||||
|
"creative": "Créatif",
|
||||||
|
"reflective": "Réfléchi"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"voiceStart": "Dicter du texte (microphone)",
|
"voiceStart": "Dicter du texte (microphone)",
|
||||||
"voiceStop": "Arrêter la dictée"
|
"voiceStop": "Arrêter la dictée",
|
||||||
|
"backlinks": "Liens entrants",
|
||||||
|
"clickToEdit": "Cliquez pour éditer",
|
||||||
|
"bold": "Gras",
|
||||||
|
"italic": "Italique",
|
||||||
|
"highlight": "Surligner",
|
||||||
|
"link": "Lien",
|
||||||
|
"bulletList": "Liste à puces",
|
||||||
|
"taskList": "Liste de tâches",
|
||||||
|
"heading": "Titre",
|
||||||
|
"codeBlock": "Bloc de code",
|
||||||
|
"plus": "Plus"
|
||||||
},
|
},
|
||||||
"wizard": {
|
"wizard": {
|
||||||
"title": "Créer un carnet avec l'IA",
|
"title": "Créer un carnet avec l'IA",
|
||||||
@@ -3937,28 +4322,148 @@
|
|||||||
"published": "Site publié !",
|
"published": "Site publié !",
|
||||||
"openSite": "Ouvrir le site",
|
"openSite": "Ouvrir le site",
|
||||||
"updateSite": "Mettre à jour le site",
|
"updateSite": "Mettre à jour le site",
|
||||||
"unpublish": "Dépublier le site"
|
"unpublish": "Dépublier le site",
|
||||||
|
"selectAtLeastOne": "Sélectionne au moins une note",
|
||||||
|
"unpublished": "Site dépublié",
|
||||||
|
"online": "Site en ligne",
|
||||||
|
"unpublishing": "Dépublication en cours…"
|
||||||
},
|
},
|
||||||
"searchModal": {
|
"searchModal": {
|
||||||
"search_ariaLabel": "Recherche",
|
"ariaLabel": "Recherche",
|
||||||
"search_placeholder": "Rechercher dans toutes vos notes…",
|
"placeholder": "Rechercher dans toutes les notes…",
|
||||||
"search_caseSensitive": "Respecter la casse",
|
"caseSensitive": "Sensible à la casse",
|
||||||
"search_regexMode": "Mode regex",
|
"regexMode": "Mode regex",
|
||||||
"search_trashIncluded": "Corbeille incluse",
|
"trashIncluded": "Corbeille incluse",
|
||||||
"search_openInEditor": "Ouvrir dans l'éditeur",
|
"openInEditor": "Ouvrir dans l'éditeur",
|
||||||
"search_title": "Memento Search",
|
"title": "Recherche Memento",
|
||||||
"search_favorites": "Favoris :",
|
"favorites": "Favoris :",
|
||||||
"search_searching": "Recherche en cours…",
|
"searching": "Recherche en cours…",
|
||||||
"search_noResults": "Aucun résultat",
|
"noResults": "Aucun résultat",
|
||||||
"search_typeToSearch": "Tapez pour rechercher",
|
"typeToSearch": "Tapez pour rechercher",
|
||||||
"search_aiAnalysis": "Analyse...",
|
"aiAnalysis": "Analyse en cours…",
|
||||||
"search_aiResponse": "Réponse IA",
|
"aiResponse": "Réponse IA",
|
||||||
"search_resultsSummary": "Synthèse des résultats...",
|
"resultsSummary": "Synthèse des résultats…",
|
||||||
"search_documentPreview": "Aperçu du document",
|
"documentPreview": "Aperçu du document",
|
||||||
"search_noMatch": "Aucune note ne correspond.",
|
"noMatch": "Aucune note ne correspond à votre recherche.",
|
||||||
"search_typeForResults": "Tapez pour obtenir des résultats instantanés.",
|
"typeForResults": "Tapez pour des résultats instantanés.",
|
||||||
"search_hintNavigate": "naviguer",
|
"hintNavigate": "naviguer",
|
||||||
"search_hintOpen": "ouvrir",
|
"hintOpen": "ouvrir",
|
||||||
"search_hintClose": "fermer"
|
"hintClose": "fermer"
|
||||||
|
},
|
||||||
|
"mobile": {
|
||||||
|
"aiNote": "IA Note",
|
||||||
|
"blockActions": "Actions sur le bloc",
|
||||||
|
"convertFormat": "Convertir le format",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"duplicate": "Dupliquer",
|
||||||
|
"paragraph": "Paragraphe",
|
||||||
|
"quote": "Citation",
|
||||||
|
"selectAll": "Sélectionner tout"
|
||||||
|
},
|
||||||
|
"clusters": {
|
||||||
|
"empty": "Aucun groupe à afficher",
|
||||||
|
"emptyHint": "Créez plus de notes pour générer des groupes",
|
||||||
|
"bridgeNote": "Note pont",
|
||||||
|
"regularNote": "Note standard",
|
||||||
|
"notes": "notes"
|
||||||
|
},
|
||||||
|
"bridgeNotes": {
|
||||||
|
"bridges": "Ponts",
|
||||||
|
"suggestions": "Suggestions",
|
||||||
|
"powerfulTitle": "Notes pont puissantes",
|
||||||
|
"score": "Score",
|
||||||
|
"empty": "Aucune note pont significative trouvée. Approfondissez vos recherches pour découvrir de nouvelles connexions.",
|
||||||
|
"missingLinks": "Liens manquants (générés par l'IA)",
|
||||||
|
"bridging": "Relie",
|
||||||
|
"dismiss": "Rejeter la suggestion",
|
||||||
|
"noSuggestions": "Aucune suggestion de connexion",
|
||||||
|
"allConnected": "Tous vos groupes sont peut-être déjà connectés !",
|
||||||
|
"generate": "Générer des suggestions"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"code": "Code du graphique",
|
||||||
|
"invalid": "Graphique invalide",
|
||||||
|
"invalidDescription": "Ce bloc de graphique contient des données invalides ou vides.",
|
||||||
|
"editSource": "Modifier le code source",
|
||||||
|
"suggestions": "Suggestions de graphiques",
|
||||||
|
"analyzing": "Analyse de votre contenu pour des données graphiques…",
|
||||||
|
"debugInfo": "Infos de débogage",
|
||||||
|
"escCancel": "Échap Annuler",
|
||||||
|
"insertFailed": "Échec de l'insertion du graphique : ",
|
||||||
|
"noDataDetected": "Aucune donnée détectée",
|
||||||
|
"noDataHint": "Essayez d'ajouter des données numériques à votre note. Les graphiques fonctionnent mieux avec :",
|
||||||
|
"dataHint1": "• Chiffres de vente, métriques ou mesures",
|
||||||
|
"dataHint2": "• Listes avec valeurs (ex. « Jan : 5000, Fév : 7500 »)",
|
||||||
|
"dataHint3": "• Pourcentages ou proportions",
|
||||||
|
"dataHint4": "• Données temporelles",
|
||||||
|
"debugShowAnalyzed": "Débogage : voir ce qui a été analysé",
|
||||||
|
"navigateHint": "← → Naviguer",
|
||||||
|
"selectHint": "↵ Sélectionner",
|
||||||
|
"dataPoints": "points de données",
|
||||||
|
"analyzingSelection": "sélection",
|
||||||
|
"analyzingNote": "note",
|
||||||
|
"words": "mots",
|
||||||
|
"found": "Trouvé"
|
||||||
|
},
|
||||||
|
"byok": {
|
||||||
|
"loadError": "Erreur de chargement",
|
||||||
|
"updated": "{{name}} mis à jour ✓",
|
||||||
|
"testHint": "Pour tester, entrez la clé API…",
|
||||||
|
"aliasPlaceholder": "ex. Ma clé pro",
|
||||||
|
"choose": "Choisir…",
|
||||||
|
"keyPlaceholder": "sk-… (optionnel)",
|
||||||
|
"keyValid": "Clé API valide ✓",
|
||||||
|
"verifyError": "Erreur de vérification",
|
||||||
|
"keySaved": "Clé {{name}} enregistrée ✓",
|
||||||
|
"keyDeleted": "Clé supprimée",
|
||||||
|
"noActiveKey": "Aucune clé active — utilisation des quotas Memento",
|
||||||
|
"savedKeys": "Clés enregistrées",
|
||||||
|
"chooseProvider": "Choisir un fournisseur…",
|
||||||
|
"apiKey": "Clé API",
|
||||||
|
"fetchingModels": "Récupération des modèles…",
|
||||||
|
"chooseModel": "Choisir un modèle…",
|
||||||
|
"editLabel": "Modifier — {{name}}",
|
||||||
|
"aliasLabel": "Alias",
|
||||||
|
"apiUrl": "URL de l'API",
|
||||||
|
"model": "Modèle",
|
||||||
|
"newKey": "Nouvelle clé API",
|
||||||
|
"newKeyHint": "(laisser vide pour conserver l'actuelle)",
|
||||||
|
"operational": "✓ Opérationnel",
|
||||||
|
"test": "Tester",
|
||||||
|
"saveChanges": "Enregistrer les modifications",
|
||||||
|
"contactError": "Impossible de contacter le serveur",
|
||||||
|
"baseUrlRequired": "Veuillez renseigner l'URL de l'API",
|
||||||
|
"keyInvalid": "Clé API invalide",
|
||||||
|
"byokActive": "BYOK actif",
|
||||||
|
"activeStatus": "● Actif",
|
||||||
|
"inactiveStatus": "○ Inactif",
|
||||||
|
"confirmDelete": "Supprimer la clé {{name}} ?",
|
||||||
|
"addOrReplace": "Ajouter / remplacer une clé",
|
||||||
|
"connectProvider": "Connecter votre fournisseur IA",
|
||||||
|
"optional": "(optionnel)",
|
||||||
|
"verify": "Vérifier",
|
||||||
|
"modelOperational": "Modèle opérationnel ✓",
|
||||||
|
"reply": "Réponse :",
|
||||||
|
"testFailed": "Échec du test",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"saveWithName": "Enregistrer — {{name}}"
|
||||||
|
},
|
||||||
|
"blockPicker": {
|
||||||
|
"searchPlaceholder": "Rechercher un extrait de note…"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"network": "Erreur réseau"
|
||||||
|
},
|
||||||
|
"pdf": {
|
||||||
|
"exportError": "Erreur export PDF"
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"explainProblem": "Expliquez le problème…"
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"defaultTitle": "Note publiée",
|
||||||
|
"tagline": "Votre mémoire augmentée par l'IA",
|
||||||
|
"aiSheet": "Fiche générée par IA",
|
||||||
|
"publishedOn": "Publié sur"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AgentSuggestion" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"topic" TEXT NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"suggestedRole" TEXT NOT NULL,
|
||||||
|
"suggestedType" TEXT NOT NULL DEFAULT 'researcher',
|
||||||
|
"suggestedFrequency" TEXT NOT NULL DEFAULT 'weekly',
|
||||||
|
"relatedNoteIds" TEXT NOT NULL,
|
||||||
|
"clusterId" INTEGER,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AgentSuggestion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AgentSuggestion_userId_status_idx" ON "AgentSuggestion"("userId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AgentSuggestion_userId_clusterId_key" ON "AgentSuggestion"("userId", "clusterId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AgentSuggestion" ADD CONSTRAINT "AgentSuggestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GmailScanHistory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"data" JSONB,
|
||||||
|
"noteId" TEXT,
|
||||||
|
"scannedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "GmailScanHistory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GmailScanHistory_userId_scannedAt_idx" ON "GmailScanHistory"("userId", "scannedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GmailScanHistory_userId_messageId_key" ON "GmailScanHistory"("userId", "messageId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GmailScanHistory" ADD CONSTRAINT "GmailScanHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "UserAISettings" ADD COLUMN IF NOT EXISTS "dashboardLayout" JSONB;
|
||||||
@@ -30,6 +30,7 @@ model User {
|
|||||||
onboardingStep Int @default(0)
|
onboardingStep Int @default(0)
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
agents Agent[]
|
agents Agent[]
|
||||||
|
agentSuggestions AgentSuggestion[]
|
||||||
aiFeedback AiFeedback[]
|
aiFeedback AiFeedback[]
|
||||||
brainstormActivities BrainstormActivity[]
|
brainstormActivities BrainstormActivity[]
|
||||||
brainstormParticipant BrainstormParticipant[]
|
brainstormParticipant BrainstormParticipant[]
|
||||||
@@ -58,6 +59,7 @@ model User {
|
|||||||
bridgeSuggestions BridgeSuggestion[]
|
bridgeSuggestions BridgeSuggestion[]
|
||||||
flashcardDecks FlashcardDeck[]
|
flashcardDecks FlashcardDeck[]
|
||||||
errorLogs ErrorLog[]
|
errorLogs ErrorLog[]
|
||||||
|
gmailScanHistory GmailScanHistory[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@@ -370,6 +372,7 @@ model UserAISettings {
|
|||||||
autoSave Boolean @default(true)
|
autoSave Boolean @default(true)
|
||||||
aiProcessingConsent Boolean @default(false)
|
aiProcessingConsent Boolean @default(false)
|
||||||
svgComplexity String @default("simple")
|
svgComplexity String @default("simple")
|
||||||
|
dashboardLayout Json?
|
||||||
integrationTokens Json? // Stores third-party integration tokens (Readwise, Calendar, etc.)
|
integrationTokens Json? // Stores third-party integration tokens (Readwise, Calendar, etc.)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -480,6 +483,39 @@ model AgentAction {
|
|||||||
@@index([agentId])
|
@@index([agentId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AgentSuggestion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
topic String
|
||||||
|
reason String
|
||||||
|
suggestedRole String @db.Text
|
||||||
|
suggestedType String @default("researcher")
|
||||||
|
suggestedFrequency String @default("weekly")
|
||||||
|
relatedNoteIds String // JSON string[]
|
||||||
|
clusterId Int?
|
||||||
|
status String @default("pending") // pending | accepted | dismissed
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, status])
|
||||||
|
@@unique([userId, clusterId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GmailScanHistory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
messageId String
|
||||||
|
category String // flight | package | subscription
|
||||||
|
data Json?
|
||||||
|
noteId String?
|
||||||
|
scannedAt DateTime @default(now())
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, messageId])
|
||||||
|
@@index([userId, scannedAt])
|
||||||
|
}
|
||||||
|
|
||||||
model Conversation {
|
model Conversation {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String?
|
title String?
|
||||||
|
|||||||
Reference in New Issue
Block a user