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

}
- @@ -246,11 +559,43 @@ export default function EditorPage() { {/* Editor Container */}
+ {error && ( + +
+ +

{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 && ( +
+ +
+ )} +
+
+ ); + + // 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 && ( + + )} + {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"} + + )} + + )} + + + +
+ {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": "出错了",