All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup - Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders - Fix all SQL embedding queries: add ::vector cast on text columns - Fix embedding truncation to 15000 chars (under 8192 token limit) - Fix NoteEmbedding INSERT: remove non-existent updatedAt column - Fix billing page: show all quota stats in grid instead of single metric - Fix usage meter: accordion expand/collapse, per-feature detail - Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch - Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
235 lines
9.0 KiB
TypeScript
235 lines
9.0 KiB
TypeScript
'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 { createPortal } from 'react-dom'
|
|
|
|
interface HierarchicalNotebookSelectorProps {
|
|
notebooks: { id: string; name: string; icon?: string | null; parentId?: string | null; trashedAt?: Date | string | null }[]
|
|
selectedId: string | null
|
|
onSelect: (id: string) => void
|
|
className?: string
|
|
placeholder?: string
|
|
dropUp?: boolean
|
|
size?: 'default' | 'sm'
|
|
}
|
|
|
|
export function HierarchicalNotebookSelector({
|
|
notebooks,
|
|
selectedId,
|
|
onSelect,
|
|
className = '',
|
|
placeholder = 'Select a notebook...',
|
|
dropUp = false,
|
|
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 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,
|
|
}
|
|
}, [dropUp])
|
|
|
|
const selectedNotebook = notebooks.find(nb => nb.id === selectedId)
|
|
|
|
const path = useMemo(() => {
|
|
if (!selectedNotebook) return []
|
|
const trail: typeof notebooks = []
|
|
let current: typeof notebooks[0] | undefined = selectedNotebook
|
|
while (current) {
|
|
trail.unshift(current)
|
|
if (!current.parentId) break
|
|
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)}
|
|
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" />
|
|
<div className="flex-1 flex items-center gap-1 min-w-0">
|
|
{path.length > 0 ? (
|
|
<div className="flex items-center gap-1.5 truncate">
|
|
{path.map((item, i) => (
|
|
<React.Fragment key={item.id}>
|
|
{i > 0 && <span className="text-concrete/40 text-[10px]">/</span>}
|
|
<span className={`truncate ${i === path.length - 1 ? 'font-bold text-ink' : 'text-concrete'}`}>
|
|
{item.name}
|
|
</span>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span className="text-concrete italic">{placeholder}</span>
|
|
)}
|
|
</div>
|
|
<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
|
|
)}
|
|
</div>
|
|
)
|
|
}
|