diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/sidebar/all-notes-sidebar.tsx new file mode 100644 index 000000000..8a3c70083 --- /dev/null +++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx @@ -0,0 +1,404 @@ +"use client"; + +import { + FileText, + type LucideIcon, + MoreHorizontal, + Plus, + RefreshCw, + Trash2, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { notesApiService } from "@/lib/apis/notes-api.service"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { useEffect, useRef } from "react"; + +// Map of icon names to their components +const actionIconMap: Record = { + FileText, + Trash2, + MoreHorizontal, + RefreshCw, +}; + +interface NoteAction { + name: string; + icon: string; + onClick: () => void; +} + +interface NoteItem { + name: string; + url: string; + icon: LucideIcon; + id?: number; + search_space_id?: number; + actions?: NoteAction[]; +} + +interface AllNotesSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + searchSpaceId: string; + onAddNote?: () => void; + hoverTimeoutRef?: React.MutableRefObject; +} + +export function AllNotesSidebar({ + open, + onOpenChange, + searchSpaceId, + onAddNote, + hoverTimeoutRef, +}: AllNotesSidebarProps) { + const t = useTranslations("sidebar"); + const router = useRouter(); + const [isDeleting, setIsDeleting] = useState(null); + const sidebarRef = useRef(null); + const [sidebarLeft, setSidebarLeft] = useState(0); // Position from left edge of viewport + + // Calculate the sidebar's right edge position + useEffect(() => { + if (typeof window === "undefined") return; + + const updatePosition = () => { + // Find the actual sidebar element (the fixed positioned one) + const sidebarElement = document.querySelector( + '[data-slot="sidebar"][data-sidebar="sidebar"]' + ) as HTMLElement; + + if (sidebarElement) { + const rect = sidebarElement.getBoundingClientRect(); + // Set the left position to be the right edge of the sidebar + setSidebarLeft(rect.right); + } else { + // Fallback: try to find any sidebar element + const fallbackSidebar = document.querySelector('[data-slot="sidebar"]') as HTMLElement; + if (fallbackSidebar) { + const rect = fallbackSidebar.getBoundingClientRect(); + setSidebarLeft(rect.right); + } else { + // Final fallback: use CSS variable + const sidebarWidth = getComputedStyle(document.documentElement) + .getPropertyValue("--sidebar-width") + .trim(); + if (sidebarWidth) { + const remValue = parseFloat(sidebarWidth); + setSidebarLeft(remValue * 16); // Convert rem to px + } else { + setSidebarLeft(256); // Default 16rem + } + } + } + }; + + updatePosition(); + // Update on window resize and scroll + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + + // Use MutationObserver to watch for sidebar state changes + const observer = new MutationObserver(updatePosition); + const sidebarWrapper = document.querySelector('[data-slot="sidebar-wrapper"]'); + if (sidebarWrapper) { + observer.observe(sidebarWrapper, { + attributes: true, + attributeFilter: ["data-state", "class"], + childList: true, + subtree: true, + }); + } + + // Also observe the sidebar element directly if it exists + const sidebarElement = document.querySelector('[data-slot="sidebar"]'); + if (sidebarElement) { + observer.observe(sidebarElement, { + attributes: true, + attributeFilter: ["data-state", "class"], + childList: false, + subtree: false, + }); + } + + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + observer.disconnect(); + }; + }, []); + + // Handle Escape key to close sidebar + useEffect(() => { + if (!open) return; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onOpenChange(false); + } + }; + + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + // Fetch all notes + const { + data: notesData, + error: notesError, + isLoading: isLoadingNotes, + refetch: refetchNotes, + } = useQuery({ + queryKey: ["all-notes", searchSpaceId], + queryFn: () => + notesApiService.getNotes({ + search_space_id: Number(searchSpaceId), + page_size: 1000, // Get all notes + }), + enabled: !!searchSpaceId && open, // Only fetch when sidebar is open + }); + + // Handle note deletion with loading state + const handleDeleteNote = useCallback( + async (noteId: number, deleteAction: () => void) => { + setIsDeleting(noteId); + try { + await deleteAction(); + refetchNotes(); + } finally { + setIsDeleting(null); + } + }, + [refetchNotes] + ); + + // Transform notes to the format expected by the component + const allNotes = useMemo(() => { + return notesData?.items + ? notesData.items.map((note) => ({ + name: note.title, + url: `/dashboard/${note.search_space_id}/editor/${note.id}`, + icon: FileText as LucideIcon, + id: note.id, + search_space_id: note.search_space_id, + actions: [ + { + name: "Delete", + icon: "Trash2", + onClick: async () => { + try { + await notesApiService.deleteNote({ + search_space_id: note.search_space_id, + note_id: note.id, + }); + } catch (error) { + console.error("Error deleting note:", error); + } + }, + }, + ], + })) + : []; + }, [notesData]); + + // Enhanced note item component + const NoteItemComponent = useCallback( + ({ note }: { note: NoteItem }) => { + const isDeletingNote = isDeleting === note.id; + + return ( + + { + router.push(note.url); + onOpenChange(false); // Close sidebar when navigating + }} + disabled={isDeletingNote} + className={cn("group/item relative", isDeletingNote && "opacity-50")} + size="sm" + > + + {note.name} + + + {note.actions && note.actions.length > 0 && ( + + + + + More + + + + {note.actions.map((action, actionIndex) => { + const ActionIcon = actionIconMap[action.icon] || FileText; + const isDeleteAction = action.name.toLowerCase().includes("delete"); + + return ( + { + if (isDeleteAction) { + handleDeleteNote(note.id || 0, action.onClick); + } else { + action.onClick(); + } + }} + disabled={isDeletingNote} + className={isDeleteAction ? "text-destructive" : ""} + > + + + {isDeletingNote && isDeleteAction ? "Deleting..." : action.name} + + + ); + })} + + + )} + + ); + }, + [isDeleting, router, onOpenChange, handleDeleteNote] + ); + + return ( + <> + {/* Floating Sidebar */} +
{ + // Clear any pending close timeout when hovering over sidebar + if (hoverTimeoutRef?.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }} + onMouseLeave={() => { + // Close sidebar when mouse leaves + if (hoverTimeoutRef) { + hoverTimeoutRef.current = setTimeout(() => { + onOpenChange(false); + }, 200); + } else { + onOpenChange(false); + } + }} + > +
+ {/* Header */} +
+

{t("all_notes") || "All Notes"}

+
+ + {/* Content */} + +
+ + + {isLoadingNotes ? ( + + + + {t("loading") || "Loading..."} + + + + ) : notesError ? ( + + + + {t("error_loading_notes") || "Error loading notes"} + + + + ) : allNotes.length > 0 ? ( + + {allNotes.map((note) => ( + + ))} + + ) : ( + + {onAddNote ? ( + { + onAddNote(); + onOpenChange(false); + }} + className="text-muted-foreground hover:text-sidebar-foreground text-xs" + size="sm" + > + + {t("create_new_note") || "Create a new note"} + + ) : ( + + + {t("no_notes") || "No notes yet"} + + )} + + )} + + +
+
+ + {/* Footer with Add Note button */} + {onAddNote && ( +
+ +
+ )} +
+
+ + ); +} + diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index bdae5b5e1..90288c394 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -453,7 +453,7 @@ export const AppSidebar = memo(function AppSidebar({ )}
- +
diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx index f392e25b6..b89ebd4a0 100644 --- a/surfsense_web/components/sidebar/nav-notes.tsx +++ b/surfsense_web/components/sidebar/nav-notes.tsx @@ -3,6 +3,7 @@ import { ChevronRight, ExternalLink, + Eye, FileText, type LucideIcon, MoreHorizontal, @@ -10,11 +11,10 @@ import { RefreshCw, Share, Trash2, - Eye, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useState } from "react"; +import { useCallback, useState, useRef } from "react"; import { Collapsible, CollapsibleContent, @@ -36,6 +36,7 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; +import { AllNotesSidebar } from "./all-notes-sidebar"; // Map of icon names to their components const actionIconMap: Record = { @@ -66,14 +67,17 @@ interface NavNotesProps { notes: NoteItem[]; onAddNote?: () => void; defaultOpen?: boolean; + searchSpaceId?: string; } -export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps) { +export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) { const t = useTranslations("sidebar"); const { isMobile } = useSidebar(); const router = useRouter(); const [isDeleting, setIsDeleting] = useState(null); const [isOpen, setIsOpen] = useState(defaultOpen); + const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); + const hoverTimeoutRef = useRef(null); // Handle note deletion with loading state const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => { @@ -166,18 +170,31 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps
- + {searchSpaceId && ( + + )} {onAddNote && (