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/agents/researcher/nodes.py b/surfsense_backend/app/agents/researcher/nodes.py index 491840589..35857c004 100644 --- a/surfsense_backend/app/agents/researcher/nodes.py +++ b/surfsense_backend/app/agents/researcher/nodes.py @@ -492,6 +492,7 @@ async def fetch_documents_by_ids( "CLICKUP_CONNECTOR": "ClickUp (Selected)", "AIRTABLE_CONNECTOR": "Airtable (Selected)", "LUMA_CONNECTOR": "Luma Events (Selected)", + "NOTE": "Notes (Selected)", } source_object = { @@ -1162,6 +1163,33 @@ async def fetch_relevant_documents( } ) + elif connector == "NOTE": + ( + source_object, + notes_chunks, + ) = await connector_service.search_notes( + user_query=reformulated_query, + search_space_id=search_space_id, + top_k=top_k, + start_date=start_date, + end_date=end_date, + ) + + # Add to sources and raw documents + if source_object: + all_sources.append(source_object) + all_raw_documents.extend(notes_chunks) + + # Stream found document count + if streaming_service and writer: + writer( + { + "yield_value": streaming_service.format_terminal_info_delta( + f"📝 Found {len(notes_chunks)} Notes related to your query" + ) + } + ) + except Exception as e: logging.error("Error in search_airtable: %s", traceback.format_exc()) error_message = f"Error searching connector {connector}: {e!s}" diff --git a/surfsense_backend/app/agents/researcher/utils.py b/surfsense_backend/app/agents/researcher/utils.py index 3666d8b8a..ea3c99547 100644 --- a/surfsense_backend/app/agents/researcher/utils.py +++ b/surfsense_backend/app/agents/researcher/utils.py @@ -34,6 +34,7 @@ def get_connector_emoji(connector_name: str) -> str: "LUMA_CONNECTOR": "✨", "ELASTICSEARCH_CONNECTOR": "⚡", "WEBCRAWLER_CONNECTOR": "🌐", + "NOTE": "📝", } return connector_emojis.get(connector_name, "🔎") @@ -59,6 +60,7 @@ def get_connector_friendly_name(connector_name: str) -> str: "LUMA_CONNECTOR": "Luma", "ELASTICSEARCH_CONNECTOR": "Elasticsearch", "WEBCRAWLER_CONNECTOR": "Web Pages", + "NOTE": "Notes", } return connector_friendly_names.get(connector_name, connector_name) 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/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index a959db5e6..d06217fc4 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -266,12 +266,27 @@ async def read_documents( document_type=doc.document_type, document_metadata=doc.document_metadata, content=doc.content, + content_hash=doc.content_hash, + unique_identifier_hash=doc.unique_identifier_hash, created_at=doc.created_at, + updated_at=doc.updated_at, search_space_id=doc.search_space_id, ) ) - return PaginatedResponse(items=api_documents, total=total) + # Calculate pagination info + actual_page = ( + page if page is not None else (offset // page_size if page_size > 0 else 0) + ) + has_more = (offset + len(api_documents)) < total if page_size > 0 else False + + return PaginatedResponse( + items=api_documents, + total=total, + page=actual_page, + page_size=page_size, + has_more=has_more, + ) except HTTPException: raise except Exception as e: @@ -385,12 +400,27 @@ async def search_documents( document_type=doc.document_type, document_metadata=doc.document_metadata, content=doc.content, + content_hash=doc.content_hash, + unique_identifier_hash=doc.unique_identifier_hash, created_at=doc.created_at, + updated_at=doc.updated_at, search_space_id=doc.search_space_id, ) ) - return PaginatedResponse(items=api_documents, total=total) + # Calculate pagination info + actual_page = ( + page if page is not None else (offset // page_size if page_size > 0 else 0) + ) + has_more = (offset + len(api_documents)) < total if page_size > 0 else False + + return PaginatedResponse( + items=api_documents, + total=total, + page=actual_page, + page_size=page_size, + has_more=has_more, + ) except HTTPException: raise except Exception as e: @@ -510,7 +540,10 @@ async def get_document_by_chunk_id( document_type=document.document_type, document_metadata=document.document_metadata, content=document.content, + content_hash=document.content_hash, + unique_identifier_hash=document.unique_identifier_hash, created_at=document.created_at, + updated_at=document.updated_at, search_space_id=document.search_space_id, chunks=sorted_chunks, ) @@ -559,7 +592,10 @@ async def read_document( document_type=document.document_type, document_metadata=document.document_metadata, content=document.content, + content_hash=document.content_hash, + unique_identifier_hash=document.unique_identifier_hash, created_at=document.created_at, + updated_at=document.updated_at, search_space_id=document.search_space_id, ) except HTTPException: @@ -614,7 +650,10 @@ async def update_document( document_type=db_document.document_type, document_metadata=db_document.document_metadata, content=db_document.content, + content_hash=db_document.content_hash, + unique_identifier_hash=db_document.unique_identifier_hash, created_at=db_document.created_at, + updated_at=db_document.updated_at, search_space_id=db_document.search_space_id, ) except HTTPException: diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index 7b7a15c13..a0e7b59c1 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, } @@ -147,6 +173,43 @@ async def save_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 document.updated_at = datetime.now(UTC) diff --git a/surfsense_backend/app/routes/notes_routes.py b/surfsense_backend/app/routes/notes_routes.py new file mode 100644 index 000000000..5bb0a88a9 --- /dev/null +++ b/surfsense_backend/app/routes/notes_routes.py @@ -0,0 +1,235 @@ +""" +Notes routes for creating and managing BlockNote documents. +""" + +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, 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 + + 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_backend/app/schemas/documents.py b/surfsense_backend/app/schemas/documents.py index fc83d24be..e1e8b9248 100644 --- a/surfsense_backend/app/schemas/documents.py +++ b/surfsense_backend/app/schemas/documents.py @@ -46,7 +46,10 @@ class DocumentRead(BaseModel): document_type: DocumentType document_metadata: dict content: str # Changed to string to match frontend + content_hash: str + unique_identifier_hash: str | None created_at: datetime + updated_at: datetime | None search_space_id: int model_config = ConfigDict(from_attributes=True) @@ -61,3 +64,6 @@ class DocumentWithChunksRead(DocumentRead): class PaginatedResponse[T](BaseModel): items: list[T] total: int + page: int + page_size: int + has_more: bool diff --git a/surfsense_backend/app/services/connector_service.py b/surfsense_backend/app/services/connector_service.py index cac1b7f47..61bc08b40 100644 --- a/surfsense_backend/app/services/connector_service.py +++ b/surfsense_backend/app/services/connector_service.py @@ -2360,6 +2360,75 @@ class ConnectorService: return result_object, elasticsearch_docs + async def search_notes( + self, + user_query: str, + search_space_id: int, + top_k: int = 20, + start_date: datetime | None = None, + end_date: datetime | None = None, + ) -> tuple: + """ + Search for Notes and return both the source information and langchain documents. + + Uses combined chunk-level and document-level hybrid search with RRF fusion. + + Args: + user_query: The user's query + search_space_id: The search space ID to search in + top_k: Maximum number of results to return + start_date: Optional start date for filtering documents by updated_at + end_date: Optional end date for filtering documents by updated_at + + Returns: + tuple: (sources_info, langchain_documents) + """ + notes_docs = await self._combined_rrf_search( + query_text=user_query, + search_space_id=search_space_id, + document_type="NOTE", + top_k=top_k, + start_date=start_date, + end_date=end_date, + ) + + # Early return if no results + if not notes_docs: + return { + "id": 51, + "name": "Notes", + "type": "NOTE", + "sources": [], + }, [] + + def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str: + return doc_info.get("title", "Untitled Note") + + def _url_fn(_doc_info: dict[str, Any], _metadata: dict[str, Any]) -> str: + return "" # Notes don't have URLs + + def _description_fn( + chunk: dict[str, Any], _doc_info: dict[str, Any], _metadata: dict[str, Any] + ) -> str: + return self._chunk_preview(chunk.get("content", ""), limit=200) + + sources_list = self._build_chunk_sources_from_documents( + notes_docs, + title_fn=_title_fn, + url_fn=_url_fn, + description_fn=_description_fn, + ) + + # Create result object + result_object = { + "id": 51, + "name": "Notes", + "type": "NOTE", + "sources": sources_list, + } + + return result_object, notes_docs + async def search_bookstack( self, user_query: str, diff --git a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py index 8ab5309f2..b9d4c3b95 100644 --- a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py @@ -3,6 +3,7 @@ import logging from sqlalchemy import delete, select +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import selectinload from sqlalchemy.pool import NullPool @@ -11,6 +12,7 @@ from app.celery_app import celery_app from app.config import config from app.db import Document from app.services.llm_service import get_user_long_context_llm +from app.services.task_logging_service import TaskLoggingService from app.utils.blocknote_converter import convert_blocknote_to_markdown from app.utils.document_converters import ( create_document_chunks, @@ -53,21 +55,42 @@ def reindex_document_task(self, document_id: int, user_id: str): async def _reindex_document(document_id: int, user_id: str): """Async function to reindex a document.""" async with get_celery_session_maker()() as session: + # First, get the document to get search_space_id for logging + result = await session.execute( + select(Document) + .options(selectinload(Document.chunks)) + .where(Document.id == document_id) + ) + document = result.scalars().first() + + if not document: + logger.error(f"Document {document_id} not found") + return + + # Initialize task logger + task_logger = TaskLoggingService(session, document.search_space_id) + + # Log task start + log_entry = await task_logger.log_task_start( + task_name="document_reindex", + source="editor", + message=f"Starting reindex for document: {document.title}", + metadata={ + "document_id": document_id, + "document_type": document.document_type.value, + "title": document.title, + "user_id": user_id, + }, + ) + try: - # Get document - result = await session.execute( - select(Document) - .options(selectinload(Document.chunks)) # Eagerly load chunks - .where(Document.id == document_id) - ) - document = result.scalars().first() - - if not document: - logger.error(f"Document {document_id} not found") - return - if not document.blocknote_document: - logger.warning(f"Document {document_id} has no BlockNote content") + await task_logger.log_task_failure( + log_entry, + f"Document {document_id} has no BlockNote content to reindex", + "No BlockNote content", + {"error_type": "NoBlockNoteContent"}, + ) return logger.info(f"Reindexing document {document_id} ({document.title})") @@ -78,7 +101,12 @@ async def _reindex_document(document_id: int, user_id: str): ) if not markdown_content: - logger.error(f"Failed to convert document {document_id} to markdown") + await task_logger.log_task_failure( + log_entry, + f"Failed to convert document {document_id} to markdown", + "Markdown conversion failed", + {"error_type": "ConversionError"}, + ) return # 2. Delete old chunks explicitly @@ -118,9 +146,39 @@ async def _reindex_document(document_id: int, user_id: str): await session.commit() + # Log success + await task_logger.log_task_success( + log_entry, + f"Successfully reindexed document: {document.title}", + { + "chunks_created": len(new_chunks), + "document_id": document_id, + }, + ) + logger.info(f"Successfully reindexed document {document_id}") + except SQLAlchemyError as db_error: + await session.rollback() + await task_logger.log_task_failure( + log_entry, + f"Database error during reindex for document {document_id}", + str(db_error), + {"error_type": "SQLAlchemyError"}, + ) + logger.error( + f"Database error reindexing document {document_id}: {db_error}", + exc_info=True, + ) + raise + except Exception as e: await session.rollback() + await task_logger.log_task_failure( + log_entry, + f"Failed to reindex document: {document.title}", + str(e), + {"error_type": type(e).__name__}, + ) logger.error(f"Error reindexing document {document_id}: {e}", exc_info=True) raise diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 028c0efdc..82197921a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -14,7 +14,6 @@ import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContain import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; -import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; @@ -224,7 +223,6 @@ export function DashboardClientLayout({
- {/* Only show artifacts toggle on researcher page */} {isResearcherPage && (
- + { + 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 [document, setDocument] = useState(null); const [loading, setLoading] = useState(true); @@ -29,10 +74,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,16 +112,17 @@ 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"; + 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."; + setError(errorMsg); setLoading(false); return; } @@ -70,9 +132,9 @@ export default function EditorPage() { 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."; + setError(errorMessage); } finally { setLoading(false); } @@ -81,7 +143,7 @@ export default function EditorPage() { if (documentId) { fetchDocument(); } - }, [documentId, params.search_space_id]); + }, [documentId, params.search_space_id, isNewNote]); // Track changes to mark as unsaved useEffect(() => { @@ -90,9 +152,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 +175,121 @@ export default function EditorPage() { return; } - if (!editorContent) { - toast.error("No content to save"); - return; - } - setSaving(true); + setError(null); + try { - // Save blocknote_document and trigger reindexing in background - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ blocknote_document: editorContent }), + // If this is a new note, create it first + if (isNewNote) { + const title = extractTitleFromBlockNote(editorContent); + + // Create the note first + const note = await notesApiService.createNote({ + search_space_id: searchSpaceId, + title: title, + blocknote_document: editorContent || undefined, + }); + + // If there's content, save it properly and trigger reindexing + if (editorContent) { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blocknote_document: editorContent }), + } + ); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ detail: "Failed to save document" })); + throw new Error(errorData.detail || "Failed to save document"); + } } - ); - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ detail: "Failed to save document" })); - throw new Error(errorData.detail || "Failed to save document"); + setHasUnsavedChanges(false); + toast.success("Note created successfully! Reindexing in background..."); + + // 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" })); + throw new Error(errorData.detail || "Failed to save document"); + } + + 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."; + 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 +320,13 @@ export default function EditorPage() { {error} - @@ -193,7 +335,7 @@ export default function EditorPage() { ); } - if (!document) { + if (!document && !isNewNote) { return (
@@ -217,26 +359,26 @@ export default function EditorPage() {
-

{document.title}

+

{displayTitle}

{hasUnsavedChanges &&

Unsaved changes

}
- @@ -244,13 +386,45 @@ 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/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index dd3f25218..5f9c4dbad 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { type ColumnDef, type ColumnFiltersState, @@ -11,6 +12,7 @@ import { type SortingState, useReactTable, } from "@tanstack/react-table"; +import { useAtomValue } from "jotai"; import { ArrowLeft, Calendar, @@ -44,6 +46,12 @@ import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; +import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; +import { + createRoleMutationAtom, + deleteRoleMutationAtom, + updateRoleMutationAtom, +} from "@/atoms/roles/roles-mutation.atoms"; import { AlertDialog, AlertDialogAction, @@ -99,24 +107,28 @@ import { } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import type { + CreateRoleRequest, + DeleteRoleRequest, + Role, + UpdateRoleRequest, +} from "@/contracts/types/roles.types"; import { type Invite, type InviteCreate, type Member, - type Role, - type RoleCreate, useInvites, useMembers, - usePermissions, - useRoles, useUserAccess, } from "@/hooks/use-rbac"; +import { rolesApiService } from "@/lib/apis/roles-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cn } from "@/lib/utils"; // Animation variants const fadeInUp = { hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" } }, + visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" as const } }, }; const staggerContainer = { @@ -132,7 +144,7 @@ const cardVariants = { visible: { opacity: 1, scale: 1, - transition: { type: "spring", stiffness: 300, damping: 30 }, + transition: { type: "spring" as const, stiffness: 300, damping: 30 }, }, }; @@ -150,14 +162,55 @@ export default function TeamManagementPage() { updateMemberRole, removeMember, } = useMembers(searchSpaceId); + + const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom); + const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom); + const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom); + + const handleUpdateRole = useCallback( + async (roleId: number, data: { permissions?: string[] }): Promise => { + const request: UpdateRoleRequest = { + search_space_id: searchSpaceId, + role_id: roleId, + data: data, + }; + return await updateRole(request); + }, + [updateRole, searchSpaceId] + ); + + const handleDeleteRole = useCallback( + async (roleId: number): Promise => { + const request: DeleteRoleRequest = { + search_space_id: searchSpaceId, + role_id: roleId, + }; + await deleteRole(request); + return true; + }, + [deleteRole, searchSpaceId] + ); + + const handleCreateRole = useCallback( + async (roleData: CreateRoleRequest["data"]): Promise => { + const request: CreateRoleRequest = { + search_space_id: searchSpaceId, + data: roleData, + }; + return await createRole(request); + }, + [createRole, searchSpaceId] + ); + const { - roles, - loading: rolesLoading, - fetchRoles, - createRole, - updateRole, - deleteRole, - } = useRoles(searchSpaceId); + data: roles = [], + isLoading: rolesLoading, + refetch: fetchRoles, + } = useQuery({ + queryKey: cacheKeys.roles.all(searchSpaceId.toString()), + queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }), + enabled: !!searchSpaceId, + }); const { invites, loading: invitesLoading, @@ -165,7 +218,19 @@ export default function TeamManagementPage() { createInvite, revokeInvite, } = useInvites(searchSpaceId); - const { groupedPermissions, loading: permissionsLoading } = usePermissions(); + + const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom); + const permissions = permissionsData?.permissions || []; + const groupedPermissions = useMemo(() => { + const groups: Record = {}; + for (const perm of permissions) { + if (!groups[perm.category]) { + groups[perm.category] = []; + } + groups[perm.category].push(perm); + } + return groups; + }, [permissions]); const canManageMembers = hasPermission("members:view"); const canManageRoles = hasPermission("roles:read"); @@ -329,7 +394,7 @@ export default function TeamManagementPage() { {activeTab === "roles" && hasPermission("roles:create") && ( )}
@@ -351,8 +416,8 @@ export default function TeamManagementPage() { roles={roles} groupedPermissions={groupedPermissions} loading={rolesLoading} - onUpdateRole={updateRole} - onDeleteRole={deleteRole} + onUpdateRole={handleUpdateRole} + onDeleteRole={handleDeleteRole} canUpdate={hasPermission("roles:update")} canDelete={hasPermission("roles:delete")} /> @@ -663,7 +728,12 @@ function RolesTab({ {canUpdate && ( - + { + // TODO: Implement edit role dialog/modal + console.log("Edit role not yet implemented", role); + }} + > Edit Role @@ -882,7 +952,7 @@ function InvitesTab({ size="sm" className="gap-2" onClick={() => copyInviteLink(invite)} - disabled={isInactive} + disabled={Boolean(isInactive)} > {copiedId === invite.id ? ( <> @@ -1158,7 +1228,7 @@ function CreateRoleDialog({ onCreateRole, }: { groupedPermissions: Record; - onCreateRole: (data: RoleCreate) => Promise; + onCreateRole: (data: CreateRoleRequest["data"]) => Promise; }) { const [open, setOpen] = useState(false); const [creating, setCreating] = useState(false); @@ -1177,7 +1247,7 @@ function CreateRoleDialog({ try { await onCreateRole({ name: name.trim(), - description: description.trim() || undefined, + description: description.trim() || null, permissions: selectedPermissions, is_default: isDefault, }); diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index b1525a9db..dbf5b7155 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -8,6 +8,9 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; +import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; +import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { Logo } from "@/components/Logo"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { UserDropdown } from "@/components/UserDropdown"; @@ -35,9 +38,6 @@ import { } from "@/components/ui/card"; import { Spotlight } from "@/components/ui/spotlight"; import { Tilt } from "@/components/ui/tilt"; -import { useUser } from "@/hooks"; -import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { authenticatedFetch } from "@/lib/auth-utils"; /** @@ -156,11 +156,15 @@ 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); - // Fetch user details - const { user, loading: isLoadingUser, error: userError } = useUser(); + const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom); // Create user object for UserDropdown const customUser = { @@ -172,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/app/dashboard/searchspaces/page.tsx b/surfsense_web/app/dashboard/searchspaces/page.tsx index 76e17c0ce..ae3956a54 100644 --- a/surfsense_web/app/dashboard/searchspaces/page.tsx +++ b/surfsense_web/app/dashboard/searchspaces/page.tsx @@ -3,8 +3,8 @@ import { useAtomValue } from "jotai"; import { motion } from "motion/react"; import { useRouter } from "next/navigation"; -import { SearchSpaceForm } from "@/components/search-space-form"; import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; +import { SearchSpaceForm } from "@/components/search-space-form"; export default function SearchSpacesPage() { const router = useRouter(); diff --git a/surfsense_web/atoms/permissions/permissions-query.atoms.ts b/surfsense_web/atoms/permissions/permissions-query.atoms.ts new file mode 100644 index 000000000..335ddd77d --- /dev/null +++ b/surfsense_web/atoms/permissions/permissions-query.atoms.ts @@ -0,0 +1,13 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { permissionsApiService } from "@/lib/apis/permissions-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const permissionsAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.permissions.all(), + staleTime: 10 * 60 * 1000, // 10 minutes + queryFn: async () => { + return permissionsApiService.getPermissions(); + }, + }; +}); diff --git a/surfsense_web/atoms/roles/roles-mutation.atoms.ts b/surfsense_web/atoms/roles/roles-mutation.atoms.ts new file mode 100644 index 000000000..ddbc68ca2 --- /dev/null +++ b/surfsense_web/atoms/roles/roles-mutation.atoms.ts @@ -0,0 +1,70 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { + CreateRoleRequest, + CreateRoleResponse, + DeleteRoleRequest, + DeleteRoleResponse, + UpdateRoleRequest, + UpdateRoleResponse, +} from "@/contracts/types/roles.types"; +import { rolesApiService } from "@/lib/apis/roles-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; + +export const createRoleMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: CreateRoleRequest) => { + return rolesApiService.createRole(request); + }, + onSuccess: (_: CreateRoleResponse, request: CreateRoleRequest) => { + toast.success("Role created successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.all(request.search_space_id.toString()), + }); + }, + onError: () => { + toast.error("Failed to create role"); + }, + }; +}); + +export const updateRoleMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: UpdateRoleRequest) => { + return rolesApiService.updateRole(request); + }, + onSuccess: (_: UpdateRoleResponse, request: UpdateRoleRequest) => { + toast.success("Role updated successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.all(request.search_space_id.toString()), + }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.byId( + request.search_space_id.toString(), + request.role_id.toString() + ), + }); + }, + onError: () => { + toast.error("Failed to update role"); + }, + }; +}); + +export const deleteRoleMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: DeleteRoleRequest) => { + return rolesApiService.deleteRole(request); + }, + onSuccess: (_: DeleteRoleResponse, request: DeleteRoleRequest) => { + toast.success("Role deleted successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.all(request.search_space_id.toString()), + }); + }, + onError: () => { + toast.error("Failed to delete role"); + }, + }; +}); diff --git a/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts b/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts index ea1415869..62f23507b 100644 --- a/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts +++ b/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts @@ -2,13 +2,13 @@ import { atomWithMutation } from "jotai-tanstack-query"; import { toast } from "sonner"; import type { CreateSearchSpaceRequest, - UpdateSearchSpaceRequest, DeleteSearchSpaceRequest, + UpdateSearchSpaceRequest, } from "@/contracts/types/search-space.types"; -import { activeSearchSpaceIdAtom } from "./search-space-query.atoms"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { queryClient } from "@/lib/query-client/client"; +import { activeSearchSpaceIdAtom } from "./search-space-query.atoms"; export const createSearchSpaceMutationAtom = atomWithMutation(() => { return { diff --git a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts index 1f03e25a2..4aa024e93 100644 --- a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts +++ b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts @@ -1,5 +1,5 @@ -import { atomWithQuery } from "jotai-tanstack-query"; import { atom } from "jotai"; +import { atomWithQuery } from "jotai-tanstack-query"; import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; diff --git a/surfsense_web/atoms/user/user-query.atoms.ts b/surfsense_web/atoms/user/user-query.atoms.ts new file mode 100644 index 000000000..ea3e7ec49 --- /dev/null +++ b/surfsense_web/atoms/user/user-query.atoms.ts @@ -0,0 +1,13 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { userApiService } from "@/lib/apis/user-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const currentUserAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.user.current(), + staleTime: 5 * 60 * 1000, // 5 minutes + queryFn: async () => { + return userApiService.getMe(); + }, + }; +}); 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..abf77da5e 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -13,10 +14,9 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { useQuery } from "@tanstack/react-query"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; interface BreadcrumbItemInterface { label: string; @@ -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/onboard/setup-prompt-step.tsx b/surfsense_web/components/onboard/setup-prompt-step.tsx index 5e3683031..b53e49700 100644 --- a/surfsense_web/components/onboard/setup-prompt-step.tsx +++ b/surfsense_web/components/onboard/setup-prompt-step.tsx @@ -1,8 +1,10 @@ "use client"; +import { useAtomValue } from "jotai"; import { ChevronDown, ChevronUp, ExternalLink, Info, Sparkles, User } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -12,9 +14,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { authenticatedFetch } from "@/lib/auth-utils"; -import { useAtomValue } from "jotai"; interface SetupPromptStepProps { searchSpaceId: number; diff --git a/surfsense_web/components/settings/prompt-config-manager.tsx b/surfsense_web/components/settings/prompt-config-manager.tsx index dae842305..82456919c 100644 --- a/surfsense_web/components/settings/prompt-config-manager.tsx +++ b/surfsense_web/components/settings/prompt-config-manager.tsx @@ -1,5 +1,7 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; import { ChevronDown, ChevronUp, @@ -12,6 +14,7 @@ import { } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -23,19 +26,20 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { useQuery } from "@tanstack/react-query"; -import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { useAtomValue } from "jotai"; import { authenticatedFetch } from "@/lib/auth-utils"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; interface PromptConfigManagerProps { searchSpaceId: number; } export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) { - const { data: searchSpace, isLoading: loading, refetch: fetchSearchSpace } = useQuery({ + const { + data: searchSpace, + isLoading: loading, + refetch: fetchSearchSpace, + } = useQuery({ queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()), queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), enabled: !!searchSpaceId, diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index fce8697f7..ca05b0e3f 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -1,12 +1,15 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; 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"; import { chatsAtom } from "@/atoms/chats/chat-query.atoms"; import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { AppSidebar } from "@/components/sidebar/app-sidebar"; import { Button } from "@/components/ui/button"; import { @@ -17,8 +20,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { useUser } from "@/hooks"; -import { useQuery } from "@tanstack/react-query"; +import { notesApiService } from "@/lib/apis/notes-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -48,6 +50,7 @@ export function AppSidebarProvider({ }: AppSidebarProviderProps) { const t = useTranslations("dashboard"); const tCommon = useTranslations("common"); + const router = useRouter(); const setChatsQueryParams = useSetAtom(globalChatsQueryParamsAtom); const { data: chats, error: chatError, isLoading: isLoadingChats } = useAtomValue(chatsAtom); const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] = @@ -68,7 +71,23 @@ export function AppSidebarProvider({ enabled: !!searchSpaceId, }); - const { user } = useUser(); + 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); @@ -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..11c4f80ec --- /dev/null +++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { documentsApiService } from "@/lib/apis/documents-api.service"; +import { notesApiService } from "@/lib/apis/notes-api.service"; +import { cn } from "@/lib/utils"; + +interface AllNotesSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + searchSpaceId: string; + onAddNote?: () => void; +} + +export function AllNotesSidebar({ + open, + onOpenChange, + searchSpaceId, + onAddNote, +}: AllNotesSidebarProps) { + const t = useTranslations("sidebar"); + const router = useRouter(); + const queryClient = useQueryClient(); + const [deletingNoteId, setDeletingNoteId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); + + // Fetch all notes (when no search query) + const { + data: notesData, + error: notesError, + isLoading: isLoadingNotes, + } = useQuery({ + queryKey: ["all-notes", searchSpaceId], + queryFn: () => + notesApiService.getNotes({ + search_space_id: Number(searchSpaceId), + page_size: 1000, + }), + enabled: !!searchSpaceId && open && !debouncedSearchQuery, + }); + + // Search notes (when there's a search query) + const { + data: searchData, + error: searchError, + isLoading: isSearching, + } = useQuery({ + queryKey: ["search-notes", searchSpaceId, debouncedSearchQuery], + queryFn: () => + documentsApiService.searchDocuments({ + queryParams: { + search_space_id: Number(searchSpaceId), + document_types: ["NOTE"], + title: debouncedSearchQuery, + page_size: 100, + }, + }), + enabled: !!searchSpaceId && open && !!debouncedSearchQuery, + }); + + // Handle note navigation + const handleNoteClick = useCallback( + (noteId: number, noteSearchSpaceId: number) => { + router.push(`/dashboard/${noteSearchSpaceId}/editor/${noteId}`); + onOpenChange(false); + }, + [router, onOpenChange] + ); + + // Handle note deletion + const handleDeleteNote = useCallback( + async (noteId: number, noteSearchSpaceId: number) => { + setDeletingNoteId(noteId); + try { + await notesApiService.deleteNote({ + search_space_id: noteSearchSpaceId, + note_id: noteId, + }); + // Invalidate queries to refresh the list + queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] }); + } catch (error) { + console.error("Error deleting note:", error); + } finally { + setDeletingNoteId(null); + } + }, + [queryClient, searchSpaceId] + ); + + // Clear search + const handleClearSearch = useCallback(() => { + setSearchQuery(""); + }, []); + + // Determine which data to show + const isSearchMode = !!debouncedSearchQuery; + const isLoading = isSearchMode ? isSearching : isLoadingNotes; + const error = isSearchMode ? searchError : notesError; + + // Transform notes data - handle both regular notes and search results + const notes = useMemo(() => { + if (isSearchMode && searchData?.items) { + return searchData.items.map((doc) => ({ + id: doc.id, + title: doc.title, + search_space_id: doc.search_space_id, + })); + } + return notesData?.items ?? []; + }, [isSearchMode, searchData, notesData]); + + return ( + + + + {t("all_notes") || "All Notes"} + + {t("all_notes_description") || "Browse and manage all your notes"} + + + {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-8 h-9" + /> + {searchQuery && ( + + )} +
+
+ + +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+ {t("error_loading_notes") || "Error loading notes"} +
+ ) : notes.length > 0 ? ( +
+ {notes.map((note) => { + const isDeleting = deletingNoteId === note.id; + + return ( +
+ {/* Main clickable area for navigation */} + + + {/* Actions dropdown - separate from main click area */} + + + + + + handleDeleteNote(note.id, note.search_space_id)} + className="text-destructive focus:text-destructive" + > + + Delete + + + +
+ ); + })} +
+ ) : isSearchMode ? ( +
+ +

+ {t("no_results_found") || "No notes found"} +

+

+ {t("try_different_search") || "Try a different search term"} +

+
+ ) : ( +
+ +

+ {t("no_notes") || "No notes yet"} +

+ {onAddNote && ( + + )} +
+ )} +
+
+ + {/* Footer with Add Note button */} + {onAddNote && notes.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index cd42e6fe8..48e7c35b8 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAtomValue } from "jotai"; import { AlertCircle, BookOpen, @@ -24,11 +25,10 @@ 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"; - +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { DropdownMenu, DropdownMenuContent, @@ -38,7 +38,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useUser } from "@/hooks/use-user"; /** * Generates a consistent color based on a string (email) @@ -115,6 +114,7 @@ function UserAvatar({ email, size = 32 }: { email: string; size?: number }) { } import { NavMain } from "@/components/sidebar/nav-main"; +import { NavNotes } from "@/components/sidebar/nav-notes"; import { NavProjects } from "@/components/sidebar/nav-projects"; import { NavSecondary } from "@/components/sidebar/nav-secondary"; import { PageUsageDisplay } from "@/components/sidebar/page-usage-display"; @@ -138,13 +138,13 @@ export const iconMap: Record = { MessageCircleMore, Settings2, SquareLibrary, + FileText, SquareTerminal, AlertCircle, Info, ExternalLink, Trash2, Podcast, - FileText, Users, }; @@ -209,6 +209,20 @@ const defaultData = { id: 1003, }, ], + RecentNotes: [ + { + name: "Meeting Notes", + url: "#", + icon: "FileText", + id: 2001, + }, + { + name: "Project Ideas", + url: "#", + icon: "FileText", + id: 2002, + }, + ], }; interface AppSidebarProps extends React.ComponentProps { @@ -240,6 +254,18 @@ interface AppSidebarProps extends React.ComponentProps { onClick: () => void; }[]; }[]; + RecentNotes?: { + name: string; + url: string; + icon: string; + id?: number; + search_space_id?: number; + actions?: { + name: string; + icon: string; + onClick: () => void; + }[]; + }[]; user?: { name: string; email: string; @@ -249,6 +275,7 @@ interface AppSidebarProps extends React.ComponentProps { pagesUsed: number; pagesLimit: number; }; + onAddNote?: () => void; } // Memoized AppSidebar component for better performance @@ -257,12 +284,14 @@ 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(); const { theme, setTheme } = useTheme(); - const { user, loading: isLoadingUser } = useUser(); + const { data: user, isPending: isLoadingUser } = useAtomValue(currentUserAtom); const [isClient, setIsClient] = useState(false); useEffect(() => { @@ -295,6 +324,16 @@ export const AppSidebar = memo(function AppSidebar({ ); }, [RecentChats]); + // Process RecentNotes to resolve icon names to components + const processedRecentNotes = useMemo(() => { + return ( + RecentNotes?.map((item) => ({ + ...item, + icon: iconMap[item.icon] || FileText, + })) || [] + ); + }, [RecentNotes]); + // Get user display name from email const userDisplayName = user?.email ? user.email.split("@")[0] : "User"; const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown"); @@ -412,6 +451,14 @@ 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..b14ecea77 --- /dev/null +++ b/surfsense_web/components/sidebar/nav-notes.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { + ChevronRight, + FileText, + FolderOpen, + Loader2, + type LucideIcon, + MoreHorizontal, + Plus, + Trash2, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { cn } from "@/lib/utils"; +import { AllNotesSidebar } from "./all-notes-sidebar"; + +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; +} + +// Map of icon names to their components +const actionIconMap: Record = { + FileText, + Trash2, + MoreHorizontal, +}; + +export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) { + const t = useTranslations("sidebar"); + const router = useRouter(); + const [isDeleting, setIsDeleting] = useState(null); + const [isOpen, setIsOpen] = useState(defaultOpen); + const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); + + // Handle note deletion with loading state + const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => { + setIsDeleting(noteId); + try { + await deleteAction(); + } finally { + setIsDeleting(null); + } + }, []); + + // Handle note navigation + const handleNoteClick = useCallback( + (url: string) => { + router.push(url); + }, + [router] + ); + + return ( + + +
+ + + + {t("notes") || "Notes"} + + + + {/* Action buttons - always visible on hover */} +
+ {searchSpaceId && notes.length > 0 && ( + + )} + {onAddNote && ( + + )} +
+
+ + + + + {notes.length > 0 ? ( + notes.map((note) => { + const isDeletingNote = isDeleting === note.id; + + return ( + + {/* Main navigation button */} + handleNoteClick(note.url)} + disabled={isDeletingNote} + className={cn( + "pr-8", // Make room for the action button + isDeletingNote && "opacity-50" + )} + > + + {note.name} + + + {/* Actions dropdown - positioned absolutely */} + {note.actions && note.actions.length > 0 && ( +
+ + + + + + {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 focus:text-destructive" + : "" + } + > + + + {isDeletingNote && isDeleteAction + ? "Deleting..." + : action.name} + + + ); + })} + + +
+ )} +
+ ); + }) + ) : ( + + {onAddNote ? ( + + + {t("create_new_note") || "Create a new note"} + + ) : ( + + + {t("no_notes") || "No notes yet"} + + )} + + )} +
+
+
+
+ + {/* All Notes Sheet */} + {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/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx index 583ace11e..a12d26197 100644 --- a/surfsense_web/contracts/enums/connectorIcons.tsx +++ b/surfsense_web/contracts/enums/connectorIcons.tsx @@ -17,7 +17,17 @@ 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 +81,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/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts index abffc68a5..3ce5388dd 100644 --- a/surfsense_web/contracts/types/document.types.ts +++ b/surfsense_web/contracts/types/document.types.ts @@ -19,6 +19,7 @@ export const documentTypeEnum = z.enum([ "LUMA_CONNECTOR", "ELASTICSEARCH_CONNECTOR", "LINEAR_CONNECTOR", + "NOTE", ]); export const document = z.object({ @@ -27,7 +28,10 @@ export const document = z.object({ document_type: documentTypeEnum, document_metadata: z.record(z.string(), z.any()), content: z.string(), + content_hash: z.string(), + unique_identifier_hash: z.string().nullable(), created_at: z.string(), + updated_at: z.string().nullable(), search_space_id: z.number(), }); @@ -68,6 +72,9 @@ export const getDocumentsRequest = z.object({ export const getDocumentsResponse = z.object({ items: z.array(document), total: z.number(), + page: z.number(), + page_size: z.number(), + has_more: z.boolean(), }); /** @@ -118,6 +125,9 @@ export const searchDocumentsRequest = z.object({ export const searchDocumentsResponse = z.object({ items: z.array(document), total: z.number(), + page: z.number(), + page_size: z.number(), + has_more: z.boolean(), }); /** diff --git a/surfsense_web/contracts/types/invites.types.ts b/surfsense_web/contracts/types/invites.types.ts new file mode 100644 index 000000000..2a9460e53 --- /dev/null +++ b/surfsense_web/contracts/types/invites.types.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; +import { role } from "./roles.types"; + +export const invite = z.object({ + id: z.number(), + name: z.string().max(100).nullable().optional(), + invite_code: z.string(), + search_space_id: z.number(), + created_by_id: z.string().nullable(), + role_id: z.number().nullable(), + expires_at: z.string().nullable(), + max_uses: z.number().nullable(), + uses_count: z.number(), + is_active: z.boolean(), + created_at: z.string(), + role: role.nullable().optional(), +}); + +/** + * Create invite + */ +export const createInviteRequest = z.object({ + search_space_id: z.number(), + data: z.object({ + name: z.string().max(100).optional(), + role_id: z.number().nullable().optional(), + expires_at: z.string().nullable().optional(), + max_uses: z.number().nullable().optional(), + }), +}); + +export const createInviteResponse = invite; + +/** + * Get invites + */ +export const getInvitesRequest = z.object({ + search_space_id: z.number(), +}); + +export const getInvitesResponse = z.array(invite); + +/** + * Update invite + */ +export const updateInviteRequest = z.object({ + search_space_id: z.number(), + invite_id: z.number(), + data: z.object({ + name: z.string().max(100).optional(), + role_id: z.number().nullable().optional(), + expires_at: z.string().nullable().optional(), + max_uses: z.number().nullable().optional(), + is_active: z.boolean().optional(), + }), +}); + +export const updateInviteResponse = invite; + +/** + * Delete invite + */ +export const deleteInviteRequest = z.object({ + search_space_id: z.number(), + invite_id: z.number(), +}); + +export const deleteInviteResponse = z.object({ + message: z.string(), +}); + +/** + * Get invite info by code + */ +export const getInviteInfoRequest = z.object({ + invite_code: z.string(), +}); + +export const getInviteInfoResponse = z.object({ + invite_code: z.string(), + search_space_name: z.string(), + role_name: z.string().nullable(), + expires_at: z.string().nullable(), + is_valid: z.boolean(), +}); + +/** + * Accept invite + */ +export const acceptInviteRequest = z.object({ + invite_code: z.string(), +}); + +export const acceptInviteResponse = z.object({ + message: z.string(), + search_space_id: z.number(), +}); + +export type Invite = z.infer; +export type CreateInviteRequest = z.infer; +export type CreateInviteResponse = z.infer; +export type GetInvitesRequest = z.infer; +export type GetInvitesResponse = z.infer; +export type UpdateInviteRequest = z.infer; +export type UpdateInviteResponse = z.infer; +export type DeleteInviteRequest = z.infer; +export type DeleteInviteResponse = z.infer; +export type GetInviteInfoRequest = z.infer; +export type GetInviteInfoResponse = z.infer; +export type AcceptInviteRequest = z.infer; +export type AcceptInviteResponse = z.infer; diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts new file mode 100644 index 000000000..a6d6333ac --- /dev/null +++ b/surfsense_web/contracts/types/members.types.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import { role } from "./roles.types"; + +export const membership = z.object({ + id: z.number(), + user_id: z.string(), + search_space_id: z.number(), + role_id: z.number().nullable(), + is_owner: z.boolean(), + joined_at: z.string(), + created_at: z.string(), + role: role.nullable().optional(), + user_email: z.string().nullable().optional(), + user_is_active: z.boolean().nullable().optional(), +}); + +/** + * Get members + */ +export const getMembersRequest = z.object({ + search_space_id: z.number(), +}); + +export const getMembersResponse = z.array(membership); + +/** + * Update membership + */ +export const updateMembershipRequest = z.object({ + search_space_id: z.number(), + membership_id: z.number(), + data: z.object({ + role_id: z.number(), + }), +}); + +export const updateMembershipResponse = membership; + +/** + * Delete membership + */ +export const deleteMembershipRequest = z.object({ + search_space_id: z.number(), + membership_id: z.number(), +}); + +export const deleteMembershipResponse = z.object({ + message: z.string(), +}); + +/** + * Leave search space + */ +export const leaveSearchSpaceRequest = z.object({ + search_space_id: z.number(), +}); + +export const leaveSearchSpaceResponse = z.object({ + message: z.string(), +}); + +/** + * Get my access + */ +export const getMyAccessRequest = z.object({ + search_space_id: z.number(), +}); + +export const getMyAccessResponse = z.object({ + user_id: z.string(), + search_space_id: z.number(), + is_owner: z.boolean(), + permissions: z.array(z.string()), + role_name: z.string().nullable(), +}); + +export type Membership = z.infer; +export type GetMembersRequest = z.infer; +export type GetMembersResponse = z.infer; +export type UpdateMembershipRequest = z.infer; +export type UpdateMembershipResponse = z.infer; +export type DeleteMembershipRequest = z.infer; +export type DeleteMembershipResponse = z.infer; +export type LeaveSearchSpaceRequest = z.infer; +export type LeaveSearchSpaceResponse = z.infer; +export type GetMyAccessRequest = z.infer; +export type GetMyAccessResponse = z.infer; diff --git a/surfsense_web/contracts/types/permissions.types.ts b/surfsense_web/contracts/types/permissions.types.ts new file mode 100644 index 000000000..3f75192a3 --- /dev/null +++ b/surfsense_web/contracts/types/permissions.types.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const permissionInfo = z.object({ + value: z.string(), + name: z.string(), + category: z.string(), +}); + +/** + * Get permissions + */ +export const getPermissionsResponse = z.object({ + permissions: z.array(permissionInfo), +}); + +export type PermissionInfo = z.infer; +export type GetPermissionsResponse = z.infer; diff --git a/surfsense_web/contracts/types/roles.types.ts b/surfsense_web/contracts/types/roles.types.ts new file mode 100644 index 000000000..9008a859a --- /dev/null +++ b/surfsense_web/contracts/types/roles.types.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; + +export const role = z.object({ + id: z.number(), + name: z.string().min(1).max(100), + description: z.string().max(500).nullable(), + permissions: z.array(z.string()), + is_default: z.boolean(), + is_system_role: z.boolean(), + search_space_id: z.number(), + created_at: z.string(), +}); + +/** + * Create role + */ +export const createRoleRequest = z.object({ + search_space_id: z.number(), + data: role.pick({ + name: true, + description: true, + permissions: true, + is_default: true, + }), +}); + +export const createRoleResponse = role; + +/** + * Get roles + */ +export const getRolesRequest = z.object({ + search_space_id: z.number(), +}); + +export const getRolesResponse = z.array(role); + +/** + * Get role by ID + */ +export const getRoleByIdRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), +}); + +export const getRoleByIdResponse = role; + +/** + * Update role + */ +export const updateRoleRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), + data: role + .pick({ + name: true, + description: true, + permissions: true, + is_default: true, + }) + .partial(), +}); + +export const updateRoleResponse = role; + +/** + * Delete role + */ +export const deleteRoleRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), +}); + +export const deleteRoleResponse = z.object({ + message: z.string(), +}); + +export type Role = z.infer; +export type CreateRoleRequest = z.infer; +export type CreateRoleResponse = z.infer; +export type GetRolesRequest = z.infer; +export type GetRolesResponse = z.infer; +export type GetRoleByIdRequest = z.infer; +export type GetRoleByIdResponse = z.infer; +export type UpdateRoleRequest = z.infer; +export type UpdateRoleResponse = z.infer; +export type DeleteRoleRequest = z.infer; +export type DeleteRoleResponse = z.infer; diff --git a/surfsense_web/contracts/types/search-space.types.ts b/surfsense_web/contracts/types/search-space.types.ts index c0096b41c..b591fafbb 100644 --- a/surfsense_web/contracts/types/search-space.types.ts +++ b/surfsense_web/contracts/types/search-space.types.ts @@ -2,26 +2,26 @@ import { z } from "zod"; import { paginationQueryParams } from "."; export const searchSpace = z.object({ - id: z.number(), - name: z.string(), - description: z.string().nullable(), - created_at: z.string(), - user_id: z.string(), - citations_enabled: z.boolean(), - qna_custom_instructions: z.string().nullable(), - member_count: z.number(), - is_owner: z.boolean(), + id: z.number(), + name: z.string(), + description: z.string().nullable(), + created_at: z.string(), + user_id: z.string(), + citations_enabled: z.boolean(), + qna_custom_instructions: z.string().nullable(), + member_count: z.number(), + is_owner: z.boolean(), }); /** * Get search spaces */ export const getSearchSpacesRequest = z.object({ - queryParams: paginationQueryParams - .extend({ - owned_only: z.boolean().optional(), - }) - .nullish(), + queryParams: paginationQueryParams + .extend({ + owned_only: z.boolean().optional(), + }) + .nullish(), }); export const getSearchSpacesResponse = z.array(searchSpace); @@ -29,12 +29,10 @@ export const getSearchSpacesResponse = z.array(searchSpace); /** * Create search space */ -export const createSearchSpaceRequest = searchSpace - .pick({ name: true, description: true }) - .extend({ - citations_enabled: z.boolean().default(true).optional(), - qna_custom_instructions: z.string().nullable().optional(), - }); +export const createSearchSpaceRequest = searchSpace.pick({ name: true, description: true }).extend({ + citations_enabled: z.boolean().default(true).optional(), + qna_custom_instructions: z.string().nullable().optional(), +}); export const createSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true }); @@ -42,13 +40,13 @@ export const createSearchSpaceResponse = searchSpace.omit({ member_count: true, * Get community prompts */ export const getCommunityPromptsResponse = z.array( - z.object({ - key: z.string(), - value: z.string(), - author: z.string(), - link: z.string(), - category: z.string(), - }) + z.object({ + key: z.string(), + value: z.string(), + author: z.string(), + link: z.string(), + category: z.string(), + }) ); /** @@ -62,10 +60,10 @@ export const getSearchSpaceResponse = searchSpace.omit({ member_count: true, is_ * Update search space */ export const updateSearchSpaceRequest = z.object({ - id: z.number(), - data: searchSpace - .pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true }) - .partial(), + id: z.number(), + data: searchSpace + .pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true }) + .partial(), }); export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true }); @@ -76,7 +74,7 @@ export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true, export const deleteSearchSpaceRequest = searchSpace.pick({ id: true }); export const deleteSearchSpaceResponse = z.object({ - message: z.literal("Search space deleted successfully"), + message: z.literal("Search space deleted successfully"), }); // Inferred types diff --git a/surfsense_web/contracts/types/user.types.ts b/surfsense_web/contracts/types/user.types.ts new file mode 100644 index 000000000..f5df17694 --- /dev/null +++ b/surfsense_web/contracts/types/user.types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const user = z.object({ + id: z.string().uuid(), + email: z.string().email(), + is_active: z.boolean(), + is_superuser: z.boolean(), + is_verified: z.boolean(), + pages_limit: z.number(), + pages_used: z.number(), +}); + +/** + * Get current user + */ +export const getMeResponse = user; + +export type User = z.infer; +export type GetMeResponse = z.infer; diff --git a/surfsense_web/hooks/index.ts b/surfsense_web/hooks/index.ts index f7ef22534..db454c161 100644 --- a/surfsense_web/hooks/index.ts +++ b/surfsense_web/hooks/index.ts @@ -1,4 +1,4 @@ +export * from "./use-debounced-value"; export * from "./use-logs"; export * from "./use-rbac"; export * from "./use-search-source-connectors"; -export * from "./use-user"; diff --git a/surfsense_web/hooks/use-debounced-value.ts b/surfsense_web/hooks/use-debounced-value.ts new file mode 100644 index 000000000..6b4ba2adb --- /dev/null +++ b/surfsense_web/hooks/use-debounced-value.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +/** + * Hook that returns a debounced value that only updates after the specified delay + * @param value - The value to debounce + * @param delay - The delay in milliseconds (default: 300ms) + * @returns The debounced value + */ +export function useDebouncedValue(value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/surfsense_web/hooks/use-logs.ts b/surfsense_web/hooks/use-logs.ts index 6ce025e89..cfd161de0 100644 --- a/surfsense_web/hooks/use-logs.ts +++ b/surfsense_web/hooks/use-logs.ts @@ -141,32 +141,43 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) { ); // Function to create a new log - const createLog = useCallback(async (logData: Omit) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`, - { - headers: { "Content-Type": "application/json" }, - method: "POST", - body: JSON.stringify(logData), + // Use silent: true to suppress toast notifications (for internal/background operations) + const createLog = useCallback( + async (logData: Omit, options?: { silent?: boolean }) => { + const { silent = false } = options || {}; + try { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`, + { + headers: { "Content-Type": "application/json" }, + method: "POST", + body: JSON.stringify(logData), + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || "Failed to create log"); } - ); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to create log"); + const newLog = await response.json(); + setLogs((prevLogs) => [newLog, ...prevLogs]); + // Only show toast if not silent + if (!silent) { + toast.success("Log created successfully"); + } + return newLog; + } catch (err: any) { + // Only show error toast if not silent + if (!silent) { + toast.error(err.message || "Failed to create log"); + } + console.error("Error creating log:", err); + throw err; } - - const newLog = await response.json(); - setLogs((prevLogs) => [newLog, ...prevLogs]); - toast.success("Log created successfully"); - return newLog; - } catch (err: any) { - toast.error(err.message || "Failed to create log"); - console.error("Error creating log:", err); - throw err; - } - }, []); + }, + [] + ); // Function to update a log const updateLog = useCallback( diff --git a/surfsense_web/hooks/use-rbac.ts b/surfsense_web/hooks/use-rbac.ts index ee3450746..fa619407a 100644 --- a/surfsense_web/hooks/use-rbac.ts +++ b/surfsense_web/hooks/use-rbac.ts @@ -218,137 +218,6 @@ export function useMembers(searchSpaceId: number) { // ============ Roles Hook ============ -export function useRoles(searchSpaceId: number) { - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchRoles = useCallback(async () => { - if (!searchSpaceId) return; - - try { - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch roles"); - } - - const data = await response.json(); - setRoles(data); - setError(null); - return data; - } catch (err: any) { - setError(err.message || "Failed to fetch roles"); - console.error("Error fetching roles:", err); - } finally { - setLoading(false); - } - }, [searchSpaceId]); - - useEffect(() => { - fetchRoles(); - }, [fetchRoles]); - - const createRole = useCallback( - async (roleData: RoleCreate) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`, - { - headers: { "Content-Type": "application/json" }, - method: "POST", - body: JSON.stringify(roleData), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to create role"); - } - - const newRole = await response.json(); - setRoles((prev) => [...prev, newRole]); - toast.success("Role created successfully"); - return newRole; - } catch (err: any) { - toast.error(err.message || "Failed to create role"); - throw err; - } - }, - [searchSpaceId] - ); - - const updateRole = useCallback( - async (roleId: number, roleData: RoleUpdate) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`, - { - headers: { "Content-Type": "application/json" }, - method: "PUT", - body: JSON.stringify(roleData), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to update role"); - } - - const updatedRole = await response.json(); - setRoles((prev) => prev.map((r) => (r.id === roleId ? updatedRole : r))); - toast.success("Role updated successfully"); - return updatedRole; - } catch (err: any) { - toast.error(err.message || "Failed to update role"); - throw err; - } - }, - [searchSpaceId] - ); - - const deleteRole = useCallback( - async (roleId: number) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`, - { method: "DELETE" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to delete role"); - } - - setRoles((prev) => prev.filter((r) => r.id !== roleId)); - toast.success("Role deleted successfully"); - return true; - } catch (err: any) { - toast.error(err.message || "Failed to delete role"); - return false; - } - }, - [searchSpaceId] - ); - - return { - roles, - loading, - error, - fetchRoles, - createRole, - updateRole, - deleteRole, - }; -} - -// ============ Invites Hook ============ - export function useInvites(searchSpaceId: number) { const [invites, setInvites] = useState([]); const [loading, setLoading] = useState(true); @@ -480,63 +349,6 @@ export function useInvites(searchSpaceId: number) { // ============ Permissions Hook ============ -export function usePermissions() { - const [permissions, setPermissions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchPermissions = useCallback(async () => { - try { - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/permissions`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch permissions"); - } - - const data = await response.json(); - setPermissions(data.permissions); - setError(null); - return data.permissions; - } catch (err: any) { - setError(err.message || "Failed to fetch permissions"); - console.error("Error fetching permissions:", err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchPermissions(); - }, [fetchPermissions]); - - // Group permissions by category - const groupedPermissions = useMemo(() => { - const groups: Record = {}; - for (const perm of permissions) { - if (!groups[perm.category]) { - groups[perm.category] = []; - } - groups[perm.category].push(perm); - } - return groups; - }, [permissions]); - - return { - permissions, - groupedPermissions, - loading, - error, - fetchPermissions, - }; -} - -// ============ User Access Hook ============ - export function useUserAccess(searchSpaceId: number) { const [access, setAccess] = useState(null); const [loading, setLoading] = useState(true); diff --git a/surfsense_web/hooks/use-user.ts b/surfsense_web/hooks/use-user.ts deleted file mode 100644 index e81ac350b..000000000 --- a/surfsense_web/hooks/use-user.ts +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { authenticatedFetch } from "@/lib/auth-utils"; - -interface User { - id: string; - email: string; - is_active: boolean; - is_superuser: boolean; - is_verified: boolean; - pages_limit: number; - pages_used: number; -} - -export function useUser() { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchUser = async () => { - try { - // Only run on client-side - if (typeof window === "undefined") return; - - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/users/me`, - { method: "GET" } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch user: ${response.status}`); - } - - const data = await response.json(); - setUser(data); - setError(null); - } catch (err: any) { - setError(err.message || "Failed to fetch user"); - console.error("Error fetching user:", err); - } finally { - setLoading(false); - } - }; - - fetchUser(); - }, []); - - return { user, loading, error }; -} 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..5e8ab8a96 --- /dev/null +++ b/surfsense_web/lib/apis/notes-api.service.ts @@ -0,0 +1,147 @@ +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/lib/apis/permissions-api.service.ts b/surfsense_web/lib/apis/permissions-api.service.ts new file mode 100644 index 000000000..d161879b9 --- /dev/null +++ b/surfsense_web/lib/apis/permissions-api.service.ts @@ -0,0 +1,10 @@ +import { getPermissionsResponse } from "@/contracts/types/permissions.types"; +import { baseApiService } from "./base-api.service"; + +class PermissionsApiService { + getPermissions = async () => { + return baseApiService.get(`/api/v1/permissions`, getPermissionsResponse); + }; +} + +export const permissionsApiService = new PermissionsApiService(); diff --git a/surfsense_web/lib/apis/roles-api.service.ts b/surfsense_web/lib/apis/roles-api.service.ts new file mode 100644 index 000000000..1a574ee4b --- /dev/null +++ b/surfsense_web/lib/apis/roles-api.service.ts @@ -0,0 +1,109 @@ +import { + type CreateRoleRequest, + createRoleRequest, + createRoleResponse, + type DeleteRoleRequest, + deleteRoleRequest, + deleteRoleResponse, + type GetRoleByIdRequest, + type GetRolesRequest, + getRoleByIdRequest, + getRoleByIdResponse, + getRolesRequest, + getRolesResponse, + type UpdateRoleRequest, + updateRoleRequest, + updateRoleResponse, +} from "@/contracts/types/roles.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class RolesApiService { + createRole = async (request: CreateRoleRequest) => { + const parsedRequest = createRoleRequest.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}`); + } + + return baseApiService.post( + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`, + createRoleResponse, + { + body: parsedRequest.data.data, + } + ); + }; + + getRoles = async (request: GetRolesRequest) => { + const parsedRequest = getRolesRequest.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}`); + } + + return baseApiService.get( + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`, + getRolesResponse + ); + }; + + getRoleById = async (request: GetRoleByIdRequest) => { + const parsedRequest = getRoleByIdRequest.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}`); + } + + return baseApiService.get( + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + getRoleByIdResponse + ); + }; + + updateRole = async (request: UpdateRoleRequest) => { + const parsedRequest = updateRoleRequest.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}`); + } + + return baseApiService.put( + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + updateRoleResponse, + { + body: parsedRequest.data.data, + } + ); + }; + + deleteRole = async (request: DeleteRoleRequest) => { + const parsedRequest = deleteRoleRequest.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}`); + } + + return baseApiService.delete( + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + deleteRoleResponse + ); + }; +} + +export const rolesApiService = new RolesApiService(); diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts index 73b57ee3c..23433faee 100644 --- a/surfsense_web/lib/apis/search-spaces-api.service.ts +++ b/surfsense_web/lib/apis/search-spaces-api.service.ts @@ -1,18 +1,18 @@ import { type CreateSearchSpaceRequest, - type DeleteSearchSpaceRequest, - type GetSearchSpaceRequest, - type GetSearchSpacesRequest, - type UpdateSearchSpaceRequest, createSearchSpaceRequest, createSearchSpaceResponse, + type DeleteSearchSpaceRequest, deleteSearchSpaceRequest, deleteSearchSpaceResponse, + type GetSearchSpaceRequest, + type GetSearchSpacesRequest, getCommunityPromptsResponse, getSearchSpaceRequest, getSearchSpaceResponse, getSearchSpacesRequest, getSearchSpacesResponse, + type UpdateSearchSpaceRequest, updateSearchSpaceRequest, updateSearchSpaceResponse, } from "@/contracts/types/search-space.types"; @@ -71,7 +71,10 @@ class SearchSpacesApiService { * Get community-curated prompts for search space system instructions */ getCommunityPrompts = async () => { - return baseApiService.get(`/api/v1/searchspaces/prompts/community`, getCommunityPromptsResponse); + return baseApiService.get( + `/api/v1/searchspaces/prompts/community`, + getCommunityPromptsResponse + ); }; /** diff --git a/surfsense_web/lib/apis/user-api.service.ts b/surfsense_web/lib/apis/user-api.service.ts new file mode 100644 index 000000000..ea46ac116 --- /dev/null +++ b/surfsense_web/lib/apis/user-api.service.ts @@ -0,0 +1,13 @@ +import { getMeResponse } from "@/contracts/types/user.types"; +import { baseApiService } from "./base-api.service"; + +class UserApiService { + /** + * Get current authenticated user + */ + getMe = async () => { + return baseApiService.get(`/users/me`, getMeResponse); + }; +} + +export const userApiService = new UserApiService(); diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 797c40b65..db7af6636 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -2,6 +2,7 @@ import type { GetChatsRequest } from "@/contracts/types/chat.types"; import type { GetDocumentsRequest } from "@/contracts/types/document.types"; import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types"; import type { GetPodcastsRequest } from "@/contracts/types/podcast.types"; +import type { GetRolesRequest } from "@/contracts/types/roles.types"; import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types"; export const cacheKeys = { @@ -40,5 +41,15 @@ export const cacheKeys = { ["search-spaces", ...(queries ? Object.values(queries) : [])] as const, detail: (searchSpaceId: string) => ["search-spaces", searchSpaceId] as const, communityPrompts: ["search-spaces", "community-prompts"] as const, - } + }, + user: { + current: () => ["user", "me"] as const, + }, + roles: { + all: (searchSpaceId: string) => ["roles", searchSpaceId] as const, + byId: (searchSpaceId: string, roleId: string) => ["roles", searchSpaceId, roleId] as const, + }, + permissions: { + all: () => ["permissions"] as const, + }, }; diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 140b8363d..e2cf89b5f 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -642,7 +642,17 @@ "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", + "all_notes_description": "Browse and manage all your notes", + "search_notes": "Search notes...", + "no_results_found": "No notes found", + "try_different_search": "Try a different search term", + "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..38546bb87 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -642,7 +642,17 @@ "no_chats_found": "未找到对话", "no_recent_chats": "暂无最近对话", "view_all_chats": "查看所有对话", - "search_space": "搜索空间" + "search_space": "搜索空间", + "notes": "笔记", + "all_notes": "所有笔记", + "all_notes_description": "浏览和管理您的所有笔记", + "search_notes": "搜索笔记...", + "no_results_found": "未找到笔记", + "try_different_search": "尝试其他搜索词", + "no_notes": "暂无笔记", + "create_new_note": "创建新笔记", + "error_loading_notes": "加载笔记时出错", + "loading": "加载中..." }, "errors": { "something_went_wrong": "出错了",