Merge pull request #591 from MODSetter/dev

feat: note management
This commit is contained in:
Rohan Verma 2025-12-17 00:15:36 -08:00 committed by GitHub
commit 90b4ce6e43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2560 additions and 452 deletions

View file

@ -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

View file

@ -492,6 +492,7 @@ async def fetch_documents_by_ids(
"CLICKUP_CONNECTOR": "ClickUp (Selected)", "CLICKUP_CONNECTOR": "ClickUp (Selected)",
"AIRTABLE_CONNECTOR": "Airtable (Selected)", "AIRTABLE_CONNECTOR": "Airtable (Selected)",
"LUMA_CONNECTOR": "Luma Events (Selected)", "LUMA_CONNECTOR": "Luma Events (Selected)",
"NOTE": "Notes (Selected)",
} }
source_object = { 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: except Exception as e:
logging.error("Error in search_airtable: %s", traceback.format_exc()) logging.error("Error in search_airtable: %s", traceback.format_exc())
error_message = f"Error searching connector {connector}: {e!s}" error_message = f"Error searching connector {connector}: {e!s}"

View file

@ -34,6 +34,7 @@ def get_connector_emoji(connector_name: str) -> str:
"LUMA_CONNECTOR": "", "LUMA_CONNECTOR": "",
"ELASTICSEARCH_CONNECTOR": "", "ELASTICSEARCH_CONNECTOR": "",
"WEBCRAWLER_CONNECTOR": "🌐", "WEBCRAWLER_CONNECTOR": "🌐",
"NOTE": "📝",
} }
return connector_emojis.get(connector_name, "🔎") return connector_emojis.get(connector_name, "🔎")
@ -59,6 +60,7 @@ def get_connector_friendly_name(connector_name: str) -> str:
"LUMA_CONNECTOR": "Luma", "LUMA_CONNECTOR": "Luma",
"ELASTICSEARCH_CONNECTOR": "Elasticsearch", "ELASTICSEARCH_CONNECTOR": "Elasticsearch",
"WEBCRAWLER_CONNECTOR": "Web Pages", "WEBCRAWLER_CONNECTOR": "Web Pages",
"NOTE": "Notes",
} }
return connector_friendly_names.get(connector_name, connector_name) return connector_friendly_names.get(connector_name, connector_name)

View file

@ -51,6 +51,7 @@ class DocumentType(str, Enum):
LUMA_CONNECTOR = "LUMA_CONNECTOR" LUMA_CONNECTOR = "LUMA_CONNECTOR"
ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR" ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR"
BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR" BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR"
NOTE = "NOTE"
class SearchSourceConnectorType(str, Enum): class SearchSourceConnectorType(str, Enum):

View file

@ -15,6 +15,7 @@ from .google_gmail_add_connector_route import (
from .llm_config_routes import router as llm_config_router from .llm_config_routes import router as llm_config_router
from .logs_routes import router as logs_router from .logs_routes import router as logs_router
from .luma_add_connector_route import router as luma_add_connector_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 .podcasts_routes import router as podcasts_router
from .rbac_routes import router as rbac_router from .rbac_routes import router as rbac_router
from .search_source_connectors_routes import router as search_source_connectors_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(rbac_router) # RBAC routes for roles, members, invites
router.include_router(editor_router) router.include_router(editor_router)
router.include_router(documents_router) router.include_router(documents_router)
router.include_router(notes_router)
router.include_router(podcasts_router) router.include_router(podcasts_router)
router.include_router(chats_router) router.include_router(chats_router)
router.include_router(search_source_connectors_router) router.include_router(search_source_connectors_router)

View file

@ -266,12 +266,27 @@ async def read_documents(
document_type=doc.document_type, document_type=doc.document_type,
document_metadata=doc.document_metadata, document_metadata=doc.document_metadata,
content=doc.content, content=doc.content,
content_hash=doc.content_hash,
unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at, created_at=doc.created_at,
updated_at=doc.updated_at,
search_space_id=doc.search_space_id, 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -385,12 +400,27 @@ async def search_documents(
document_type=doc.document_type, document_type=doc.document_type,
document_metadata=doc.document_metadata, document_metadata=doc.document_metadata,
content=doc.content, content=doc.content,
content_hash=doc.content_hash,
unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at, created_at=doc.created_at,
updated_at=doc.updated_at,
search_space_id=doc.search_space_id, 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -510,7 +540,10 @@ async def get_document_by_chunk_id(
document_type=document.document_type, document_type=document.document_type,
document_metadata=document.document_metadata, document_metadata=document.document_metadata,
content=document.content, content=document.content,
content_hash=document.content_hash,
unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at, created_at=document.created_at,
updated_at=document.updated_at,
search_space_id=document.search_space_id, search_space_id=document.search_space_id,
chunks=sorted_chunks, chunks=sorted_chunks,
) )
@ -559,7 +592,10 @@ async def read_document(
document_type=document.document_type, document_type=document.document_type,
document_metadata=document.document_metadata, document_metadata=document.document_metadata,
content=document.content, content=document.content,
content_hash=document.content_hash,
unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at, created_at=document.created_at,
updated_at=document.updated_at,
search_space_id=document.search_space_id, search_space_id=document.search_space_id,
) )
except HTTPException: except HTTPException:
@ -614,7 +650,10 @@ async def update_document(
document_type=db_document.document_type, document_type=db_document.document_type,
document_metadata=db_document.document_metadata, document_metadata=db_document.document_metadata,
content=db_document.content, content=db_document.content,
content_hash=db_document.content_hash,
unique_identifier_hash=db_document.unique_identifier_hash,
created_at=db_document.created_at, created_at=db_document.created_at,
updated_at=db_document.updated_at,
search_space_id=db_document.search_space_id, search_space_id=db_document.search_space_id,
) )
except HTTPException: except HTTPException:

View file

