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..7f8254270
--- /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
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..1baf52e85 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
@@ -59,13 +59,38 @@ 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
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,
+ "document_type": document.document_type.value,
+ "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)
@@ -102,6 +127,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,
}
@@ -146,6 +172,43 @@ async def save_document(
blocknote_document = data.get("blocknote_document")
if not blocknote_document:
raise HTTPException(status_code=400, detail="blocknote_document is required")
+
+ # Add type validation
+ if not isinstance(blocknote_document, list):
+ raise HTTPException(status_code=400, detail="blocknote_document must be a list")
+
+ # 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")
+ and isinstance(first_block["content"], list)
+ ):
+ # Extract text from first block content
+ # Match the frontend extractTitleFromBlockNote logic exactly
+ title_parts = []
+ for item in first_block["content"]:
+ if isinstance(item, str):
+ title_parts.append(item)
+ elif (
+ isinstance(item, dict)
+ and "text" in item
+ and isinstance(item["text"], str)
+ ):
+ # BlockNote structure: {"type": "text", "text": "...", "styles": {}}
+ title_parts.append(item["text"])
+
+ new_title = "".join(title_parts).strip()
+ if new_title:
+ document.title = new_title
+ else:
+ # Only set to "Untitled" if content exists but is empty
+ document.title = "Untitled"
# Save BlockNote document
document.blocknote_document = blocknote_document
diff --git a/surfsense_backend/app/routes/notes_routes.py b/surfsense_backend/app/routes/notes_routes.py
new file mode 100644
index 000000000..99a12e803
--- /dev/null
+++ b/surfsense_backend/app/routes/notes_routes.py
@@ -0,0 +1,236 @@
+"""
+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]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
index 0483940e0..a2fee25a4 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
@@ -150,7 +150,7 @@ export function DocumentsTableShell({
<>
-
+
{
+ 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();
+ const queryClient = useQueryClient();
const documentId = params.documentId as string;
+ const searchSpaceId = Number(params.search_space_id);
+ const isNewNote = documentId === "new";
+
+ const { createLog } = useLogs(searchSpaceId);
const [document, setDocument] = useState(null);
const [loading, setLoading] = useState(true);
@@ -29,10 +77,26 @@ 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
+ // 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");
@@ -51,28 +115,101 @@ export default function EditorPage() {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to fetch document" }));
- throw new Error(errorData.detail || "Failed to fetch document");
+ const errorMessage = errorData.detail || "Failed to fetch document";
+
+ // Log fetch error
+ try {
+ await createLog({
+ level: "ERROR",
+ status: "FAILED",
+ message: `Failed to fetch document: ${errorMessage}`,
+ source: "editor",
+ search_space_id: searchSpaceId,
+ log_metadata: {
+ document_id: documentId,
+ error_type: "fetch_error",
+ http_status: response.status,
+ },
+ });
+ } catch (err) {
+ console.error("Failed to create log:", err);
+ }
+
+ throw new Error(errorMessage);
}
const data = await response.json();
// Check if blocknote_document exists
if (!data.blocknote_document) {
- setError(
- "This document does not have BlockNote content. Please re-upload the document to enable editing."
- );
+ const errorMsg = "This document does not have BlockNote content. Please re-upload the document to enable editing.";
+
+ // Log missing BlockNote content
+ try {
+ await createLog({
+ level: "WARNING",
+ status: "FAILED",
+ message: `Document ${documentId} does not have BlockNote content`,
+ source: "editor",
+ search_space_id: searchSpaceId,
+ log_metadata: {
+ document_id: documentId,
+ error_type: "missing_blocknote_content",
+ },
+ });
+ } catch (err) {
+ console.error("Failed to create log:", err);
+ }
+
+ setError(errorMsg);
setLoading(false);
return;
}
+ // Log successful fetch
+ try {
+ await createLog({
+ level: "INFO",
+ status: "SUCCESS",
+ message: `Document ${documentId} loaded successfully`,
+ source: "editor",
+ search_space_id: searchSpaceId,
+ log_metadata: {
+ document_id: documentId,
+ document_type: data.document_type,
+ title: data.title,
+ },
+ });
+ } catch (err) {
+ console.error("Failed to create log:", err);
+ }
+
setDocument(data);
setEditorContent(data.blocknote_document);
setError(null);
} catch (error) {
console.error("Error fetching document:", error);
- setError(
- error instanceof Error ? error.message : "Failed to fetch document. Please try again."
- );
+ const errorMessage =
+ error instanceof Error ? error.message : "Failed to fetch document. Please try again.";
+
+ // Log general fetch error
+ try {
+ await createLog({
+ level: "ERROR",
+ status: "FAILED",
+ message: `Error fetching document: ${errorMessage}`,
+ source: "editor",
+ search_space_id: searchSpaceId,
+ log_metadata: {
+ document_id: documentId,
+ error_type: "fetch_exception",
+ },
+ });
+ } catch (err) {
+ console.error("Failed to create log:", err);
+ }
+
+ setError(errorMessage);
} finally {
setLoading(false);
}
@@ -81,7 +218,7 @@ export default function EditorPage() {
if (documentId) {
fetchDocument();
}
- }, [documentId, params.search_space_id]);
+ }, [documentId, params.search_space_id, isNewNote, searchSpaceId, createLog]);
// Track changes to mark as unsaved
useEffect(() => {
@@ -90,9 +227,21 @@ export default function EditorPage() {
}
}, [editorContent, document]);
+ // Check if this is a NOTE type document
+ const isNote = isNewNote || 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
+ // For new notes, create the note first, then save
const handleSave = async () => {
const token = getBearerToken();
if (!token) {
@@ -101,57 +250,217 @@ 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" }));
+
+ // Log save error
+ try {
+ await createLog({
+ level: "ERROR",
+ status: "FAILED",
+ message: `Failed to save new note: ${errorData.detail || "Unknown error"}`,
+ source: "editor",
+ search_space_id: searchSpaceId,
+ log_metadata: {
+ document_id: note.id,
+ is_new_note: true,
+ action: "save",
+ http_status: response.status,
+ },
+ });
+ } catch (err) {
+ console.error("Failed to create log:", err);
+ }
+
+ 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");
+ // Log successful note creation
+ try {
+ await createLog({
+ level: "INFO",
+ status: "SUCCESS",
+ message: `Note created successfully: ${title}`,
+ source: "editor",
+ search_space_id: searchSpaceId,
+ log_metadata: {
+ document_id: note.id,
+ is_new_note: true,
+ action: "save",
+ title: title,
+ },
+ });
+ } catch (err) {
+ console.error("Failed to create log:", err);
+ }
+
+ setHasUnsavedChanges(false);
+ toast.success("Note created successfully! Reindexing in background...");
+
+ // Invalidate notes query to refresh the sidebar
+ queryClient.invalidateQueries({
+ queryKey: ["notes", String(searchSpaceId)],
+ });
+
+ // Update URL to reflect the new document ID without navigation
+ window.history.replaceState({}, "", `/dashboard/${searchSpaceId}/editor/${note.id}`);
+ // Update document state to reflect the new ID
+ setDocument({
+ document_id: note.id,
+ title: title,
+ document_type: "NOTE",
+ blocknote_document: editorContent,
+ updated_at: new Date().toISOString(),
+ });
+ } 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" }));
+
+ // Log save error
+ try {
+ await createLog({
+ level: "ERROR",
+ status: "FAILED",
+ message: `Failed to save document ${documentId}: ${errorData.detail || "Unknown error"}`,
+ source: "editor",
+ search_space_id: searchSpaceId,
+ log_metadata: {
+ document_id: documentId,
+ action: "save",
+ http_status: response.status,
+ },
+ });
+ } catch (err) {
+ console.error("Failed to create log:", err);
+ }
+
+ throw new Error(errorData.detail || "Failed to save document");
+ }
+
+ // Log successful save
+ try {
+ await createLog({
+ level: "INFO",
+ status: "SUCCESS",
+ message: `Document ${documentId} saved successfully`,
+ source: "editor",
+ search_space_id: searchSpaceId,
+ log_metadata: {
+ document_id: documentId,
+ action: "save",
+ title: document?.title,
+ },
+ });
+ } catch (err) {
+ console.error("Failed to create log:", err);
+ }
+
+ setHasUnsavedChanges(false);
+ toast.success("Document saved! Reindexing in background...");
+
+ // Invalidate notes query when updating notes to refresh the sidebar
+ if (isNote) {
+ queryClient.invalidateQueries({
+ queryKey: ["notes", String(searchSpaceId)],
+ });
+ }
}
-
- 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.";
+
+ // Log save error
+ try {
+ await createLog({
+ level: "ERROR",
+ status: "FAILED",
+ message: `Error saving document: ${errorMessage}`,
+ source: "editor",
+ search_space_id: searchSpaceId,
+ log_metadata: {
+ document_id: isNewNote ? null : documentId,
+ is_new_note: isNewNote,
+ action: "save",
+ error_type: "save_exception",
+ },
+ });
+ } catch (err) {
+ console.error("Failed to create log:", err);
+ }
+
+ setError(errorMessage);
+ toast.error(errorMessage);
} finally {
setSaving(false);
}
};
- 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();
+ router.push(`/dashboard/${searchSpaceId}/researcher`);
}
};
+ const handleConfirmLeave = () => {
+ setShowUnsavedDialog(false);
+ router.push(`/dashboard/${searchSpaceId}/researcher`);
+ };
+
if (loading) {
return (
@@ -182,9 +491,13 @@ export default function EditorPage() {
{error}
-
@@ -193,7 +506,7 @@ export default function EditorPage() {
);
}
- if (!document) {
+ if (!document && !isNewNote) {
return (
@@ -217,26 +530,26 @@ export default function EditorPage() {
-
{document.title}
+
{displayTitle}
{hasUnsavedChanges &&
Unsaved changes
}
-
-
- Cancel
+
+
+ Back
{saving ? (
<>
- Saving...
+ {isNewNote ? "Creating..." : "Saving..."}
>
) : (
<>
- Save & Exit
+ Save
>
)}
@@ -246,11 +559,43 @@ export default function EditorPage() {
{/* Editor Container */}
+ {error && (
+
+
+
+ )}
-
+
+
+ {/* Unsaved Changes Dialog */}
+
+
+
+ Unsaved Changes
+
+ You have unsaved changes. Are you sure you want to leave?
+
+
+
+ Cancel
+ OK
+
+
+
);
}
diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx
index d3d88a47a..ed39593e3 100644
--- a/surfsense_web/app/dashboard/page.tsx
+++ b/surfsense_web/app/dashboard/page.tsx
@@ -156,7 +156,12 @@ const DashboardPage = () => {
},
};
- const { data: searchSpaces = [], isLoading: loading, error, refetch: refreshSearchSpaces } = useAtomValue(searchSpacesAtom);
+ const {
+ data: searchSpaces = [],
+ isLoading: loading,
+ error,
+ refetch: refreshSearchSpaces,
+ } = useAtomValue(searchSpacesAtom);
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom);
@@ -171,7 +176,7 @@ const DashboardPage = () => {
};
if (loading) return ;
- if (error) return ;
+ if (error) return ;
const handleDeleteSearchSpace = async (id: number) => {
await deleteSearchSpace({ id });
diff --git a/surfsense_web/components/BlockNoteEditor.tsx b/surfsense_web/components/BlockNoteEditor.tsx
index 8064a0dc4..dc033bc5a 100644
--- a/surfsense_web/components/BlockNoteEditor.tsx
+++ b/surfsense_web/components/BlockNoteEditor.tsx
@@ -10,31 +10,123 @@ 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 +135,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/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/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx
index 55bc8331c..ea717e125 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";
@@ -18,6 +19,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
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";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
@@ -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 { data: user } = useAtomValue(currentUserAtom);
+ // 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: 5, // Get 5 notes (changed from 10)
+ }),
+ 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,53 @@ 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(() => {
+ if (!notesData?.items) return [];
+
+ // Sort notes by updated_at (most recent first), fallback to created_at if updated_at is null
+ const sortedNotes = [...notesData.items].sort((a, b) => {
+ const dateA = a.updated_at
+ ? new Date(a.updated_at).getTime()
+ : new Date(a.created_at).getTime();
+ const dateB = b.updated_at
+ ? new Date(b.updated_at).getTime()
+ : new Date(b.created_at).getTime();
+ return dateB - dateA; // Descending order (most recent first)
+ });
+
+ // Limit to 5 notes
+ return sortedNotes.slice(0, 5).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}/editor/new`);
+ }, [router, searchSpaceId]);
+
// Memoized updated navSecondary
const updatedNavSecondary = useMemo(() => {
const updated = [...navSecondary];
@@ -204,6 +270,7 @@ export function AppSidebarProvider({
navSecondary={navSecondary}
navMain={navMain}
RecentChats={[]}
+ RecentNotes={[]}
pageUsage={pageUsage}
/>
);
@@ -216,6 +283,8 @@ export function AppSidebarProvider({
navSecondary={updatedNavSecondary}
navMain={navMain}
RecentChats={displayChats}
+ RecentNotes={recentNotes}
+ onAddNote={handleAddNote}
pageUsage={pageUsage}
/>
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..f2065edef
--- /dev/null
+++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx
@@ -0,0 +1,397 @@
+"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";
+import { createPortal } from "react-dom";
+
+// 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")}
+ >
+
+ {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]
+ );
+
+ const sidebarContent = (
+ {
+ // 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"
+ >
+
+ {t("create_new_note") || "Create a new note"}
+
+ ) : (
+
+
+ {t("no_notes") || "No notes yet"}
+
+ )}
+
+ )}
+
+
+
+
+
+ {/* Footer with Add Note button */}
+ {onAddNote && (
+
+
{
+ onAddNote();
+ onOpenChange(false);
+ }}
+ className="w-full"
+ size="sm"
+ >
+
+ {t("create_new_note") || "Create a new note"}
+
+
+ )}
+
+
+ );
+
+ // Render sidebar via portal to avoid stacking context issues
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ return createPortal(sidebarContent, document.body);
+}
diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx
index 6f86d3808..f1d690c7f 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";
@@ -116,6 +115,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";
@@ -139,13 +139,13 @@ export const iconMap: Record = {
MessageCircleMore,
Settings2,
SquareLibrary,
+ FileText,
SquareTerminal,
AlertCircle,
Info,
ExternalLink,
Trash2,
Podcast,
- FileText,
Users,
};
@@ -210,6 +210,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 {
@@ -241,6 +255,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;
@@ -250,6 +276,7 @@ interface AppSidebarProps extends React.ComponentProps {
pagesUsed: number;
pagesLimit: number;
};
+ onAddNote?: () => void;
}
// Memoized AppSidebar component for better performance
@@ -258,7 +285,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();
@@ -296,6 +325,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");
@@ -413,6 +452,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..60e5d9c12
--- /dev/null
+++ b/surfsense_web/components/sidebar/nav-notes.tsx
@@ -0,0 +1,246 @@
+"use client";
+
+import {
+ ChevronRight,
+ ExternalLink,
+ Eye,
+ FileText,
+ type LucideIcon,
+ MoreHorizontal,
+ Plus,
+ RefreshCw,
+ Share,
+ Trash2,
+} from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useCallback, useState, useRef } 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";
+import { AllNotesSidebar } from "./all-notes-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;
+ searchSpaceId?: string;
+}
+
+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) => {
+ 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" : ""}`}
+ >
+
+ {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"}
+
+
+
+ {searchSpaceId && notes.length > 0 && (
+
{
+ e.stopPropagation();
+ // Clear any pending close timeout
+ if (hoverTimeoutRef.current) {
+ clearTimeout(hoverTimeoutRef.current);
+ hoverTimeoutRef.current = null;
+ }
+ setIsAllNotesSidebarOpen(true);
+ }}
+ onMouseLeave={(e) => {
+ e.stopPropagation();
+ // Add a small delay before closing to allow moving to the sidebar
+ hoverTimeoutRef.current = setTimeout(() => {
+ setIsAllNotesSidebarOpen(false);
+ }, 200);
+ }}
+ aria-label="View all notes"
+ className="text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden relative"
+ >
+
+
+ )}
+ {onAddNote && (
+
{
+ e.stopPropagation();
+ onAddNote();
+ }}
+ aria-label="Add note"
+ className="text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden relative"
+ >
+
+
+ )}
+
+
+
+
+
+ {/* 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"}
+
+ )}
+
+ )}
+
+
+
+
+ {searchSpaceId && (
+
+ )}
+
+ );
+}
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..74e0e4671 100644
--- a/surfsense_web/components/sidebar/page-usage-display.tsx
+++ b/surfsense_web/components/sidebar/page-usage-display.tsx
@@ -60,4 +60,4 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
);
-}
+}
\ No newline at end of file
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":
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..376e76a07 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -642,7 +642,13 @@
"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",
+ "all_notes": "All Notes",
+ "no_notes": "No notes yet",
+ "create_new_note": "Create a new note",
+ "error_loading_notes": "Error loading notes",
+ "loading": "Loading..."
},
"errors": {
"something_went_wrong": "Something went wrong",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index 5fe2239cf..e9a50930c 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -642,7 +642,13 @@
"no_chats_found": "未找到对话",
"no_recent_chats": "暂无最近对话",
"view_all_chats": "查看所有对话",
- "search_space": "搜索空间"
+ "search_space": "搜索空间",
+ "notes": "笔记",
+ "all_notes": "所有笔记",
+ "no_notes": "暂无笔记",
+ "create_new_note": "创建新笔记",
+ "error_loading_notes": "加载笔记时出错",
+ "loading": "加载中..."
},
"errors": {
"something_went_wrong": "出错了",