From 8eceb7a5cbc28602f49901312cbaab2546be9e75 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:28:30 +0530 Subject: [PATCH 1/8] feat: add note management functionality with BlockNote support - Introduced a new ENUM value 'NOTE' for document types in the database. - Implemented backend routes for creating, listing, and deleting notes. - Added a new frontend page for creating notes with a BlockNote editor. - Updated sidebar to include recent notes and an option to add new notes. - Enhanced API service for notes with validation and request/response schemas. - Updated translations to support new note-related terms. --- .../48_add_note_to_documenttype_enum.py | 47 ++++ surfsense_backend/app/db.py | 1 + surfsense_backend/app/routes/__init__.py | 2 + surfsense_backend/app/routes/editor_routes.py | 25 +- surfsense_backend/app/routes/notes_routes.py | 228 ++++++++++++++++++ .../[search_space_id]/notes/new/page.tsx | 155 ++++++++++++ .../components/sidebar/AppSidebarProvider.tsx | 57 +++++ .../components/sidebar/app-sidebar.tsx | 45 +++- .../components/sidebar/nav-notes.tsx | 214 ++++++++++++++++ .../components/sidebar/nav-projects.tsx | 13 - .../components/sidebar/page-usage-display.tsx | 44 ++-- surfsense_web/lib/apis/notes-api.service.ts | 148 ++++++++++++ surfsense_web/messages/en.json | 5 +- 13 files changed, 948 insertions(+), 36 deletions(-) create mode 100644 surfsense_backend/alembic/versions/48_add_note_to_documenttype_enum.py create mode 100644 surfsense_backend/app/routes/notes_routes.py create mode 100644 surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx create mode 100644 surfsense_web/components/sidebar/nav-notes.tsx create mode 100644 surfsense_web/lib/apis/notes-api.service.ts diff --git a/surfsense_backend/alembic/versions/48_add_note_to_documenttype_enum.py b/surfsense_backend/alembic/versions/48_add_note_to_documenttype_enum.py new file mode 100644 index 000000000..353b5b042 --- /dev/null +++ b/surfsense_backend/alembic/versions/48_add_note_to_documenttype_enum.py @@ -0,0 +1,47 @@ +"""48_add_note_to_documenttype_enum + +Revision ID: 48 +Revises: 47 +Adds NOTE document type to support user-created BlockNote documents. +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "48" +down_revision: str | None = "47" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# Define the ENUM type name and the new value +ENUM_NAME = "documenttype" +NEW_VALUE = "NOTE" + + +def upgrade() -> None: + """Safely add 'NOTE' to documenttype enum if missing.""" + op.execute( + f""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + WHERE t.typname = '{ENUM_NAME}' AND e.enumlabel = '{NEW_VALUE}' + ) THEN + ALTER TYPE {ENUM_NAME} ADD VALUE '{NEW_VALUE}'; + END IF; + END + $$; + """ + ) + + +def downgrade() -> None: + """ + Downgrade logic not implemented since PostgreSQL + does not support removing enum values. + """ + pass \ No newline at end of file diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index cf267bd3d..c338240b3 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -51,6 +51,7 @@ class DocumentType(str, Enum): LUMA_CONNECTOR = "LUMA_CONNECTOR" ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR" BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR" + NOTE = "NOTE" class SearchSourceConnectorType(str, Enum): diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 4b829fe84..c9d70588d 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -15,6 +15,7 @@ from .google_gmail_add_connector_route import ( from .llm_config_routes import router as llm_config_router from .logs_routes import router as logs_router from .luma_add_connector_route import router as luma_add_connector_router +from .notes_routes import router as notes_router from .podcasts_routes import router as podcasts_router from .rbac_routes import router as rbac_router from .search_source_connectors_routes import router as search_source_connectors_router @@ -26,6 +27,7 @@ router.include_router(search_spaces_router) router.include_router(rbac_router) # RBAC routes for roles, members, invites router.include_router(editor_router) router.include_router(documents_router) +router.include_router(notes_router) router.include_router(podcasts_router) router.include_router(chats_router) router.include_router(search_source_connectors_router) diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index 7b7a15c13..3d23f66a1 100644 --- a/surfsense_backend/app/routes/editor_routes.py +++ b/surfsense_backend/app/routes/editor_routes.py @@ -10,7 +10,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.db import Document, Permission, User, get_async_session +from app.db import Document, DocumentType, Permission, User, get_async_session from app.users import current_active_user from app.utils.rbac import check_permission @@ -65,7 +65,28 @@ async def get_editor_content( else None, } - # Lazy migration: Try to generate blocknote_document from chunks + # For NOTE type documents, return empty BlockNote structure if no content exists + if document.document_type == DocumentType.NOTE: + # Return empty BlockNote structure + empty_blocknote = [ + { + "type": "paragraph", + "content": [], + "children": [], + } + ] + # Save empty structure if not already saved + if not document.blocknote_document: + document.blocknote_document = empty_blocknote + await session.commit() + return { + "document_id": document.id, + "title": document.title, + "blocknote_document": empty_blocknote, + "updated_at": document.updated_at.isoformat() if document.updated_at else None, + } + + # Lazy migration: Try to generate blocknote_document from chunks (for other document types) from app.utils.blocknote_converter import convert_markdown_to_blocknote chunks = sorted(document.chunks, key=lambda c: c.id) diff --git a/surfsense_backend/app/routes/notes_routes.py b/surfsense_backend/app/routes/notes_routes.py new file mode 100644 index 000000000..1ea8dc345 --- /dev/null +++ b/surfsense_backend/app/routes/notes_routes.py @@ -0,0 +1,228 @@ +""" +Notes routes for creating and managing BlockNote documents. +""" + +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, Body, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import Document, DocumentType, Permission, User, get_async_session +from app.schemas import DocumentRead, PaginatedResponse +from app.users import current_active_user +from app.utils.rbac import check_permission + +router = APIRouter() + + +class CreateNoteRequest(BaseModel): + title: str + blocknote_document: list[dict[str, Any]] | None = None + + +@router.post("/search-spaces/{search_space_id}/notes", response_model=DocumentRead) +async def create_note( + search_space_id: int, + request: CreateNoteRequest, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Create a new note (BlockNote document). + + Requires DOCUMENTS_CREATE permission. + """ + # Check RBAC permission + await check_permission( + session, + user, + search_space_id, + Permission.DOCUMENTS_CREATE.value, + "You don't have permission to create notes in this search space", + ) + + if not request.title or not request.title.strip(): + raise HTTPException(status_code=400, detail="Title is required") + + # Default empty BlockNote structure if not provided + blocknote_document = request.blocknote_document + if blocknote_document is None: + blocknote_document = [ + { + "type": "paragraph", + "content": [], + "children": [], + } + ] + + # Generate content hash (use title for now, will be updated on save) + import hashlib + + content_hash = hashlib.sha256(request.title.encode()).hexdigest() + + # Create document with NOTE type + from app.config import config + + document = Document( + search_space_id=search_space_id, + title=request.title.strip(), + document_type=DocumentType.NOTE, + content="", # Empty initially, will be populated on first save/reindex + content_hash=content_hash, + blocknote_document=blocknote_document, + content_needs_reindexing=False, # Will be set to True on first save + document_metadata={"NOTE": True}, + embedding=None, # Will be generated on first reindex + updated_at=datetime.now(UTC), + ) + + session.add(document) + await session.commit() + await session.refresh(document) + + return DocumentRead( + id=document.id, + title=document.title, + document_type=document.document_type, + content=document.content, + content_hash=document.content_hash, + unique_identifier_hash=document.unique_identifier_hash, + document_metadata=document.document_metadata, + search_space_id=document.search_space_id, + created_at=document.created_at, + updated_at=document.updated_at, + ) + + +@router.get("/search-spaces/{search_space_id}/notes", response_model=PaginatedResponse[DocumentRead]) +async def list_notes( + search_space_id: int, + skip: int | None = None, + page: int | None = None, + page_size: int = 50, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + List all notes in a search space. + + Requires DOCUMENTS_READ permission. + """ + # Check RBAC permission + await check_permission( + session, + user, + search_space_id, + Permission.DOCUMENTS_READ.value, + "You don't have permission to read notes in this search space", + ) + + from sqlalchemy import func + + # Build query + query = select(Document).where( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.NOTE, + ) + + # Get total count + count_query = select(func.count()).select_from( + select(Document).where( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.NOTE, + ).subquery() + ) + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # Apply pagination + if skip is not None: + query = query.offset(skip) + elif page is not None: + query = query.offset(page * page_size) + else: + query = query.offset(0) + + if page_size > 0: + query = query.limit(page_size) + + # Order by updated_at descending (most recent first) + query = query.order_by(Document.updated_at.desc()) + + # Execute query + result = await session.execute(query) + documents = result.scalars().all() + + # Convert to response models + items = [ + DocumentRead( + id=doc.id, + title=doc.title, + document_type=doc.document_type, + content=doc.content, + content_hash=doc.content_hash, + unique_identifier_hash=doc.unique_identifier_hash, + document_metadata=doc.document_metadata, + search_space_id=doc.search_space_id, + created_at=doc.created_at, + updated_at=doc.updated_at, + ) + for doc in documents + ] + + # Calculate pagination info + actual_skip = skip if skip is not None else (page * page_size if page is not None else 0) + has_more = (actual_skip + len(items)) < total if page_size > 0 else False + + return PaginatedResponse( + items=items, + total=total, + page=page if page is not None else (actual_skip // page_size if page_size > 0 else 0), + page_size=page_size, + has_more=has_more, + ) + + +@router.delete("/search-spaces/{search_space_id}/notes/{note_id}") +async def delete_note( + search_space_id: int, + note_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Delete a note. + + Requires DOCUMENTS_DELETE permission. + """ + # Check RBAC permission + await check_permission( + session, + user, + search_space_id, + Permission.DOCUMENTS_DELETE.value, + "You don't have permission to delete notes in this search space", + ) + + # Get document + result = await session.execute( + select(Document).where( + Document.id == note_id, + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.NOTE, + ) + ) + document = result.scalars().first() + + if not document: + raise HTTPException(status_code=404, detail="Note not found") + + # Delete document (chunks will be cascade deleted) + await session.delete(document) + await session.commit() + + return {"message": "Note deleted successfully", "note_id": note_id} + diff --git a/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx new file mode 100644 index 000000000..c34b8167c --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { AlertCircle, FileText, Loader2, Plus, X } from "lucide-react"; +import { motion } from "motion/react"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { notesApiService } from "@/lib/apis/notes-api.service"; + +export default function NewNotePage() { + const params = useParams(); + const router = useRouter(); + const searchSpaceId = Number(params.search_space_id); + + const [title, setTitle] = useState(""); + const [editorContent, setEditorContent] = useState(null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + + const handleCreate = async () => { + if (!title.trim()) { + toast.error("Please enter a title for your note"); + return; + } + + setCreating(true); + setError(null); + + try { + const note = await notesApiService.createNote({ + search_space_id: searchSpaceId, + title: title.trim(), + blocknote_document: editorContent || undefined, + }); + + toast.success("Note created successfully!"); + // Redirect to editor + router.push(`/dashboard/${searchSpaceId}/editor/${note.id}`); + } catch (error) { + console.error("Error creating note:", error); + const errorMessage = + error instanceof Error ? error.message : "Failed to create note. Please try again."; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setCreating(false); + } + }; + + const handleCancel = () => { + router.back(); + }; + + return ( + + {/* Toolbar */} +
+
+ +
+

New Note

+

Create a new note

+
+
+ +
+ + +
+
+ + {/* Content */} +
+
+
+ {error && ( + + + +
+ + Error +
+ {error} +
+
+
+ )} + + + + Note Details + Enter a title for your note + + +
+ + setTitle(e.target.value)} + disabled={creating} + className="text-lg" + /> +
+
+
+ + + + Content + Start writing your note (optional) + + +
+ +
+
+
+
+
+
+
+ ); +} + diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index fce8697f7..27147a4b0 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -2,6 +2,7 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms"; @@ -19,6 +20,7 @@ import { } from "@/components/ui/dialog"; import { useUser } from "@/hooks"; import { useQuery } from "@tanstack/react-query"; +import { notesApiService } from "@/lib/apis/notes-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -48,6 +50,7 @@ export function AppSidebarProvider({ }: AppSidebarProviderProps) { const t = useTranslations("dashboard"); const tCommon = useTranslations("common"); + const router = useRouter(); const setChatsQueryParams = useSetAtom(globalChatsQueryParamsAtom); const { data: chats, error: chatError, isLoading: isLoadingChats } = useAtomValue(chatsAtom); const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] = @@ -70,6 +73,22 @@ export function AppSidebarProvider({ const { user } = useUser(); + // Fetch notes + const { + data: notesData, + error: notesError, + isLoading: isLoadingNotes, + refetch: refetchNotes, + } = useQuery({ + queryKey: ["notes", searchSpaceId], + queryFn: () => + notesApiService.getNotes({ + search_space_id: Number(searchSpaceId), + page_size: 10, // Get recent 10 notes + }), + enabled: !!searchSpaceId, + }); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isClient, setIsClient] = useState(false); @@ -162,6 +181,41 @@ export function AppSidebarProvider({ // Use fallback chats if there's an error or no chats const displayChats = recentChats.length > 0 ? recentChats : fallbackChats; + // Transform notes to the format expected by NavNotes + const recentNotes = useMemo(() => { + return notesData?.items + ? notesData.items.map((note) => ({ + name: note.title, + url: `/dashboard/${note.search_space_id}/editor/${note.id}`, + icon: "FileText", + 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, + }); + refetchNotes(); + } catch (error) { + console.error("Error deleting note:", error); + } + }, + }, + ], + })) + : []; + }, [notesData, refetchNotes]); + + // Handle add note + const handleAddNote = useCallback(() => { + router.push(`/dashboard/${searchSpaceId}/notes/new`); + }, [router, searchSpaceId]); + // Memoized updated navSecondary const updatedNavSecondary = useMemo(() => { const updated = [...navSecondary]; @@ -204,6 +258,7 @@ export function AppSidebarProvider({ navSecondary={navSecondary} navMain={navMain} RecentChats={[]} + RecentNotes={[]} pageUsage={pageUsage} /> ); @@ -216,6 +271,8 @@ export function AppSidebarProvider({ navSecondary={updatedNavSecondary} navMain={navMain} RecentChats={displayChats} + RecentNotes={recentNotes} + onAddNote={handleAddNote} pageUsage={pageUsage} /> diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index cd42e6fe8..2144dd861 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -24,7 +24,6 @@ import { UserPlus, Users, } from "lucide-react"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTheme } from "next-themes"; import { memo, useEffect, useMemo, useState } from "react"; @@ -115,6 +114,7 @@ function UserAvatar({ email, size = 32 }: { email: string; size?: number }) { } import { NavMain } from "@/components/sidebar/nav-main"; +import { NavNotes } from "@/components/sidebar/nav-notes"; import { NavProjects } from "@/components/sidebar/nav-projects"; import { NavSecondary } from "@/components/sidebar/nav-secondary"; import { PageUsageDisplay } from "@/components/sidebar/page-usage-display"; @@ -209,6 +209,20 @@ const defaultData = { id: 1003, }, ], + RecentNotes: [ + { + name: "Meeting Notes", + url: "#", + icon: "FileText", + id: 2001, + }, + { + name: "Project Ideas", + url: "#", + icon: "FileText", + id: 2002, + }, + ], }; interface AppSidebarProps extends React.ComponentProps { @@ -240,6 +254,18 @@ interface AppSidebarProps extends React.ComponentProps { onClick: () => void; }[]; }[]; + RecentNotes?: { + name: string; + url: string; + icon: string; + id?: number; + search_space_id?: number; + actions?: { + name: string; + icon: string; + onClick: () => void; + }[]; + }[]; user?: { name: string; email: string; @@ -249,6 +275,7 @@ interface AppSidebarProps extends React.ComponentProps { pagesUsed: number; pagesLimit: number; }; + onAddNote?: () => void; } // Memoized AppSidebar component for better performance @@ -257,7 +284,9 @@ export const AppSidebar = memo(function AppSidebar({ navMain = defaultData.navMain, navSecondary = defaultData.navSecondary, RecentChats = defaultData.RecentChats, + RecentNotes = defaultData.RecentNotes, pageUsage, + onAddNote, ...props }: AppSidebarProps) { const router = useRouter(); @@ -295,6 +324,16 @@ export const AppSidebar = memo(function AppSidebar({ ); }, [RecentChats]); + // Process RecentNotes to resolve icon names to components + const processedRecentNotes = useMemo(() => { + return ( + RecentNotes?.map((item) => ({ + ...item, + icon: iconMap[item.icon] || FileText, + })) || [] + ); + }, [RecentNotes]); + // Get user display name from email const userDisplayName = user?.email ? user.email.split("@")[0] : "User"; const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown"); @@ -412,6 +451,10 @@ export const AppSidebar = memo(function AppSidebar({ )} + +
+ +
{pageUsage && ( diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx new file mode 100644 index 000000000..14dd75c59 --- /dev/null +++ b/surfsense_web/components/sidebar/nav-notes.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { + ChevronRight, + ExternalLink, + FileText, + type LucideIcon, + MoreHorizontal, + Plus, + RefreshCw, + Share, + Trash2, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useState } from "react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; + +// Map of icon names to their components +const actionIconMap: Record = { + ExternalLink, + FileText, + Share, + 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 NavNotesProps { + notes: NoteItem[]; + onAddNote?: () => void; + defaultOpen?: boolean; +} + +export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps) { + const t = useTranslations("sidebar"); + const { isMobile } = useSidebar(); + const router = useRouter(); + const [isDeleting, setIsDeleting] = useState(null); + const [isOpen, setIsOpen] = useState(defaultOpen); + + // Handle note deletion with loading state + const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => { + setIsDeleting(noteId); + try { + await deleteAction(); + } finally { + setIsDeleting(null); + } + }, []); + + // Enhanced note item component + const NoteItemComponent = useCallback( + ({ note }: { note: NoteItem }) => { + const isDeletingNote = isDeleting === note.id; + + return ( + + router.push(note.url)} + disabled={isDeletingNote} + className={`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, isMobile, handleDeleteNote] + ); + + return ( + + +
+ + + + + {t("notes") || "Notes"} + + + + {onAddNote && ( + + )} +
+ + + + {/* Note Items */} + {notes.length > 0 ? ( + notes.map((note) => ) + ) : ( + /* Empty state with create button */ + + {onAddNote ? ( + + + {t("create_new_note") || "Create a new note"} + + ) : ( + + + {t("no_notes") || "No notes yet"} + + )} + + )} + + + +
+
+ ); +} + diff --git a/surfsense_web/components/sidebar/nav-projects.tsx b/surfsense_web/components/sidebar/nav-projects.tsx index b3e1435d1..3862ce75d 100644 --- a/surfsense_web/components/sidebar/nav-projects.tsx +++ b/surfsense_web/components/sidebar/nav-projects.tsx @@ -148,19 +148,6 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) { return ( {t("recent_chats")} - - {/* Search Input */} - {showSearch && ( -
- setSearchQuery(e.target.value)} - className="h-8" - /> -
- )} - {/* Chat Items */} {filteredChats.length > 0 ? ( diff --git a/surfsense_web/components/sidebar/page-usage-display.tsx b/surfsense_web/components/sidebar/page-usage-display.tsx index 51e67d2f0..80f76b14d 100644 --- a/surfsense_web/components/sidebar/page-usage-display.tsx +++ b/surfsense_web/components/sidebar/page-usage-display.tsx @@ -1,6 +1,6 @@ "use client"; -import { Mail } from "lucide-react"; +import { ChevronRight, Mail } from "lucide-react"; import { Progress } from "@/components/ui/progress"; import { SidebarGroup, @@ -8,6 +8,7 @@ import { SidebarGroupLabel, useSidebar, } from "@/components/ui/sidebar"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; interface PageUsageDisplayProps { pagesUsed: number; @@ -21,19 +22,16 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp return ( - - Page Usage - - -
- {isCollapsed ? ( - // Show only a compact progress indicator when collapsed -
- -
- ) : ( - // Show full details when expanded - <> + + + + Page Usage + + + + + +
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages @@ -54,10 +52,18 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp to increase limits

- - )} -
-
+
+
+ + + {isCollapsed && ( + // Show only a compact progress indicator when sidebar is collapsed + +
+ +
+
+ )}
); -} +} \ No newline at end of file diff --git a/surfsense_web/lib/apis/notes-api.service.ts b/surfsense_web/lib/apis/notes-api.service.ts new file mode 100644 index 000000000..38a48e0d7 --- /dev/null +++ b/surfsense_web/lib/apis/notes-api.service.ts @@ -0,0 +1,148 @@ +import { z } from "zod"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +// Request/Response schemas +const createNoteRequest = z.object({ + search_space_id: z.number(), + title: z.string().min(1), + blocknote_document: z.array(z.any()).optional(), +}); + +const createNoteResponse = z.object({ + id: z.number(), + title: z.string(), + document_type: z.string(), + content: z.string(), + content_hash: z.string(), + unique_identifier_hash: z.string().nullable(), + document_metadata: z.record(z.any()).nullable(), + search_space_id: z.number(), + created_at: z.string(), + updated_at: z.string().nullable(), +}); + +const getNotesRequest = z.object({ + search_space_id: z.number(), + skip: z.number().optional(), + page: z.number().optional(), + page_size: z.number().optional(), +}); + +const noteItem = z.object({ + id: z.number(), + title: z.string(), + document_type: z.string(), + content: z.string(), + content_hash: z.string(), + unique_identifier_hash: z.string().nullable(), + document_metadata: z.record(z.any()).nullable(), + search_space_id: z.number(), + created_at: z.string(), + updated_at: z.string().nullable(), +}); + +const getNotesResponse = z.object({ + items: z.array(noteItem), + total: z.number(), + page: z.number(), + page_size: z.number(), + has_more: z.boolean(), +}); + +const deleteNoteRequest = z.object({ + search_space_id: z.number(), + note_id: z.number(), +}); + +const deleteNoteResponse = z.object({ + message: z.string(), + note_id: z.number(), +}); + +// Type exports +export type CreateNoteRequest = z.infer; +export type CreateNoteResponse = z.infer; +export type GetNotesRequest = z.infer; +export type GetNotesResponse = z.infer; +export type NoteItem = z.infer; +export type DeleteNoteRequest = z.infer; +export type DeleteNoteResponse = z.infer; + +class NotesApiService { + /** + * Create a new note + */ + createNote = async (request: CreateNoteRequest) => { + const parsedRequest = createNoteRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { search_space_id, title, blocknote_document } = parsedRequest.data; + + // Send both title and blocknote_document in request body + const body = { + title, + ...(blocknote_document && { blocknote_document }), + }; + + return baseApiService.post( + `/api/v1/search-spaces/${search_space_id}/notes`, + createNoteResponse, + { body } + ); + }; + + /** + * Get list of notes + */ + getNotes = async (request: GetNotesRequest) => { + const parsedRequest = getNotesRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { search_space_id, skip, page, page_size } = parsedRequest.data; + + // Build query params + const params = new URLSearchParams(); + if (skip !== undefined) params.append("skip", String(skip)); + if (page !== undefined) params.append("page", String(page)); + if (page_size !== undefined) params.append("page_size", String(page_size)); + + return baseApiService.get( + `/api/v1/search-spaces/${search_space_id}/notes?${params.toString()}`, + getNotesResponse + ); + }; + + /** + * Delete a note + */ + deleteNote = async (request: DeleteNoteRequest) => { + const parsedRequest = deleteNoteRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { search_space_id, note_id } = parsedRequest.data; + + return baseApiService.delete( + `/api/v1/search-spaces/${search_space_id}/notes/${note_id}`, + deleteNoteResponse + ); + }; +} + +export const notesApiService = new NotesApiService(); + diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 140b8363d..8e9046abb 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -642,7 +642,10 @@ "no_chats_found": "No chats found", "no_recent_chats": "No recent chats", "view_all_chats": "View All Chats", - "search_space": "Search Space" + "search_space": "Search Space", + "notes": "Notes", + "no_notes": "No notes yet", + "create_new_note": "Create a new note" }, "errors": { "something_went_wrong": "Something went wrong", From e1e813702a5a16d5f4489c2e7bd5087722fb253e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:57:06 +0530 Subject: [PATCH 2/8] feat: enhance note creation and editing experience - Added dynamic title extraction from the first block of BlockNote documents for NOTE type. - Updated editor routes to support new note creation with a BlockNote editor. - Implemented unsaved changes dialog for better user experience when navigating away from the editor. - Refactored BlockNoteEditor to ensure the first block is a heading when creating new notes. - Removed the old note creation page in favor of the new streamlined editor experience. --- surfsense_backend/app/routes/editor_routes.py | 24 +++ .../editor/[documentId]/page.tsx | 97 ++++++++-- .../[search_space_id]/editor/new/page.tsx | 175 ++++++++++++++++++ .../[search_space_id]/notes/new/page.tsx | 155 ---------------- surfsense_web/components/BlockNoteEditor.tsx | 106 ++++++++++- .../components/sidebar/AppSidebarProvider.tsx | 2 +- 6 files changed, 386 insertions(+), 173 deletions(-) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/editor/new/page.tsx delete mode 100644 surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index 3d23f66a1..541a7a012 100644 --- a/surfsense_backend/app/routes/editor_routes.py +++ b/surfsense_backend/app/routes/editor_routes.py @@ -59,6 +59,7 @@ async def get_editor_content( return { "document_id": document.id, "title": document.title, + "document_type": document.document_type.value, "blocknote_document": document.blocknote_document, "updated_at": document.updated_at.isoformat() if document.updated_at @@ -82,6 +83,7 @@ async def get_editor_content( return { "document_id": document.id, "title": document.title, + "document_type": document.document_type.value, "blocknote_document": empty_blocknote, "updated_at": document.updated_at.isoformat() if document.updated_at else None, } @@ -123,6 +125,7 @@ async def get_editor_content( return { "document_id": document.id, "title": document.title, + "document_type": document.document_type.value, "blocknote_document": blocknote_json, "updated_at": document.updated_at.isoformat() if document.updated_at else None, } @@ -168,6 +171,27 @@ async def save_document( if not blocknote_document: raise HTTPException(status_code=400, detail="blocknote_document is required") + # For NOTE type documents, extract title from first block (heading) + if ( + document.document_type == DocumentType.NOTE + and blocknote_document + and len(blocknote_document) > 0 + ): + first_block = blocknote_document[0] + if first_block and first_block.get("content"): + # Extract text from first block content + title_parts = [] + for item in first_block["content"]: + if isinstance(item, str): + title_parts.append(item) + elif isinstance(item, dict) and "text" in item: + title_parts.append(item["text"]) + new_title = "".join(title_parts).strip() + if new_title: + document.title = new_title + else: + document.title = "Untitled" + # Save BlockNote document document.blocknote_document = blocknote_document document.updated_at = datetime.now(UTC) diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index a6dbd4536..9e188b09a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -1,11 +1,21 @@ "use client"; -import { AlertCircle, FileText, Loader2, Save, X } from "lucide-react"; +import { AlertCircle, ArrowLeft, FileText, Loader2, Save, X } from "lucide-react"; import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -14,10 +24,40 @@ import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth- interface EditorContent { document_id: number; title: string; + document_type?: string; blocknote_document: any; updated_at: string | null; } +// Helper function to extract title from BlockNote document +// Takes the text content from the first block (should be a heading for notes) +function extractTitleFromBlockNote(blocknoteDocument: any[] | null | undefined): string { + if (!blocknoteDocument || !Array.isArray(blocknoteDocument) || blocknoteDocument.length === 0) { + return "Untitled"; + } + + const firstBlock = blocknoteDocument[0]; + if (!firstBlock) { + return "Untitled"; + } + + // Extract text from block content + // BlockNote blocks have a content array with inline content + if (firstBlock.content && Array.isArray(firstBlock.content)) { + const textContent = firstBlock.content + .map((item: any) => { + if (typeof item === "string") return item; + if (item?.text) return item.text; + return ""; + }) + .join("") + .trim(); + return textContent || "Untitled"; + } + + return "Untitled"; +} + export default function EditorPage() { const params = useParams(); const router = useRouter(); @@ -29,6 +69,7 @@ export default function EditorPage() { const [editorContent, setEditorContent] = useState(null); const [error, setError] = useState(null); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); // Fetch document content - DIRECT CALL TO FASTAPI useEffect(() => { @@ -90,6 +131,17 @@ export default function EditorPage() { } }, [editorContent, document]); + // Check if this is a NOTE type document + const isNote = document?.document_type === "NOTE"; + + // Extract title dynamically from editor content for notes, otherwise use document title + const displayTitle = useMemo(() => { + if (isNote && editorContent) { + return extractTitleFromBlockNote(editorContent); + } + return document?.title || "Untitled"; + }, [isNote, editorContent, document?.title]); + // TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI // Save and exit - DIRECT CALL TO FASTAPI @@ -142,16 +194,19 @@ export default function EditorPage() { } }; - const handleCancel = () => { + const handleBack = () => { if (hasUnsavedChanges) { - if (confirm("You have unsaved changes. Are you sure you want to leave?")) { - router.back(); - } + setShowUnsavedDialog(true); } else { router.back(); } }; + const handleConfirmLeave = () => { + setShowUnsavedDialog(false); + router.back(); + }; + if (loading) { return (
@@ -217,15 +272,15 @@ export default function EditorPage() {
-

{document.title}

+

{displayTitle}

{hasUnsavedChanges &&

Unsaved changes

}
- + +
+
+ + {/* Editor Container - matches editor page layout */} +
+
+ {error && ( + +
+ +

{error}

+
+
+ )} +
+ +
+
+
+ + ); +} \ No newline at end of file diff --git a/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx deleted file mode 100644 index c34b8167c..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx +++ /dev/null @@ -1,155 +0,0 @@ -"use client"; - -import { AlertCircle, FileText, Loader2, Plus, X } from "lucide-react"; -import { motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; -import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import { notesApiService } from "@/lib/apis/notes-api.service"; - -export default function NewNotePage() { - const params = useParams(); - const router = useRouter(); - const searchSpaceId = Number(params.search_space_id); - - const [title, setTitle] = useState(""); - const [editorContent, setEditorContent] = useState(null); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); - - const handleCreate = async () => { - if (!title.trim()) { - toast.error("Please enter a title for your note"); - return; - } - - setCreating(true); - setError(null); - - try { - const note = await notesApiService.createNote({ - search_space_id: searchSpaceId, - title: title.trim(), - blocknote_document: editorContent || undefined, - }); - - toast.success("Note created successfully!"); - // Redirect to editor - router.push(`/dashboard/${searchSpaceId}/editor/${note.id}`); - } catch (error) { - console.error("Error creating note:", error); - const errorMessage = - error instanceof Error ? error.message : "Failed to create note. Please try again."; - setError(errorMessage); - toast.error(errorMessage); - } finally { - setCreating(false); - } - }; - - const handleCancel = () => { - router.back(); - }; - - return ( - - {/* Toolbar */} -
-
- -
-

New Note

-

Create a new note

-
-
- -
- - -
-
- - {/* Content */} -
-
-
- {error && ( - - - -
- - Error -
- {error} -
-
-
- )} - - - - Note Details - Enter a title for your note - - -
- - setTitle(e.target.value)} - disabled={creating} - className="text-lg" - /> -
-
-
- - - - Content - Start writing your note (optional) - - -
- -
-
-
-
-
-
-
- ); -} - diff --git a/surfsense_web/components/BlockNoteEditor.tsx b/surfsense_web/components/BlockNoteEditor.tsx index 8064a0dc4..04a54fd3e 100644 --- a/surfsense_web/components/BlockNoteEditor.tsx +++ b/surfsense_web/components/BlockNoteEditor.tsx @@ -10,31 +10,119 @@ import { useCreateBlockNote } from "@blocknote/react"; interface BlockNoteEditorProps { initialContent?: any; onChange?: (content: any) => void; + useTitleBlock?: boolean; // Whether to use first block as title (Notion-style) } -export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteEditorProps) { +// Helper to ensure first block is a heading for title +function ensureTitleBlock(content: any[] | undefined): any[] { + if (!content || content.length === 0) { + // Return empty heading block for new notes + return [ + { + type: "heading", + props: { level: 1 }, + content: [], + children: [], + }, + ]; + } + + // If first block is not a heading, convert it to one + const firstBlock = content[0]; + if (firstBlock?.type !== "heading") { + // Extract text from first block + let titleText = ""; + if (firstBlock?.content && Array.isArray(firstBlock.content)) { + titleText = firstBlock.content + .map((item: any) => { + if (typeof item === "string") return item; + if (item?.text) return item.text; + return ""; + }) + .join("") + .trim(); + } + + // Create heading block with extracted text + const titleBlock = { + type: "heading", + props: { level: 1 }, + content: titleText + ? [ + { + type: "text", + text: titleText, + styles: {}, + }, + ] + : [], + children: [], + }; + + // Replace first block with heading, keep rest + return [titleBlock, ...content.slice(1)]; + } + + return content; +} + +export default function BlockNoteEditor({ initialContent, onChange, useTitleBlock = false }: BlockNoteEditorProps) { const { resolvedTheme } = useTheme(); // Track the initial content to prevent re-initialization const initialContentRef = useRef(null); const isInitializedRef = useRef(false); + // Prepare initial content - ensure first block is a heading if useTitleBlock is true + const preparedInitialContent = useMemo(() => { + if (initialContentRef.current !== null) { + return undefined; // Already initialized + } + if (initialContent === undefined) { + // New note - create empty heading block + return useTitleBlock + ? [ + { + type: "heading", + props: { level: 1 }, + content: [], + children: [], + }, + ] + : undefined; + } + // Existing note - ensure first block is heading + return useTitleBlock ? ensureTitleBlock(initialContent) : initialContent; + }, [initialContent, useTitleBlock]); + // Creates a new editor instance - only use initialContent on first render const editor = useCreateBlockNote({ - initialContent: initialContentRef.current === null ? initialContent || undefined : undefined, + initialContent: initialContentRef.current === null ? preparedInitialContent : undefined, }); // Store initial content on first render only useEffect(() => { - if (initialContent && initialContentRef.current === null) { - initialContentRef.current = initialContent; + if (preparedInitialContent !== undefined && initialContentRef.current === null) { + initialContentRef.current = preparedInitialContent; + isInitializedRef.current = true; + } else if (preparedInitialContent === undefined && initialContentRef.current === null) { + // Mark as initialized even when initialContent is undefined (for new notes) isInitializedRef.current = true; } - }, [initialContent]); + }, [preparedInitialContent]); // Call onChange when document changes (but don't update from props) useEffect(() => { - if (!onChange || !editor || !isInitializedRef.current) return; + if (!onChange || !editor) return; + + // For new notes (no initialContent), we need to wait for editor to be ready + // Use a small delay to ensure editor is fully initialized + if (!isInitializedRef.current) { + const timer = setTimeout(() => { + isInitializedRef.current = true; + }, 100); + return () => clearTimeout(timer); + } const handleChange = () => { onChange(editor.document); @@ -43,6 +131,12 @@ export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteE // Subscribe to document changes const unsubscribe = editor.onChange(handleChange); + // Also call onChange once with current document to capture initial state + // This ensures we capture content even if user doesn't make changes + if (editor.document) { + onChange(editor.document); + } + return () => { unsubscribe(); }; diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index 27147a4b0..62c127120 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -213,7 +213,7 @@ export function AppSidebarProvider({ // Handle add note const handleAddNote = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/notes/new`); + router.push(`/dashboard/${searchSpaceId}/editor/new`); }, [router, searchSpaceId]); // Memoized updated navSecondary From e9d3d36dfcdc21019dfb498848f2ae2279b85486 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:46:18 +0530 Subject: [PATCH 3/8] feat: improve note editing and creation workflow - Enhanced the editor to handle new note creation directly, initializing with an empty state. - Updated save functionality to create a note first if it's new, followed by saving the content. - Modified breadcrumb navigation to accommodate new notes and improve user experience. - Removed the old note creation page to streamline the editing process. - Added error handling and dynamic title updates for better feedback during note creation. --- .../editor/[documentId]/page.tsx | 164 ++++++++++++---- .../[search_space_id]/editor/new/page.tsx | 175 ------------------ .../components/dashboard-breadcrumb.tsx | 16 +- .../components/sidebar/app-sidebar.tsx | 2 +- .../components/sidebar/nav-notes.tsx | 31 +++- .../contracts/enums/connectorIcons.tsx | 4 +- 6 files changed, 167 insertions(+), 225 deletions(-) delete mode 100644 surfsense_web/app/dashboard/[search_space_id]/editor/new/page.tsx diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 9e188b09a..578c838b7 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertCircle, ArrowLeft, FileText, Loader2, Save, X } from "lucide-react"; +import { AlertCircle, ArrowLeft, FileText, Loader2, Plus, SquarePen, Save, X } from "lucide-react"; import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; @@ -19,6 +19,7 @@ import { import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; +import { notesApiService } from "@/lib/apis/notes-api.service"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; interface EditorContent { @@ -62,6 +63,8 @@ export default function EditorPage() { const params = useParams(); const router = useRouter(); const documentId = params.documentId as string; + const searchSpaceId = Number(params.search_space_id); + const isNewNote = documentId === "new"; const [document, setDocument] = useState(null); const [loading, setLoading] = useState(true); @@ -72,8 +75,23 @@ export default function EditorPage() { const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); // Fetch document content - DIRECT CALL TO FASTAPI + // Skip fetching if this is a new note useEffect(() => { async function fetchDocument() { + // For new notes, initialize with empty state + if (isNewNote) { + setDocument({ + document_id: 0, + title: "Untitled", + document_type: "NOTE", + blocknote_document: null, + updated_at: null, + }); + setEditorContent(null); + setLoading(false); + return; + } + const token = getBearerToken(); if (!token) { console.error("No auth token found"); @@ -122,7 +140,7 @@ export default function EditorPage() { if (documentId) { fetchDocument(); } - }, [documentId, params.search_space_id]); + }, [documentId, params.search_space_id, isNewNote]); // Track changes to mark as unsaved useEffect(() => { @@ -132,7 +150,7 @@ export default function EditorPage() { }, [editorContent, document]); // Check if this is a NOTE type document - const isNote = document?.document_type === "NOTE"; + const isNote = isNewNote || document?.document_type === "NOTE"; // Extract title dynamically from editor content for notes, otherwise use document title const displayTitle = useMemo(() => { @@ -145,6 +163,7 @@ export default function EditorPage() { // TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI // Save and exit - DIRECT CALL TO FASTAPI + // For new notes, create the note first, then save const handleSave = async () => { const token = getBearerToken(); if (!token) { @@ -153,42 +172,90 @@ export default function EditorPage() { return; } - if (!editorContent) { - toast.error("No content to save"); - return; - } - setSaving(true); + setError(null); + try { - // Save blocknote_document and trigger reindexing in background - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ blocknote_document: editorContent }), + // If this is a new note, create it first + if (isNewNote) { + const title = extractTitleFromBlockNote(editorContent); + + // Create the note first + const note = await notesApiService.createNote({ + search_space_id: searchSpaceId, + title: title, + blocknote_document: editorContent || undefined, + }); + + // If there's content, save it properly and trigger reindexing + if (editorContent) { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blocknote_document: editorContent }), + } + ); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ detail: "Failed to save document" })); + throw new Error(errorData.detail || "Failed to save document"); + } } - ); - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ detail: "Failed to save document" })); - throw new Error(errorData.detail || "Failed to save document"); + setHasUnsavedChanges(false); + toast.success("Note created successfully! Reindexing in background..."); + + // Redirect to editor with the new document ID + setTimeout(() => { + router.push(`/dashboard/${searchSpaceId}/editor/${note.id}`); + }, 500); + } else { + // Existing document - save normally + if (!editorContent) { + toast.error("No content to save"); + setSaving(false); + return; + } + + // Save blocknote_document and trigger reindexing in background + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blocknote_document: editorContent }), + } + ); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ detail: "Failed to save document" })); + throw new Error(errorData.detail || "Failed to save document"); + } + + setHasUnsavedChanges(false); + toast.success("Document saved! Reindexing in background..."); + + // Small delay before redirect to show success message + setTimeout(() => { + router.push(`/dashboard/${params.search_space_id}/documents`); + }, 500); } - - setHasUnsavedChanges(false); - toast.success("Document saved! Reindexing in background..."); - - // Small delay before redirect to show success message - setTimeout(() => { - router.push(`/dashboard/${params.search_space_id}/documents`); - }, 500); } catch (error) { console.error("Error saving document:", error); - toast.error( - error instanceof Error ? error.message : "Failed to save document. Please try again." - ); + const errorMessage = + error instanceof Error + ? error.message + : isNewNote + ? "Failed to create note. Please try again." + : "Failed to save document. Please try again."; + setError(errorMessage); + toast.error(errorMessage); } finally { setSaving(false); } @@ -248,7 +315,7 @@ export default function EditorPage() { ); } - if (!document) { + if (!document && !isNewNote) { return (
@@ -286,13 +353,20 @@ export default function EditorPage() { {saving ? ( <> - Saving... + {isNewNote ? "Creating..." : "Saving..."} ) : ( - <> - - Save & Exit - + isNewNote ? ( + <> + + Create Note + + ) : ( + <> + + Save & Exit + + ) )}
@@ -301,9 +375,21 @@ export default function EditorPage() { {/* Editor Container */}
+ {error && ( + +
+ +

{error}

+
+
+ )}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/new/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/new/page.tsx deleted file mode 100644 index 8e3d2c911..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/new/page.tsx +++ /dev/null @@ -1,175 +0,0 @@ -"use client"; - -import { AlertCircle, ArrowLeft, FileText, Loader2, Plus } from "lucide-react"; -import { motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; -import { toast } from "sonner"; -import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; -import { notesApiService } from "@/lib/apis/notes-api.service"; -import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; - -// Helper function to extract title from BlockNote document -// Takes the text content from the first block (should be a heading) -function extractTitleFromBlockNote(blocknoteDocument: any[] | null | undefined): string { - if (!blocknoteDocument || !Array.isArray(blocknoteDocument) || blocknoteDocument.length === 0) { - return "Untitled"; - } - - const firstBlock = blocknoteDocument[0]; - if (!firstBlock) { - return "Untitled"; - } - - // Extract text from block content - // BlockNote blocks have a content array with inline content - if (firstBlock.content && Array.isArray(firstBlock.content)) { - const textContent = firstBlock.content - .map((item: any) => { - if (typeof item === "string") return item; - if (item?.text) return item.text; - return ""; - }) - .join("") - .trim(); - return textContent || "Untitled"; - } - - return "Untitled"; -} - -export default function NewNotePage() { - const params = useParams(); - const router = useRouter(); - const searchSpaceId = Number(params.search_space_id); - - const [editorContent, setEditorContent] = useState(null); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); - - // Extract title dynamically from editor content - const dynamicTitle = useMemo(() => { - return extractTitleFromBlockNote(editorContent); - }, [editorContent]); - - const handleCreate = async () => { - setCreating(true); - setError(null); - - try { - // Extract title from first block of editor content - const title = extractTitleFromBlockNote(editorContent); - - // Create the note first - const note = await notesApiService.createNote({ - search_space_id: searchSpaceId, - title: title, - blocknote_document: editorContent || undefined, - }); - - // If there's content, save it properly and trigger reindexing - if (editorContent) { - const token = getBearerToken(); - if (!token) { - toast.error("Please login to save"); - redirectToLogin(); - return; - } - - // Call the save endpoint to properly save blocknote_document and trigger reindexing - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ blocknote_document: editorContent }), - } - ); - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ detail: "Failed to save document" })); - throw new Error(errorData.detail || "Failed to save document"); - } - } - - toast.success("Note created successfully! Reindexing in background..."); - // Redirect to editor - router.push(`/dashboard/${searchSpaceId}/editor/${note.id}`); - } catch (error) { - console.error("Error creating note:", error); - const errorMessage = - error instanceof Error ? error.message : "Failed to create note. Please try again."; - setError(errorMessage); - toast.error(errorMessage); - } finally { - setCreating(false); - } - }; - - const handleBack = () => { - router.back(); - }; - - return ( - - {/* Toolbar */} -
-
- -
-

{dynamicTitle}

-
-
- -
- - -
-
- - {/* Editor Container - matches editor page layout */} -
-
- {error && ( - -
- -

{error}

-
-
- )} -
- -
-
-
-
- ); -} \ No newline at end of file diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 6335f9f1a..c6b264879 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -44,6 +44,13 @@ export function DashboardBreadcrumb() { useEffect(() => { if (segments[2] === "editor" && segments[3] && searchSpaceId) { const documentId = segments[3]; + + // Skip fetch for "new" notes + if (documentId === "new") { + setDocumentTitle(null); + return; + } + const token = getBearerToken(); if (token) { @@ -110,7 +117,14 @@ export function DashboardBreadcrumb() { // Handle editor sub-sections (document ID) if (section === "editor") { - const documentLabel = documentTitle || subSection; + // Handle special cases for editor + let documentLabel: string; + if (subSection === "new") { + documentLabel = "New Note"; + } else { + documentLabel = documentTitle || subSection; + } + breadcrumbs.push({ label: t("documents"), href: `/dashboard/${segments[1]}/documents`, diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index 2144dd861..bdae5b5e1 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -138,13 +138,13 @@ export const iconMap: Record = { MessageCircleMore, Settings2, SquareLibrary, + FileText, SquareTerminal, AlertCircle, Info, ExternalLink, Trash2, Podcast, - FileText, Users, }; diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx index 14dd75c59..f392e25b6 100644 --- a/surfsense_web/components/sidebar/nav-notes.tsx +++ b/surfsense_web/components/sidebar/nav-notes.tsx @@ -10,6 +10,7 @@ import { RefreshCw, Share, Trash2, + Eye, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -149,9 +150,9 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps ); return ( - + -
+
- {onAddNote && ( +
- )} + {onAddNote && ( + + )} +
diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx index 583ace11e..18521edb7 100644 --- a/surfsense_web/contracts/enums/connectorIcons.tsx +++ b/surfsense_web/contracts/enums/connectorIcons.tsx @@ -17,7 +17,7 @@ import { IconTicket, IconWorldWww, } from "@tabler/icons-react"; -import { File, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react"; +import { File, FileText, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react"; import { EnumConnectorName } from "./connector"; export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => { @@ -71,6 +71,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas return ; case "FILE": return ; + case "NOTE": + return ; case "EXTENSION": return ; case "DEEP": From 5da41d91c8727523aebf6ae894af21a44e2861b5 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:14:35 +0530 Subject: [PATCH 4/8] feat: add All Notes sidebar component and integrate with NavNotes - Introduced a new AllNotesSidebar component for displaying all notes in a sidebar. - Enhanced NavNotes to include a button for opening the All Notes sidebar. - Updated translations to support new sidebar labels and error messages. - Improved note deletion handling and loading states within the sidebar. - Refactored AppSidebar to pass searchSpaceId to NavNotes for better context. --- .../components/sidebar/all-notes-sidebar.tsx | 404 ++++++++++++++++++ .../components/sidebar/app-sidebar.tsx | 2 +- .../components/sidebar/nav-notes.tsx | 56 ++- surfsense_web/messages/en.json | 4 +- surfsense_web/messages/zh.json | 7 +- 5 files changed, 455 insertions(+), 18 deletions(-) create mode 100644 surfsense_web/components/sidebar/all-notes-sidebar.tsx 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 && (