All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 4s
Covers architecture, configuration steps, user flows, API routes, webhooks, pricing, testing with Stripe CLI, production checklist, and troubleshooting.
1264 lines
51 KiB
TypeScript
1264 lines
51 KiB
TypeScript
'use client'
|
|
|
|
import Link from 'next/link'
|
|
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
Settings,
|
|
Plus,
|
|
ChevronRight,
|
|
Lock,
|
|
BookOpen,
|
|
Bot,
|
|
Inbox,
|
|
FlaskConical,
|
|
ArrowUpDown,
|
|
Archive,
|
|
Trash2,
|
|
User,
|
|
LogOut,
|
|
Shield,
|
|
GripVertical,
|
|
Users,
|
|
Bell,
|
|
Pencil,
|
|
Clock,
|
|
Moon,
|
|
Sun,
|
|
Pin,
|
|
PinOff,
|
|
Sparkles,
|
|
Home,
|
|
} from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { applyDocumentTheme } from '@/lib/apply-document-theme'
|
|
import { getAllNotes, getTrashCount } from '@/app/actions/notes'
|
|
import { useNotebooks } from '@/context/notebooks-context'
|
|
import { Notebook, Note } from '@/lib/types'
|
|
import { toast } from 'sonner'
|
|
import { motion, AnimatePresence } from 'motion/react'
|
|
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
|
import { CreateNotebookDialog } from './create-notebook-dialog'
|
|
import { NotificationPanel } from './notification-panel'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { signOut } from 'next-auth/react'
|
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
|
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
|
import { UsageMeter } from './usage-meter'
|
|
|
|
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms'
|
|
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
|
|
|
function NoteLink({
|
|
title,
|
|
isActive,
|
|
onClick,
|
|
}: {
|
|
title: string
|
|
isActive: boolean
|
|
onClick: () => void
|
|
}) {
|
|
const { language } = useLanguage()
|
|
const slideX = language === 'fa' || language === 'ar' ? 10 : -10
|
|
return (
|
|
<motion.button
|
|
initial={{ opacity: 0, x: slideX }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
onClick={onClick}
|
|
className={cn(
|
|
'w-full flex items-center gap-2 ps-12 pe-4 py-2 text-[12px] transition-colors rounded-lg text-start',
|
|
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'w-1.5 h-1.5 rounded-full shrink-0',
|
|
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30'
|
|
)} />
|
|
<span className="break-words line-clamp-2 leading-tight">{title}</span>
|
|
</motion.button>
|
|
)
|
|
}
|
|
|
|
function SidebarBrainstorms() {
|
|
const { data: sessions, isLoading } = useBrainstormSessions()
|
|
const deleteBrainstorm = useDeleteBrainstorm()
|
|
const router = useRouter()
|
|
const { t } = useLanguage()
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="px-4 space-y-2">
|
|
{[1, 2, 3].map(i => (
|
|
<div key={i} className="h-12 rounded-xl bg-paper/50 animate-pulse" />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!sessions || sessions.length === 0) {
|
|
return (
|
|
<div className="px-4 py-6 text-center">
|
|
<Sparkles size={20} className="mx-auto text-brand-accent/40 mb-2" />
|
|
<p className="text-[11px] text-concrete">{t('brainstorm.noSessions')}</p>
|
|
<button
|
|
onClick={() => router.push('/brainstorm')}
|
|
className="mt-2 text-[11px] text-brand-accent hover:text-brand-accent/80 font-medium"
|
|
>
|
|
{t('brainstorm.startOne')} →
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-0.5">
|
|
{sessions.slice(0, 10).map(s => (
|
|
<div key={s.id} className="relative group/item">
|
|
<button
|
|
onClick={() => router.replace(`/brainstorm?session=${s.id}`)}
|
|
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all duration-200 text-start hover:bg-brand-accent/5 group ${
|
|
(s as any)._owned === false ? 'border-s-2 border-brand-accent/30 dark:border-brand-accent/70' : ''
|
|
}`}
|
|
>
|
|
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 ${
|
|
(s as any)._owned === false
|
|
? 'border border-brand-accent/20 dark:border-brand-accent/80 bg-brand-accent/5 dark:bg-brand-accent/20'
|
|
: 'border border-brand-accent/20 dark:border-brand-accent/40 bg-brand-accent/5 dark:bg-brand-accent/20'
|
|
}`}>
|
|
<Sparkles size={12} className="text-brand-accent" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[12px] font-medium truncate">
|
|
{s.seedIdea}
|
|
{(s as any)._owned === false && (
|
|
<span className="text-[9px] ms-1.5 text-brand-accent/70 font-normal">{t('sidebar.sharedNotebookBadge')}</span>
|
|
)}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{s.activeIdeas} {t('brainstorm.ideas')} · {new Date(s.createdAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
{(s as any)._owned !== false && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
deleteBrainstorm.mutate(s.id)
|
|
}}
|
|
className="absolute end-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg opacity-0 group-hover/item:opacity-100 hover:bg-rose-500/10 text-muted-foreground hover:text-rose-500 transition-all"
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SidebarCarnetItem({
|
|
carnet,
|
|
isActive,
|
|
notes,
|
|
activeNoteId,
|
|
onCarnetClick,
|
|
onNoteClick,
|
|
onAddSubNotebook,
|
|
onRename,
|
|
onDelete,
|
|
onTogglePin,
|
|
isPinned,
|
|
children,
|
|
isDragging,
|
|
dragHandleProps,
|
|
level,
|
|
isExpanded,
|
|
toggleExpand,
|
|
hasChildNotebooks,
|
|
}: {
|
|
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
|
isActive: boolean
|
|
notes: { id: string; title: string }[]
|
|
activeNoteId: string | null
|
|
onCarnetClick: () => void
|
|
onNoteClick: (noteId: string, carnetId: string) => void
|
|
onAddSubNotebook: () => void
|
|
onRename: () => void
|
|
onDelete: () => void
|
|
onTogglePin: () => void
|
|
isPinned: boolean
|
|
children?: React.ReactNode
|
|
isDragging?: boolean
|
|
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
|
|
level: number
|
|
isExpanded: boolean
|
|
toggleExpand: () => void
|
|
hasChildNotebooks?: boolean
|
|
}) {
|
|
const { t, language } = useLanguage()
|
|
const isRtl = language === 'fa' || language === 'ar'
|
|
const hasChildren = hasChildNotebooks || React.Children.count(children) > 0
|
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
|
|
|
// Close context menu on outside click
|
|
useEffect(() => {
|
|
if (!contextMenu) return
|
|
const handler = () => setContextMenu(null)
|
|
window.addEventListener('click', handler)
|
|
return () => window.removeEventListener('click', handler)
|
|
}, [contextMenu])
|
|
|
|
return (
|
|
<div className={cn('transition-opacity', isDragging && 'opacity-40')}>
|
|
<div
|
|
className="flex items-center group relative h-10"
|
|
style={{ paddingInlineStart: `${level * 24 + 8}px` }}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setContextMenu({ x: e.clientX, y: e.clientY })
|
|
}}
|
|
>
|
|
{level > 0 && (
|
|
<div className="absolute start-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
|
|
)}
|
|
{level > 0 && (
|
|
<div className="absolute start-[8px] top-1/2 w-[8px] h-px bg-border/40" />
|
|
)}
|
|
|
|
<div
|
|
{...dragHandleProps}
|
|
className="absolute start-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
|
>
|
|
<GripVertical size={12} />
|
|
</div>
|
|
|
|
<div className="flex-1 flex items-center gap-1">
|
|
{hasChildren ? (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleExpand() }}
|
|
className="p-1 hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
|
|
>
|
|
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
|
<ChevronRight size={14} className="rtl:scale-x-[-1]" />
|
|
</motion.div>
|
|
</button>
|
|
) : (
|
|
<div className="w-6" />
|
|
)}
|
|
|
|
<motion.div
|
|
whileHover={{ x: isRtl ? -2 : 2 }}
|
|
onClick={onCarnetClick}
|
|
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
|
|
className={cn(
|
|
'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative',
|
|
isActive ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' : 'hover:bg-white/40 dark:hover:bg-white/5'
|
|
)}
|
|
>
|
|
{isActive && (
|
|
<motion.div
|
|
layoutId="active-indicator"
|
|
className="absolute -start-1 w-1 h-4 bg-brand-accent rounded-full"
|
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
/>
|
|
)}
|
|
<div className={cn(
|
|
'w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border shrink-0 transition-all',
|
|
isActive
|
|
? 'bg-brand-accent text-white border-brand-accent'
|
|
: 'bg-white/60 dark:bg-white/5 text-ink dark:text-foreground border-border'
|
|
)}>
|
|
{carnet.initial}
|
|
</div>
|
|
|
|
<div className="flex-1 text-start flex items-center gap-2 min-w-0">
|
|
<span className={cn(
|
|
'text-[12px] font-medium transition-colors truncate',
|
|
isActive ? 'text-ink' : 'text-muted-ink group-hover/item:text-ink'
|
|
)}>
|
|
{carnet.name}
|
|
</span>
|
|
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
|
|
{isPinned && (
|
|
<span className="text-brand-accent" title={t('notebook.pinnedFrozenTooltip')}>
|
|
<Pin size={9} className="opacity-70" />
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onAddSubNotebook() }}
|
|
className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink"
|
|
title={t('notebook.createSubNotebook')}
|
|
>
|
|
<Plus size={10} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onRename() }}
|
|
className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink"
|
|
title={t('notebook.rename')}
|
|
>
|
|
<Pencil size={10} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
|
className="p-1 hover:bg-rose-50 rounded-md transition-all text-concrete hover:text-rose-500"
|
|
title={t('notebook.delete')}
|
|
>
|
|
<Trash2 size={10} />
|
|
</button>
|
|
|
|
{notes.length > 0 && (
|
|
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover/item:text-concrete transition-colors">
|
|
{notes.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right-click context menu */}
|
|
<AnimatePresence>
|
|
{contextMenu && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
transition={{ duration: 0.1 }}
|
|
style={{ position: 'fixed', top: contextMenu.y, left: contextMenu.x, zIndex: 9999 }}
|
|
className="bg-card border border-border rounded-xl shadow-xl py-1.5 min-w-[180px] overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
onClick={() => { onTogglePin(); setContextMenu(null) }}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
|
|
>
|
|
{isPinned
|
|
? <><PinOff size={13} className="text-brand-accent" /><span>{t('sidebar.unfreezePinnedNotebook')}</span></>
|
|
: <><Pin size={13} className="text-brand-accent" /><span>{t('sidebar.freezePinnedNotebook')}</span></>
|
|
}
|
|
</button>
|
|
<div className="mx-3 my-1 border-t border-border/50" />
|
|
<button
|
|
onClick={() => { onAddSubNotebook(); setContextMenu(null) }}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
|
|
>
|
|
<Plus size={13} className="text-concrete" />
|
|
<span>{t('sidebar.newSubNotebook')}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => { onRename(); setContextMenu(null) }}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
|
|
>
|
|
<Pencil size={13} className="text-concrete" />
|
|
<span>{t('sidebar.renameNotebook')}</span>
|
|
</button>
|
|
<div className="mx-3 my-1 border-t border-border/50" />
|
|
<button
|
|
onClick={() => { onDelete(); setContextMenu(null) }}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30 transition-colors"
|
|
>
|
|
<Trash2 size={13} />
|
|
<span>{t('common.delete')}</span>
|
|
</button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<AnimatePresence initial={false}>
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="relative" style={{ marginInlineStart: `${(level + 1) * 16 + 10}px` }}>
|
|
<div className="absolute start-[-6px] top-0 bottom-4 w-px bg-border/30" />
|
|
|
|
<div className="space-y-0.5 py-1">
|
|
{children}
|
|
{isActive && notes.map(note => (
|
|
<NoteLink
|
|
key={note.id}
|
|
title={note.title}
|
|
isActive={activeNoteId === note.id}
|
|
onClick={() => onNoteClick(note.id, carnet.id)}
|
|
/>
|
|
))}
|
|
{isActive && notes.length === 0 && !hasChildren && (
|
|
<p className="ps-8 py-2 text-[10px] italic text-muted-foreground/40 font-light">
|
|
{t('common.noResults')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Sidebar({ className, user }: { className?: string; user?: any }) {
|
|
const pathname = usePathname()
|
|
const searchParams = useSearchParams()
|
|
const router = useRouter()
|
|
const { t, language } = useLanguage()
|
|
const isRtl = language === 'fa' || language === 'ar'
|
|
const { notebooks, trashNotebook, updateNotebookOrderOptimistic, moveNotebookToParent } = useNotebooks()
|
|
const { refreshKey } = useNoteRefresh()
|
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
|
const [createParentId, setCreateParentId] = useState<string | null>(null)
|
|
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
|
|
const [renameValue, setRenameValue] = useState('')
|
|
const [isDark, setIsDark] = useState(false)
|
|
|
|
useEffect(() => {
|
|
setIsDark(document.documentElement.classList.contains('dark'))
|
|
}, [])
|
|
|
|
const toggleTheme = useCallback(() => {
|
|
const next = !isDark
|
|
setIsDark(next)
|
|
const theme = next ? 'dark' : 'light'
|
|
localStorage.setItem('theme-preference', theme)
|
|
applyDocumentTheme(theme)
|
|
}, [isDark])
|
|
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
const [isRenaming, setIsRenaming] = useState(false)
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
|
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
|
|
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string }[]>>({})
|
|
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
|
const [showSortMenu, setShowSortMenu] = useState(false)
|
|
const [trashCount, setTrashCount] = useState(0)
|
|
|
|
const [draggedId, setDraggedId] = useState<string | null>(null)
|
|
const [orderedNotebooks, setOrderedNotebooks] = useState<Notebook[]>([])
|
|
const dragOverId = useRef<string | null>(null)
|
|
const isSavingRef = useRef(false)
|
|
|
|
const rootNotebooks = useMemo(() => orderedNotebooks.filter(nb => !nb.parentId), [orderedNotebooks])
|
|
const childNotebooks = useMemo(() => {
|
|
const map = new Map<string, Notebook[]>()
|
|
for (const nb of orderedNotebooks) {
|
|
if (nb.parentId) {
|
|
const children = map.get(nb.parentId) || []
|
|
children.push(nb)
|
|
map.set(nb.parentId, children)
|
|
}
|
|
}
|
|
return map
|
|
}, [orderedNotebooks])
|
|
|
|
const currentNotebookId = searchParams.get('notebook')
|
|
const currentNoteId = searchParams.get('openNote')
|
|
|
|
const isInboxActive =
|
|
pathname === '/home' &&
|
|
!searchParams.get('notebook') &&
|
|
!searchParams.get('labels') &&
|
|
!searchParams.get('archived') &&
|
|
!searchParams.get('trashed')
|
|
|
|
useEffect(() => {
|
|
if (pathname.startsWith('/brainstorm')) setActiveView('brainstorms')
|
|
else if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) setActiveView('agents')
|
|
else setActiveView('notebooks')
|
|
}, [pathname])
|
|
|
|
const displayName = user?.name || user?.email || ''
|
|
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
|
|
|
|
// Sorted list for the sort dropdown (not used directly when dragging)
|
|
const sortedNotebooks = useMemo(() => {
|
|
const arr = [...notebooks]
|
|
if (sortOrder === 'manual') return arr.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
if (sortOrder === 'alpha') return arr.sort((a, b) => a.name.localeCompare(b.name))
|
|
if (sortOrder === 'newest') return arr.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
|
if (sortOrder === 'oldest') return arr.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
|
return arr
|
|
}, [notebooks, sortOrder])
|
|
|
|
// Sync orderedNotebooks from server ONLY when not in the middle of a drag save
|
|
useEffect(() => {
|
|
if (isSavingRef.current) return
|
|
setOrderedNotebooks(sortedNotebooks)
|
|
}, [sortedNotebooks])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
getTrashCount().then(count => { if (!cancelled) setTrashCount(count) })
|
|
return () => { cancelled = true }
|
|
}, [refreshKey])
|
|
|
|
const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks])
|
|
|
|
useEffect(() => {
|
|
if (!notebookIdsKey) return
|
|
let cancelled = false
|
|
const load = async () => {
|
|
const mappedEntries = await Promise.all(
|
|
notebooks.map(async (nb: Notebook) => {
|
|
const notes = await getAllNotes(false, nb.id)
|
|
const mapped = notes.map((n: Note) => ({
|
|
id: n.id,
|
|
title: getNoteDisplayTitle(n, t('notes.untitled')),
|
|
}))
|
|
return [nb.id, mapped] as const
|
|
})
|
|
)
|
|
if (cancelled) return
|
|
setNotebookNotes(Object.fromEntries(mappedEntries))
|
|
}
|
|
load()
|
|
return () => { cancelled = true }
|
|
// refreshKey: reload note titles whenever any note is saved/created/deleted
|
|
}, [notebookIdsKey, refreshKey, t])
|
|
|
|
const handleCarnetClick = (notebookId: string) => {
|
|
const params = new URLSearchParams()
|
|
params.set('notebook', notebookId)
|
|
params.set('forceList', '1')
|
|
router.push(`/home?${params.toString()}`)
|
|
}
|
|
|
|
const handleInboxClick = () => {
|
|
router.push('/home?forceList=1')
|
|
}
|
|
|
|
const handleNoteClick = (noteId: string, notebookId: string) => {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
params.set('notebook', notebookId)
|
|
params.set('openNote', noteId)
|
|
params.delete('forceList')
|
|
router.push(`/home?${params.toString()}`)
|
|
}
|
|
|
|
// ── Drag state ──
|
|
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
|
const [dropAction, setDropAction] = useState<'into' | null>(null)
|
|
|
|
const handleDragStart = (e: React.DragEvent, notebookId: string) => {
|
|
setDraggedId(notebookId)
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
e.dataTransfer.setData('text/plain', notebookId)
|
|
}
|
|
|
|
const handleDragEnd = () => {
|
|
setDraggedId(null)
|
|
dragOverId.current = null
|
|
isSavingRef.current = false
|
|
setDropTarget(null)
|
|
setDropAction(null)
|
|
setOrderedNotebooks(sortedNotebooks)
|
|
}
|
|
|
|
const handleDropOnNotebook = async (e: React.DragEvent, targetId: string) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setDropTarget(null)
|
|
setDropAction(null)
|
|
const dragId = draggedId || e.dataTransfer.getData('text/plain')
|
|
if (!dragId || dragId === targetId) {
|
|
setDraggedId(null)
|
|
return
|
|
}
|
|
setDraggedId(null)
|
|
dragOverId.current = null
|
|
try {
|
|
await moveNotebookToParent(dragId, targetId)
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
|
|
setOrderedNotebooks(sortedNotebooks)
|
|
}
|
|
}
|
|
|
|
const handleDropToRoot = async (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setDropTarget(null)
|
|
setDropAction(null)
|
|
const dragId = draggedId || e.dataTransfer.getData('text/plain')
|
|
if (!dragId) {
|
|
setDraggedId(null)
|
|
return
|
|
}
|
|
setDraggedId(null)
|
|
dragOverId.current = null
|
|
try {
|
|
await moveNotebookToParent(dragId, null)
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
|
|
setOrderedNotebooks(sortedNotebooks)
|
|
}
|
|
}
|
|
|
|
const sortLabels: Record<SortOrder, string> = {
|
|
newest: t('sidebar.sortNewest'),
|
|
oldest: t('sidebar.sortOldest'),
|
|
alpha: t('sidebar.sortAlpha'),
|
|
manual: t('sidebar.sortManual'),
|
|
}
|
|
|
|
const toggleExpand = useCallback((id: string) => {
|
|
setExpandedIds(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
// Load pinned notebooks from localStorage on mount
|
|
useEffect(() => {
|
|
try {
|
|
const stored = localStorage.getItem('momento-pinned-notebooks')
|
|
if (stored) setPinnedIds(new Set(JSON.parse(stored)))
|
|
} catch {}
|
|
}, [])
|
|
|
|
// Auto-expand all parent notebooks on first load
|
|
useEffect(() => {
|
|
if (orderedNotebooks.length === 0) return
|
|
const parentIds = new Set<string>()
|
|
for (const nb of orderedNotebooks) {
|
|
if (nb.parentId) parentIds.add(nb.parentId)
|
|
}
|
|
if (parentIds.size > 0) {
|
|
setExpandedIds(prev => {
|
|
const next = new Set(prev)
|
|
for (const id of parentIds) next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
}, [orderedNotebooks])
|
|
|
|
const togglePin = useCallback((id: string) => {
|
|
setPinnedIds(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) {
|
|
next.delete(id)
|
|
// Also collapse the notebook when unpinning
|
|
setExpandedIds(e => { const ne = new Set(e); ne.delete(id); return ne })
|
|
} else {
|
|
next.add(id)
|
|
// Ensure it's also expanded when pinned
|
|
setExpandedIds(e => { const ne = new Set(e); ne.add(id); return ne })
|
|
}
|
|
try { localStorage.setItem('momento-pinned-notebooks', JSON.stringify([...next])) } catch {}
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const handleStartRename = useCallback((notebook: Notebook) => {
|
|
setRenamingNotebook(notebook)
|
|
setRenameValue(notebook.name)
|
|
}, [])
|
|
|
|
const handleConfirmRename = useCallback(async () => {
|
|
if (!renamingNotebook || !renameValue.trim()) return
|
|
setIsRenaming(true)
|
|
try {
|
|
const res = await fetch(`/api/notebooks/${renamingNotebook.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: renameValue.trim() }),
|
|
})
|
|
if (!res.ok) throw new Error('Rename failed')
|
|
setRenamingNotebook(null)
|
|
setRenameValue('')
|
|
router.refresh()
|
|
} catch (err) {
|
|
console.error('Rename failed:', err)
|
|
} finally {
|
|
setIsRenaming(false)
|
|
}
|
|
}, [renamingNotebook, renameValue, router])
|
|
|
|
const getDescendantIds = useCallback((notebookId: string): string[] => {
|
|
const ids: string[] = []
|
|
const children = childNotebooks.get(notebookId) || []
|
|
for (const child of children) {
|
|
ids.push(child.id)
|
|
ids.push(...getDescendantIds(child.id))
|
|
}
|
|
return ids
|
|
}, [childNotebooks])
|
|
|
|
const handleConfirmDelete = useCallback(async () => {
|
|
if (!deletingNotebook) return
|
|
setIsDeleting(true)
|
|
try {
|
|
await trashNotebook(deletingNotebook.id)
|
|
setDeletingNotebook(null)
|
|
if (currentNotebookId === deletingNotebook.id) {
|
|
router.push('/home')
|
|
}
|
|
} catch (err) {
|
|
console.error('Trash failed:', err)
|
|
} finally {
|
|
setIsDeleting(false)
|
|
}
|
|
}, [deletingNotebook, trashNotebook, currentNotebookId, router])
|
|
|
|
const renderCarnetTree = useCallback((parentId: string | undefined, level: number): React.ReactNode => {
|
|
const items = parentId === undefined
|
|
? rootNotebooks
|
|
: (childNotebooks.get(parentId) || [])
|
|
|
|
return items.map((notebook: Notebook) => {
|
|
const isActive = currentNotebookId === notebook.id
|
|
const notes = notebookNotes[notebook.id] || []
|
|
const isDragging = draggedId === notebook.id
|
|
const children = childNotebooks.get(notebook.id) || []
|
|
const hasDescendant = (nid: string): boolean => {
|
|
const desc = childNotebooks.get(nid) || []
|
|
return desc.some(c => currentNotebookId === c.id || hasDescendant(c.id))
|
|
}
|
|
const hasActiveDescendant = hasDescendant(notebook.id)
|
|
// A notebook stays expanded if: manually expanded, has active descendant, OR is pinned
|
|
const isExpanded = expandedIds.has(notebook.id) || hasActiveDescendant || pinnedIds.has(notebook.id)
|
|
|
|
return (
|
|
<motion.div key={notebook.id}>
|
|
<div
|
|
draggable
|
|
onDragStart={(e) => {
|
|
e.stopPropagation()
|
|
handleDragStart(e, notebook.id)
|
|
}}
|
|
onDragEnd={handleDragEnd}
|
|
onDragOver={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (!draggedId || draggedId === notebook.id) return
|
|
e.dataTransfer.dropEffect = 'move'
|
|
setDropTarget(notebook.id)
|
|
setDropAction('into')
|
|
}}
|
|
onDragLeave={(e) => {
|
|
if (e.currentTarget.contains(e.relatedTarget as Node)) return
|
|
if (dropTarget === notebook.id) {
|
|
setDropTarget(null)
|
|
setDropAction(null)
|
|
}
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
handleDropOnNotebook(e, notebook.id)
|
|
}}
|
|
className={cn(
|
|
'rounded-lg transition-colors',
|
|
dropTarget === notebook.id && dropAction === 'into' && draggedId && draggedId !== notebook.id
|
|
&& 'bg-brand-accent/10 ring-1 ring-brand-accent/30',
|
|
isDragging && 'opacity-50'
|
|
)}
|
|
>
|
|
<SidebarCarnetItem
|
|
carnet={{
|
|
id: notebook.id,
|
|
name: notebook.name,
|
|
initial: notebook.name.charAt(0).toUpperCase(),
|
|
isPrivate: notebook.isPrivate,
|
|
}}
|
|
isActive={isActive}
|
|
notes={notes}
|
|
activeNoteId={currentNoteId}
|
|
onCarnetClick={() => {
|
|
if (currentNotebookId === notebook.id) {
|
|
toggleExpand(notebook.id)
|
|
} else {
|
|
handleCarnetClick(notebook.id)
|
|
if (!expandedIds.has(notebook.id)) toggleExpand(notebook.id)
|
|
}
|
|
}}
|
|
onNoteClick={handleNoteClick}
|
|
onAddSubNotebook={() => {
|
|
setCreateParentId(notebook.id)
|
|
setIsCreateDialogOpen(true)
|
|
if (!expandedIds.has(notebook.id)) toggleExpand(notebook.id)
|
|
}}
|
|
onRename={() => handleStartRename(notebook)}
|
|
onDelete={() => setDeletingNotebook(notebook)}
|
|
onTogglePin={() => togglePin(notebook.id)}
|
|
isPinned={pinnedIds.has(notebook.id)}
|
|
isDragging={isDragging}
|
|
level={level}
|
|
isExpanded={isExpanded}
|
|
toggleExpand={() => toggleExpand(notebook.id)}
|
|
hasChildNotebooks={children.length > 0}
|
|
/>
|
|
</div>
|
|
{isExpanded && renderCarnetTree(notebook.id, level + 1)}
|
|
</motion.div>
|
|
)
|
|
})
|
|
}, [rootNotebooks, childNotebooks, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
|
|
|
|
return (
|
|
<>
|
|
<aside
|
|
className={cn(
|
|
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex',
|
|
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
|
|
className
|
|
)}
|
|
>
|
|
{/* ── Top: Logo + Icons + View Toggle ── */}
|
|
<div className="p-6 mb-8 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<div className="flex items-center gap-2 group/logo cursor-pointer">
|
|
<div className="w-10 h-10 bg-brand-accent flex items-center justify-center rounded-xl shadow-lg shadow-brand-accent/10 rotate-3 group-hover/logo:rotate-0 transition-all duration-500">
|
|
<span className="text-white font-serif text-xl font-bold">M</span>
|
|
</div>
|
|
<span className="text-lg font-serif font-bold tracking-tight text-ink dark:text-paper">Memento</span>
|
|
</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-52 bg-popover border-border">
|
|
<DropdownMenuItem asChild>
|
|
<Link href="/settings/profile" className="flex items-center gap-2 cursor-pointer">
|
|
<User className="h-4 w-4" />
|
|
{t('sidebar.profile')}
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
{(user as { role?: string } | undefined)?.role === 'ADMIN' && (
|
|
<DropdownMenuItem asChild>
|
|
<a href="/admin" className="flex items-center gap-2 cursor-pointer">
|
|
<Shield className="h-4 w-4" />
|
|
{t('nav.adminDashboard')}
|
|
</a>
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="text-destructive focus:text-destructive"
|
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
|
>
|
|
<LogOut className="h-4 w-4 me-2" />
|
|
{t('sidebar.signOut')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
<Link
|
|
href="/settings"
|
|
className={cn(
|
|
'p-1.5 transition-all rounded-lg border flex items-center justify-center',
|
|
pathname.startsWith('/settings')
|
|
? 'bg-brand-accent text-white border-brand-accent shadow-lg shadow-brand-accent/20'
|
|
: 'text-muted-ink hover:text-ink hover:bg-white/50 dark:hover:bg-white/10 border-transparent hover:border-border'
|
|
)}
|
|
title={t('nav.settings')}
|
|
>
|
|
<Settings size={14} />
|
|
</Link>
|
|
<button
|
|
onClick={() => { router.push('/home') }}
|
|
className="p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
|
title={t('nav.home')}
|
|
>
|
|
<Home size={14} />
|
|
</button>
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
|
>
|
|
{isDark ? <Sun size={14} /> : <Moon size={14} />}
|
|
</button>
|
|
<NotificationPanel />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex bg-white/50 dark:bg-white/10 p-1 rounded-xl border border-border dark:border-white/10">
|
|
<button
|
|
onClick={() => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }}
|
|
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'notebooks' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
|
title={t('nav.notebooks')}
|
|
>
|
|
<BookOpen size={14} />
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveView('reminders')}
|
|
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'reminders' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
|
title={t('sidebar.reminders')}
|
|
>
|
|
<Clock size={14} />
|
|
</button>
|
|
<button
|
|
onClick={() => { setActiveView('agents'); router.push('/agents') }}
|
|
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'agents' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
|
title={t('nav.agents')}
|
|
>
|
|
<Bot size={14} />
|
|
</button>
|
|
<button
|
|
onClick={() => { setActiveView('brainstorms'); router.push('/brainstorm') }}
|
|
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'brainstorms' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
|
title={t('brainstorm.sessions')}
|
|
>
|
|
<Sparkles size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Scrollable content ── */}
|
|
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar pb-4">
|
|
|
|
<AnimatePresence mode="wait">
|
|
{activeView === 'notebooks' ? (
|
|
<motion.div
|
|
key="notebooks"
|
|
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{/* Section header with sort button */}
|
|
<div className="flex items-center justify-between px-4 mb-3">
|
|
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">
|
|
{t('nav.notebooks')}
|
|
</p>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => { setCreateParentId(null); setIsCreateDialogOpen(true) }}
|
|
className="p-1 text-muted-foreground hover:text-foreground hover:bg-white/40 transition-all rounded"
|
|
title={t('notebook.create')}
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowSortMenu(s => !s)}
|
|
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
|
title={t('sidebar.sortOrder')}
|
|
>
|
|
<ArrowUpDown size={12} />
|
|
</button>
|
|
<AnimatePresence>
|
|
{showSortMenu && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9, y: -4 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9, y: -4 }}
|
|
className="absolute end-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
|
|
>
|
|
{(['newest', 'oldest', 'alpha', 'manual'] as SortOrder[]).map(order => (
|
|
<button
|
|
key={order}
|
|
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
|
|
className={cn(
|
|
'w-full text-start px-4 py-2 text-[12px] transition-colors',
|
|
sortOrder === order
|
|
? 'font-bold text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
|
)}
|
|
>
|
|
{sortLabels[order]}
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Inbox — Notes without notebook */}
|
|
<button
|
|
onClick={handleInboxClick}
|
|
className={cn('sidebar-inbox-item', isInboxActive && 'active')}
|
|
>
|
|
<div className={cn(
|
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
|
isInboxActive
|
|
? 'bg-brand-accent text-white border-brand-accent'
|
|
: 'bg-paper dark:bg-white/5 text-muted-ink border-border group-hover:border-brand-accent/20'
|
|
)}>
|
|
<Inbox size={14} />
|
|
</div>
|
|
<span className={cn(
|
|
'text-[13px] font-medium truncate',
|
|
isInboxActive ? 'text-ink' : 'text-muted-ink'
|
|
)}>
|
|
{t('sidebar.inbox')}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Divider */}
|
|
<div className="mx-4 my-3 h-px bg-border/40" />
|
|
|
|
{/* Notebooks list — draggable */}
|
|
<div
|
|
className="space-y-0.5 min-h-[60px]"
|
|
onDrop={handleDropToRoot}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
>
|
|
{renderCarnetTree(undefined, 0)}
|
|
{draggedId && (
|
|
<div
|
|
className="h-10 rounded-lg border-2 border-dashed border-brand-accent/20 flex items-center justify-center text-[11px] text-brand-accent/50"
|
|
>
|
|
{t('sidebar.dropToRoot')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
) : activeView === 'reminders' ? (
|
|
<motion.div
|
|
key="reminders"
|
|
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase mb-4 px-4">
|
|
{t('sidebar.reminders')}
|
|
</p>
|
|
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
|
|
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
|
|
<p className="text-[11px] text-concrete italic">{t('sidebar.noReminders')}</p>
|
|
</div>
|
|
</motion.div>
|
|
) : activeView === 'agents' ? (
|
|
<motion.div
|
|
key="agents"
|
|
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
|
|
{t('agents.intelligenceOS')}
|
|
</p>
|
|
<div className="space-y-1">
|
|
{[
|
|
{ id: 'agents', href: '/agents', label: t('agents.myAgents'), icon: Bot },
|
|
{ id: 'lab', href: '/lab', label: t('nav.lab'), icon: FlaskConical },
|
|
{ id: 'brainstorm', href: '/brainstorm', label: t('brainstorm.sessions'), icon: Sparkles },
|
|
].map(item => {
|
|
const isActive = pathname.startsWith(item.href)
|
|
return (
|
|
<Link
|
|
key={item.id}
|
|
href={item.href}
|
|
className={cn(
|
|
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
|
|
isActive ? 'memento-active-nav' : 'text-muted-foreground hover:bg-foreground/5 hover:text-foreground'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
|
|
isActive
|
|
? 'bg-brand-accent text-white border-brand-accent'
|
|
: 'bg-paper border-border group-hover:border-brand-accent/20'
|
|
)}>
|
|
<item.icon size={16} />
|
|
</div>
|
|
<span className="text-[13px] font-medium">{item.label}</span>
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="brainstorms"
|
|
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<div className="flex items-center justify-between px-4 mb-3">
|
|
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
|
|
{t('brainstorm.sessions')}
|
|
</p>
|
|
<button
|
|
onClick={() => router.push('/brainstorm')}
|
|
className="p-1 text-muted-foreground hover:text-brand-accent transition-colors rounded"
|
|
title={t('brainstorm.newBrainstorm')}
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
</div>
|
|
<SidebarBrainstorms />
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* ── Footer ── */}
|
|
<div className="pt-4 border-t border-border/40 mt-auto pb-4 space-y-4">
|
|
<UsageMeter />
|
|
<div className="px-2 space-y-0.5">
|
|
<Link
|
|
href="/home?shared=1&forceList=1"
|
|
className={cn(
|
|
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
|
searchParams.get('shared') === '1' && pathname === '/home'
|
|
? 'bg-accent/5 text-accent'
|
|
: 'text-muted-ink hover:text-ink hover:bg-black/5'
|
|
)}
|
|
>
|
|
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/home' ? 'text-accent' : 'text-muted-ink group-hover:text-ink'} />
|
|
<span>{t('sidebar.sharedWithMe')}</span>
|
|
</Link>
|
|
|
|
<Link
|
|
href="/archive"
|
|
className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-black/5 transition-all font-medium group rounded-xl"
|
|
>
|
|
<Archive size={14} className="text-muted-ink group-hover:text-ink" />
|
|
<span>{t('sidebar.archive')}</span>
|
|
</Link>
|
|
|
|
<Link
|
|
href="/trash"
|
|
className={cn(
|
|
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
|
pathname === '/trash'
|
|
? 'bg-rose-50 text-rose-500'
|
|
: 'text-muted-ink hover:text-rose-500 hover:bg-rose-50/50'
|
|
)}
|
|
>
|
|
<Trash2 size={14} className={pathname === '/trash' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
|
|
<span>{t('sidebar.trash')}</span>
|
|
{trashCount > 0 && (
|
|
<span className="ms-auto w-1.5 h-1.5 rounded-full bg-rose-400" />
|
|
)}
|
|
</Link>
|
|
|
|
<div className="my-2 h-px bg-border/20 mx-2" />
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<CreateNotebookDialog
|
|
open={isCreateDialogOpen}
|
|
onOpenChange={setIsCreateDialogOpen}
|
|
parentNotebookId={createParentId}
|
|
/>
|
|
|
|
{/* Rename Dialog */}
|
|
<AnimatePresence>
|
|
{renamingNotebook && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
|
onClick={() => !isRenaming && setRenamingNotebook(null)}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0, y: 10 }}
|
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
exit={{ scale: 0.9, opacity: 0, y: 10 }}
|
|
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
|
className="bg-card border border-border rounded-2xl p-6 shadow-xl w-80"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-sm font-semibold mb-4 font-memento-serif">
|
|
{t('notebook.rename')}
|
|
</h3>
|
|
<input
|
|
autoFocus
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleConfirmRename(); if (e.key === 'Escape') setRenamingNotebook(null) }}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-transparent focus:outline-none focus:ring-2 focus:ring-foreground/20"
|
|
placeholder={t('notebook.namePlaceholder')}
|
|
/>
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
<button
|
|
onClick={() => setRenamingNotebook(null)}
|
|
disabled={isRenaming}
|
|
className="px-4 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors rounded-lg"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleConfirmRename}
|
|
disabled={isRenaming || !renameValue.trim()}
|
|
className="px-4 py-1.5 text-xs font-medium bg-foreground text-background rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
>
|
|
{isRenaming ? '...' : t('notebook.confirmRename')}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Delete Confirmation */}
|
|
<AnimatePresence>
|
|
{deletingNotebook && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
|
onClick={() => !isDeleting && setDeletingNotebook(null)}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0, y: 10 }}
|
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
exit={{ scale: 0.9, opacity: 0, y: 10 }}
|
|
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
|
className="bg-card border border-border rounded-2xl p-6 shadow-xl w-80"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="w-10 h-10 rounded-full bg-red-50 border border-red-100 flex items-center justify-center mb-3">
|
|
<Trash2 size={16} className="text-red-500" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold mb-1 font-memento-serif">
|
|
{t('notebook.trashTitle')}
|
|
</h3>
|
|
<p className="text-xs text-muted-foreground mb-4">
|
|
{t('notebook.trashConfirm', { name: deletingNotebook.name }) || `Move "${deletingNotebook.name}" to trash? You can restore it within 30 days.`}
|
|
</p>
|
|
{getDescendantIds(deletingNotebook.id).length > 0 && (
|
|
<p className="text-xs text-amber-600 mb-4 bg-amber-50 px-3 py-2 rounded-lg border border-amber-100">
|
|
{t('notebook.trashCascadeWarning', { count: getDescendantIds(deletingNotebook.id).length }) || `${getDescendantIds(deletingNotebook.id).length} sub-notebook(s) will also be moved to trash.`}
|
|
</p>
|
|
)}
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setDeletingNotebook(null)}
|
|
disabled={isDeleting}
|
|
className="px-4 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors rounded-lg"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleConfirmDelete}
|
|
disabled={isDeleting}
|
|
className="px-4 py-1.5 text-xs font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50"
|
|
>
|
|
{isDeleting ? '...' : t('notebook.moveToTrash')}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
)
|
|
}
|