feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
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:
196
memento-note/components/block-picker.tsx
Normal file
196
memento-note/components/block-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user