Files
Momento/memento-note/components/notebook-hierarchy-panel.tsx
Antigravity e2672cd2c2
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped
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>
2026-05-24 14:27:29 +00:00

243 lines
8.4 KiB
TypeScript

'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>
)
}