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:
242
memento-note/components/notebook-hierarchy-panel.tsx
Normal file
242
memento-note/components/notebook-hierarchy-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user