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

@@ -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 && (
<>