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",