@ -10,7 +10,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.users import current_active_user
from app.utils.rbac import check_permission from app.utils.rbac import check_permission
@ -59,13 +59,38 @@ async def get_editor_content(
return { return {
"document_id": document.id, "document_id": document.id,
"title": document.title, "title": document.title,
"document_type": document.document_type.value,
"blocknote_document": document.blocknote_document, "blocknote_document": document.blocknote_document,
"updated_at": document.updated_at.isoformat() "updated_at": document.updated_at.isoformat()
if document.updated_at if document.updated_at
else None, 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 from app.utils.blocknote_converter import convert_markdown_to_blocknote
chunks = sorted(document.chunks, key=lambda c: c.id) chunks = sorted(document.chunks, key=lambda c: c.id)
@ -102,6 +127,7 @@ async def get_editor_content(
return { return {
"document_id": document.id, "document_id": document.id,
"title": document.title, "title": document.title,
"document_type": document.document_type.value,
"blocknote_document": blocknote_json, "blocknote_document": blocknote_json,
"updated_at": document.updated_at.isoformat() if document.updated_at else None, "updated_at": document.updated_at.isoformat() if document.updated_at else None,
} }
@ -147,6 +173,43 @@ async def save_document(
if not blocknote_document: if not blocknote_document:
raise HTTPException(status_code=400, detail="blocknote_document is required") 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 # Save BlockNote document
document.blocknote_document = blocknote_document document.blocknote_document = blocknote_document
document.updated_at = datetime.now(UTC) document.updated_at = datetime.now(UTC)

View file

@ -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}

View file

@ -46,7 +46,10 @@ class DocumentRead(BaseModel):
document_type: DocumentType document_type: DocumentType
document_metadata: dict document_metadata: dict
content: str # Changed to string to match frontend content: str # Changed to string to match frontend
content_hash: str
unique_identifier_hash: str | None
created_at: datetime created_at: datetime
updated_at: datetime | None
search_space_id: int search_space_id: int
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -61,3 +64,6 @@ class DocumentWithChunksRead(DocumentRead):
class PaginatedResponse[T](BaseModel): class PaginatedResponse[T](BaseModel):
items: list[T] items: list[T]
total: int total: int
page: int
page_size: int
has_more: bool

View file

@ -2360,6 +2360,75 @@ class ConnectorService:
return result_object, elasticsearch_docs 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( async def search_bookstack(
self, self,
user_query: str, user_query: str,

View file

@ -3,6 +3,7 @@
import logging import logging
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
@ -11,6 +12,7 @@ from app.celery_app import celery_app
from app.config import config from app.config import config
from app.db import Document from app.db import Document
from app.services.llm_service import get_user_long_context_llm 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.blocknote_converter import convert_blocknote_to_markdown
from app.utils.document_converters import ( from app.utils.document_converters import (
create_document_chunks, create_document_chunks,
@ -53,11 +55,10 @@ def reindex_document_task(self, document_id: int, user_id: str):
async def _reindex_document(document_id: int, user_id: str): async def _reindex_document(document_id: int, user_id: str):
"""Async function to reindex a document.""" """Async function to reindex a document."""
async with get_celery_session_maker()() as session: async with get_celery_session_maker()() as session:
try: # First, get the document to get search_space_id for logging
# Get document
result = await session.execute( result = await session.execute(
select(Document) select(Document)
.options(selectinload(Document.chunks)) # Eagerly load chunks .options(selectinload(Document.chunks))
.where(Document.id == document_id) .where(Document.id == document_id)
) )
document = result.scalars().first() document = result.scalars().first()
@ -66,8 +67,30 @@ async def _reindex_document(document_id: int, user_id: str):
logger.error(f"Document {document_id} not found") logger.error(f"Document {document_id} not found")
return 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:
if not document.blocknote_document: 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 return
logger.info(f"Reindexing document {document_id} ({document.title})") 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: 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 return
# 2. Delete old chunks explicitly # 2. Delete old chunks explicitly
@ -118,9 +146,39 @@ async def _reindex_document(document_id: int, user_id: str):
await session.commit() 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}") 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: except Exception as e:
await session.rollback() 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) logger.error(f"Error reindexing document {document_id}: {e}", exc_info=True)
raise raise

View file

@ -14,7 +14,6 @@ import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContain
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
@ -224,7 +223,6 @@ export function DashboardClientLayout({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LanguageSwitcher /> <LanguageSwitcher />
<ThemeTogglerComponent />
{/* Only show artifacts toggle on researcher page */} {/* Only show artifacts toggle on researcher page */}
{isResearcherPage && ( {isResearcherPage && (
<motion.div <motion.div

View file

@ -150,7 +150,7 @@ export function DocumentsTableShell({
<> <>
<div className="hidden md:block max-h-[60vh] overflow-auto"> <div className="hidden md:block max-h-[60vh] overflow-auto">
<Table className="table-fixed w-full"> <Table className="table-fixed w-full">
<TableHeader className="sticky top-0 bg-background z-10"> <TableHeader className="sticky top-0 bg-background">
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead style={{ width: 28 }}> <TableHead style={{ width: 28 }}>
<Checkbox <Checkbox

View file

@ -1,27 +1,72 @@
"use client"; "use client";
import { AlertCircle, FileText, Loader2, Save, X } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query";
import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
interface EditorContent { interface EditorContent {
document_id: number; document_id: number;
title: string; title: string;
document_type?: string;
blocknote_document: any; blocknote_document: any;
updated_at: string | null; updated_at: string | null;
} }
// Helper function to extract title from BlockNote document
// Takes the text content from the first block (should be a heading for notes)
function extractTitleFromBlockNote(blocknoteDocument: any[] | null | undefined): string {
if (!blocknoteDocument || !Array.isArray(blocknoteDocument) || blocknoteDocument.length === 0) {
return "Untitled";
}
const firstBlock = blocknoteDocument[0];
if (!firstBlock) {
return "Untitled";
}
// Extract text from block content
// BlockNote blocks have a content array with inline content
if (firstBlock.content && Array.isArray(firstBlock.content)) {
const textContent = firstBlock.content
.map((item: any) => {
if (typeof item === "string") return item;
if (item?.text) return item.text;
return "";
})
.join("")
.trim();
return textContent || "Untitled";
}
return "Untitled";
}
export default function EditorPage() { export default function EditorPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const documentId = params.documentId as string; const documentId = params.documentId as string;
const searchSpaceId = Number(params.search_space_id);
const isNewNote = documentId === "new";
const [document, setDocument] = useState<EditorContent | null>(null); const [document, setDocument] = useState<EditorContent | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -29,10 +74,26 @@ export default function EditorPage() {
const [editorContent, setEditorContent] = useState<any>(null); const [editorContent, setEditorContent] = useState<any>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
// Fetch document content - DIRECT CALL TO FASTAPI // Fetch document content - DIRECT CALL TO FASTAPI
// Skip fetching if this is a new note
useEffect(() => { useEffect(() => {
async function fetchDocument() { 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(); const token = getBearerToken();
if (!token) { if (!token) {
console.error("No auth token found"); console.error("No auth token found");
@ -51,16 +112,17 @@ export default function EditorPage() {
const errorData = await response const errorData = await response
.json() .json()
.catch(() => ({ detail: "Failed to fetch document" })); .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(); const data = await response.json();
// Check if blocknote_document exists // Check if blocknote_document exists
if (!data.blocknote_document) { if (!data.blocknote_document) {
setError( const errorMsg =
"This document does not have BlockNote content. Please re-upload the document to enable editing." "This document does not have BlockNote content. Please re-upload the document to enable editing.";
); setError(errorMsg);
setLoading(false); setLoading(false);
return; return;
} }
@ -70,9 +132,9 @@ export default function EditorPage() {
setError(null); setError(null);
} catch (error) { } catch (error) {
console.error("Error fetching document:", error); console.error("Error fetching document:", error);
setError( const errorMessage =
error instanceof Error ? error.message : "Failed to fetch document. Please try again." error instanceof Error ? error.message : "Failed to fetch document. Please try again.";
); setError(errorMessage);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -81,7 +143,7 @@ export default function EditorPage() {
if (documentId) { if (documentId) {
fetchDocument(); fetchDocument();
} }
}, [documentId, params.search_space_id]); }, [documentId, params.search_space_id, isNewNote]);
// Track changes to mark as unsaved // Track changes to mark as unsaved
useEffect(() => { useEffect(() => {
@ -90,9 +152,21 @@ export default function EditorPage() {
} }
}, [editorContent, document]); }, [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 // TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
// Save and exit - DIRECT CALL TO FASTAPI // Save and exit - DIRECT CALL TO FASTAPI
// For new notes, create the note first, then save
const handleSave = async () => { const handleSave = async () => {
const token = getBearerToken(); const token = getBearerToken();
if (!token) { if (!token) {
@ -101,13 +175,66 @@ export default function EditorPage() {
return; return;
} }
setSaving(true);
setError(null);
try {
// 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");
}
}
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) { if (!editorContent) {
toast.error("No content to save"); toast.error("No content to save");
setSaving(false);
return; return;
} }
setSaving(true);
try {
// Save blocknote_document and trigger reindexing in background // Save blocknote_document and trigger reindexing in background
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
@ -128,30 +255,41 @@ export default function EditorPage() {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background..."); toast.success("Document saved! Reindexing in background...");
// Small delay before redirect to show success message // Invalidate notes query when updating notes to refresh the sidebar
setTimeout(() => { if (isNote) {
router.push(`/dashboard/${params.search_space_id}/documents`); queryClient.invalidateQueries({
}, 500); queryKey: ["notes", String(searchSpaceId)],
});
}
}
} catch (error) { } catch (error) {
console.error("Error saving document:", error); console.error("Error saving document:", error);
toast.error( const errorMessage =
error instanceof Error ? error.message : "Failed to save document. Please try again." 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 { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handleCancel = () => { const handleBack = () => {
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
if (confirm("You have unsaved changes. Are you sure you want to leave?")) { setShowUnsavedDialog(true);
router.back();
}
} else { } else {
router.back(); router.push(`/dashboard/${searchSpaceId}/researcher`);
} }
}; };
const handleConfirmLeave = () => {
setShowUnsavedDialog(false);
router.push(`/dashboard/${searchSpaceId}/researcher`);
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[400px] p-6"> <div className="flex items-center justify-center min-h-[400px] p-6">
@ -182,9 +320,13 @@ export default function EditorPage() {
<CardDescription>{error}</CardDescription> <CardDescription>{error}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button onClick={() => router.back()} variant="outline" className="w-full"> <Button
<X className="mr-2 h-4 w-4" /> onClick={() => router.push(`/dashboard/${searchSpaceId}/researcher`)}
Go Back variant="outline"
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@ -193,7 +335,7 @@ export default function EditorPage() {
); );
} }
if (!document) { if (!document && !isNewNote) {
return ( return (
<div className="flex items-center justify-center min-h-[400px] p-6"> <div className="flex items-center justify-center min-h-[400px] p-6">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
@ -217,26 +359,26 @@ export default function EditorPage() {
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-3 flex-1 min-w-0">
<FileText className="h-5 w-5 text-muted-foreground shrink-0" /> <FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<h1 className="text-lg font-semibold truncate">{document.title}</h1> <h1 className="text-lg font-semibold truncate">{displayTitle}</h1>
{hasUnsavedChanges && <p className="text-xs text-muted-foreground">Unsaved changes</p>} {hasUnsavedChanges && <p className="text-xs text-muted-foreground">Unsaved changes</p>}
</div> </div>
</div> </div>
<Separator orientation="vertical" className="h-6" /> <Separator orientation="vertical" className="h-6" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel} disabled={saving} className="gap-2"> <Button variant="outline" onClick={handleBack} disabled={saving} className="gap-2">
<X className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Cancel Back
</Button> </Button>
<Button onClick={handleSave} disabled={saving} className="gap-2"> <Button onClick={handleSave} disabled={saving} className="gap-2">
{saving ? ( {saving ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Saving... {isNewNote ? "Creating..." : "Saving..."}
</> </>
) : ( ) : (
<> <>
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
Save & Exit Save
</> </>
)} )}
</Button> </Button>
@ -244,13 +386,45 @@ export default function EditorPage() {
</div> </div>
{/* Editor Container */} {/* Editor Container */}
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-visible relative">
<div className="h-full w-full overflow-auto p-6"> <div className="h-full w-full overflow-auto p-6">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 max-w-4xl mx-auto"
>
<div className="flex items-center gap-2 p-4 rounded-lg border border-destructive/50 bg-destructive/10 text-destructive">
<AlertCircle className="h-5 w-5 shrink-0" />
<p className="text-sm">{error}</p>
</div>
</motion.div>
)}
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<BlockNoteEditor initialContent={editorContent} onChange={setEditorContent} /> <BlockNoteEditor
initialContent={isNewNote ? undefined : editorContent}
onChange={setEditorContent}
useTitleBlock={isNote}
/>
</div> </div>
</div> </div>
</div> </div>
{/* Unsaved Changes Dialog */}
<AlertDialog open={showUnsavedDialog} onOpenChange={setShowUnsavedDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Are you sure you want to leave?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmLeave}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div> </motion.div>
); );
} }

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query";
import { import {
type ColumnDef, type ColumnDef,
type ColumnFiltersState, type ColumnFiltersState,
@ -11,6 +12,7 @@ import {
type SortingState, type SortingState,
useReactTable, useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { useAtomValue } from "jotai";
import { import {
ArrowLeft, ArrowLeft,
Calendar, Calendar,
@ -44,6 +46,12 @@ import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms";
import {
createRoleMutationAtom,
deleteRoleMutationAtom,
updateRoleMutationAtom,
} from "@/atoms/roles/roles-mutation.atoms";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -99,24 +107,28 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import type {
CreateRoleRequest,
DeleteRoleRequest,
Role,
UpdateRoleRequest,
} from "@/contracts/types/roles.types";
import { import {
type Invite, type Invite,
type InviteCreate, type InviteCreate,
type Member, type Member,
type Role,
type RoleCreate,
useInvites, useInvites,
useMembers, useMembers,
usePermissions,
useRoles,
useUserAccess, useUserAccess,
} from "@/hooks/use-rbac"; } 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"; import { cn } from "@/lib/utils";
// Animation variants // Animation variants
const fadeInUp = { const fadeInUp = {
hidden: { opacity: 0, y: 20 }, 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 = { const staggerContainer = {
@ -132,7 +144,7 @@ const cardVariants = {
visible: { visible: {
opacity: 1, opacity: 1,
scale: 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, updateMemberRole,
removeMember, removeMember,
} = useMembers(searchSpaceId); } = 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<Role> => {
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<boolean> => {
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<Role> => {
const request: CreateRoleRequest = {
search_space_id: searchSpaceId,
data: roleData,
};
return await createRole(request);
},
[createRole, searchSpaceId]
);
const { const {
roles, data: roles = [],
loading: rolesLoading, isLoading: rolesLoading,
fetchRoles, refetch: fetchRoles,
createRole, } = useQuery({
updateRole, queryKey: cacheKeys.roles.all(searchSpaceId.toString()),
deleteRole, queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }),
} = useRoles(searchSpaceId); enabled: !!searchSpaceId,
});
const { const {
invites, invites,
loading: invitesLoading, loading: invitesLoading,
@ -165,7 +218,19 @@ export default function TeamManagementPage() {
createInvite, createInvite,
revokeInvite, revokeInvite,
} = useInvites(searchSpaceId); } = useInvites(searchSpaceId);
const { groupedPermissions, loading: permissionsLoading } = usePermissions();
const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom);
const permissions = permissionsData?.permissions || [];
const groupedPermissions = useMemo(() => {
const groups: Record<string, typeof permissions> = {};
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 canManageMembers = hasPermission("members:view");
const canManageRoles = hasPermission("roles:read"); const canManageRoles = hasPermission("roles:read");
@ -329,7 +394,7 @@ export default function TeamManagementPage() {
{activeTab === "roles" && hasPermission("roles:create") && ( {activeTab === "roles" && hasPermission("roles:create") && (
<CreateRoleDialog <CreateRoleDialog
groupedPermissions={groupedPermissions} groupedPermissions={groupedPermissions}
onCreateRole={createRole} onCreateRole={handleCreateRole}
/> />
)} )}
</div> </div>
@ -351,8 +416,8 @@ export default function TeamManagementPage() {
roles={roles} roles={roles}
groupedPermissions={groupedPermissions} groupedPermissions={groupedPermissions}
loading={rolesLoading} loading={rolesLoading}
onUpdateRole={updateRole} onUpdateRole={handleUpdateRole}
onDeleteRole={deleteRole} onDeleteRole={handleDeleteRole}
canUpdate={hasPermission("roles:update")} canUpdate={hasPermission("roles:update")}
canDelete={hasPermission("roles:delete")} canDelete={hasPermission("roles:delete")}
/> />
@ -663,7 +728,12 @@ function RolesTab({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{canUpdate && ( {canUpdate && (
<DropdownMenuItem> <DropdownMenuItem
onClick={() => {
// TODO: Implement edit role dialog/modal
console.log("Edit role not yet implemented", role);
}}
>
<Edit2 className="h-4 w-4 mr-2" /> <Edit2 className="h-4 w-4 mr-2" />
Edit Role Edit Role
</DropdownMenuItem> </DropdownMenuItem>
@ -882,7 +952,7 @@ function InvitesTab({
size="sm" size="sm"
className="gap-2" className="gap-2"
onClick={() => copyInviteLink(invite)} onClick={() => copyInviteLink(invite)}
disabled={isInactive} disabled={Boolean(isInactive)}
> >
{copiedId === invite.id ? ( {copiedId === invite.id ? (
<> <>
@ -1158,7 +1228,7 @@ function CreateRoleDialog({
onCreateRole, onCreateRole,
}: { }: {
groupedPermissions: Record<string, { value: string; name: string; category: string }[]>; groupedPermissions: Record<string, { value: string; name: string; category: string }[]>;
onCreateRole: (data: RoleCreate) => Promise<Role>; onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@ -1177,7 +1247,7 @@ function CreateRoleDialog({
try { try {
await onCreateRole({ await onCreateRole({
name: name.trim(), name: name.trim(),
description: description.trim() || undefined, description: description.trim() || null,
permissions: selectedPermissions, permissions: selectedPermissions,
is_default: isDefault, is_default: isDefault,
}); });

View file

@ -8,6 +8,9 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; 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 { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { UserDropdown } from "@/components/UserDropdown"; import { UserDropdown } from "@/components/UserDropdown";
@ -35,9 +38,6 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Spotlight } from "@/components/ui/spotlight"; import { Spotlight } from "@/components/ui/spotlight";
import { Tilt } from "@/components/ui/tilt"; 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"; 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); const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
// Fetch user details const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom);
const { user, loading: isLoadingUser, error: userError } = useUser();
// Create user object for UserDropdown // Create user object for UserDropdown
const customUser = { const customUser = {
@ -172,7 +176,7 @@ const DashboardPage = () => {
}; };
if (loading) return <LoadingScreen />; if (loading) return <LoadingScreen />;
if (error) return <ErrorScreen message={error?.message || 'Failed to load search spaces'} />; if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
const handleDeleteSearchSpace = async (id: number) => { const handleDeleteSearchSpace = async (id: number) => {
await deleteSearchSpace({ id }); await deleteSearchSpace({ id });

View file

@ -3,8 +3,8 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { SearchSpaceForm } from "@/components/search-space-form";
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { SearchSpaceForm } from "@/components/search-space-form";
export default function SearchSpacesPage() { export default function SearchSpacesPage() {
const router = useRouter(); const router = useRouter();

View file

@ -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();
},
};
});

View file

@ -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");
},
};
});

View file

@ -2,13 +2,13 @@ import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner"; import { toast } from "sonner";
import type { import type {
CreateSearchSpaceRequest, CreateSearchSpaceRequest,
UpdateSearchSpaceRequest,
DeleteSearchSpaceRequest, DeleteSearchSpaceRequest,
UpdateSearchSpaceRequest,
} from "@/contracts/types/search-space.types"; } from "@/contracts/types/search-space.types";
import { activeSearchSpaceIdAtom } from "./search-space-query.atoms";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client"; import { queryClient } from "@/lib/query-client/client";
import { activeSearchSpaceIdAtom } from "./search-space-query.atoms";
export const createSearchSpaceMutationAtom = atomWithMutation(() => { export const createSearchSpaceMutationAtom = atomWithMutation(() => {
return { return {

View file

@ -1,5 +1,5 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { atom } from "jotai"; import { atom } from "jotai";
import { atomWithQuery } from "jotai-tanstack-query";
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types"; import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";

View file

@ -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();
},
};
});

View file

@ -10,31 +10,123 @@ import { useCreateBlockNote } from "@blocknote/react";
interface BlockNoteEditorProps { interface BlockNoteEditorProps {
initialContent?: any; initialContent?: any;
onChange?: (content: any) => void; 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(); const { resolvedTheme } = useTheme();
// Track the initial content to prevent re-initialization // Track the initial content to prevent re-initialization
const initialContentRef = useRef<any>(null); const initialContentRef = useRef<any>(null);
const isInitializedRef = useRef(false); 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 // Creates a new editor instance - only use initialContent on first render
const editor = useCreateBlockNote({ const editor = useCreateBlockNote({
initialContent: initialContentRef.current === null ? initialContent || undefined : undefined, initialContent: initialContentRef.current === null ? preparedInitialContent : undefined,
}); });
// Store initial content on first render only // Store initial content on first render only
useEffect(() => { useEffect(() => {
if (initialContent && initialContentRef.current === null) { if (preparedInitialContent !== undefined && initialContentRef.current === null) {
initialContentRef.current = initialContent; 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; isInitializedRef.current = true;
} }
}, [initialContent]); }, [preparedInitialContent]);
// Call onChange when document changes (but don't update from props) // Call onChange when document changes (but don't update from props)
useEffect(() => { 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 = () => { const handleChange = () => {
onChange(editor.document); onChange(editor.document);
@ -43,6 +135,12 @@ export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteE
// Subscribe to document changes // Subscribe to document changes
const unsubscribe = editor.onChange(handleChange); 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 () => { return () => {
unsubscribe(); unsubscribe();
}; };

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@ -13,10 +14,9 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { useQuery } from "@tanstack/react-query";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; 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 { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface BreadcrumbItemInterface { interface BreadcrumbItemInterface {
label: string; label: string;
@ -44,6 +44,13 @@ export function DashboardBreadcrumb() {
useEffect(() => { useEffect(() => {
if (segments[2] === "editor" && segments[3] && searchSpaceId) { if (segments[2] === "editor" && segments[3] && searchSpaceId) {
const documentId = segments[3]; const documentId = segments[3];
// Skip fetch for "new" notes
if (documentId === "new") {
setDocumentTitle(null);
return;
}
const token = getBearerToken(); const token = getBearerToken();
if (token) { if (token) {
@ -110,7 +117,14 @@ export function DashboardBreadcrumb() {
// Handle editor sub-sections (document ID) // Handle editor sub-sections (document ID)
if (section === "editor") { 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({ breadcrumbs.push({
label: t("documents"), label: t("documents"),
href: `/dashboard/${segments[1]}/documents`, href: `/dashboard/${segments[1]}/documents`,

View file

@ -1,8 +1,10 @@
"use client"; "use client";
import { useAtomValue } from "jotai";
import { ChevronDown, ChevronUp, ExternalLink, Info, Sparkles, User } from "lucide-react"; import { ChevronDown, ChevronUp, ExternalLink, Info, Sparkles, User } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -12,9 +14,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { useAtomValue } from "jotai";
interface SetupPromptStepProps { interface SetupPromptStepProps {
searchSpaceId: number; searchSpaceId: number;

View file

@ -1,5 +1,7 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
@ -12,6 +14,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -23,19 +26,20 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; 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 { 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 { authenticatedFetch } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface PromptConfigManagerProps { interface PromptConfigManagerProps {
searchSpaceId: number; searchSpaceId: number;
} }
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) { 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()), queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId, enabled: !!searchSpaceId,

View file

@ -1,12 +1,15 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms"; import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
import { chatsAtom } from "@/atoms/chats/chat-query.atoms"; import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms"; import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AppSidebar } from "@/components/sidebar/app-sidebar"; import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -17,8 +20,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useUser } from "@/hooks"; import { notesApiService } from "@/lib/apis/notes-api.service";
import { useQuery } from "@tanstack/react-query";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -48,6 +50,7 @@ export function AppSidebarProvider({
}: AppSidebarProviderProps) { }: AppSidebarProviderProps) {
const t = useTranslations("dashboard"); const t = useTranslations("dashboard");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const router = useRouter();
const setChatsQueryParams = useSetAtom(globalChatsQueryParamsAtom); const setChatsQueryParams = useSetAtom(globalChatsQueryParamsAtom);
const { data: chats, error: chatError, isLoading: isLoadingChats } = useAtomValue(chatsAtom); const { data: chats, error: chatError, isLoading: isLoadingChats } = useAtomValue(chatsAtom);
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] = const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
@ -68,7 +71,23 @@ export function AppSidebarProvider({
enabled: !!searchSpaceId, 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 [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); 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 // Use fallback chats if there's an error or no chats
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats; 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 // Memoized updated navSecondary
const updatedNavSecondary = useMemo(() => { const updatedNavSecondary = useMemo(() => {
const updated = [...navSecondary]; const updated = [...navSecondary];
@ -204,6 +270,7 @@ export function AppSidebarProvider({
navSecondary={navSecondary} navSecondary={navSecondary}
navMain={navMain} navMain={navMain}
RecentChats={[]} RecentChats={[]}
RecentNotes={[]}
pageUsage={pageUsage} pageUsage={pageUsage}
/> />
); );
@ -216,6 +283,8 @@ export function AppSidebarProvider({
navSecondary={updatedNavSecondary} navSecondary={updatedNavSecondary}
navMain={navMain} navMain={navMain}
RecentChats={displayChats} RecentChats={displayChats}
RecentNotes={recentNotes}
onAddNote={handleAddNote}
pageUsage={pageUsage} pageUsage={pageUsage}
/> />

View file

@ -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<number | null>(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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-80 p-0 flex flex-col">
<SheetHeader className="px-4 py-4 border-b space-y-3">
<SheetTitle>{t("all_notes") || "All Notes"}</SheetTitle>
<SheetDescription className="sr-only">
{t("all_notes_description") || "Browse and manage all your notes"}
</SheetDescription>
{/* Search Input */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_notes") || "Search notes..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">Clear search</span>
</Button>
)}
</div>
</SheetHeader>
<ScrollArea className="flex-1">
<div className="p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="text-center py-8 text-sm text-destructive">
{t("error_loading_notes") || "Error loading notes"}
</div>
) : notes.length > 0 ? (
<div className="space-y-1">
{notes.map((note) => {
const isDeleting = deletingNoteId === note.id;
return (
<div
key={note.id}
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isDeleting && "opacity-50 pointer-events-none"
)}
>
{/* Main clickable area for navigation */}
<button
type="button"
onClick={() => handleNoteClick(note.id, note.search_space_id)}
disabled={isDeleting}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{note.title}</span>
</button>
{/* Actions dropdown - separate from main click area */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"opacity-0 group-hover:opacity-100 focus:opacity-100",
"transition-opacity"
)}
disabled={isDeleting}
>
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<MoreHorizontal className="h-3.5 w-3.5" />
)}
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={() => handleDeleteNote(note.id, note.search_space_id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_results_found") || "No notes found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
<FileText className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground mb-4">
{t("no_notes") || "No notes yet"}
</p>
{onAddNote && (
<Button
variant="outline"
size="sm"
onClick={() => {
onAddNote();
onOpenChange(false);
}}
>
<Plus className="mr-2 h-4 w-4" />
{t("create_new_note") || "Create a note"}
</Button>
)}
</div>
)}
</div>
</ScrollArea>
{/* Footer with Add Note button */}
{onAddNote && notes.length > 0 && (
<div className="p-3 border-t">
<Button
onClick={() => {
onAddNote();
onOpenChange(false);
}}
className="w-full"
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
{t("create_new_note") || "Create a new note"}
</Button>
</div>
)}
</SheetContent>
</Sheet>
);
}

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useAtomValue } from "jotai";
import { import {
AlertCircle, AlertCircle,
BookOpen, BookOpen,
@ -24,11 +25,10 @@ import {
UserPlus, UserPlus,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { memo, useEffect, useMemo, useState } from "react"; import { memo, useEffect, useMemo, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -38,7 +38,6 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useUser } from "@/hooks/use-user";
/** /**
* Generates a consistent color based on a string (email) * 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 { NavMain } from "@/components/sidebar/nav-main";
import { NavNotes } from "@/components/sidebar/nav-notes";
import { NavProjects } from "@/components/sidebar/nav-projects"; import { NavProjects } from "@/components/sidebar/nav-projects";
import { NavSecondary } from "@/components/sidebar/nav-secondary"; import { NavSecondary } from "@/components/sidebar/nav-secondary";
import { PageUsageDisplay } from "@/components/sidebar/page-usage-display"; import { PageUsageDisplay } from "@/components/sidebar/page-usage-display";
@ -138,13 +138,13 @@ export const iconMap: Record<string, LucideIcon> = {
MessageCircleMore, MessageCircleMore,
Settings2, Settings2,
SquareLibrary, SquareLibrary,
FileText,
SquareTerminal, SquareTerminal,
AlertCircle, AlertCircle,
Info, Info,
ExternalLink, ExternalLink,
Trash2, Trash2,
Podcast, Podcast,
FileText,
Users, Users,
}; };
@ -209,6 +209,20 @@ const defaultData = {
id: 1003, id: 1003,
}, },
], ],
RecentNotes: [
{
name: "Meeting Notes",
url: "#",
icon: "FileText",
id: 2001,
},
{
name: "Project Ideas",
url: "#",
icon: "FileText",
id: 2002,
},
],
}; };
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> { interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
@ -240,6 +254,18 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
onClick: () => void; onClick: () => void;
}[]; }[];
}[]; }[];
RecentNotes?: {
name: string;
url: string;
icon: string;
id?: number;
search_space_id?: number;
actions?: {
name: string;
icon: string;
onClick: () => void;
}[];
}[];
user?: { user?: {
name: string; name: string;
email: string; email: string;
@ -249,6 +275,7 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
pagesUsed: number; pagesUsed: number;
pagesLimit: number; pagesLimit: number;
}; };
onAddNote?: () => void;
} }
// Memoized AppSidebar component for better performance // Memoized AppSidebar component for better performance
@ -257,12 +284,14 @@ export const AppSidebar = memo(function AppSidebar({
navMain = defaultData.navMain, navMain = defaultData.navMain,
navSecondary = defaultData.navSecondary, navSecondary = defaultData.navSecondary,
RecentChats = defaultData.RecentChats, RecentChats = defaultData.RecentChats,
RecentNotes = defaultData.RecentNotes,
pageUsage, pageUsage,
onAddNote,
...props ...props
}: AppSidebarProps) { }: AppSidebarProps) {
const router = useRouter(); const router = useRouter();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { user, loading: isLoadingUser } = useUser(); const { data: user, isPending: isLoadingUser } = useAtomValue(currentUserAtom);
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
useEffect(() => { useEffect(() => {
@ -295,6 +324,16 @@ export const AppSidebar = memo(function AppSidebar({
); );
}, [RecentChats]); }, [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 // Get user display name from email
const userDisplayName = user?.email ? user.email.split("@")[0] : "User"; const userDisplayName = user?.email ? user.email.split("@")[0] : "User";
const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown"); const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown");
@ -412,6 +451,14 @@ export const AppSidebar = memo(function AppSidebar({
<NavProjects chats={processedRecentChats} /> <NavProjects chats={processedRecentChats} />
</div> </div>
)} )}
<div className="space-y-2">
<NavNotes
notes={processedRecentNotes}
onAddNote={onAddNote}
searchSpaceId={searchSpaceId}
/>
</div>
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
{pageUsage && ( {pageUsage && (

View file

@ -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<string, LucideIcon> = {
FileText,
Trash2,
MoreHorizontal,
};
export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const [isDeleting, setIsDeleting] = useState<number | null>(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 (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center group/header">
<CollapsibleTrigger asChild>
<SidebarGroupLabel className="cursor-pointer rounded-md px-2 py-1.5 -mx-2 transition-colors flex items-center gap-1.5 flex-1">
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-all duration-200 shrink-0",
isOpen && "rotate-90"
)}
/>
<span>{t("notes") || "Notes"}</span>
</SidebarGroupLabel>
</CollapsibleTrigger>
{/* Action buttons - always visible on hover */}
<div className="flex items-center gap-0.5 opacity-0 group-hover/header:opacity-100 transition-opacity pr-1">
{searchSpaceId && notes.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
setIsAllNotesSidebarOpen(true);
}}
aria-label="View all notes"
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
)}
{onAddNote && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
onAddNote();
}}
aria-label="Add note"
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{notes.length > 0 ? (
notes.map((note) => {
const isDeletingNote = isDeleting === note.id;
return (
<SidebarMenuItem key={note.id || note.name} className="group/note">
{/* Main navigation button */}
<SidebarMenuButton
onClick={() => handleNoteClick(note.url)}
disabled={isDeletingNote}
className={cn(
"pr-8", // Make room for the action button
isDeletingNote && "opacity-50"
)}
>
<note.icon className="h-4 w-4 shrink-0" />
<span className="truncate">{note.name}</span>
</SidebarMenuButton>
{/* Actions dropdown - positioned absolutely */}
{note.actions && note.actions.length > 0 && (
<div className="absolute right-1 top-1/2 -translate-y-1/2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6",
"opacity-0 group-hover/note:opacity-100 focus:opacity-100",
"data-[state=open]:opacity-100",
"transition-opacity"
)}
disabled={isDeletingNote}
>
{isDeletingNote ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<MoreHorizontal className="h-3.5 w-3.5" />
)}
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right" className="w-40">
{note.actions.map((action, actionIndex) => {
const ActionIcon = actionIconMap[action.icon] || FileText;
const isDeleteAction = action.name.toLowerCase().includes("delete");
return (
<DropdownMenuItem
key={`${action.name}-${actionIndex}`}
onClick={() => {
if (isDeleteAction) {
handleDeleteNote(note.id || 0, action.onClick);
} else {
action.onClick();
}
}}
disabled={isDeletingNote}
className={
isDeleteAction
? "text-destructive focus:text-destructive"
: ""
}
>
<ActionIcon className="mr-2 h-4 w-4" />
<span>
{isDeletingNote && isDeleteAction
? "Deleting..."
: action.name}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</SidebarMenuItem>
);
})
) : (
<SidebarMenuItem>
{onAddNote ? (
<SidebarMenuButton
onClick={onAddNote}
className="text-muted-foreground hover:text-sidebar-foreground text-xs"
>
<Plus className="h-4 w-4" />
<span>{t("create_new_note") || "Create a new note"}</span>
</SidebarMenuButton>
) : (
<SidebarMenuButton disabled className="text-muted-foreground text-xs">
<FileText className="h-4 w-4" />
<span>{t("no_notes") || "No notes yet"}</span>
</SidebarMenuButton>
)}
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
{/* All Notes Sheet */}
{searchSpaceId && (
<AllNotesSidebar
open={isAllNotesSidebarOpen}
onOpenChange={setIsAllNotesSidebarOpen}
searchSpaceId={searchSpaceId}
onAddNote={onAddNote}
/>
)}
</SidebarGroup>
);
}

View file

@ -148,19 +148,6 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) {
return ( return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden"> <SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>{t("recent_chats")}</SidebarGroupLabel> <SidebarGroupLabel>{t("recent_chats")}</SidebarGroupLabel>
{/* Search Input */}
{showSearch && (
<div className="px-2 pb-2">
<SidebarInput
placeholder={t("search_chats")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
)}
<SidebarMenu> <SidebarMenu>
{/* Chat Items */} {/* Chat Items */}
{filteredChats.length > 0 ? ( {filteredChats.length > 0 ? (

View file

@ -17,7 +17,17 @@ import {
IconTicket, IconTicket,
IconWorldWww, IconWorldWww,
} from "@tabler/icons-react"; } 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"; import { EnumConnectorName } from "./connector";
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => { export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {
@ -71,6 +81,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <IconBrandYoutube {...iconProps} />; return <IconBrandYoutube {...iconProps} />;
case "FILE": case "FILE":
return <File {...iconProps} />; return <File {...iconProps} />;
case "NOTE":
return <FileText {...iconProps} />;
case "EXTENSION": case "EXTENSION":
return <Webhook {...iconProps} />; return <Webhook {...iconProps} />;
case "DEEP": case "DEEP":

View file

@ -19,6 +19,7 @@ export const documentTypeEnum = z.enum([
"LUMA_CONNECTOR", "LUMA_CONNECTOR",
"ELASTICSEARCH_CONNECTOR", "ELASTICSEARCH_CONNECTOR",
"LINEAR_CONNECTOR", "LINEAR_CONNECTOR",
"NOTE",
]); ]);
export const document = z.object({ export const document = z.object({
@ -27,7 +28,10 @@ export const document = z.object({
document_type: documentTypeEnum, document_type: documentTypeEnum,
document_metadata: z.record(z.string(), z.any()), document_metadata: z.record(z.string(), z.any()),
content: z.string(), content: z.string(),
content_hash: z.string(),
unique_identifier_hash: z.string().nullable(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string().nullable(),
search_space_id: z.number(), search_space_id: z.number(),
}); });
@ -68,6 +72,9 @@ export const getDocumentsRequest = z.object({
export const getDocumentsResponse = z.object({ export const getDocumentsResponse = z.object({
items: z.array(document), items: z.array(document),
total: z.number(), 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({ export const searchDocumentsResponse = z.object({
items: z.array(document), items: z.array(document),
total: z.number(), total: z.number(),
page: z.number(),
page_size: z.number(),
has_more: z.boolean(),
}); });
/** /**

View file

@ -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<typeof invite>;
export type CreateInviteRequest = z.infer<typeof createInviteRequest>;
export type CreateInviteResponse = z.infer<typeof createInviteResponse>;
export type GetInvitesRequest = z.infer<typeof getInvitesRequest>;
export type GetInvitesResponse = z.infer<typeof getInvitesResponse>;
export type UpdateInviteRequest = z.infer<typeof updateInviteRequest>;
export type UpdateInviteResponse = z.infer<typeof updateInviteResponse>;
export type DeleteInviteRequest = z.infer<typeof deleteInviteRequest>;
export type DeleteInviteResponse = z.infer<typeof deleteInviteResponse>;
export type GetInviteInfoRequest = z.infer<typeof getInviteInfoRequest>;
export type GetInviteInfoResponse = z.infer<typeof getInviteInfoResponse>;
export type AcceptInviteRequest = z.infer<typeof acceptInviteRequest>;
export type AcceptInviteResponse = z.infer<typeof acceptInviteResponse>;

View file

@ -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<typeof membership>;
export type GetMembersRequest = z.infer<typeof getMembersRequest>;
export type GetMembersResponse = z.infer<typeof getMembersResponse>;
export type UpdateMembershipRequest = z.infer<typeof updateMembershipRequest>;
export type UpdateMembershipResponse = z.infer<typeof updateMembershipResponse>;
export type DeleteMembershipRequest = z.infer<typeof deleteMembershipRequest>;
export type DeleteMembershipResponse = z.infer<typeof deleteMembershipResponse>;
export type LeaveSearchSpaceRequest = z.infer<typeof leaveSearchSpaceRequest>;
export type LeaveSearchSpaceResponse = z.infer<typeof leaveSearchSpaceResponse>;
export type GetMyAccessRequest = z.infer<typeof getMyAccessRequest>;
export type GetMyAccessResponse = z.infer<typeof getMyAccessResponse>;

View file

@ -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<typeof permissionInfo>;
export type GetPermissionsResponse = z.infer<typeof getPermissionsResponse>;

View file

@ -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<typeof role>;
export type CreateRoleRequest = z.infer<typeof createRoleRequest>;
export type CreateRoleResponse = z.infer<typeof createRoleResponse>;
export type GetRolesRequest = z.infer<typeof getRolesRequest>;
export type GetRolesResponse = z.infer<typeof getRolesResponse>;
export type GetRoleByIdRequest = z.infer<typeof getRoleByIdRequest>;
export type GetRoleByIdResponse = z.infer<typeof getRoleByIdResponse>;
export type UpdateRoleRequest = z.infer<typeof updateRoleRequest>;
export type UpdateRoleResponse = z.infer<typeof updateRoleResponse>;
export type DeleteRoleRequest = z.infer<typeof deleteRoleRequest>;
export type DeleteRoleResponse = z.infer<typeof deleteRoleResponse>;

View file

@ -29,12 +29,10 @@ export const getSearchSpacesResponse = z.array(searchSpace);
/** /**
* Create search space * Create search space
*/ */
export const createSearchSpaceRequest = searchSpace export const createSearchSpaceRequest = searchSpace.pick({ name: true, description: true }).extend({
.pick({ name: true, description: true })
.extend({
citations_enabled: z.boolean().default(true).optional(), citations_enabled: z.boolean().default(true).optional(),
qna_custom_instructions: z.string().nullable().optional(), qna_custom_instructions: z.string().nullable().optional(),
}); });
export const createSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true }); export const createSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true });

View file

@ -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<typeof user>;
export type GetMeResponse = z.infer<typeof getMeResponse>;

View file

@ -1,4 +1,4 @@
export * from "./use-debounced-value";
export * from "./use-logs"; export * from "./use-logs";
export * from "./use-rbac"; export * from "./use-rbac";
export * from "./use-search-source-connectors"; export * from "./use-search-source-connectors";
export * from "./use-user";

View file

@ -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<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View file

@ -141,7 +141,10 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
); );
// Function to create a new log // Function to create a new log
const createLog = useCallback(async (logData: Omit<Log, "id" | "created_at">) => { // Use silent: true to suppress toast notifications (for internal/background operations)
const createLog = useCallback(
async (logData: Omit<Log, "id" | "created_at">, options?: { silent?: boolean }) => {
const { silent = false } = options || {};
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
@ -159,14 +162,22 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
const newLog = await response.json(); const newLog = await response.json();
setLogs((prevLogs) => [newLog, ...prevLogs]); setLogs((prevLogs) => [newLog, ...prevLogs]);
// Only show toast if not silent
if (!silent) {
toast.success("Log created successfully"); toast.success("Log created successfully");
}
return newLog; return newLog;
} catch (err: any) { } catch (err: any) {
// Only show error toast if not silent
if (!silent) {
toast.error(err.message || "Failed to create log"); toast.error(err.message || "Failed to create log");
}
console.error("Error creating log:", err); console.error("Error creating log:", err);
throw err; throw err;
} }
}, []); },
[]
);
// Function to update a log // Function to update a log
const updateLog = useCallback( const updateLog = useCallback(

View file

@ -218,137 +218,6 @@ export function useMembers(searchSpaceId: number) {
// ============ Roles Hook ============ // ============ Roles Hook ============
export function useRoles(searchSpaceId: number) {
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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) { export function useInvites(searchSpaceId: number) {
const [invites, setInvites] = useState<Invite[]>([]); const [invites, setInvites] = useState<Invite[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -480,63 +349,6 @@ export function useInvites(searchSpaceId: number) {
// ============ Permissions Hook ============ // ============ Permissions Hook ============
export function usePermissions() {
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, PermissionInfo[]> = {};
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) { export function useUserAccess(searchSpaceId: number) {
const [access, setAccess] = useState<UserAccess | null>(null); const [access, setAccess] = useState<UserAccess | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);

View file

@ -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<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}

View file

@ -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<typeof createNoteRequest>;
export type CreateNoteResponse = z.infer<typeof createNoteResponse>;
export type GetNotesRequest = z.infer<typeof getNotesRequest>;
export type GetNotesResponse = z.infer<typeof getNotesResponse>;
export type NoteItem = z.infer<typeof noteItem>;
export type DeleteNoteRequest = z.infer<typeof deleteNoteRequest>;
export type DeleteNoteResponse = z.infer<typeof deleteNoteResponse>;
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();

View file

@ -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();

View file

@ -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();

View file

@ -1,18 +1,18 @@
import { import {
type CreateSearchSpaceRequest, type CreateSearchSpaceRequest,
type DeleteSearchSpaceRequest,
type GetSearchSpaceRequest,
type GetSearchSpacesRequest,
type UpdateSearchSpaceRequest,
createSearchSpaceRequest, createSearchSpaceRequest,
createSearchSpaceResponse, createSearchSpaceResponse,
type DeleteSearchSpaceRequest,
deleteSearchSpaceRequest, deleteSearchSpaceRequest,
deleteSearchSpaceResponse, deleteSearchSpaceResponse,
type GetSearchSpaceRequest,
type GetSearchSpacesRequest,
getCommunityPromptsResponse, getCommunityPromptsResponse,
getSearchSpaceRequest, getSearchSpaceRequest,
getSearchSpaceResponse, getSearchSpaceResponse,
getSearchSpacesRequest, getSearchSpacesRequest,
getSearchSpacesResponse, getSearchSpacesResponse,
type UpdateSearchSpaceRequest,
updateSearchSpaceRequest, updateSearchSpaceRequest,
updateSearchSpaceResponse, updateSearchSpaceResponse,
} from "@/contracts/types/search-space.types"; } from "@/contracts/types/search-space.types";
@ -71,7 +71,10 @@ class SearchSpacesApiService {
* Get community-curated prompts for search space system instructions * Get community-curated prompts for search space system instructions
*/ */
getCommunityPrompts = async () => { getCommunityPrompts = async () => {
return baseApiService.get(`/api/v1/searchspaces/prompts/community`, getCommunityPromptsResponse); return baseApiService.get(
`/api/v1/searchspaces/prompts/community`,
getCommunityPromptsResponse
);
}; };
/** /**

View file

@ -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();

View file

@ -2,6 +2,7 @@ import type { GetChatsRequest } from "@/contracts/types/chat.types";
import type { GetDocumentsRequest } from "@/contracts/types/document.types"; import type { GetDocumentsRequest } from "@/contracts/types/document.types";
import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types"; import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types";
import type { GetPodcastsRequest } from "@/contracts/types/podcast.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"; import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
export const cacheKeys = { export const cacheKeys = {
@ -40,5 +41,15 @@ export const cacheKeys = {
["search-spaces", ...(queries ? Object.values(queries) : [])] as const, ["search-spaces", ...(queries ? Object.values(queries) : [])] as const,
detail: (searchSpaceId: string) => ["search-spaces", searchSpaceId] as const, detail: (searchSpaceId: string) => ["search-spaces", searchSpaceId] as const,
communityPrompts: ["search-spaces", "community-prompts"] 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,
},
}; };

View file

@ -642,7 +642,17 @@
"no_chats_found": "No chats found", "no_chats_found": "No chats found",
"no_recent_chats": "No recent chats", "no_recent_chats": "No recent chats",
"view_all_chats": "View All 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": { "errors": {
"something_went_wrong": "Something went wrong", "something_went_wrong": "Something went wrong",

View file

@ -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": "搜索空间",
"notes": "笔记",
"all_notes": "所有笔记",
"all_notes_description": "浏览和管理您的所有笔记",
"search_notes": "搜索笔记...",
"no_results_found": "未找到笔记",
"try_different_search": "尝试其他搜索词",
"no_notes": "暂无笔记",
"create_new_note": "创建新笔记",
"error_loading_notes": "加载笔记时出错",
"loading": "加载中..."
}, },
"errors": { "errors": {
"something_went_wrong": "出错了", "something_went_wrong": "出错了",