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

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

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

View File

@@ -1,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>
)
}