mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-27 19:25:15 +02:00
commit
90b4ce6e43
52 changed files with 2560 additions and 452 deletions
|
|
@ -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
|
||||
|
|
@ -492,6 +492,7 @@ async def fetch_documents_by_ids(
|
|||
"CLICKUP_CONNECTOR": "ClickUp (Selected)",
|
||||
"AIRTABLE_CONNECTOR": "Airtable (Selected)",
|
||||
"LUMA_CONNECTOR": "Luma Events (Selected)",
|
||||
"NOTE": "Notes (Selected)",
|
||||
}
|
||||
|
||||
source_object = {
|
||||
|
|
@ -1162,6 +1163,33 @@ async def fetch_relevant_documents(
|
|||
}
|
||||
)
|
||||
|
||||
elif connector == "NOTE":
|
||||
(
|
||||
source_object,
|
||||
notes_chunks,
|
||||
) = await connector_service.search_notes(
|
||||
user_query=reformulated_query,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
# Add to sources and raw documents
|
||||
if source_object:
|
||||
all_sources.append(source_object)
|
||||
all_raw_documents.extend(notes_chunks)
|
||||
|
||||
# Stream found document count
|
||||
if streaming_service and writer:
|
||||
writer(
|
||||
{
|
||||
"yield_value": streaming_service.format_terminal_info_delta(
|
||||
f"📝 Found {len(notes_chunks)} Notes related to your query"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Error in search_airtable: %s", traceback.format_exc())
|
||||
error_message = f"Error searching connector {connector}: {e!s}"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ def get_connector_emoji(connector_name: str) -> str:
|
|||
"LUMA_CONNECTOR": "✨",
|
||||
"ELASTICSEARCH_CONNECTOR": "⚡",
|
||||
"WEBCRAWLER_CONNECTOR": "🌐",
|
||||
"NOTE": "📝",
|
||||
}
|
||||
return connector_emojis.get(connector_name, "🔎")
|
||||
|
||||
|
|
@ -59,6 +60,7 @@ def get_connector_friendly_name(connector_name: str) -> str:
|
|||
"LUMA_CONNECTOR": "Luma",
|
||||
"ELASTICSEARCH_CONNECTOR": "Elasticsearch",
|
||||
"WEBCRAWLER_CONNECTOR": "Web Pages",
|
||||
"NOTE": "Notes",
|
||||
}
|
||||
return connector_friendly_names.get(connector_name, connector_name)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ class DocumentType(str, Enum):
|
|||
LUMA_CONNECTOR = "LUMA_CONNECTOR"
|
||||
ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR"
|
||||
BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR"
|
||||
NOTE = "NOTE"
|
||||
|
||||
|
||||
class SearchSourceConnectorType(str, Enum):
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from .google_gmail_add_connector_route import (
|
|||
from .llm_config_routes import router as llm_config_router
|
||||
from .logs_routes import router as logs_router
|
||||
from .luma_add_connector_route import router as luma_add_connector_router
|
||||
from .notes_routes import router as notes_router
|
||||
from .podcasts_routes import router as podcasts_router
|
||||
from .rbac_routes import router as rbac_router
|
||||
from .search_source_connectors_routes import router as search_source_connectors_router
|
||||
|
|
@ -26,6 +27,7 @@ router.include_router(search_spaces_router)
|
|||
router.include_router(rbac_router) # RBAC routes for roles, members, invites
|
||||
router.include_router(editor_router)
|
||||
router.include_router(documents_router)
|
||||
router.include_router(notes_router)
|
||||
router.include_router(podcasts_router)
|
||||
router.include_router(chats_router)
|
||||
router.include_router(search_source_connectors_router)
|
||||
|
|
|
|||
|
|
@ -266,12 +266,27 @@ async def read_documents(
|
|||
document_type=doc.document_type,
|
||||
document_metadata=doc.document_metadata,
|
||||
content=doc.content,
|
||||
content_hash=doc.content_hash,
|
||||
unique_identifier_hash=doc.unique_identifier_hash,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
search_space_id=doc.search_space_id,
|
||||
)
|
||||
)
|
||||
|
||||
return PaginatedResponse(items=api_documents, total=total)
|
||||
# Calculate pagination info
|
||||
actual_page = (
|
||||
page if page is not None else (offset // page_size if page_size > 0 else 0)
|
||||
)
|
||||
has_more = (offset + len(api_documents)) < total if page_size > 0 else False
|
||||
|
||||
return PaginatedResponse(
|
||||
items=api_documents,
|
||||
total=total,
|
||||
page=actual_page,
|
||||
page_size=page_size,
|
||||
has_more=has_more,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -385,12 +400,27 @@ async def search_documents(
|
|||
document_type=doc.document_type,
|
||||
document_metadata=doc.document_metadata,
|
||||
content=doc.content,
|
||||
content_hash=doc.content_hash,
|
||||
unique_identifier_hash=doc.unique_identifier_hash,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
search_space_id=doc.search_space_id,
|
||||
)
|
||||
)
|
||||
|
||||
return PaginatedResponse(items=api_documents, total=total)
|
||||
# Calculate pagination info
|
||||
actual_page = (
|
||||
page if page is not None else (offset // page_size if page_size > 0 else 0)
|
||||
)
|
||||
has_more = (offset + len(api_documents)) < total if page_size > 0 else False
|
||||
|
||||
return PaginatedResponse(
|
||||
items=api_documents,
|
||||
total=total,
|
||||
page=actual_page,
|
||||
page_size=page_size,
|
||||
has_more=has_more,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -510,7 +540,10 @@ async def get_document_by_chunk_id(
|
|||
document_type=document.document_type,
|
||||
document_metadata=document.document_metadata,
|
||||
content=document.content,
|
||||
content_hash=document.content_hash,
|
||||
unique_identifier_hash=document.unique_identifier_hash,
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
search_space_id=document.search_space_id,
|
||||
chunks=sorted_chunks,
|
||||
)
|
||||
|
|
@ -559,7 +592,10 @@ async def read_document(
|
|||
document_type=document.document_type,
|
||||
document_metadata=document.document_metadata,
|
||||
content=document.content,
|
||||
content_hash=document.content_hash,
|
||||
unique_identifier_hash=document.unique_identifier_hash,
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
search_space_id=document.search_space_id,
|
||||
)
|
||||
except HTTPException:
|
||||
|
|
@ -614,7 +650,10 @@ async def update_document(
|
|||
document_type=db_document.document_type,
|
||||
document_metadata=db_document.document_metadata,
|
||||
content=db_document.content,
|
||||
content_hash=db_document.content_hash,
|
||||
unique_identifier_hash=db_document.unique_identifier_hash,
|
||||
created_at=db_document.created_at,
|
||||
updated_at=db_document.updated_at,
|
||||
search_space_id=db_document.search_space_id,
|
||||
)
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db import Document, Permission, User, get_async_session
|
||||
from app.db import Document, DocumentType, Permission, User, get_async_session
|
||||
from app.users import current_active_user
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
|
|
@ -59,13 +59,38 @@ async def get_editor_content(
|
|||
return {
|
||||
"document_id": document.id,
|
||||
"title": document.title,
|
||||
"document_type": document.document_type.value,
|
||||
"blocknote_document": document.blocknote_document,
|
||||
"updated_at": document.updated_at.isoformat()
|
||||
if document.updated_at
|
||||
else None,
|
||||
}
|
||||
|
||||
# Lazy migration: Try to generate blocknote_document from chunks
|
||||
# For NOTE type documents, return empty BlockNote structure if no content exists
|
||||
if document.document_type == DocumentType.NOTE:
|
||||
# Return empty BlockNote structure
|
||||
empty_blocknote = [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [],
|
||||
"children": [],
|
||||
}
|
||||
]
|
||||
# Save empty structure if not already saved
|
||||
if not document.blocknote_document:
|
||||
document.blocknote_document = empty_blocknote
|
||||
await session.commit()
|
||||
return {
|
||||
"document_id": document.id,
|
||||
"title": document.title,
|
||||
"document_type": document.document_type.value,
|
||||
"blocknote_document": empty_blocknote,
|
||||
"updated_at": document.updated_at.isoformat()
|
||||
if document.updated_at
|
||||
else None,
|
||||
}
|
||||
|
||||
# Lazy migration: Try to generate blocknote_document from chunks (for other document types)
|
||||
from app.utils.blocknote_converter import convert_markdown_to_blocknote
|
||||
|
||||
chunks = sorted(document.chunks, key=lambda c: c.id)
|
||||
|
|
@ -102,6 +127,7 @@ async def get_editor_content(
|
|||
return {
|
||||
"document_id": document.id,
|
||||
"title": document.title,
|
||||
"document_type": document.document_type.value,
|
||||
"blocknote_document": blocknote_json,
|
||||
"updated_at": document.updated_at.isoformat() if document.updated_at else None,
|
||||
}
|
||||
|
|
@ -147,6 +173,43 @@ async def save_document(
|
|||
if not blocknote_document:
|
||||
raise HTTPException(status_code=400, detail="blocknote_document is required")
|
||||
|
||||
# Add type validation
|
||||
if not isinstance(blocknote_document, list):
|
||||
raise HTTPException(status_code=400, detail="blocknote_document must be a list")
|
||||
|
||||
# For NOTE type documents, extract title from first block (heading)
|
||||
if (
|
||||
document.document_type == DocumentType.NOTE
|
||||
and blocknote_document
|
||||
and len(blocknote_document) > 0
|
||||
):
|
||||
first_block = blocknote_document[0]
|
||||
if (
|
||||
first_block
|
||||
and first_block.get("content")
|
||||
and isinstance(first_block["content"], list)
|
||||
):
|
||||
# Extract text from first block content
|
||||
# Match the frontend extractTitleFromBlockNote logic exactly
|
||||
title_parts = []
|
||||
for item in first_block["content"]:
|
||||
if isinstance(item, str):
|
||||
title_parts.append(item)
|
||||
elif (
|
||||
isinstance(item, dict)
|
||||
and "text" in item
|
||||
and isinstance(item["text"], str)
|
||||
):
|
||||
# BlockNote structure: {"type": "text", "text": "...", "styles": {}}
|
||||
title_parts.append(item["text"])
|
||||
|
||||
new_title = "".join(title_parts).strip()
|
||||
if new_title:
|
||||
document.title = new_title
|
||||
else:
|
||||
# Only set to "Untitled" if content exists but is empty
|
||||
document.title = "Untitled"
|
||||
|
||||
# Save BlockNote document
|
||||
document.blocknote_document = blocknote_document
|
||||
document.updated_at = datetime.now(UTC)
|
||||
|
|
|
|||
235
surfsense_backend/app/routes/notes_routes.py
Normal file
235
surfsense_backend/app/routes/notes_routes.py
Normal 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}
|
||||
|
|
@ -46,7 +46,10 @@ class DocumentRead(BaseModel):
|
|||
document_type: DocumentType
|
||||
document_metadata: dict
|
||||
content: str # Changed to string to match frontend
|
||||
content_hash: str
|
||||
unique_identifier_hash: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
search_space_id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
|
@ -61,3 +64,6 @@ class DocumentWithChunksRead(DocumentRead):
|
|||
class PaginatedResponse[T](BaseModel):
|
||||
items: list[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
has_more: bool
|
||||
|
|
|
|||
|
|
@ -2360,6 +2360,75 @@ class ConnectorService:
|
|||
|
||||
return result_object, elasticsearch_docs
|
||||
|
||||
async def search_notes(
|
||||
self,
|
||||
user_query: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Search for Notes and return both the source information and langchain documents.
|
||||
|
||||
Uses combined chunk-level and document-level hybrid search with RRF fusion.
|
||||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
start_date: Optional start date for filtering documents by updated_at
|
||||
end_date: Optional end date for filtering documents by updated_at
|
||||
|
||||
Returns:
|
||||
tuple: (sources_info, langchain_documents)
|
||||
"""
|
||||
notes_docs = await self._combined_rrf_search(
|
||||
query_text=user_query,
|
||||
search_space_id=search_space_id,
|
||||
document_type="NOTE",
|
||||
top_k=top_k,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
# Early return if no results
|
||||
if not notes_docs:
|
||||
return {
|
||||
"id": 51,
|
||||
"name": "Notes",
|
||||
"type": "NOTE",
|
||||
"sources": [],
|
||||
}, []
|
||||
|
||||
def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||
return doc_info.get("title", "Untitled Note")
|
||||
|
||||
def _url_fn(_doc_info: dict[str, Any], _metadata: dict[str, Any]) -> str:
|
||||
return "" # Notes don't have URLs
|
||||
|
||||
def _description_fn(
|
||||
chunk: dict[str, Any], _doc_info: dict[str, Any], _metadata: dict[str, Any]
|
||||
) -> str:
|
||||
return self._chunk_preview(chunk.get("content", ""), limit=200)
|
||||
|
||||
sources_list = self._build_chunk_sources_from_documents(
|
||||
notes_docs,
|
||||
title_fn=_title_fn,
|
||||
url_fn=_url_fn,
|
||||
description_fn=_description_fn,
|
||||
)
|
||||
|
||||
# Create result object
|
||||
result_object = {
|
||||
"id": 51,
|
||||
"name": "Notes",
|
||||
"type": "NOTE",
|
||||
"sources": sources_list,
|
||||
}
|
||||
|
||||
return result_object, notes_docs
|
||||
|
||||
async def search_bookstack(
|
||||
self,
|
||||
user_query: str,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import logging
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
|
@ -11,6 +12,7 @@ from app.celery_app import celery_app
|
|||
from app.config import config
|
||||
from app.db import Document
|
||||
from app.services.llm_service import get_user_long_context_llm
|
||||
from app.services.task_logging_service import TaskLoggingService
|
||||
from app.utils.blocknote_converter import convert_blocknote_to_markdown
|
||||
from app.utils.document_converters import (
|
||||
create_document_chunks,
|
||||
|
|
@ -53,21 +55,42 @@ def reindex_document_task(self, document_id: int, user_id: str):
|
|||
async def _reindex_document(document_id: int, user_id: str):
|
||||
"""Async function to reindex a document."""
|
||||
async with get_celery_session_maker()() as session:
|
||||
# First, get the document to get search_space_id for logging
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.options(selectinload(Document.chunks))
|
||||
.where(Document.id == document_id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
if not document:
|
||||
logger.error(f"Document {document_id} not found")
|
||||
return
|
||||
|
||||
# Initialize task logger
|
||||
task_logger = TaskLoggingService(session, document.search_space_id)
|
||||
|
||||
# Log task start
|
||||
log_entry = await task_logger.log_task_start(
|
||||
task_name="document_reindex",
|
||||
source="editor",
|
||||
message=f"Starting reindex for document: {document.title}",
|
||||
metadata={
|
||||
"document_id": document_id,
|
||||
"document_type": document.document_type.value,
|
||||
"title": document.title,
|
||||
"user_id": user_id,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Get document
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.options(selectinload(Document.chunks)) # Eagerly load chunks
|
||||
.where(Document.id == document_id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
if not document:
|
||||
logger.error(f"Document {document_id} not found")
|
||||
return
|
||||
|
||||
if not document.blocknote_document:
|
||||
logger.warning(f"Document {document_id} has no BlockNote content")
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Document {document_id} has no BlockNote content to reindex",
|
||||
"No BlockNote content",
|
||||
{"error_type": "NoBlockNoteContent"},
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"Reindexing document {document_id} ({document.title})")
|
||||
|
|
@ -78,7 +101,12 @@ async def _reindex_document(document_id: int, user_id: str):
|
|||
)
|
||||
|
||||
if not markdown_content:
|
||||
logger.error(f"Failed to convert document {document_id} to markdown")
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to convert document {document_id} to markdown",
|
||||
"Markdown conversion failed",
|
||||
{"error_type": "ConversionError"},
|
||||
)
|
||||
return
|
||||
|
||||
# 2. Delete old chunks explicitly
|
||||
|
|
@ -118,9 +146,39 @@ async def _reindex_document(document_id: int, user_id: str):
|
|||
|
||||
await session.commit()
|
||||
|
||||
# Log success
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"Successfully reindexed document: {document.title}",
|
||||
{
|
||||
"chunks_created": len(new_chunks),
|
||||
"document_id": document_id,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Successfully reindexed document {document_id}")
|
||||
|
||||
except SQLAlchemyError as db_error:
|
||||
await session.rollback()
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Database error during reindex for document {document_id}",
|
||||
str(db_error),
|
||||
{"error_type": "SQLAlchemyError"},
|
||||
)
|
||||
logger.error(
|
||||
f"Database error reindexing document {document_id}: {db_error}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to reindex document: {document.title}",
|
||||
str(e),
|
||||
{"error_type": type(e).__name__},
|
||||
)
|
||||
logger.error(f"Error reindexing document {document_id}: {e}", exc_info=True)
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContain
|
|||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
|
|
@ -224,7 +223,6 @@ export function DashboardClientLayout({
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<ThemeTogglerComponent />
|
||||
{/* Only show artifacts toggle on researcher page */}
|
||||
{isResearcherPage && (
|
||||
<motion.div
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export function DocumentsTableShell({
|
|||
<>
|
||||
<div className="hidden md:block max-h-[60vh] overflow-auto">
|
||||
<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">
|
||||
<TableHead style={{ width: 28 }}>
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -1,27 +1,72 @@
|
|||
"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 { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
interface EditorContent {
|
||||
document_id: number;
|
||||
title: string;
|
||||
document_type?: string;
|
||||
blocknote_document: any;
|
||||
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() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const documentId = params.documentId as string;
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const isNewNote = documentId === "new";
|
||||
|
||||
const [document, setDocument] = useState<EditorContent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -29,10 +74,26 @@ export default function EditorPage() {
|
|||
const [editorContent, setEditorContent] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
||||
|
||||
// Fetch document content - DIRECT CALL TO FASTAPI
|
||||
// Skip fetching if this is a new note
|
||||
useEffect(() => {
|
||||
async function fetchDocument() {
|
||||
// For new notes, initialize with empty state
|
||||
if (isNewNote) {
|
||||
setDocument({
|
||||
document_id: 0,
|
||||
title: "Untitled",
|
||||
document_type: "NOTE",
|
||||
blocknote_document: null,
|
||||
updated_at: null,
|
||||
});
|
||||
setEditorContent(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
console.error("No auth token found");
|
||||
|
|
@ -51,16 +112,17 @@ export default function EditorPage() {
|
|||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to fetch document" }));
|
||||
throw new Error(errorData.detail || "Failed to fetch document");
|
||||
const errorMessage = errorData.detail || "Failed to fetch document";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check if blocknote_document exists
|
||||
if (!data.blocknote_document) {
|
||||
setError(
|
||||
"This document does not have BlockNote content. Please re-upload the document to enable editing."
|
||||
);
|
||||
const errorMsg =
|
||||
"This document does not have BlockNote content. Please re-upload the document to enable editing.";
|
||||
setError(errorMsg);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -70,9 +132,9 @@ export default function EditorPage() {
|
|||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error fetching document:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Failed to fetch document. Please try again."
|
||||
);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to fetch document. Please try again.";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -81,7 +143,7 @@ export default function EditorPage() {
|
|||
if (documentId) {
|
||||
fetchDocument();
|
||||
}
|
||||
}, [documentId, params.search_space_id]);
|
||||
}, [documentId, params.search_space_id, isNewNote]);
|
||||
|
||||
// Track changes to mark as unsaved
|
||||
useEffect(() => {
|
||||
|
|
@ -90,9 +152,21 @@ export default function EditorPage() {
|
|||
}
|
||||
}, [editorContent, document]);
|
||||
|
||||
// Check if this is a NOTE type document
|
||||
const isNote = isNewNote || document?.document_type === "NOTE";
|
||||
|
||||
// Extract title dynamically from editor content for notes, otherwise use document title
|
||||
const displayTitle = useMemo(() => {
|
||||
if (isNote && editorContent) {
|
||||
return extractTitleFromBlockNote(editorContent);
|
||||
}
|
||||
return document?.title || "Untitled";
|
||||
}, [isNote, editorContent, document?.title]);
|
||||
|
||||
// TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
|
||||
|
||||
// Save and exit - DIRECT CALL TO FASTAPI
|
||||
// For new notes, create the note first, then save
|
||||
const handleSave = async () => {
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
|
|
@ -101,57 +175,121 @@ export default function EditorPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!editorContent) {
|
||||
toast.error("No content to save");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Save blocknote_document and trigger reindexing in background
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blocknote_document: editorContent }),
|
||||
// If this is a new note, create it first
|
||||
if (isNewNote) {
|
||||
const title = extractTitleFromBlockNote(editorContent);
|
||||
|
||||
// Create the note first
|
||||
const note = await notesApiService.createNote({
|
||||
search_space_id: searchSpaceId,
|
||||
title: title,
|
||||
blocknote_document: editorContent || undefined,
|
||||
});
|
||||
|
||||
// If there's content, save it properly and trigger reindexing
|
||||
if (editorContent) {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blocknote_document: editorContent }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to save document" }));
|
||||
throw new Error(errorData.detail || "Failed to save document");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to save document" }));
|
||||
throw new Error(errorData.detail || "Failed to save document");
|
||||
setHasUnsavedChanges(false);
|
||||
toast.success("Note created successfully! Reindexing in background...");
|
||||
|
||||
// Invalidate notes query to refresh the sidebar
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["notes", String(searchSpaceId)],
|
||||
});
|
||||
|
||||
// Update URL to reflect the new document ID without navigation
|
||||
window.history.replaceState({}, "", `/dashboard/${searchSpaceId}/editor/${note.id}`);
|
||||
// Update document state to reflect the new ID
|
||||
setDocument({
|
||||
document_id: note.id,
|
||||
title: title,
|
||||
document_type: "NOTE",
|
||||
blocknote_document: editorContent,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Existing document - save normally
|
||||
if (!editorContent) {
|
||||
toast.error("No content to save");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save blocknote_document and trigger reindexing in background
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blocknote_document: editorContent }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to save document" }));
|
||||
throw new Error(errorData.detail || "Failed to save document");
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
|
||||
// Invalidate notes query when updating notes to refresh the sidebar
|
||||
if (isNote) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["notes", String(searchSpaceId)],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
|
||||
// Small delay before redirect to show success message
|
||||
setTimeout(() => {
|
||||
router.push(`/dashboard/${params.search_space_id}/documents`);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error("Error saving document:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save document. Please try again."
|
||||
);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: isNewNote
|
||||
? "Failed to create note. Please try again."
|
||||
: "Failed to save document. Please try again.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
const handleBack = () => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (confirm("You have unsaved changes. Are you sure you want to leave?")) {
|
||||
router.back();
|
||||
}
|
||||
setShowUnsavedDialog(true);
|
||||
} else {
|
||||
router.back();
|
||||
router.push(`/dashboard/${searchSpaceId}/researcher`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmLeave = () => {
|
||||
setShowUnsavedDialog(false);
|
||||
router.push(`/dashboard/${searchSpaceId}/researcher`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
||||
|
|
@ -182,9 +320,13 @@ export default function EditorPage() {
|
|||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => router.back()} variant="outline" className="w-full">
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
<Button
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/researcher`)}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -193,7 +335,7 @@ export default function EditorPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
if (!document && !isNewNote) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
||||
<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">
|
||||
<FileText className="h-5 w-5 text-muted-foreground shrink-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>}
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel} disabled={saving} className="gap-2">
|
||||
<X className="h-4 w-4" />
|
||||
Cancel
|
||||
<Button variant="outline" onClick={handleBack} disabled={saving} className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-2">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
{isNewNote ? "Creating..." : "Saving..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Save & Exit
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -244,13 +386,45 @@ export default function EditorPage() {
|
|||
</div>
|
||||
|
||||
{/* 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">
|
||||
{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">
|
||||
<BlockNoteEditor initialContent={editorContent} onChange={setEditorContent} />
|
||||
<BlockNoteEditor
|
||||
initialContent={isNewNote ? undefined : editorContent}
|
||||
onChange={setEditorContent}
|
||||
useTitleBlock={isNote}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
|
|
@ -44,6 +46,12 @@ import { motion } from "motion/react";
|
|||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms";
|
||||
import {
|
||||
createRoleMutationAtom,
|
||||
deleteRoleMutationAtom,
|
||||
updateRoleMutationAtom,
|
||||
} from "@/atoms/roles/roles-mutation.atoms";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -99,24 +107,28 @@ import {
|
|||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type {
|
||||
CreateRoleRequest,
|
||||
DeleteRoleRequest,
|
||||
Role,
|
||||
UpdateRoleRequest,
|
||||
} from "@/contracts/types/roles.types";
|
||||
import {
|
||||
type Invite,
|
||||
type InviteCreate,
|
||||
type Member,
|
||||
type Role,
|
||||
type RoleCreate,
|
||||
useInvites,
|
||||
useMembers,
|
||||
usePermissions,
|
||||
useRoles,
|
||||
useUserAccess,
|
||||
} from "@/hooks/use-rbac";
|
||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Animation variants
|
||||
const fadeInUp = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" } },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" as const } },
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
|
|
@ -132,7 +144,7 @@ const cardVariants = {
|
|||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { type: "spring", stiffness: 300, damping: 30 },
|
||||
transition: { type: "spring" as const, stiffness: 300, damping: 30 },
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -150,14 +162,55 @@ export default function TeamManagementPage() {
|
|||
updateMemberRole,
|
||||
removeMember,
|
||||
} = useMembers(searchSpaceId);
|
||||
|
||||
const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom);
|
||||
const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom);
|
||||
const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom);
|
||||
|
||||
const handleUpdateRole = useCallback(
|
||||
async (roleId: number, data: { permissions?: string[] }): Promise<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 {
|
||||
roles,
|
||||
loading: rolesLoading,
|
||||
fetchRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
} = useRoles(searchSpaceId);
|
||||
data: roles = [],
|
||||
isLoading: rolesLoading,
|
||||
refetch: fetchRoles,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.roles.all(searchSpaceId.toString()),
|
||||
queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
const {
|
||||
invites,
|
||||
loading: invitesLoading,
|
||||
|
|
@ -165,7 +218,19 @@ export default function TeamManagementPage() {
|
|||
createInvite,
|
||||
revokeInvite,
|
||||
} = useInvites(searchSpaceId);
|
||||
const { groupedPermissions, loading: permissionsLoading } = usePermissions();
|
||||
|
||||
const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom);
|
||||
const permissions = permissionsData?.permissions || [];
|
||||
const groupedPermissions = useMemo(() => {
|
||||
const groups: Record<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 canManageRoles = hasPermission("roles:read");
|
||||
|
|
@ -329,7 +394,7 @@ export default function TeamManagementPage() {
|
|||
{activeTab === "roles" && hasPermission("roles:create") && (
|
||||
<CreateRoleDialog
|
||||
groupedPermissions={groupedPermissions}
|
||||
onCreateRole={createRole}
|
||||
onCreateRole={handleCreateRole}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -351,8 +416,8 @@ export default function TeamManagementPage() {
|
|||
roles={roles}
|
||||
groupedPermissions={groupedPermissions}
|
||||
loading={rolesLoading}
|
||||
onUpdateRole={updateRole}
|
||||
onDeleteRole={deleteRole}
|
||||
onUpdateRole={handleUpdateRole}
|
||||
onDeleteRole={handleDeleteRole}
|
||||
canUpdate={hasPermission("roles:update")}
|
||||
canDelete={hasPermission("roles:delete")}
|
||||
/>
|
||||
|
|
@ -663,7 +728,12 @@ function RolesTab({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{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" />
|
||||
Edit Role
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -882,7 +952,7 @@ function InvitesTab({
|
|||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => copyInviteLink(invite)}
|
||||
disabled={isInactive}
|
||||
disabled={Boolean(isInactive)}
|
||||
>
|
||||
{copiedId === invite.id ? (
|
||||
<>
|
||||
|
|
@ -1158,7 +1228,7 @@ function CreateRoleDialog({
|
|||
onCreateRole,
|
||||
}: {
|
||||
groupedPermissions: Record<string, { value: string; name: string; category: string }[]>;
|
||||
onCreateRole: (data: RoleCreate) => Promise<Role>;
|
||||
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
|
@ -1177,7 +1247,7 @@ function CreateRoleDialog({
|
|||
try {
|
||||
await onCreateRole({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
description: description.trim() || null,
|
||||
permissions: selectedPermissions,
|
||||
is_default: isDefault,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import Link from "next/link";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||
import { UserDropdown } from "@/components/UserDropdown";
|
||||
|
|
@ -35,9 +38,6 @@ import {
|
|||
} from "@/components/ui/card";
|
||||
import { Spotlight } from "@/components/ui/spotlight";
|
||||
import { Tilt } from "@/components/ui/tilt";
|
||||
import { useUser } from "@/hooks";
|
||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
/**
|
||||
|
|
@ -156,11 +156,15 @@ const DashboardPage = () => {
|
|||
},
|
||||
};
|
||||
|
||||
const { data: searchSpaces = [], isLoading: loading, error, refetch: refreshSearchSpaces } = useAtomValue(searchSpacesAtom);
|
||||
const {
|
||||
data: searchSpaces = [],
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch: refreshSearchSpaces,
|
||||
} = useAtomValue(searchSpacesAtom);
|
||||
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
||||
|
||||
// Fetch user details
|
||||
const { user, loading: isLoadingUser, error: userError } = useUser();
|
||||
const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom);
|
||||
|
||||
// Create user object for UserDropdown
|
||||
const customUser = {
|
||||
|
|
@ -172,7 +176,7 @@ const DashboardPage = () => {
|
|||
};
|
||||
|
||||
if (loading) return <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) => {
|
||||
await deleteSearchSpace({ id });
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SearchSpaceForm } from "@/components/search-space-form";
|
||||
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { SearchSpaceForm } from "@/components/search-space-form";
|
||||
|
||||
export default function SearchSpacesPage() {
|
||||
const router = useRouter();
|
||||
|
|
|
|||
13
surfsense_web/atoms/permissions/permissions-query.atoms.ts
Normal file
13
surfsense_web/atoms/permissions/permissions-query.atoms.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
});
|
||||
70
surfsense_web/atoms/roles/roles-mutation.atoms.ts
Normal file
70
surfsense_web/atoms/roles/roles-mutation.atoms.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -2,13 +2,13 @@ import { atomWithMutation } from "jotai-tanstack-query";
|
|||
import { toast } from "sonner";
|
||||
import type {
|
||||
CreateSearchSpaceRequest,
|
||||
UpdateSearchSpaceRequest,
|
||||
DeleteSearchSpaceRequest,
|
||||
UpdateSearchSpaceRequest,
|
||||
} from "@/contracts/types/search-space.types";
|
||||
import { activeSearchSpaceIdAtom } from "./search-space-query.atoms";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { activeSearchSpaceIdAtom } from "./search-space-query.atoms";
|
||||
|
||||
export const createSearchSpaceMutationAtom = atomWithMutation(() => {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { atom } from "jotai";
|
||||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
|
|
|||
13
surfsense_web/atoms/user/user-query.atoms.ts
Normal file
13
surfsense_web/atoms/user/user-query.atoms.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -10,31 +10,123 @@ import { useCreateBlockNote } from "@blocknote/react";
|
|||
interface BlockNoteEditorProps {
|
||||
initialContent?: any;
|
||||
onChange?: (content: any) => void;
|
||||
useTitleBlock?: boolean; // Whether to use first block as title (Notion-style)
|
||||
}
|
||||
|
||||
export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteEditorProps) {
|
||||
// Helper to ensure first block is a heading for title
|
||||
function ensureTitleBlock(content: any[] | undefined): any[] {
|
||||
if (!content || content.length === 0) {
|
||||
// Return empty heading block for new notes
|
||||
return [
|
||||
{
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
content: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// If first block is not a heading, convert it to one
|
||||
const firstBlock = content[0];
|
||||
if (firstBlock?.type !== "heading") {
|
||||
// Extract text from first block
|
||||
let titleText = "";
|
||||
if (firstBlock?.content && Array.isArray(firstBlock.content)) {
|
||||
titleText = firstBlock.content
|
||||
.map((item: any) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item?.text) return item.text;
|
||||
return "";
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Create heading block with extracted text
|
||||
const titleBlock = {
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
content: titleText
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: titleText,
|
||||
styles: {},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
children: [],
|
||||
};
|
||||
|
||||
// Replace first block with heading, keep rest
|
||||
return [titleBlock, ...content.slice(1)];
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export default function BlockNoteEditor({
|
||||
initialContent,
|
||||
onChange,
|
||||
useTitleBlock = false,
|
||||
}: BlockNoteEditorProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
// Track the initial content to prevent re-initialization
|
||||
const initialContentRef = useRef<any>(null);
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
// Prepare initial content - ensure first block is a heading if useTitleBlock is true
|
||||
const preparedInitialContent = useMemo(() => {
|
||||
if (initialContentRef.current !== null) {
|
||||
return undefined; // Already initialized
|
||||
}
|
||||
if (initialContent === undefined) {
|
||||
// New note - create empty heading block
|
||||
return useTitleBlock
|
||||
? [
|
||||
{
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
content: [],
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
}
|
||||
// Existing note - ensure first block is heading
|
||||
return useTitleBlock ? ensureTitleBlock(initialContent) : initialContent;
|
||||
}, [initialContent, useTitleBlock]);
|
||||
|
||||
// Creates a new editor instance - only use initialContent on first render
|
||||
const editor = useCreateBlockNote({
|
||||
initialContent: initialContentRef.current === null ? initialContent || undefined : undefined,
|
||||
initialContent: initialContentRef.current === null ? preparedInitialContent : undefined,
|
||||
});
|
||||
|
||||
// Store initial content on first render only
|
||||
useEffect(() => {
|
||||
if (initialContent && initialContentRef.current === null) {
|
||||
initialContentRef.current = initialContent;
|
||||
if (preparedInitialContent !== undefined && initialContentRef.current === null) {
|
||||
initialContentRef.current = preparedInitialContent;
|
||||
isInitializedRef.current = true;
|
||||
} else if (preparedInitialContent === undefined && initialContentRef.current === null) {
|
||||
// Mark as initialized even when initialContent is undefined (for new notes)
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
}, [initialContent]);
|
||||
}, [preparedInitialContent]);
|
||||
|
||||
// Call onChange when document changes (but don't update from props)
|
||||
useEffect(() => {
|
||||
if (!onChange || !editor || !isInitializedRef.current) return;
|
||||
if (!onChange || !editor) return;
|
||||
|
||||
// For new notes (no initialContent), we need to wait for editor to be ready
|
||||
// Use a small delay to ensure editor is fully initialized
|
||||
if (!isInitializedRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
isInitializedRef.current = true;
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
const handleChange = () => {
|
||||
onChange(editor.document);
|
||||
|
|
@ -43,6 +135,12 @@ export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteE
|
|||
// Subscribe to document changes
|
||||
const unsubscribe = editor.onChange(handleChange);
|
||||
|
||||
// Also call onChange once with current document to capture initial state
|
||||
// This ensures we capture content even if user doesn't make changes
|
||||
if (editor.document) {
|
||||
onChange(editor.document);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
|
@ -13,10 +14,9 @@ import {
|
|||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
interface BreadcrumbItemInterface {
|
||||
label: string;
|
||||
|
|
@ -44,6 +44,13 @@ export function DashboardBreadcrumb() {
|
|||
useEffect(() => {
|
||||
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
|
||||
const documentId = segments[3];
|
||||
|
||||
// Skip fetch for "new" notes
|
||||
if (documentId === "new") {
|
||||
setDocumentTitle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBearerToken();
|
||||
|
||||
if (token) {
|
||||
|
|
@ -110,7 +117,14 @@ export function DashboardBreadcrumb() {
|
|||
|
||||
// Handle editor sub-sections (document ID)
|
||||
if (section === "editor") {
|
||||
const documentLabel = documentTitle || subSection;
|
||||
// Handle special cases for editor
|
||||
let documentLabel: string;
|
||||
if (subSection === "new") {
|
||||
documentLabel = "New Note";
|
||||
} else {
|
||||
documentLabel = documentTitle || subSection;
|
||||
}
|
||||
|
||||
breadcrumbs.push({
|
||||
label: t("documents"),
|
||||
href: `/dashboard/${segments[1]}/documents`,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ChevronDown, ChevronUp, ExternalLink, Info, Sparkles, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -12,9 +14,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
interface SetupPromptStepProps {
|
||||
searchSpaceId: number;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
|
|
@ -12,6 +14,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -23,19 +26,20 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
interface PromptConfigManagerProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) {
|
||||
const { data: searchSpace, isLoading: loading, refetch: fetchSearchSpace } = useQuery({
|
||||
const {
|
||||
data: searchSpace,
|
||||
isLoading: loading,
|
||||
refetch: fetchSearchSpace,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
|
||||
enabled: !!searchSpaceId,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
|
||||
import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
|
||||
import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -17,8 +20,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useUser } from "@/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
|
|
@ -48,6 +50,7 @@ export function AppSidebarProvider({
|
|||
}: AppSidebarProviderProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const setChatsQueryParams = useSetAtom(globalChatsQueryParamsAtom);
|
||||
const { data: chats, error: chatError, isLoading: isLoadingChats } = useAtomValue(chatsAtom);
|
||||
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
|
||||
|
|
@ -68,7 +71,23 @@ export function AppSidebarProvider({
|
|||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
const { user } = useUser();
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
|
||||
// Fetch notes
|
||||
const {
|
||||
data: notesData,
|
||||
error: notesError,
|
||||
isLoading: isLoadingNotes,
|
||||
refetch: refetchNotes,
|
||||
} = useQuery({
|
||||
queryKey: ["notes", searchSpaceId],
|
||||
queryFn: () =>
|
||||
notesApiService.getNotes({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
page_size: 5, // Get 5 notes (changed from 10)
|
||||
}),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||
|
|
@ -162,6 +181,53 @@ export function AppSidebarProvider({
|
|||
// Use fallback chats if there's an error or no chats
|
||||
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
|
||||
|
||||
// Transform notes to the format expected by NavNotes
|
||||
const recentNotes = useMemo(() => {
|
||||
if (!notesData?.items) return [];
|
||||
|
||||
// Sort notes by updated_at (most recent first), fallback to created_at if updated_at is null
|
||||
const sortedNotes = [...notesData.items].sort((a, b) => {
|
||||
const dateA = a.updated_at
|
||||
? new Date(a.updated_at).getTime()
|
||||
: new Date(a.created_at).getTime();
|
||||
const dateB = b.updated_at
|
||||
? new Date(b.updated_at).getTime()
|
||||
: new Date(b.created_at).getTime();
|
||||
return dateB - dateA; // Descending order (most recent first)
|
||||
});
|
||||
|
||||
// Limit to 5 notes
|
||||
return sortedNotes.slice(0, 5).map((note) => ({
|
||||
name: note.title,
|
||||
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
|
||||
icon: "FileText",
|
||||
id: note.id,
|
||||
search_space_id: note.search_space_id,
|
||||
actions: [
|
||||
{
|
||||
name: "Delete",
|
||||
icon: "Trash2",
|
||||
onClick: async () => {
|
||||
try {
|
||||
await notesApiService.deleteNote({
|
||||
search_space_id: note.search_space_id,
|
||||
note_id: note.id,
|
||||
});
|
||||
refetchNotes();
|
||||
} catch (error) {
|
||||
console.error("Error deleting note:", error);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, [notesData, refetchNotes]);
|
||||
|
||||
// Handle add note
|
||||
const handleAddNote = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/editor/new`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
// Memoized updated navSecondary
|
||||
const updatedNavSecondary = useMemo(() => {
|
||||
const updated = [...navSecondary];
|
||||
|
|
@ -204,6 +270,7 @@ export function AppSidebarProvider({
|
|||
navSecondary={navSecondary}
|
||||
navMain={navMain}
|
||||
RecentChats={[]}
|
||||
RecentNotes={[]}
|
||||
pageUsage={pageUsage}
|
||||
/>
|
||||
);
|
||||
|
|
@ -216,6 +283,8 @@ export function AppSidebarProvider({
|
|||
navSecondary={updatedNavSecondary}
|
||||
navMain={navMain}
|
||||
RecentChats={displayChats}
|
||||
RecentNotes={recentNotes}
|
||||
onAddNote={handleAddNote}
|
||||
pageUsage={pageUsage}
|
||||
/>
|
||||
|
||||
|
|
|
|||
293
surfsense_web/components/sidebar/all-notes-sidebar.tsx
Normal file
293
surfsense_web/components/sidebar/all-notes-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
|
|
@ -24,11 +25,10 @@ import {
|
|||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -38,7 +38,6 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useUser } from "@/hooks/use-user";
|
||||
|
||||
/**
|
||||
* Generates a consistent color based on a string (email)
|
||||
|
|
@ -115,6 +114,7 @@ function UserAvatar({ email, size = 32 }: { email: string; size?: number }) {
|
|||
}
|
||||
|
||||
import { NavMain } from "@/components/sidebar/nav-main";
|
||||
import { NavNotes } from "@/components/sidebar/nav-notes";
|
||||
import { NavProjects } from "@/components/sidebar/nav-projects";
|
||||
import { NavSecondary } from "@/components/sidebar/nav-secondary";
|
||||
import { PageUsageDisplay } from "@/components/sidebar/page-usage-display";
|
||||
|
|
@ -138,13 +138,13 @@ export const iconMap: Record<string, LucideIcon> = {
|
|||
MessageCircleMore,
|
||||
Settings2,
|
||||
SquareLibrary,
|
||||
FileText,
|
||||
SquareTerminal,
|
||||
AlertCircle,
|
||||
Info,
|
||||
ExternalLink,
|
||||
Trash2,
|
||||
Podcast,
|
||||
FileText,
|
||||
Users,
|
||||
};
|
||||
|
||||
|
|
@ -209,6 +209,20 @@ const defaultData = {
|
|||
id: 1003,
|
||||
},
|
||||
],
|
||||
RecentNotes: [
|
||||
{
|
||||
name: "Meeting Notes",
|
||||
url: "#",
|
||||
icon: "FileText",
|
||||
id: 2001,
|
||||
},
|
||||
{
|
||||
name: "Project Ideas",
|
||||
url: "#",
|
||||
icon: "FileText",
|
||||
id: 2002,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
|
|
@ -240,6 +254,18 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
|||
onClick: () => void;
|
||||
}[];
|
||||
}[];
|
||||
RecentNotes?: {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
id?: number;
|
||||
search_space_id?: number;
|
||||
actions?: {
|
||||
name: string;
|
||||
icon: string;
|
||||
onClick: () => void;
|
||||
}[];
|
||||
}[];
|
||||
user?: {
|
||||
name: string;
|
||||
email: string;
|
||||
|
|
@ -249,6 +275,7 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
|||
pagesUsed: number;
|
||||
pagesLimit: number;
|
||||
};
|
||||
onAddNote?: () => void;
|
||||
}
|
||||
|
||||
// Memoized AppSidebar component for better performance
|
||||
|
|
@ -257,12 +284,14 @@ export const AppSidebar = memo(function AppSidebar({
|
|||
navMain = defaultData.navMain,
|
||||
navSecondary = defaultData.navSecondary,
|
||||
RecentChats = defaultData.RecentChats,
|
||||
RecentNotes = defaultData.RecentNotes,
|
||||
pageUsage,
|
||||
onAddNote,
|
||||
...props
|
||||
}: AppSidebarProps) {
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { user, loading: isLoadingUser } = useUser();
|
||||
const { data: user, isPending: isLoadingUser } = useAtomValue(currentUserAtom);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -295,6 +324,16 @@ export const AppSidebar = memo(function AppSidebar({
|
|||
);
|
||||
}, [RecentChats]);
|
||||
|
||||
// Process RecentNotes to resolve icon names to components
|
||||
const processedRecentNotes = useMemo(() => {
|
||||
return (
|
||||
RecentNotes?.map((item) => ({
|
||||
...item,
|
||||
icon: iconMap[item.icon] || FileText,
|
||||
})) || []
|
||||
);
|
||||
}, [RecentNotes]);
|
||||
|
||||
// Get user display name from email
|
||||
const userDisplayName = user?.email ? user.email.split("@")[0] : "User";
|
||||
const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown");
|
||||
|
|
@ -412,6 +451,14 @@ export const AppSidebar = memo(function AppSidebar({
|
|||
<NavProjects chats={processedRecentChats} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<NavNotes
|
||||
notes={processedRecentNotes}
|
||||
onAddNote={onAddNote}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
{pageUsage && (
|
||||
|
|
|
|||
256
surfsense_web/components/sidebar/nav-notes.tsx
Normal file
256
surfsense_web/components/sidebar/nav-notes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -148,19 +148,6 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) {
|
|||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<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>
|
||||
{/* Chat Items */}
|
||||
{filteredChats.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -17,7 +17,17 @@ import {
|
|||
IconTicket,
|
||||
IconWorldWww,
|
||||
} from "@tabler/icons-react";
|
||||
import { File, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react";
|
||||
import {
|
||||
File,
|
||||
FileText,
|
||||
Globe,
|
||||
Link,
|
||||
Microscope,
|
||||
Search,
|
||||
Sparkles,
|
||||
Telescope,
|
||||
Webhook,
|
||||
} from "lucide-react";
|
||||
import { EnumConnectorName } from "./connector";
|
||||
|
||||
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {
|
||||
|
|
@ -71,6 +81,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
|||
return <IconBrandYoutube {...iconProps} />;
|
||||
case "FILE":
|
||||
return <File {...iconProps} />;
|
||||
case "NOTE":
|
||||
return <FileText {...iconProps} />;
|
||||
case "EXTENSION":
|
||||
return <Webhook {...iconProps} />;
|
||||
case "DEEP":
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const documentTypeEnum = z.enum([
|
|||
"LUMA_CONNECTOR",
|
||||
"ELASTICSEARCH_CONNECTOR",
|
||||
"LINEAR_CONNECTOR",
|
||||
"NOTE",
|
||||
]);
|
||||
|
||||
export const document = z.object({
|
||||
|
|
@ -27,7 +28,10 @@ export const document = z.object({
|
|||
document_type: documentTypeEnum,
|
||||
document_metadata: z.record(z.string(), z.any()),
|
||||
content: z.string(),
|
||||
content_hash: z.string(),
|
||||
unique_identifier_hash: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().nullable(),
|
||||
search_space_id: z.number(),
|
||||
});
|
||||
|
||||
|
|
@ -68,6 +72,9 @@ export const getDocumentsRequest = z.object({
|
|||
export const getDocumentsResponse = z.object({
|
||||
items: z.array(document),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
page_size: z.number(),
|
||||
has_more: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -118,6 +125,9 @@ export const searchDocumentsRequest = z.object({
|
|||
export const searchDocumentsResponse = z.object({
|
||||
items: z.array(document),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
page_size: z.number(),
|
||||
has_more: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
111
surfsense_web/contracts/types/invites.types.ts
Normal file
111
surfsense_web/contracts/types/invites.types.ts
Normal 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>;
|
||||
87
surfsense_web/contracts/types/members.types.ts
Normal file
87
surfsense_web/contracts/types/members.types.ts
Normal 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>;
|
||||
17
surfsense_web/contracts/types/permissions.types.ts
Normal file
17
surfsense_web/contracts/types/permissions.types.ts
Normal 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>;
|
||||
88
surfsense_web/contracts/types/roles.types.ts
Normal file
88
surfsense_web/contracts/types/roles.types.ts
Normal 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>;
|
||||
|
|
@ -2,26 +2,26 @@ import { z } from "zod";
|
|||
import { paginationQueryParams } from ".";
|
||||
|
||||
export const searchSpace = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
user_id: z.string(),
|
||||
citations_enabled: z.boolean(),
|
||||
qna_custom_instructions: z.string().nullable(),
|
||||
member_count: z.number(),
|
||||
is_owner: z.boolean(),
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
user_id: z.string(),
|
||||
citations_enabled: z.boolean(),
|
||||
qna_custom_instructions: z.string().nullable(),
|
||||
member_count: z.number(),
|
||||
is_owner: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get search spaces
|
||||
*/
|
||||
export const getSearchSpacesRequest = z.object({
|
||||
queryParams: paginationQueryParams
|
||||
.extend({
|
||||
owned_only: z.boolean().optional(),
|
||||
})
|
||||
.nullish(),
|
||||
queryParams: paginationQueryParams
|
||||
.extend({
|
||||
owned_only: z.boolean().optional(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export const getSearchSpacesResponse = z.array(searchSpace);
|
||||
|
|
@ -29,12 +29,10 @@ export const getSearchSpacesResponse = z.array(searchSpace);
|
|||
/**
|
||||
* Create search space
|
||||
*/
|
||||
export const createSearchSpaceRequest = searchSpace
|
||||
.pick({ name: true, description: true })
|
||||
.extend({
|
||||
citations_enabled: z.boolean().default(true).optional(),
|
||||
qna_custom_instructions: z.string().nullable().optional(),
|
||||
});
|
||||
export const createSearchSpaceRequest = searchSpace.pick({ name: true, description: true }).extend({
|
||||
citations_enabled: z.boolean().default(true).optional(),
|
||||
qna_custom_instructions: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const createSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true });
|
||||
|
||||
|
|
@ -42,13 +40,13 @@ export const createSearchSpaceResponse = searchSpace.omit({ member_count: true,
|
|||
* Get community prompts
|
||||
*/
|
||||
export const getCommunityPromptsResponse = z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
author: z.string(),
|
||||
link: z.string(),
|
||||
category: z.string(),
|
||||
})
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
author: z.string(),
|
||||
link: z.string(),
|
||||
category: z.string(),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -62,10 +60,10 @@ export const getSearchSpaceResponse = searchSpace.omit({ member_count: true, is_
|
|||
* Update search space
|
||||
*/
|
||||
export const updateSearchSpaceRequest = z.object({
|
||||
id: z.number(),
|
||||
data: searchSpace
|
||||
.pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true })
|
||||
.partial(),
|
||||
id: z.number(),
|
||||
data: searchSpace
|
||||
.pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true })
|
||||
.partial(),
|
||||
});
|
||||
|
||||
export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true });
|
||||
|
|
@ -76,7 +74,7 @@ export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true,
|
|||
export const deleteSearchSpaceRequest = searchSpace.pick({ id: true });
|
||||
|
||||
export const deleteSearchSpaceResponse = z.object({
|
||||
message: z.literal("Search space deleted successfully"),
|
||||
message: z.literal("Search space deleted successfully"),
|
||||
});
|
||||
|
||||
// Inferred types
|
||||
|
|
|
|||
19
surfsense_web/contracts/types/user.types.ts
Normal file
19
surfsense_web/contracts/types/user.types.ts
Normal 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>;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export * from "./use-debounced-value";
|
||||
export * from "./use-logs";
|
||||
export * from "./use-rbac";
|
||||
export * from "./use-search-source-connectors";
|
||||
export * from "./use-user";
|
||||
|
|
|
|||
23
surfsense_web/hooks/use-debounced-value.ts
Normal file
23
surfsense_web/hooks/use-debounced-value.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -141,32 +141,43 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
|
|||
);
|
||||
|
||||
// Function to create a new log
|
||||
const createLog = useCallback(async (logData: Omit<Log, "id" | "created_at">) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify(logData),
|
||||
// Use silent: true to suppress toast notifications (for internal/background operations)
|
||||
const createLog = useCallback(
|
||||
async (logData: Omit<Log, "id" | "created_at">, options?: { silent?: boolean }) => {
|
||||
const { silent = false } = options || {};
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify(logData),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to create log");
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to create log");
|
||||
const newLog = await response.json();
|
||||
setLogs((prevLogs) => [newLog, ...prevLogs]);
|
||||
// Only show toast if not silent
|
||||
if (!silent) {
|
||||
toast.success("Log created successfully");
|
||||
}
|
||||
return newLog;
|
||||
} catch (err: any) {
|
||||
// Only show error toast if not silent
|
||||
if (!silent) {
|
||||
toast.error(err.message || "Failed to create log");
|
||||
}
|
||||
console.error("Error creating log:", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const newLog = await response.json();
|
||||
setLogs((prevLogs) => [newLog, ...prevLogs]);
|
||||
toast.success("Log created successfully");
|
||||
return newLog;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to create log");
|
||||
console.error("Error creating log:", err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Function to update a log
|
||||
const updateLog = useCallback(
|
||||
|
|
|
|||
|
|
@ -218,137 +218,6 @@ export function useMembers(searchSpaceId: number) {
|
|||
|
||||
// ============ 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) {
|
||||
const [invites, setInvites] = useState<Invite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -480,63 +349,6 @@ export function useInvites(searchSpaceId: number) {
|
|||
|
||||
// ============ 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) {
|
||||
const [access, setAccess] = useState<UserAccess | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
147
surfsense_web/lib/apis/notes-api.service.ts
Normal file
147
surfsense_web/lib/apis/notes-api.service.ts
Normal 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();
|
||||
10
surfsense_web/lib/apis/permissions-api.service.ts
Normal file
10
surfsense_web/lib/apis/permissions-api.service.ts
Normal 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();
|
||||
109
surfsense_web/lib/apis/roles-api.service.ts
Normal file
109
surfsense_web/lib/apis/roles-api.service.ts
Normal 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();
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
import {
|
||||
type CreateSearchSpaceRequest,
|
||||
type DeleteSearchSpaceRequest,
|
||||
type GetSearchSpaceRequest,
|
||||
type GetSearchSpacesRequest,
|
||||
type UpdateSearchSpaceRequest,
|
||||
createSearchSpaceRequest,
|
||||
createSearchSpaceResponse,
|
||||
type DeleteSearchSpaceRequest,
|
||||
deleteSearchSpaceRequest,
|
||||
deleteSearchSpaceResponse,
|
||||
type GetSearchSpaceRequest,
|
||||
type GetSearchSpacesRequest,
|
||||
getCommunityPromptsResponse,
|
||||
getSearchSpaceRequest,
|
||||
getSearchSpaceResponse,
|
||||
getSearchSpacesRequest,
|
||||
getSearchSpacesResponse,
|
||||
type UpdateSearchSpaceRequest,
|
||||
updateSearchSpaceRequest,
|
||||
updateSearchSpaceResponse,
|
||||
} from "@/contracts/types/search-space.types";
|
||||
|
|
@ -71,7 +71,10 @@ class SearchSpacesApiService {
|
|||
* Get community-curated prompts for search space system instructions
|
||||
*/
|
||||
getCommunityPrompts = async () => {
|
||||
return baseApiService.get(`/api/v1/searchspaces/prompts/community`, getCommunityPromptsResponse);
|
||||
return baseApiService.get(
|
||||
`/api/v1/searchspaces/prompts/community`,
|
||||
getCommunityPromptsResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
13
surfsense_web/lib/apis/user-api.service.ts
Normal file
13
surfsense_web/lib/apis/user-api.service.ts
Normal 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();
|
||||
|
|
@ -2,6 +2,7 @@ import type { GetChatsRequest } from "@/contracts/types/chat.types";
|
|||
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
|
||||
import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types";
|
||||
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
|
||||
import type { GetRolesRequest } from "@/contracts/types/roles.types";
|
||||
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
|
||||
|
||||
export const cacheKeys = {
|
||||
|
|
@ -40,5 +41,15 @@ export const cacheKeys = {
|
|||
["search-spaces", ...(queries ? Object.values(queries) : [])] as const,
|
||||
detail: (searchSpaceId: string) => ["search-spaces", searchSpaceId] as const,
|
||||
communityPrompts: ["search-spaces", "community-prompts"] as const,
|
||||
}
|
||||
},
|
||||
user: {
|
||||
current: () => ["user", "me"] as const,
|
||||
},
|
||||
roles: {
|
||||
all: (searchSpaceId: string) => ["roles", searchSpaceId] as const,
|
||||
byId: (searchSpaceId: string, roleId: string) => ["roles", searchSpaceId, roleId] as const,
|
||||
},
|
||||
permissions: {
|
||||
all: () => ["permissions"] as const,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -642,7 +642,17 @@
|
|||
"no_chats_found": "No chats found",
|
||||
"no_recent_chats": "No recent chats",
|
||||
"view_all_chats": "View All Chats",
|
||||
"search_space": "Search Space"
|
||||
"search_space": "Search Space",
|
||||
"notes": "Notes",
|
||||
"all_notes": "All Notes",
|
||||
"all_notes_description": "Browse and manage all your notes",
|
||||
"search_notes": "Search notes...",
|
||||
"no_results_found": "No notes found",
|
||||
"try_different_search": "Try a different search term",
|
||||
"no_notes": "No notes yet",
|
||||
"create_new_note": "Create a new note",
|
||||
"error_loading_notes": "Error loading notes",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"errors": {
|
||||
"something_went_wrong": "Something went wrong",
|
||||
|
|
|
|||
|
|
@ -642,7 +642,17 @@
|
|||
"no_chats_found": "未找到对话",
|
||||
"no_recent_chats": "暂无最近对话",
|
||||
"view_all_chats": "查看所有对话",
|
||||
"search_space": "搜索空间"
|
||||
"search_space": "搜索空间",
|
||||
"notes": "笔记",
|
||||
"all_notes": "所有笔记",
|
||||
"all_notes_description": "浏览和管理您的所有笔记",
|
||||
"search_notes": "搜索笔记...",
|
||||
"no_results_found": "未找到笔记",
|
||||
"try_different_search": "尝试其他搜索词",
|
||||
"no_notes": "暂无笔记",
|
||||
"create_new_note": "创建新笔记",
|
||||
"error_loading_notes": "加载笔记时出错",
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"errors": {
|
||||
"something_went_wrong": "出错了",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue