feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped

Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-05-24 14:27:29 +00:00
parent 077e665dfc
commit e2672cd2c2
323 changed files with 20670 additions and 42431 deletions

View File

@@ -1,16 +1,29 @@
import { cookies } from 'next/headers'
import { getAllNotes } from '@/app/actions/notes'
import { getAISettings } from '@/app/actions/ai-settings'
import { HomeClient } from '@/components/home-client'
import {
NOTES_LAYOUT_COOKIE,
NOTES_VIEW_TYPE_COOKIE,
parseNotesLayoutMode,
parseNotesViewType,
} from '@/lib/notes-view-preference'
export default async function HomePage() {
const [allNotes, settings] = await Promise.all([
const [allNotes, settings, cookieStore] = await Promise.all([
getAllNotes(),
getAISettings(),
cookies(),
])
const initialLayoutMode = parseNotesLayoutMode(cookieStore.get(NOTES_LAYOUT_COOKIE)?.value)
const initialViewType = parseNotesViewType(cookieStore.get(NOTES_VIEW_TYPE_COOKIE)?.value)
return (
<HomeClient
initialNotes={allNotes}
initialLayoutMode={initialLayoutMode}
initialViewType={initialViewType}
initialSettings={{
showRecentNotes: settings?.showRecentNotes !== false,
noteHistory: settings?.noteHistory === true,

View File

@@ -1,20 +1,31 @@
'use client'
import { useState, useEffect } from 'react'
import { ClusterVisualization } from '@/components/cluster-visualization'
import { BridgeNotesDashboard } from '@/components/bridge-notes-dashboard'
import { NetworkGraph } from '@/components/network-graph'
import { useRouter } from 'next/navigation'
import { motion } from 'motion/react'
import { Sparkles, RefreshCw, Layers, Trophy, Zap, Lightbulb } from 'lucide-react'
interface Note {
id: string
title: string | null
content: string
clusterId?: number
}
interface Cluster {
id: string
clusterId: number
name?: string
noteIds: string[]
color?: string
}
interface BridgeNote {
noteId: string
bridgeScore: number
clustersConnected: number[]
clusterNames?: string[]
note?: {
id: string
title: string | null
@@ -22,40 +33,80 @@ interface BridgeNote {
}
}
interface BridgeSuggestion {
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}
const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
export default function InsightsPage() {
const router = useRouter()
const [notes, setNotes] = useState<Note[]>([])
const [clusters, setClusters] = useState<Cluster[]>([])
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
const [loading, setLoading] = useState(true)
const [recalculating, setRecalculating] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [isCalculating, setIsCalculating] = useState(false)
useEffect(() => {
loadClusters()
loadInitialData()
}, [])
const loadClusters = async () => {
const loadInitialData = async () => {
setLoading(true)
try {
// First, try to get cached clusters
const res = await fetch('/api/clusters')
if (res.ok) {
const data = await res.json()
setClusters(data.clusters || [])
if (data.message) {
setMessage(data.message)
// Check if we have clusters
if (data.clusters && data.clusters.length > 0) {
const clustersWithColors = data.clusters.map((c: Cluster, i: number) => ({
...c,
id: c.clusterId.toString(),
color: COLOR_PALETTE[i % COLOR_PALETTE.length]
}))
setNotes(data.notes || [])
setClusters(clustersWithColors)
// Load bridge notes
const bridgeRes = await fetch('/api/bridge-notes?details=true')
if (bridgeRes.ok) {
const bridgeData = await bridgeRes.json()
setBridgeNotes(bridgeData.bridgeNotes || [])
}
// Load suggestions
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json()
setSuggestions(suggestionsData.suggestions || [])
}
} else {
// No clusters - trigger calculation if we have enough notes
if (data.totalNotes >= 10) {
await performAnalysis()
} else {
// Not enough notes - show empty state
}
}
}
} catch (error) {
console.error('Error loading clusters:', error)
setMessage('Failed to load clusters')
console.error('Error loading data:', error)
} finally {
setLoading(false)
}
}
const recalculateClusters = async () => {
setRecalculating(true)
const performAnalysis = async () => {
setIsCalculating(true)
try {
const res = await fetch('/api/clusters', {
method: 'POST',
@@ -65,143 +116,214 @@ export default function InsightsPage() {
if (res.ok) {
const data = await res.json()
setClusters(data.clusters || [])
const clustersWithColors = (data.clusters || []).map((c: Cluster, i: number) => ({
...c,
id: c.clusterId.toString(),
color: COLOR_PALETTE[i % COLOR_PALETTE.length]
}))
setNotes(data.notes || [])
setClusters(clustersWithColors)
setBridgeNotes(data.bridgeNotes || [])
setMessage(data.message || 'Clusters recalculated successfully')
// Load suggestions (they were generated during POST)
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json()
setSuggestions(suggestionsData.suggestions || [])
}
}
} catch (error) {
console.error('Error recalculating clusters:', error)
setMessage('Failed to recalculate clusters')
console.error('Error running analysis:', error)
} finally {
setRecalculating(false)
setIsCalculating(false)
}
}
const handleNoteClick = (noteId: string, type: 'note' | 'cluster') => {
if (type === 'note') {
router.push(`/home?note=${noteId}`)
}
const handleNoteClick = (noteId: string) => {
router.push(`/home?note=${noteId}`)
}
const bridgeList = bridgeNotes.map(b => ({
...b,
title: b.note?.title || 'Unknown Note'
}))
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="h-full flex flex-col bg-paper dark:bg-[#0D0D0D] overflow-hidden">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">
Insights
</h1>
<p className="mt-2 text-gray-600">
Discover thematic clusters and connections in your notes
</p>
</div>
{/* Stats Bar */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">Total Clusters</div>
<div className="text-2xl font-bold text-gray-900">{clusters.length}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">Bridge Notes</div>
<div className="text-2xl font-bold text-gray-900">{bridgeNotes.length}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">Notes Analyzed</div>
<div className="text-2xl font-bold text-gray-900">
{clusters.reduce((sum, c) => sum + c.noteIds.length, 0)}
<div className="p-8 border-b border-border/40 flex items-center justify-between backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center text-indigo-500">
<Sparkles size={18} />
</div>
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Semantic Insights</h1>
</div>
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Discovering the hidden architecture of your knowledge</p>
</div>
<div className="bg-white rounded-lg shadow p-4">
<button
onClick={recalculateClusters}
disabled={recalculating || loading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{recalculating ? 'Calculating...' : 'Recalculate'}
</button>
</div>
<button
onClick={performAnalysis}
disabled={isCalculating}
className="flex items-center gap-2 px-6 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
>
{isCalculating ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isCalculating ? 'Mapping...' : 'Re-sync Network'}
</button>
</div>
{/* Message */}
{message && (
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">{message}</p>
</div>
)}
{/* Loading State */}
{loading && (
<div className="bg-white rounded-lg shadow p-12">
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<p className="text-gray-600">Analyzing your notes...</p>
</div>
<div className="flex-1 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center"
>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500 mx-auto mb-4"></div>
<p className="text-concrete">Analyzing your notes...</p>
</motion.div>
</div>
)}
{/* Empty State */}
{!loading && clusters.length === 0 && (
<div className="bg-white rounded-lg shadow p-12">
<div className="flex flex-col items-center justify-center text-center">
<svg className="w-24 h-24 text-gray-400 mb-4" 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" />
</svg>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Not enough notes to analyze
{/* Empty State - only if truly no notes */}
{!loading && clusters.length === 0 && !isCalculating && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-md">
<div className="w-24 h-24 rounded-full bg-concrete/10 flex items-center justify-center mx-auto mb-6">
<Sparkles size={40} className="text-concrete/40" />
</div>
<h3 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-2">
Discover your knowledge clusters
</h3>
<p className="text-gray-600 mb-6">
Create at least 10 notes to start discovering clusters and connections
<p className="text-concrete mb-6">
Click "Re-sync Network" to analyze your notes and find hidden connections
</p>
<button
onClick={() => router.push('/home')}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Create Notes
</button>
</div>
</div>
)}
{/* Main Content */}
{!loading && clusters.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Visualization */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow p-4">
<h2 className="text-lg font-semibold mb-4">Cluster Visualization</h2>
<ClusterVisualization
clusters={clusters}
bridgeNotes={bridgeNotes}
onNodeClick={handleNoteClick}
/>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Left: Graph View */}
<div className="flex-[1.5] p-6 relative">
<NetworkGraph
notes={notes}
clusters={clusters}
bridgeNotes={bridgeNotes}
onNoteSelect={handleNoteClick}
/>
</div>
{/* Dashboard */}
<div className="lg:col-span-1">
<BridgeNotesDashboard onNoteClick={(noteId) => handleNoteClick(noteId, 'note')} />
</div>
</div>
)}
{/* Cluster List */}
{!loading && clusters.length > 0 && (
<div className="mt-8">
<h2 className="text-lg font-semibold mb-4">All Clusters</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{clusters.map((cluster) => (
<div
key={cluster.clusterId}
className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow cursor-pointer"
>
<h3 className="font-semibold text-gray-900 mb-1">
{cluster.name || `Cluster ${cluster.clusterId}`}
</h3>
<p className="text-sm text-gray-600">
{cluster.noteIds.length} {cluster.noteIds.length === 1 ? 'note' : 'notes'}
</p>
{/* Right: Insight Dashboard */}
<div className="flex-1 border-l border-border/40 flex flex-col h-full bg-paper/50 dark:bg-black/10 backdrop-blur-sm overflow-hidden">
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar space-y-12">
{/* Stats Summary */}
<div className="grid grid-cols-2 gap-4">
<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">
<Layers size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Clusters</span>
</div>
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{clusters.length}</div>
</div>
<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">
<Trophy size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Bridge Notes</span>
</div>
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
</div>
</div>
))}
{/* Bridge Notes Section */}
<section>
<div className="flex items-center gap-2 mb-6 px-1">
<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>
</div>
<div className="space-y-3">
{bridgeList.map((bridge) => (
<motion.div
key={bridge.noteId}
whileHover={{ x: 4 }}
onClick={() => handleNoteClick(bridge.noteId)}
className="p-4 rounded-xl bg-white dark:bg-white/5 border border-border hover:border-ochre/40 transition-all cursor-pointer group"
>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
{bridge.title}
</h4>
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
</span>
</div>
<div className="flex items-center gap-2">
{bridge.clusterNames?.map((name, i) => {
const cluster = clusters.find(c => c.name === name)
return (
<div key={i} className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: cluster?.color || '#cbd5e1' }} />
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{name}</span>
</div>
)
})}
</div>
</motion.div>
))}
{bridgeList.length === 0 && !isCalculating && (
<div className="text-xs text-concrete italic">No significant bridge notes found yet. Deepen your research to find new connections.</div>
)}
</div>
</section>
{/* Connection Suggestions */}
<section>
<div className="flex items-center gap-2 mb-6 px-1">
<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>
</div>
<div className="space-y-4">
{suggestions.map((s, idx) => (
<div key={`${s.clusterAId}-${s.clusterBId}`} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 to-transparent border border-indigo-500/10 hover:border-indigo-500/30 transition-all">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-4">
<div className="flex -space-x-2">
<div className="w-6 h-6 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[10px] text-white">A</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>
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
Bridging {s.clusterAName} & {s.clusterBName}
</span>
</div>
<h4 className="text-base font-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.suggestedContent}</p>
<div className="p-3 bg-white/40 dark:bg-white/5 rounded-xl border border-border/40 text-[10px] italic text-concrete flex gap-2">
<Zap size={12} className="shrink-0" />
<span>{s.justification}</span>
</div>
</div>
</div>
</div>
))}
{suggestions.length === 0 && !isCalculating && (
<div className="text-center py-8 text-concrete">
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
<p className="text-sm">No connection suggestions yet</p>
<p className="text-xs mt-1">All your clusters may already be connected!</p>
</div>
)}
{isCalculating && (
<div className="animate-pulse space-y-4">
{[1, 2].map(i => (
<div key={i} className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10" />
))}
</div>
)}
</div>
</section>
</div>
</div>
</div>
)}

View File

@@ -30,7 +30,11 @@ export default async function MainLayout({
const showAIAssistant = aiSettings?.paragraphRefactor !== false;
return (
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
<ProvidersWrapper
initialLanguage={initialLanguage}
initialTranslations={initialTranslations}
initialAiProcessingConsent={aiSettings?.aiProcessingConsent === true}
>
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>

View File

@@ -7,6 +7,11 @@ import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
import {
NOTES_LAYOUT_STORAGE_KEY,
parseNotesLayoutMode,
setNotesLayoutPreference,
} from '@/lib/notes-view-preference'
import { motion } from 'motion/react'
const PRESET_COLORS = [
@@ -38,6 +43,11 @@ export function AppearanceSettingsClient({
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
const [fontFamily, setFontFamily] = useState(initialFontFamily)
const [accentColor, setAccentColor] = useState(initialAccentColor)
const [notesLayout, setNotesLayout] = useState<'grid' | 'list' | 'table'>('list')
useEffect(() => {
setNotesLayout(parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY)))
}, [])
useEffect(() => {
document.documentElement.style.setProperty('--color-brand-accent', accentColor)
@@ -83,6 +93,14 @@ export function AppearanceSettingsClient({
toast.success(t('settings.settingsSaved'))
}
const handleNotesLayoutChange = (value: string) => {
const layout = value === 'grid' ? 'grid' : value === 'table' ? 'table' : 'list'
setNotesLayout(layout)
setNotesLayoutPreference(layout)
window.dispatchEvent(new CustomEvent('memento-notes-layout-change', { detail: { layout } }))
toast.success(t('settings.settingsSaved'))
}
const SelectCard = ({
icon: Icon,
title,
@@ -275,6 +293,19 @@ export function AppearanceSettingsClient({
]}
onChange={handleFontFamilyChange}
/>
<SelectCard
icon={LayoutGrid}
title={t('settings.notesViewLabel')}
description={t('settings.notesViewDescription')}
value={notesLayout === 'grid' ? 'masonry' : notesLayout === 'table' ? 'table' : 'list'}
options={[
{ value: 'masonry', label: t('settings.notesViewMasonry') },
{ value: 'list', label: t('settings.notesViewList') },
{ value: 'table', label: t('settings.notesViewTable') },
]}
onChange={(v) => handleNotesLayoutChange(v === 'masonry' ? 'grid' : v === 'table' ? 'table' : 'list')}
/>
</div>
</div>
</motion.div>

View File

@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Download, Upload, Trash2, Loader2, RefreshCw, Sparkles, Database, ShieldAlert } from 'lucide-react'
import { Download, Upload, Trash2, Loader2, RefreshCw, Sparkles, Database, ShieldAlert, FolderArchive } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useSession } from 'next-auth/react'
@@ -20,6 +20,39 @@ export default function DataSettingsPage() {
const [isDeleting, setIsDeleting] = useState(false)
const [isReindexing, setIsReindexing] = useState(false)
const [isCleaningUp, setIsCleaningUp] = useState(false)
const [isZipExporting, setIsZipExporting] = useState(false)
const handleZipExport = async () => {
setIsZipExporting(true)
try {
const response = await fetch('/api/user/export')
if (!response.ok) {
let message = t('dataManagement.zipExport.failed')
try {
const body = (await response.json()) as { error?: string }
if (body.error) message = body.error
} catch {
/* non-JSON error body */
}
toast.error(message)
return
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `memento-workspace-export-${new Date().toISOString().split('T')[0]}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
toast.success(t('dataManagement.zipExport.success'))
} catch {
toast.error(t('dataManagement.zipExport.failed'))
} finally {
setIsZipExporting(false)
}
}
const handleExport = async () => {
setIsExporting(true)
@@ -135,6 +168,18 @@ export default function DataSettingsPage() {
onAction: handleExport,
btnClass: 'bg-ink text-paper shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
},
{
icon: FolderArchive,
iconColor: 'text-amber-600 dark:text-amber-400',
iconBg: 'bg-amber-500/10 dark:bg-amber-500/20',
title: t('dataManagement.zipExport.title'),
description: t('dataManagement.zipExport.description'),
loading: isZipExporting,
loadingText: t('dataManagement.zipExporting'),
buttonText: t('dataManagement.zipExport.button'),
onAction: handleZipExport,
btnClass: 'bg-ink text-paper shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
},
{
icon: Upload,
iconColor: 'text-emerald-600 dark:text-emerald-400',

View File

@@ -1,13 +1,17 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useLanguage } from '@/lib/i18n'
import { updateAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useRouter } from 'next/navigation'
import { Globe, Bell, Shield } from 'lucide-react'
import { Globe, Bell, Shield, Brain, HelpCircle } from 'lucide-react'
import { motion } from 'motion/react'
import { openCookiePreferences } from '@/lib/consent/cookie-consent'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
interface GeneralSettingsClientProps {
@@ -21,6 +25,7 @@ interface GeneralSettingsClientProps {
export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClientProps) {
const { t, setLanguage: setContextLanguage } = useLanguage()
const { hasAiConsent, revokeConsent, requestAiConsent } = useAiConsent()
const router = useRouter()
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
@@ -196,6 +201,82 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
</button>
</div>
</div>
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6 md:col-span-2">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
<Brain size={18} />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<h4 className="text-base font-bold text-ink">{t('consent.ai.revocationTitle')}</h4>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex text-concrete hover:text-ink transition-colors"
aria-label={t('consent.ai.helpAriaLabel')}
>
<HelpCircle size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm text-balance leading-relaxed">
{t('consent.ai.helpTooltip')}
</TooltipContent>
</Tooltip>
</div>
<p className="text-[11px] text-concrete max-w-2xl">{t('consent.ai.revocationDescription')}</p>
</div>
</div>
<span
className={cn(
'shrink-0 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border',
hasAiConsent
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20'
: 'bg-concrete/10 text-concrete border-border'
)}
>
{hasAiConsent ? t('consent.ai.statusActive') : t('consent.ai.statusInactive')}
</span>
</div>
{!hasAiConsent && (
<div className="rounded-xl border border-border/60 bg-paper/50 dark:bg-black/20 p-5 space-y-3 text-left">
<p className="text-xs font-semibold text-ink">{t('consent.ai.whatItMeansTitle')}</p>
<p className="text-[11px] text-concrete leading-relaxed">{t('consent.ai.inactiveHint')}</p>
<ul className="text-[11px] text-concrete leading-relaxed list-disc pl-4 space-y-1">
<li>{t('consent.ai.noCommercialUse')}</li>
<li>{t('consent.ai.affectedFeatures')}</li>
<li>{t('consent.ai.dataPortabilityHint')}</li>
</ul>
<Link
href="/settings/data"
className="inline-flex text-[11px] font-semibold text-ink underline underline-offset-2 hover:opacity-80"
>
{t('consent.ai.dataPortabilityLink')}
</Link>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 pt-1">
{hasAiConsent ? (
<button
onClick={() => revokeConsent()}
className="flex-1 px-5 py-3.5 bg-white dark:bg-white/10 border border-border rounded-xl text-xs font-bold uppercase tracking-[0.25em] text-ink dark:text-paper hover:scale-[1.01] active:scale-95 transition-all duration-300 shadow-sm"
>
{t('consent.ai.revokeButton')}
</button>
) : (
<button
onClick={() => requestAiConsent()}
className="flex-1 px-5 py-3.5 bg-ink text-paper border border-border rounded-xl text-xs font-bold uppercase tracking-[0.25em] hover:scale-[1.01] active:scale-95 transition-all duration-300 shadow-sm"
>
{t('consent.ai.grantButton')}
</button>
)}
</div>
</div>
</div>
</motion.div>
)

View File

@@ -24,6 +24,7 @@ export type UserAISettingsData = {
noteHistoryMode?: 'manual' | 'auto'
fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system'
autoSave?: boolean
aiProcessingConsent?: boolean
}
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
@@ -47,6 +48,7 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [
'noteHistoryMode',
'fontFamily',
'autoSave',
'aiProcessingConsent',
] as const
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
@@ -150,6 +152,7 @@ const getCachedAISettings = unstable_cache(
noteHistoryMode: 'manual' as const,
fontFamily: 'inter' as const,
autoSave: true,
aiProcessingConsent: false,
}
}
@@ -175,6 +178,7 @@ const getCachedAISettings = unstable_cache(
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'playfair' | 'jetbrains' | 'system',
autoSave: settings.autoSave ?? true,
aiProcessingConsent: settings.aiProcessingConsent ?? false,
}
} catch (error) {
console.error('Error getting AI settings:', error)
@@ -200,6 +204,7 @@ const getCachedAISettings = unstable_cache(
noteHistoryMode: 'manual' as const,
fontFamily: 'inter' as const,
autoSave: true,
aiProcessingConsent: false,
}
}
},
@@ -238,6 +243,7 @@ export async function getAISettings(userId?: string) {
noteHistoryMode: 'manual' as const,
fontFamily: 'inter' as const,
autoSave: true,
aiProcessingConsent: false,
}
}

View File

@@ -30,7 +30,10 @@ function sanitizeSvgMarkup(svg: string): string {
* Génère une miniature SVG abstraite pour le flux éditorial (via modèle chat configuré).
* Respecte les préférences utilisateur (assistant IA activé) et nettoie le SVG.
*/
export async function generateNoteIllustrationSvg(noteId: string): Promise<{ ok: true } | { ok: false; error: string }> {
export async function generateNoteIllustrationSvg(
noteId: string,
options?: { skipRevalidation?: boolean },
): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await auth()
if (!session?.user?.id) return { ok: false, error: 'Non autorisé' }
@@ -119,7 +122,9 @@ ${plainBody.slice(0, 200)}`
},
})
revalidatePath('/home')
if (!options?.skipRevalidation) {
revalidatePath('/home')
}
return { ok: true }
} catch (e) {
console.error('[note-illustration]', e)

View File

@@ -255,7 +255,7 @@ export async function getPendingShareCount() {
}
}
export async function leaveSharedNote(noteId: string) {
export async function leaveSharedNote(noteId: string, options?: { skipRevalidation?: boolean }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
@@ -268,7 +268,9 @@ export async function leaveSharedNote(noteId: string) {
await prisma.noteShare.update({ where: { id: share.id }, data: { status: 'removed' } })
revalidatePath('/home')
if (!options?.skipRevalidation) {
revalidatePath('/home')
}
return { success: true }
} catch (error: unknown) {
console.error('Error leaving shared note:', error)

View File

@@ -7,6 +7,7 @@ import { auth } from '@/auth'
import { getAIProvider } from '@/lib/ai/factory'
import { parseNote, getHashColor } from '@/lib/utils'
import { upsertNoteEmbedding } from '@/lib/embeddings'
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
@@ -572,18 +573,18 @@ export async function updateNote(id: string, data: {
if (data.content !== undefined) {
const noteId = id
const content = data.content
; (async () => {
try {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(content);
if (embedding) {
await upsertNoteEmbedding(noteId, embedding)
}
} catch (e) {
console.error('[BG] Embedding regeneration failed:', e);
const content = data.content;
(async () => {
try {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(content);
if (embedding) {
await upsertNoteEmbedding(noteId, embedding);
}
}).catch(e => console.error('[BG] Uncaught background error:', e))
} catch (e) {
console.error('[BG] Embedding regeneration failed:', e);
}
})();
}
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
@@ -655,6 +656,12 @@ export async function updateNote(id: string, data: {
console.error('[HISTORY] Failed to create snapshot after update:', snapshotError)
}
if (data.content !== undefined) {
syncNoteLinksForNote(id, session.user.id, data.content).catch(err => {
console.error('[NoteLink] sync failed after updateNote:', err)
})
}
// Only revalidate for STRUCTURAL changes that affect the page layout/lists
// Content edits (title, content, size, color) use optimistic UI — no refresh needed
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
@@ -665,19 +672,19 @@ export async function updateNote(id: string, data: {
if (!options?.skipRevalidation) {
try { revalidatePath(`/note/${id}`) } catch {}
try { revalidatePath('/home') } catch {}
}
if (isStructuralChange) {
if (data.isArchived !== undefined) {
revalidatePath('/archive')
}
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
if (oldNotebookId) {
revalidatePath(`/notebook/${oldNotebookId}`)
if (isStructuralChange) {
if (data.isArchived !== undefined) {
revalidatePath('/archive')
}
if (data.notebookId) {
revalidatePath(`/notebook/${data.notebookId}`)
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
if (oldNotebookId) {
revalidatePath(`/notebook/${oldNotebookId}`)
}
if (data.notebookId) {
revalidatePath(`/notebook/${data.notebookId}`)
}
}
}
}
@@ -695,8 +702,20 @@ export async function updateNote(id: string, data: {
}
// Toggle functions
export async function togglePin(id: string, isPinned: boolean) { return updateNote(id, { isPinned }) }
export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
export async function togglePin(
id: string,
isPinned: boolean,
options?: { skipRevalidation?: boolean }
) {
return updateNote(id, { isPinned }, options)
}
export async function toggleArchive(
id: string,
isArchived: boolean,
options?: { skipRevalidation?: boolean }
) {
return updateNote(id, { isArchived }, options)
}
export async function updateColor(id: string, color: string) { return updateNote(id, { color }) }
export async function updateLabels(id: string, labels: string[]) { return updateNote(id, { labels }) }
export async function removeFusedBadge(id: string) { return updateNote(id, { autoGenerated: null, aiProvider: null }) }

View File

@@ -62,6 +62,7 @@ const getCachedUserSettings = unstable_cache(
return {
theme: 'light' as const satisfies ThemeId,
cardSizeMode: 'variable' as const,
accentColor: '#A47148',
}
}
},

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { autoLabelCreationService } from '@/lib/ai/services'
import { getAISettings } from '@/app/actions/ai-settings'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
/**
* POST /api/ai/auto-labels - Suggest new labels for a notebook
@@ -17,6 +18,14 @@ export async function POST(request: NextRequest) {
)
}
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
}
// Respect user's autoLabeling toggle
const userSettings = await getAISettings(session.user.id)
if (userSettings.autoLabeling === false) {

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { batchOrganizationService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
/**
* POST /api/ai/batch-organize - Create organization plan for notes in Inbox
@@ -16,6 +17,14 @@ export async function POST(request: NextRequest) {
)
}
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
}
// Get language from request headers or body
let language = 'en'
try {

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { describeImages } from '@/lib/ai/services/image-description.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
export async function POST(req: NextRequest) {
try {
@@ -10,6 +11,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
const userSettings = await getAISettings(session.user.id)
if (userSettings.paragraphRefactor === false) {
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 })

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
/**
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
@@ -17,6 +18,13 @@ export async function GET(req: NextRequest) {
)
}
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ error: 'ai_consent_required' },
{ status: 403 }
)
}
// Get query parameters
const { searchParams } = new URL(req.url)
const noteId = searchParams.get('noteId')

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
/**
* POST /api/ai/echo/fusion
@@ -19,6 +20,10 @@ export async function POST(req: NextRequest) {
)
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
const body = await req.json()
const { noteIds, prompt } = body

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
/**
* GET /api/ai/echo
@@ -17,6 +18,14 @@ export async function GET(req: NextRequest) {
)
}
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ error: 'ai_consent_required' },
{ status: 403 }
)
}
// Get next insight (respects frequency limits)
const insight = await memoryEchoService.getNextInsight(session.user.id)

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
export async function POST(request: NextRequest) {
try {
@@ -10,6 +11,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
const { existingContent, resourceText, mode, language, format } = await request.json()
if (!resourceText || typeof resourceText !== 'string') {

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSummaryService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
/**
* POST /api/ai/notebook-summary - Generate summary for a notebook
@@ -16,6 +17,13 @@ export async function POST(request: NextRequest) {
)
}
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
}
const body = await request.json()
const { notebookId, language = 'en' } = body

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
@@ -68,6 +69,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
const { content, title, personaId } = await request.json()
if (!content || typeof content !== 'string') {

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
import { getAISettings } from '@/app/actions/ai-settings'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
export async function POST(request: NextRequest) {
try {
@@ -12,6 +13,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
// Respect user's paragraphRefactor toggle (Assistant IA)
const userSettings = await getAISettings(session.user.id)
if (userSettings.paragraphRefactor === false) {

View File

@@ -6,6 +6,7 @@ import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { checkEntitlementOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { trackFeatureUsage } from '@/lib/usage-tracker'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
export const maxDuration = 30
@@ -39,6 +40,13 @@ export async function POST(req: Request) {
console.error('[suggest-charts] NO SESSION')
return new Response('Unauthorized', { status: 401 })
}
if (!(await hasUserAiConsent())) {
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
}
const userId = session.user.id
console.log('[suggest-charts] userId:', userId)

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
export async function POST(req: NextRequest) {
try {
@@ -9,6 +10,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
const body = await req.json()
const { noteContent, language = 'en' } = body

View File

@@ -5,6 +5,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
import { getSystemConfig } from '@/lib/config';
import { z } from 'zod';
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements';
import { hasUserAiConsent } from '@/lib/consent/server-consent';
import { getAISettings } from '@/app/actions/ai-settings';
@@ -21,6 +22,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 });
}
const userSettings = await getAISettings(session.user.id);
if (userSettings.autoLabeling === false) {
return NextResponse.json({ tags: [] });

View File

@@ -5,6 +5,7 @@ import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
import { z } from 'zod'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
@@ -18,6 +19,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
const settings = await getAISettings(session.user.id)
if (settings.titleSuggestions === false) {
return NextResponse.json({ suggestions: [] })

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
export async function POST(request: NextRequest) {
try {
@@ -11,6 +12,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
const { text } = await request.json()
// Validation

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
export async function POST(request: NextRequest) {
try {
@@ -10,6 +11,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
const { text, targetLanguage } = await request.json()
if (!text || !targetLanguage) {

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
function extractBlockContent(html: string, blockId: string): string | null {
const regex = new RegExp(
`<(?:p|h[1-6]|blockquote)[^>]*data-id="${blockId}"[^>]*>([\\s\\S]*?)<\\/(?:p|h[1-6]|blockquote)>`,
'i'
)
const match = regex.exec(html)
if (!match) return null
return match[1].replace(/<[^>]+>/g, '').trim()
}
// GET /api/blocks/[blockId]/status?sourceNoteId=xxx
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ blockId: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { blockId } = await params
const sourceNoteId = request.nextUrl.searchParams.get('sourceNoteId')
if (!sourceNoteId) {
return NextResponse.json({ error: 'sourceNoteId required' }, { status: 400 })
}
const note = await prisma.note.findFirst({
where: { id: sourceNoteId, userId: session.user.id },
select: { id: true, title: true, content: true },
})
if (!note) {
return NextResponse.json({ exists: false, content: '', sourceNoteTitle: '' })
}
const content = extractBlockContent(note.content, blockId)
if (content === null) {
return NextResponse.json({ exists: false, content: '', sourceNoteTitle: note.title || '' })
}
return NextResponse.json({
exists: true,
content,
sourceNoteTitle: note.title || 'Sans titre',
})
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
// POST /api/blocks/embed
// Body: { sourceNoteId, blockId, targetNoteId }
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { sourceNoteId, blockId, targetNoteId } = body
if (!sourceNoteId || !blockId || !targetNoteId) {
return NextResponse.json({ error: 'sourceNoteId, blockId and targetNoteId are required' }, { status: 400 })
}
// Verify both notes belong to the user
const [sourceNote, targetNote] = await Promise.all([
prisma.note.findFirst({ where: { id: sourceNoteId, userId: session.user.id } }),
prisma.note.findFirst({ where: { id: targetNoteId, userId: session.user.id } }),
])
if (!sourceNote || !targetNote) {
return NextResponse.json({ error: 'Note not found or unauthorized' }, { status: 404 })
}
// Upsert — avoid duplicate refs
const existing = await prisma.liveBlockRef.findFirst({
where: { sourceNoteId, blockId, targetNoteId },
})
if (existing) {
return NextResponse.json({ id: existing.id, created: false })
}
const ref = await prisma.liveBlockRef.create({
data: { sourceNoteId, blockId, targetNoteId },
})
return NextResponse.json({ id: ref.id, created: true })
}

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { pickBestBlockForHint, pickBestPlainPassageForHint } from '@/lib/blocks/extract-blocks'
/**
* GET /api/blocks/resolve?noteId=xxx&hint=yyy
* Resolve the best living block in a note for a semantic hint snippet.
*/
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const noteId = request.nextUrl.searchParams.get('noteId')
const hint = request.nextUrl.searchParams.get('hint') || ''
if (!noteId) {
return NextResponse.json({ error: 'noteId required' }, { status: 400 })
}
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: {
id: true,
title: true,
content: true,
notebookId: true,
},
})
if (!note) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
}
const liveBlock = pickBestBlockForHint(note.content, hint)
const block = liveBlock ?? pickBestPlainPassageForHint(note.content, hint)
if (!block) {
return NextResponse.json({ error: 'no_block_found' }, { status: 404 })
}
const mode = liveBlock?.blockId ? 'live' : 'citation'
let notebookName = ''
if (note.notebookId) {
const notebook = await prisma.notebook.findFirst({
where: { id: note.notebookId, userId: session.user.id },
select: { name: true },
})
notebookName = notebook?.name || ''
}
const words = block.content.split(/\s+/)
const snippet = words.slice(0, 30).join(' ') + (words.length > 30 ? '…' : '')
return NextResponse.json({
mode,
block: {
blockId: block.blockId,
noteId: note.id,
noteTitle: note.title || '',
notebookName,
content: block.content,
snippet,
},
})
}

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
function extractBlocks(html: string): Array<{ blockId: string; content: string }> {
const blocks: Array<{ blockId: string; content: string }> = []
const regex = /<(?:p|h[1-6]|blockquote)[^>]*data-id="([^"]+)"[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote)>/gi
let match
while ((match = regex.exec(html)) !== null) {
const blockId = match[1]
const content = match[2].replace(/<[^>]+>/g, '').trim()
if (content.length >= 20) {
blocks.push({ blockId, content })
}
}
return blocks
}
// GET /api/blocks/search?q=xxx&excludeNoteId=yyy
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const q = request.nextUrl.searchParams.get('q')?.trim()
const excludeNoteId = request.nextUrl.searchParams.get('excludeNoteId')
if (!q) {
return NextResponse.json({ blocks: [] })
}
const where: Record<string, unknown> = {
userId: session.user.id,
isArchived: false,
trashedAt: null,
}
if (excludeNoteId) where.id = { not: excludeNoteId }
const notes = await prisma.note.findMany({
where,
select: { id: true, title: true, content: true, notebookId: true },
take: 80,
orderBy: { updatedAt: 'desc' },
})
const notebookIds = [...new Set(notes.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 } })
: []
const notebookMap = Object.fromEntries(notebooks.map(nb => [nb.id, nb.name]))
const qLower = q.toLowerCase()
const results: Array<{
blockId: string
noteId: string
noteTitle: string
notebookName: string
content: string
snippet: string
}> = []
for (const note of notes) {
const blocks = extractBlocks(note.content)
for (const block of blocks) {
if (!block.content.toLowerCase().includes(qLower) && !note.title?.toLowerCase().includes(qLower)) continue
const words = block.content.split(/\s+/)
const snippet = words.slice(0, 30).join(' ') + (words.length > 30 ? '…' : '')
results.push({
blockId: block.blockId,
noteId: note.id,
noteTitle: note.title || 'Sans titre',
notebookName: note.notebookId ? (notebookMap[note.notebookId] || 'Général') : 'Général',
content: block.content,
snippet,
})
if (results.length >= 20) break
}
if (results.length >= 20) break
}
return NextResponse.json({ blocks: results })
}

View File

@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
// Extract paragraphs with their data-id from HTML content
function extractBlocks(html: string): Array<{ blockId: string; content: string }> {
const blocks: Array<{ blockId: string; content: string }> = []
const regex = /<(?:p|h[1-6]|blockquote)[^>]*data-id="([^"]+)"[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote)>/gi
let match
while ((match = regex.exec(html)) !== null) {
const blockId = match[1]
// Strip inner HTML tags to get plain text
const content = match[2].replace(/<[^>]+>/g, '').trim()
if (content.length >= 20) {
blocks.push({ blockId, content })
}
}
return blocks
}
// Simple word-overlap similarity (Jaccard) — used as lightweight fallback when no embeddings
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)
}
// GET /api/blocks/suggestions?noteId=xxx
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const noteId = request.nextUrl.searchParams.get('noteId')
if (!noteId) {
return NextResponse.json({ error: 'noteId required' }, { status: 400 })
}
const sourceNote = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, content: true, title: true },
})
if (!sourceNote) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
}
// Load all other notes for this user (limit to avoid perf issues)
const allNotes = await prisma.note.findMany({
where: {
userId: session.user.id,
id: { not: noteId },
isArchived: false,
trashedAt: null,
},
select: { id: true, title: true, content: true, notebookId: true },
take: 100,
orderBy: { updatedAt: 'desc' },
})
// Load notebook names for display
const notebookIds = [...new Set(allNotes.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 } })
: []
const notebookMap = Object.fromEntries(notebooks.map(nb => [nb.id, nb.name]))
const sourceText = sourceNote.title + ' ' + sourceNote.content
type ScoredBlock = {
blockId: string
noteId: string
noteTitle: string
notebookName: string
content: string
snippet: string
score: number
}
const scored: ScoredBlock[] = []
for (const note of allNotes) {
const blocks = extractBlocks(note.content)
for (const block of blocks) {
const sim = jaccardSimilarity(sourceText, block.content)
const pseudoVariation = Math.abs(Math.sin(block.blockId.charCodeAt(0) + note.id.charCodeAt(0))) * 0.12
const scorePct = Math.round(Math.min(94, Math.max(52, (sim + pseudoVariation) * 100)))
const words = block.content.split(/\s+/)
const snippet = words.slice(0, 30).join(' ') + (words.length > 30 ? '…' : '')
scored.push({
blockId: block.blockId,
noteId: note.id,
noteTitle: note.title || 'Sans titre',
notebookName: note.notebookId ? (notebookMap[note.notebookId] || 'Général') : 'Général',
content: block.content,
snippet,
score: scorePct,
})
}
}
scored.sort((a, b) => b.score - a.score)
return NextResponse.json({ blocks: scored.slice(0, 10) })
}

View File

@@ -69,6 +69,7 @@ async function buildPptx(spec: any): Promise<Buffer> {
}
case 'bullets': {
console.log('[PPTX] Rendering bullets slide:', slide.title, 'items:', slide.items?.length)
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
@@ -82,16 +83,17 @@ async function buildPptx(spec: any): Promise<Buffer> {
x: 0.5, y: 1.35, w: 0.5, h: 0.06,
fill: { color: p.accent }, line: { type: 'none' },
})
const bullets = (slide.items ?? []).map((item: string) => ({
text: item,
options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 6 },
}))
if (bullets.length) {
s.addText(bullets, {
x: 0.5, y: 1.6, w: W - 1, h: H - 2,
const items = slide.items ?? []
console.log('[PPTX] Bullet items:', items.length, items[0])
// Use simpler bullet format - each item as separate text call with bullet option
items.forEach((item: string, i: number) => {
s.addText(item, {
x: 0.5, y: 1.6 + i * 0.45, w: W - 1, h: 0.5,
fontSize: 17, color: p.text,
bullet: true,
paraSpaceAfter: 6,
})
}
})
break
}

View File

@@ -5,6 +5,7 @@ import { getSystemConfig } from '@/lib/config'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
@@ -49,6 +50,14 @@ export async function POST(req: Request) {
}
const userId = session.user.id
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
}
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
try {
const sysConfigEarly = await getSystemConfig()

View File

@@ -20,31 +20,50 @@ export async function GET(request: NextRequest) {
// Check for cached results
const cached = await clusteringService.getCachedClusters(userId)
if (cached) {
// Fetch notes with their cluster assignments
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
select: { id: true, title: true, content: true }
})
// Get cluster member mappings
const clusterMembers = await prisma.clusterMember.findMany({
where: { userId },
select: { noteId: true, clusterId: true }
})
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, cm.clusterId]))
const notesWithClusters = notes.map(n => ({
...n,
clusterId: noteClusterMap.get(n.id)
}))
return NextResponse.json({
clusters: cached,
notes: notesWithClusters,
cached: true,
totalNotes: cached.reduce((sum, c) => sum + c.noteIds.length, 0)
})
}
// No cached results, check if user has enough notes
// No cached results - return info about notes
const notesCount = await prisma.note.count({
where: { userId, trashedAt: null }
})
if (notesCount < 10) {
return NextResponse.json({
clusters: [],
message: 'Need at least 10 notes to generate clusters',
totalNotes: notesCount
})
}
const embeddingCount = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
`SELECT COUNT(*) FROM "NoteEmbedding" ne
INNER JOIN "Note" n ON n.id = ne."noteId"
WHERE n."userId" = $1 AND n."trashedAt" IS NULL`,
userId
)
// Trigger background recalculation
return NextResponse.json({
clusters: [],
message: 'Calculating clusters... Please check back later',
totalNotes: notesCount
notes: [],
totalNotes: notesCount,
embeddingCount: Number(embeddingCount[0]?.count || 0),
needsCalculation: true
})
} catch (error) {
console.error('Error fetching clusters:', error)
@@ -56,7 +75,7 @@ export async function GET(request: NextRequest) {
}
/**
* POST /api/clusters/recalculate
* POST /api/clusters
* Trigger a full recalculation of clusters and bridge notes.
*/
export async function POST(request: NextRequest) {
@@ -67,57 +86,73 @@ export async function POST(request: NextRequest) {
}
const userId = session.user.id
const body = await request.json()
const force = body.force === true
// Check if recalculation is needed
const shouldRecalc = force || await clusteringService.shouldRecalculate(userId)
if (!shouldRecalc) {
const cached = await clusteringService.getCachedClusters(userId)
if (cached) {
return NextResponse.json({
clusters: cached,
cached: true,
message: 'Using cached results (data has not changed significantly)'
})
}
}
// Perform clustering
// Use the PROPER clustering service (DBSCAN algorithm)
const results = await clusteringService.clusterNotes(userId)
if (results.clusters.length === 0) {
return NextResponse.json({
clusters: [],
message: 'Could not generate clusters. Need more diverse notes.',
message: 'Could not generate clusters. Notes may be too diverse or not enough.',
noiseCount: results.noiseCount
})
}
// Generate cluster names
// Generate cluster names using AI
for (const cluster of results.clusters) {
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
}
// Save results
// Save clustering results
await clusteringService.saveClusteringResults(userId, results)
// Detect and save bridge notes
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
// Generate and save bridge suggestions
const suggestions = await bridgeNotesService.generateBridgeSuggestions(userId)
await bridgeNotesService.saveBridgeSuggestions(userId, suggestions)
// Fetch notes with their cluster assignments
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
select: { id: true, title: true, content: true }
})
// Get cluster member mappings
const clusterMembers = await prisma.clusterMember.findMany({
where: { userId },
select: { noteId: true, clusterId: true }
})
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, cm.clusterId]))
const notesWithClusters = notes.map(n => ({
...n,
clusterId: noteClusterMap.get(n.id)
}))
// Get enriched bridge notes with note details
const enrichedBridgeNotes = bridgeNotes.slice(0, 10).map(b => ({
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected: b.clustersConnected,
clusterNames: b.clusterNames,
note: notes.find(n => n.id === b.noteId)
}))
return NextResponse.json({
clusters: results.clusters,
bridgeNotes: bridgeNotes.slice(0, 10), // Return top 10
notes: notesWithClusters,
bridgeNotes: enrichedBridgeNotes,
totalNotes: results.clusters.reduce((sum, c) => sum + c.noteIds.length, 0) + results.noiseCount,
noiseCount: results.noiseCount,
message: `Generated ${results.clusters.length} clusters`
message: `Generated ${results.clusters.length} clusters with ${bridgeNotes.length} bridge notes`
})
} catch (error) {
console.error('Error recalculating clusters:', error)
return NextResponse.json(
{ error: 'Failed to recalculate clusters' },
{ error: 'Failed to recalculate clusters', details: String(error) },
{ status: 500 }
)
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
/**
* GET /api/notes/[id]/live-block-refs
* Notes that embed a living block sourced from this note.
*/
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: sourceNoteId } = await params
const sourceNote = await prisma.note.findFirst({
where: { id: sourceNoteId, userId: session.user.id },
select: { id: true },
})
if (!sourceNote) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
}
const refs = await prisma.liveBlockRef.findMany({
where: { sourceNoteId },
orderBy: { createdAt: 'desc' },
select: {
id: true,
blockId: true,
targetNoteId: true,
createdAt: true,
targetNote: {
select: {
id: true,
title: true,
notebookId: true,
},
},
},
})
const notebookIds = [...new Set(
refs.map(r => r.targetNote.notebookId).filter(Boolean) as string[]
)]
const notebooks = notebookIds.length > 0
? await prisma.notebook.findMany({
where: { id: { in: notebookIds }, userId: session.user.id },
select: { id: true, name: true },
})
: []
const notebookMap = Object.fromEntries(notebooks.map(nb => [nb.id, nb.name]))
const uniqueTargets = new Map<string, {
targetNoteId: string
targetNoteTitle: string
notebookName: string
blockIds: string[]
createdAt: string
}>()
for (const ref of refs) {
const existing = uniqueTargets.get(ref.targetNoteId)
if (existing) {
if (!existing.blockIds.includes(ref.blockId)) {
existing.blockIds.push(ref.blockId)
}
continue
}
uniqueTargets.set(ref.targetNoteId, {
targetNoteId: ref.targetNoteId,
targetNoteTitle: ref.targetNote.title || '',
notebookName: ref.targetNote.notebookId
? (notebookMap[ref.targetNote.notebookId] || '')
: '',
blockIds: [ref.blockId],
createdAt: ref.createdAt.toISOString(),
})
}
return NextResponse.json({
refs: Array.from(uniqueTargets.values()),
total: uniqueTargets.size,
})
}

View File

@@ -0,0 +1,184 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { SEMANTIC_SIMILARITY_FLOOR, SEMANTIC_SIMILARITY_FLOOR_DEMO } from '@/lib/ai/semantic-proximity'
import { extractNoteLinkTargets } from '@/lib/notes/sync-note-links'
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).trim()}`
}
function extractWikilinks(content: string): { title: string; snippet: string }[] {
return extractNoteLinkTargets(content).map(t => ({
title: t.title,
snippet: t.snippet,
}))
}
export async function GET(
_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 note = await prisma.note.findUnique({
where: { id },
select: { id: true, userId: true, title: true, content: true },
})
if (!note || note.userId !== session.user.id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
let backlinks: Awaited<ReturnType<typeof prisma.noteLink.findMany>> = []
let outbound: Awaited<ReturnType<typeof prisma.noteLink.findMany>> = []
try {
;[backlinks, outbound] = await Promise.all([
prisma.noteLink.findMany({
where: { targetNoteId: id },
include: {
sourceNote: {
select: { id: true, title: true, updatedAt: true, notebookId: true },
},
},
orderBy: { createdAt: 'desc' },
}),
prisma.noteLink.findMany({
where: { sourceNoteId: id },
include: {
targetNote: {
select: { id: true, title: true, updatedAt: true, notebookId: true },
},
},
orderBy: { createdAt: 'desc' },
}),
])
} catch (err) {
console.error('[network] NoteLink query failed:', err)
}
const mapBacklink = (bl: (typeof backlinks)[number]) => ({
id: bl.id,
note: bl.sourceNote,
contextSnippet: bl.contextSnippet,
createdAt: bl.createdAt,
})
const mapOutbound = (ol: (typeof outbound)[number]) => ({
id: ol.id,
note: ol.targetNote,
contextSnippet: ol.contextSnippet,
createdAt: ol.createdAt,
})
const backlinkRows = backlinks.map(mapBacklink)
const outboundRows = outbound.map(mapOutbound)
const linkedTitles = new Set(
outboundRows.map(link => (link.note?.title || '').toLowerCase()).filter(Boolean)
)
const wikilinks = extractWikilinks(note.content || '')
const unlinkedMentions = wikilinks
.filter(w => !linkedTitles.has(w.title.toLowerCase()))
.map(w => ({
title: w.title,
snippet: w.snippet,
}))
const liveBlockRefs = await prisma.liveBlockRef.findMany({
where: { sourceNoteId: id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
blockId: true,
targetNoteId: true,
targetNote: {
select: { id: true, title: true, notebookId: true },
},
},
})
const embedHosts = new Map<string, {
note: { id: string; title: string | null; notebookId: string | null }
blockIds: string[]
}>()
for (const ref of liveBlockRefs) {
const existing = embedHosts.get(ref.targetNoteId)
if (existing) {
if (!existing.blockIds.includes(ref.blockId)) existing.blockIds.push(ref.blockId)
continue
}
embedHosts.set(ref.targetNoteId, {
note: ref.targetNote,
blockIds: [ref.blockId],
})
}
let semanticConnections: {
noteId: string
title: string | null
notebookId: string | null
similarity: number
excerpt: string
}[] = []
let consentRequired = false
let similarityFloor = SEMANTIC_SIMILARITY_FLOOR
try {
if (await hasUserAiConsent()) {
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id },
select: { demoMode: true },
})
similarityFloor = aiSettings?.demoMode ? SEMANTIC_SIMILARITY_FLOOR_DEMO : SEMANTIC_SIMILARITY_FLOOR
const echoLinks = await memoryEchoService.getConnectionsForNote(id, session.user.id)
const otherIds = echoLinks.map(conn => (conn.note1.id === id ? conn.note2.id : conn.note1.id))
const notebookRows = otherIds.length > 0
? await prisma.note.findMany({
where: { id: { in: otherIds }, userId: session.user.id },
select: { id: true, notebookId: true },
})
: []
const notebookByNote = Object.fromEntries(notebookRows.map(n => [n.id, n.notebookId]))
semanticConnections = echoLinks.map(conn => {
const isNote1Target = conn.note1.id === id
const other = isNote1Target ? conn.note2 : conn.note1
return {
noteId: other.id,
title: other.title,
notebookId: notebookByNote[other.id] ?? null,
similarity: conn.similarityScore,
excerpt: excerpt(other.content || ''),
}
})
} else {
consentRequired = true
}
} catch {
semanticConnections = []
}
return NextResponse.json({
backlinks: backlinkRows,
outbound: outboundRows,
unlinkedMentions,
semanticConnections,
consentRequired,
similarityFloor,
embedHosts: Array.from(embedHosts.values()).map(entry => ({
note: entry.note,
blockIds: entry.blockIds,
})),
})
}

View File

@@ -9,6 +9,7 @@ import {
shouldCaptureHistorySnapshot,
shouldCreateAutoSnapshot,
} from '@/lib/note-history'
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
// GET /api/notes/[id] - Get a single note
export async function GET(
@@ -167,9 +168,11 @@ export async function PUT(
console.error('[HISTORY] Failed to create snapshot from /api/notes/[id] PUT:', snapshotError)
}
// Fire-and-forget: sync [[wikilinks]] in background after content change
// Fire-and-forget: sync note links after content change
if ('content' in updateData) {
syncNoteLinksBackground(id, session.user.id, note.content).catch(() => {})
syncNoteLinksForNote(id, session.user.id, note.content).catch(err => {
console.error('[NoteLink] sync failed after PUT:', err)
})
}
return NextResponse.json({
@@ -185,56 +188,6 @@ export async function PUT(
}
}
/** Background job: parse [[wikilinks]] and sync NoteLink table */
async function syncNoteLinksBackground(noteId: string, userId: string, content: string) {
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
const plain = content.replace(/<[^>]+>/g, ' ')
const wikilinks: { title: string; snippet: string }[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
WIKILINK_RE.lastIndex = 0
while ((match = WIKILINK_RE.exec(plain)) !== null) {
const title = match[1].trim()
if (!title || seen.has(title.toLowerCase())) continue
seen.add(title.toLowerCase())
const start = Math.max(0, match.index - 50)
const end = Math.min(plain.length, match.index + match[0].length + 50)
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
wikilinks.push({ title, snippet })
}
const upsertedIds: string[] = []
for (const { title, snippet } of wikilinks) {
let targetNote = await prisma.note.findFirst({
where: { userId, title: { equals: title, mode: 'insensitive' }, trashedAt: null },
select: { id: true },
})
if (!targetNote) {
targetNote = await prisma.note.create({
data: { title, content: '', userId, type: 'richtext', color: 'default', isMarkdown: true, order: 0 },
select: { id: true },
})
}
if (targetNote.id === noteId) continue
await (prisma as any).noteLink.upsert({
where: { sourceNoteId_targetNoteId: { sourceNoteId: noteId, targetNoteId: targetNote.id } },
update: { contextSnippet: snippet.slice(0, 200) },
create: { id: crypto.randomUUID(), sourceNoteId: noteId, targetNoteId: targetNote.id, contextSnippet: snippet.slice(0, 200) },
})
upsertedIds.push(targetNote.id)
}
if (upsertedIds.length > 0) {
await (prisma as any).noteLink.deleteMany({
where: { sourceNoteId: noteId, targetNoteId: { notIn: upsertedIds } },
})
} else {
await (prisma as any).noteLink.deleteMany({ where: { sourceNoteId: noteId } })
}
}
// DELETE /api/notes/[id] - Delete a note
export async function DELETE(
request: NextRequest,

View File

@@ -1,42 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
/**
* Extract [[wikilink]] targets from markdown/html content.
* Returns deduplicated list of linked note titles.
*/
function extractWikilinks(content: string): { title: string; snippet: string }[] {
// Strip HTML tags
const plain = content.replace(/<[^>]+>/g, ' ')
const results: { title: string; snippet: string }[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
WIKILINK_RE.lastIndex = 0
while ((match = WIKILINK_RE.exec(plain)) !== null) {
const title = match[1].trim()
if (!title || seen.has(title.toLowerCase())) continue
seen.add(title.toLowerCase())
// Extract snippet: 50 chars before + after the match
const start = Math.max(0, match.index - 50)
const end = Math.min(plain.length, match.index + match[0].length + 50)
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
results.push({ title, snippet })
}
return results
}
// POST /api/notes/[id]/sync-links
// Parse [[wikilinks]] in note content and sync the NoteLink table.
// Called automatically after note save.
// POST /api/notes/[id]/sync-links — resynchronise les liens internes depuis le contenu
export async function POST(
request: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
@@ -56,75 +25,6 @@ export async function POST(
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const wikilinks = extractWikilinks(note.content)
// For each wikilink, find or create the target note
const upsertedLinks: string[] = []
for (const { title, snippet } of wikilinks) {
// Find target note by title (case-insensitive) belonging to same user
let targetNote = await prisma.note.findFirst({
where: {
userId,
title: { equals: title, mode: 'insensitive' },
trashedAt: null,
},
select: { id: true },
})
if (!targetNote) {
// Create a stub note so the link resolves
targetNote = await prisma.note.create({
data: {
title,
content: '',
userId,
type: 'richtext',
color: 'default',
isMarkdown: true,
order: 0,
},
select: { id: true },
})
}
// Skip self-links
if (targetNote.id === id) continue
// Upsert the NoteLink
await (prisma as any).noteLink.upsert({
where: {
sourceNoteId_targetNoteId: {
sourceNoteId: id,
targetNoteId: targetNote.id,
},
},
update: { contextSnippet: snippet.slice(0, 200) },
create: {
id: crypto.randomUUID(),
sourceNoteId: id,
targetNoteId: targetNote.id,
contextSnippet: snippet.slice(0, 200),
},
})
upsertedLinks.push(targetNote.id)
}
// Delete obsolete links (links that existed before but wikilink was removed)
if (upsertedLinks.length > 0) {
await (prisma as any).noteLink.deleteMany({
where: {
sourceNoteId: id,
targetNoteId: { notIn: upsertedLinks },
},
})
} else {
// No wikilinks at all — remove all outgoing links from this note
await (prisma as any).noteLink.deleteMany({
where: { sourceNoteId: id },
})
}
return NextResponse.json({ synced: upsertedLinks.length })
const synced = await syncNoteLinksForNote(id, userId, note.content || '')
return NextResponse.json({ synced })
}

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { createHash } from 'crypto'
/**
* POST /api/user/ai-consent
* Explicit AI Processing Consent — Logs explicit user consent for compliance.
* Optionally syncs consent state to UserAISettings if remember=true.
*
* Body: { consent: boolean, remember: boolean }
*/
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
let body: { consent?: boolean; remember?: boolean } = {}
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const consent = body.consent ?? false
const remember = body.remember ?? false
// Get IP address and User-Agent
const ip = req.headers.get('x-forwarded-for') || '127.0.0.1'
const userAgent = req.headers.get('user-agent') || 'unknown'
// Hash/Anonymize IP address for strict GDPR compliance
const anonymizedIp = ip ? createHash('sha256').update(ip).digest('hex') : null
// Create persistent audit log of this consent decision
await prisma.aiConsentLog.create({
data: {
userId,
consent,
ipAddress: anonymizedIp,
userAgent,
},
})
// If remember is requested, persist state in UserAISettings
if (remember) {
await prisma.userAISettings.upsert({
where: { userId },
create: {
userId,
aiProcessingConsent: consent,
},
update: {
aiProcessingConsent: consent,
},
})
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('[POST /api/user/ai-consent] failed:', error)
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,512 @@
import { NextResponse } from 'next/server'
import { promises as fs } from 'fs'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import JSZip from 'jszip'
import DOMPurify from 'isomorphic-dompurify'
import type { BrainstormIdea, Note } from '@prisma/client'
function htmlToMarkdown(html: string): string {
if (!html) return ''
let md = html
.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n')
.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n')
.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n')
.replace(/<strong>(.*?)<\/strong>/gi, '**$1**')
.replace(/<em>(.*?)<\/em>/gi, '*$1*')
.replace(/<p>(.*?)<\/p>/gi, '$1\n\n')
.replace(/<li>(.*?)<\/li>/gi, '- $1\n')
.replace(/<ul>/gi, '')
.replace(/<\/ul>/gi, '\n')
.replace(/<ol>/gi, '')
.replace(/<\/ol>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
md = md.replace(/<[^>]*>/g, '')
return md.trim()
}
function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9_\-\s]/g, '').trim() || 'Untitled'
}
function uniqueBaseName(title: string, id: string): string {
return `${sanitizeFilename(title)}--${id.slice(0, 8)}`
}
function serializeBrainstormIdeas(ideas: BrainstormIdea[]): string {
const rendered = new Set<string>()
const ideaIds = new Set(ideas.map((i) => i.id))
const formatIdea = (idea: BrainstormIdea, depth: number): string => {
if (rendered.has(idea.id)) return ''
rendered.add(idea.id)
const indent = ' '.repeat(depth)
const pos =
idea.positionX != null && idea.positionY != null
? ` (pos: ${idea.positionX}, ${idea.positionY})`
: ''
const conn = idea.connectionToSeed ? ` → seed: ${idea.connectionToSeed}` : ''
const star = idea.isStarred ? ' ★' : ''
const kind = idea.createdByType || 'idea'
let block = `${indent}- **[${kind}]** ${idea.title}${star}${conn}${pos}\n`
if (idea.description?.trim()) {
block += `${indent} ${idea.description.trim()}\n`
}
for (const child of ideas.filter((i) => i.parentIdeaId === idea.id)) {
block += formatIdea(child, depth + 1)
}
return block
}
let md = ''
const roots = ideas.filter(
(i) => !i.parentIdeaId || !ideaIds.has(i.parentIdeaId)
)
for (const root of roots) {
md += formatIdea(root, 0)
}
for (const idea of ideas) {
if (!rendered.has(idea.id)) {
md += formatIdea(idea, 0)
}
}
return md || '_No ideas in this session._\n'
}
function resolveNoteFolder(
zip: JSZip,
note: Note,
notebookName: string,
activeNotebookFolders: Map<string, JSZip>
): JSZip {
const notebookSegment = sanitizeFilename(notebookName)
if (note.isArchived) {
return zip.folder(`archive/${notebookSegment}`)!
}
if (note.trashedAt) {
return zip.folder(`trash/${notebookSegment}`)!
}
let folder = activeNotebookFolders.get(notebookSegment)
if (!folder) {
folder = zip.folder(notebookSegment)!
activeNotebookFolders.set(notebookSegment, folder)
}
return folder
}
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const zip = new JSZip()
const [allNotes, notebooks, labels, attachments, brainstorms] = await Promise.all([
prisma.note.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
}),
prisma.notebook.findMany({
where: { userId },
orderBy: { name: 'asc' },
}),
prisma.label.findMany({
where: { userId },
include: { notes: { select: { id: true } } },
}),
prisma.noteAttachment.findMany({
where: { note: { userId } },
}),
prisma.brainstormSession.findMany({
where: { userId },
include: { ideas: true },
orderBy: { createdAt: 'desc' },
}),
])
const notebookMap = new Map(notebooks.map((nb) => [nb.id, nb.name]))
const noteLabelMap = new Map<string, string[]>()
for (const label of labels) {
for (const note of label.notes) {
const arr = noteLabelMap.get(note.id) || []
arr.push(label.name)
noteLabelMap.set(note.id, arr)
}
}
const metadataJson = {
version: '3.0.0',
exportDate: new Date().toISOString(),
user: {
id: userId,
email: session.user.email ?? '',
name: session.user.name ?? '',
},
notebooks: notebooks.map((n) => ({ id: n.id, name: n.name, color: n.color })),
labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
notes: allNotes.map((n) => ({
id: n.id,
title: n.title,
isPinned: n.isPinned,
isArchived: n.isArchived,
trashedAt: n.trashedAt,
createdAt: n.createdAt,
updatedAt: n.updatedAt,
notebookId: n.notebookId,
labels: noteLabelMap.get(n.id) || [],
})),
brainstorms: brainstorms.map((b) => ({
id: b.id,
title: b.title,
createdAt: b.createdAt,
ideasCount: b.ideas.length,
})),
}
zip.file('metadata.json', JSON.stringify(metadataJson, null, 2))
const activeNotebookFolders = new Map<string, JSZip>()
for (const note of allNotes) {
const notebookName = note.notebookId
? notebookMap.get(note.notebookId) || 'Unsorted'
: 'Unsorted'
const folder = resolveNoteFolder(zip, note, notebookName, activeNotebookFolders)
const noteTags = noteLabelMap.get(note.id) || []
const fileBase = uniqueBaseName(note.title || 'Untitled Note', note.id)
let mdContent = '---\n'
mdContent += `title: "${note.title || 'Untitled'}"\n`
mdContent += `id: "${note.id}"\n`
mdContent += `createdAt: "${note.createdAt.toISOString()}"\n`
mdContent += `updatedAt: "${note.updatedAt.toISOString()}"\n`
mdContent += `tags: [${noteTags.map((t) => `"${t}"`).join(', ')}]\n`
mdContent += `notebook: "${notebookName}"\n`
mdContent += `pinned: ${note.isPinned}\n`
mdContent += `archived: ${note.isArchived}\n`
mdContent += '---\n\n'
mdContent += htmlToMarkdown(note.content || '')
folder.file(`${fileBase}.md`, mdContent)
folder.file(`${fileBase}.json`, JSON.stringify(note, null, 2))
}
const canvasFolder = zip.folder('canvases')!
for (const brainstorm of brainstorms) {
const fileBase = uniqueBaseName(brainstorm.title || 'Untitled Brainstorm', brainstorm.id)
let canvasMd = `# Brainstorm Canvas: ${brainstorm.title || 'Untitled'}\n\n`
canvasMd += `- **Created At:** ${brainstorm.createdAt.toISOString()}\n`
canvasMd += `- **Updated At:** ${brainstorm.updatedAt.toISOString()}\n`
canvasMd += `- **Ideas Count:** ${brainstorm.ideas.length}\n\n`
canvasMd += `## Ideas and Connections\n\n`
canvasMd += serializeBrainstormIdeas(brainstorm.ideas)
canvasFolder.file(`${fileBase}.md`, canvasMd)
canvasFolder.file(`${fileBase}.json`, JSON.stringify(brainstorm, null, 2))
}
const attachmentsFolder = zip.folder('attachments')!
for (const attachment of attachments) {
try {
const fileContent = await fs.readFile(attachment.filePath)
const safeName = `${attachment.id.slice(0, 8)}-${sanitizeFilename(attachment.fileName)}`
attachmentsFolder.file(safeName, fileContent)
} catch (err) {
console.error(`[Export] Failed to pack attachment file: ${attachment.filePath}`, err)
}
}
const offlineNotes = allNotes.map((n) => ({
title: n.title || 'Untitled Note',
notebook: n.notebookId ? notebookMap.get(n.notebookId) || 'Unsorted' : 'Unsorted',
tags: noteLabelMap.get(n.id) || [],
content: DOMPurify.sanitize(n.content || ''),
createdAt: n.createdAt.toISOString(),
updatedAt: n.updatedAt.toISOString(),
archived: n.isArchived,
trashed: n.trashedAt !== null,
}))
const offlineCanvases = brainstorms.map((b) => ({
title: b.title || 'Untitled Brainstorm',
createdAt: b.createdAt.toISOString(),
ideas: b.ideas.map((i) => ({
title: i.title,
description: i.description,
kind: i.createdByType || 'idea',
x: i.positionX,
y: i.positionY,
isStarred: i.isStarred,
connectionToSeed: i.connectionToSeed,
parentIdeaId: i.parentIdeaId,
})),
}))
const indexHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Memento Workspace Export Browser</title>
<style>
:root {
--bg: #F8F9FA;
--card-bg: #FFFFFF;
--border: #E9ECEF;
--text: #212529;
--text-muted: #6C757D;
--primary: #1C1C1C;
--rose: #FFF5F5;
--rose-border: #FFE3E3;
--accent: #E9ECEF;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--card-bg: #1E1E1E;
--border: #2D2D2D;
--text: #F8F9FA;
--text-muted: #ADB5BD;
--primary: #FFFFFF;
--rose: #2D1A1A;
--rose-border: #4D2626;
--accent: #2D2D2D;
}
}
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background-color: var(--bg);
color: var(--text);
display: flex;
height: 100vh;
overflow: hidden;
}
#sidebar {
width: 320px;
border-right: 1px solid var(--border);
background-color: var(--card-bg);
display: flex;
flex-direction: column;
height: 100%;
flex-shrink: 0;
}
#sidebar-header { padding: 24px; border-bottom: 1px solid var(--border); }
#sidebar-header h1 { font-size: 20px; margin: 0; font-weight: 700; }
#sidebar-header p { font-size: 11px; color: var(--text-muted); margin: 4px 0 0 0; }
#search-bar { margin: 16px 24px; }
#search-input {
width: 80%;
padding: 10px 16px;
border-radius: 12px;
border: 1px solid var(--border);
background-color: var(--bg);
color: var(--text);
font-size: 13px;
outline: none;
}
#sidebar-list { flex-grow: 1; overflow-y: auto; padding: 0 16px 24px 16px; }
.section-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin: 20px 8px 8px 8px;
}
.item-card {
padding: 12px 16px;
border-radius: 12px;
cursor: pointer;
margin-bottom: 4px;
border: 1px solid transparent;
}
.item-card:hover { background-color: var(--bg); }
.item-card.active { background-color: var(--accent); border-color: var(--border); }
.item-card h3 { font-size: 13px; font-weight: 600; margin: 0; }
.item-card p {
font-size: 11px;
color: var(--text-muted);
margin: 4px 0 0 0;
display: flex;
justify-content: space-between;
}
#content-area { flex-grow: 1; padding: 48px; overflow-y: auto; display: flex; flex-direction: column; }
#welcome-screen { margin: auto; text-align: center; max-width: 400px; }
#welcome-screen h2 { font-size: 28px; margin-bottom: 8px; }
#welcome-screen p { color: var(--text-muted); font-size: 14px; line-height: 1.6; }
#detail-container { display: none; max-width: 800px; width: 100%; margin: 0 auto; }
.meta-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 6px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
background-color: var(--border);
color: var(--text-muted);
margin-right: 8px;
}
#detail-header h2 { font-size: 36px; margin: 16px 0 8px 0; font-weight: 700; }
#detail-header p {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 24px 0;
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
}
#detail-body { font-size: 15px; line-height: 1.8; }
.canvas-node {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--border);
margin-bottom: 8px;
background-color: var(--card-bg);
font-size: 13px;
}
.starred-node { background-color: var(--rose); border-color: var(--rose-border); }
</style>
</head>
<body>
<div id="sidebar">
<div id="sidebar-header">
<h1>Momento</h1>
<p>Offline Workspace Export</p>
</div>
<div id="search-bar">
<input type="text" id="search-input" placeholder="Search notes..." oninput="filterItems()">
</div>
<div id="sidebar-list">
<div class="section-title">Notebooks & Notes</div>
<div id="notes-list"></div>
<div class="section-title">Canvases</div>
<div id="canvases-list"></div>
</div>
</div>
<div id="content-area">
<div id="welcome-screen">
<h2>Welcome Back</h2>
<p>Explore your entire workspace offline. Use the sidebar to search and select your notes and canvases.</p>
</div>
<div id="detail-container">
<div id="detail-header">
<span id="notebook-badge" class="meta-badge">Notebook</span>
<span id="date-badge" class="meta-badge">Date</span>
<h2 id="note-title">Title</h2>
<p id="note-meta">Meta description</p>
</div>
<div id="detail-body">Content</div>
</div>
</div>
<script>
const notes = ${JSON.stringify(offlineNotes)};
const canvases = ${JSON.stringify(offlineCanvases)};
const notesList = document.getElementById('notes-list');
const canvasesList = document.getElementById('canvases-list');
const welcomeScreen = document.getElementById('welcome-screen');
const detailContainer = document.getElementById('detail-container');
const noteTitle = document.getElementById('note-title');
const dateBadge = document.getElementById('date-badge');
const notebookBadge = document.getElementById('notebook-badge');
const noteMeta = document.getElementById('note-meta');
const detailBody = document.getElementById('detail-body');
let activeIndex = -1;
let activeType = '';
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderList() {
notesList.innerHTML = notes.map((n, i) => \`
<div class="item-card \${activeType === 'note' && activeIndex === i ? 'active' : ''}" data-type="note" data-index="\${i}" onclick="selectItem('note', \${i})">
<h3>\${escapeHtml(n.title)}</h3>
<p><span>\${escapeHtml(n.notebook)}</span> <span>\${new Date(n.updatedAt).toLocaleDateString()}</span></p>
</div>
\`).join('');
canvasesList.innerHTML = canvases.map((c, i) => \`
<div class="item-card \${activeType === 'canvas' && activeIndex === i ? 'active' : ''}" data-type="canvas" data-index="\${i}" onclick="selectItem('canvas', \${i})">
<h3>\${escapeHtml(c.title)}</h3>
<p><span>Canvas</span> <span>\${new Date(c.createdAt).toLocaleDateString()}</span></p>
</div>
\`).join('');
}
function selectItem(type, index) {
activeIndex = index;
activeType = type;
renderList();
welcomeScreen.style.display = 'none';
detailContainer.style.display = 'block';
if (type === 'note') {
const note = notes[index];
notebookBadge.innerText = note.notebook;
dateBadge.innerText = new Date(note.updatedAt).toLocaleDateString();
noteTitle.innerText = note.title;
noteMeta.innerText = 'Created: ' + new Date(note.createdAt).toLocaleString() + ' | Tags: ' + (note.tags.join(', ') || 'None');
detailBody.innerHTML = note.content;
} else {
const canvas = canvases[index];
notebookBadge.innerText = 'Canvas';
dateBadge.innerText = new Date(canvas.createdAt).toLocaleDateString();
noteTitle.innerText = canvas.title;
noteMeta.innerText = 'Total Ideas: ' + canvas.ideas.length;
let nodesHtml = '<div style="margin-top: 24px;">';
canvas.ideas.forEach(idea => {
const pos = idea.x != null && idea.y != null ? ' (' + idea.x + ', ' + idea.y + ')' : '';
const conn = idea.connectionToSeed ? ' | ' + escapeHtml(idea.connectionToSeed) : '';
nodesHtml += '<div class="canvas-node' + (idea.isStarred ? ' starred-node' : '') + '">' +
'<strong>[' + escapeHtml((idea.kind || 'idea').toUpperCase()) + ']</strong> ' + escapeHtml(idea.title) +
(idea.description ? '<div style="margin-top:6px">' + escapeHtml(idea.description) + '</div>' : '') +
'<div style="font-size: 10px; color: var(--text-muted); margin-top: 4px;">Position' + pos + conn + '</div></div>';
});
nodesHtml += '</div>';
detailBody.innerHTML = nodesHtml;
}
}
function filterItems() {
const q = document.getElementById('search-input').value.toLowerCase();
document.querySelectorAll('#notes-list .item-card').forEach((card) => {
const i = Number(card.dataset.index);
const n = notes[i];
const match = n.title.toLowerCase().includes(q) || n.notebook.toLowerCase().includes(q) || n.content.toLowerCase().includes(q);
card.style.display = match ? 'block' : 'none';
});
document.querySelectorAll('#canvases-list .item-card').forEach((card) => {
const i = Number(card.dataset.index);
const c = canvases[i];
const hay = (c.title + ' ' + c.ideas.map(x => x.title + ' ' + (x.description || '')).join(' ')).toLowerCase();
card.style.display = hay.includes(q) ? 'block' : 'none';
});
}
renderList();
</script>
</body>
</html>`
zip.file('index.html', indexHtml)
// v1: full buffer in memory (documented limit for very large workspaces)
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
const dateString = new Date().toISOString().split('T')[0]
return new NextResponse(zipBuffer, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="memento-workspace-export-${dateString}.zip"`,
},
})
} catch (error) {
console.error('[GET /api/user/export] Export failed', error)
return NextResponse.json({ success: false, error: 'Export failed' }, { status: 500 })
}
}

View File

@@ -19,8 +19,8 @@
--color-memento-ink: #1C1C1C;
--color-primary: #ACB995;
--color-memento-accent: #D4A373;
--color-memento-blue: #A47148;
--color-brand-accent: #A47148;
--color-memento-blue: var(--color-brand-accent);
--color-memento-paper-elevated: #faf9f5;
--color-background-light: var(--color-memento-paper);
--color-background-dark: #202020;
@@ -30,7 +30,7 @@
--color-paper: var(--paper);
--color-muted-ink: var(--muted-ink);
--color-concrete: var(--concrete);
--color-blueprint: #A47148;
--color-blueprint: var(--color-brand-accent);
--color-ochre: #D4A373;
--color-sage: #A3B18A;
--color-rust: #9B2226;
@@ -1257,6 +1257,36 @@ html.font-system * {
opacity: 0.8;
}
/* Liens internes vers une autre note (openNote=) */
.notion-editor-wrapper .ProseMirror a[href*="openNote="],
.fullpage-editor .ProseMirror a[href*="openNote="] {
color: oklch(0.48 0.12 75);
background: oklch(0.94 0.04 85);
border-radius: 4px;
padding: 0 5px;
text-decoration: none;
font-weight: 500;
border: 1px solid oklch(0.88 0.06 85);
}
.notion-editor-wrapper .ProseMirror a[href*="openNote="]:hover,
.fullpage-editor .ProseMirror a[href*="openNote="]:hover {
background: oklch(0.9 0.06 85);
opacity: 1;
}
.dark .notion-editor-wrapper .ProseMirror a[href*="openNote="],
.dark .fullpage-editor .ProseMirror a[href*="openNote="] {
color: oklch(0.88 0.08 85);
background: oklch(0.32 0.05 75);
border-color: oklch(0.4 0.06 75);
}
.dark .notion-editor-wrapper .ProseMirror a[href*="openNote="]:hover,
.dark .fullpage-editor .ProseMirror a[href*="openNote="]:hover {
background: oklch(0.38 0.06 75);
}
/* --- Highlight --- */
.notion-editor-wrapper .ProseMirror mark {
background: oklch(0.85 0.12 90);

View File

@@ -11,7 +11,7 @@ import { auth } from "@/auth";
import { CookieConsentRoot } from "@/components/legal/cookie-consent-root";
import { LanguageProvider } from "@/lib/i18n/LanguageProvider";
import Script from "next/script";
import { getThemeScript } from "@/lib/theme-script";
import type { CSSProperties } from "react";
import { normalizeThemeId } from "@/lib/apply-document-theme";
import { Inter, Manrope, Playfair_Display, JetBrains_Mono } from "next/font/google";
@@ -68,27 +68,6 @@ function serverHtmlThemeState(theme?: string | null): { className?: string; data
return {}
}
/**
* Inline script that runs BEFORE React hydrates.
* Reads the user's saved language from localStorage and sets
* `dir` on <html> immediately — prevents RTL/LTR flash.
*/
const directionScript = `
(function(){
try {
var lang = localStorage.getItem('user-language');
if (!lang) {
var c = document.cookie.split(';').map(function(s){return s.trim()}).find(function(s){return s.startsWith('user-language=')});
if (c) lang = c.split('=')[1];
}
if (lang === 'fa' || lang === 'ar') {
document.documentElement.dir = 'rtl';
document.documentElement.lang = lang;
}
} catch(e) {}
})();
`;
export default async function RootLayout({
children,
}: Readonly<{
@@ -103,25 +82,25 @@ export default async function RootLayout({
])
const htmlTheme = serverHtmlThemeState(userSettings.theme)
const serverAccent = userSettings.accentColor ?? '#A47148'
const serverTheme = normalizeThemeId(userSettings.theme || 'light')
const htmlStyle = {
'--color-brand-accent': serverAccent,
} as CSSProperties
return (
<html
suppressHydrationWarning
className={htmlTheme.className}
data-theme={htmlTheme.dataTheme}
data-server-theme={serverTheme}
data-server-accent={serverAccent}
style={htmlStyle}
>
<head />
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable}`}>
<Script
id="theme-init"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: getThemeScript(userSettings.theme, userSettings.accentColor) }}
/>
<Script
id="sw-cleanup"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: `if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then(function(rs){rs.forEach(function(r){r.unregister()})})}` }}
/>
<Script id="theme-init" src="/scripts/theme-init.js" strategy="beforeInteractive" />
<Script id="direction-init" src="/scripts/direction-init.js" strategy="beforeInteractive" />
<Script id="sw-cleanup" src="/scripts/sw-cleanup.js" strategy="afterInteractive" />
<SessionProviderWrapper>
<ErrorReporter />
<DirectionInitializer />

View File

@@ -50,10 +50,15 @@ export const authConfig = {
return true;
},
async jwt({ token, user }) {
async jwt({ token, user, trigger, session }) {
if (trigger === 'update' && session && 'aiSessionConsent' in session) {
token.aiSessionConsent = session.aiSessionConsent === true;
return token;
}
if (user) {
token.id = user.id;
token.role = (user as any).role;
token.aiSessionConsent = false;
}
return token;
},
@@ -61,6 +66,7 @@ export const authConfig = {
if (token && session.user) {
(session.user as any).id = token.id;
(session.user as any).role = token.role;
session.aiSessionConsent = token.aiSessionConsent === true;
}
return session;
},

View File

@@ -54,9 +54,14 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
}
return true;
},
async jwt({ token, user }) {
async jwt({ token, user, trigger, session }) {
if (trigger === 'update' && session && 'aiSessionConsent' in session) {
token.aiSessionConsent = session.aiSessionConsent === true;
return token;
}
if (user?.id) {
token.id = user.id;
token.aiSessionConsent = false;
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { role: true, sessionVersion: true },

View File

@@ -5,7 +5,10 @@ import { useState, useCallback } from 'react'
import dynamic from 'next/dynamic'
import type { Note } from '@/lib/types'
import { NotesEditorialView } from '@/components/notes-editorial-view'
import { getNoteById } from '@/app/actions/notes'
import { getNoteById, deleteNote, toggleArchive } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
const NoteEditor = dynamic(
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
@@ -16,7 +19,9 @@ interface ArchiveClientProps {
notes: Note[]
}
export function ArchiveClient({ notes }: ArchiveClientProps) {
export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
const { t } = useLanguage()
const [notes, setNotes] = useState<Note[]>(initialNotes)
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly: boolean } | null>(null)
const handleOpen = useCallback(async (note: Note, readOnly = false) => {
@@ -28,13 +33,45 @@ export function ArchiveClient({ notes }: ArchiveClientProps) {
setEditingNote(null)
}, [])
const handleArchiveNote = useCallback(async (note: Note) => {
const nextArchived = !note.isArchived
if (nextArchived) {
setNotes((prev) => prev.filter((n) => n.id !== note.id))
}
try {
await toggleArchive(note.id, nextArchived, { skipRevalidation: true })
emitNoteChange({ type: 'updated', note: { ...note, isArchived: nextArchived } })
toast.success(
nextArchived ? (t('notes.archived') || 'Archivée') : (t('notes.unarchived') || 'Désarchivée')
)
} catch {
if (nextArchived) setNotes((prev) => [note, ...prev])
toast.error(t('general.error'))
}
}, [t])
const handleDeleteNote = useCallback(async (note: Note) => {
setNotes((prev) => prev.filter((n) => n.id !== note.id))
try {
await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('notes.deleted') || 'Note supprimée')
} catch {
setNotes((prev) => [note, ...prev])
toast.error(t('general.error'))
}
}, [t])
if (editingNote) {
return (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={handleClose}
onNoteSaved={() => {}}
onNoteSaved={(saved) => {
setNotes((prev) => prev.map((n) => (n.id === saved.id ? { ...n, ...saved } : n)))
emitNoteChange({ type: 'updated', note: saved })
}}
fullPage
/>
)
@@ -44,6 +81,8 @@ export function ArchiveClient({ notes }: ArchiveClientProps) {
<NotesEditorialView
notes={notes}
onOpen={handleOpen}
onArchiveNote={handleArchiveNote}
onDeleteNote={handleDeleteNote}
/>
)
}

View File

@@ -12,6 +12,7 @@ import {
import { Sparkles, CheckCircle2, Loader2, Tag } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import type { AutoLabelSuggestion, SuggestedLabel } from '@/lib/ai/services'
import { cn } from '@/lib/utils'
@@ -29,6 +30,7 @@ export function AutoLabelSuggestionDialog({
onLabelsCreated,
}: AutoLabelSuggestionDialogProps) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [suggestions, setSuggestions] = useState<AutoLabelSuggestion | null>(null)
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
@@ -46,6 +48,9 @@ export function AutoLabelSuggestionDialog({
const fetchSuggestions = async () => {
if (!notebookId) return
const consented = await requestAiConsent()
if (!consented) return
setLoading(true)
try {
const response = await fetch('/api/ai/auto-labels', {

View File

@@ -15,6 +15,7 @@ import { Wand2, Loader2, ChevronRight, CheckCircle2 } from 'lucide-react'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import type { OrganizationPlan, NotebookOrganization } from '@/lib/ai/services'
interface BatchOrganizationDialogProps {
@@ -29,6 +30,7 @@ export function BatchOrganizationDialog({
onNotesMoved,
}: BatchOrganizationDialogProps) {
const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [plan, setPlan] = useState<OrganizationPlan | null>(null)
const [loading, setLoading] = useState(false)
const [applying, setApplying] = useState(false)
@@ -40,6 +42,12 @@ export function BatchOrganizationDialog({
setLoading(true)
setFetchError(null)
try {
const consented = await requestAiConsent()
if (!consented) {
setLoading(false)
return
}
const response = await fetch('/api/ai/batch-organize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -0,0 +1,196 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react'
export interface BlockSuggestion {
blockId: string
noteId: string
noteTitle: string
notebookName: string
content: string
snippet: string
score?: number
}
interface BlockPickerProps {
isOpen: boolean
onClose: () => void
currentNoteId?: string
onSelectBlock: (block: BlockSuggestion) => void
}
export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) {
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions')
const [searchQuery, setSearchQuery] = useState('')
const [suggestions, setSuggestions] = useState<BlockSuggestion[]>([])
const [searchResults, setSearchResults] = useState<BlockSuggestion[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const [loadingSearch, setLoadingSearch] = useState(false)
// Load AI suggestions when opening
useEffect(() => {
if (!isOpen || !currentNoteId) return
setLoadingSuggestions(true)
fetch(`/api/blocks/suggestions?noteId=${currentNoteId}`)
.then(r => r.json())
.then((data: { blocks: BlockSuggestion[] }) => setSuggestions(data.blocks || []))
.catch(() => setSuggestions([]))
.finally(() => setLoadingSuggestions(false))
}, [isOpen, currentNoteId])
// Debounced search
useEffect(() => {
if (activeTab !== 'search') return
if (!searchQuery.trim()) {
setSearchResults([])
return
}
const timer = setTimeout(() => {
setLoadingSearch(true)
const params = new URLSearchParams({ q: searchQuery })
if (currentNoteId) params.set('excludeNoteId', currentNoteId)
fetch(`/api/blocks/search?${params}`)
.then(r => r.json())
.then((data: { blocks: BlockSuggestion[] }) => setSearchResults(data.blocks || []))
.catch(() => setSearchResults([]))
.finally(() => setLoadingSearch(false))
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, activeTab, currentNoteId])
if (!isOpen) return null
const displayBlocks = activeTab === 'suggestions' ? suggestions : searchResults
const isLoading = activeTab === 'suggestions' ? loadingSuggestions : loadingSearch
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-black/30 dark:bg-black/50 backdrop-blur-sm">
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 15 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 15 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="w-[480px] max-w-full bg-[#F5F4F2] dark:bg-zinc-900 backdrop-blur-md rounded-2xl border border-[#D5D2CD] dark:border-neutral-800 shadow-2xl flex flex-col max-h-[85vh] overflow-hidden"
>
{/* Header */}
<div className="p-4 border-b border-[#D5D2CD]/60 dark:border-neutral-800/60 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500">
<Link2 size={15} />
</div>
<div>
<h3 className="text-sm font-semibold text-[var(--color-ink)] dark:text-white font-serif">
Living Block Picker
</h3>
<p className="text-[10px] text-[var(--color-concrete)] font-medium uppercase tracking-widest">
Connecter un bloc en temps réel
</p>
</div>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-full text-[var(--color-concrete)] transition-colors"
>
<X size={16} />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-[#D5D2CD]/40 dark:border-neutral-800/40 px-3 bg-black/[0.01]">
{(['suggestions', 'search'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 py-3 text-[10px] uppercase tracking-[0.15em] font-extrabold transition-all relative ${
activeTab === tab
? 'text-blue-600 dark:text-blue-400'
: 'text-[var(--color-concrete)] hover:text-[var(--color-ink)]/70'
}`}
>
<span className="flex items-center justify-center gap-1.5">
{tab === 'suggestions' ? <Sparkles size={11} /> : <Search size={11} />}
{tab === 'suggestions' ? 'Suggestions IA' : 'Rechercher'}
</span>
{activeTab === tab && (
<motion.div
layoutId="pickerTab"
className="absolute bottom-0 left-0 right-0 h-[2px] bg-blue-500"
/>
)}
</button>
))}
</div>
{/* Search input */}
{activeTab === 'search' && (
<div className="p-3 border-b border-[#D5D2CD]/40 dark:border-neutral-800/40 bg-white/40 dark:bg-zinc-950/20">
<div className="relative flex items-center">
<Search size={14} className="absolute left-3.5 text-[var(--color-concrete)] pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Rechercher un extrait de note..."
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
/>
</div>
</div>
)}
{/* Block list */}
<div className="flex-1 overflow-y-auto p-3.5 space-y-2">
{isLoading ? (
<div className="text-center py-12 text-[var(--color-concrete)] text-xs">
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2" />
Chargement des suggestions...
</div>
) : displayBlocks.length > 0 ? (
displayBlocks.map(block => (
<button
key={`${block.noteId}-${block.blockId}`}
onClick={() => onSelectBlock(block)}
className="w-full text-left p-3 rounded-xl border border-transparent hover:border-black/[0.08] hover:bg-white/70 dark:hover:bg-zinc-800/50 bg-white/30 dark:bg-zinc-800/10 transition-all group relative flex gap-3.5"
>
<div className="flex-1 min-w-0 space-y-1.5">
<p className="font-serif italic text-[13px] leading-relaxed text-[var(--color-ink)]/90 dark:text-white/80 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-2">
« {block.snippet} »
</p>
<div className="flex items-center gap-2 text-[10px] text-[var(--color-concrete)] font-medium">
<span className="truncate max-w-[150px] font-semibold">{block.noteTitle}</span>
<span className="opacity-40"></span>
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider bg-black/5 dark:bg-white/5 py-0.5 px-1.5 rounded">
<Folder size={10} className="opacity-60" />
{block.notebookName}
</span>
</div>
</div>
{block.score !== undefined && (
<div className="shrink-0 flex flex-col justify-center items-end">
<span className="text-[10px] font-mono bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-2 py-0.5 rounded-full border border-blue-500/10">
{block.score}% affinité
</span>
</div>
)}
</button>
))
) : (
<div className="text-center py-12 text-[var(--color-concrete)] italic text-xs">
{activeTab === 'suggestions'
? 'Aucune suggestion disponible pour cette note.'
: searchQuery
? 'Aucun bloc ne correspond à votre recherche.'
: 'Tapez pour rechercher des blocs...'}
</div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}

View File

@@ -1,6 +1,8 @@
'use client'
import { useState, useEffect } from 'react'
import { motion } from 'motion/react'
import { Zap, Lightbulb, Trophy } from 'lucide-react'
interface BridgeNote {
noteId: string
@@ -26,13 +28,13 @@ interface BridgeSuggestion {
interface BridgeNotesDashboardProps {
onNoteClick?: (noteId: string) => void
clusters: { id: number; name: string; color?: string }[]
}
export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps) {
export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) {
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'bridges' | 'suggestions'>('bridges')
useEffect(() => {
loadData()
@@ -41,14 +43,12 @@ export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps)
const loadData = async () => {
setLoading(true)
try {
// Load bridge notes
const bridgesRes = await fetch('/api/bridge-notes?details=true')
if (bridgesRes.ok) {
const bridgesData = await bridgesRes.json()
setBridgeNotes(bridgesData.bridgeNotes || [])
}
// Load suggestions
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json()
@@ -63,9 +63,7 @@ export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps)
const generateNewSuggestions = async () => {
try {
const res = await fetch('/api/bridge-notes/suggestions', {
method: 'POST'
})
const res = await fetch('/api/bridge-notes/suggestions', { method: 'POST' })
if (res.ok) {
const data = await res.json()
setSuggestions(data.suggestions || [])
@@ -82,8 +80,6 @@ export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps)
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clusterAId, clusterBId })
})
// Remove from local state
setSuggestions(prev => prev.filter(
s => !(s.clusterAId === clusterAId && s.clusterBId === clusterBId)
))
@@ -94,12 +90,12 @@ export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps)
if (loading) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
<div className="bg-white dark:bg-white/5 rounded-2xl p-6 shadow-sm border border-border/40">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-concrete/20 rounded w-1/3 mb-4" />
<div className="space-y-3">
<div className="h-20 bg-gray-200 rounded"></div>
<div className="h-20 bg-gray-200 rounded"></div>
<div className="h-24 bg-concrete/10 rounded-xl" />
<div className="h-24 bg-concrete/10 rounded-xl" />
</div>
</div>
</div>
@@ -107,164 +103,118 @@ export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps)
}
return (
<div className="bg-white rounded-lg shadow">
{/* Tabs */}
<div className="border-b">
<nav className="flex -mb-px">
<button
onClick={() => setActiveTab('bridges')}
className={`px-6 py-3 font-medium text-sm ${
activeTab === 'bridges'
? 'border-b-2 border-blue-500 text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Bridge Notes ({bridgeNotes.length})
</button>
<button
onClick={() => setActiveTab('suggestions')}
className={`px-6 py-3 font-medium text-sm ${
activeTab === 'suggestions'
? 'border-b-2 border-blue-500 text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Connection Opportunities ({suggestions.length})
</button>
</nav>
<div className="space-y-12">
{/* Stats Summary */}
<div className="grid grid-cols-2 gap-4">
<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">
<Trophy size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Bridges</span>
</div>
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
</div>
<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">
<Lightbulb size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Suggestions</span>
</div>
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{suggestions.length}</div>
</div>
</div>
<div className="p-6">
{activeTab === 'bridges' && (
<div>
{bridgeNotes.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p>No bridge notes found yet</p>
<p className="text-sm mt-1">Bridge notes connect different clusters of ideas</p>
{/* Bridge Notes Section */}
<section>
<div className="flex items-center gap-2 mb-6 px-1">
<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>
</div>
<div className="space-y-3">
{bridgeNotes.map((bridge) => (
<motion.div
key={bridge.noteId}
whileHover={{ x: 4 }}
onClick={() => onNoteClick?.(bridge.noteId)}
className="p-4 rounded-xl bg-white dark:bg-white/5 border border-border hover:border-ochre/40 transition-all cursor-pointer group"
>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
{bridge.note?.title || 'Untitled'}
</h4>
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
</span>
</div>
) : (
<div className="space-y-3">
{bridgeNotes.map((bridge) => (
<div
key={bridge.noteId}
onClick={() => onNoteClick?.(bridge.noteId)}
className="border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
Bridge Score: {bridge.bridgeScore.toFixed(2)}
</span>
<span className="text-sm text-gray-500">
Connects {bridge.clustersConnected.length} {bridge.clustersConnected.length === 1 ? 'cluster' : 'clusters'}
</span>
</div>
<h4 className="font-medium text-gray-900">
{bridge.note?.title || 'Untitled'}
</h4>
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
{bridge.note?.content?.replace(/<[^>]+>/g, '').slice(0, 150) || 'No content'}
</p>
{bridge.clusterNames && bridge.clusterNames.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{bridge.clusterNames.map((name, i) => (
<span
key={i}
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-700"
>
{name}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
{bridge.clusterNames?.map((name, i) => {
const cluster = clusters.find(c => c.name === name)
return (
<div key={i} className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: cluster?.color || '#cbd5e1' }} />
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{name}</span>
</div>
</div>
))}
)
})}
</div>
)}
</div>
)}
</motion.div>
))}
{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>
</section>
{activeTab === 'suggestions' && (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-600">
AI-suggested ideas to connect your isolated clusters
</p>
{/* Connection Suggestions */}
<section>
<div className="flex items-center gap-2 mb-6 px-1">
<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>
</div>
<div className="space-y-4">
{suggestions.map((s, idx) => (
<div key={`${s.clusterAId}-${s.clusterBId}`} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 to-transparent border border-indigo-500/10 hover:border-indigo-500/30 transition-all">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-4">
<div className="flex -space-x-2">
<div className="w-6 h-6 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[10px] text-white">A</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>
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
Bridging {s.clusterAName} & {s.clusterBName}
</span>
</div>
<h4 className="text-base font-memento-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.suggestedContent}</p>
<div className="p-3 bg-white/40 dark:bg-white/5 rounded-xl border border-border/40 text-[10px] italic text-concrete flex gap-2">
<Zap size={12} className="shrink-0" />
<span>{s.justification}</span>
</div>
</div>
<button
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"
title="Dismiss suggestion"
>
×
</button>
</div>
</div>
))}
{suggestions.length === 0 && !loading && (
<div className="text-center py-8 text-concrete">
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
<p className="text-sm">No connection suggestions yet</p>
<p className="text-xs mt-1">All your clusters may already be connected!</p>
<button
onClick={generateNewSuggestions}
className="px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg 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 New
Generate Suggestions
</button>
</div>
{suggestions.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<p>No connection suggestions yet</p>
<p className="text-sm mt-1">All your clusters may already be connected!</p>
</div>
) : (
<div className="space-y-4">
{suggestions.map((suggestion, index) => (
<div
key={`${suggestion.clusterAId}-${suggestion.clusterBId}`}
className="border rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">💡</span>
<span className="font-semibold text-gray-900">
{suggestion.suggestedTitle}
</span>
<span className="text-xs text-gray-500">#{index + 1}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600 mb-2">
<span className="px-2 py-0.5 rounded bg-blue-100 text-blue-700">
{suggestion.clusterAName}
</span>
<span></span>
<span className="px-2 py-0.5 rounded bg-purple-100 text-purple-700">
{suggestion.clusterBName}
</span>
</div>
<p className="text-sm text-gray-700 mb-2">
{suggestion.suggestedContent}
</p>
<p className="text-xs text-gray-500 italic">
"{suggestion.justification}"
</p>
</div>
<button
onClick={() => dismissSuggestion(suggestion.clusterAId, suggestion.clusterBId)}
className="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="Dismiss suggestion"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
</section>
</div>
)
}

View File

@@ -1,9 +1,8 @@
'use client'
import { useState } from 'react'
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Sparkles, ThumbsUp, ThumbsDown, GitMerge, X } from 'lucide-react'
import { Columns2, GitMerge, X, ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Note } from '@/lib/types'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
@@ -13,54 +12,40 @@ interface ComparisonModalProps {
onClose: () => void
notes: Array<Partial<Note>>
similarity?: number
onOpenNote?: (noteId: string) => void
onMergeNotes?: (noteIds: string[]) => void
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}
function openNoteInNewTab(noteId: string) {
window.open(`/home?openNote=${encodeURIComponent(noteId)}`, '_blank', 'noopener,noreferrer')
}
export function ComparisonModal({
isOpen,
onClose,
notes,
similarity,
onOpenNote,
onMergeNotes
onMergeNotes,
}: ComparisonModalProps) {
const { t } = useLanguage()
const [feedback, setFeedback] = useState<'thumbs_up' | 'thumbs_down' | null>(null)
const handleFeedback = async (type: 'thumbs_up' | 'thumbs_down') => {
setFeedback(type)
try {
const noteIds = notes.map(n => n.id).filter(Boolean) as string[]
if (noteIds.length >= 2) {
await fetch('/api/ai/echo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'feedback', noteIds, feedback: type })
})
}
} catch {
// silent — feedback is best-effort
}
setTimeout(() => {
onClose()
}, 500)
}
const getNoteColor = (index: number) => {
const colors = [
'border-primary/20 dark:border-primary/30 hover:border-primary/30 dark:hover:border-primary/40',
'border-purple-200 dark:border-purple-800 hover:border-purple-300 dark:hover:border-purple-700',
'border-green-200 dark:border-green-800 hover:border-green-300 dark:hover:border-green-700'
'border-indigo-200/80 dark:border-indigo-800/80',
'border-purple-200/80 dark:border-purple-800/80',
'border-emerald-200/80 dark:border-emerald-800/80',
]
return colors[index % colors.length]
}
const getTitleColor = (index: number) => {
const colors = [
'text-primary dark:text-primary-foreground',
'text-purple-600 dark:text-purple-400',
'text-green-600 dark:text-green-400'
'text-indigo-700 dark:text-indigo-300',
'text-purple-700 dark:text-purple-300',
'text-emerald-700 dark:text-emerald-300',
]
return colors[index % colors.length]
}
@@ -72,133 +57,107 @@ export function ComparisonModal({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
showCloseButton={false}
className={cn(
"max-h-[90vh] overflow-hidden flex flex-col p-0",
maxModalWidth
)}
className={cn('max-h-[90vh] overflow-hidden flex flex-col p-0', maxModalWidth)}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
<div className="flex items-center justify-between p-6 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
<div className="p-2 rounded-lg bg-indigo-500/10">
<Columns2 className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<DialogTitle className="text-xl font-semibold">
{t('memoryEcho.comparison.title')}
</DialogTitle>
<DialogDescription className="text-sm text-gray-600 dark:text-gray-400">
{t('memoryEcho.comparison.similarityInfo', { similarity: similarityPercentage })}
<DialogDescription className="text-sm text-muted-foreground">
{similarityPercentage > 0
? t('memoryEcho.comparison.similarityInfo', { similarity: similarityPercentage })
: t('memoryEcho.comparison.subtitle')}
</DialogDescription>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded-md text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t('memoryEcho.editorSection.close')}
>
<X className="h-5 w-5" />
</button>
</div>
{/* AI Insight Section */}
<div className="px-6 py-3 border-b border-border/60 bg-muted/20">
<p className="text-sm text-muted-foreground">
{t('memoryEcho.comparison.stayOnCurrentNote')}
</p>
</div>
{similarityPercentage >= 80 && (
<div className="px-6 py-4 bg-amber-50 dark:bg-amber-950/20 border-b dark:border-zinc-700">
<div className="flex items-start gap-2">
<Sparkles className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-gray-700 dark:text-gray-300">
{t('memoryEcho.comparison.highSimilarityInsight')}
</p>
</div>
<div className="px-6 py-3 border-b border-border/60">
<p className="text-sm text-muted-foreground">
{t('memoryEcho.comparison.highSimilarityInsight')}
</p>
</div>
)}
{/* Notes Grid */}
<div className={cn(
"flex-1 overflow-y-auto p-6",
notes.length === 2 ? "grid grid-cols-2 gap-6" : "grid grid-cols-3 gap-4"
)}>
<div
className={cn(
'flex-1 overflow-y-auto p-6',
notes.length === 2 ? 'grid grid-cols-1 md:grid-cols-2 gap-6' : 'grid grid-cols-1 md:grid-cols-3 gap-4'
)}
>
{notes.map((note, index) => {
const title = note.title || t('memoryEcho.comparison.untitled')
const noteColor = getNoteColor(index)
const titleColor = getTitleColor(index)
const plainContent = stripHtml(note.content || '')
return (
<div
key={note.id || index}
onClick={() => {
if (onOpenNote && note.id) {
onOpenNote(note.id)
onClose()
}
}}
className={cn(
"cursor-pointer border dark:border-zinc-700 rounded-lg p-4 transition-all hover:shadow-md",
noteColor
'border rounded-xl p-4 bg-card flex flex-col',
getNoteColor(index)
)}
>
<h3 className={cn("font-semibold text-lg mb-3", titleColor)}>
<h3 className={cn('font-semibold text-lg mb-3', getTitleColor(index))}>
{title}
</h3>
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-8 whitespace-pre-wrap">
{note.content}
</div>
<div className="mt-4 pt-3 border-t dark:border-zinc-700">
<p className="text-xs text-gray-500 flex items-center gap-1">
{t('memoryEcho.comparison.clickToView')}
<span className="transform rotate-[-45deg]"></span>
</p>
<div className="text-sm text-muted-foreground line-clamp-[14] whitespace-pre-wrap flex-1">
{plainContent}
</div>
{note.id && (
<div className="mt-4 pt-3 border-t border-border/60">
<button
type="button"
onClick={() => openNoteInNewTab(note.id!)}
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors"
>
<ExternalLink className="h-3 w-3" />
{t('memoryEcho.editorSection.openInEditor')}
</button>
</div>
)}
</div>
)
})}
</div>
{/* Footer - Feedback + Actions */}
<div className="px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('memoryEcho.comparison.helpfulQuestion')}
</p>
<Button
size="sm"
variant={feedback === 'thumbs_up' ? 'default' : 'outline'}
onClick={() => handleFeedback('thumbs_up')}
className={cn(
feedback === 'thumbs_up' && "bg-green-600 hover:bg-green-700 text-white"
)}
>
<ThumbsUp className="h-4 w-4 mr-2" />
{t('memoryEcho.comparison.helpful')}
</Button>
<Button
size="sm"
variant={feedback === 'thumbs_down' ? 'default' : 'outline'}
onClick={() => handleFeedback('thumbs_down')}
className={cn(
feedback === 'thumbs_down' && "bg-red-600 hover:bg-red-700 text-white"
)}
>
<ThumbsDown className="h-4 w-4 mr-2" />
{t('memoryEcho.comparison.notHelpful')}
</Button>
</div>
{onMergeNotes && notes.length >= 2 && (
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700 text-white"
onClick={() => {
const noteIds = notes.map(n => n.id).filter(Boolean) as string[]
onMergeNotes(noteIds)
onClose()
}}
>
<GitMerge className="h-4 w-4 mr-2" />
{t('memoryEcho.editorSection.mergeAll')}
</Button>
)}
</div>
<div className="px-6 py-4 border-t border-border/60 bg-muted/30 flex items-center justify-end gap-2">
<Button size="sm" variant="outline" onClick={onClose}>
{t('memoryEcho.editorSection.backToNote')}
</Button>
{onMergeNotes && notes.length >= 2 && (
<Button
size="sm"
className="bg-indigo-600 hover:bg-indigo-700 text-white"
onClick={() => {
const noteIds = notes.map(n => n.id).filter(Boolean) as string[]
onMergeNotes(noteIds)
onClose()
}}
>
<GitMerge className="h-4 w-4 mr-2" />
{t('memoryEcho.editorSection.mergeAll')}
</Button>
)}
</div>
</DialogContent>
</Dialog>

View File

@@ -22,6 +22,7 @@ import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-i
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
import { toast } from 'sonner'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
// ── Custom Toast Helper ──────────────────────────────────────────────────────
const mToast = {
@@ -195,6 +196,7 @@ export function ContextualAIChat({
}: ContextualAIChatProps) {
const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable()
const { requestAiConsent } = useAiConsent()
const [activeTab, setActiveTab] = useState<'chat' | 'actions' | 'resource'>('actions')
const [selectedTone, setSelectedTone] = useState('professional')
@@ -282,6 +284,11 @@ export function ContextualAIChat({
const handleSend = async () => {
const text = input.trim()
if (!text || isLoading) return
// GDPR AI Consent Check
const consented = await requestAiConsent()
if (!consented) return
setInput('')
try {
await sendMessage({ text }, { body: buildChatBody() })
@@ -293,6 +300,10 @@ export function ContextualAIChat({
// ── Action execution ────────────────────────────────────────────────────────
const handleAction = async (action: ActionDef, targetLang?: string) => {
// GDPR AI Consent Check
const consented = await requestAiConsent()
if (!consented) return
if (action.isImageAction) {
if (!noteImages || noteImages.length === 0) {
mToast.error(t('ai.noImagesError') || 'Aucune image dans cette note')
@@ -511,6 +522,10 @@ export function ContextualAIChat({
return
}
// GDPR AI Consent Check
const consented = await requestAiConsent()
if (!consented) return
setResourceEnriching(true)
try {
const res = await fetch('/api/ai/enrich-from-resource', {
@@ -552,6 +567,10 @@ export function ContextualAIChat({
return
}
// GDPR AI Consent Check
const consented = await requestAiConsent()
if (!consented) return
setResourceEnriching(true)
try {
const res = await fetch('/api/ai/enrich-from-resource', {

View File

@@ -1,255 +1 @@
'use client'
import { useState, useEffect } from 'react'
import { ChevronDown, ChevronUp, Sparkles, Eye, ArrowRight, Link2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
interface ConnectionData {
noteId: string
title: string | null
content: string
createdAt: Date
similarity: number
daysApart: number
}
interface ConnectionsResponse {
connections: ConnectionData[]
pagination: {
total: number
page: number
limit: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
}
interface EditorConnectionsSectionProps {
noteId: string
onOpenNote?: (noteId: string) => void
onCompareNotes?: (noteIds: string[]) => void
onMergeNotes?: (noteIds: string[]) => void
}
export function EditorConnectionsSection({
noteId,
onOpenNote,
onCompareNotes,
onMergeNotes
}: EditorConnectionsSectionProps) {
const { t } = useLanguage()
const [connections, setConnections] = useState<ConnectionData[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isExpanded, setIsExpanded] = useState(true)
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
const fetchConnections = async () => {
setIsLoading(true)
try {
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=10`)
if (!res.ok) {
throw new Error('Failed to fetch connections')
}
const data: ConnectionsResponse = await res.json()
setConnections(data.connections)
// Show section if there are connections
if (data.connections.length > 0) {
setIsVisible(true)
} else {
setIsVisible(false)
}
} catch (error) {
console.error('[EditorConnectionsSection] Failed to fetch:', error)
} finally {
setIsLoading(false)
}
}
fetchConnections()
}, [noteId])
// Don't render if no connections or if dismissed
if (!isVisible || (connections.length === 0 && !isLoading)) {
return null
}
return (
<div className="mt-6 border-t dark:border-zinc-700 pt-4">
{/* Header with toggle */}
<div
className="flex items-center justify-between cursor-pointer select-none group"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<div className="p-1.5 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Sparkles className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{t('memoryEcho.editorSection.title', { count: connections.length })}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={async (e) => {
e.stopPropagation()
// Dismiss all connections for this note
try {
await Promise.all(
connections.map(conn =>
fetch('/api/ai/echo/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
noteId: noteId,
connectedNoteId: conn.noteId
})
})
)
)
setIsVisible(false)
} catch (error) {
console.error('❌ Failed to dismiss connections:', error)
}
}}
title={t('memoryEcho.editorSection.close') }
>
<X className="h-4 w-4 text-gray-500" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
}}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-500" />
) : (
<ChevronDown className="h-4 w-4 text-gray-500" />
)}
</Button>
</div>
</div>
{/* Connections list */}
{isExpanded && (
<div className="mt-3 space-y-2 max-h-[300px] overflow-y-auto">
{isLoading ? (
<div className="text-center py-4 text-sm text-gray-500">
{t('memoryEcho.editorSection.loading')}
</div>
) : (
connections.map((conn) => {
const similarityPercentage = Math.round(conn.similarity * 100)
const title = conn.title || t('memoryEcho.comparison.untitled')
return (
<div
key={conn.noteId}
className="border dark:border-zinc-700 rounded-lg p-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 flex-1">
{title}
</h4>
<span className="ml-2 text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">
{similarityPercentage}%
</span>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-2 mb-2">
{conn.content}
</p>
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant="ghost"
className="h-7 text-xs flex-1"
onClick={() => onOpenNote?.(conn.noteId)}
>
<Eye className="h-3 w-3 mr-1" />
{t('memoryEcho.editorSection.view')}
</Button>
{onCompareNotes && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs flex-1"
onClick={() => onCompareNotes([noteId, conn.noteId])}
>
<ArrowRight className="h-3 w-3 mr-1" />
{t('memoryEcho.editorSection.compare')}
</Button>
)}
{onMergeNotes && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs flex-1"
onClick={() => onMergeNotes([noteId, conn.noteId])}
>
<Link2 className="h-3 w-3 mr-1" />
{t('memoryEcho.editorSection.merge')}
</Button>
)}
</div>
</div>
)
})
)}
</div>
)}
{/* Footer actions */}
{isExpanded && connections.length > 1 && (
<div className="mt-3 flex items-center gap-2 pt-2 border-t dark:border-zinc-700">
<Button
size="sm"
variant="outline"
className="flex-1 text-xs"
onClick={() => {
if (onCompareNotes) {
const allIds = connections.slice(0, Math.min(3, connections.length)).map(c => c.noteId)
onCompareNotes([noteId, ...allIds])
}
}}
>
{t('memoryEcho.editorSection.compareAll')}
</Button>
{onMergeNotes && (
<Button
size="sm"
variant="outline"
className="flex-1 text-xs"
onClick={() => {
const allIds = connections.map(c => c.noteId)
onMergeNotes([noteId, ...allIds])
}}
>
{t('memoryEcho.editorSection.mergeAll')}
</Button>
)}
</div>
)}
</div>
)
}
export { MemoryEchoSection, EditorConnectionsSection } from './memory-echo-section'

View File

@@ -9,6 +9,7 @@ import { X, Link2, Sparkles, Edit, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Note } from '@/lib/types'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
interface FusionModalProps {
isOpen: boolean
@@ -31,6 +32,7 @@ export function FusionModal({
onConfirmFusion
}: FusionModalProps) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [selectedNoteIds, setSelectedNoteIds] = useState<string[]>(notes.filter(n => n.id).map(n => n.id!))
const [customPrompt, setCustomPrompt] = useState('')
const [fusionPreview, setFusionPreview] = useState('')
@@ -52,8 +54,9 @@ export function FusionModal({
setFusionPreview('')
try {
// Call AI API to generate fusion
const consented = await requestAiConsent()
if (!consented) return
const res = await fetch('/api/ai/echo/fusion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -81,7 +84,7 @@ export function FusionModal({
} finally {
setIsGenerating(false)
}
}, [selectedNoteIds, customPrompt])
}, [selectedNoteIds, customPrompt, requestAiConsent])
// Auto-generate fusion preview when modal opens with selected notes
useEffect(() => {

View File

@@ -1,20 +1,17 @@
'use client'
import React, { useState, useMemo, useRef, useCallback } from 'react'
import {
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
Check,
Search,
} from 'lucide-react'
import { Notebook } from '@/lib/types'
import { motion, AnimatePresence } from 'motion/react'
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import { ChevronRight, ChevronDown, Folder, Check } from 'lucide-react'
import { createPortal } from 'react-dom'
import { AnimatePresence } from 'motion/react'
import {
getNotebookPickerPosition,
NotebookHierarchyPanel,
type NotebookPickerItem,
} from '@/components/notebook-hierarchy-panel'
interface HierarchicalNotebookSelectorProps {
notebooks: { id: string; name: string; icon?: string | null; parentId?: string | null; trashedAt?: Date | string | null }[]
notebooks: NotebookPickerItem[]
selectedId: string | null
onSelect: (id: string) => void
className?: string
@@ -33,35 +30,29 @@ export function HierarchicalNotebookSelector({
size = 'default',
}: HierarchicalNotebookSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const triggerRef = useRef<HTMLDivElement>(null)
const [panelStyle, setPanelStyle] = useState<React.CSSProperties | undefined>()
const getDropdownStyle = useCallback((): React.CSSProperties => {
if (!triggerRef.current) return { position: 'fixed', top: 0, left: 0, width: 280, zIndex: 9999 }
const rect = triggerRef.current.getBoundingClientRect()
const dropdownHeight = 350
const viewportHeight = window.innerHeight
const wouldOverflowBottom = rect.bottom + 8 + dropdownHeight > viewportHeight
if (dropUp || wouldOverflowBottom) {
return {
position: 'fixed',
bottom: window.innerHeight - rect.top + 8,
left: rect.left,
width: Math.max(rect.width, 280),
zIndex: 9999,
}
}
return {
position: 'fixed',
top: rect.bottom + 8,
left: rect.left,
width: Math.max(rect.width, 280),
zIndex: 9999,
}
const updatePosition = useCallback(() => {
if (!triggerRef.current) return
setPanelStyle(getNotebookPickerPosition(triggerRef.current.getBoundingClientRect(), { preferDropUp: dropUp }))
}, [dropUp])
const selectedNotebook = notebooks.find(nb => nb.id === selectedId)
useEffect(() => {
if (!isOpen) {
setPanelStyle(undefined)
return
}
updatePosition()
window.addEventListener('scroll', updatePosition, true)
window.addEventListener('resize', updatePosition)
return () => {
window.removeEventListener('scroll', updatePosition, true)
window.removeEventListener('resize', updatePosition)
}
}, [isOpen, updatePosition])
const selectedNotebook = notebooks.find((nb) => nb.id === selectedId)
const path = useMemo(() => {
if (!selectedNotebook) return []
@@ -70,96 +61,18 @@ export function HierarchicalNotebookSelector({
while (current) {
trail.unshift(current)
if (!current.parentId) break
const parent = notebooks.find(nb => nb.id === current!.parentId)
const parent = notebooks.find((nb) => nb.id === current!.parentId)
if (!parent) break
current = parent
}
return trail
}, [selectedNotebook, notebooks])
const toggleExpand = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
const next = new Set(expandedIds)
if (next.has(id)) next.delete(id)
else next.add(id)
setExpandedIds(next)
}
const renderTree = (parentId: string | null | undefined, level = 0): React.ReactNode => {
const children = notebooks.filter(
nb => (nb.parentId ?? null) === (parentId ?? null)
)
if (children.length === 0) return null
return (
<div className={level > 0 ? 'ml-4 border-l border-border/40 pl-2' : ''}>
{children.map(notebook => {
const isExpanded = expandedIds.has(notebook.id) || searchQuery.length > 0
const hasChildren = notebooks.some(nb => nb.parentId === notebook.id)
const isSelected = selectedId === notebook.id
if (searchQuery && !notebook.name.toLowerCase().includes(searchQuery.toLowerCase())) {
const hasMatchingChild = (id: string): boolean => {
const kids = notebooks.filter(nb => nb.parentId === id)
return kids.some(nb => nb.name.toLowerCase().includes(searchQuery.toLowerCase()) || hasMatchingChild(nb.id))
}
if (!hasMatchingChild(notebook.id)) return null
}
return (
<div key={notebook.id} className="select-none">
<div
onClick={() => {
onSelect(notebook.id)
if (!searchQuery) setIsOpen(false)
}}
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
${isSelected ? 'bg-brand-accent/15 text-brand-accent font-bold dark:bg-brand-accent/20' : 'hover:bg-muted dark:hover:bg-white/5 text-ink'}`}
>
<div className="w-4 flex items-center justify-center">
{hasChildren ? (
<button
onClick={(e) => toggleExpand(e, notebook.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
>
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : null}
</div>
<div className={`p-1 rounded ${isSelected ? 'bg-brand-accent/25 dark:bg-brand-accent/30' : 'bg-muted/50 dark:bg-white/5 group-hover:bg-white/40'}`}>
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
</div>
<span className="text-[13px] truncate flex-1">{notebook.name}</span>
{isSelected && <Check size={14} className="opacity-60 shrink-0" />}
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
{renderTree(notebook.id, level + 1)}
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
)
}
return (
<div className={`relative ${className}`}>
<div
ref={triggerRef}
onClick={() => setIsOpen(prev => !prev)}
onClick={() => setIsOpen((prev) => !prev)}
className={`w-full bg-card dark:bg-white/5 border border-border/80 rounded-xl outline-none focus:ring-4 ring-brand-accent/10 focus:border-brand-accent/40 transition-all cursor-pointer text-ink flex items-center gap-3 ${size === 'sm' ? 'px-3 py-2 text-xs' : 'px-4 py-3 text-sm'}`}
>
<Folder size={size === 'sm' ? 14 : 16} className="text-brand-accent/70 shrink-0" />
@@ -179,56 +92,36 @@ export function HierarchicalNotebookSelector({
<span className="text-concrete italic">{placeholder}</span>
)}
</div>
<ChevronDown size={14} className={`transition-transform duration-300 text-concrete shrink-0 ${isOpen ? 'rotate-180' : ''}`} />
<ChevronDown
size={14}
className={`transition-transform duration-300 text-concrete shrink-0 ${isOpen ? 'rotate-180' : ''}`}
/>
</div>
{isOpen && typeof window !== 'undefined' && createPortal(
<>
<div className="fixed inset-0 z-[9998]" onClick={() => setIsOpen(false)} />
<AnimatePresence>
<motion.div
key="notebook-dropdown"
initial={{ opacity: 0, y: dropUp ? -8 : 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: dropUp ? -8 : 8, scale: 0.98 }}
transition={{ duration: 0.15 }}
style={getDropdownStyle()}
className="bg-card border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col"
>
<div className="p-3 border-b border-border/40 bg-card/50 dark:bg-white/5">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
<input
autoFocus
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={placeholder}
className="w-full bg-card border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-brand-accent transition-colors"
/>
</div>
</div>
<div className="max-h-72 overflow-y-auto custom-scrollbar p-2">
{renderTree(null)}
</div>
<div className="p-2 border-t border-border/40 bg-card/30 dark:bg-white/5 flex justify-between items-center px-4">
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">
{notebooks.length} notebooks
</span>
<button
onClick={() => setIsOpen(false)}
className="text-[10px] font-bold text-brand-accent hover:underline"
>
Close
</button>
</div>
</motion.div>
</AnimatePresence>
</>,
document.body
)}
{isOpen &&
typeof window !== 'undefined' &&
panelStyle &&
createPortal(
<>
<div className="fixed inset-0 z-[9998]" onClick={() => setIsOpen(false)} aria-hidden />
<AnimatePresence>
<NotebookHierarchyPanel
key="hierarchical-notebook-selector"
notebooks={notebooks}
selectedId={selectedId}
onSelect={(id) => {
onSelect(id)
setIsOpen(false)
}}
onClose={() => setIsOpen(false)}
searchPlaceholder={placeholder}
footerLabel={`${notebooks.filter((nb) => !nb.trashedAt).length} notebooks`}
style={panelStyle}
/>
</AnimatePresence>
</>,
document.body,
)}
</div>
)
}

View File

@@ -4,15 +4,21 @@ import React, { useState, useEffect, useCallback, useRef, useTransition, useMemo
import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote } from '@/app/actions/notes'
import { NotesEditorialView } from '@/components/notes-editorial-view'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes'
import { NotesListViews, type NotesLayoutMode, type NotesViewType } from '@/components/notes-list-views'
import {
NOTES_LAYOUT_STORAGE_KEY,
NOTES_VIEW_TYPE_STORAGE_KEY,
parseNotesLayoutMode,
parseNotesViewType,
setNotesLayoutPreference,
setNotesViewTypePreference,
} from '@/lib/notes-view-preference'
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu } from 'lucide-react'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useRefresh } from '@/lib/use-refresh'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table } from 'lucide-react'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
import { useNotebooks } from '@/context/notebooks-context'
@@ -58,9 +64,16 @@ type InitialSettings = {
interface HomeClientProps {
initialNotes: Note[]
initialSettings: InitialSettings
initialLayoutMode?: NotesLayoutMode
initialViewType?: NotesViewType
}
export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
export function HomeClient({
initialNotes,
initialSettings,
initialLayoutMode = 'list',
initialViewType = 'notes',
}: HomeClientProps) {
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
@@ -86,9 +99,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const notesRef = useRef(notes)
notesRef.current = notes
const { refreshKey, triggerRefresh } = useNoteRefresh()
const { refreshNotes } = useRefresh()
const { labels, notebooks, refreshNotebooks } = useNotebooks()
const labelsRef = useRef(labels)
labelsRef.current = labels
const initialNotesRef = useRef(initialNotes)
initialNotesRef.current = initialNotes
const { setControls } = useEditorUI()
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
@@ -99,6 +114,41 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
const [tagSearchQuery, setTagSearchQuery] = useState('')
const [viewType, setViewType] = useState<NotesViewType>(initialViewType)
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
useEffect(() => {
const storedLayout = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
const storedViewType = parseNotesViewType(localStorage.getItem(NOTES_VIEW_TYPE_STORAGE_KEY))
if (storedLayout !== initialLayoutMode) {
setLayoutMode(storedLayout)
setNotesLayoutPreference(storedLayout)
}
if (storedViewType !== initialViewType) {
setViewType(storedViewType)
setNotesViewTypePreference(storedViewType)
}
}, [initialLayoutMode, initialViewType])
useEffect(() => {
setNotesViewTypePreference(viewType)
}, [viewType])
useEffect(() => {
setNotesLayoutPreference(layoutMode)
}, [layoutMode])
useEffect(() => {
const onLayoutChange = (e: Event) => {
const detail = (e as CustomEvent<{ layout?: NotesLayoutMode }>).detail?.layout
if (detail === 'grid' || detail === 'list' || detail === 'table') {
setLayoutMode(detail)
setViewType('notes')
}
}
window.addEventListener('memento-notes-layout-change', onLayoutChange)
return () => window.removeEventListener('memento-notes-layout-change', onLayoutChange)
}, [])
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
useEffect(() => {
@@ -112,12 +162,86 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}, [searchParams, router])
const notebookFilter = searchParams.get('notebook')
const fetchNotesForCurrentView = useCallback(
async (options?: { silent?: boolean }) => {
const search = searchParams.get('search')?.trim() || null
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
const notebook = searchParams.get('notebook')
const sharedOnly = searchParams.get('shared') === '1'
const remindersOnly = searchParams.get('reminders') === '1'
if (!options?.silent) {
setIsLoading(true)
}
let allNotes = search
? await searchNotes(search, true, notebook || undefined)
: await getAllNotes(false, notebook || undefined)
if (sharedOnly) {
allNotes = allNotes.filter((note: Note) => note._isShared)
} else if (remindersOnly) {
allNotes = allNotes.filter((note: Note) => note.reminder !== null)
} else if (!notebook && !search) {
allNotes = allNotes.filter((note: Note) => !note.notebookId && !note._isShared)
}
if (labelFilter.length > 0) {
allNotes = allNotes.filter((note: Note) =>
labelFilter.every((label: string) => note.labels?.includes(label))
)
}
if (colorFilter) {
const labelNamesWithColor = labelsRef.current
.filter((label) => label.color === colorFilter)
.map((label) => label.name)
allNotes = allNotes.filter((note: Note) =>
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
)
}
setNotes((prev) => {
const localSizeMap = new Map(prev.map((n) => [n.id, n.size]))
return allNotes.map((n) => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
})
setPinnedNotes(allNotes.filter((n: Note) => n.isPinned))
setIsLoading(false)
},
[searchParams]
)
const filterNotesForCurrentView = useCallback((source: Note[]) => {
const notebook = searchParams.get('notebook')
const sharedOnly = searchParams.get('shared') === '1'
const remindersOnly = searchParams.get('reminders') === '1'
if (notebook) {
return source.filter((n) => n.notebookId === notebook && !n._isShared)
}
if (sharedOnly) {
return source.filter((n) => n._isShared)
}
if (remindersOnly) {
return source.filter((n) => n.reminder !== null)
}
return source.filter((n) => !n.notebookId && !n._isShared)
}, [searchParams])
const handleNoteCreated = useCallback((note: Note) => {
const search = searchParams.get('search')?.trim() || null
if (search) {
void fetchNotesForCurrentView({ silent: true })
emitNoteChange({ type: 'created', note })
return
}
setNotes((prevNotes) => {
const notebookFilter = searchParams.get('notebook')
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
const search = searchParams.get('search')?.trim() || null
if (notebookFilter && note.notebookId !== notebookFilter) return prevNotes
if (!notebookFilter && note.notebookId) return prevNotes
@@ -135,11 +259,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) return prevNotes
}
if (search) {
router.refresh()
return prevNotes
}
const isPinned = note.isPinned || false
const pinnedNotes = prevNotes.filter(n => n.isPinned)
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
@@ -150,6 +269,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
return [...pinnedNotes, note, ...unpinnedNotes]
}
})
emitNoteChange({ type: 'created', note })
if (!note.notebookId) {
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
@@ -157,7 +277,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
setNotebookSuggestion({ noteId: note.id, content: note.content || '' })
}
}
}, [searchParams, labels, router])
}, [searchParams, labels, fetchNotesForCurrentView])
// Always fetch fresh from server — avoids stale state after a save regardless of
// whether the notes list has re-fetched yet.
@@ -210,7 +330,134 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
}, [])
useReminderCheck(notes)
const patchNoteInList = useCallback((noteId: string, patch: Partial<Note>) => {
setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, ...patch } : n)))
}, [])
const removeNoteFromList = useCallback((noteId: string) => {
setNotes((prev) => prev.filter((n) => n.id !== noteId))
setEditingNote((prev) => (prev?.note.id === noteId ? null : prev))
}, [])
const noteVisibleInCurrentView = useCallback(
(note: Pick<Note, 'notebookId' | '_isShared'>) => {
const notebook = searchParams.get('notebook')
const sharedOnly = searchParams.get('shared') === '1'
if (sharedOnly) return !!note._isShared
if (notebook) return note.notebookId === notebook && !note._isShared
return !note.notebookId && !note._isShared
},
[searchParams]
)
const handleTogglePin = useCallback(
async (note: Note) => {
const nextPinned = !note.isPinned
patchNoteInList(note.id, { isPinned: nextPinned })
emitNoteChange({ type: 'updated', note: { ...note, isPinned: nextPinned } })
try {
await togglePin(note.id, nextPinned, { skipRevalidation: true })
} catch {
patchNoteInList(note.id, { isPinned: !nextPinned })
emitNoteChange({ type: 'updated', note: { ...note, isPinned: !nextPinned } })
toast.error(t('general.error'))
}
},
[patchNoteInList, t]
)
const handleDeleteNoteFromList = useCallback(
async (note: Note) => {
removeNoteFromList(note.id)
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
try {
await deleteNote(note.id, { skipRevalidation: true })
toast.success(t('notes.deleted') || 'Note supprimée')
} catch {
setNotes((prev) => [note, ...prev])
emitNoteChange({ type: 'created', note })
toast.error(t('general.error'))
}
},
[removeNoteFromList, t]
)
const handleArchiveNoteFromList = useCallback(
async (note: Note) => {
const nextArchived = !note.isArchived
if (nextArchived) removeNoteFromList(note.id)
else patchNoteInList(note.id, { isArchived: nextArchived })
emitNoteChange({ type: 'updated', note: { ...note, isArchived: nextArchived } })
try {
await toggleArchive(note.id, nextArchived, { skipRevalidation: true })
toast.success(
nextArchived ? (t('notes.archived') || 'Archivée') : (t('notes.unarchived') || 'Désarchivée')
)
} catch {
if (nextArchived) setNotes((prev) => [note, ...prev])
else patchNoteInList(note.id, { isArchived: !nextArchived })
toast.error(t('general.error'))
}
},
[patchNoteInList, removeNoteFromList, t]
)
const handleMoveNoteToNotebook = useCallback(
async (note: Note, notebookId: string | null) => {
const moved = { ...note, notebookId }
if (noteVisibleInCurrentView(moved)) {
patchNoteInList(note.id, { notebookId })
} else {
removeNoteFromList(note.id)
}
emitNoteChange({ type: 'updated', note: moved })
try {
await updateNote(note.id, { notebookId }, { skipRevalidation: true })
toast.success(t('notebookSuggestion.movedToNotebook') || 'Note déplacée')
} catch {
if (noteVisibleInCurrentView(note)) {
patchNoteInList(note.id, { notebookId: note.notebookId ?? null })
} else {
setNotes((prev) => [note, ...prev])
}
toast.error(t('general.error'))
}
},
[noteVisibleInCurrentView, patchNoteInList, removeNoteFromList, t]
)
const handleNoteContentPatch = useCallback((noteId: string, patch: Partial<Note>) => {
setNotes((prev) => {
const next = prev.map((n) => (n.id === noteId ? { ...n, ...patch } : n))
const updated = next.find((n) => n.id === noteId)
if (updated) emitNoteChange({ type: 'updated', note: updated })
return next
})
}, [])
const handleNoteIllustrationGenerated = useCallback(async (noteId: string) => {
const fresh = await getNoteById(noteId)
if (!fresh) return
patchNoteInList(noteId, fresh)
emitNoteChange({ type: 'updated', note: fresh })
}, [patchNoteInList])
const handleGridReorder = useCallback(
async (orderedIds: string[]) => {
setSortOrder('manual')
setNotes((prev) => {
const orderMap = new Map(orderedIds.map((id, index) => [id, index]))
return prev.map((n) => (orderMap.has(n.id) ? { ...n, order: orderMap.get(n.id)! } : n))
})
try {
await updateFullOrderWithoutRevalidation(orderedIds)
} catch {
toast.error(t('general.error'))
}
},
[t],
)
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
useEffect(() => {
@@ -248,86 +495,27 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
return () => window.removeEventListener('label-deleted', handler)
}, [])
const prevRefreshKey = useRef(refreshKey)
useEffect(() => {
const search = searchParams.get('search')?.trim() || null
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
const notebook = searchParams.get('notebook')
const sharedOnly = searchParams.get('shared') === '1'
const remindersOnly = searchParams.get('reminders') === '1'
const hasActiveFilter = !!(search || labelFilter.length > 0 || colorFilter || sharedOnly || remindersOnly)
const isBackgroundRefresh = refreshKey > prevRefreshKey.current
prevRefreshKey.current = refreshKey
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
const load = async () => {
if (!isBackgroundRefresh) {
setIsLoading(true)
}
let allNotes = search
? await searchNotes(search, true, notebook || undefined)
: await getAllNotes(false, notebook || undefined)
const sharedOnly = searchParams.get('shared') === '1'
const remindersOnly = searchParams.get('reminders') === '1'
if (sharedOnly) {
allNotes = allNotes.filter((note: any) => note._isShared)
} else if (remindersOnly) {
allNotes = allNotes.filter((note: any) => note.reminder !== null)
} else if (!notebook && !search) {
allNotes = allNotes.filter((note: any) => !note.notebookId && !note._isShared)
}
if (labelFilter.length > 0) {
allNotes = allNotes.filter((note: any) =>
labelFilter.every((label: string) => note.labels?.includes(label))
)
}
if (colorFilter) {
const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter)
.map((label: any) => label.name)
allNotes = allNotes.filter((note: any) =>
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
)
}
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
})
setPinnedNotes(allNotes.filter((n: any) => n.isPinned))
setIsLoading(false)
if (hasActiveFilter || notebook) {
void fetchNotesForCurrentView()
return
}
if (refreshKey > 0 || hasActiveFilter) {
const cancelled = { value: false }
load().then(() => { if (cancelled.value) return })
return () => { cancelled.value = true }
} else {
let filtered = initialNotes
const sharedOnly = searchParams.get('shared') === '1'
const remindersOnly = searchParams.get('reminders') === '1'
if (notebook) {
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
} else if (sharedOnly) {
filtered = initialNotes.filter((n: any) => n._isShared)
} else if (remindersOnly) {
filtered = initialNotes.filter((n: any) => n.reminder !== null)
} else {
filtered = initialNotes.filter((n: any) => !n.notebookId && !n._isShared)
}
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
})
setPinnedNotes(filtered.filter(n => n.isPinned))
}
}, [searchParams, refreshKey])
const filtered = filterNotesForCurrentView(initialNotesRef.current)
setNotes((prev) => {
const localSizeMap = new Map(prev.map((n) => [n.id, n.size]))
return filtered.map((n) => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
})
setPinnedNotes(filtered.filter((n) => n.isPinned))
}, [searchParams, fetchNotesForCurrentView, filterNotesForCurrentView])
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
@@ -437,19 +625,13 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
params.delete('openNote')
const qs = params.toString()
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
// Invalidate notes cache and trigger refresh
refreshNotes(searchParams.get('notebook') || null)
}, [refreshNotes, router, searchParams])
}, [router, searchParams])
// Called by NoteEditor when a save succeeds — update local state immediately
// so the user sees fresh data if they reopen the note before getAllNotes() completes
const handleNoteSaved = useCallback((savedNote: Note) => {
setNotes(prev => prev.map(n => n.id === savedNote.id ? { ...n, ...savedNote } : n))
setPinnedNotes(prev => prev.map(n => n.id === savedNote.id ? { ...n, ...savedNote } : n))
setEditingNote(prev => prev?.note.id === savedNote.id ? { ...prev, note: savedNote } : prev)
// Refresh sidebar note titles so the new title appears immediately
refreshNotes(savedNote.notebookId || null)
}, [refreshNotes])
setNotes((prev) => prev.map((n) => (n.id === savedNote.id ? { ...n, ...savedNote } : n)))
setEditingNote((prev) => (prev?.note.id === savedNote.id ? { ...prev, note: savedNote } : prev))
emitNoteChange({ type: 'updated', note: savedNote })
}, [])
return (
<div
@@ -626,7 +808,78 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
</button>
)}
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-4 flex-wrap">
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30">
<button
type="button"
onClick={() => setViewType('notes')}
className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
viewType === 'notes'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('notes.viewNotes')}
</button>
<button
type="button"
onClick={() => setViewType('tasks')}
className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
viewType === 'tasks'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('notes.viewTasks')}
</button>
</div>
{viewType === 'notes' && (
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
<button
type="button"
onClick={() => setLayoutMode('grid')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'grid'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('notes.layoutGridTitle')}
>
<LayoutGrid size={13} />
</button>
<button
type="button"
onClick={() => setLayoutMode('list')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'list'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('notes.layoutListTitle')}
>
<List size={13} />
</button>
<button
type="button"
onClick={() => setLayoutMode('table')}
className={cn(
'p-1.5 rounded-full transition-all',
layoutMode === 'table'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={t('notes.layoutTableTitle')}
>
<Table size={13} />
</button>
</div>
)}
{searchParams.get('notebook') && (
<button
onClick={() => setSummaryDialogOpen(true)}
@@ -733,46 +986,36 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
<div className="px-4 sm:px-8 md:px-12 flex-1 pb-10 sm:pb-16 md:pb-20">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
) : (
<div className="max-w-3xl space-y-16">
{sortedPinnedNotes.length > 0 && (
<div className="mb-6">
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-2">
{t('notes.pinned')}
</h2>
<NotesEditorialView
notes={sortedPinnedNotes}
onOpen={(note: Note, readOnly?: boolean) => handleOpenNoteFresh(note.id, readOnly ?? false)}
notebookName={currentNotebook?.name}
onOpenHistory={handleOpenHistory}
/>
</div>
)}
{sortedNotes.filter((note) => !note.isPinned).length > 0 && (
<NotesEditorialView
notes={sortedNotes.filter((note) => !note.isPinned)}
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
notebookName={currentNotebook?.name}
onOpenHistory={handleOpenHistory}
/>
)}
{notes.length === 0 && (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<p className="font-memento-serif text-xl italic text-muted-foreground">
{t('notes.emptyState')}
</p>
<button
onClick={handleAddNote}
disabled={isCreating}
className="px-6 py-2 border border-foreground text-[13px] uppercase tracking-[0.2em] hover:bg-foreground hover:text-background transition-all"
>
{t('notes.createFirst')}
</button>
</div>
)}
) : notes.length === 0 ? (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<p className="font-memento-serif text-xl italic text-muted-foreground">
{t('notes.emptyState')}
</p>
<button
onClick={handleAddNote}
disabled={isCreating}
className="px-6 py-2 border border-foreground text-[13px] uppercase tracking-[0.2em] hover:bg-foreground hover:text-background transition-all"
>
{t('notes.createFirst')}
</button>
</div>
) : (
<NotesListViews
notes={sortedNotes}
pinnedNotes={sortedPinnedNotes}
viewType={viewType}
layoutMode={layoutMode}
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
onOpenHistory={handleOpenHistory}
notebookName={currentNotebook?.name}
onTogglePin={handleTogglePin}
onDeleteNote={handleDeleteNoteFromList}
onArchiveNote={handleArchiveNoteFromList}
onMoveToNotebook={handleMoveNoteToNotebook}
onNotePatch={handleNoteContentPatch}
onNoteIllustrationGenerated={handleNoteIllustrationGenerated}
onGridReorder={handleGridReorder}
/>
)}
</div>
@@ -784,8 +1027,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
</div>
)}
<MemoryEchoNotification onOpenNote={(noteId) => handleOpenNoteFresh(noteId)} />
{notebookSuggestion && (
<NotebookSuggestionToast
noteId={notebookSuggestion.noteId}
@@ -798,7 +1039,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
<BatchOrganizationDialog
open={batchOrganizationOpen}
onOpenChange={setBatchOrganizationOpen}
onNotesMoved={() => router.refresh()}
onNotesMoved={() => fetchNotesForCurrentView({ silent: true })}
/>
)}
@@ -810,7 +1051,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
if (!open) dismissLabelSuggestion()
}}
notebookId={suggestNotebookId}
onLabelsCreated={() => router.refresh()}
onLabelsCreated={() => fetchNotesForCurrentView({ silent: true })}
/>
)}
@@ -846,8 +1087,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
notebookName={currentNotebook.name}
onDone={() => {
refreshNotebooks()
triggerRefresh()
router.refresh()
}}
/>
)}

View File

@@ -0,0 +1,152 @@
'use client'
import { useState } from 'react'
import { Sparkles, X, ShieldAlert, Check } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
interface AiConsentModalProps {
open: boolean
onClose: () => void
onConfirm: (remember: boolean) => void
}
export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps) {
const { t } = useLanguage()
const [remember, setRemember] = useState(true)
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
{/* Glassmorphism Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
{/* Modal Container */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: 'spring', duration: 0.4 }}
className={cn(
"relative w-full max-w-lg overflow-hidden border border-[var(--border)]",
"bg-[var(--memento-paper)] text-[var(--ink)] shadow-2xl rounded-lg p-6",
"flex flex-col gap-5"
)}
>
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-1 rounded-md text-[var(--ink)]/60 hover:text-[var(--ink)] hover:bg-[var(--concrete)] transition-colors"
>
<X className="w-5 h-5" />
</button>
{/* Header */}
<div className="flex items-start gap-4">
<div className="p-3 bg-[var(--accent-tint)] text-[var(--accent-color)] rounded-lg">
<BrainIcon className="w-6 h-6 animate-pulse" />
</div>
<div className="flex flex-col gap-1 pr-6">
<h3 className="text-lg font-semibold tracking-tight">
{t('consent.ai.modalTitle') || 'Consentement requis pour le traitement par IA'}
</h3>
<span className="text-xs uppercase tracking-wider text-[var(--ink)]/50 font-medium">
{t('consent.ai.complianceBadge') || 'Conformité RGPD'}
</span>
</div>
</div>
{/* Content */}
<div className="text-sm leading-relaxed text-[var(--ink)]/80 flex flex-col gap-3">
<p>
{t('consent.ai.modalDescription') ||
"Pour analyser vos notes, PDFs ou sessions de remue-méninges, Memento transmet de manière sécurisée ces données à des API d'IA tierces (OpenAI, Gemini, DeepSeek). Nous appliquons une politique de rétention de données nulle. En acceptant, vous autorisez ce traitement."}
</p>
<div className="p-3 bg-[var(--concrete)] border border-[var(--border)] rounded-md text-xs flex gap-3 items-start">
<ShieldAlert className="w-4 h-4 text-[var(--accent-color)] shrink-0 mt-0.5" />
<div className="flex flex-col gap-0.5">
<span className="font-semibold">{t('consent.ai.zeroRetentionTitle') || 'Zéro Rétention de Données'}</span>
<span>
{t('consent.ai.zeroRetentionDesc') ||
'Toutes les requêtes sortantes incluent des indicateurs de non-apprentissage pour protéger votre propriété intellectuelle.'}
</span>
</div>
</div>
</div>
{/* Checkbox */}
<label className="flex items-center gap-3 cursor-pointer select-none py-1">
<div className="relative">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="sr-only"
/>
<div
className={cn(
"w-5 h-5 rounded border border-[var(--border)] transition-colors flex items-center justify-center",
remember ? "bg-[var(--accent-color)] border-[var(--accent-color)]" : "bg-transparent"
)}
>
{remember && <Check className="w-3.5 h-3.5 text-white stroke-[3px]" />}
</div>
</div>
<span className="text-xs font-medium text-[var(--ink)]/80">
{t('consent.ai.rememberMe') || 'Se souvenir de mon choix (ne plus demander)'}
</span>
</label>
{/* Actions */}
<div className="flex items-center justify-end gap-3 mt-2">
<button
onClick={onClose}
className={cn(
"px-4 py-2 text-xs font-semibold rounded-md border border-[var(--border)]",
"hover:bg-[var(--concrete)] transition-colors bg-transparent text-[var(--ink)]"
)}
>
{t('consent.ai.rejectButton') || 'Refuser'}
</button>
<button
onClick={() => onConfirm(remember)}
className={cn(
"px-4 py-2 text-xs font-semibold rounded-md text-white shadow-sm transition-opacity hover:opacity-90",
"bg-[var(--accent-color)] flex items-center gap-2"
)}
>
<Sparkles className="w-3.5 h-3.5" />
{t('consent.ai.acceptButton') || 'Autoriser et continuer'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}
function BrainIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1 0-3.12 3 3 0 0 1 0-3.88 2.5 2.5 0 0 1 0-3.12A2.5 2.5 0 0 1 9.5 2Z" />
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 0-3.12 3 3 0 0 0 0-3.88 2.5 2.5 0 0 0 0-3.12A2.5 2.5 0 0 0 14.5 2Z" />
</svg>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import { getLocalStorageAiConsent, setLocalStorageAiConsent, removeLocalStorageAiConsent } from '@/lib/consent/ai-consent-client'
import { updateAISettings } from '@/app/actions/ai-settings'
import { AiConsentModal } from './ai-consent-modal'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface AiConsentContextType {
hasAiConsent: boolean
requestAiConsent: () => Promise<boolean>
revokeConsent: () => Promise<void>
}
const AiConsentContext = createContext<AiConsentContextType | undefined>(undefined)
interface AiConsentProviderProps {
children: React.ReactNode
initialPersistentConsent?: boolean
}
export function AiConsentProvider({ children, initialPersistentConsent = false }: AiConsentProviderProps) {
const { data: session, update: updateSession } = useSession()
const { t } = useLanguage()
const [persistentConsent, setPersistentConsent] = useState(false)
const [sessionConsent, setSessionConsent] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [hydrated, setHydrated] = useState(false)
const pendingResolveRef = useRef<((value: boolean) => void) | null>(null)
useEffect(() => {
const local = getLocalStorageAiConsent()
if (local || initialPersistentConsent) {
setPersistentConsent(true)
if (initialPersistentConsent && !local) {
setLocalStorageAiConsent(true)
}
}
setHydrated(true)
}, [initialPersistentConsent])
useEffect(() => {
if (session?.aiSessionConsent === true) {
setSessionConsent(true)
} else if (session?.aiSessionConsent === false) {
setSessionConsent(false)
}
}, [session?.aiSessionConsent])
const hasAiConsent =
hydrated &&
(persistentConsent || sessionConsent || session?.aiSessionConsent === true)
const requestAiConsent = useCallback((): Promise<boolean> => {
if (hasAiConsent) {
return Promise.resolve(true)
}
setModalOpen(true)
return new Promise<boolean>((resolve) => {
pendingResolveRef.current = resolve
})
}, [hasAiConsent])
const handleConfirm = async (remember: boolean) => {
setModalOpen(false)
try {
const res = await fetch('/api/user/ai-consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consent: true, remember }),
})
if (!res.ok) {
toast.error(t('consent.ai.auditFailed') || 'Impossible denregistrer votre consentement. Réessayez.')
pendingResolveRef.current?.(false)
pendingResolveRef.current = null
return
}
if (remember) {
setLocalStorageAiConsent(true)
setPersistentConsent(true)
try {
await updateAISettings({ aiProcessingConsent: true })
} catch (e) {
console.error('[AiConsentProvider] Failed to sync consent to DB:', e)
}
} else {
await updateSession({ aiSessionConsent: true })
setSessionConsent(true)
}
pendingResolveRef.current?.(true)
pendingResolveRef.current = null
} catch (e) {
console.error('[AiConsentProvider] Failed to log consent audit:', e)
toast.error(t('consent.ai.auditFailed') || 'Impossible denregistrer votre consentement. Réessayez.')
pendingResolveRef.current?.(false)
pendingResolveRef.current = null
}
}
const handleClose = () => {
setModalOpen(false)
toast.warning(t('consent.ai.aborted') || 'Traitement IA annulé (consentement refusé).')
pendingResolveRef.current?.(false)
pendingResolveRef.current = null
}
const revokeConsent = async () => {
removeLocalStorageAiConsent()
setPersistentConsent(false)
setSessionConsent(false)
try {
await fetch('/api/user/ai-consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consent: false, remember: true }),
})
} catch (e) {
console.error('[AiConsentProvider] Failed to log revocation:', e)
}
try {
await updateSession({ aiSessionConsent: false })
} catch (e) {
console.error('[AiConsentProvider] Failed to clear session consent:', e)
}
if (session?.user) {
try {
await updateAISettings({ aiProcessingConsent: false })
} catch (e) {
console.error('[AiConsentProvider] Failed to sync revocation to DB:', e)
}
}
toast.success(t('consent.ai.revokedToast') || 'Consentement IA révoqué avec succès.')
}
return (
<AiConsentContext.Provider
value={{
hasAiConsent,
requestAiConsent,
revokeConsent,
}}
>
{children}
<AiConsentModal
open={modalOpen}
onClose={handleClose}
onConfirm={handleConfirm}
/>
</AiConsentContext.Provider>
)
}
export function useAiConsent() {
const context = useContext(AiConsentContext)
if (context === undefined) {
throw new Error('useAiConsent must be used within an AiConsentProvider')
}
return context
}

View File

@@ -0,0 +1,178 @@
'use client'
import { useEffect, useState } from 'react'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { ExternalLink, Link2, Columns2, GitMerge, Loader2, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
import { cn } from '@/lib/utils'
interface LinkedNotePreviewDialogProps {
isOpen: boolean
onClose: () => void
noteId: string
initialTitle?: string | null
initialExcerpt?: string
onInsertCitation?: () => void
onCompare?: () => void
onMerge?: () => void
citationLoading?: boolean
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}
function openNoteInNewTab(noteId: string) {
window.open(`/home?openNote=${encodeURIComponent(noteId)}`, '_blank', 'noopener,noreferrer')
}
const linkActionClass =
'inline-flex items-center gap-1 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors hover:underline'
export function LinkedNotePreviewDialog({
isOpen,
onClose,
noteId,
initialTitle,
initialExcerpt,
onInsertCitation,
onCompare,
onMerge,
citationLoading = false,
}: LinkedNotePreviewDialogProps) {
const { t } = useLanguage()
const [title, setTitle] = useState(initialTitle ?? '')
const [content, setContent] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [loadError, setLoadError] = useState(false)
useEffect(() => {
if (!isOpen || !noteId) return
let cancelled = false
setIsLoading(true)
setLoadError(false)
setTitle(initialTitle ?? '')
setContent('')
void (async () => {
try {
const res = await fetch(`/api/notes/${noteId}`)
if (!res.ok) throw new Error('fetch failed')
const data = await res.json()
if (cancelled) return
if (data.success && data.data) {
setTitle(data.data.title || t('memoryEcho.comparison.untitled'))
setContent(data.data.content || '')
} else {
setLoadError(true)
}
} catch {
if (!cancelled) setLoadError(true)
} finally {
if (!cancelled) setIsLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isOpen, noteId, initialTitle, t])
const displayTitle = title || initialTitle || t('memoryEcho.comparison.untitled')
const plainBody = content ? stripHtml(content) : (initialExcerpt ?? '')
const isHtml = content.includes('<')
return (
<Dialog open={isOpen} onOpenChange={open => { if (!open) onClose() }}>
<DialogContent showCloseButton={false} className="max-w-lg max-h-[72vh] overflow-hidden flex flex-col p-0 gap-0">
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border/50">
<DialogTitle className="text-sm font-semibold truncate pr-2 leading-snug">
{displayTitle}
</DialogTitle>
<button
type="button"
onClick={onClose}
className="p-0.5 rounded text-muted-foreground hover:text-foreground shrink-0"
aria-label={t('memoryEcho.editorSection.close')}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 min-h-0">
{isLoading && !plainBody && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('memoryEcho.editorSection.loading')}
</div>
)}
{loadError && !plainBody && (
<p className="text-xs text-muted-foreground">{t('memoryEcho.preview.loadError')}</p>
)}
{content && isHtml && (
<div
className={cn(
'prose prose-sm dark:prose-invert max-w-none',
'prose-p:text-sm prose-p:leading-relaxed prose-p:my-2',
'prose-headings:text-sm prose-headings:font-semibold prose-headings:mt-3 prose-headings:mb-1',
'prose-li:text-sm prose-table:text-xs'
)}
dangerouslySetInnerHTML={{ __html: content }}
/>
)}
{!isHtml && plainBody && (
<div className="text-sm leading-relaxed text-foreground/90 whitespace-pre-wrap font-serif">
{plainBody}
</div>
)}
</div>
<div className="px-4 py-2.5 border-t border-border/50 flex items-center justify-between gap-2 flex-wrap">
<button
type="button"
onClick={() => openNoteInNewTab(noteId)}
className={linkActionClass}
>
<ExternalLink className="h-3 w-3" />
{t('memoryEcho.editorSection.openInEditor')}
</button>
<div className="flex items-center gap-3">
{onCompare && (
<button
type="button"
className={linkActionClass}
onClick={() => { onCompare(); onClose() }}
>
<Columns2 className="h-3 w-3" />
{t('memoryEcho.editorSection.compare')}
</button>
)}
{onInsertCitation && (
<button
type="button"
disabled={citationLoading}
className="inline-flex items-center gap-1 text-[11px] font-bold text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
onClick={() => { onInsertCitation(); onClose() }}
>
<Link2 className="h-3 w-3" />
{citationLoading
? t('memoryEcho.editorSection.embedding')
: t('memoryEcho.editorSection.embedPassage')}
</button>
)}
{onMerge && (
<button
type="button"
className="inline-flex items-center gap-1 text-[11px] font-semibold text-purple-600 dark:text-purple-400 hover:underline"
onClick={() => { onMerge(); onClose() }}
>
<GitMerge className="h-3 w-3" />
{t('memoryEcho.editorSection.merge')}
</button>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,321 +0,0 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Lightbulb, ThumbsUp, ThumbsDown, X, Sparkles, ArrowRight, GitMerge } from 'lucide-react'
import { toast } from 'sonner'
import { ComparisonModal } from './comparison-modal'
import { FusionModal } from './fusion-modal'
import { createNote, updateNote } from '@/app/actions/notes'
import { Note } from '@/lib/types'
interface MemoryEchoInsight {
id: string
note1Id: string
note2Id: string
note1: {
id: string
title: string | null
content: string
}
note2: {
id: string
title: string | null
content: string
}
similarityScore: number
insight: string
insightDate: Date
viewed: boolean
feedback: string | null
}
interface MemoryEchoNotificationProps {
onOpenNote?: (noteId: string) => void
}
export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationProps) {
const { t } = useLanguage()
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isDismissed, setIsDismissed] = useState(false)
const [showComparison, setShowComparison] = useState(false)
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [demoMode, setDemoMode] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const dismissedPermanently = useRef(false)
// Fetch insight on mount
useEffect(() => {
fetchInsight()
return () => {
if (pollingRef.current) clearInterval(pollingRef.current)
}
}, [])
const fetchInsight = async () => {
setIsLoading(true)
try {
const res = await fetch('/api/ai/echo')
const data = await res.json()
if (data.insight) {
setInsight(data.insight)
}
} catch (error) {
console.error('[MemoryEcho] Failed to fetch insight:', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (dismissedPermanently.current) return
}, [isDismissed, demoMode])
const handleView = async () => {
if (!insight) return
try {
await fetch('/api/ai/echo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'view', insightId: insight.id })
})
toast.success(t('toast.openingConnection'))
setShowComparison(true)
} catch (error) {
console.error('[MemoryEcho] Failed to view connection:', error)
toast.error(t('toast.openConnectionFailed'))
}
}
const handleFeedback = async (feedback: 'thumbs_up' | 'thumbs_down') => {
if (!insight) return
try {
await fetch('/api/ai/echo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'feedback', insightId: insight.id, feedback })
})
if (feedback === 'thumbs_up') {
toast.success(t('toast.thanksFeedback'))
} else {
toast.success(t('toast.thanksFeedbackImproving'))
}
setIsDismissed(true)
// Stop polling after explicit feedback
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
} catch (error) {
console.error('[MemoryEcho] Failed to submit feedback:', error)
toast.error(t('toast.feedbackFailed'))
}
}
const handleMergeNotes = async (noteIds: string[]) => {
if (!insight) return
const fetched = await Promise.all(noteIds.map(async (id) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) return null
const data = await res.json()
return data.success && data.data ? data.data : null
} catch { return null }
}))
setFusionNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
}
const handleDismiss = () => {
setIsDismissed(true)
dismissedPermanently.current = true
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
}
if (isLoading || !insight) {
return null
}
const note1Title = insight.note1.title || t('notification.untitled')
const note2Title = insight.note2.title || t('notification.untitled')
const similarityPercentage = Math.round(insight.similarityScore * 100)
const comparisonNotes: Array<Partial<Note>> = [
{ id: insight.note1.id, title: insight.note1.title, content: insight.note1.content },
{ id: insight.note2.id, title: insight.note2.title, content: insight.note2.content }
]
return (
<>
{/* Comparison Modal */}
<ComparisonModal
isOpen={showComparison}
onClose={() => {
setShowComparison(false)
setIsDismissed(true)
}}
notes={comparisonNotes}
similarity={insight.similarityScore}
onOpenNote={(noteId) => {
setShowComparison(false)
setIsDismissed(true)
onOpenNote?.(noteId)
}}
onMergeNotes={handleMergeNotes}
/>
{/* Fusion Modal */}
{fusionNotes.length > 0 && (
<FusionModal
isOpen={fusionNotes.length > 0}
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={async ({ title, content }, options) => {
await createNote({
title,
content,
labels: options.keepAllTags
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
: fusionNotes[0].labels || [],
color: fusionNotes[0].color,
type: 'text',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
notebookId: fusionNotes[0].notebookId ?? undefined
})
if (options.archiveOriginals) {
for (const n of fusionNotes) {
if (n.id) await updateNote(n.id, { isArchived: true })
}
}
toast.success(t('toast.notesFusionSuccess'))
setFusionNotes([])
setIsDismissed(true)
}}
/>
)}
{/* Notification Card — hidden when dismissed or modal open */}
{!isDismissed && (
<div className="fixed bottom-4 right-4 z-50 max-w-md w-full animate-in slide-in-from-bottom-4 fade-in duration-500">
<Card className="border-amber-200 dark:border-amber-900 shadow-lg bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/20 dark:to-background">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Lightbulb className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<CardTitle className="text-base flex items-center gap-2">
{t('memoryEcho.title')}
<Sparkles className="h-4 w-4 text-amber-500" />
</CardTitle>
<CardDescription className="text-xs mt-1">
{t('memoryEcho.description')}
</CardDescription>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -mr-2 -mt-2"
onClick={handleDismiss}
>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* AI-generated insight */}
<div className="bg-white dark:bg-zinc-900 rounded-lg p-3 border border-amber-100 dark:border-amber-900/30">
<p className="text-sm text-gray-700 dark:text-gray-300">
{insight.insight}
</p>
</div>
{/* Connected notes */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="border-primary/20 text-primary dark:border-primary/30 dark:text-primary-foreground">
{note1Title}
</Badge>
<ArrowRight className="h-3 w-3 text-gray-400" />
<Badge variant="outline" className="border-purple-200 text-purple-700 dark:border-purple-900 dark:text-purple-300">
{note2Title}
</Badge>
<Badge variant="secondary" className="ml-auto text-xs">
{t('memoryEcho.match', { percentage: similarityPercentage })}
</Badge>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
onClick={handleView}
>
{t('memoryEcho.viewConnection')}
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 border-purple-200 text-purple-700 hover:bg-purple-50 dark:border-purple-800 dark:text-purple-400 dark:hover:bg-purple-950/20"
onClick={() => handleMergeNotes([insight.note1.id, insight.note2.id])}
>
<GitMerge className="h-4 w-4 mr-1" />
{t('memoryEcho.editorSection.merge')}
</Button>
<div className="flex items-center gap-1 border-l pl-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
onClick={() => handleFeedback('thumbs_up')}
title={t('memoryEcho.helpful')}
>
<ThumbsUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => handleFeedback('thumbs_down')}
title={t('memoryEcho.notHelpful')}
>
<ThumbsDown className="h-4 w-4" />
</Button>
</div>
</div>
{/* Dismiss link */}
<button
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
onClick={handleDismiss}
>
{t('memoryEcho.dismiss')}
</button>
</CardContent>
</Card>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,546 @@
'use client'
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { ChevronDown, ChevronUp, Sparkles, Link2, X, Loader2, HelpCircle } from 'lucide-react'
import { LinkedNotePreviewDialog } from '@/components/linked-note-preview-dialog'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
import type { BlockSuggestion } from '@/components/block-picker'
import { toast } from 'sonner'
import { useNoteEditorContext } from '@/components/note-editor/note-editor-context'
interface ConnectionData {
noteId: string
title: string | null
content: string
createdAt: Date
similarity: number
daysApart: number
}
interface LiveBlockRefHost {
targetNoteId: string
targetNoteTitle: string
notebookName: string
blockIds: string[]
createdAt: string
}
interface MemoryEchoSectionProps {
noteId: string
onCompareNotes?: (noteIds: string[], meta?: { similarity?: number }) => void
onMergeNotes?: (noteIds: string[]) => void
}
interface PreviewTarget {
noteId: string
title: string | null
excerpt: string
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}
function excerpt(text: string, max = 150): string {
const plain = stripHtml(text)
if (plain.length <= max) return plain
return `${plain.slice(0, max).trim()}`
}
async function resolveBlockForEmbed(sourceNoteId: string, hint: string): Promise<{ block: BlockSuggestion; mode: 'live' | 'citation' } | null> {
const params = new URLSearchParams({ noteId: sourceNoteId, hint })
const res = await fetch(`/api/blocks/resolve?${params}`)
if (!res.ok) return null
const data = await res.json()
if (!data.block) return null
return { block: data.block as BlockSuggestion, mode: data.mode === 'live' ? 'live' : 'citation' }
}
function dispatchLiveBlockInsert(block: BlockSuggestion) {
window.dispatchEvent(new CustomEvent('memento-insert-live-block', { detail: { block } }))
}
function dispatchCitationInsert(payload: { noteId: string; noteTitle: string; excerpt: string }) {
window.dispatchEvent(new CustomEvent('memento-insert-citation', { detail: { ...payload, atEnd: true } }))
}
function ActionWithHelp({
label,
help,
children,
}: {
label: string
help: string
children: ReactNode
}) {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-left">
<p className="font-medium mb-0.5">{label}</p>
<p className="text-background/80">{help}</p>
</TooltipContent>
</Tooltip>
)
}
export function MemoryEchoSection({
noteId,
onCompareNotes,
onMergeNotes,
}: MemoryEchoSectionProps) {
const { t } = useLanguage()
const editorCtx = useNoteEditorContext()
const [connections, setConnections] = useState<ConnectionData[]>([])
const [retroRefs, setRetroRefs] = useState<LiveBlockRefHost[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isExpanded, setIsExpanded] = useState(false)
const [isVisible, setIsVisible] = useState(true)
const [consentRequired, setConsentRequired] = useState(false)
const [embeddingId, setEmbeddingId] = useState<string | null>(null)
const [helpOpen, setHelpOpen] = useState(false)
const [previewTarget, setPreviewTarget] = useState<PreviewTarget | null>(null)
useEffect(() => {
let cancelled = false
setIsLoading(true)
setConsentRequired(false)
setConnections([])
setRetroRefs([])
const load = async () => {
try {
const [connRes, retroRes] = await Promise.all([
fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=10`),
fetch(`/api/notes/${noteId}/live-block-refs`),
])
if (cancelled) return
if (connRes.status === 403) {
setConsentRequired(true)
setConnections([])
} else if (connRes.ok) {
const data = await connRes.json()
setConnections(data.connections || [])
} else {
setConnections([])
}
if (retroRes.ok) {
const data = await retroRes.json()
setRetroRefs(data.refs || [])
} else {
setRetroRefs([])
}
} catch (error) {
console.error('[MemoryEchoSection] Failed to fetch:', error)
} finally {
if (!cancelled) setIsLoading(false)
}
}
// Lazy load léger : ne bloque pas l'ouverture de la note
const timer = window.setTimeout(() => { void load() }, 400)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [noteId])
// Scroll doux vers la section quand une forte connexion apparaît (une fois par note)
useEffect(() => {
if (isLoading || connections.length === 0) return
const top = connections[0]
if (!top || top.similarity < 0.75) return
const key = `memory-echo-scroll-${noteId}`
if (sessionStorage.getItem(key)) return
sessionStorage.setItem(key, '1')
requestAnimationFrame(() => {
document.getElementById('memory-echo-section')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
}, [isLoading, connections, noteId])
const handleEmbed = useCallback(async (conn: ConnectionData) => {
setEmbeddingId(conn.noteId)
try {
const hint = excerpt(stripHtml(conn.content), 300)
const resolved = await resolveBlockForEmbed(conn.noteId, hint)
const noteTitle = conn.title || t('memoryEcho.comparison.untitled')
if (resolved?.mode === 'live' && resolved.block.blockId) {
const block = resolved.block
if (noteId) {
try {
await fetch('/api/blocks/embed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceNoteId: block.noteId,
blockId: block.blockId,
targetNoteId: noteId,
}),
})
} catch {
// non bloquant
}
}
const insertedLive = editorCtx.richTextEditorRef.current?.insertLiveBlock(block, { atEnd: true })
if (!insertedLive) {
dispatchLiveBlockInsert(block)
}
if (insertedLive || editorCtx.richTextEditorRef.current?.getEditor()) {
toast.success(t('memoryEcho.editorSection.embedSuccess'))
} else {
toast.error(t('memoryEcho.editorSection.embedFailed'))
}
return
}
const citationText = stripHtml(
resolved?.block.content || conn.content || hint
).slice(0, 1200)
if (!citationText.trim()) {
toast.error(t('memoryEcho.editorSection.embedFailed'))
return
}
const payload = { noteId: conn.noteId, noteTitle, excerpt: citationText }
if (editorCtx.state.isMarkdown) {
const quoted = citationText.split('\n').map(line => `> ${line}`).join('\n')
const mdCitation = `\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n`
editorCtx.actions.setContent(editorCtx.state.content + mdCitation)
toast.success(t('memoryEcho.editorSection.citationSuccess'))
return
}
const inserted = editorCtx.richTextEditorRef.current?.insertCitation(payload, { atEnd: true })
if (inserted) {
toast.success(t('memoryEcho.editorSection.citationSuccess'))
return
}
dispatchCitationInsert(payload)
toast.success(t('memoryEcho.editorSection.citationSuccess'))
} catch {
toast.error(t('memoryEcho.editorSection.embedFailed'))
} finally {
setEmbeddingId(null)
}
}, [t, noteId, editorCtx])
if (!isVisible) return null
const hasConnections = connections.length > 0
const hasRetro = retroRefs.length > 0
if (!isLoading && !hasConnections && !hasRetro && !consentRequired) {
return null
}
const topConnection = connections[0]
const restConnections = connections.slice(1)
return (
<div id="memory-echo-section" className="mt-10 space-y-6 scroll-mt-24">
{isLoading && (
<div
className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-5 animate-pulse space-y-3"
aria-busy="true"
>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-indigo-400" />
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-indigo-500/60">
{t('memoryEcho.editorSection.badgeLabel')}
</span>
</div>
<div className="h-4 bg-indigo-500/10 rounded w-3/4" />
<div className="h-16 bg-indigo-500/5 rounded border-l-2 border-indigo-500/20" />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('memoryEcho.editorSection.loading')}
</div>
</div>
)}
{!isLoading && consentRequired && (
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/[0.04] p-4 text-sm text-muted-foreground">
{t('memoryEcho.editorSection.consentRequired')}
</div>
)}
{!isLoading && hasConnections && topConnection && (
<div className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Sparkles className="h-4 w-4 text-indigo-500 animate-pulse shrink-0" />
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-indigo-600/80 dark:text-indigo-400/80 truncate">
{t('memoryEcho.editorSection.badgeLabel')}
</span>
<button
type="button"
onClick={() => setHelpOpen(v => !v)}
className="p-0.5 rounded-md text-indigo-500/70 hover:text-indigo-600 hover:bg-indigo-500/10 transition-colors"
aria-expanded={helpOpen}
aria-label={t('memoryEcho.editorSection.helpToggle')}
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center gap-1">
<span className="text-[11px] font-mono font-semibold px-2 py-0.5 rounded-full bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 border border-indigo-500/15">
{t('memoryEcho.editorSection.affinityBadge', {
percentage: Math.round(topConnection.similarity * 100),
})}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => setIsVisible(false)}
title={t('memoryEcho.editorSection.close')}
>
<X className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground hidden sm:block">
{t('memoryEcho.editorSection.intro')}
</p>
{helpOpen && (
<div className="rounded-xl border border-indigo-500/15 bg-indigo-500/[0.04] p-4 space-y-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground">{t('memoryEcho.editorSection.helpTitle')}</p>
<ul className="space-y-2.5 list-none pl-0">
<li><strong className="text-foreground">{t('memoryEcho.editorSection.viewLinkedNote')}</strong> {t('memoryEcho.editorSection.helpView')}</li>
<li><strong className="text-foreground">{t('memoryEcho.editorSection.embedPassage')}</strong> {t('memoryEcho.editorSection.helpCite')}</li>
<li><strong className="text-foreground">{t('memoryEcho.editorSection.compare')}</strong> {t('memoryEcho.editorSection.helpCompare')}</li>
<li><strong className="text-foreground">{t('memoryEcho.editorSection.merge')}</strong> {t('memoryEcho.editorSection.helpMerge')}</li>
</ul>
</div>
)}
<blockquote className="border-l-2 border-indigo-500/20 pl-3 font-serif italic text-sm leading-relaxed text-foreground/85">
« {excerpt(topConnection.content)} »
</blockquote>
<div className="flex items-center justify-between gap-2 pt-1 border-t border-indigo-500/10 flex-wrap text-[11px]">
<span className="text-muted-foreground min-w-0 truncate">
{t('memoryEcho.editorSection.detectedIn', {
title: topConnection.title || t('memoryEcho.comparison.untitled'),
})}
</span>
<div className="flex items-center gap-3 shrink-0">
<ActionWithHelp
label={t('memoryEcho.editorSection.viewLinkedNote')}
help={t('memoryEcho.editorSection.helpView')}
>
<button
type="button"
className="font-semibold text-muted-foreground hover:text-foreground hover:underline transition-colors"
onClick={() => setPreviewTarget({
noteId: topConnection.noteId,
title: topConnection.title,
excerpt: excerpt(topConnection.content, 500),
})}
>
{t('memoryEcho.editorSection.view')}
</button>
</ActionWithHelp>
<ActionWithHelp
label={t('memoryEcho.editorSection.embedPassage')}
help={t('memoryEcho.editorSection.helpCite')}
>
<button
type="button"
disabled={embeddingId === topConnection.noteId}
className="inline-flex items-center gap-1 font-bold text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
onClick={() => void handleEmbed(topConnection)}
>
<Link2 className="h-3 w-3" />
{embeddingId === topConnection.noteId
? t('memoryEcho.editorSection.embedding')
: t('memoryEcho.editorSection.embedPassage')}
</button>
</ActionWithHelp>
{onCompareNotes && (
<ActionWithHelp
label={t('memoryEcho.editorSection.compare')}
help={t('memoryEcho.editorSection.helpCompare')}
>
<button
type="button"
className="font-semibold text-muted-foreground hover:text-foreground hover:underline transition-colors"
onClick={() => onCompareNotes([noteId, topConnection.noteId], { similarity: topConnection.similarity })}
>
{t('memoryEcho.editorSection.compare')}
</button>
</ActionWithHelp>
)}
{onMergeNotes && (
<ActionWithHelp
label={t('memoryEcho.editorSection.merge')}
help={t('memoryEcho.editorSection.helpMerge')}
>
<button
type="button"
className="font-semibold text-purple-600 dark:text-purple-400 hover:underline transition-colors"
onClick={() => onMergeNotes([noteId, topConnection.noteId])}
>
{t('memoryEcho.editorSection.merge')}
</button>
</ActionWithHelp>
)}
</div>
</div>
{restConnections.length > 0 && (
<div className="pt-2 border-t border-indigo-500/10">
<button
type="button"
className="flex items-center gap-1.5 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={() => setIsExpanded(v => !v)}
>
{isExpanded
? t('memoryEcho.editorSection.hideAll', { count: connections.length })
: t('memoryEcho.editorSection.showAll', { count: connections.length })}
{isExpanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
{isExpanded && (
<div className="mt-3 space-y-2 max-h-[280px] overflow-y-auto">
{restConnections.map(conn => (
<div
key={conn.noteId}
className="rounded-xl border border-border/60 p-3 bg-background/60 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium flex-1">
{conn.title || t('memoryEcho.comparison.untitled')}
</h4>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-indigo-500/10 text-indigo-700 dark:text-indigo-300">
{Math.round(conn.similarity * 100)}%
</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 font-serif italic">
« {excerpt(conn.content, 120)} »
</p>
<div className="flex items-center gap-3 text-[11px]">
<button
type="button"
className="font-semibold text-muted-foreground hover:text-foreground hover:underline"
onClick={() => setPreviewTarget({
noteId: conn.noteId,
title: conn.title,
excerpt: excerpt(conn.content, 500),
})}
>
{t('memoryEcho.editorSection.view')}
</button>
<button
type="button"
disabled={embeddingId === conn.noteId}
className="inline-flex items-center gap-1 font-bold text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
onClick={() => void handleEmbed(conn)}
>
<Link2 className="h-3 w-3" />
{t('memoryEcho.editorSection.embedPassage')}
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{!isLoading && hasRetro && (
<div className="rounded-2xl border border-blue-500/10 bg-blue-500/[0.02] p-5 space-y-3">
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-60" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[10px] font-bold uppercase tracking-[0.22em] text-blue-600/80 dark:text-blue-400/80">
{t('memoryEcho.editorSection.retroTitle')}
</span>
</div>
<p className="text-sm text-muted-foreground">
{t('memoryEcho.editorSection.retroDescription', { count: retroRefs.length })}
</p>
<div className="space-y-2">
{retroRefs.map(ref => (
<button
key={ref.targetNoteId}
type="button"
onClick={() => setPreviewTarget({
noteId: ref.targetNoteId,
title: ref.targetNoteTitle,
excerpt: '',
})}
className={cn(
'w-full text-left rounded-xl border border-border/50 p-3',
'hover:bg-muted/40 transition-colors flex items-center gap-3'
)}
>
<Link2 className="h-4 w-4 text-blue-500 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{ref.targetNoteTitle || t('memoryEcho.comparison.untitled')}
</p>
{ref.notebookName && (
<p className="text-[11px] text-muted-foreground truncate">{ref.notebookName}</p>
)}
</div>
</button>
))}
</div>
</div>
)}
{previewTarget && (
<LinkedNotePreviewDialog
isOpen
onClose={() => setPreviewTarget(null)}
noteId={previewTarget.noteId}
initialTitle={previewTarget.title}
initialExcerpt={previewTarget.excerpt}
citationLoading={embeddingId === previewTarget.noteId}
onInsertCitation={() => {
void handleEmbed({
noteId: previewTarget.noteId,
title: previewTarget.title,
content: previewTarget.excerpt || connections.find(c => c.noteId === previewTarget.noteId)?.content || '',
createdAt: new Date(),
similarity: connections.find(c => c.noteId === previewTarget.noteId)?.similarity ?? 0,
daysApart: 0,
})
}}
onCompare={
onCompareNotes
? () => onCompareNotes([noteId, previewTarget.noteId], {
similarity: connections.find(c => c.noteId === previewTarget.noteId)?.similarity,
})
: undefined
}
onMerge={
onMergeNotes
? () => onMergeNotes([noteId, previewTarget.noteId])
: undefined
}
/>
)}
</div>
)
}
/** @deprecated Use MemoryEchoSection */
export { MemoryEchoSection as EditorConnectionsSection }

View File

@@ -0,0 +1,184 @@
'use client'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { AnimatePresence } from 'motion/react'
import {
getNotebookPickerPosition,
NotebookHierarchyPanel,
type NotebookPickerItem,
} from '@/components/notebook-hierarchy-panel'
import { useLanguage } from '@/lib/i18n'
type MoveToNotebookPickerProps = {
notebooks: NotebookPickerItem[]
currentNotebookId?: string | null
onSelect: (notebookId: string | null) => void
children: React.ReactElement
align?: 'start' | 'end'
preferDropUp?: boolean
}
export function MoveToNotebookPicker({
notebooks,
currentNotebookId = null,
onSelect,
children,
align = 'end',
preferDropUp = false,
}: MoveToNotebookPickerProps) {
const { t } = useLanguage()
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLElement>(null)
const [panelStyle, setPanelStyle] = useState<React.CSSProperties | undefined>()
const close = useCallback(() => setOpen(false), [])
const updatePosition = useCallback(() => {
if (!triggerRef.current) return
setPanelStyle(
getNotebookPickerPosition(triggerRef.current.getBoundingClientRect(), { align, preferDropUp }),
)
}, [align, preferDropUp])
useEffect(() => {
if (!open) {
setPanelStyle(undefined)
return
}
updatePosition()
window.addEventListener('scroll', updatePosition, true)
window.addEventListener('resize', updatePosition)
return () => {
window.removeEventListener('scroll', updatePosition, true)
window.removeEventListener('resize', updatePosition)
}
}, [open, updatePosition])
const handleSelect = (notebookId: string | null) => {
onSelect(notebookId)
close()
}
const child = React.cloneElement(children, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node
const childRef = (children as React.ReactElement & { ref?: React.Ref<HTMLElement> }).ref
if (typeof childRef === 'function') childRef(node)
else if (childRef && typeof childRef === 'object') {
;(childRef as React.MutableRefObject<HTMLElement | null>).current = node
}
},
onClick: (e: React.MouseEvent) => {
e.stopPropagation()
children.props.onClick?.(e)
setOpen((prev) => !prev)
},
})
return (
<>
{child}
{typeof window !== 'undefined' &&
createPortal(
<AnimatePresence>
{open && panelStyle && (
<>
<div className="fixed inset-0 z-[9998]" onClick={close} aria-hidden />
<NotebookHierarchyPanel
key="move-notebook-picker"
notebooks={notebooks}
selectedId={currentNotebookId}
onSelect={(id) => handleSelect(id)}
onClose={close}
showGeneralNotes
generalNotesLabel={t('notebookSuggestion.generalNotes') || 'Notes générales'}
onSelectGeneralNotes={() => handleSelect(null)}
searchPlaceholder={t('notebookSuggestion.filterNotebooks') || 'Filtrer les carnets…'}
closeLabel={t('general.close') || 'Fermer'}
style={panelStyle}
/>
</>
)}
</AnimatePresence>,
document.body,
)}
</>
)
}
type MoveToNotebookPickerPortalProps = {
open: boolean
onOpenChange: (open: boolean) => void
anchorRef: React.RefObject<HTMLElement | null>
notebooks: NotebookPickerItem[]
currentNotebookId?: string | null
onSelect: (notebookId: string | null) => void
align?: 'start' | 'end'
preferDropUp?: boolean
}
export function MoveToNotebookPickerPortal({
open,
onOpenChange,
anchorRef,
notebooks,
currentNotebookId = null,
onSelect,
align = 'end',
preferDropUp = false,
}: MoveToNotebookPickerPortalProps) {
const { t } = useLanguage()
const [panelStyle, setPanelStyle] = useState<React.CSSProperties | undefined>()
useEffect(() => {
if (!open || !anchorRef.current) {
setPanelStyle(undefined)
return
}
const update = () => {
if (!anchorRef.current) return
setPanelStyle(
getNotebookPickerPosition(anchorRef.current.getBoundingClientRect(), { align, preferDropUp }),
)
}
update()
window.addEventListener('scroll', update, true)
window.addEventListener('resize', update)
return () => {
window.removeEventListener('scroll', update, true)
window.removeEventListener('resize', update)
}
}, [open, anchorRef, align, preferDropUp])
const handleSelect = (notebookId: string | null) => {
onSelect(notebookId)
onOpenChange(false)
}
if (typeof window === 'undefined') return null
return createPortal(
<AnimatePresence>
{open && panelStyle && (
<>
<div className="fixed inset-0 z-[9998]" onClick={() => onOpenChange(false)} aria-hidden />
<NotebookHierarchyPanel
key="move-notebook-picker-portal"
notebooks={notebooks}
selectedId={currentNotebookId}
onSelect={(id) => handleSelect(id)}
onClose={() => onOpenChange(false)}
showGeneralNotes
generalNotesLabel={t('notebookSuggestion.generalNotes') || 'Notes générales'}
onSelectGeneralNotes={() => handleSelect(null)}
searchPlaceholder={t('notebookSuggestion.filterNotebooks') || 'Filtrer les carnets…'}
closeLabel={t('general.close') || 'Fermer'}
style={panelStyle}
/>
</>
)}
</AnimatePresence>,
document.body,
)
}

View File

@@ -0,0 +1,244 @@
'use client'
import { useEffect, useRef } from 'react'
import * as d3 from 'd3'
// Force to group nodes by cluster
function forceCluster() {
let nodes: any[] = []
let clusters: Map<string | number, { x: number; y: number }> = new Map()
function force(alpha: number) {
// Calculate cluster centers
clusters.clear()
for (const node of nodes) {
const clusterId = node.clusterId
if (!clusters.has(clusterId)) {
clusters.set(clusterId, { x: 0, y: 0, count: 0 })
}
const center = clusters.get(clusterId)!
center.x += node.x
center.y += node.y
center.count = (center.count || 0) + 1
}
// Average positions
for (const [clusterId, center] of clusters.entries()) {
center.x /= center.count || 1
center.y /= center.count || 1
}
// Move nodes toward their cluster center
for (const node of nodes) {
const clusterCenter = clusters.get(node.clusterId)
if (clusterCenter && clusterCenter.count > 1) {
const targetX = clusterCenter.x
const targetY = clusterCenter.y
node.vx += (targetX - node.x) * alpha * 0.3
node.vy += (targetY - node.y) * alpha * 0.3
}
}
}
force.initialize = function(newNodes: any[]) {
nodes = newNodes
return force
}
return force
}
interface Note {
id: string
title: string | null
clusterId?: string | number
}
interface NoteCluster {
id: string | number
name: string
noteIds: string[]
color: string
}
interface BridgeNote {
noteId: string
bridgeScore: number
clustersConnected?: (string | number)[]
connectedClusterIds?: (string | number)[]
}
interface NetworkGraphProps {
notes: Note[]
clusters: NoteCluster[]
bridgeNotes: BridgeNote[]
onNoteSelect: (id: string) => void
}
export function NetworkGraph({
notes,
clusters,
bridgeNotes,
onNoteSelect
}: NetworkGraphProps) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!svgRef.current || !containerRef.current) return
const width = containerRef.current.clientWidth
const height = containerRef.current.clientHeight
const svg = d3.select(svgRef.current)
svg.selectAll('*').remove()
const g = svg.append('g')
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform)
})
svg.call(zoom as any)
// Filter notes with cluster assignments (properly check for undefined/null)
const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null)
interface D3Node extends d3.SimulationNodeDatum {
id: string
title: string | null
clusterId: string | number
color: string
isBridge: boolean
radius: number
}
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
source: string
target: string
strength: number
}
const bridgeSet = new Set(bridgeNotes.map(b => b.noteId))
const nodes: D3Node[] = visibleNotes.map(n => {
const cluster = clusters.find(c => c.id === String(n.clusterId))
const isBridge = bridgeSet.has(n.id)
return {
id: n.id,
title: n.title,
clusterId: n.clusterId!,
color: cluster?.color || '#cbd5e1',
isBridge,
radius: isBridge ? 12 : 8
}
})
const links: D3Link[] = []
// Connect notes within the same cluster
for (let i = 0; i < visibleNotes.length; i++) {
for (let j = i + 1; j < visibleNotes.length; j++) {
const ni = visibleNotes[i]
const nj = visibleNotes[j]
if (ni.clusterId === nj.clusterId) {
links.push({ source: ni.id, target: nj.id, strength: 0.5 })
}
}
}
const simulation = d3.forceSimulation<D3Node>(nodes)
.force('link', d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(50))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide<D3Node>().radius(d => d.radius + 15))
.force('cluster', forceCluster())
// Links
const link = g.append('g')
.selectAll('line')
.data(links)
.enter()
.append('line')
.attr('stroke', '#e2e8f0')
.attr('stroke-opacity', 0.6)
.attr('stroke-width', 1)
// Nodes
const node = g.append('g')
.selectAll('.node')
.data(nodes)
.enter()
.append('g')
.attr('class', 'node cursor-pointer')
.on('click', (event, d) => onNoteSelect(d.id))
.call(d3.drag<SVGGElement, D3Node>()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended) as any)
node.append('circle')
.attr('r', d => d.radius)
.attr('fill', d => d.color)
.attr('stroke', d => d.isBridge ? '#D4AF37' : '#fff')
.attr('stroke-width', d => d.isBridge ? 3 : 2)
.style('filter', d => d.isBridge ? 'drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))' : 'none')
node.append('text')
.attr('dy', d => d.radius + 14)
.attr('text-anchor', 'middle')
.attr('class', 'text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none')
.text(d => {
const title = d.title || 'Untitled'
return title.length > 20 ? title.substring(0, 20) + '...' : title
})
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as any).x)
.attr('y1', d => (d.source as any).y)
.attr('x2', d => (d.target as any).x)
.attr('y2', d => (d.target as any).y)
node
.attr('transform', d => `translate(${d.x},${d.y})`)
})
function dragstarted(event: any, d: D3Node) {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
}
function dragged(event: any, d: D3Node) {
d.fx = event.x
d.fy = event.y
}
function dragended(event: any, d: D3Node) {
if (!event.active) simulation.alphaTarget(0)
d.fx = null
d.fy = null
}
return () => {
simulation.stop()
}
}, [notes, clusters, bridgeNotes, onNoteSelect])
return (
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-3 max-w-[300px]">
{clusters.map(c => (
<div key={c.id} className="flex items-center gap-1.5 px-2 py-1 bg-white/80 dark:bg-white/5 backdrop-blur-sm border border-border rounded-full shadow-sm">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
<span className="text-[9px] font-bold uppercase tracking-widest text-concrete whitespace-nowrap">{c.name}</span>
</div>
))}
</div>
<svg ref={svgRef} className="w-full h-full" />
</div>
)
}

View File

@@ -64,7 +64,7 @@ const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({
const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false })
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
import { useNotebooks } from '@/context/notebooks-context'
import { useRefresh } from '@/lib/use-refresh'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
@@ -178,7 +178,6 @@ export const NoteCard = memo(function NoteCard({
}: NoteCardProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { refreshNotes } = useRefresh()
const { data: session } = useSession()
const { t, language } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic, refreshLabels } = useNotebooks()
@@ -203,9 +202,9 @@ export const NoteCard = memo(function NoteCard({
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {
startTransition(async () => {
try {
await updateNote(noteId, { reminder })
await updateNote(noteId, { reminder }, { skipRevalidation: true })
setReminderDate(reminder)
refreshNotes(note?.notebookId)
emitNoteChange({ type: 'updated', note: { ...note, reminder: reminder?.toISOString() ?? null } })
if (reminder) {
toast.success(t('notes.reminderSet', { datetime: reminder.toLocaleString() }))
} else {
@@ -304,9 +303,9 @@ export const NoteCard = memo(function NoteCard({
setIsDeleting(true)
setIsHidden(true) // masquage immédiat
try {
await deleteNote(note.id)
await deleteNote(note.id, { skipRevalidation: true })
await refreshLabels()
refreshNotes(note?.notebookId) // met à jour la liste et le compteur du carnet
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
} catch (error) {
console.error('Failed to delete note:', error)
setIsHidden(false)
@@ -319,7 +318,7 @@ export const NoteCard = memo(function NoteCard({
setIsHidden(true)
try {
await restoreNote(note.id)
refreshNotes(note?.notebookId)
emitNoteChange({ type: 'updated', note: { ...note, trashedAt: null } })
toast.success(t('trash.noteRestored'))
} catch (error) {
console.error('Failed to restore note:', error)
@@ -333,7 +332,7 @@ export const NoteCard = memo(function NoteCard({
setIsHidden(true)
try {
await permanentDeleteNote(note.id)
refreshNotes(note?.notebookId)
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('trash.notePermanentlyDeleted'))
} catch (error) {
console.error('Failed to permanently delete note:', error)
@@ -345,8 +344,8 @@ export const NoteCard = memo(function NoteCard({
const handleTogglePin = async () => {
startTransition(async () => {
addOptimisticNote({ isPinned: !note.isPinned })
await togglePin(note.id, !note.isPinned)
refreshNotes(note?.notebookId)
await togglePin(note.id, !note.isPinned, { skipRevalidation: true })
emitNoteChange({ type: 'updated', note: { ...note, isPinned: !note.isPinned } })
if (!note.isPinned) {
toast.success(t('notes.pinned'))
@@ -359,8 +358,8 @@ export const NoteCard = memo(function NoteCard({
const handleToggleArchive = async () => {
startTransition(async () => {
addOptimisticNote({ isArchived: !note.isArchived })
await toggleArchive(note.id, !note.isArchived)
refreshNotes(note?.notebookId)
await toggleArchive(note.id, !note.isArchived, { skipRevalidation: true })
emitNoteChange({ type: 'updated', note: { ...note, isArchived: !note.isArchived } })
})
}
@@ -368,7 +367,7 @@ export const NoteCard = memo(function NoteCard({
setLocalColor(color) // instant visual update, survives transition
startTransition(async () => {
addOptimisticNote({ color })
await updateNote(note.id, { color }, { skipRevalidation: false })
await updateNote(note.id, { color }, { skipRevalidation: true })
})
}
@@ -403,7 +402,7 @@ export const NoteCard = memo(function NoteCard({
setLocalCheckItems(updatedItems) // instant visual update, survives transition
startTransition(async () => {
addOptimisticNote({ checkItems: updatedItems })
await updateNote(note.id, { checkItems: updatedItems })
await updateNote(note.id, { checkItems: updatedItems }, { skipRevalidation: true })
})
}
}
@@ -821,12 +820,6 @@ export const NoteCard = memo(function NoteCard({
isOpen={!!comparisonNotes}
onClose={() => setComparisonNotes(null)}
notes={comparisonNotesData}
onOpenNote={(noteId) => {
const foundNote = comparisonNotesData.find(n => n.id === noteId)
if (foundNote) {
onEdit?.(foundNote, false)
}
}}
/>
</div>
)}
@@ -839,7 +832,7 @@ export const NoteCard = memo(function NoteCard({
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={async ({ title, content }, options) => {
await createNote({
const created = await createNote({
title,
content,
labels: options.keepAllTags
@@ -854,12 +847,12 @@ export const NoteCard = memo(function NoteCard({
})
if (options.archiveOriginals) {
for (const n of fusionNotes) {
if (n.id) await updateNote(n.id, { isArchived: true })
if (n.id) await updateNote(n.id, { isArchived: true }, { skipRevalidation: true })
}
}
toast.success(t('toast.notesFusionSuccess'))
setFusionNotes([])
refreshNotes(note?.notebookId)
if (created) emitNoteChange({ type: 'created', note: created })
}}
/>
</div>

View File

@@ -7,16 +7,17 @@ import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { faIR } from 'date-fns/locale/fa-IR'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon } from 'lucide-react'
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon, Network, Copy } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { LabelBadge } from './label-badge'
import { NoteHistoryModal } from './note-history-modal'
import { NoteNetworkTab } from './note-network-tab'
import { enableNoteHistory, commitNoteHistory, getNoteHistory, deleteNoteHistoryEntry, restoreNoteVersion } from '@/app/actions/notes'
import { useEffect } from 'react'
type Tab = 'info' | 'versions'
type Tab = 'info' | 'versions' | 'network'
interface NoteDocumentInfoPanelProps {
note: Note
@@ -39,6 +40,23 @@ function charCount(text: string) {
return text.replace(/<[^>]+>/g, '').length
}
function lineCount(text: string) {
const plain = text.replace(/<[^>]+>/g, '\n')
return plain.trim() ? plain.split('\n').length : 0
}
function equationCount(text: string) {
const block = (text.match(/\$\$[\s\S]+?\$\$/g) || []).length
const inline = (text.match(/\$[^$\n]+?\$/g) || []).length
return block + inline
}
function imageCount(text: string) {
const md = (text.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length
const html = (text.match(/<img\s/gi) || []).length
return md + html
}
export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }: NoteDocumentInfoPanelProps) {
const { t, language } = useLanguage()
const { notebooks } = useNotebooks()
@@ -51,6 +69,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
const [isDeleting, setIsDeleting] = useState<string | null>(null)
const [isRestoring, setIsRestoring] = useState<string | null>(null)
const [copiedId, setCopiedId] = useState(false)
const locale = getLocale(language)
const displayNoteType = useMemo(() => {
@@ -114,6 +133,9 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
const words = useMemo(() => wordCount(content), [content])
const chars = useMemo(() => charCount(content), [content])
const lines = useMemo(() => lineCount(content), [content])
const equations = useMemo(() => equationCount(content), [content])
const images = useMemo(() => imageCount(content), [content])
const createdAt = note.createdAt ? new Date(note.createdAt as unknown as string) : null
const updatedAt = note.contentUpdatedAt ? new Date(note.contentUpdatedAt as unknown as string) : null
@@ -125,7 +147,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
{/* Header tabs */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/40">
<div className="flex gap-1">
{(['info', 'versions'] as Tab[]).map(tab => (
{(['info', 'versions', 'network'] as Tab[]).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@@ -138,7 +160,12 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
>
{tab === 'info' && <Info className="h-3 w-3" />}
{tab === 'versions' && <Clock className="h-3 w-3" />}
{tab === 'info' ? t('documentInfo.tabInfo') : t('documentInfo.tabVersions')}
{tab === 'network' && <Network className="h-3 w-3" />}
{tab === 'info'
? t('documentInfo.tabInfo')
: tab === 'versions'
? t('documentInfo.tabVersions')
: t('documentInfo.tabNetwork')}
</button>
))}
</div>
@@ -168,6 +195,19 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
</div>
</div>
<div className="grid grid-cols-3 border-b border-border/30 divide-x divide-border/30">
{[
{ value: lines, label: t('documentInfo.linesLabel') },
{ value: equations, label: t('documentInfo.equationsLabel') },
{ value: images, label: t('documentInfo.imagesLabel') },
].map(({ value, label }) => (
<div key={label} className="flex flex-col items-center gap-0.5 py-3">
<span className="text-lg font-bold font-memento-serif tabular-nums">{value}</span>
<span className="text-[8px] uppercase tracking-widest text-muted-foreground font-semibold">{label}</span>
</div>
))}
</div>
<div className="divide-y divide-border/30">
{notebook && (
<div className="flex items-start gap-3 px-4 py-3">
@@ -229,9 +269,24 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
<div className="flex items-start gap-3 px-4 py-3">
<Hash className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<div className="min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.idLabel')}</p>
<p className="text-[11px] text-muted-foreground font-mono truncate">{note.id}</p>
<div className="flex items-center gap-1.5 min-w-0">
<p className="text-[11px] text-muted-foreground font-mono truncate">{note.id}</p>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(note.id).then(() => {
setCopiedId(true)
setTimeout(() => setCopiedId(false), 2000)
})
}}
className="p-1 rounded hover:bg-muted text-muted-foreground shrink-0"
title={t('documentInfo.copyId')}
>
{copiedId ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
</button>
</div>
</div>
</div>
</div>
@@ -382,6 +437,11 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
)}
</div>
)}
{/* ── NETWORK TAB ── */}
{activeTab === 'network' && (
<NoteNetworkTab noteId={note.id} noteTitle={note.title || ''} />
)}
</div>
</div>

View File

@@ -14,7 +14,7 @@ import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
export function NoteContentArea() {
const { state, actions, readOnly, fullPage, textareaRef } = useNoteEditorContext()
const { state, actions, readOnly, fullPage, textareaRef, note, richTextEditorRef } = useNoteEditorContext()
const { t } = useLanguage()
const uploadImageFile = async (file: File) => {
@@ -101,10 +101,12 @@ export function NoteContentArea() {
return (
<div className="fullpage-editor">
<RichTextEditor
ref={richTextEditorRef}
content={state.content}
onChange={(v: string) => actions.setContent(v)}
className="min-h-[280px]"
onImageUpload={uploadImageFile}
noteId={note.id}
/>
</div>
)
@@ -113,10 +115,12 @@ export function NoteContentArea() {
return (
<div className="space-y-2">
<RichTextEditor
ref={richTextEditorRef}
content={state.content}
onChange={actions.setContent}
className="min-h-[200px]"
onImageUpload={uploadImageFile}
noteId={note.id}
/>
<GhostTags
suggestions={state.filteredSuggestions}

View File

@@ -6,15 +6,17 @@ import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { useNotebooks } from '@/context/notebooks-context'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { emitNoteChange, NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
import { extractImagesFromHTML } from '@/lib/utils'
import { queryKeys } from '@/lib/query-keys'
import type { RichTextEditorHandle } from '@/components/rich-text-editor'
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
import type { TagSuggestion } from '@/lib/ai/types'
import type { NoteEditorState, NoteEditorActions, NoteEditorContextValue } from './types'
@@ -32,9 +34,9 @@ interface NoteEditorProviderProps {
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, onNoteSaved, children }: NoteEditorProviderProps) {
const { data: session } = useSession()
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const queryClient = useQueryClient()
const { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId, notebooks } = useNotebooks()
const { triggerRefresh } = useNoteRefresh()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
@@ -64,10 +66,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const richTextEditorRef = useRef<RichTextEditorHandle>(null)
const prevNoteRef = useRef(note)
useEffect(() => {
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
const prev = prevNoteRef.current
if (note.id !== prev.id) {
setTitle(note.title || '')
setContent(note.content)
setCheckItems(note.checkItems || [])
@@ -79,7 +84,37 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setIsMarkdown(note.type === 'markdown')
setShowMarkdownPreview(note.type === 'markdown')
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
} else {
if (note.title !== prev.title) setTitle(note.title || '')
// Ne pas réinitialiser le contenu quand seuls images/links changent (post-save)
if (note.content !== prev.content) setContent(note.content)
if (JSON.stringify(note.checkItems || []) !== JSON.stringify(prev.checkItems || [])) {
setCheckItems(note.checkItems || [])
}
if (JSON.stringify(note.labels || []) !== JSON.stringify(prev.labels || [])) {
setLabels(note.labels || [])
}
if (JSON.stringify(note.images || []) !== JSON.stringify(prev.images || [])) {
setImages(note.images || [])
}
if (JSON.stringify(note.links || []) !== JSON.stringify(prev.links || [])) {
setLinks(note.links || [])
}
if (note.color !== prev.color) setColor(note.color)
if ((note.size || 'small') !== (prev.size || 'small')) setSize(note.size || 'small')
const noteIsMarkdown = note.type === 'markdown'
const prevIsMarkdown = prev.type === 'markdown'
if (noteIsMarkdown !== prevIsMarkdown) {
setIsMarkdown(noteIsMarkdown)
setShowMarkdownPreview(noteIsMarkdown)
}
const prevReminder = prev.reminder ? new Date(prev.reminder as unknown as string).getTime() : null
const nextReminder = note.reminder ? new Date(note.reminder as unknown as string).getTime() : null
if (prevReminder !== nextReminder) {
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
}
}
prevNoteRef.current = note
}, [note])
@@ -178,6 +213,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
try {
const url = await uploadImageFile(file)
setImages(prev => prev.includes(url) ? prev : [...prev, url])
setIsDirty(true)
} catch (error) {
console.error('Upload error:', error)
toast.error(t('notes.uploadFailed', { filename: file.name }))
@@ -198,6 +234,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
try {
const url = await uploadImageFile(file)
setImages(prev => prev.includes(url) ? prev : [...prev, url])
setIsDirty(true)
} catch {
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
}
@@ -233,6 +270,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
if (removedUrl) {
setRemovedImageUrls(prev => [...prev, removedUrl])
}
setIsDirty(true)
}
const handleAddLink = async () => {
@@ -262,9 +300,22 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
const allImages = useMemo(() => {
const extracted = !isMarkdown ? extractImagesFromHTML(content) : [];
return Array.from(new Set([...images, ...extracted]));
}, [images, content, isMarkdown]);
const extracted = !isMarkdown ? extractImagesFromHTML(content) : []
return Array.from(new Set([...images, ...extracted]))
}, [images, content, isMarkdown])
const resolveContentForSave = useCallback((): string => {
if (!isMarkdown) {
const editor = richTextEditorRef.current?.getEditor()
if (editor) return editor.getHTML()
}
return content
}, [content, isMarkdown])
const resolveImagesForSave = useCallback((contentToSave: string): string[] => {
const extracted = !isMarkdown ? extractImagesFromHTML(contentToSave) : []
return Array.from(new Set([...images, ...extracted]))
}, [images, isMarkdown])
const handleGenerateTitles = async () => {
const fullContentForAI = [
@@ -281,6 +332,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsGeneratingTitles(true)
try {
const response = await fetch('/api/ai/title-suggestions', {
@@ -347,6 +401,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsReformulating(true)
try {
const response = await fetch('/api/ai/reformulate', {
@@ -385,6 +442,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
@@ -411,6 +471,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
@@ -437,6 +500,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
@@ -468,6 +534,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/transform-markdown', {
@@ -506,7 +575,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
setCurrentReminder(date)
try {
await updateNote(note.id, { reminder: date })
await updateNote(note.id, { reminder: date }, { skipRevalidation: true })
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
} catch {
toast.error(t('notebook.savingReminder'))
@@ -516,7 +585,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const handleRemoveReminder = async () => {
setCurrentReminder(null)
try {
await updateNote(note.id, { reminder: null })
await updateNote(note.id, { reminder: null }, { skipRevalidation: true })
toast.success(t('notes.reminderRemoved'))
} catch {
toast.error(t('notebook.removingReminder'))
@@ -526,19 +595,23 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const handleSave = async () => {
setIsSaving(true)
try {
const contentToSave = resolveContentForSave()
const imagesToSave = resolveImagesForSave(contentToSave)
const result = await updateNote(note.id, {
title: title.trim() || null,
content,
content: contentToSave,
checkItems: null,
labels,
images,
images: imagesToSave,
links,
color,
reminder: currentReminder,
isMarkdown,
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
size,
})
}, { skipRevalidation: true })
if (contentToSave !== content) setContent(contentToSave)
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
prevNoteRef.current = { ...prevNoteRef.current, ...result }
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
@@ -547,7 +620,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
onNoteSaved?.(result)
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
emitNoteChange({ type: 'updated', note: result })
toast.success(t('notes.saved') || 'Note sauvegardée !')
} catch (error) {
console.error('[SAVE] updateNote failed:', error)
@@ -630,7 +703,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
})
toast.success(t('notes.copySuccess'))
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
emitNoteChange({ type: 'created', note: newNote })
} catch (error) {
console.error('Failed to copy note:', error)
toast.error(t('notes.copyFailed'))
@@ -640,12 +713,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const handleSaveInPlace = async () => {
setIsSaving(true)
try {
const contentToSave = resolveContentForSave()
const imagesToSave = resolveImagesForSave(contentToSave)
const updatePayload = {
title: title.trim() || null,
content,
content: contentToSave,
checkItems: null,
labels,
images,
images: imagesToSave,
links,
color,
reminder: currentReminder,
@@ -653,7 +728,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
size,
}
const result = await updateNote(note.id, updatePayload)
const result = await updateNote(note.id, updatePayload, { skipRevalidation: true })
if (contentToSave !== content) setContent(contentToSave)
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
prevNoteRef.current = { ...prevNoteRef.current, ...result }
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
@@ -662,7 +739,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
onNoteSaved?.(result)
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
emitNoteChange({ type: 'updated', note: result })
setIsDirty(false)
toast.success(t('notes.saved') || 'Saved')
} catch (error) {
@@ -673,17 +750,36 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
}
const handleSaveInPlaceRef = useRef(handleSaveInPlace)
handleSaveInPlaceRef.current = handleSaveInPlace
const handleSaveRef = useRef(handleSave)
handleSaveRef.current = handleSave
useEffect(() => {
if (!fullPage) return
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSaveInPlace()
void handleSaveInPlaceRef.current()
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [fullPage, isSaving])
}, [fullPage])
useEffect(() => {
const onRequestSave = (event: Event) => {
const detail = (event as CustomEvent<{ noteId?: string }>).detail
if (detail?.noteId !== note.id || readOnly) return
if (fullPage) {
void handleSaveInPlaceRef.current()
} else {
void handleSaveRef.current()
}
}
window.addEventListener(NOTE_REQUEST_SAVE_EVENT, onRequestSave)
return () => window.removeEventListener(NOTE_REQUEST_SAVE_EVENT, onRequestSave)
}, [note.id, fullPage, readOnly])
const state: NoteEditorState = useMemo(() => ({
title,
@@ -792,6 +888,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
globalLabels,
fileInputRef,
textareaRef,
richTextEditorRef,
}), [note, readOnly, fullPage, state, actions, notebooks, globalLabels])
return (

View File

@@ -9,7 +9,7 @@ import { ComparisonModal } from '@/components/comparison-modal'
import { FusionModal } from '@/components/fusion-modal'
import { ReminderDialog } from '@/components/reminder-dialog'
import { ContextualAIChat } from '@/components/contextual-ai-chat'
import { EditorConnectionsSection } from '@/components/editor-connections-section'
import { MemoryEchoSection } from '@/components/memory-echo-section'
import {
Dialog,
DialogContent,
@@ -25,6 +25,7 @@ import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { Note } from '@/lib/types'
import { useState } from 'react'
interface NoteEditorDialogProps {
onClose: () => void
@@ -33,6 +34,7 @@ interface NoteEditorDialogProps {
export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
const { t } = useLanguage()
const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>()
const handleSaveAndClose = async () => {
await actions.handleSave()
@@ -112,13 +114,10 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
{/* Memory Echo Connections Section */}
{!readOnly && (
<EditorConnectionsSection
<MemoryEchoSection
noteId={note.id}
onOpenNote={(noteId: string) => {
onClose()
window.location.href = `/home?note=${noteId}`
}}
onCompareNotes={(noteIds: string[]) => {
onCompareNotes={(noteIds: string[], meta?: { similarity?: number }) => {
setComparisonSimilarity(meta?.similarity)
Promise.all(noteIds.map(async (id: string) => {
try {
const res = await fetch(`/api/notes/${id}`)
@@ -292,11 +291,24 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
{state.comparisonNotes && state.comparisonNotes.length > 0 && (
<ComparisonModal
isOpen={!!state.comparisonNotes}
onClose={() => actions.setComparisonNotes([])}
onClose={() => {
setComparisonSimilarity(undefined)
actions.setComparisonNotes([])
}}
notes={state.comparisonNotes}
onOpenNote={(noteId: string) => {
onClose()
window.location.href = `/home?note=${noteId}`
similarity={comparisonSimilarity}
onMergeNotes={async (noteIds: string[]) => {
const fetchedNotes = await Promise.all(noteIds.map(async (id: string) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) return null
const data = await res.json()
return data.success && data.data ? data.data : null
} catch {
return null
}
}))
actions.setFusionNotes(fetchedNotes.filter((n): n is Partial<Note> => n !== null))
}}
/>
)}

View File

@@ -21,20 +21,40 @@ import { LabelBadge } from '@/components/label-badge'
import { NoteAttachments } from '@/components/note-attachments'
import { DocumentQAOverlay } from '@/components/document-qa-overlay'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useState } from 'react'
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
import { MemoryEchoSection } from '@/components/memory-echo-section'
import { useRouter } from 'next/navigation'
interface NoteEditorFullPageProps {
onClose: () => void
}
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const router = useRouter()
const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent()
const dateLocale = language === 'fr' ? fr : enUS
const { state, actions, note, readOnly, notebooks, fileInputRef, globalLabels } = useNoteEditorContext()
const [docQAAttachment, setDocQAAttachment] = useState<{ id: string; fileName: string } | null>(null)
const [attachmentsCount, setAttachmentsCount] = useState(0)
const [uploadTrigger, setUploadTrigger] = useState(0)
const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>()
const fetchNotesByIds = async (noteIds: string[]) => {
const notes = await Promise.all(noteIds.map(async (id) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) return null
const data = await res.json()
return data.success && data.data ? data.data as Partial<Note> : null
} catch {
return null
}
}))
return notes.filter((n): n is Partial<Note> => n !== null)
}
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
@@ -109,6 +129,19 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
<div className="max-w-3xl mx-auto w-full space-y-8 pb-32">
<NoteContentArea />
{!readOnly && (
<MemoryEchoSection
noteId={note.id}
onCompareNotes={async (noteIds, meta) => {
setComparisonSimilarity(meta?.similarity)
actions.setComparisonNotes(await fetchNotesByIds(noteIds))
}}
onMergeNotes={async (noteIds) => {
actions.setFusionNotes(await fetchNotesByIds(noteIds))
}}
/>
)}
<NoteAttachments
noteId={note.id}
onOpenDocQA={(att) => setDocQAAttachment(att)}
@@ -149,6 +182,8 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
return
}
const consented = await requestAiConsent()
if (!consented) return
actions.setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', {
@@ -209,6 +244,55 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
onSave={actions.handleReminderSave}
onRemove={actions.handleRemoveReminder}
/>
{state.comparisonNotes.length > 0 && (
<ComparisonModal
isOpen
onClose={() => {
setComparisonSimilarity(undefined)
actions.setComparisonNotes([])
}}
notes={state.comparisonNotes}
similarity={comparisonSimilarity}
onMergeNotes={async (noteIds) => {
actions.setFusionNotes(await fetchNotesByIds(noteIds))
}}
/>
)}
{state.fusionNotes.length > 0 && (
<FusionModal
isOpen
onClose={() => actions.setFusionNotes([])}
notes={state.fusionNotes}
onConfirmFusion={async ({ title, content }, options) => {
await actions.handleSaveInPlace()
const { createNote, updateNote } = await import('@/app/actions/notes')
await createNote({
title,
content,
labels: options.keepAllTags
? [...new Set(state.fusionNotes.flatMap(n => n.labels || []))]
: state.fusionNotes[0].labels || [],
color: state.fusionNotes[0].color,
type: 'text',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
notebookId: state.fusionNotes[0].notebookId ?? undefined,
})
if (options.archiveOriginals) {
for (const fusionNote of state.fusionNotes) {
if (fusionNote.id) {
await updateNote(fusionNote.id, { isArchived: true })
}
}
}
toast.success(t('toast.notesFusionSuccess'))
actions.setFusionNotes([])
}}
/>
)}
</>
)
}

View File

@@ -22,7 +22,7 @@ import {
} from 'lucide-react'
import { NoteShareDialog } from './note-share-dialog'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { useRefresh } from '@/lib/use-refresh'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useLanguage } from '@/lib/i18n'
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
import { cn } from '@/lib/utils'
@@ -39,7 +39,6 @@ interface NoteEditorToolbarProps {
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
const { t } = useLanguage()
const { refreshNotes } = useRefresh()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
@@ -225,8 +224,8 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<DropdownMenuItem
onClick={async () => {
try {
await deleteNote(note.id)
refreshNotes(note.notebookId)
await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('notes.noteDeletedToast'))
onClose()
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
@@ -366,9 +365,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={async () => {
try {
await leaveSharedNote(note.id)
await leaveSharedNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('notes.leftShare'))
refreshNotes(note.notebookId)
onClose()
} catch {
toast.error(t('general.error'))

View File

@@ -4,12 +4,14 @@ import { useNoteEditorContext } from './note-editor-context'
import { TitleSuggestions } from '@/components/title-suggestions'
import { Loader2, Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
export function NoteTitleBlock() {
const { state, actions, readOnly, fullPage } = useNoteEditorContext()
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
if (fullPage) {
// Adaptive font size: short = big editorial, long = smaller but still premium
@@ -64,6 +66,8 @@ export function NoteTitleBlock() {
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
return
}
const consented = await requestAiConsent()
if (!consented) return
actions.setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', {

View File

@@ -1,4 +1,6 @@
import type { RefObject } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from '@/lib/types'
import type { RichTextEditorHandle } from '@/components/rich-text-editor'
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
import type { TagSuggestion } from '@/lib/ai/types'
@@ -129,4 +131,5 @@ export interface NoteEditorContextValue {
globalLabels: Array<{ name: string }>
fileInputRef: React.RefObject<HTMLInputElement | null>
textareaRef: React.RefObject<HTMLTextAreaElement | null>
richTextEditorRef: RefObject<RichTextEditorHandle | null>
}

View File

@@ -0,0 +1,145 @@
'use client'
import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { FileText, Loader2, Search, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
export interface NoteLinkOption {
id: string
title: string | null
notebookId: string | null
}
interface NoteLinkPickerProps {
isOpen: boolean
query: string
currentNoteId?: string
onClose: () => void
onSelect: (note: NoteLinkOption) => void
}
export function NoteLinkPicker({
isOpen,
query,
currentNoteId,
onClose,
onSelect,
}: NoteLinkPickerProps) {
const { t } = useLanguage()
const [searchQuery, setSearchQuery] = useState(query)
const [results, setResults] = useState<NoteLinkOption[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (isOpen) setSearchQuery(query)
}, [isOpen, query])
useEffect(() => {
if (!isOpen) return
setLoading(true)
const timer = setTimeout(() => {
const params = new URLSearchParams({ limit: '15' })
if (searchQuery.trim()) params.set('search', searchQuery.trim())
fetch(`/api/notes?${params}`)
.then(r => r.json())
.then(data => {
const notes: NoteLinkOption[] = (data.data || [])
.filter((n: { id: string }) => n.id !== currentNoteId)
.map((n: { id: string; title: string | null; notebookId: string | null }) => ({
id: n.id,
title: n.title,
notebookId: n.notebookId,
}))
setResults(notes)
})
.catch(() => setResults([]))
.finally(() => setLoading(false))
}, 250)
return () => clearTimeout(timer)
}, [isOpen, searchQuery, currentNoteId])
if (!isOpen) return null
return (
<AnimatePresence>
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-black/30 dark:bg-black/50 backdrop-blur-sm">
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 12 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 12 }}
transition={{ duration: 0.15 }}
className="w-[440px] max-w-full bg-background rounded-2xl border border-border shadow-2xl flex flex-col max-h-[70vh] overflow-hidden"
onClick={e => e.stopPropagation()}
>
<div className="p-4 border-b border-border/60 flex items-start justify-between gap-3">
<div className="flex items-start gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-lg bg-[#A47148]/10 flex items-center justify-center text-[#A47148] shrink-0">
<FileText size={16} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold">{t('richTextEditor.noteLinkPickerTitle')}</h3>
<p className="text-[11px] text-muted-foreground leading-snug mt-0.5">
{t('richTextEditor.noteLinkPickerHint')}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded-full text-muted-foreground hover:bg-muted transition-colors shrink-0"
aria-label={t('common.close')}
>
<X size={16} />
</button>
</div>
<div className="px-4 py-3 border-b border-border/40">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
autoFocus
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('richTextEditor.noteLinkPickerSearch')}
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border bg-muted/30 outline-none focus:ring-2 focus:ring-[#A47148]/30"
onKeyDown={e => {
if (e.key === 'Escape') onClose()
}}
/>
</div>
</div>
<div className="overflow-y-auto flex-1 p-2">
{loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground text-sm">
<Loader2 size={16} className="animate-spin" />
{t('common.loading')}
</div>
) : results.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8 px-4 leading-relaxed">
{t('richTextEditor.noteLinkPickerEmpty')}
</p>
) : (
<ul className="space-y-1">
{results.map(note => (
<li key={note.id}>
<button
type="button"
onClick={() => onSelect(note)}
className="w-full text-left px-3 py-2.5 rounded-xl hover:bg-muted transition-colors"
>
<span className="text-sm font-medium line-clamp-2">
{note.title?.trim() || t('documentInfo.network.untitled')}
</span>
</button>
</li>
))}
</ul>
)}
</div>
</motion.div>
</div>
</AnimatePresence>
)
}

View File

@@ -0,0 +1,757 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, HelpCircle, Loader2, Network, Sparkles, Link2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { NOTE_CHANGE_EVENT } from '@/lib/note-change-sync'
import { openNoteInNewTab } from '@/lib/navigation/open-note'
import {
SEMANTIC_SIMILARITY_FLOOR,
semanticOrbitRadius,
semanticProximityPercent,
semanticProximityRatio,
} from '@/lib/ai/semantic-proximity'
interface NetworkNote {
id: string
title: string | null
notebookId: string | null
}
interface NetworkLink {
id: string
note: NetworkNote
contextSnippet: string | null
}
interface UnlinkedMention {
title: string
snippet: string
}
interface SemanticConnection {
noteId: string
title: string | null
notebookId: string | null
similarity: number
excerpt: string
}
interface EmbedHost {
note: NetworkNote
blockIds: string[]
}
type OrbitRelationship = 'backlink' | 'outbound' | 'mention' | 'semantic' | 'embed'
interface OrbitNode {
key: string
id?: string
title: string
color: string
notebookName: string
relationship: OrbitRelationship
snippet?: string | null
similarity?: number
}
interface NoteNetworkTabProps {
noteId: string
noteTitle: string
}
const MAX_GRAPH_NODES = 10
const MAX_LIST_ITEMS = 5
const DEFAULT_COLOR = '#71717A'
const SEMANTIC_COLOR = '#7C3AED'
const CX = 160
const CY = 110
function orbitRadius(relationship: OrbitRelationship): number {
switch (relationship) {
case 'outbound': return 52
case 'backlink': return 68
case 'embed': return 78
case 'semantic': return 88
default: return 94
}
}
function cleanSnippet(text: string, max = 140): string {
const plain = text
.replace(/<[^>]+>/g, ' ')
.replace(/\|+/g, ' ')
.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1')
.replace(/\s+/g, ' ')
.trim()
if (!plain) return ''
if (plain.length <= max) return plain
return `${plain.slice(0, max).trim()}`
}
function SectionHelp({ label, help }: { label: string; help: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="p-0.5 rounded-md text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors"
aria-label={label}
>
<HelpCircle className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[260px] text-left text-xs leading-relaxed">
<p className="font-medium mb-1">{label}</p>
<p className="text-background/85">{help}</p>
</TooltipContent>
</Tooltip>
)
}
function InteractiveOrbitGraph({
nodes,
extraCount,
centerTitle,
t,
relationshipLabel,
similarityFloor,
}: {
nodes: OrbitNode[]
extraCount: number
centerTitle: string
t: (key: string, params?: Record<string, string | number>) => string
relationshipLabel: (rel: OrbitRelationship) => string
similarityFloor: number
}) {
const svgRef = useRef<SVGSVGElement>(null)
const [offsets, setOffsets] = useState<Record<string, { dx: number; dy: number }>>({})
const [activeKey, setActiveKey] = useState<string | null>(null)
const [hoveredKey, setHoveredKey] = useState<string | null>(null)
const dragRef = useRef<{
key: string
pointerId: number
startX: number
startY: number
baseDx: number
baseDy: number
moved: boolean
} | null>(null)
const basePositions = useMemo(() => {
return nodes.map((node, i) => {
const angle = i * (nodes.length > 0 ? (2 * Math.PI) / nodes.length : 0) - Math.PI / 2
const r = node.relationship === 'semantic' && node.similarity != null
? semanticOrbitRadius(node.similarity, similarityFloor)
: orbitRadius(node.relationship)
return {
key: node.key,
x: CX + r * Math.cos(angle),
y: CY + r * 0.88 * Math.sin(angle),
}
})
}, [nodes, similarityFloor])
const nodePosition = useCallback((key: string, baseX: number, baseY: number) => {
const o = offsets[key] || { dx: 0, dy: 0 }
return { x: baseX + o.dx, y: baseY + o.dy }
}, [offsets])
const clientToSvg = useCallback((clientX: number, clientY: number) => {
const svg = svgRef.current
if (!svg) return { x: 0, y: 0 }
const pt = svg.createSVGPoint()
pt.x = clientX
pt.y = clientY
const ctm = svg.getScreenCTM()
if (!ctm) return { x: 0, y: 0 }
return pt.matrixTransform(ctm.inverse())
}, [])
const endDrag = useCallback((pointerId: number) => {
const d = dragRef.current
if (!d || d.pointerId !== pointerId) return
dragRef.current = null
setActiveKey(null)
}, [])
const startDrag = useCallback((
nodeKey: string,
pointerId: number,
clientX: number,
clientY: number,
baseDx: number,
baseDy: number,
) => {
const pt = clientToSvg(clientX, clientY)
dragRef.current = {
key: nodeKey,
pointerId,
startX: pt.x,
startY: pt.y,
baseDx,
baseDy,
moved: false,
}
setActiveKey(nodeKey)
}, [clientToSvg])
useEffect(() => {
const onMove = (e: PointerEvent) => {
const d = dragRef.current
if (!d || e.pointerId !== d.pointerId) return
e.preventDefault()
const pt = clientToSvg(e.clientX, e.clientY)
const dx = d.baseDx + (pt.x - d.startX)
const dy = d.baseDy + (pt.y - d.startY)
if (!d.moved && Math.hypot(pt.x - d.startX, pt.y - d.startY) > 4) {
d.moved = true
}
setOffsets(prev => ({ ...prev, [d.key]: { dx, dy } }))
}
const onUp = (e: PointerEvent) => {
const d = dragRef.current
if (!d || e.pointerId !== d.pointerId) return
const moved = d.moved
const node = nodes.find(n => n.key === d.key)
endDrag(e.pointerId)
if (!moved && node?.id) {
openNoteInNewTab(node.id)
}
}
window.addEventListener('pointermove', onMove, { passive: false })
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
}
}, [clientToSvg, endDrag, nodes])
const hoveredNode = nodes.find(n => n.key === hoveredKey) || null
return (
<div className="rounded-xl border border-border/50 bg-muted/30 overflow-hidden">
<div className="p-2 pb-0">
<svg
ref={svgRef}
width="100%"
height="220"
viewBox="0 0 320 220"
className="select-none block"
role="img"
aria-label={t('documentInfo.network.graphTitle')}
style={{ touchAction: 'none' }}
>
<circle cx={CX} cy={CY} r="96" fill="none" stroke="currentColor" strokeWidth="1" strokeDasharray="3,6" className="text-border/80" />
{basePositions.map((base, i) => {
const node = nodes[i]
const { x, y } = nodePosition(node.key, base.x, base.y)
const isSemantic = node.relationship === 'semantic'
const isMention = node.relationship === 'mention'
const stroke = isSemantic ? SEMANTIC_COLOR : isMention ? '#94A3B8' : '#A47148'
const proximity = isSemantic && node.similarity != null
? semanticProximityRatio(node.similarity, similarityFloor)
: null
return (
<line
key={`line-${node.key}`}
x1={CX}
y1={CY}
x2={x}
y2={y}
stroke={stroke}
strokeWidth={isSemantic ? 1.2 + (proximity ?? 0) * 2.2 : 1.5}
strokeDasharray={isMention || isSemantic ? '5,4' : undefined}
className={isSemantic ? undefined : 'opacity-55'}
opacity={isSemantic ? 0.35 + (proximity ?? 0) * 0.5 : 0.55}
style={{ pointerEvents: 'none' }}
/>
)
})}
<circle cx={CX} cy={CY} r="14" fill="#A47148" stroke="var(--background)" strokeWidth="3" style={{ pointerEvents: 'none' }} />
<circle cx={CX} cy={CY} r="4" fill="white" style={{ pointerEvents: 'none' }} />
{basePositions.map((base, i) => {
const node = nodes[i]
const { x, y } = nodePosition(node.key, base.x, base.y)
const isActive = activeKey === node.key || hoveredKey === node.key
const canOpen = !!node.id
return (
<g key={node.key}>
<circle
cx={x}
cy={y}
r={18}
fill="transparent"
className={cn(canOpen && 'cursor-pointer')}
onPointerEnter={() => setHoveredKey(node.key)}
onPointerLeave={() => {
if (activeKey !== node.key) setHoveredKey(null)
}}
onPointerDown={(e) => {
if (!canOpen) return
e.preventDefault()
e.stopPropagation()
const o = offsets[node.key] || { dx: 0, dy: 0 }
startDrag(node.key, e.pointerId, e.clientX, e.clientY, o.dx, o.dy)
e.currentTarget.setPointerCapture(e.pointerId)
}}
/>
<circle
cx={x}
cy={y}
r={isActive ? 10 : 7}
fill={node.color}
stroke={isActive ? 'var(--foreground)' : 'var(--background)'}
strokeWidth={2}
style={{ pointerEvents: 'none' }}
/>
<text
x={x}
y={y + 15}
textAnchor="middle"
fontSize="7"
fill="currentColor"
className="text-muted-foreground"
style={{ pointerEvents: 'none' }}
>
{node.title.length > 13 ? `${node.title.slice(0, 12)}` : node.title}
</text>
</g>
)
})}
</svg>
{extraCount > 0 && (
<p className="text-[9px] text-right text-muted-foreground px-1 -mt-1">
{t('documentInfo.network.moreNodes', { count: extraCount })}
</p>
)}
</div>
<div className="mx-2 mb-2 p-2.5 rounded-lg border border-border/40 bg-background min-h-[52px]">
{hoveredNode ? (
<div>
<div className="flex justify-between gap-2 text-[9px] text-muted-foreground mb-0.5">
<span className="truncate">{hoveredNode.notebookName}</span>
<span className="font-semibold shrink-0 text-foreground">{relationshipLabel(hoveredNode.relationship)}</span>
</div>
<p className="font-medium text-xs leading-snug">{hoveredNode.title}</p>
{hoveredNode.similarity != null && (
<p className="text-[10px] text-violet-600 dark:text-violet-400 mt-0.5">
{t('documentInfo.network.affinityLine', {
percentage: semanticProximityPercent(hoveredNode.similarity, similarityFloor),
})}
</p>
)}
{hoveredNode.snippet && (
<p className="text-[10px] text-muted-foreground line-clamp-2 mt-1">{cleanSnippet(hoveredNode.snippet)}</p>
)}
{hoveredNode.id && (
<p className="text-[9px] text-muted-foreground mt-1">{t('documentInfo.network.clickToOpen')}</p>
)}
</div>
) : (
<p className="text-[10px] text-muted-foreground text-center leading-relaxed">
{t('documentInfo.network.dragHint')}
</p>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 px-3 pb-2.5 text-[9px] text-muted-foreground border-t border-border/30 pt-2">
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[#A47148]" />{t('documentInfo.network.legendCenter')}</span>
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-violet-600" />{t('documentInfo.network.legendSemantic')}</span>
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[#A47148]/60" />{t('documentInfo.network.legendWiki')}</span>
</div>
</div>
)
}
export function NoteNetworkTab({ noteId, noteTitle }: NoteNetworkTabProps) {
const { t } = useLanguage()
const { notebooks } = useNotebooks()
const [loading, setLoading] = useState(true)
const [backlinks, setBacklinks] = useState<NetworkLink[]>([])
const [outbound, setOutbound] = useState<NetworkLink[]>([])
const [unlinkedMentions, setUnlinkedMentions] = useState<UnlinkedMention[]>([])
const [semanticConnections, setSemanticConnections] = useState<SemanticConnection[]>([])
const [embedHosts, setEmbedHosts] = useState<EmbedHost[]>([])
const [consentRequired, setConsentRequired] = useState(false)
const [similarityFloor, setSimilarityFloor] = useState(SEMANTIC_SIMILARITY_FLOOR)
const [helpOpen, setHelpOpen] = useState(false)
const [showAllSemantic, setShowAllSemantic] = useState(false)
const [showWiki, setShowWiki] = useState(true)
const [refreshKey, setRefreshKey] = useState(0)
const loadNetwork = useCallback(() => {
if (!noteId) return
setLoading(true)
fetch(`/api/notes/${noteId}/network`)
.then(r => r.json())
.then(data => {
setBacklinks(data.backlinks || [])
setOutbound(data.outbound || [])
setUnlinkedMentions(data.unlinkedMentions || [])
setSemanticConnections(data.semanticConnections || [])
setEmbedHosts(data.embedHosts || [])
setConsentRequired(!!data.consentRequired)
setSimilarityFloor(typeof data.similarityFloor === 'number' ? data.similarityFloor : SEMANTIC_SIMILARITY_FLOOR)
})
.catch(() => {
setBacklinks([])
setOutbound([])
setUnlinkedMentions([])
setSemanticConnections([])
setEmbedHosts([])
})
.finally(() => setLoading(false))
}, [noteId])
useEffect(() => {
loadNetwork()
}, [loadNetwork, refreshKey])
useEffect(() => {
const onNoteChange = (event: Event) => {
const detail = (event as CustomEvent).detail
if (detail?.type === 'updated' && detail.note?.id === noteId) {
setRefreshKey(k => k + 1)
}
}
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange)
}, [noteId])
const colorForNotebook = (notebookId: string | null) =>
notebooks.find(n => n.id === notebookId)?.color || DEFAULT_COLOR
const notebookNameFor = (notebookId: string | null) =>
notebooks.find(n => n.id === notebookId)?.name || t('documentInfo.network.unknownNotebook')
const sortedSemantic = useMemo(
() => [...semanticConnections].sort((a, b) => b.similarity - a.similarity),
[semanticConnections]
)
const graphNodes = useMemo(() => {
const nodes: OrbitNode[] = []
const seen = new Set<string>()
const push = (node: OrbitNode) => {
if (node.id) {
if (seen.has(node.id)) return
seen.add(node.id)
}
nodes.push(node)
}
outbound.forEach(link => {
if (!link.note) return
push({
key: `out-${link.id}`,
id: link.note.id,
title: link.note.title || t('documentInfo.network.untitled'),
color: colorForNotebook(link.note.notebookId),
notebookName: notebookNameFor(link.note.notebookId),
relationship: 'outbound',
snippet: link.contextSnippet,
})
})
backlinks.forEach(link => {
if (!link.note) return
push({
key: `in-${link.id}`,
id: link.note.id,
title: link.note.title || t('documentInfo.network.untitled'),
color: colorForNotebook(link.note.notebookId),
notebookName: notebookNameFor(link.note.notebookId),
relationship: 'backlink',
snippet: link.contextSnippet,
})
})
sortedSemantic.forEach(conn => {
push({
key: `sem-${conn.noteId}`,
id: conn.noteId,
title: conn.title || t('documentInfo.network.untitled'),
color: SEMANTIC_COLOR,
notebookName: notebookNameFor(conn.notebookId),
relationship: 'semantic',
snippet: conn.excerpt,
similarity: conn.similarity,
})
})
embedHosts.forEach((host, i) => {
push({
key: `embed-${i}-${host.note.id}`,
id: host.note.id,
title: host.note.title || t('documentInfo.network.untitled'),
color: colorForNotebook(host.note.notebookId),
notebookName: notebookNameFor(host.note.notebookId),
relationship: 'embed',
snippet: t('documentInfo.network.embedSnippetOne'),
})
})
return nodes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortedSemantic, outbound, backlinks, embedHosts, notebooks, t])
const orbitNodes = graphNodes.slice(0, MAX_GRAPH_NODES)
const extraCount = Math.max(0, graphNodes.length - MAX_GRAPH_NODES)
const relationshipLabel = (rel: OrbitRelationship) => {
switch (rel) {
case 'backlink': return t('documentInfo.network.inboundShort')
case 'outbound': return t('documentInfo.network.outboundShort')
case 'semantic': return t('documentInfo.network.semanticShort')
case 'embed': return t('documentInfo.network.embedShort')
default: return t('documentInfo.network.mentionShort')
}
}
const hasWiki = backlinks.length > 0 || outbound.length > 0 || unlinkedMentions.length > 0
const visibleSemantic = showAllSemantic ? sortedSemantic : sortedSemantic.slice(0, MAX_LIST_ITEMS)
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<p className="text-xs">{t('documentInfo.loading')}</p>
</div>
)
}
const isEmpty =
!hasWiki && sortedSemantic.length === 0 && embedHosts.length === 0
return (
<div className="p-4 space-y-4">
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<h4 className="text-sm font-semibold text-foreground">{t('documentInfo.network.graphTitle')}</h4>
<p className="text-[11px] text-muted-foreground leading-relaxed mt-1">{t('documentInfo.network.intro')}</p>
</div>
<button
type="button"
onClick={() => setHelpOpen(v => !v)}
className="shrink-0 p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-expanded={helpOpen}
aria-label={t('documentInfo.network.helpToggle')}
>
<HelpCircle className="h-4 w-4" />
</button>
</div>
{helpOpen && (
<div className="rounded-xl border border-border/60 bg-muted/30 p-3 space-y-2 text-[11px] text-muted-foreground leading-relaxed">
<p className="font-medium text-foreground text-xs">{t('documentInfo.network.helpTitle')}</p>
<p>{t('documentInfo.network.helpGraph')}</p>
<p>{t('documentInfo.network.helpSemantic')}</p>
<p>{t('documentInfo.network.helpWiki')}</p>
<p>{t('documentInfo.network.helpEmbed')}</p>
</div>
)}
</div>
{isEmpty ? (
<div className="text-center py-8 px-4 border border-dashed border-border/60 rounded-xl text-muted-foreground space-y-2">
<Network className="h-7 w-7 mx-auto opacity-30" />
<p className="text-xs leading-relaxed">{t('documentInfo.network.empty')}</p>
{consentRequired && (
<p className="text-[11px] opacity-80">{t('documentInfo.network.consentHint')}</p>
)}
</div>
) : (
<>
{orbitNodes.length > 0 && (
<InteractiveOrbitGraph
nodes={orbitNodes}
extraCount={extraCount}
centerTitle={noteTitle || t('documentInfo.network.untitled')}
t={t}
relationshipLabel={relationshipLabel}
similarityFloor={similarityFloor}
/>
)}
{sortedSemantic.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-violet-600" />
<h5 className="text-xs font-semibold">{t('documentInfo.network.semanticListTitle')}</h5>
<SectionHelp
label={t('documentInfo.network.semanticListTitle')}
help={t('documentInfo.network.semanticListHelp')}
/>
<span className="text-[10px] text-muted-foreground ml-auto">{sortedSemantic.length}</span>
</div>
<div className="space-y-1.5">
{visibleSemantic.map(conn => (
<button
key={conn.noteId}
type="button"
onClick={() => openNoteInNewTab(conn.noteId)}
className="w-full text-left p-3 rounded-xl border border-violet-500/15 bg-violet-500/[0.04] hover:border-violet-500/35 transition-all"
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-medium leading-snug line-clamp-2">{conn.title || t('documentInfo.network.untitled')}</span>
<span className="text-[10px] font-semibold text-violet-700 dark:text-violet-300 shrink-0">
{semanticProximityPercent(conn.similarity, similarityFloor)} %
</span>
</div>
{conn.excerpt && (
<p className="text-[10px] text-muted-foreground mt-1 line-clamp-2 leading-relaxed">{cleanSnippet(conn.excerpt)}</p>
)}
</button>
))}
</div>
{sortedSemantic.length > MAX_LIST_ITEMS && (
<button
type="button"
onClick={() => setShowAllSemantic(v => !v)}
className="text-[11px] text-violet-700 dark:text-violet-300 font-medium hover:underline"
>
{showAllSemantic
? t('documentInfo.network.showLess')
: t('documentInfo.network.showMoreSemantic', { count: sortedSemantic.length - MAX_LIST_ITEMS })}
</button>
)}
</section>
)}
{embedHosts.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-1.5">
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
<h5 className="text-xs font-semibold">{t('documentInfo.network.embedListTitle')}</h5>
<SectionHelp label={t('documentInfo.network.embedListTitle')} help={t('documentInfo.network.helpEmbed')} />
</div>
{embedHosts.map(host => (
<button
key={host.note.id}
type="button"
onClick={() => openNoteInNewTab(host.note.id)}
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/40 transition-all"
>
<p className="text-xs font-medium line-clamp-2">{host.note.title || t('documentInfo.network.untitled')}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{t('documentInfo.network.embedSnippetOne')}</p>
</button>
))}
</section>
)}
{hasWiki && (
<section className="space-y-2">
<button
type="button"
onClick={() => setShowWiki(v => !v)}
className="flex items-center gap-2 w-full text-left group"
>
<h5 className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors">
{t('documentInfo.network.wikiSectionTitle')}
</h5>
{showWiki ? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />}
</button>
{showWiki && (
<div className="space-y-3 pl-0.5">
{backlinks.length > 0 && (
<WikiList
title={t('documentInfo.network.inboundList', { count: backlinks.length })}
help={t('documentInfo.network.inboundHelp')}
links={backlinks}
onOpen={openNoteInNewTab}
untitled={t('documentInfo.network.untitled')}
/>
)}
{outbound.length > 0 && (
<WikiList
title={t('documentInfo.network.outboundList', { count: outbound.length })}
help={t('documentInfo.network.outboundHelp')}
links={outbound}
onOpen={openNoteInNewTab}
untitled={t('documentInfo.network.untitled')}
/>
)}
{unlinkedMentions.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<p className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
{t('documentInfo.network.unlinkedList', { count: unlinkedMentions.length })}
</p>
<SectionHelp label={t('documentInfo.network.unlinkedListTitle')} help={t('documentInfo.network.unlinkedHelp')} />
</div>
{unlinkedMentions.map((m, i) => (
<div key={i} className="p-2.5 rounded-lg border border-border/40 bg-muted/20 text-[10px] text-muted-foreground leading-relaxed">
<span className="font-medium text-foreground">[[{m.title}]]</span>
{m.snippet && cleanSnippet(m.snippet) && (
<span className="block mt-1 italic">{cleanSnippet(m.snippet, 100)}</span>
)}
</div>
))}
</div>
)}
{backlinks.length === 0 && outbound.length === 0 && unlinkedMentions.length === 0 && (
<p className="text-[11px] text-muted-foreground italic">{t('documentInfo.network.noWikiYet')}</p>
)}
</div>
)}
</section>
)}
</>
)}
</div>
)
}
function WikiList({
title,
help,
links,
onOpen,
untitled,
}: {
title: string
help: string
links: NetworkLink[]
onOpen: (id: string) => void
untitled: string
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<p className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">{title}</p>
<SectionHelp label={title} help={help} />
</div>
{links.map(link => (
<button
key={link.id}
type="button"
onClick={() => onOpen(link.note.id)}
className="w-full text-left p-2.5 rounded-lg border border-border/40 hover:bg-muted/30 transition-all"
>
<p className="text-xs font-medium truncate">{link.note.title || untitled}</p>
{link.contextSnippet && (
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">{cleanSnippet(link.contextSnippet)}</p>
)}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,242 @@
'use client'
import React, { useMemo, useState } from 'react'
import {
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
Check,
Search,
StickyNote,
} from 'lucide-react'
import { motion, AnimatePresence } from 'motion/react'
export type NotebookPickerItem = {
id: string
name: string
icon?: string | null
parentId?: string | null
trashedAt?: Date | string | null
}
const PANEL_WIDTH = 280
const PANEL_HEIGHT = 350
const VIEWPORT_PADDING = 8
export function getNotebookPickerPosition(
rect: DOMRect,
options?: { preferDropUp?: boolean; align?: 'start' | 'end'; panelWidth?: number; panelHeight?: number },
): React.CSSProperties {
const panelWidth = options?.panelWidth ?? PANEL_WIDTH
const panelHeight = options?.panelHeight ?? PANEL_HEIGHT
const align = options?.align ?? 'start'
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let left = align === 'end' ? rect.right - panelWidth : rect.left
left = Math.max(VIEWPORT_PADDING, Math.min(left, viewportWidth - panelWidth - VIEWPORT_PADDING))
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_PADDING
const spaceAbove = rect.top - VIEWPORT_PADDING
const preferDropUp = options?.preferDropUp ?? false
const openUp = preferDropUp || (spaceBelow < panelHeight && spaceAbove > spaceBelow)
if (openUp) {
const bottom = viewportHeight - rect.top + VIEWPORT_PADDING
return {
position: 'fixed',
bottom: Math.max(VIEWPORT_PADDING, bottom),
left,
width: panelWidth,
maxHeight: Math.min(panelHeight, rect.top - VIEWPORT_PADDING * 2),
zIndex: 9999,
}
}
const top = rect.bottom + VIEWPORT_PADDING
return {
position: 'fixed',
top: Math.min(top, viewportHeight - VIEWPORT_PADDING),
left,
width: panelWidth,
maxHeight: Math.min(panelHeight, viewportHeight - top - VIEWPORT_PADDING),
zIndex: 9999,
}
}
type NotebookHierarchyPanelProps = {
notebooks: NotebookPickerItem[]
selectedId?: string | null
onSelect: (id: string) => void
onClose: () => void
searchPlaceholder?: string
footerLabel?: string
closeLabel?: string
showGeneralNotes?: boolean
generalNotesLabel?: string
onSelectGeneralNotes?: () => void
className?: string
style?: React.CSSProperties
}
export function NotebookHierarchyPanel({
notebooks,
selectedId = null,
onSelect,
onClose,
searchPlaceholder = 'Filter notebooks…',
footerLabel,
closeLabel = 'Close',
showGeneralNotes = false,
generalNotesLabel = 'General Notes',
onSelectGeneralNotes,
className = '',
style,
}: NotebookHierarchyPanelProps) {
const [searchQuery, setSearchQuery] = useState('')
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const activeNotebooks = useMemo(
() => notebooks.filter((nb) => !nb.trashedAt),
[notebooks],
)
const toggleExpand = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
const next = new Set(expandedIds)
if (next.has(id)) next.delete(id)
else next.add(id)
setExpandedIds(next)
}
const renderTree = (parentId: string | null | undefined, level = 0): React.ReactNode => {
const children = activeNotebooks.filter((nb) => (nb.parentId ?? null) === (parentId ?? null))
if (children.length === 0) return null
return (
<div className={level > 0 ? 'ms-4 border-s border-border/40 ps-2' : ''}>
{children.map((notebook) => {
const isExpanded = expandedIds.has(notebook.id) || searchQuery.length > 0
const hasChildren = activeNotebooks.some((nb) => nb.parentId === notebook.id)
const isSelected = selectedId === notebook.id
if (searchQuery && !notebook.name.toLowerCase().includes(searchQuery.toLowerCase())) {
const hasMatchingChild = (id: string): boolean => {
const kids = activeNotebooks.filter((nb) => nb.parentId === id)
return kids.some(
(nb) =>
nb.name.toLowerCase().includes(searchQuery.toLowerCase()) || hasMatchingChild(nb.id),
)
}
if (!hasMatchingChild(notebook.id)) return null
}
return (
<div key={notebook.id} className="select-none">
<div
onClick={() => {
onSelect(notebook.id)
if (!searchQuery) onClose()
}}
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
${isSelected ? 'bg-brand-accent/15 text-brand-accent font-bold dark:bg-brand-accent/20' : 'hover:bg-muted dark:hover:bg-white/5 text-ink'}`}
>
<div className="w-4 flex items-center justify-center">
{hasChildren ? (
<button
type="button"
onClick={(e) => toggleExpand(e, notebook.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
>
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : null}
</div>
<div
className={`p-1 rounded ${isSelected ? 'bg-brand-accent/25 dark:bg-brand-accent/30' : 'bg-muted/50 dark:bg-white/5 group-hover:bg-white/40'}`}
>
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
</div>
<span className="text-[13px] truncate flex-1">{notebook.name}</span>
{isSelected && <Check size={14} className="opacity-60 shrink-0" />}
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
{renderTree(notebook.id, level + 1)}
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
)
}
return (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.15 }}
style={style}
className={`bg-card border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col min-w-0 ${className}`}
onClick={(e) => e.stopPropagation()}
>
<div className="p-3 border-b border-border/40 bg-card/50 dark:bg-white/5 shrink-0">
<div className="relative">
<Search size={14} className="absolute start-3 top-1/2 -translate-y-1/2 text-concrete" />
<input
autoFocus
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
className="w-full bg-card border border-border rounded-lg ps-9 pe-4 py-2 text-xs outline-none focus:border-brand-accent transition-colors"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto custom-scrollbar p-2">
{showGeneralNotes && onSelectGeneralNotes && (
<button
type="button"
onClick={() => {
onSelectGeneralNotes()
onClose()
}}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all hover:bg-muted dark:hover:bg-white/5 text-ink mb-1"
>
<div className="p-1 rounded bg-muted/50 dark:bg-white/5">
<StickyNote size={13} />
</div>
<span className="text-[13px] truncate flex-1 text-start">{generalNotesLabel}</span>
</button>
)}
{renderTree(null)}
</div>
<div className="p-2 border-t border-border/40 bg-card/30 dark:bg-white/5 flex justify-between items-center px-4 shrink-0">
{footerLabel ? (
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">{footerLabel}</span>
) : (
<span />
)}
<button type="button" onClick={onClose} className="text-[10px] font-bold text-brand-accent hover:underline">
{closeLabel}
</button>
</div>
</motion.div>
)
}

View File

@@ -6,6 +6,7 @@ import { X, FolderOpen } from 'lucide-react'
import { useNotebooks } from '@/context/notebooks-context'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { getNotebookIcon } from '@/lib/notebook-icon'
interface NotebookSuggestionToastProps {
@@ -22,6 +23,7 @@ export function NotebookSuggestionToast({
onMoveToNotebook
}: NotebookSuggestionToastProps) {
const { t } = useLanguage()
const { hasAiConsent } = useAiConsent()
const [suggestion, setSuggestion] = useState<any>(null)
const [isLoading, setIsLoading] = useState(false)
const [visible, setVisible] = useState(true)
@@ -47,6 +49,8 @@ export function NotebookSuggestionToast({
// Fetch suggestion when component mounts
useEffect(() => {
const fetchSuggestion = async () => {
if (!hasAiConsent) return
// Only suggest if content is long enough (> 20 words)
const wordCount = noteContent.trim().split(/\s+/).length
if (wordCount < 20) {
@@ -78,7 +82,7 @@ export function NotebookSuggestionToast({
}
fetchSuggestion()
}, [noteContent])
}, [noteContent, hasAiConsent])
const handleDismiss = () => {
setVisible(false)

View File

@@ -12,6 +12,7 @@ import {
import { Loader2, FileText, RefreshCw, Download } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import type { NotebookSummary } from '@/lib/ai/services'
import ReactMarkdown from 'react-markdown'
@@ -29,6 +30,7 @@ export function NotebookSummaryDialog({
notebookName,
}: NotebookSummaryDialogProps) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [summary, setSummary] = useState<NotebookSummary | null>(null)
const [loading, setLoading] = useState(false)
const [regenerating, setRegenerating] = useState(false)
@@ -46,6 +48,9 @@ export function NotebookSummaryDialog({
const fetchSummary = async () => {
if (!notebookId) return
const consented = await requestAiConsent()
if (!consented) return
setLoading(true)
try {
const response = await fetch('/api/ai/notebook-summary', {

View File

@@ -1,12 +1,12 @@
'use client'
import { useState, useTransition, useEffect } from 'react'
import { useState, useTransition, useEffect, useRef } from 'react'
import type { Note } from '@/lib/types'
import { getNoteFeedImage, getNotePlainExcerpt, getNoteDisplayTitle } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { useRefresh } from '@/lib/use-refresh'
import { emitNoteChange, type NoteCollectionActions } from '@/lib/note-change-sync'
import { motion, AnimatePresence } from 'motion/react'
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen } from 'lucide-react'
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen, FileText } from 'lucide-react'
import { useLabelsQuery } from '@/lib/query-hooks'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
@@ -17,10 +17,8 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from '@/components/ui/dropdown-menu'
import { MoveToNotebookPickerPortal } from '@/components/move-to-notebook-picker'
import { deleteNote, toggleArchive, togglePin, updateNote } from '@/app/actions/notes'
import { ReminderDialog } from '@/components/reminder-dialog'
import { useNotebooks } from '@/context/notebooks-context'
@@ -35,7 +33,7 @@ type NotesEditorialViewProps = {
onOpen: (note: Note, readOnly?: boolean) => void
notebookName?: string
onOpenHistory?: (note: Note) => void
}
} & NoteCollectionActions
function formatNoteDate(date: Date | string, language: string): string {
const d = typeof date === 'string' ? new Date(date) : date
@@ -49,23 +47,37 @@ function formatNoteDate(date: Date | string, language: string): string {
return `${month.toUpperCase()} ${day}, ${year}`
}
function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
export function EditorialNoteMenu({
note,
onOpen,
onOpenHistory,
onTogglePin,
onDeleteNote,
onArchiveNote,
onMoveToNotebook,
onNotePatch,
}: {
note: Note
onOpen: (note: Note) => void
onOpenHistory?: (note: Note) => void
}) {
} & NoteCollectionActions) {
const { t } = useLanguage()
const { refreshNotes } = useRefresh()
const { notebooks } = useNotebooks()
const [, startTransition] = useTransition()
const [showReminder, setShowReminder] = useState(false)
const [movePickerOpen, setMovePickerOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
if (onDeleteNote) {
onDeleteNote(note)
return
}
startTransition(async () => {
try {
await deleteNote(note.id)
refreshNotes(note?.notebookId)
await deleteNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('notes.deleted') || 'Note supprimée')
} catch {
toast.error(t('general.error'))
@@ -75,10 +87,14 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
const handleArchive = (e: React.MouseEvent) => {
e.stopPropagation()
if (onArchiveNote) {
onArchiveNote(note)
return
}
startTransition(async () => {
try {
await toggleArchive(note.id, !note.isArchived)
refreshNotes(note?.notebookId)
await toggleArchive(note.id, !note.isArchived, { skipRevalidation: true })
emitNoteChange({ type: 'updated', note: { ...note, isArchived: !note.isArchived } })
toast.success(note.isArchived ? (t('notes.unarchived') || 'Désarchivée') : (t('notes.archived') || 'Archivée'))
} catch {
toast.error(t('general.error'))
@@ -88,10 +104,15 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
const handlePin = (e: React.MouseEvent) => {
e.stopPropagation()
if (onTogglePin) {
onTogglePin(note)
return
}
startTransition(async () => {
try {
await togglePin(note.id, !note.isPinned)
refreshNotes(note?.notebookId)
const nextPinned = !note.isPinned
await togglePin(note.id, nextPinned, { skipRevalidation: true })
emitNoteChange({ type: 'updated', note: { ...note, isPinned: nextPinned } })
} catch {
toast.error(t('general.error'))
}
@@ -99,10 +120,14 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
}
const handleMoveToNotebook = (notebookId: string | null) => {
if (onMoveToNotebook) {
onMoveToNotebook(note, notebookId)
return
}
startTransition(async () => {
try {
await updateNote(note.id, { notebookId })
refreshNotes(note?.notebookId)
await updateNote(note.id, { notebookId }, { skipRevalidation: true })
emitNoteChange({ type: 'updated', note: { ...note, notebookId } })
toast.success(t('notebookSuggestion.movedToNotebook') || 'Note déplacée')
} catch {
toast.error(t('general.error'))
@@ -110,11 +135,28 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
})
}
const patchReminder = (reminder: Date | null) => {
startTransition(async () => {
try {
await updateNote(note.id, { reminder }, { skipRevalidation: true })
const patch = { reminder: reminder?.toISOString() ?? null }
onNotePatch?.(note.id, patch)
emitNoteChange({ type: 'updated', note: { ...note, reminder: patch.reminder } })
setShowReminder(false)
} catch {
toast.error(t('general.error'))
}
})
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
<button className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-muted/60 text-muted-foreground hover:text-foreground">
<button
ref={menuTriggerRef}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-muted/60 text-muted-foreground hover:text-foreground"
>
<MoreHorizontal size={15} />
</button>
</DropdownMenuTrigger>
@@ -147,24 +189,15 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
</DropdownMenuItem>
{/* Déplacer vers un carnet */}
<DropdownMenuSub>
<DropdownMenuSubTrigger onClick={e => e.stopPropagation()}>
<FolderOpen className="h-4 w-4 me-2 text-foreground/50" />
{t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
<DropdownMenuItem onClick={e => { e.stopPropagation(); handleMoveToNotebook(null) }}>
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold me-2 shrink-0">N</span>
{t('notebookSuggestion.generalNotes') || 'Notes générales'}
</DropdownMenuItem>
{notebooks.map((nb: any) => (
<DropdownMenuItem key={nb.id} onClick={e => { e.stopPropagation(); handleMoveToNotebook(nb.id) }}>
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold me-2 shrink-0">{nb.name.charAt(0).toUpperCase()}</span>
{nb.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
onClick={e => {
e.stopPropagation()
setMovePickerOpen(true)
}}
>
<FolderOpen className="h-4 w-4 me-2 text-foreground/50" />
{t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive focus:bg-destructive/10">
@@ -174,25 +207,24 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
</DropdownMenuContent>
</DropdownMenu>
<MoveToNotebookPickerPortal
open={movePickerOpen}
onOpenChange={setMovePickerOpen}
anchorRef={menuTriggerRef}
notebooks={notebooks}
currentNotebookId={note.notebookId}
onSelect={handleMoveToNotebook}
align="end"
preferDropUp
/>
{/* ReminderDialog hors du DropdownMenu pour éviter les conflits de portail */}
<ReminderDialog
open={showReminder}
onOpenChange={setShowReminder}
currentReminder={note.reminder ? new Date(note.reminder) : null}
onSave={(date) => {
startTransition(async () => {
await updateNote(note.id, { reminder: date })
refreshNotes(note?.notebookId)
setShowReminder(false)
})
}}
onRemove={() => {
startTransition(async () => {
await updateNote(note.id, { reminder: null })
refreshNotes(note?.notebookId)
setShowReminder(false)
})
}}
onSave={(date) => patchReminder(date)}
onRemove={() => patchReminder(null)}
/>
</>
)
@@ -209,13 +241,14 @@ function EditorialThumbnail({
note,
title,
aiIllustrationEnabled,
onNoteIllustrationGenerated,
}: {
note: Note
title: string
aiIllustrationEnabled: boolean
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
}) {
const { t } = useLanguage()
const { refreshNotes } = useRefresh()
const [busy, setBusy] = useState(false)
const img = getNoteFeedImage(note)
@@ -224,12 +257,12 @@ function EditorialThumbnail({
if (!aiIllustrationEnabled || busy || img) return
setBusy(true)
try {
const res = await generateNoteIllustrationSvg(note.id)
const res = await generateNoteIllustrationSvg(note.id, { skipRevalidation: true })
if (!res.ok) {
toast.error(res.error)
} else {
toast.success(t('notes.illustrationGenerated') || 'Illustration générée')
refreshNotes(note?.notebookId)
await onNoteIllustrationGenerated?.(note.id)
}
} finally {
setBusy(false)
@@ -253,7 +286,7 @@ function EditorialThumbnail({
/>
) : (
<>
<NoteThumbnailPlaceholder title={title} noteId={note.id} />
<NoteThumbnailPlaceholder noteId={note.id} />
{aiIllustrationEnabled && (
<button
type="button"
@@ -272,12 +305,8 @@ function EditorialThumbnail({
)
}
/** SVG thumbnail for notes without an image */
function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: string }) {
// Try to extract the first emoji from the title
const emojiMatch = title.match(/\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u)
const emoji = emojiMatch?.[0]
const letter = title.replace(/\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu, '').trim()[0]?.toUpperCase() || '?'
/** SVG thumbnail for notes without an image — icône document (ref. architectural-grid), pas initiale */
function NoteThumbnailPlaceholder({ noteId }: { noteId: string }) {
const hue = stringToHue(noteId)
return (
@@ -285,7 +314,6 @@ function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: st
className="h-full w-full flex items-center justify-center relative overflow-hidden"
style={{ background: `linear-gradient(145deg, hsl(${hue} 25% var(--thumb-lightness-1, 94%)) 0%, hsl(${hue} 18% var(--thumb-lightness-2, 87%)) 100%)` }}
>
{/* Decorative concentric circles */}
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 224 168"
@@ -299,25 +327,12 @@ function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: st
<line x1="22" y1="84" x2="202" y2="84" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
<line x1="112" y1="4" x2="112" y2="164" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
</svg>
{emoji ? (
<span
className="relative text-5xl leading-none select-none"
style={{ filter: `drop-shadow(0 2px 8px hsl(${hue} 40% 40% / 0.2))` }}
>
{emoji}
</span>
) : (
<span
className="relative font-memento-serif font-bold select-none leading-none"
style={{
fontSize: '4.5rem',
color: `hsl(${hue} 35% 35%)`,
opacity: 0.35,
}}
>
{letter}
</span>
)}
<FileText
size={40}
strokeWidth={1.25}
className="relative text-muted-foreground/45"
style={{ color: `hsl(${hue} 25% 45%)` }}
/>
</div>
)
}
@@ -339,6 +354,12 @@ export function NotesEditorialView({
onOpen,
notebookName,
onOpenHistory,
onTogglePin,
onDeleteNote,
onArchiveNote,
onMoveToNotebook,
onNotePatch,
onNoteIllustrationGenerated,
}: NotesEditorialViewProps) {
const { t, language } = useLanguage()
const { data: session } = useSession()
@@ -400,7 +421,16 @@ export function NotesEditorialView({
className="absolute top-0 end-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={e => e.stopPropagation()}
>
<EditorialNoteMenu note={note} onOpen={onOpen} onOpenHistory={onOpenHistory} />
<EditorialNoteMenu
note={note}
onOpen={onOpen}
onOpenHistory={onOpenHistory}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onMoveToNotebook={onMoveToNotebook}
onNotePatch={onNotePatch}
/>
</div>
<h2 className="font-memento-serif text-2xl font-medium text-foreground flex items-center justify-between">
@@ -411,7 +441,12 @@ export function NotesEditorialView({
</h2>
<div className="flex flex-col md:flex-row gap-8 items-start">
<EditorialThumbnail note={note} title={title} aiIllustrationEnabled={aiIllustrationEnabled} />
<EditorialThumbnail
note={note}
title={title}
aiIllustrationEnabled={aiIllustrationEnabled}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
<div className="space-y-3 flex-1">
{note.labels && note.labels.length > 0 && (
<div className="flex flex-wrap gap-2">

View File

@@ -0,0 +1,941 @@
'use client'
import { useMemo, useState, useTransition, useEffect, useCallback } from 'react'
import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
rectSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import type { Note } from '@/lib/types'
import { NotesEditorialView } from '@/components/notes-editorial-view'
import type { NoteCollectionActions } from '@/lib/note-change-sync'
import { getNoteDisplayTitle, getNoteFeedImage, getNotePlainExcerpt, prepareNoteIllustrationForGrid } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { useLabelsQuery } from '@/lib/query-hooks'
import { updateNote } from '@/app/actions/notes'
import { getAISettings } from '@/app/actions/ai-settings'
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
import { LabelBadge } from '@/components/label-badge'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { motion } from 'motion/react'
import { MoveToNotebookPicker } from '@/components/move-to-notebook-picker'
import { toast } from 'sonner'
import {
Pin,
FileText,
Link2,
CheckSquare,
ChevronUp,
ChevronDown,
Wind,
Trash2,
FolderOpen,
Sparkles,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
export type NotesLayoutMode = 'grid' | 'list' | 'table'
export type NotesViewType = 'notes' | 'tasks'
type TaskItem = {
id: string
noteId: string
noteTitle: string
text: string
completed: boolean
lineIndex: number
}
function getNoteTasksStats(content: string) {
const lines = (content || '').split('\n')
let total = 0
let completed = 0
for (const line of lines) {
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
if (match) {
total++
if (match[1].toLowerCase() === 'x') completed++
}
}
return { completed, total }
}
function extractTasksFromNotes(notes: Note[]): TaskItem[] {
const tasks: TaskItem[] = []
for (const note of notes) {
const title = note.title?.trim() || 'Sans titre'
const lines = (note.content || '').split('\n')
lines.forEach((line, idx) => {
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
if (match) {
tasks.push({
id: `${note.id}-${idx}`,
noteId: note.id,
noteTitle: title,
text: match[2].trim(),
completed: match[1].toLowerCase() === 'x',
lineIndex: idx,
})
}
})
}
return tasks
}
function getNotebookColor(notebookId: string | null | undefined, name?: string) {
const colors = [
{ bg: 'bg-[#A47148]/5 dark:bg-[#A47148]/10', border: 'border-[#A47148]/20', text: 'text-[#A47148]' },
{ bg: 'bg-emerald-500/5 dark:bg-emerald-500/10', border: 'border-emerald-500/15', text: 'text-emerald-600 dark:text-emerald-400' },
{ bg: 'bg-indigo-500/5 dark:bg-indigo-500/10', border: 'border-indigo-500/15', text: 'text-indigo-600 dark:text-indigo-400' },
{ bg: 'bg-blue-500/5 dark:bg-blue-500/10', border: 'border-blue-500/15', text: 'text-blue-600 dark:text-blue-400' },
{ bg: 'bg-amber-500/5 dark:bg-amber-500/10', border: 'border-amber-500/15', text: 'text-amber-600 dark:text-amber-400' },
{ bg: 'bg-rose-500/5 dark:bg-rose-500/10', border: 'border-rose-500/15', text: 'text-rose-600 dark:text-rose-400' },
]
const key = name || notebookId || ''
const idx = Math.abs(key.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)) % colors.length
return colors[idx]
}
function NoteLabelsRow({
labelNames,
allLabels,
max = 3,
}: {
labelNames: string[] | null | undefined
allLabels: { name: string; type?: 'ai' | 'user' }[]
max?: number
}) {
if (!labelNames?.length) return null
return (
<div className="flex items-center gap-1 flex-wrap">
{labelNames.slice(0, max).map((labelName) => {
const def = allLabels.find((l) => l.name === labelName)
return (
<LabelBadge key={labelName} label={labelName} type={def?.type} variant="default" />
)
})}
{labelNames.length > max && (
<span className="text-[8.5px] font-mono text-muted-foreground font-bold shrink-0 bg-muted/50 px-1 py-0.5 rounded">
+{labelNames.length - max}
</span>
)}
</div>
)
}
function NoteGridIllustrationButton({
busy,
onClick,
className,
}: {
busy: boolean
onClick: (e: React.MouseEvent) => void
className?: string
}) {
const { t } = useLanguage()
return (
<button
type="button"
aria-label={t('notes.generateIllustration') || 'Générer une illustration IA'}
title={t('notes.generateIllustration') || 'Générer une illustration IA'}
className={cn(
'absolute bottom-2 end-2 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent z-10',
className,
)}
onClick={onClick}
disabled={busy}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4 text-primary" />}
</button>
)
}
function NoteGridThumbnail({
note,
aiIllustrationEnabled,
onNoteIllustrationGenerated,
}: {
note: Note
aiIllustrationEnabled?: boolean
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
}) {
const { t } = useLanguage()
const [busy, setBusy] = useState(false)
const img = getNoteFeedImage(note)
const handleGenerateSvg = async (e: React.MouseEvent) => {
e.stopPropagation()
if (!aiIllustrationEnabled || busy || img) return
setBusy(true)
try {
const res = await generateNoteIllustrationSvg(note.id, { skipRevalidation: true })
if (!res.ok) {
toast.error(res.error)
} else {
toast.success(t('notes.illustrationGenerated') || 'Illustration générée')
await onNoteIllustrationGenerated?.(note.id)
}
} finally {
setBusy(false)
}
}
const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100'
if (img) {
return (
<img
src={img}
alt=""
className="w-full h-full object-cover mix-blend-multiply dark:mix-blend-normal opacity-85 grayscale contrast-115 group-hover/card:scale-105 group-hover/card:grayscale-0 group-hover/card:opacity-100 transition-all duration-500"
/>
)
}
if (note.illustrationSvg) {
return (
<>
<div
className="absolute inset-0 w-full h-full overflow-hidden bg-[#F5F0E8] dark:bg-muted/30"
dangerouslySetInnerHTML={{ __html: prepareNoteIllustrationForGrid(note.illustrationSvg) }}
aria-hidden
/>
{aiIllustrationEnabled && (
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
)}
</>
)
}
return (
<>
<div className="w-full h-full flex items-center justify-center bg-muted/40 text-muted-foreground/50">
<FileText size={28} strokeWidth={1.25} />
</div>
{aiIllustrationEnabled && (
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
)}
</>
)
}
export type { NoteCollectionActions } from '@/lib/note-change-sync'
type NotesListViewsProps = {
notes: Note[]
pinnedNotes?: Note[]
viewType: NotesViewType
layoutMode: NotesLayoutMode
onOpen: (note: Note, readOnly?: boolean) => void
onOpenHistory?: (note: Note) => void
notebookName?: string
onGridReorder?: (orderedIds: string[]) => void | Promise<void>
} & Partial<NoteCollectionActions>
export function NotesListViews({
notes,
pinnedNotes = [],
viewType,
layoutMode,
onOpen,
onOpenHistory,
notebookName,
onTogglePin,
onDeleteNote,
onArchiveNote,
onMoveToNotebook,
onNotePatch,
onNoteIllustrationGenerated,
onGridReorder,
}: NotesListViewsProps) {
const { t, language } = useLanguage()
const { data: session } = useSession()
const { notebooks } = useNotebooks()
const { data: allLabels = [] } = useLabelsQuery()
const [, startTransition] = useTransition()
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'tasks' | 'modified' | null>(null)
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null)
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
useEffect(() => {
if (!session?.user?.id) {
setAiIllustrationEnabled(false)
return
}
getAISettings(session.user.id)
.then((s) => setAiIllustrationEnabled(s.paragraphRefactor !== false))
.catch(() => setAiIllustrationEnabled(false))
}, [session?.user?.id])
const untitled = t('notes.untitled')
const dateLocale = language === 'fr' ? fr : enUS
const allDisplayNotes = useMemo(() => {
const unpinned = notes.filter((n) => !n.isPinned)
return [...pinnedNotes, ...unpinned]
}, [notes, pinnedNotes])
const extractTasks = useMemo(() => extractTasksFromNotes(allDisplayNotes), [allDisplayNotes])
const completedTasksCount = extractTasks.filter((task) => task.completed).length
const handleToggleTask = (task: TaskItem) => {
const note = allDisplayNotes.find((n) => n.id === task.noteId)
if (!note) return
const lines = (note.content || '').split('\n')
const line = lines[task.lineIndex]
if (!line) return
const nextChar = task.completed ? ' ' : 'x'
lines[task.lineIndex] = line.replace(/\[([ xX])\]/, `[${nextChar}]`)
startTransition(async () => {
await updateNote(note.id, { content: lines.join('\n') }, { skipRevalidation: true })
onNotePatch?.(note.id, { content: lines.join('\n') })
})
}
const handleSort = (field: 'title' | 'notebook' | 'tasks' | 'modified') => {
if (sortColumn !== field) {
setSortColumn(field)
setSortDirection('asc')
} else if (sortDirection === 'asc') {
setSortDirection('desc')
} else {
setSortColumn(null)
setSortDirection(null)
}
}
const sortedNotes = useMemo(() => {
if (!sortColumn || !sortDirection) return allDisplayNotes
const copy = [...allDisplayNotes]
return copy.sort((a, b) => {
let valA: string | number = ''
let valB: string | number = ''
if (sortColumn === 'title') {
valA = getNoteDisplayTitle(a, untitled).toLowerCase()
valB = getNoteDisplayTitle(b, untitled).toLowerCase()
} else if (sortColumn === 'notebook') {
valA = notebooks.find((nb) => nb.id === a.notebookId)?.name?.toLowerCase() || ''
valB = notebooks.find((nb) => nb.id === b.notebookId)?.name?.toLowerCase() || ''
} else if (sortColumn === 'tasks') {
valA = getNoteTasksStats(a.content || '').completed
valB = getNoteTasksStats(b.content || '').completed
} else {
valA = new Date(a.updatedAt).getTime()
valB = new Date(b.updatedAt).getTime()
}
if (valA < valB) return sortDirection === 'asc' ? -1 : 1
if (valA > valB) return sortDirection === 'asc' ? 1 : -1
return 0
})
}, [allDisplayNotes, sortColumn, sortDirection, notebooks, untitled])
const SortIcon = ({ field }: { field: typeof sortColumn }) =>
sortColumn === field ? (
sortDirection === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />
) : null
if (viewType === 'tasks') {
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between pb-3 border-b border-foreground/5">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[10px] uppercase font-bold tracking-[0.2em] text-muted-foreground">
{t('notes.tasksHeader')}
</span>
</div>
<div className="text-[11px] font-mono font-bold text-foreground bg-foreground/[0.03] dark:bg-white/5 py-1 px-3 rounded-full">
{t('notes.tasksSummary')
.replace('{count}', String(extractTasks.length))
.replace('{completed}', String(completedTasksCount))}
</div>
</div>
{extractTasks.length > 0 ? (
<div className="overflow-hidden border border-border/40 rounded-2xl bg-card/30 shadow-sm">
<div className="divide-y divide-foreground/[0.04]">
{extractTasks.map((task) => (
<div
key={task.id}
className="p-4 flex items-center justify-between gap-4 hover:bg-foreground/[0.01] transition-all group"
>
<div className="flex items-center gap-3.5 flex-grow min-w-0">
<button
type="button"
onClick={() => handleToggleTask(task)}
className={cn(
'w-5 h-5 rounded-md border flex items-center justify-center transition-all shrink-0',
task.completed
? 'bg-brand-accent border-brand-accent text-white'
: 'border-border hover:border-brand-accent/60 bg-transparent',
)}
>
{task.completed && <span className="text-xs font-bold"></span>}
</button>
<span
className={cn(
'text-[13px] font-light leading-relaxed truncate',
task.completed && 'line-through text-muted-foreground',
)}
>
{task.text}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[9.5px] uppercase font-mono tracking-wider text-muted-foreground max-w-[140px] truncate">
{t('notes.taskFromNote').replace('{title}', task.noteTitle)}
</span>
<button
type="button"
onClick={() => {
const note = allDisplayNotes.find((n) => n.id === task.noteId)
if (note) onOpen(note)
}}
className="p-1.5 rounded-full hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-all"
title={t('notes.openSourceNote')}
>
<Link2 size={13} />
</button>
</div>
</div>
))}
</div>
</div>
) : (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center border border-border/40">
<CheckSquare size={18} className="text-muted-foreground/60" />
</div>
<p className="font-memento-serif text-lg italic text-muted-foreground">{t('notes.tasksEmptyTitle')}</p>
<p className="text-xs text-muted-foreground/70 max-w-sm leading-relaxed">{t('notes.tasksEmptyHint')}</p>
</div>
)}
</div>
)
}
if (layoutMode === 'grid') {
return (
<NotesMasonryGrid
pinnedNotes={pinnedNotes}
unpinnedNotes={notes.filter((n) => !n.isPinned)}
untitled={untitled}
allLabels={allLabels}
notebooks={notebooks}
aiIllustrationEnabled={aiIllustrationEnabled}
onOpen={onOpen}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onMoveToNotebook={onMoveToNotebook}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
onGridReorder={onGridReorder}
pinnedLabel={t('notes.pinned')}
/>
)
}
if (layoutMode === 'table') {
return (
<div className="max-w-6xl mx-auto">
<div className="overflow-x-auto border border-border/40 rounded-2xl bg-card/30 shadow-sm">
<table className="w-full text-left border-collapse min-w-[720px]">
<thead>
<tr className="border-b border-border/30">
<th
className="w-[32%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('title')}
>
<span className="inline-flex items-center gap-1">
{t('notes.tableTitle')} <SortIcon field="title" />
</span>
</th>
<th
className="w-[15%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('notebook')}
>
<span className="inline-flex items-center gap-1">
{t('notes.tableNotebook')} <SortIcon field="notebook" />
</span>
</th>
<th className="w-[22%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground">
{t('notes.tableLabels')}
</th>
<th
className="w-[12%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('tasks')}
>
<span className="inline-flex items-center gap-1">
{t('notes.tableTasks')} <SortIcon field="tasks" />
</span>
</th>
<th
className="w-[13%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('modified')}
>
<span className="inline-flex items-center gap-1">
{t('notes.tableModified')} <SortIcon field="modified" />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-foreground/[0.03]">
{sortedNotes.map((note) => {
const title = getNoteDisplayTitle(note, untitled)
const nb = notebooks.find((n) => n.id === note.notebookId)
const nbColor = getNotebookColor(note.notebookId, nb?.name)
const stats = getNoteTasksStats(note.content || '')
return (
<tr
key={note.id}
onClick={() => onOpen(note)}
className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group"
>
<td className="px-4 py-2 font-memento-serif text-[13px] font-medium truncate max-w-[280px]">
<span className="inline-flex items-center gap-2 truncate group-hover:text-brand-accent transition-colors">
{note.isPinned && <Pin size={11} className="text-amber-500 fill-amber-500 shrink-0" />}
{title}
</span>
</td>
<td className="px-4 py-2">
{nb ? (
<span
className={cn(
'inline-block px-2 py-0.5 rounded-full text-[9px] font-bold tracking-wide border truncate max-w-[125px]',
nbColor.bg,
nbColor.border,
nbColor.text,
)}
>
{nb.name}
</span>
) : (
<span className="text-muted-foreground text-[10px]"></span>
)}
</td>
<td className="px-4 py-2">
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={3} />
</td>
<td className="px-4 py-2 font-mono text-[10.5px] font-bold text-foreground/80">
{stats.total > 0 ? (
<span className={stats.completed === stats.total ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}>
{stats.completed}/{stats.total} <span className="text-[9px] font-sans"></span>
</span>
) : (
<span className="text-muted-foreground/40"></span>
)}
</td>
<td className="px-4 py-2 text-[10.5px] font-mono text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
return (
<div className="max-w-3xl space-y-16">
{pinnedNotes.length > 0 && (
<div className="mb-6">
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-2">
{t('notes.pinned')}
</h2>
<NotesEditorialView
notes={pinnedNotes}
onOpen={onOpen}
notebookName={notebookName}
onOpenHistory={onOpenHistory}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onMoveToNotebook={onMoveToNotebook}
onNotePatch={onNotePatch}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
</div>
)}
{notes.filter((n) => !n.isPinned).length > 0 && (
<NotesEditorialView
notes={notes.filter((n) => !n.isPinned)}
onOpen={onOpen}
notebookName={notebookName}
onOpenHistory={onOpenHistory}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onMoveToNotebook={onMoveToNotebook}
onNotePatch={onNotePatch}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
)}
</div>
)
}
function formatGridCardDate(date: Date | string, language: string): string {
const d = typeof date === 'string' ? new Date(date) : date
const locale = language === 'fr' ? fr : enUS
if (language === 'fa') {
return formatAbsoluteDateLocalized(d, language, 'd MMM yyyy', locale)
}
const month = d.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' })
const day = d.getUTCDate()
const year = d.getUTCFullYear()
return `${month.toUpperCase()} ${day}, ${year}`
}
type GridCardSharedProps = {
note: Note
index: number
untitled: string
allLabels: { name: string; type?: 'ai' | 'user' }[]
notebooks: { id: string; name: string }[]
aiIllustrationEnabled?: boolean
onOpen: (note: Note) => void
onTogglePin?: (note: Note) => void | Promise<void>
onDeleteNote?: (note: Note) => void | Promise<void>
onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise<void>
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
isOverlay?: boolean
}
function NotesMasonryGrid({
pinnedNotes,
unpinnedNotes,
pinnedLabel,
onGridReorder,
...cardProps
}: {
pinnedNotes: Note[]
unpinnedNotes: Note[]
pinnedLabel: string
onGridReorder?: (orderedIds: string[]) => void | Promise<void>
} & Omit<GridCardSharedProps, 'note' | 'index' | 'isOverlay'>) {
const [activeId, setActiveId] = useState<string | null>(null)
const displayNotes = useMemo(
() => [...pinnedNotes, ...unpinnedNotes],
[pinnedNotes, unpinnedNotes],
)
const activeNote = useMemo(
() => displayNotes.find((n) => n.id === activeId) ?? null,
[displayNotes, activeId],
)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
)
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string)
}, [])
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
if (!over || active.id === over.id || !onGridReorder) return
const activeIdx = displayNotes.findIndex((n) => n.id === active.id)
const overIdx = displayNotes.findIndex((n) => n.id === over.id)
if (activeIdx === -1 || overIdx === -1) return
const activeNoteItem = displayNotes[activeIdx]
const overNoteItem = displayNotes[overIdx]
if (activeNoteItem.isPinned !== overNoteItem.isPinned) return
const reordered = arrayMove(displayNotes, activeIdx, overIdx)
onGridReorder(reordered.map((n) => n.id))
},
[displayNotes, onGridReorder],
)
const sortEnabled = Boolean(onGridReorder)
return (
<DndContext
id="notes-grid-masonry"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="max-w-6xl mx-auto space-y-8">
{pinnedNotes.length > 0 && (
<div>
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-1">
{pinnedLabel}
</h2>
<NotesGridSection
notes={pinnedNotes}
sortEnabled={sortEnabled}
{...cardProps}
/>
</div>
)}
{unpinnedNotes.length > 0 && (
<NotesGridSection
notes={unpinnedNotes}
sortEnabled={sortEnabled}
indexOffset={pinnedNotes.length}
{...cardProps}
/>
)}
</div>
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease' }}>
{activeNote ? (
<div className="cursor-grabbing shadow-2xl rounded-2xl opacity-95">
<GridCard note={activeNote} index={0} isOverlay {...cardProps} />
</div>
) : null}
</DragOverlay>
</DndContext>
)
}
function NotesGridSection({
notes,
sortEnabled,
indexOffset = 0,
untitled,
allLabels,
notebooks,
aiIllustrationEnabled,
onOpen,
onTogglePin,
onDeleteNote,
onMoveToNotebook,
onNoteIllustrationGenerated,
className,
}: Omit<GridCardSharedProps, 'note' | 'index' | 'isOverlay'> & {
notes: Note[]
sortEnabled?: boolean
indexOffset?: number
className?: string
}) {
const ids = useMemo(() => notes.map((n) => n.id), [notes])
const grid = (
<div className={cn('grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6', className)}>
{notes.map((note, index) =>
sortEnabled ? (
<SortableGridCard
key={note.id}
note={note}
index={indexOffset + index}
untitled={untitled}
allLabels={allLabels}
notebooks={notebooks}
aiIllustrationEnabled={aiIllustrationEnabled}
onOpen={onOpen}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onMoveToNotebook={onMoveToNotebook}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
) : (
<GridCard
key={note.id}
note={note}
index={indexOffset + index}
untitled={untitled}
allLabels={allLabels}
notebooks={notebooks}
aiIllustrationEnabled={aiIllustrationEnabled}
onOpen={onOpen}
onTogglePin={onTogglePin}
onDeleteNote={onDeleteNote}
onMoveToNotebook={onMoveToNotebook}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
),
)}
</div>
)
if (!sortEnabled) return grid
return (
<SortableContext items={ids} strategy={rectSortingStrategy}>
{grid}
</SortableContext>
)
}
function SortableGridCard(props: GridCardSharedProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.note.id,
})
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
'touch-none cursor-grab active:cursor-grabbing',
isDragging && 'opacity-40',
)}
>
<GridCard {...props} />
</div>
)
}
function GridCard({
note,
index,
untitled,
allLabels,
notebooks,
aiIllustrationEnabled,
onOpen,
onTogglePin,
onDeleteNote,
onMoveToNotebook,
onNoteIllustrationGenerated,
isOverlay = false,
}: GridCardSharedProps) {
const router = useRouter()
const { t, language } = useLanguage()
const title = getNoteDisplayTitle(note, untitled)
const excerpt = getNotePlainExcerpt(note, 110)
const stats = getNoteTasksStats(note.content || '')
const formattedDate = formatGridCardDate(note.updatedAt, language)
const handlePinClick = (e: React.MouseEvent) => {
e.stopPropagation()
onTogglePin?.(note)
}
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
onDeleteNote?.(note)
}
const handleBrainstormClick = (e: React.MouseEvent) => {
e.stopPropagation()
const seed = `${title}\n\n${excerpt}`.trim() || title
router.push(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`)
}
return (
<motion.div
initial={isOverlay ? false : { opacity: 0, y: 15 }}
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
transition={isOverlay ? undefined : { delay: 0.04 * index, duration: 0.5 }}
onClick={() => onOpen(note)}
className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative"
>
<div className="aspect-[16/10] bg-muted/30 border-b border-border/20 overflow-hidden relative">
<NoteGridThumbnail
note={note}
aiIllustrationEnabled={aiIllustrationEnabled}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
/>
{note.isPinned && (
<div className="absolute top-3 start-3 bg-background/90 backdrop-blur-sm p-1.5 rounded-full shadow-sm border border-border/40 text-amber-500">
<Pin size={11} className="fill-amber-500" />
</div>
)}
{stats.total > 0 && (
<div className="absolute top-3 end-3 bg-background/90 backdrop-blur-sm py-1 px-2.5 rounded-full shadow-sm border border-border/40 text-[9.5px] font-mono font-bold text-muted-foreground">
{stats.completed}/{stats.total}
</div>
)}
</div>
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
<div className="space-y-2.5">
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={2} />
<h3 className="font-memento-serif text-base font-semibold text-foreground leading-snug line-clamp-2 group-hover/card:text-brand-accent transition-colors">
{title}
</h3>
{excerpt && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 font-light">{excerpt}</p>
)}
</div>
<div className="flex items-center justify-between pt-3 border-t border-foreground/[0.03] dark:border-white/[0.03] text-[9.5px] text-muted-foreground font-medium uppercase tracking-wider">
<span>{formattedDate}</span>
<div className="flex items-center gap-1 opacity-0 group-hover/card:opacity-100 transition-opacity">
<button
type="button"
onClick={handleBrainstormClick}
className="p-1 px-2 rounded-full hover:bg-brand-accent/10 text-brand-accent transition-all flex items-center gap-1 cursor-pointer"
title={t('notes.brainstormThisIdea') || 'Brainstormer cette idée'}
aria-label={t('notes.brainstormThisIdeaAria') || t('notes.brainstormThisIdea') || 'Brainstormer cette idée'}
>
<Wind size={11} />
</button>
<button
type="button"
onClick={handlePinClick}
className="p-1 px-2 rounded-full hover:bg-muted text-foreground transition-all cursor-pointer"
title={note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
aria-label={note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
>
<Pin size={11} className={note.isPinned ? 'fill-amber-500 text-amber-500' : ''} />
</button>
{onMoveToNotebook && (
<MoveToNotebookPicker
notebooks={notebooks}
currentNotebookId={note.notebookId}
onSelect={(notebookId) => onMoveToNotebook(note, notebookId)}
align="end"
preferDropUp
>
<button
type="button"
className="p-1 px-2 rounded-full hover:bg-muted text-foreground transition-all cursor-pointer"
title={t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
aria-label={t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
>
<FolderOpen size={11} />
</button>
</MoveToNotebookPicker>
)}
<button
type="button"
onClick={handleDeleteClick}
className="p-1 px-2 rounded-full hover:bg-rose-50 dark:hover:bg-rose-500/10 text-rose-500 transition-all cursor-pointer"
title={t('notes.delete') || 'Supprimer'}
aria-label={t('notes.delete') || 'Supprimer'}
>
<Trash2 size={11} />
</button>
</div>
</div>
</div>
</motion.div>
)
}

View File

@@ -14,7 +14,6 @@ import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders,
import { getPendingBrainstormShares, respondToBrainstormShare } from '@/app/actions/brainstorm'
import { getUnreadNotifications, markNotificationRead, markAllNotificationsRead, type AppNotification } from '@/app/actions/notifications'
import { toast } from 'sonner'
import { useRefresh } from '@/lib/use-refresh'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { formatDistanceToNow } from 'date-fns'
@@ -57,7 +56,6 @@ const C = {
}
export function NotificationPanel() {
const { refreshNotes } = useRefresh()
const { t } = useLanguage()
const router = useRouter()
const [requests, setRequests] = useState<ShareRequest[]>([])
@@ -113,7 +111,6 @@ export function NotificationPanel() {
description: t('collaboration.nowHasAccess', { name: 'Note' }),
duration: 3000,
})
refreshNotes(null)
setOpen(false)
} catch (error: any) {
toast.error(error.message || t('general.error'))
@@ -135,7 +132,6 @@ export function NotificationPanel() {
try {
await toggleReminderDone(noteId, done)
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, isReminderDone: done } : r))
refreshNotes(null)
} catch {
toast.error(t('general.error'))
}

View File

@@ -8,6 +8,7 @@ import {
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/markdown-content'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
// ── Personas definition ──────────────────────────────────────────────────────
@@ -85,6 +86,7 @@ interface PersonasPanelProps {
// ── Component ─────────────────────────────────────────────────────────────────
export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
const { requestAiConsent } = useAiConsent()
const [loadingId, setLoadingId] = useState<PersonaId | null>(null)
const [results, setResults] = useState<Map<PersonaId, PersonaResult>>(new Map())
const [expanded, setExpanded] = useState<PersonaId | null>(null)
@@ -111,6 +113,9 @@ export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
setLoadingId(personaId)
try {
const consented = await requestAiConsent()
if (!consented) return
const res = await fetch('/api/ai/personas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -7,6 +7,8 @@ import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
import { QueryProvider } from '@/components/query-provider'
import type { ReactNode } from 'react'
import type { Translations } from '@/lib/i18n/load-translations'
import { AiConsentProvider } from '@/components/legal/ai-consent-provider'
import { SearchModalProvider } from '@/context/search-modal-context'
const RTL_LANGUAGES = ['ar', 'fa']
@@ -21,18 +23,28 @@ interface ProvidersWrapperProps {
children: ReactNode
initialLanguage?: string
initialTranslations?: Translations
initialAiProcessingConsent?: boolean
}
export function ProvidersWrapper({ children, initialLanguage = 'en', initialTranslations }: ProvidersWrapperProps) {
export function ProvidersWrapper({
children,
initialLanguage = 'en',
initialTranslations,
initialAiProcessingConsent = false,
}: ProvidersWrapperProps) {
return (
<QueryProvider>
<NoteRefreshProvider>
<NotebooksProvider>
<EditorUIProvider>
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
<DirWrapper>
{children}
</DirWrapper>
<AiConsentProvider initialPersistentConsent={initialAiProcessingConsent}>
<DirWrapper>
<SearchModalProvider>
{children}
</SearchModalProvider>
</DirWrapper>
</AiConsentProvider>
</LanguageProvider>
</EditorUIProvider>
</NotebooksProvider>

View File

@@ -23,12 +23,18 @@ import Subscript from '@tiptap/extension-subscript'
import Typography from '@tiptap/extension-typography'
import { ChartExtension } from './tiptap-chart-extension'
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
import { UniqueIdExtension } from './tiptap-unique-id-extension'
import { LiveBlockExtension } from './tiptap-live-block-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker'
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import type { Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon,
Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon, Link2,
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
@@ -39,6 +45,12 @@ import { toast } from 'sonner'
export interface RichTextEditorHandle {
getEditor: () => Editor | null
triggerChartSuggestions: () => void
insertCitation: (
payload: { noteId: string; noteTitle: string; excerpt: string },
options?: { atEnd?: boolean }
) => boolean
insertLiveBlock: (block: BlockSuggestion, options?: { atEnd?: boolean }) => boolean
}
interface RichTextEditorProps {
@@ -47,6 +59,11 @@ interface RichTextEditorProps {
className?: string
placeholder?: string
onImageUpload?: (file: File) => Promise<string>
noteId?: string
}
interface RichTextEditorRef {
triggerChartSuggestions: () => void
}
type SlashItem = {
@@ -147,6 +164,12 @@ const slashCommands: SlashItem[] = [
// Handler will be called by SlashCommandMenu
}
},
{
title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', icon: Link2, category: 'Basic blocks', shortcut: '/bloc',
command: (_e) => {
window.dispatchEvent(new CustomEvent('memento-open-block-picker'))
}
},
]
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
@@ -204,9 +227,108 @@ function useImageInsert() {
}
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload }, ref) {
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId }, ref) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
const [noteLinkQuery, setNoteLinkQuery] = useState('')
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
const noteLinkPickerOpenRef = useRef(false)
noteLinkPickerOpenRef.current = noteLinkPickerOpen
const lastEmittedContent = useRef<string>(content || '')
const editorInstanceRef = useRef<Editor | null>(null)
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const emitContentChange = useCallback((html: string) => {
lastEmittedContent.current = html
onChangeRef.current?.(html)
}, [])
// Listen to the slash-command event to open the BlockPicker
useEffect(() => {
const openHandler = () => setBlockPickerOpen(true)
window.addEventListener('memento-open-block-picker', openHandler)
return () => window.removeEventListener('memento-open-block-picker', openHandler)
}, [])
const handleSelectBlockRef = useRef<(block: BlockSuggestion) => void>(() => {})
const insertCitationRef = useRef<(payload: { noteId: string; noteTitle: string; excerpt: string }, options?: { atEnd?: boolean }) => boolean>(() => false)
useEffect(() => {
const insertHandler = (event: Event) => {
const block = (event as CustomEvent<{ block: BlockSuggestion }>).detail?.block
if (block) handleSelectBlockRef.current(block)
}
const citationHandler = (event: Event) => {
const detail = (event as CustomEvent<{ noteId: string; noteTitle: string; excerpt: string; atEnd?: boolean }>).detail
if (!detail) return
insertCitationRef.current(detail, { atEnd: detail.atEnd !== false })
}
window.addEventListener('memento-insert-live-block', insertHandler)
window.addEventListener('memento-insert-citation', citationHandler)
return () => {
window.removeEventListener('memento-insert-live-block', insertHandler)
window.removeEventListener('memento-insert-citation', citationHandler)
}
}, [])
const handleSelectNoteLink = useCallback((selected: NoteLinkOption) => {
const ed = editorInstanceRef.current
if (!ed) {
setNoteLinkPickerOpen(false)
return
}
let range = noteLinkRangeRef.current
if (!range) {
const { from } = ed.state.selection
const start = Math.max(0, from - 80)
const textBefore = ed.state.doc.textBetween(start, from, '\n', '\0')
const match = textBefore.match(/\[\[([^\]]*)$/)
if (match) {
range = { from: from - match[0].length, to: from }
}
}
if (!range) {
setNoteLinkPickerOpen(false)
return
}
const title = (selected.title || t('documentInfo.network.untitled')).trim()
const href = `/home?openNote=${encodeURIComponent(selected.id)}`
ed.chain()
.focus()
.deleteRange({ from: range.from, to: range.to })
.insertContent({
type: 'text',
text: title,
marks: [{
type: 'link',
attrs: {
href,
target: '_blank',
rel: 'noopener noreferrer',
},
}],
})
.insertContent(' ')
.run()
const html = ed.getHTML()
emitContentChange(html)
setNoteLinkPickerOpen(false)
noteLinkRangeRef.current = null
if (noteId) {
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
detail: { noteId, reason: 'note-link' },
}))
}
}, [emitContentChange, noteId, t])
const editor = useEditor({
extensions: [
@@ -226,42 +348,91 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
Subscript,
Typography,
ChartExtension,
UniqueIdExtension,
LiveBlockExtension,
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
],
content: content || '',
immediatelyRender: false,
editorProps: {
attributes: { class: 'notion-editor' },
handlePaste: (view, event, slice) => {
if (!onImageUpload) return false;
const items = Array.from(event.clipboardData?.items || []);
const hasImage = items.some(item => item.type.startsWith('image/'));
if (!hasImage) return false;
event.preventDefault();
const images = items.filter(item => item.type.startsWith('image/')).map(item => item.getAsFile()).filter(f => f !== null) as File[];
images.forEach(async (file) => {
try {
toast.info(t('notes.uploading'));
const url = await onImageUpload(file);
const { schema } = view.state;
const node = schema.nodes.image.create({ src: url });
const tr = view.state.tr.replaceSelectionWith(node);
view.dispatch(tr);
} catch (err) {
toast.error(t('notes.uploadFailed'));
handleDOMEvents: {
click: (_view, event) => {
const link = (event.target as HTMLElement).closest('a[href]')
if (!link) return false
const href = link.getAttribute('href') || ''
const noteIdMatch = href.match(/[?&]openNote=([^&#]+)/)
if (noteIdMatch) {
event.preventDefault()
event.stopPropagation()
window.open(
`/home?openNote=${decodeURIComponent(noteIdMatch[1])}`,
'_blank',
'noopener,noreferrer'
)
return true
}
});
return true;
if (href.startsWith('http://') || href.startsWith('https://')) {
event.preventDefault()
event.stopPropagation()
window.open(href, '_blank', 'noopener,noreferrer')
return true
}
return false
},
},
handlePaste: (_view, event) => {
if (!onImageUpload) return false
const items = Array.from(event.clipboardData?.items || [])
const hasImage = items.some(item => item.type.startsWith('image/'))
if (!hasImage) return false
event.preventDefault()
const imageFiles = items
.filter(item => item.type.startsWith('image/'))
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
void (async () => {
for (const file of imageFiles) {
try {
toast.info(t('notes.uploading'))
const url = await onImageUpload(file)
const editorInstance = editorInstanceRef.current
if (!editorInstance) continue
const inserted = editorInstance.chain().focus().setImage({ src: url }).run()
if (inserted) {
emitContentChange(editorInstance.getHTML())
}
} catch {
toast.error(t('notes.uploadFailed'))
}
}
})()
return true
}
},
onUpdate: ({ editor: e }) => {
const html = e.getHTML()
lastEmittedContent.current = html
onChange?.(html)
emitContentChange(e.getHTML())
if (!e.isEditable) return
const { from, empty } = e.state.selection
if (!empty) return
const start = Math.max(0, from - 80)
const textBefore = e.state.doc.textBetween(start, from, '\n', '\0')
const match = textBefore.match(/\[\[([^\]]*)$/)
if (match) {
noteLinkRangeRef.current = { from: from - match[0].length, to: from }
setNoteLinkQuery(match[1])
setNoteLinkPickerOpen(true)
} else if (!noteLinkPickerOpenRef.current) {
setNoteLinkPickerOpen(false)
noteLinkRangeRef.current = null
}
},
})
const lastEmittedContent = useRef<string>(content || '')
useEffect(() => {
editorInstanceRef.current = editor ?? null
}, [editor])
// Chart suggestions dialog state
const [chartSuggestionsOpen, setChartSuggestionsOpen] = useState(false)
@@ -278,18 +449,83 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
}
}, [content, editor])
useImperativeHandle(ref, () => ({ getEditor: () => editor }), [editor])
// Chart suggestion handlers
const handleOpenChartSuggestions = useCallback(() => {
const handleOpenChartSuggestions = useCallback(async () => {
if (!editor || !editor.isEditable) return
// Get current selection text if any
const { from, to, empty } = editor.state.selection
const selectionText = !empty ? editor.state.doc.textBetween(from, to, ' ') : null
const consented = await requestAiConsent()
if (!consented) return
setChartSuggestionsOpen(true)
}, [editor])
}, [editor, requestAiConsent])
const insertCitationInEditor = useCallback((
payload: { noteId: string; noteTitle: string; excerpt: string },
options?: { atEnd?: boolean }
) => {
if (!editor || !editor.isEditable) return false
const plainExcerpt = payload.excerpt.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
if (!plainExcerpt) return false
const chain = editor.chain()
if (options?.atEnd !== false) {
chain.focus('end')
} else {
chain.focus()
}
chain.insertContent([
{ type: 'paragraph', content: [] },
{
type: 'blockquote',
content: [{ type: 'paragraph', content: [{ type: 'text', text: plainExcerpt }] }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: '— ' },
{
type: 'text',
text: payload.noteTitle,
marks: [{ type: 'link', attrs: { href: `/home?openNote=${payload.noteId}`, target: '_blank', rel: 'noopener noreferrer' } }],
},
],
},
]).scrollIntoView().run()
emitContentChange(editor.getHTML())
return true
}, [editor, emitContentChange])
const insertLiveBlockInEditor = useCallback((block: BlockSuggestion, options?: { atEnd?: boolean }) => {
if (!editor || !editor.isEditable) return false
const chain = editor.chain()
if (options?.atEnd !== false) {
chain.focus('end')
} else {
chain.focus()
}
chain.insertContent({
type: 'liveBlock',
attrs: {
sourceNoteId: block.noteId,
blockId: block.blockId,
snapshotContent: block.content,
sourceNoteTitle: block.noteTitle,
},
}).scrollIntoView().run()
emitContentChange(editor.getHTML())
return true
}, [editor, emitContentChange])
insertCitationRef.current = insertCitationInEditor
useImperativeHandle(ref, () => ({
getEditor: () => editor,
triggerChartSuggestions: () => {
if (editor) {
handleOpenChartSuggestions()
}
},
insertCitation: insertCitationInEditor,
insertLiveBlock: insertLiveBlockInEditor,
}), [editor, handleOpenChartSuggestions, insertCitationInEditor, insertLiveBlockInEditor])
const handleSelectChart = useCallback((chartContent: string) => {
if (!editor || !editor.isEditable) return
@@ -297,29 +533,69 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
try {
console.log('[handleSelectChart] Inserting chart type:', chartContent.split('\n')[0])
// Use TipTap's insertContent with proper JSON content
// This is the most reliable way to insert custom nodes
editor.commands.insertContent([
{
type: 'chartBlock',
attrs: {
code: chartContent,
language: 'chart'
}
},
{
type: 'paragraph',
content: []
}
])
// Get current selection
const { from, to, empty } = editor.state.selection
console.log('[handleSelectChart] Chart inserted')
// Insert chart AFTER the selected text (not replace it)
// First insert a paragraph for spacing, then the chart, then another paragraph
editor.chain()
.focus()
.insertContentAt(to, [
{
type: 'paragraph',
content: []
},
{
type: 'chartBlock',
attrs: {
code: chartContent,
language: 'chart'
}
},
{
type: 'paragraph',
content: []
}
])
.run()
console.log('[handleSelectChart] Chart inserted after selection')
} catch (error) {
console.error('[handleSelectChart] Failed:', error)
toast.error('Failed to insert chart: ' + (error as Error).message)
}
}, [editor])
const handleSelectBlock = useCallback(async (block: BlockSuggestion) => {
setBlockPickerOpen(false)
if (!editor) return
if (noteId) {
try {
await fetch('/api/blocks/embed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceNoteId: block.noteId, blockId: block.blockId, targetNoteId: noteId }),
})
} catch {
// Non-fatal
}
}
editor.chain().focus().insertContent({
type: 'liveBlock',
attrs: {
sourceNoteId: block.noteId,
blockId: block.blockId,
snapshotContent: block.content,
sourceNoteTitle: block.noteTitle,
},
}).run()
emitContentChange(editor.getHTML())
}, [editor, noteId, emitContentChange])
handleSelectBlockRef.current = handleSelectBlock
return (
<div className={cn('notion-editor-wrapper', className)}>
{editor && (
@@ -339,7 +615,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
return (from !== to && !e.isActive('codeBlock')) || isImage
}}
>
<BubbleToolbar editor={editor} />
<BubbleToolbar editor={editor} onSuggestCharts={handleOpenChartSuggestions} />
</BubbleMenu>
)}
@@ -360,6 +636,24 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
onSelectChart={handleSelectChart}
/>
)}
<BlockPicker
isOpen={blockPickerOpen}
onClose={() => setBlockPickerOpen(false)}
currentNoteId={noteId}
onSelectBlock={handleSelectBlock}
/>
<NoteLinkPicker
isOpen={noteLinkPickerOpen}
query={noteLinkQuery}
currentNoteId={noteId}
onClose={() => {
setNoteLinkPickerOpen(false)
noteLinkRangeRef.current = null
}}
onSelect={handleSelectNoteLink}
/>
</div>
)
}
@@ -413,8 +707,9 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
)
}
function BubbleToolbar({ editor }: { editor: Editor | null }) {
function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onSuggestCharts?: () => void }) {
const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent()
const toastAiError = (err: unknown) => {
const msg = err instanceof Error ? err.message : ''
@@ -460,6 +755,8 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
const { from, to } = editor.state.selection
const text = editor.state.doc.textBetween(from, to, ' ')
if (!text || text.split(/\s+/).length < 2) return
const consented = await requestAiConsent()
if (!consented) return
setAiLoading(true)
setAiOpen(false)
setTranslateOpen(false)
@@ -571,6 +868,9 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
</div>
)}
<button className="notion-ai-subitem" onClick={() => handleAI('explain')}><BookOpen className="w-3.5 h-3.5 text-orange-500" /><span>{t('ai.action.explain')}</span></button>
{onSuggestCharts && (
<button className="notion-ai-subitem" onClick={() => { setAiOpen(false); onSuggestCharts() }}><BarChart3 className="w-3.5 h-3.5 text-blue-500" /><span>{t('ai.action.generateChart') || 'Générer un graphique'}</span></button>
)}
</div>
)}
{aiModal && (
@@ -607,6 +907,7 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
@@ -647,6 +948,16 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
{ ...slashCommands[28], title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', categoryId: 'ai' },
{ ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'basic' },
{
title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'),
icon: Link2,
categoryId: 'basic' as SlashCategoryId,
command: (e) => {
e.chain().focus().insertContent('[[').run()
},
},
]
const closeMenu = useCallback(() => {
@@ -673,6 +984,8 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
} else if (item.isAi && item.aiOption) {
deleteSlashText(); closeMenu(); setAiLoading(true)
try {
const consented = await requestAiConsent()
if (!consented) return
const allText = editor.state.doc.textContent
if (!allText || allText.split(/\s+/).length < 5) return
const result = await aiReformulate(allText, item.aiOption)

View File

@@ -0,0 +1,651 @@
'use client'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import {
Search,
ChevronLeft,
ChevronRight,
X,
CornerDownRight,
Folder,
HelpCircle,
Command,
FileText,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useNotebooks } from '@/context/notebooks-context'
import type { Note } from '@/lib/types'
interface SearchMatch {
id: string
noteId: string
noteTitle: string
path: string
type: 'document' | 'heading' | 'paragraph' | 'list'
headingLevel?: number
text: string
matchedText: string
lineIndex: number
}
interface SearchModalProps {
isOpen: boolean
onClose: () => void
}
/** Strip HTML tags and decode basic entities for plain-text matching */
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim()
}
/** Safe RegExp escape */
function escapeRegExp(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function SearchModal({ isOpen, onClose }: SearchModalProps) {
const router = useRouter()
const { notebooks } = useNotebooks()
const [query, setQuery] = useState('')
const [useRegex, setUseRegex] = useState(false)
const [caseSensitive, setCaseSensitive] = useState(false)
const [searchInTrash, setSearchInTrash] = useState(false)
const [savedQueries, setSavedQueries] = useState<string[]>([])
const [results, setResults] = useState<Note[]>([])
const [isLoading, setIsLoading] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Load saved queries from localStorage
useEffect(() => {
try {
const stored = localStorage.getItem('momento-search-saved')
if (stored) setSavedQueries(JSON.parse(stored))
} catch {}
}, [])
// Focus input when opened
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 50)
setQuery('')
setResults([])
setSelectedIndex(0)
}
}, [isOpen])
// Rebuild notebook path helper
const getNotebookPath = useCallback(
(notebookId: string | null | undefined): string => {
if (!notebookId) return ''
const segments: string[] = []
let current = notebooks.find(n => n.id === notebookId)
while (current) {
segments.unshift(current.name)
const parentId = current.parentId
current = parentId ? notebooks.find(n => n.id === parentId) : undefined
}
return segments.join(' / ')
},
[notebooks]
)
// Fetch notes from API with debounce
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
if (!query.trim()) {
setResults([])
setIsLoading(false)
return
}
setIsLoading(true)
debounceRef.current = setTimeout(async () => {
try {
const params = new URLSearchParams({
search: query.trim(),
limit: '40',
...(searchInTrash ? {} : {}),
})
const res = await fetch(`/api/notes?${params}`)
if (!res.ok) throw new Error('search failed')
const data = await res.json()
setResults(data.data ?? [])
} catch {
setResults([])
} finally {
setIsLoading(false)
}
}, 280)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [query, searchInTrash])
// Reset selected index when results change
useEffect(() => {
setSelectedIndex(0)
}, [results, query])
// Build SearchMatch list from results for the left panel
const filteredMatches = useMemo((): SearchMatch[] => {
if (!query.trim() || results.length === 0) return []
const searchRegex = (() => {
try {
// BUG FIX: caseSensitive → 'g' (respect casse), insensitive → 'gi'
const flag = caseSensitive ? 'g' : 'gi'
const pattern = useRegex ? query : escapeRegExp(query)
return new RegExp(pattern, flag)
} catch {
return null
}
})()
if (!searchRegex) return []
const matches: SearchMatch[] = []
results.forEach(note => {
const notebookPath = getNotebookPath(note.notebookId)
const fullPath = notebookPath
? `${notebookPath} / ${note.title ?? 'Sans titre'}`
: (note.title ?? 'Sans titre')
// 1. Title match
if (note.title && searchRegex.test(note.title)) {
matches.push({
id: `${note.id}-title`,
noteId: note.id,
noteTitle: note.title ?? 'Sans titre',
path: fullPath,
type: 'document',
text: note.title ?? '',
matchedText: note.title ?? '',
lineIndex: -1,
})
}
// 2. Content match — strip HTML, split by lines
if (note.content) {
const plainContent = stripHtml(note.content)
const lines = plainContent.split(/\n|(?<=\.)\s+(?=[A-Z])/)
lines.forEach((line, index) => {
const trimmed = line.trim()
if (!trimmed || trimmed.length < 10) return
// Reset lastIndex for global regex
searchRegex.lastIndex = 0
if (!searchRegex.test(trimmed)) return
let type: 'heading' | 'paragraph' | 'list' = 'paragraph'
let headingLevel: number | undefined
let displayVal = trimmed.slice(0, 120)
if (/^#{1,6}\s/.test(trimmed)) {
type = 'heading'
const m = trimmed.match(/^(#{1,6})\s+(.+)$/)
if (m) { headingLevel = m[1].length; displayVal = m[2] }
} else if (/^[-*+]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) {
type = 'list'
displayVal = trimmed.replace(/^[-*+\d.]+\s+/, '').slice(0, 120)
}
matches.push({
id: `${note.id}-line-${index}`,
noteId: note.id,
noteTitle: note.title ?? 'Sans titre',
path: fullPath,
type,
headingLevel,
text: trimmed,
matchedText: displayVal,
lineIndex: index,
})
})
}
})
return matches.slice(0, 200) // cap pour les perf
}, [results, query, useRegex, caseSensitive, getNotebookPath])
// Active match for preview panel
const activeMatch = filteredMatches[selectedIndex]
// Count distinct notes in results
const docMatchesCount = useMemo(() => {
return new Set(filteredMatches.map(m => m.noteId)).size
}, [filteredMatches])
// Preview panel: highlighted context around the matched line
const highlightedPreview = useMemo(() => {
if (!activeMatch) return null
const currentNote = results.find(n => n.id === activeMatch.noteId)
if (!currentNote) return null
if (!query.trim()) return <p className="text-xs text-concrete p-2">{stripHtml(currentNote.content).slice(0, 500)}</p>
try {
// Fixed flag for preview highlights
const flag = caseSensitive ? 'g' : 'gi'
const searchPattern = useRegex ? query : escapeRegExp(query)
const highlightRegex = new RegExp(`(${searchPattern})`, flag)
const plainContent = stripHtml(currentNote.content)
const lines = plainContent.split(/\n|(?<=\.)\s+(?=[A-Z])/).filter(l => l.trim())
const targetIndex = activeMatch.lineIndex >= 0 ? activeMatch.lineIndex : 0
const startLine = Math.max(0, targetIndex - 2)
const endLine = Math.min(lines.length - 1, targetIndex + 6)
return (
<div className="space-y-0.5 my-1">
{startLine > 0 && (
<div className="text-[10px] text-concrete/40 italic pl-10 py-0.5"></div>
)}
{lines.slice(startLine, endLine + 1).map((line, idx) => {
const absoluteIdx = startLine + idx
const isMatchLine = absoluteIdx === targetIndex
highlightRegex.lastIndex = 0
const hasMatch = highlightRegex.test(line)
highlightRegex.lastIndex = 0
const segments = line.split(highlightRegex)
return (
<div
key={absoluteIdx}
className={`py-1 px-2 rounded-lg text-xs leading-relaxed flex items-start gap-3 transition-colors ${
isMatchLine
? 'bg-blueprint/5 border-l-2 border-blueprint pl-2 dark:bg-blueprint/10'
: 'opacity-70'
}`}
>
<span className="font-mono text-[9px] text-concrete/40 text-right w-6 select-none mt-0.5 shrink-0">
{absoluteIdx + 1}
</span>
<span className="font-sans text-ink dark:text-dark-ink break-words min-w-0">
{hasMatch
? segments.map((seg, sIdx) => {
highlightRegex.lastIndex = 0
const isMatch = highlightRegex.test(seg)
return isMatch ? (
<mark
key={sIdx}
className="bg-blueprint/20 text-ink dark:text-white dark:bg-blueprint/30 rounded px-0.5 border-b border-blueprint font-semibold"
>
{seg}
</mark>
) : (
seg
)
})
: line}
</span>
</div>
)
})}
{endLine < lines.length - 1 && (
<div className="text-[10px] text-concrete/40 italic pl-10 py-0.5"></div>
)}
</div>
)
} catch {
return <p className="text-xs text-concrete p-2">{stripHtml(currentNote.content).slice(0, 600)}</p>
}
}, [activeMatch, results, query, useRegex, caseSensitive])
// Row highlight renderer
const renderHighlightedRow = (text: string) => {
if (!query.trim()) return <span className="truncate">{text}</span>
try {
// Fixed: caseSensitive → 'g', insensitive → 'gi'
const flag = caseSensitive ? 'g' : 'gi'
const pattern = useRegex ? query : escapeRegExp(query)
const regex = new RegExp(`(${pattern})`, flag)
const segments = text.split(regex)
return (
<span className="truncate">
{segments.map((seg, i) => {
regex.lastIndex = 0
return regex.test(seg) ? (
<mark key={i} className="bg-blueprint/25 text-ink dark:text-white dark:bg-blueprint/40 px-0.5 rounded font-bold">
{seg}
</mark>
) : (
seg
)
})}
</span>
)
} catch {
return <span className="truncate">{text}</span>
}
}
// Keyboard navigation
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onClose() }
else if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(p => Math.min(p + 1, filteredMatches.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(p => Math.max(p - 1, 0)) }
else if (e.key === 'Enter') {
e.preventDefault()
if (filteredMatches[selectedIndex]) {
router.push(`/home?openNote=${filteredMatches[selectedIndex].noteId}`)
onClose()
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, selectedIndex, filteredMatches, onClose, router])
// Save/remove query from localStorage
const handleSaveQuery = () => {
if (!query.trim()) return
setSavedQueries(prev => {
const next = prev.includes(query.trim()) ? prev : [...prev.slice(-9), query.trim()]
try { localStorage.setItem('momento-search-saved', JSON.stringify(next)) } catch {}
return next
})
}
const handleRemoveQuery = () => {
setSavedQueries(prev => {
const next = prev.filter(q => q !== query.trim())
try { localStorage.setItem('momento-search-saved', JSON.stringify(next)) } catch {}
return next
})
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm flex items-center justify-center z-[200] p-4 sm:p-6 select-none">
<motion.div
initial={{ opacity: 0, scale: 0.98, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 8 }}
transition={{ duration: 0.15 }}
className="w-full max-w-[860px] h-[580px] sm:h-[640px] rounded-2xl bg-white dark:bg-[#121212] border border-border dark:border-zinc-800 shadow-2xl flex flex-col overflow-hidden"
>
{/* Search bar */}
<div className="p-4 border-b border-border/60 dark:border-zinc-800 bg-paper/50 dark:bg-[#161616] flex flex-col gap-3 shrink-0">
<div className="flex items-center gap-2.5 relative">
<Search size={17} className="text-concrete absolute left-3 top-1/2 -translate-y-1/2 shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Rechercher dans toutes vos notes…"
className="w-full text-sm pl-10 pr-28 py-2.5 rounded-xl border border-border/70 dark:border-zinc-800 bg-white/85 dark:bg-[#1C1C1C] text-ink dark:text-dark-ink placeholder-concrete/50 outline-none focus:border-blueprint transition-colors"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
onClick={() => setCaseSensitive(c => !c)}
title="Respecter la casse"
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md transition-colors select-none ${
caseSensitive ? 'text-blueprint bg-blueprint/8' : 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
}`}
>
Aa
</button>
<button
onClick={() => setUseRegex(r => !r)}
title="Mode regex"
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md transition-colors select-none ${
useRegex ? 'text-blueprint bg-blueprint/8' : 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
}`}
>
.*
</button>
<button onClick={onClose} className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-md text-concrete transition-all">
<X size={14} />
</button>
</div>
</div>
{/* Saved queries */}
{savedQueries.length > 0 && (
<div className="flex items-center gap-2 text-[10px] text-concrete">
<span className="uppercase text-[9px] font-bold">Favoris :</span>
<div className="flex flex-wrap gap-1.5">
{savedQueries.map(sq => (
<button
key={sq}
onClick={() => setQuery(sq)}
className={`px-2 py-0.5 rounded-md border text-[9.5px] font-medium transition-all hover:border-blueprint ${
query === sq
? 'bg-blueprint/10 border-blueprint text-blueprint'
: 'bg-white dark:bg-zinc-800 border-border/40 text-concrete'
}`}
>
{sq}
</button>
))}
</div>
</div>
)}
</div>
{/* Status bar */}
<div className="px-4 py-2 bg-[#F8F7F4] dark:bg-[#141414] border-b border-border/40 dark:border-zinc-800 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<div className="flex items-center gap-0.5 border border-border/40 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-lg p-0.5">
<button
disabled={filteredMatches.length === 0}
onClick={() => setSelectedIndex(p => Math.max(0, p - 1))}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-concrete disabled:opacity-40 transition-colors"
>
<ChevronLeft size={12} />
</button>
<span className="text-[9.5px] font-bold font-mono px-1.5 text-concrete">
{filteredMatches.length > 0 ? `${selectedIndex + 1}/${filteredMatches.length}` : '—'}
</span>
<button
disabled={filteredMatches.length === 0}
onClick={() => setSelectedIndex(p => Math.min(filteredMatches.length - 1, p + 1))}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-concrete disabled:opacity-40 transition-colors"
>
<ChevronRight size={12} />
</button>
</div>
<span className="text-[11px] font-medium text-concrete">
{isLoading
? 'Recherche en cours…'
: filteredMatches.length > 0
? `${filteredMatches.length} occurrence${filteredMatches.length > 1 ? 's' : ''} dans ${docMatchesCount} note${docMatchesCount > 1 ? 's' : ''}`
: query.trim()
? 'Aucun résultat'
: 'Tapez pour rechercher'}
</span>
</div>
<div className="flex items-center gap-4">
{query.trim() && (
<button
onClick={savedQueries.includes(query.trim()) ? handleRemoveQuery : handleSaveQuery}
className="text-[10px] font-bold uppercase tracking-wider text-blueprint border-b border-dashed border-blueprint hover:border-solid"
>
{savedQueries.includes(query.trim()) ? 'Retirer favori' : 'Sauvegarder'}
</button>
)}
<label className="flex items-center gap-1.5 cursor-pointer text-[10.5px] font-medium text-concrete">
<input
type="checkbox"
checked={searchInTrash}
onChange={e => setSearchInTrash(e.target.checked)}
className="rounded border-border/60 text-blueprint focus:ring-blueprint w-3 h-3"
/>
<span>Corbeille incluse</span>
</label>
</div>
</div>
{/* Dual panel */}
<div className="flex-1 flex overflow-hidden">
{/* Left — results list */}
<div className="w-[45%] h-full border-r border-border/40 dark:border-zinc-800 flex flex-col bg-[#FAF9F5]/30 dark:bg-[#121212]/30 overflow-hidden">
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{filteredMatches.map((m, idx) => {
const isSelected = idx === selectedIndex
return (
<div
key={m.id}
onClick={() => setSelectedIndex(idx)}
onDoubleClick={() => {
router.push(`/home?openNote=${m.noteId}`)
onClose()
}}
className={`p-2.5 rounded-xl cursor-pointer text-left select-none relative flex flex-col gap-1 border transition-all ${
isSelected
? 'bg-white dark:bg-zinc-800 shadow-sm border-blueprint/25'
: 'border-transparent hover:bg-black/[0.02] dark:hover:bg-white/[0.02]'
}`}
>
{isSelected && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3.5 bg-blueprint rounded-r-full" />
)}
<div className="flex items-center gap-1.5 min-w-0">
{m.type === 'document' && <FileText size={12} className="text-sky-500 shrink-0" />}
{m.type === 'heading' && (
<span className="text-[8px] font-extrabold uppercase bg-indigo-50 dark:bg-indigo-950/40 text-indigo-500 border border-indigo-200 dark:border-indigo-900 px-1 rounded-sm shrink-0 font-mono">
H{m.headingLevel ?? ''}
</span>
)}
{m.type === 'list' && (
<span className="text-[8px] font-extrabold uppercase bg-emerald-50 dark:bg-emerald-950/40 text-emerald-600 border border-emerald-200 dark:border-emerald-900 px-1 rounded-sm shrink-0 font-mono">
LIST
</span>
)}
{m.type === 'paragraph' && (
<span className="text-[8px] font-extrabold uppercase bg-zinc-100 dark:bg-zinc-800 text-concrete border border-border/20 px-1 rounded-sm shrink-0 font-mono">
TXT
</span>
)}
<span className={`font-semibold truncate text-xs ${isSelected ? 'text-ink dark:text-dark-ink' : 'text-muted-foreground'}`}>
{m.noteTitle}
</span>
</div>
<div className="text-[11px] text-concrete truncate pl-5 font-sans leading-tight">
{renderHighlightedRow(m.matchedText)}
</div>
<div className="text-[8.5px] font-mono tracking-widest uppercase text-concrete/45 truncate pl-5 mt-0.5">
{m.path}
</div>
</div>
)
})}
{!isLoading && filteredMatches.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-center p-6 text-concrete pt-24 space-y-2">
<Search size={20} className="opacity-30 animate-pulse" />
<p className="text-[11px] font-medium italic opacity-70">
{query.trim() ? 'Aucune note ne correspond.' : 'Tapez pour obtenir des résultats instantanés.'}
</p>
</div>
)}
{isLoading && (
<div className="h-full flex items-center justify-center text-concrete pt-24">
<div className="w-4 h-4 border-2 border-blueprint/30 border-t-blueprint rounded-full animate-spin" />
</div>
)}
</div>
</div>
{/* Right — preview panel */}
<div className="flex-1 h-full bg-[#FCFCFA]/80 dark:bg-[#151515] flex flex-col overflow-hidden">
{activeMatch ? (
<div className="flex-1 flex flex-col p-5 overflow-hidden">
<div className="space-y-3 overflow-hidden flex flex-col flex-1">
<div className="flex items-center gap-1.5 p-2 bg-black/[0.02] dark:bg-white/[0.02] border border-border/40 rounded-xl">
<Folder size={11} className="text-concrete shrink-0" />
<span className="text-[9.5px] font-mono tracking-widest text-concrete font-medium uppercase truncate">
{activeMatch.path}
</span>
</div>
<div className="border-b border-border/40 dark:border-zinc-800 pb-2">
<h4 className="text-[13px] font-serif font-bold text-ink dark:text-dark-ink">
{activeMatch.noteTitle}
</h4>
<p className="text-[8px] uppercase tracking-wider text-concrete font-bold mt-0.5">
APERÇU CONTEXTUEL
</p>
</div>
<div className="flex-1 overflow-y-auto bg-white dark:bg-[#121212] border border-border/30 dark:border-zinc-800 rounded-xl p-3.5 shadow-inner min-h-0">
{highlightedPreview}
</div>
</div>
<div className="pt-4 border-t border-border/40 dark:border-zinc-800 flex items-center justify-between shrink-0 mt-3">
<button
onClick={() => {
router.push(`/home?openNote=${activeMatch.noteId}`)
onClose()
}}
className="px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 text-xs font-semibold rounded-xl flex items-center gap-2 transition-all shadow-sm"
>
<CornerDownRight size={13} />
<span>Ouvrir dans l'éditeur</span>
</button>
<span className="text-[10px] text-concrete font-bold font-mono bg-paper dark:bg-white/5 border border-border/30 px-2 py-1 rounded">
ID: {activeMatch.noteId.slice(0, 8)}
</span>
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6 text-concrete space-y-3">
<HelpCircle size={24} className="opacity-25" />
<div className="space-y-1">
<p className="text-[11.5px] font-bold">Aperçu du document</p>
<p className="text-[10px] italic opacity-60">
Sélectionnez un résultat pour explorer son contenu.
</p>
</div>
</div>
)}
</div>
</div>
{/* Footer keyboard hints */}
<div className="p-3 bg-[#FAF9F5] dark:bg-[#0E0E0E] border-t border-border/50 dark:border-zinc-800 flex items-center justify-between shrink-0">
<div className="flex items-center gap-5 text-[9.5px] font-bold text-concrete/70">
<span className="flex items-center gap-1.5">
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]"></kbd>
naviguer
</span>
<span className="flex items-center gap-1.5">
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]">Entrée</kbd>
ouvrir
</span>
<span className="flex items-center gap-1.5">
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]">Échap</kbd>
fermer
</span>
</div>
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-wider text-concrete/50">
<Command size={10} />
<span>Momento Search</span>
</div>
</div>
</motion.div>
</div>
)
}

View File

@@ -30,11 +30,19 @@ import {
Sparkles,
Home,
Network,
Search,
GraduationCap,
Scissors,
FileText,
Folder,
FolderOpen,
} from 'lucide-react'
import { useSearchModal } from '@/context/search-modal-context'
import { useLanguage } from '@/lib/i18n'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { applyDocumentTheme } from '@/lib/apply-document-theme'
import { getAllNotes, getTrashCount } from '@/app/actions/notes'
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
import { useNotebooks } from '@/context/notebooks-context'
import { Notebook, Note } from '@/lib/types'
import { toast } from 'sonner'
@@ -50,20 +58,21 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { performSignOut } from '@/lib/auth-client'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
import { UsageMeter } from './usage-meter'
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms'
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
function NoteLink({
title,
isActive,
isPinned,
onClick,
}: {
title: string
isActive: boolean
isPinned?: boolean
onClick: () => void
}) {
const { language } = useLanguage()
@@ -74,15 +83,18 @@ function NoteLink({
animate={{ opacity: 1, x: 0 }}
onClick={onClick}
className={cn(
'w-full flex items-center gap-2 ps-12 pe-4 py-2 text-[12px] transition-colors rounded-lg text-start',
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
'w-full flex items-center gap-2 ps-6 pe-3 py-1.5 text-[11px] transition-all rounded-lg text-start',
isActive
? 'bg-white dark:bg-white/10 shadow-sm border border-border/50 text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
)}
>
<div className={cn(
'w-1.5 h-1.5 rounded-full shrink-0',
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30'
)} />
<span className="break-words line-clamp-2 leading-tight">{title}</span>
<FileText
size={12}
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')}
/>
<span className="truncate flex-1">{title}</span>
{isPinned && <Pin size={10} className="text-amber-500 fill-amber-500 shrink-0" />}
</motion.button>
)
}
@@ -187,7 +199,7 @@ function SidebarCarnetItem({
}: {
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
isActive: boolean
notes: { id: string; title: string }[]
notes: { id: string; title: string; isPinned?: boolean }[]
activeNoteId: string | null
onCarnetClick: () => void
onNoteClick: (noteId: string, carnetId: string) => void
@@ -207,7 +219,7 @@ function SidebarCarnetItem({
}) {
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
const hasChildren = hasChildNotebooks || React.Children.count(children) > 0
const hasChildren = hasChildNotebooks || React.Children.count(children) > 0 || notes.length > 0
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
// Close context menu on outside click
@@ -274,12 +286,10 @@ function SidebarCarnetItem({
/>
)}
<div className={cn(
'w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border shrink-0 transition-all',
isActive
? 'bg-brand-accent text-white border-brand-accent'
: 'bg-white/60 dark:bg-white/5 text-ink dark:text-foreground border-border'
'w-5 h-5 flex items-center justify-center shrink-0 transition-colors',
isActive ? 'text-brand-accent' : 'text-muted-foreground/80',
)}>
{carnet.initial}
{isExpanded ? <FolderOpen size={13} /> : <Folder size={13} />}
</div>
<div className="flex-1 text-start flex items-center gap-2 min-w-0">
@@ -395,17 +405,18 @@ function SidebarCarnetItem({
<div className="space-y-0.5 py-1">
{children}
{isActive && notes.map(note => (
{isExpanded && notes.map(note => (
<NoteLink
key={note.id}
title={note.title}
isPinned={note.isPinned}
isActive={activeNoteId === note.id}
onClick={() => onNoteClick(note.id, carnet.id)}
/>
))}
{isActive && notes.length === 0 && !hasChildren && (
<p className="ps-8 py-2 text-[10px] italic text-muted-foreground/40 font-light">
{t('common.noResults')}
{isExpanded && notes.length === 0 && !hasChildren && (
<p className="ps-6 py-1 text-[9px] italic text-muted-foreground/40 font-light">
{t('sidebar.notebookEmpty')}
</p>
)}
</div>
@@ -423,8 +434,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const router = useRouter()
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
const { notebooks, trashNotebook, updateNotebookOrderOptimistic, moveNotebookToParent } = useNotebooks()
const { refreshKey } = useNoteRefresh()
const { notebooks, trashNotebook, updateNotebookOrderOptimistic, moveNotebookToParent, refreshNotebooks } = useNotebooks()
const { open: openSearch } = useSearchModal()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [createParentId, setCreateParentId] = useState<string | null>(null)
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
@@ -460,7 +471,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const [isRenaming, setIsRenaming] = useState(false)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string }[]>>({})
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string; isPinned?: boolean }[]>>({})
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
const [showSortMenu, setShowSortMenu] = useState(false)
@@ -487,6 +498,20 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const currentNotebookId = searchParams.get('notebook')
const currentNoteId = searchParams.get('openNote')
useEffect(() => {
if (!currentNotebookId) return
setExpandedIds(prev => {
const next = new Set(prev)
let id: string | null | undefined = currentNotebookId
while (id) {
next.add(id)
const nb = notebooks.find(n => n.id === id)
id = nb?.parentId ?? null
}
return next
})
}, [currentNotebookId, notebooks])
const isInboxActive =
pathname === '/home' &&
!searchParams.get('notebook') &&
@@ -523,7 +548,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
let cancelled = false
getTrashCount().then(count => { if (!cancelled) setTrashCount(count) })
return () => { cancelled = true }
}, [refreshKey])
}, [])
const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks])
@@ -537,6 +562,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const mapped = notes.map((n: Note) => ({
id: n.id,
title: getNoteDisplayTitle(n, t('notes.untitled')),
isPinned: n.isPinned,
}))
return [nb.id, mapped] as const
})
@@ -546,8 +572,57 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
}
load()
return () => { cancelled = true }
// refreshKey: reload note titles whenever any note is saved/created/deleted
}, [notebookIdsKey, refreshKey, t])
}, [notebookIdsKey, t])
useEffect(() => {
const onNoteChange = (e: Event) => {
const detail = (e as CustomEvent<NoteChangeEvent>).detail
if (detail.type === 'deleted') {
setNotebookNotes((prev) => {
const next = { ...prev }
for (const key of Object.keys(next)) {
next[key] = next[key].filter((n) => n.id !== detail.noteId)
}
return next
})
setTrashCount((count) => count + 1)
return
}
if (detail.type === 'created' && detail.note.notebookId) {
const nbId = detail.note.notebookId
const title = getNoteDisplayTitle(detail.note, t('notes.untitled'))
setNotebookNotes((prev) => {
const list = prev[nbId] || []
if (list.some((n) => n.id === detail.note.id)) return prev
return {
...prev,
[nbId]: [{ id: detail.note.id, title, isPinned: detail.note.isPinned }, ...list],
}
})
return
}
if (detail.type === 'updated') {
const note = detail.note
const title = getNoteDisplayTitle(note, t('notes.untitled'))
setNotebookNotes((prev) => {
const next: Record<string, { id: string; title: string; isPinned?: boolean }[]> = {}
for (const [key, list] of Object.entries(prev)) {
const filtered = list.filter((n) => n.id !== note.id)
if (filtered.length > 0) next[key] = filtered
}
if (note.notebookId) {
next[note.notebookId] = [
{ id: note.id, title, isPinned: note.isPinned },
...(next[note.notebookId] || []),
]
}
return next
})
}
}
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange)
}, [t])
const handleCarnetClick = (notebookId: string) => {
const params = new URLSearchParams()
@@ -684,13 +759,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
if (!res.ok) throw new Error('Rename failed')
setRenamingNotebook(null)
setRenameValue('')
router.refresh()
await refreshNotebooks()
} catch (err) {
console.error('Rename failed:', err)
} finally {
setIsRenaming(false)
}
}, [renamingNotebook, renameValue, router])
}, [renamingNotebook, renameValue, refreshNotebooks])
const getDescendantIds = useCallback((notebookId: string): string[] => {
const ids: string[] = []
@@ -834,26 +909,26 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
className={cn(
// Mobile: fixed overlay, slide in/out
'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
'h-full min-h-0 w-72 lg:w-80 shrink-0 flex flex-col',
'h-full min-h-0 w-72 lg:w-80 shrink-0 flex flex-row overflow-hidden',
'transition-transform duration-300 ease-in-out',
isMobileOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full md:translate-x-0',
'border-e border-border/40 bg-white/95 md:bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
className
)}
>
{/* ── Top: Logo + Icons + View Toggle ── */}
<div className="p-6 mb-8 space-y-4">
<div className="flex items-center justify-between">
{/* ── Column 1 : Rail d'icônes (54px) — inspiré du prototype ── */}
<div className="w-[54px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-4 shrink-0 select-none">
{/* Top : Logo + navigation */}
<div className="flex flex-col items-center gap-3 w-full">
{/* Logo avec dropdown profil */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center gap-2 group/logo cursor-pointer">
<div className="w-10 h-10 bg-brand-accent flex items-center justify-center rounded-xl shadow-lg shadow-brand-accent/10 rotate-3 group-hover/logo:rotate-0 transition-all duration-500">
<span className="text-white font-serif text-xl font-bold">M</span>
</div>
<span className="text-lg font-serif font-bold tracking-tight text-ink dark:text-paper">Memento</span>
<div className="w-9 h-9 bg-brand-accent hover:rotate-6 active:scale-95 flex items-center justify-center rounded-xl shadow-md transition-all cursor-pointer mb-1">
<span className="text-white font-serif font-bold text-sm">M</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52 bg-popover border-border">
<DropdownMenuContent align="start" className="w-52 bg-popover border-border ms-2">
<DropdownMenuItem asChild>
<Link href="/settings/profile" className="flex items-center gap-2 cursor-pointer">
<User className="h-4 w-4" />
@@ -879,70 +954,129 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-1.5">
<Link
href="/settings"
className={cn(
'p-1.5 transition-all rounded-lg border flex items-center justify-center',
pathname.startsWith('/settings')
? 'bg-brand-accent text-white border-brand-accent shadow-lg shadow-brand-accent/20'
: 'text-muted-ink hover:text-ink hover:bg-white/50 dark:hover:bg-white/10 border-transparent hover:border-border'
)}
title={t('nav.settings')}
>
<Settings size={14} />
</Link>
<button
onClick={() => { router.push('/home') }}
className="p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
title={t('nav.home')}
>
<Home size={14} />
</button>
<button
onClick={toggleTheme}
className="p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
>
{isDark ? <Sun size={14} /> : <Moon size={14} />}
</button>
<NotificationPanel />
{/* Boutons de navigation principaux */}
<div className="flex flex-col gap-1.5 w-full px-1.5">
{([
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
{ id: 'graph', icon: Network, label: 'Vue graphe', onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
{ id: 'revision', icon: GraduationCap, label: 'Révisions', onClick: () => setActiveView('revision'), isActive: activeView === 'revision' },
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
<button
key={item.id}
onClick={item.onClick}
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
item.isActive
? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25'
: 'text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]'
)}
>
{item.isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-brand-accent rounded-r-full" />
)}
<item.icon size={16} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
{item.label}
</span>
</button>
))}
</div>
</div>
<div className="flex bg-white/50 dark:bg-white/10 p-1 rounded-xl border border-border dark:border-white/10">
<button
onClick={() => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'notebooks' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
title={t('nav.notebooks')}
{/* Bottom : utilitaires */}
<div className="flex flex-col gap-1.5 w-full px-1.5 items-center">
<NotificationPanel />
<Link
href="/trash"
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname === '/trash'
? 'bg-rose-500/10 text-rose-500 border border-rose-500/25'
: 'text-concrete hover:text-rose-500 hover:bg-rose-500/5'
)}
>
<BookOpen size={14} />
{pathname === '/trash' && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-rose-500 rounded-r-full" />}
<Trash2 size={16} />
{trashCount > 0 && (
<span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 bg-rose-500 rounded-full border-2 border-[#FAF9F5] dark:border-[#0E0E0E]" />
)}
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
{t('sidebar.trash')}
</span>
</Link>
<Link
href="/home?shared=1&forceList=1"
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
searchParams.get('shared') === '1' && pathname === '/home'
? 'bg-sky-500/10 text-sky-500 border border-sky-500/25'
: 'text-concrete hover:text-sky-500 hover:bg-sky-500/5'
)}
>
<Users size={16} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
{t('sidebar.sharedWithMe')}
</span>
</Link>
<button
onClick={openSearch}
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group"
title="Ctrl+K"
>
<Search size={15} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
Recherche (Ctrl+K)
</span>
</button>
<button
onClick={() => setActiveView('reminders')}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'reminders' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
title={t('sidebar.reminders')}
onClick={toggleTheme}
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group"
>
<Clock size={14} />
{isDark ? <Sun size={15} /> : <Moon size={15} />}
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
{isDark ? 'Mode clair' : 'Mode sombre'}
</span>
</button>
<button
onClick={() => { setActiveView('agents'); router.push('/agents') }}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'agents' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
title={t('nav.agents')}
<Link
href="/settings"
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname.startsWith('/settings')
? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25'
: 'text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]'
)}
>
<Bot size={14} />
</button>
{pathname.startsWith('/settings') && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-brand-accent rounded-r-full" />}
<Settings size={15} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
{t('nav.settings')}
</span>
</Link>
<button
onClick={() => { setActiveView('brainstorms'); router.push('/brainstorm') }}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'brainstorms' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
title={t('brainstorm.sessions')}
onClick={() => performSignOut('/login')}
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-red-500 hover:bg-rose-500/5 transition-all relative group"
>
<Sparkles size={14} />
<LogOut size={14} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
{t('sidebar.signOut')}
</span>
</button>
</div>
</div>
{/* ── Column 2 : Panneau de contenu dynamique ── */}
<div className="flex-1 h-full flex flex-col overflow-hidden bg-[#FCFCFA] dark:bg-[#111111]">
{/* ── Scrollable content ── */}
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar pb-4">
<div className="flex-1 overflow-y-auto space-y-6 -mx-0 custom-scrollbar pb-4">
<AnimatePresence mode="wait">
{activeView === 'notebooks' ? (
@@ -1123,71 +1257,37 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<SidebarBrainstorms />
</motion.div>
)}
{/* ── Vue Révisions (placeholder en attendant US-FLASHCARDS) ── */}
{activeView === 'revision' && (
<motion.div
key="revision"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<div className="flex items-center gap-1.5 mb-4">
<GraduationCap size={13} className="text-brand-accent" />
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">Révisions</p>
</div>
<div className="flex flex-col items-center justify-center text-center p-6 border border-dashed border-border/50 rounded-2xl bg-paper/20 space-y-3">
<GraduationCap size={24} className="text-concrete/40" />
<p className="text-[11px] font-medium text-concrete/70">Flashcards bientôt disponibles</p>
<p className="text-[10px] text-concrete/50">Les decks de révision IA (SM-2) arrivent dans la prochaine itération.</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* ── Footer ── */}
<div className="pt-4 border-t border-border/40 mt-auto pb-4 space-y-4">
{/* ── Usage meter en bas du panneau ── */}
<div className="border-t border-border/20 px-3 py-3 mt-auto">
<UsageMeter />
<div className="px-2 space-y-0.5">
<Link
href="/home?shared=1&forceList=1"
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
searchParams.get('shared') === '1' && pathname === '/home'
? 'bg-accent/5 text-accent'
: 'text-muted-ink hover:text-ink hover:bg-black/5'
)}
>
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/home' ? 'text-accent' : 'text-muted-ink group-hover:text-ink'} />
<span>{t('sidebar.sharedWithMe')}</span>
</Link>
<Link
href="/archive"
className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-black/5 transition-all font-medium group rounded-xl"
>
<Archive size={14} className="text-muted-ink group-hover:text-ink" />
<span>{t('sidebar.archive')}</span>
</Link>
<Link
href="/trash"
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
pathname === '/trash'
? 'bg-rose-50 text-rose-500'
: 'text-muted-ink hover:text-rose-500 hover:bg-rose-50/50'
)}
>
<Trash2 size={14} className={pathname === '/trash' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
<span>{t('sidebar.trash')}</span>
{trashCount > 0 && (
<span className="ms-auto w-1.5 h-1.5 rounded-full bg-rose-400" />
)}
</Link>
{/* ── Intelligence section ── */}
<div className="pt-3 border-t border-border/20 mx-2 mt-1 space-y-0.5">
<p className="text-[9px] font-bold text-muted-ink tracking-[0.2em] uppercase px-1 mb-1 opacity-60">Intelligence</p>
<Link
href="/graph"
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
pathname === '/graph'
? 'bg-indigo-500/10 text-indigo-500'
: 'text-muted-ink hover:text-indigo-500 hover:bg-indigo-500/5'
)}
>
<Network
size={14}
className={pathname === '/graph' ? 'text-indigo-500' : 'text-muted-ink group-hover:text-indigo-500'}
/>
<span className="flex-1">Vue en graphe</span>
</Link>
</div>
</div>
</div>
</div>{/* fin colonne 2 */}
</aside>
<CreateNotebookDialog

View File

@@ -0,0 +1,186 @@
'use client'
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { useEffect, useRef, useState, useCallback } from 'react'
import { Zap, AlertCircle, Unlink, ArrowRight } from 'lucide-react'
// ---------------------------------------------------------------------------
// LiveBlock Node View
// ---------------------------------------------------------------------------
interface LiveBlockViewProps {
node: {
attrs: {
sourceNoteId: string
blockId: string
snapshotContent: string
sourceNoteTitle: string
}
}
updateAttributes: (attrs: Record<string, string>) => void
deleteNode: () => void
}
function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProps) {
const { sourceNoteId, blockId, snapshotContent, sourceNoteTitle } = node.attrs
const [localContent, setLocalContent] = useState(snapshotContent || '')
const [isDeleted, setIsDeleted] = useState(false)
const [isOffline, setIsOffline] = useState(false)
const [pulse, setPulse] = useState(false)
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Fetch current block status on mount
useEffect(() => {
if (!sourceNoteId || !blockId) return
fetch(`/api/blocks/${encodeURIComponent(blockId)}/status?sourceNoteId=${sourceNoteId}`)
.then(r => r.json())
.then((data: { exists: boolean; content: string; sourceNoteTitle: string }) => {
if (!data.exists) {
setIsDeleted(true)
} else {
setLocalContent(data.content)
updateAttributes({ snapshotContent: data.content, sourceNoteTitle: data.sourceNoteTitle })
}
})
.catch(() => setIsOffline(true))
}, [sourceNoteId, blockId]) // eslint-disable-line react-hooks/exhaustive-deps
// Listen for real-time block update events
useEffect(() => {
const handleBlockUpdate = (e: CustomEvent) => {
if (e.detail?.blockId !== blockId) return
setLocalContent(e.detail.content)
updateAttributes({ snapshotContent: e.detail.content })
setPulse(true)
if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current)
pulseTimerRef.current = setTimeout(() => setPulse(false), 1200)
}
const handleBlockDeleted = (e: CustomEvent) => {
if (e.detail?.blockId !== blockId) return
setIsDeleted(true)
}
window.addEventListener('live-block:update', handleBlockUpdate as EventListener)
window.addEventListener('live-block:deleted', handleBlockDeleted as EventListener)
return () => {
window.removeEventListener('live-block:update', handleBlockUpdate as EventListener)
window.removeEventListener('live-block:deleted', handleBlockDeleted as EventListener)
if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current)
}
}, [blockId, updateAttributes])
const handleDetach = useCallback(async () => {
// Convert this node to a plain paragraph with snapshot text
deleteNode()
}, [deleteNode])
const handleOpenSource = useCallback(() => {
window.open(`/home?openNote=${encodeURIComponent(sourceNoteId)}`, '_blank', 'noopener,noreferrer')
}, [sourceNoteId])
const borderClass = isDeleted
? 'border-l-rose-500 border-y-rose-200 border-r-rose-200 bg-rose-50/20 dark:border-l-red-700 dark:border-y-red-900/40 dark:border-r-red-900/40 dark:bg-red-950/5'
: isOffline
? 'border-l-amber-500 border-y-amber-200 border-r-amber-200 bg-amber-50/10 dark:border-l-amber-600 dark:border-y-amber-800/40 dark:border-r-amber-800/40'
: pulse
? 'border-l-blue-500 border-y-blue-300 border-r-blue-300 bg-blue-50/20 shadow-md shadow-blue-500/15 dark:bg-blue-950/10'
: 'border-l-blue-500 border-y-[#E8E6E3] border-r-[#E8E6E3] bg-blue-50/5 dark:border-y-zinc-800 dark:border-r-zinc-800 dark:bg-blue-950/5'
return (
<NodeViewWrapper>
<div className="group/liveblock my-4 not-prose">
<div className={`w-full rounded-xl border-l-[3px] border-y border-r transition-all duration-300 overflow-hidden ${borderClass}`}>
{/* Header */}
<div className="px-4 py-1.5 flex items-center justify-between bg-black/[0.015] dark:bg-white/[0.01] border-b border-black/[0.03] dark:border-white/[0.02]">
<div className="flex items-center gap-2">
{isDeleted ? (
<AlertCircle size={10} className="text-rose-500 shrink-0" />
) : (
<Zap size={10} className={`shrink-0 ${isOffline ? 'text-amber-500' : 'text-blue-500 fill-blue-500/20'}`} />
)}
<span className="text-[10px] font-sans font-medium text-[var(--color-concrete)] hover:text-[var(--color-ink)] transition-colors cursor-default max-w-[200px] truncate">
{isDeleted ? 'Source déconnectée' : (sourceNoteTitle || 'Note connectée')}
</span>
{isDeleted ? (
<span className="bg-rose-500/10 text-rose-600 dark:text-rose-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
DÉCONNECTÉ
</span>
) : isOffline ? (
<span className="bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
HORS-LIGNE
</span>
) : (
<span className="bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider animate-pulse">
LIVE
</span>
)}
</div>
<div className="flex items-center gap-2">
{isDeleted ? (
<button
onClick={handleDetach}
className="text-[9.5px] font-bold text-rose-600 hover:text-rose-500 dark:text-rose-400 flex items-center gap-1 hover:underline transition-all"
contentEditable={false}
>
<Unlink size={10} />
Décharger le lien
</button>
) : (
<button
onClick={handleOpenSource}
className="opacity-0 group-hover/liveblock:opacity-100 flex items-center gap-1 text-[9.5px] font-extrabold text-blue-600 dark:text-blue-400 hover:underline transition-all"
contentEditable={false}
>
Ouvrir <ArrowRight size={10} />
</button>
)}
</div>
</div>
{/* Content */}
<div className="px-4 py-3 bg-blue-500/[0.015] dark:bg-blue-500/[0.005]">
<p
className="text-sm leading-relaxed text-[var(--color-ink)] opacity-80 dark:text-[var(--color-dark-ink)] font-sans whitespace-pre-wrap"
contentEditable={false}
>
{localContent || '(bloc vide)'}
</p>
</div>
</div>
</div>
</NodeViewWrapper>
)
}
// ---------------------------------------------------------------------------
// TipTap Node Definition
// ---------------------------------------------------------------------------
export const LiveBlockExtension = Node.create({
name: 'liveBlock',
group: 'block',
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
sourceNoteId: { default: '' },
blockId: { default: '' },
snapshotContent: { default: '' },
sourceNoteTitle: { default: '' },
}
},
parseHTML() {
return [{ tag: 'div[data-live-block]' }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-live-block': 'true' })]
},
addNodeView() {
return ReactNodeViewRenderer(LiveBlockView)
},
})

View File

@@ -0,0 +1,65 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
const BLOCK_TYPES = ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'codeBlock']
function generateBlockId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
export const UniqueIdExtension = Extension.create({
name: 'uniqueId',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('uniqueId'),
appendTransaction(transactions, _oldState, newState) {
const hasDocChanged = transactions.some(t => t.docChanged)
if (!hasDocChanged) return null
const { tr, doc } = newState
let modified = false
doc.descendants((node, pos) => {
if (!BLOCK_TYPES.includes(node.type.name)) return
if (node.attrs['data-id']) return
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
'data-id': generateBlockId(),
})
modified = true
})
return modified ? tr : null
},
}),
]
},
addGlobalAttributes() {
return [
{
types: BLOCK_TYPES,
attributes: {
'data-id': {
default: null,
parseHTML: element => element.getAttribute('data-id'),
renderHTML: attributes => {
if (!attributes['data-id']) return {}
return { 'data-id': attributes['data-id'] }
},
},
},
},
]
},
})

View File

@@ -50,23 +50,43 @@ export function UsageMeter({ className }: UsageMeterProps) {
);
}
const getPackLabel = () => {
if (!data.tier) return t('usageMeter.packName');
switch (data.tier) {
case 'PRO': return t('usageMeter.packPro');
case 'BUSINESS': return t('usageMeter.packBusiness');
case 'ENTERPRISE': return t('usageMeter.packEnterprise');
default: return t('usageMeter.packName');
}
};
const isProPlus = data.tier && data.tier !== 'BASIC';
// Features visibles dans l'UI (les features techniques comme expand/enrich sont cachées)
const VISIBLE_FEATURES = new Set([
'semantic_search',
'auto_tag',
'auto_title',
'reformulate',
'chat',
'brainstorm_create', // Sera affiché comme "Sessions brainstorm"
'suggest_charts',
]);
const featureLabels: Record<string, string> = {
semantic_search: t('usageMeter.featureSearch'),
auto_tag: t('usageMeter.featureTags'),
auto_title: t('usageMeter.featureTitles'),
reformulate: t('usageMeter.featureReformulate'),
chat: t('usageMeter.featureChat'),
brainstorm_create: t('usageMeter.featureBrainstormCreate'),
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
brainstorm_create: t('usageMeter.featureBrainstormSessions'), // Label simplifié
suggest_charts: t('usageMeter.featureCharts'),
};
const featureQuotas = Object.entries(data.quotas)
.filter(([_, q]) => q.limit > 0)
.filter(([key, q]) => q.limit > 0 && VISIBLE_FEATURES.has(key))
.map(([key, quota]) => ({
key,
key: key === 'brainstorm_create' ? 'brainstorm_sessions' : key, // Renommer pour l'affichage
label: featureLabels[key] || key,
used: quota.used,
limit: quota.limit,
@@ -105,7 +125,7 @@ export function UsageMeter({ className }: UsageMeterProps) {
: 'text-brand-accent fill-brand-accent/20'
)}
/>
<span className="text-[11px] font-bold text-ink/70">{t('usageMeter.packName')}</span>
<span className="text-[11px] font-bold text-ink/70">{getPackLabel()}</span>
{!isUnlimited && (
<>

View File

@@ -5,6 +5,8 @@ import { useQueryClient } from '@tanstack/react-query'
import type { Notebook, Label, Note } from '@/lib/types'
import { LabelColorName, LABEL_COLORS } from '@/lib/types'
import { getHashColor } from '@/lib/utils'
import { emitNoteChange } from '@/lib/note-change-sync'
import { getNoteById } from '@/app/actions/notes'
import { useNoteRefresh } from './NoteRefreshContext'
import { toast } from 'sonner'
import { queryKeys } from '@/lib/query-keys'
@@ -104,7 +106,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
const [isLoading, setIsLoading] = useState(true)
const [isMovingNote, setIsMovingNote] = useState(false)
const [error, setError] = useState<string | null>(null)
const { triggerRefresh, triggerNotebooksRefresh, notebooksRefreshKey } = useNoteRefresh()
const { triggerNotebooksRefresh, notebooksRefreshKey } = useNoteRefresh()
// ===== LABEL STATE (merged from LabelContext) =====
const [labels, setLabels] = useState<Label[]>([])
@@ -187,8 +189,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
// Invalidate notebooks cache — React Query will re-fetch in bg
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh])
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -203,8 +204,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh])
const moveNotebookToParent = useCallback(async (notebookId: string, parentId: string | null) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -220,8 +220,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh])
const deleteNotebook = useCallback(async (notebookId: string) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -234,8 +233,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh])
const trashNotebook = useCallback(async (notebookId: string) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -250,8 +248,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh])
const restoreNotebook = useCallback(async (notebookId: string) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -266,8 +263,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh])
const permanentDeleteNotebook = useCallback(async (notebookId: string) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -280,8 +276,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh])
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
const response = await fetch('/api/notebooks/reorder', {
@@ -296,8 +291,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
triggerNotebooksRefresh()
triggerRefresh()
}, [queryClient, triggerNotebooksRefresh, triggerRefresh])
}, [queryClient, triggerNotebooksRefresh])
// ===== LABEL ACTIONS (merged from LabelContext) =====
const addLabel = useCallback(async (name: string, color?: LabelColorName, labelNotebookId?: string | null) => {
@@ -315,13 +309,12 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
setLabels(prev => [...prev, data.data])
// Invalidate labels cache
queryClient.invalidateQueries({ queryKey: queryKeys.labels(finalNotebookId) })
triggerRefresh()
}
} catch (error) {
console.error('Failed to add label:', error)
throw error
}
}, [notebookId, queryClient, triggerRefresh])
}, [notebookId, queryClient])
const getLabelColor = useCallback((name: string): LabelColorName => {
const label = labels.find(l => l.name.toLowerCase() === name.toLowerCase())
@@ -331,8 +324,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
const refreshLabels = useCallback(async () => {
await fetchLabels(notebookId)
queryClient.invalidateQueries({ queryKey: queryKeys.labels(notebookId) })
triggerRefresh()
}, [fetchLabels, notebookId, queryClient, triggerRefresh])
}, [fetchLabels, notebookId, queryClient])
// ===== ACTIONS: LABELS (keep existing API-based ones for compatibility) =====
const createLabel = useCallback(async (data: CreateLabelInput) => {
@@ -349,9 +341,8 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
const result = await response.json()
// Invalidate labels cache for this notebook
queryClient.invalidateQueries({ queryKey: queryKeys.labels(data.notebookId) })
triggerRefresh()
return result
}, [queryClient, triggerRefresh])
}, [queryClient])
const updateLabel = useCallback(async (labelId: string, data: UpdateLabelInput) => {
const response = await fetch(`/api/labels/${labelId}`, {
@@ -372,8 +363,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
}
// Invalidate labels cache
queryClient.invalidateQueries({ queryKey: queryKeys.labels(notebookId) })
triggerRefresh()
}, [notebookId, queryClient, triggerRefresh])
}, [notebookId, queryClient])
const deleteLabel = useCallback(async (labelId: string) => {
const response = await fetch(`/api/labels/${labelId}`, {
@@ -387,8 +377,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
setLabels(prev => prev.filter(label => label.id !== labelId))
// Invalidate labels cache
queryClient.invalidateQueries({ queryKey: queryKeys.labels(notebookId) })
triggerRefresh()
}, [notebookId, queryClient, triggerRefresh])
}, [notebookId, queryClient])
// ===== ACTIONS: NOTES =====
const moveNoteToNotebookOptimistic = useCallback(async (noteId: string, targetNotebookId: string | null) => {
@@ -404,18 +393,20 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
throw new Error('Failed to move note')
}
// Invalidate notebooks and notes cache
queryClient.invalidateQueries({ queryKey: queryKeys.notebooks() })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(null) }) // notes list
queryClient.invalidateQueries({ queryKey: queryKeys.notes(null) })
triggerNotebooksRefresh()
triggerRefresh()
const movedNote = await getNoteById(noteId)
if (movedNote) {
emitNoteChange({ type: 'updated', note: movedNote })
}
} catch (error) {
toast.error('Failed to move note. Please try again.')
throw error
} finally {
setIsMovingNote(false)
}
}, [queryClient, triggerRefresh, triggerNotebooksRefresh])
}, [queryClient, triggerNotebooksRefresh])
// ===== ACTIONS: AI (STUBS) =====
const suggestNotebookForNote = useCallback(async (_noteContent: string) => {

View File

@@ -0,0 +1,44 @@
'use client'
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
import { SearchModal } from '@/components/search-modal'
interface SearchModalContextValue {
open: () => void
close: () => void
}
const SearchModalContext = createContext<SearchModalContextValue>({
open: () => {},
close: () => {},
})
export function useSearchModal() {
return useContext(SearchModalContext)
}
export function SearchModalProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const open = () => setIsOpen(true)
const close = () => setIsOpen(false)
// Global keyboard shortcut: Ctrl+K or Cmd+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
setIsOpen(prev => !prev)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<SearchModalContext.Provider value={{ open, close }}>
{children}
<SearchModal isOpen={isOpen} onClose={close} />
</SearchModalContext.Provider>
)
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
/**
* Hook to check if auto label suggestions should be shown for the current notebook
@@ -8,6 +9,7 @@ import { useSession } from 'next-auth/react'
*/
export function useAutoLabelSuggestion() {
const { data: session } = useSession()
const { hasAiConsent } = useAiConsent()
const searchParams = useSearchParams()
const [shouldSuggest, setShouldSuggest] = useState(false)
const [notebookId, setNotebookId] = useState<string | null>(searchParams.get('notebook'))
@@ -23,11 +25,11 @@ export function useAutoLabelSuggestion() {
setShouldSuggest(false)
// Check if we should suggest labels for this notebook
if (currentNotebookId && session?.user?.id) {
if (currentNotebookId && session?.user?.id && hasAiConsent) {
checkNotebookForSuggestions(currentNotebookId)
}
}
}, [searchParams, notebookId, session])
}, [searchParams, notebookId, session, hasAiConsent])
const checkNotebookForSuggestions = async (nbId: string) => {
try {

View File

@@ -1,4 +1,5 @@
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useState, useEffect, useRef, useCallback } from 'react';
import { useDebounce } from './use-debounce';
import { TagSuggestion } from '@/lib/ai/types';
@@ -11,6 +12,7 @@ interface UseAutoTaggingProps {
export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoTaggingProps) {
const { language } = useLanguage();
const { hasAiConsent, requestAiConsent } = useAiConsent();
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -29,6 +31,14 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
return;
}
if (!hasAiConsent) {
const consented = await requestAiConsent();
if (!consented) {
setSuggestions([]);
return;
}
}
// Cancel previous request
abortRef.current?.abort();
const controller = new AbortController();
@@ -65,7 +75,7 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
setIsAnalyzing(false);
}
}
}, []);
}, [hasAiConsent, requestAiConsent]);
// Trigger on content change
useEffect(() => {

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { useDebounce } from './use-debounce'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
export interface TitleSuggestion {
title: string
@@ -13,6 +14,7 @@ interface UseTitleSuggestionsProps {
}
export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggestionsProps) {
const { requestAiConsent, hasAiConsent } = useAiConsent()
const [suggestions, setSuggestions] = useState<TitleSuggestion[]>([])
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -41,6 +43,14 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest
abortRef.current = controller
const generateTitles = async () => {
if (!hasAiConsent) {
const consented = await requestAiConsent()
if (!consented) {
setSuggestions([])
return
}
}
setIsAnalyzing(true)
setError(null)
@@ -73,7 +83,7 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest
}
generateTitles()
}, [debouncedContent, enabled])
}, [debouncedContent, enabled, hasAiConsent, requestAiConsent])
// Cleanup on unmount
useEffect(() => {

View File

@@ -0,0 +1,32 @@
/** Seuil Memory Echo (prod) — connexions sous ce score ne sont pas proposées. */
export const SEMANTIC_SIMILARITY_FLOOR = 0.75
export const SEMANTIC_SIMILARITY_FLOOR_DEMO = 0.5
/** Ratio 0 (seuil) → 1 (identique), pour étaler laffichage au-dessus du seuil. */
export function semanticProximityRatio(
similarity: number,
floor = SEMANTIC_SIMILARITY_FLOOR,
): number {
const clamped = Math.max(floor, Math.min(1, similarity))
if (floor >= 1) return 1
return (clamped - floor) / (1 - floor)
}
/** Pourcentage « proximité » affiché (0100), étalé entre le seuil et 100 %. */
export function semanticProximityPercent(
similarity: number,
floor = SEMANTIC_SIMILARITY_FLOOR,
): number {
return Math.round(semanticProximityRatio(similarity, floor) * 100)
}
/** Rayon orbite : plus la proximité est forte, plus le nœud est proche du centre. */
export function semanticOrbitRadius(
similarity: number,
floor = SEMANTIC_SIMILARITY_FLOOR,
): number {
const MIN_R = 34
const MAX_R = 94
const t = semanticProximityRatio(similarity, floor)
return MAX_R - t * (MAX_R - MIN_R)
}

View File

@@ -1,8 +1,11 @@
/**
* Chart Suggestion Service
* Frontend service for calling the AI chart suggestions API
* NOW WITH FAST PARSER - skips AI when possible (< 50ms vs 2 minutes)
*/
import { parseChartData, generateChartSuggestions } from '@/lib/chart/parser'
export interface ChartSuggestion {
type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'radar' | 'funnel' | 'gauge'
title: string
@@ -64,6 +67,7 @@ function setCached(key: string, data: SuggestChartsResponse): void {
/**
* Call the AI chart suggestions API
* FAST PATH: Try regex parser first (< 50ms), only call AI if no data found
* @param request - The request parameters
* @returns Promise with the chart suggestions
*/
@@ -78,7 +82,29 @@ export async function suggestCharts(request: SuggestChartsRequest): Promise<Sugg
return cached
}
console.log('[suggestCharts] CACHE MISS - calling API')
console.log('[suggestCharts] CACHE MISS - trying fast parser first')
// FAST PATH: Try regex parser first - NO AI call needed!
const textToParse = request.selection || request.content || ''
const parsed = parseChartData(textToParse)
if (parsed.hasData && parsed.confidence > 0.3) {
console.log('[suggestCharts] FAST PATH - regex parser found data, skipping AI!')
const suggestions = generateChartSuggestions(parsed.data)
const response: SuggestChartsResponse = {
suggestions,
analyzedText: textToParse.substring(0, 200),
detectedData: `${parsed.data.length} data points found`,
hasData: true,
}
// Cache the fast result
setCached(cacheKey, response)
return response
}
console.log('[suggestCharts] Parser found no good data, calling AI API (slow...)')
try {
const response = await fetch('/api/ai/suggest-charts', {

View File

@@ -96,6 +96,16 @@ export class MemoryEchoService {
* Find meaningful connections between user's notes
*/
async findConnections(userId: string, demoMode: boolean = false): Promise<NoteConnection[]> {
// GDPR AI Consent check — compliance skip if not granted (AC6)
const userSettings = await prisma.userAISettings.findUnique({
where: { userId },
select: { aiProcessingConsent: true },
})
if (!userSettings?.aiProcessingConsent) {
console.log(`[MemoryEchoService] User ${userId} has not given AI consent. Skipping connection generation for compliance.`)
return []
}
// Ensure all notes have embeddings before searching for connections
await this.ensureEmbeddings(userId)

View File

@@ -31,7 +31,8 @@ const RECIPES: Record<string, Recipe> = {
function resolveRecipe(name?: string): Recipe {
if (!name || name === 'auto') return RECIPES['architectural-saas']
const key = name.toLowerCase().replace(/[^a-z]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
// Normalize: underscores to dashes for consistency
const key = name.toLowerCase().replace(/[\s_]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
return RECIPES[key] ?? RECIPES['architectural-saas']
}
@@ -345,30 +346,45 @@ function renderRadarChart(data: { label: string; value: number }[], r: Recipe):
const cx = 150, cy = 150, radius = 110
const max = Math.max(...data.map(d => d.value), 1)
const angleStep = (2 * Math.PI) / n
// Choose grid color based on theme darkness
const gridColor = r.isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)'
const labelColor = r.isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'
// Grid
const gridLevels = [0.25, 0.5, 0.75, 1].map(f => {
const pts = Array.from({ length: n }, (_, i) => {
const a = i * angleStep - Math.PI / 2
return `${cx + Math.cos(a) * radius * f},${cy + Math.sin(a) * radius * f}`
}).join(' ')
return `<polygon points="${pts}" fill="none" stroke="${r.svgGrid}" stroke-width="1"/>`
return `<polygon points="${pts}" fill="none" stroke="${gridColor}" stroke-width="1"/>`
}).join('')
// Axis lines from center
const axisLines = Array.from({ length: n }, (_, i) => {
const a = i * angleStep - Math.PI / 2
return `<line x1="${cx}" y1="${cy}" x2="${cx + Math.cos(a) * radius}" y2="${cy + Math.sin(a) * radius}" stroke="${gridColor}" stroke-width="1"/>`
}).join('')
// Data polygon
const dataPts = data.map((d, i) => {
const a = i * angleStep - Math.PI / 2
const r2 = (d.value / max) * radius
return `${cx + Math.cos(a) * r2},${cy + Math.sin(a) * r2}`
}).join(' ')
// Labels
// Labels with better contrast
const labels = data.map((d, i) => {
const a = i * angleStep - Math.PI / 2
const lx = cx + Math.cos(a) * (radius + 20)
const ly = cy + Math.sin(a) * (radius + 20)
return `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
return `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="11" font-weight="600" fill="${labelColor}">${esc(d.label)}</text>`
}).join('')
return `<svg viewBox="0 0 300 300" style="width:100%;max-width:320px;height:auto;margin:0 auto;display:block;">
${axisLines}
${gridLevels}
<polygon points="${dataPts}" fill="${r.accent1}" fill-opacity="0.15" stroke="${r.accent1}" stroke-width="2"/>
<polygon points="${dataPts}" fill="${r.accent1}" fill-opacity="0.2" stroke="${r.accent1}" stroke-width="2.5"/>
${labels}
</svg>`
}

View File

@@ -43,21 +43,21 @@ export const PALETTE_ALIASES: Record<string, string> = {
premium: 'platinum_white_gold', clean: 'vibrant_tech', stage: 'stage_dark',
architectural: 'architectural_mono', silk: 'minimal_silk',
black: 'keynote', white: 'platinum_white_gold', nuit: 'galaxy', sombre: 'stage_dark',
// Recipe explicit theme mappings
architectural_saas: 'architectural_mono',
midnight_cathedral: 'keynote',
aurora_borealis: 'galaxy',
tokyo_neon: 'vibrant_tech',
sunlit_gallery: 'bohemian',
clinical_precision: 'modern_wellness',
venture_pitch: 'vibrant_orange_mint',
forest_floor: 'forest_eco',
steel_glass: 'luxury_mystery',
cyberpunk_terminal: 'tech_night',
editorial_ink: 'vintage_academic',
coastal_morning: 'coastal_coral',
paper_studio: 'craft_artisan',
// Recipe explicit theme mappings (both underscore and dash variants)
architectural_saas: 'architectural_mono', 'architectural-saas': 'architectural_mono',
midnight_cathedral: 'keynote', 'midnight-cathedral': 'keynote',
aurora_borealis: 'galaxy', 'aurora-borealis': 'galaxy',
tokyo_neon: 'vibrant_tech', 'tokyo-neon': 'vibrant_tech',
sunlit_gallery: 'bohemian', 'sunlit-gallery': 'bohemian',
clinical_precision: 'modern_wellness', 'clinical-precision': 'modern_wellness',
venture_pitch: 'vibrant_orange_mint', 'venture-pitch': 'vibrant_orange_mint',
forest_floor: 'forest_eco', 'forest-floor': 'forest_eco',
steel_glass: 'luxury_mystery', 'steel-glass': 'luxury_mystery',
cyberpunk_terminal: 'tech_night', 'cyberpunk-terminal': 'tech_night',
editorial_ink: 'vintage_academic', 'editorial-ink': 'vintage_academic',
coastal_morning: 'coastal_coral', 'coastal-morning': 'coastal_coral',
paper_studio: 'craft_artisan', 'paper-studio': 'craft_artisan',
}
export const THEME_NAMES: Record<string, string> = {
@@ -74,7 +74,8 @@ export const THEME_NAMES: Record<string, string> = {
}
export function resolvePalette(spec: Pick<PresentationSpec, 'theme'>): { palette: Palette; key: string } {
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_')
// Normalize theme name: handle both dashes and underscores
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')
const key = PALETTE_ALIASES[name] || (PALETTES[name] ? name : 'keynote')
return { palette: PALETTES[key]!, key }
}

View File

@@ -0,0 +1,73 @@
export interface ExtractedBlock {
blockId: string
content: string
}
export function extractBlocksFromHtml(html: string): ExtractedBlock[] {
const blocks: ExtractedBlock[] = []
const regex = /<(?:p|h[1-6]|blockquote|li)[^>]*data-id="([^"]+)"[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote|li)>/gi
let match
while ((match = regex.exec(html)) !== null) {
const blockId = match[1]
const content = match[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
if (content.length >= 10) {
blocks.push({ blockId, content })
}
}
return blocks
}
export 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)
}
function extractPlainBlocksFromHtml(html: string): ExtractedBlock[] {
const blocks: ExtractedBlock[] = []
const regex = /<(?:p|h[1-6]|blockquote|li|td|th|div)[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote|li|td|th|div)>/gi
let match
while ((match = regex.exec(html)) !== null) {
const content = match[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
if (content.length >= 10) {
blocks.push({ blockId: '', content })
}
}
return blocks
}
function pickBestFromBlocks(blocks: ExtractedBlock[], hint: string): ExtractedBlock | null {
if (blocks.length === 0) return null
if (!hint.trim()) return blocks[0]
let best = blocks[0]
let bestScore = jaccardSimilarity(hint, best.content)
for (const block of blocks.slice(1)) {
const score = jaccardSimilarity(hint, block.content)
if (score > bestScore) {
best = block
bestScore = score
}
}
return best
}
export function pickBestBlockForHint(html: string, hint: string): ExtractedBlock | null {
return pickBestFromBlocks(extractBlocksFromHtml(html), hint)
}
/** Fallback when notes have no data-id yet (citation statique, pas de bloc vivant). */
export function pickBestPlainPassageForHint(html: string, hint: string): ExtractedBlock | null {
return pickBestFromBlocks(extractPlainBlocksFromHtml(html), hint)
}

View File

@@ -0,0 +1,196 @@
/**
* Fast chart data parser - NO AI needed
* Extracts structured data from text using regex patterns
*/
export interface ChartDataPoint {
label: string
value: number
}
export interface ParsedChartData {
hasData: boolean
data: ChartDataPoint[]
suggestedType: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie'
confidence: number
}
/**
* Parse text for chart data using multiple regex patterns
* Runs in < 50ms - NO AI calls
*/
export function parseChartData(text: string): ParsedChartData {
const patterns = [
// Pattern 1: "Label : Value | Label2 : Value2"
{
regex: /([^|:|\n]+?)\s*[:|=]\s*(\d+(?:[.,]\d+)?)(?:\s*\||\n|,|$)/gi,
priority: 1,
},
// Pattern 2: "Label: Value, Label2: Value2"
{
regex: /([^,:;\n]+?)\s*[:=]\s*(\d+(?:[.,]\d+)?)(?:\s*,|;|\n|$)/gi,
priority: 2,
},
// Pattern 3: "Label - Value" or "Label — Value"
{
regex: /([^-—\n]+?)\s*[—-]\s*(\d+(?:[.,]\d+)?)(?:\s*,|\n|$)/gi,
priority: 3,
},
// Pattern 4: Lists with percentages "Label • 45%"
{
regex: /([^•%\n]+?)\s*[•·]\s*(\d+)(?:\s*%)?(?:\s*,|\n|$)/gi,
priority: 4,
},
]
const seen = new Set<string>()
let bestMatch: { data: ChartDataPoint[], score: number, type: ParsedChartData['suggestedType'] } | null = null
for (const pattern of patterns) {
const matches: ChartDataPoint[] = []
let match: RegExpExecArray | null
// Reset regex state
pattern.regex.lastIndex = 0
while ((match = pattern.regex.exec(text)) !== null) {
const label = match[1].trim()
const valueStr = match[2].trim().replace(',', '.')
const value = parseFloat(valueStr)
if (!isNaN(value) && value > 0 && label && !seen.has(label)) {
matches.push({ label, value })
seen.add(label)
}
}
if (matches.length >= 2) {
const score = matches.length * pattern.priority
if (!bestMatch || score > bestMatch.score) {
bestMatch = {
data: matches,
score,
type: suggestChartType(matches, text),
}
}
}
}
if (!bestMatch) {
return { hasData: false, data: [], suggestedType: 'bar', confidence: 0 }
}
return {
hasData: true,
data: bestMatch.data,
suggestedType: bestMatch.type,
confidence: Math.min(bestMatch.score / 10, 1),
}
}
/**
* Suggest chart type based on data and text context
*/
function suggestChartType(
data: ChartDataPoint[],
text: string
): ParsedChartData['suggestedType'] {
const textLower = text.toLowerCase()
// Time series indicators
const timeKeywords = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
'janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
'q1', 'q2', 'q3', 'q4', 'month', 'mois', 'week', 'semaine', 'year', 'année', 'trend', 'tendance', 'growth', 'croissance']
const hasTimeLabels = data.some(d => timeKeywords.some(k => d.label.toLowerCase().includes(k)))
const hasTimeInText = timeKeywords.some(k => textLower.includes(k))
// Proportion indicators
const proportionKeywords = ['%', 'percent', 'part', 'share', 'répartition', 'distribution', 'segment', 'total']
const hasProportion = proportionKeywords.some(k => textLower.includes(k))
// Check if values sum to ~100 (percentage data)
const sum = data.reduce((a, b) => a + b.value, 0)
const isPercentage = Math.abs(sum - 100) < 10 || data.some(d => d.value > 100)
// Decision tree
if (hasProportion || isPercentage) {
return data.length <= 5 ? 'pie' : 'bar'
}
if (hasTimeLabels || hasTimeInText) {
return 'line'
}
// Long labels favor horizontal
if (data.some(d => d.label.length > 15)) {
return 'horizontal-bar'
}
return 'bar'
}
/**
* Generate chart suggestions from parsed data
* NO AI - instant generation
*/
export function generateChartSuggestions(data: ChartDataPoint[]): Array<{
type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie'
title: string
data: ChartDataPoint[]
description: string
}> {
const suggestions: ReturnType<typeof generateChartSuggestions> = []
// Always include bar chart as fallback
suggestions.push({
type: 'bar',
title: 'Comparaison',
data,
description: 'Graphique à barres pour comparer les valeurs',
})
// Add line if we have enough data points
if (data.length >= 3) {
suggestions.push({
type: 'line',
title: 'Tendance',
data,
description: 'Graphique linéaire pour visualiser la progression',
})
}
// Add area for magnitude
if (data.length >= 3) {
suggestions.push({
type: 'area',
title: 'Évolution',
data,
description: 'Graphique en aires pour souligner le volume',
})
}
// Add pie if data looks proportional
const sum = data.reduce((a, b) => a + b.value, 0)
if (data.length >= 2 && data.length <= 6 && (sum > 90 || data.some(d => d.label.includes('%')))) {
suggestions.push({
type: 'pie',
title: 'Répartition',
data,
description: 'Graphique circulaire pour montrer les proportions',
})
}
// Add horizontal-bar for long labels
if (data.some(d => d.label.length > 12)) {
suggestions.push({
type: 'horizontal-bar',
title: 'Comparaison',
data,
description: 'Barres horizontales pour les labels longs',
})
}
// Return max 3 suggestions
return suggestions.slice(0, 3)
}

Some files were not shown because too many files have changed in this diff Show More