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